dsh-remote-local-plugin 0.0.0-stage → 0.8.0

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +85 -2
  3. package/assets/dsh-remote-agent-0.2.1.tgz +0 -0
  4. package/cordis.patch.yml +50 -0
  5. package/lib/client.js +1511 -0
  6. package/lib/client.js.map +7 -0
  7. package/lib/index.js +4565 -0
  8. package/lib/types/config.d.ts +94 -0
  9. package/lib/types/credentials/proxy.d.ts +40 -0
  10. package/lib/types/errors.d.ts +23 -0
  11. package/lib/types/host-api/contract.d.ts +217 -0
  12. package/lib/types/host-api/handler.d.ts +15 -0
  13. package/lib/types/host-api/terminal.d.ts +15 -0
  14. package/lib/types/hosts/id.d.ts +16 -0
  15. package/lib/types/hosts/store.d.ts +34 -0
  16. package/lib/types/hosts/types.d.ts +85 -0
  17. package/lib/types/index.d.ts +19 -0
  18. package/lib/types/mirror/service.d.ts +87 -0
  19. package/lib/types/mirror/test-visibility.d.ts +23 -0
  20. package/lib/types/remote/directory.d.ts +19 -0
  21. package/lib/types/remote/files.d.ts +14 -0
  22. package/lib/types/remote/local-directory.d.ts +11 -0
  23. package/lib/types/remote/provisioner.d.ts +32 -0
  24. package/lib/types/remote/terminal.d.ts +55 -0
  25. package/lib/types/rpc/contract.d.ts +114 -0
  26. package/lib/types/rpc/handler.d.ts +12 -0
  27. package/lib/types/service.d.ts +92 -0
  28. package/lib/types/ssh/probe.d.ts +33 -0
  29. package/lib/types/ssh/target.d.ts +24 -0
  30. package/lib/types/ssh/tunnel.d.ts +50 -0
  31. package/lib/types/tools.d.ts +19 -0
  32. package/node_modules/dsh-remote-terminal-controller/README.md +9 -0
  33. package/node_modules/dsh-remote-terminal-controller/cordis.patch.yml +8 -0
  34. package/node_modules/dsh-remote-terminal-controller/lib/client.js +1367 -0
  35. package/node_modules/dsh-remote-terminal-controller/lib/client.js.map +7 -0
  36. package/node_modules/dsh-remote-terminal-controller/lib/index.js +3 -0
  37. package/node_modules/dsh-remote-terminal-controller/package.json +59 -0
  38. package/package.json +146 -4
package/lib/index.js ADDED
@@ -0,0 +1,4565 @@
1
+ /* dsh-remote-local-plugin@0.8.0 — bundled host entry */
2
+
3
+ // src/index.ts
4
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
5
+
6
+ // src/config.ts
7
+ import { homedir } from "node:os";
8
+ import { isAbsolute, join, resolve } from "node:path";
9
+ import z from "@deepseek-ai/schemastery";
10
+ var Config = z.object({
11
+ stateDir: z.string().default(""),
12
+ sshBinary: z.string().default("ssh"),
13
+ remoteStateFile: z.string().default(""),
14
+ probeTimeoutMs: z.number().min(1e3).max(12e4).default(1e4),
15
+ connectTimeoutMs: z.number().min(1e3).max(12e4).default(15e3),
16
+ healthIntervalMs: z.number().min(2e3).max(6e5).default(15e3),
17
+ reconnect: z.boolean().default(true),
18
+ maxReconnectDelayMs: z.number().min(1e3).max(6e5).default(3e4),
19
+ strictHostKeyChecking: z.string().default("accept-new"),
20
+ autoProvision: z.boolean().default(true),
21
+ remoteDshHome: z.string().default("~/.dsh"),
22
+ remoteInstallDir: z.string().default("~/.local/share/dsh-remote"),
23
+ remoteDshVersion: z.string().default("0.2.1-rc.2"),
24
+ remotePnpmVersion: z.string().default("11.21.0"),
25
+ remoteNodeVersion: z.string().default("22.19.0"),
26
+ autoInstallNode: z.boolean().default(true),
27
+ remoteStart: z.string().default("systemd"),
28
+ provisionTimeoutMs: z.number().min(6e4).max(18e5).default(6e5),
29
+ credentialProxy: z.boolean().default(false),
30
+ copyModelKey: z.boolean().default(true),
31
+ remoteModelBaseUrl: z.string().default("https://api.deepseek.com/anthropic"),
32
+ credentialProxyUpstream: z.string().default("https://api.deepseek.com/anthropic"),
33
+ credentialProxyRemotePort: z.number().min(1).max(65535).default(47821),
34
+ modelCredentialRef: z.string().default("DEEPSEEK_API_KEY"),
35
+ mirrorSessions: z.boolean().default(true),
36
+ mirrorPollMs: z.number().min(500).max(3e4).default(1500),
37
+ mirrorMaxEvents: z.number().min(100).max(1e4).default(2e3)
38
+ });
39
+ function resolveDshHome(env = process.env) {
40
+ const configured = env.DSH_HOME;
41
+ if (configured !== void 0 && configured.trim() !== "") return resolve(configured);
42
+ return join(homedir(), ".dsh");
43
+ }
44
+ function resolveLocalPath(path, home = resolveDshHome()) {
45
+ const trimmed = path.trim();
46
+ if (trimmed === "") return join(home, "remote");
47
+ if (trimmed === "~") return home;
48
+ if (trimmed.startsWith("~/")) return join(home, trimmed.slice(2));
49
+ return isAbsolute(trimmed) ? trimmed : resolve(home, trimmed);
50
+ }
51
+ function joinRemotePath(base, suffix) {
52
+ const trimmed = base.trim();
53
+ if (trimmed === "" || trimmed === "~") return `~/${suffix}`;
54
+ return `${trimmed.replace(/\/+$/u, "")}/${suffix}`;
55
+ }
56
+ function resolveConfig(config = {}, env = process.env) {
57
+ const strict = config.strictHostKeyChecking ?? "accept-new";
58
+ if (strict !== "yes" && strict !== "no" && strict !== "ask" && strict !== "accept-new") {
59
+ throw new Error(`invalid strictHostKeyChecking: ${JSON.stringify(strict)}`);
60
+ }
61
+ const remoteStart = config.remoteStart ?? "systemd";
62
+ if (remoteStart !== "systemd" && remoteStart !== "nohup" && remoteStart !== "none") {
63
+ throw new Error(`invalid remoteStart: ${JSON.stringify(remoteStart)}`);
64
+ }
65
+ const remoteDshHome = config.remoteDshHome ?? "~/.dsh";
66
+ const configuredStateFile = (config.remoteStateFile ?? "").trim();
67
+ return {
68
+ stateDir: resolveLocalPath(config.stateDir ?? "", resolveDshHome(env)),
69
+ sshBinary: (config.sshBinary ?? "ssh").trim() || "ssh",
70
+ remoteStateFile: configuredStateFile === "" ? joinRemotePath(remoteDshHome, "remote-agent/agent.json") : configuredStateFile,
71
+ probeTimeoutMs: config.probeTimeoutMs ?? 1e4,
72
+ connectTimeoutMs: config.connectTimeoutMs ?? 15e3,
73
+ healthIntervalMs: config.healthIntervalMs ?? 15e3,
74
+ reconnect: config.reconnect ?? true,
75
+ maxReconnectDelayMs: config.maxReconnectDelayMs ?? 3e4,
76
+ strictHostKeyChecking: strict,
77
+ autoProvision: config.autoProvision ?? true,
78
+ remoteDshHome,
79
+ remoteInstallDir: config.remoteInstallDir ?? "~/.local/share/dsh-remote",
80
+ remoteDshVersion: config.remoteDshVersion ?? "0.2.1-rc.2",
81
+ remotePnpmVersion: config.remotePnpmVersion ?? "11.21.0",
82
+ remoteNodeVersion: config.remoteNodeVersion ?? "22.19.0",
83
+ autoInstallNode: config.autoInstallNode ?? true,
84
+ remoteStart,
85
+ provisionTimeoutMs: config.provisionTimeoutMs ?? 6e5,
86
+ credentialProxy: config.credentialProxy ?? false,
87
+ copyModelKey: config.copyModelKey ?? true,
88
+ remoteModelBaseUrl: config.remoteModelBaseUrl ?? "https://api.deepseek.com/anthropic",
89
+ credentialProxyUpstream: config.credentialProxyUpstream ?? "https://api.deepseek.com/anthropic",
90
+ credentialProxyRemotePort: config.credentialProxyRemotePort ?? 47821,
91
+ modelCredentialRef: config.modelCredentialRef ?? "DEEPSEEK_API_KEY",
92
+ mirrorSessions: config.mirrorSessions ?? true,
93
+ mirrorPollMs: config.mirrorPollMs ?? 1500,
94
+ mirrorMaxEvents: config.mirrorMaxEvents ?? 2e3
95
+ };
96
+ }
97
+
98
+ // src/errors.ts
99
+ var LocalError = class extends Error {
100
+ code;
101
+ details;
102
+ retryable;
103
+ constructor(code, message, options = {}) {
104
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
105
+ this.name = "LocalError";
106
+ this.code = code;
107
+ this.details = options.details ?? {};
108
+ this.retryable = options.retryable ?? (code === "agent/unavailable" || code === "internal");
109
+ }
110
+ };
111
+ function asLocalError(error, fallback = "internal") {
112
+ if (error instanceof LocalError) return error;
113
+ const message = error instanceof Error && error.message !== "" ? error.message : "remote host operation failed";
114
+ return new LocalError(fallback, message, { cause: error });
115
+ }
116
+
117
+ // src/remote/directory.ts
118
+ import { spawn } from "node:child_process";
119
+ var LISTER = String.raw`
120
+ import json, os, sys
121
+ raw = sys.argv[1] if len(sys.argv) > 1 else '~'
122
+ path = os.path.abspath(os.path.expanduser(raw))
123
+ parent = os.path.dirname(path.rstrip('/')) or '/'
124
+ entries = []
125
+ with os.scandir(path) as it:
126
+ for entry in it:
127
+ try:
128
+ if not entry.is_dir(follow_symlinks=True):
129
+ continue
130
+ entries.append({'name': entry.name, 'path': os.path.join(path, entry.name)})
131
+ except OSError:
132
+ continue
133
+ entries.sort(key=lambda item: item['name'].lower())
134
+ print(json.dumps({
135
+ 'path': path,
136
+ 'parent': parent,
137
+ 'entries': entries[:500],
138
+ 'truncated': len(entries) > 500,
139
+ }))
140
+ `;
141
+ async function listRemoteDirectories(hosts, hostId, path = "~") {
142
+ const ssh = hosts.sshFor(hostId);
143
+ const args = [
144
+ "-o",
145
+ "BatchMode=yes",
146
+ "-o",
147
+ "ConnectTimeout=10000",
148
+ ...ssh.port === null ? [] : ["-p", String(ssh.port)],
149
+ ssh.target,
150
+ "python3",
151
+ "-c",
152
+ `'import base64;exec(base64.b64decode("${Buffer.from(LISTER).toString("base64")}"))'`,
153
+ path
154
+ ];
155
+ const output = await new Promise((resolve4, reject) => {
156
+ const child = spawn(ssh.binary, args, { stdio: ["ignore", "pipe", "pipe"] });
157
+ let stdout = "";
158
+ let stderr = "";
159
+ child.stdout.setEncoding("utf8");
160
+ child.stderr.setEncoding("utf8");
161
+ child.stdout.on("data", (chunk) => {
162
+ stdout += chunk;
163
+ });
164
+ child.stderr.on("data", (chunk) => {
165
+ stderr += chunk;
166
+ });
167
+ child.on("error", reject);
168
+ child.on("close", (code) => {
169
+ if (code === 0) resolve4(stdout);
170
+ else reject(new LocalError("directory/unreadable", stderr.trim() || `remote directory listing exited with ${String(code)}`));
171
+ });
172
+ });
173
+ try {
174
+ const parsed = JSON.parse(output);
175
+ if (typeof parsed.path !== "string" || typeof parsed.parent !== "string" || !Array.isArray(parsed.entries)) {
176
+ throw new Error("invalid directory listing");
177
+ }
178
+ return parsed;
179
+ } catch (error) {
180
+ throw new LocalError("directory/unreadable", `remote directory listing returned invalid JSON: ${String(error)}`);
181
+ }
182
+ }
183
+
184
+ // src/remote/local-directory.ts
185
+ import { readdir, stat } from "node:fs/promises";
186
+ import { homedir as homedir2 } from "node:os";
187
+ import { dirname, resolve as resolve2 } from "node:path";
188
+ function normalize(path) {
189
+ const raw = (path ?? "").trim();
190
+ if (raw === "" || raw === "~") return homedir2();
191
+ if (raw.startsWith("~/")) return resolve2(homedir2(), raw.slice(2));
192
+ return resolve2(raw);
193
+ }
194
+ async function listLocalDirectories(path) {
195
+ const target = normalize(path);
196
+ try {
197
+ const names = await readdir(target);
198
+ const entries = [];
199
+ for (const name2 of names.sort((a, b) => a.localeCompare(b))) {
200
+ try {
201
+ if ((await stat(resolve2(target, name2))).isDirectory()) {
202
+ entries.push({ name: name2, path: resolve2(target, name2) });
203
+ }
204
+ } catch {
205
+ }
206
+ }
207
+ return {
208
+ path: target,
209
+ parent: dirname(target) || target,
210
+ entries: entries.slice(0, 500),
211
+ truncated: entries.length > 500
212
+ };
213
+ } catch (error) {
214
+ throw new LocalError("directory/unreadable", `could not read local directory ${target}: ${String(error)}`);
215
+ }
216
+ }
217
+
218
+ // src/host-api/contract.ts
219
+ var HOST_API_PATH = "/api/dsh-remote";
220
+
221
+ // src/host-api/handler.ts
222
+ function registerHostApi(ctx, service, mirrors) {
223
+ return ctx.connection.fetch.register({
224
+ path: HOST_API_PATH,
225
+ methods: ["POST"],
226
+ requestBody: "buffered",
227
+ fetch: (request) => handleHostApi(ctx, service, mirrors, request)
228
+ });
229
+ }
230
+ async function handleHostApi(ctx, service, mirrors, request) {
231
+ let body;
232
+ try {
233
+ body = await request.json();
234
+ } catch (error) {
235
+ return jsonResult({
236
+ ok: false,
237
+ error: {
238
+ code: "request/invalid",
239
+ message: "request body must be JSON",
240
+ details: {}
241
+ }
242
+ });
243
+ }
244
+ if (typeof body !== "object" || body === null || typeof body.endpoint !== "string") {
245
+ return jsonResult({
246
+ ok: false,
247
+ error: { code: "request/invalid", message: "request.endpoint must be a string", details: {} }
248
+ });
249
+ }
250
+ try {
251
+ const value = await dispatch(ctx, service, mirrors, body.endpoint, body.payload);
252
+ return jsonResult({ ok: true, value });
253
+ } catch (error) {
254
+ const failure = asLocalError(error);
255
+ return jsonResult({
256
+ ok: false,
257
+ error: {
258
+ code: failure.code,
259
+ message: failure.message,
260
+ details: failure.details
261
+ }
262
+ });
263
+ }
264
+ }
265
+ async function dispatch(ctx, service, mirrors, endpoint, payload) {
266
+ switch (endpoint) {
267
+ case "state":
268
+ return service.state();
269
+ case "target/validate":
270
+ return service.validateTarget(requireStringField(payload, "sshTarget"));
271
+ case "host/add": {
272
+ const body = requireRecord(payload);
273
+ return service.add({
274
+ sshTarget: requireStringField(body, "sshTarget"),
275
+ ...optionalStringField(body, "label") === void 0 ? {} : { label: optionalStringField(body, "label") },
276
+ ...optionalStringField(body, "token") === void 0 ? {} : { token: optionalStringField(body, "token") },
277
+ ...optionalNumberField(body, "remoteAgentPort") === void 0 ? {} : { remoteAgentPort: optionalNumberField(body, "remoteAgentPort") },
278
+ ...optionalBooleanField(body, "autoConnect") === void 0 ? {} : { autoConnect: optionalBooleanField(body, "autoConnect") }
279
+ });
280
+ }
281
+ case "host/remove": {
282
+ const body = requireRecord(payload);
283
+ await service.remove(requireStringField(body, "hostId"));
284
+ return { removed: true };
285
+ }
286
+ case "host/connect":
287
+ return service.connect(requireStringField(payload, "hostId"));
288
+ case "host/disconnect":
289
+ return service.disconnect(requireStringField(payload, "hostId"));
290
+ case "host/refresh":
291
+ return service.refresh(requireStringField(payload, "hostId"));
292
+ case "host/provision":
293
+ return service.provision(requireStringField(payload, "hostId"));
294
+ case "host/workspaces": {
295
+ const hostId = requireStringField(payload, "hostId");
296
+ const workspaces = await service.workspaces(hostId);
297
+ return {
298
+ workspaces: workspaces.filter((workspace) => mirrors?.isTestHidden(workspace.workspaceId, workspace.path) !== true).map((workspace) => {
299
+ const localWorkspaceId = mirrors?.localWorkspaceId(hostId, workspace.workspaceId);
300
+ return localWorkspaceId === void 0 ? workspace : { ...workspace, localWorkspaceId };
301
+ })
302
+ };
303
+ }
304
+ case "host/workspace/create": {
305
+ const body = requireRecord(payload);
306
+ return service.createWorkspace(requireStringField(body, "hostId"), {
307
+ path: requireStringField(body, "path"),
308
+ ...optionalStringField(body, "title") === void 0 ? {} : { title: optionalStringField(body, "title") }
309
+ });
310
+ }
311
+ case "host/directory/list": {
312
+ const body = requireRecord(payload);
313
+ return listRemoteDirectories(service, requireStringField(body, "hostId"), optionalStringField(body, "path") ?? "~");
314
+ }
315
+ case "local/directory/list": {
316
+ const body = requireRecord(payload);
317
+ return listLocalDirectories(optionalStringField(body, "path"));
318
+ }
319
+ case "local/workspace/create": {
320
+ const body = requireRecord(payload);
321
+ const registry = ctx.get("workspaceRegistry");
322
+ if (registry === void 0) throw new LocalError("agent/unavailable", "workspace registry is unavailable");
323
+ const workspace = await registry.create(
324
+ requireStringField(body, "path"),
325
+ optionalStringField(body, "title")
326
+ );
327
+ return { workspaceId: String(workspace.id) };
328
+ }
329
+ case "host/sessions": {
330
+ const body = requireRecord(payload);
331
+ return service.sessions(
332
+ requireStringField(body, "hostId"),
333
+ optionalStringField(body, "workspaceId")
334
+ );
335
+ }
336
+ case "host/session": {
337
+ const body = requireRecord(payload);
338
+ return service.snapshot(
339
+ requireStringField(body, "hostId"),
340
+ requireStringField(body, "sessionId"),
341
+ optionalNumberField(body, "limit")
342
+ );
343
+ }
344
+ case "host/session/create": {
345
+ const body = requireRecord(payload);
346
+ const hostId = requireStringField(body, "hostId");
347
+ const created = await service.createSession(hostId, {
348
+ ...optionalStringField(body, "workspaceId") === void 0 ? {} : { workspaceId: optionalStringField(body, "workspaceId") },
349
+ ...optionalStringField(body, "cwd") === void 0 ? {} : { cwd: optionalStringField(body, "cwd") },
350
+ ...optionalStringField(body, "agentPreset") === void 0 ? {} : { agentPreset: optionalStringField(body, "agentPreset") }
351
+ });
352
+ const localSessionId = await mirrors?.ensureRemoteSession(hostId, created.sessionId);
353
+ return { ...created, ...localSessionId === void 0 ? {} : { localSessionId } };
354
+ }
355
+ case "host/session/prompt": {
356
+ const body = requireRecord(payload);
357
+ const mode = optionalStringField(body, "mode");
358
+ if (mode !== void 0 && mode !== "queue" && mode !== "steer") {
359
+ throw new LocalError("request/invalid", "mode must be queue or steer");
360
+ }
361
+ const provider = optionalStringField(body, "provider");
362
+ const model = optionalStringField(body, "model");
363
+ const reasoningEffort = optionalStringField(body, "reasoningEffort");
364
+ return service.promptSession(
365
+ requireStringField(body, "hostId"),
366
+ requireStringField(body, "sessionId"),
367
+ requireStringField(body, "text"),
368
+ mode,
369
+ provider === void 0 || model === void 0 ? void 0 : { provider, model, ...reasoningEffort === void 0 ? {} : { reasoningEffort } }
370
+ );
371
+ }
372
+ case "host/session/cancel": {
373
+ const body = requireRecord(payload);
374
+ return service.cancelSession(
375
+ requireStringField(body, "hostId"),
376
+ requireStringField(body, "sessionId")
377
+ );
378
+ }
379
+ case "host/web-url":
380
+ return service.webUrl(requireStringField(payload, "hostId"));
381
+ case "host/files/list":
382
+ case "host/files/read":
383
+ case "host/files/stat":
384
+ case "host/files/bytes": {
385
+ const body = requireRecord(payload);
386
+ const sessionId = requireStringField(body, "sessionId");
387
+ const path = requireStringField(body, "path");
388
+ const sessions = ctx.get("sessions");
389
+ const session = sessions?.get(sessionId);
390
+ const root = session?.header.cwd;
391
+ if (root === void 0 || mirrors?.remoteFileContext(root) === void 0) return { mirrored: false };
392
+ const files = ctx.get("workspaceFiles");
393
+ if (files === void 0) return { mirrored: false };
394
+ const scope = { sessionId, workspaceRoot: root };
395
+ const signal = new AbortController().signal;
396
+ if (endpoint === "host/files/list") return { mirrored: true, value: await files.list(scope, path, signal) };
397
+ if (endpoint === "host/files/stat") return { mirrored: true, value: await files.stat(scope, path, signal) };
398
+ if (endpoint === "host/files/bytes") return { mirrored: true, value: await files.readBytes(scope, path, {
399
+ range: {
400
+ ...body.offset === void 0 ? {} : { offset: body.offset },
401
+ ...body.length === void 0 ? {} : { length: body.length }
402
+ }
403
+ }, signal) };
404
+ return { mirrored: true, value: await files.read(scope, path, {
405
+ ...body.offset === void 0 ? {} : { offset: body.offset },
406
+ ...body.limit === void 0 ? {} : { limit: body.limit }
407
+ }, signal) };
408
+ }
409
+ default:
410
+ throw new LocalError("request/invalid", `unknown remote-host endpoint: ${JSON.stringify(endpoint)}`);
411
+ }
412
+ }
413
+ function jsonResult(result) {
414
+ return Response.json(result, {
415
+ status: 200,
416
+ headers: { "cache-control": "no-store" }
417
+ });
418
+ }
419
+ function requireRecord(value) {
420
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
421
+ throw new LocalError("request/invalid", "payload must be a JSON object");
422
+ }
423
+ return value;
424
+ }
425
+ function requireStringField(value, field) {
426
+ const record = requireRecord(value);
427
+ const fieldValue = record[field];
428
+ if (typeof fieldValue !== "string" || fieldValue.trim() === "") {
429
+ throw new LocalError("request/invalid", `payload.${field} must be a non-empty string`);
430
+ }
431
+ return fieldValue.trim();
432
+ }
433
+ function optionalStringField(value, field) {
434
+ const record = requireRecord(value);
435
+ const fieldValue = record[field];
436
+ if (fieldValue === void 0 || fieldValue === null) return void 0;
437
+ if (typeof fieldValue !== "string") {
438
+ throw new LocalError("request/invalid", `payload.${field} must be a string`);
439
+ }
440
+ return fieldValue;
441
+ }
442
+ function optionalNumberField(value, field) {
443
+ const record = requireRecord(value);
444
+ const fieldValue = record[field];
445
+ if (fieldValue === void 0 || fieldValue === null) return void 0;
446
+ if (typeof fieldValue !== "number" || !Number.isFinite(fieldValue)) {
447
+ throw new LocalError("request/invalid", `payload.${field} must be a number`);
448
+ }
449
+ return fieldValue;
450
+ }
451
+ function optionalBooleanField(value, field) {
452
+ const record = requireRecord(value);
453
+ const fieldValue = record[field];
454
+ if (fieldValue === void 0 || fieldValue === null) return void 0;
455
+ if (typeof fieldValue !== "boolean") {
456
+ throw new LocalError("request/invalid", `payload.${field} must be a boolean`);
457
+ }
458
+ return fieldValue;
459
+ }
460
+
461
+ // src/mirror/service.ts
462
+ import { createHash, randomUUID } from "node:crypto";
463
+ import { realpathSync } from "node:fs";
464
+ import { mkdir, readFile as readFile2, rename, writeFile } from "node:fs/promises";
465
+ import { join as join3 } from "node:path";
466
+ import { scopeTarget } from "@deepseek-ai/dsh-scope";
467
+
468
+ // src/mirror/test-visibility.ts
469
+ import { readFile } from "node:fs/promises";
470
+ import { join as join2 } from "node:path";
471
+ var EMPTY = { hiddenPaths: [], hiddenWorkspaceIds: [] };
472
+ async function loadTestVisibility(stateDir) {
473
+ try {
474
+ const raw = await readFile(join2(stateDir, "test-visibility.json"), "utf8");
475
+ const parsed = JSON.parse(raw);
476
+ return {
477
+ hiddenPaths: Array.isArray(parsed.hiddenPaths) ? parsed.hiddenPaths.filter((item) => typeof item === "string") : [],
478
+ hiddenWorkspaceIds: Array.isArray(parsed.hiddenWorkspaceIds) ? parsed.hiddenWorkspaceIds.filter((item) => typeof item === "string") : []
479
+ };
480
+ } catch {
481
+ return EMPTY;
482
+ }
483
+ }
484
+ function isTestHidden(remotePath, remoteWorkspaceId, visibility) {
485
+ if (visibility.hiddenWorkspaceIds.includes(remoteWorkspaceId)) return true;
486
+ const path = remotePath.trim().replace(/\/+$/u, "") || "/";
487
+ return visibility.hiddenPaths.some((prefix) => {
488
+ const root = prefix.trim().replace(/\/+$/u, "") || "/";
489
+ return path === root || path.startsWith(`${root}/`);
490
+ });
491
+ }
492
+
493
+ // src/mirror/service.ts
494
+ var MIRROR_VERSION = 1;
495
+ var SURFACE_TYPES = /* @__PURE__ */ new Set([
496
+ "system/message",
497
+ "developer/message",
498
+ "user/message",
499
+ "assistant/message",
500
+ "tool/result"
501
+ ]);
502
+ var RemoteMirrorAgent = class {
503
+ id;
504
+ session;
505
+ options;
506
+ ctx;
507
+ inbox = { "next-turn": [], "next-step": [], nextTurn: [], nextStep: [] };
508
+ #runtime;
509
+ #mirror;
510
+ constructor(mirror, runtime, ctx, options) {
511
+ this.#mirror = mirror;
512
+ this.#runtime = runtime;
513
+ this.id = runtime.session.id;
514
+ this.session = runtime.session;
515
+ this.ctx = ctx;
516
+ this.options = options;
517
+ }
518
+ get status() {
519
+ return this.#runtime.running ? "running" : "idle";
520
+ }
521
+ cancel() {
522
+ this.#mirror.cancel(this.#runtime);
523
+ }
524
+ whenIdle() {
525
+ if (!this.#runtime.running) return Promise.resolve();
526
+ return new Promise((resolve4) => {
527
+ this.#runtime.waiters.push(resolve4);
528
+ });
529
+ }
530
+ runMaintenance(task) {
531
+ return task(new AbortController().signal);
532
+ }
533
+ send(message, _target, wakeup = false) {
534
+ if (wakeup) this.followup(message);
535
+ else this.#mirror.prompt(this.#runtime, message, "queue");
536
+ }
537
+ followup(message) {
538
+ this.#mirror.prompt(this.#runtime, message, "queue");
539
+ }
540
+ steer(message) {
541
+ this.#mirror.prompt(this.#runtime, message, "steer");
542
+ }
543
+ inject() {
544
+ }
545
+ };
546
+ var RemoteMirrorService = class {
547
+ get #sessions() {
548
+ const value = this.#ctx.get("sessions");
549
+ if (value === void 0) throw new Error("dsh-remote-local: sessions service is unavailable");
550
+ return value;
551
+ }
552
+ get #workspaceRegistry() {
553
+ const value = this.#ctx.get("workspaceRegistry");
554
+ if (value === void 0) throw new Error("dsh-remote-local: workspaceRegistry service is unavailable");
555
+ return value;
556
+ }
557
+ get #agents() {
558
+ const value = this.#ctx.get("agents");
559
+ if (value === void 0) throw new Error("dsh-remote-local: agents service is unavailable");
560
+ return value;
561
+ }
562
+ #ctx;
563
+ #hosts;
564
+ #config;
565
+ #state = { version: MIRROR_VERSION, sessions: {}, workspaces: {}, testHidden: [] };
566
+ #testVisibility = { hiddenPaths: [], hiddenWorkspaceIds: [] };
567
+ #runtimes = /* @__PURE__ */ new Map();
568
+ #registrations = /* @__PURE__ */ new Map();
569
+ #approvalStreams = /* @__PURE__ */ new Map();
570
+ #syncing = false;
571
+ #saveQueue = Promise.resolve();
572
+ #timer;
573
+ #disposed = false;
574
+ constructor(ctx, hosts, config) {
575
+ this.#ctx = ctx;
576
+ this.#hosts = hosts;
577
+ this.#config = config;
578
+ }
579
+ /** Load mappings and begin periodic discovery. */
580
+ async start() {
581
+ await this.#load();
582
+ if (!this.#config.mirrorSessions) return;
583
+ this.#timer = setInterval(() => {
584
+ void this.sync();
585
+ }, Math.max(5e3, this.#config.mirrorPollMs * 4));
586
+ await this.sync();
587
+ }
588
+ /** Stop mirrors and timers. */
589
+ async dispose() {
590
+ this.#disposed = true;
591
+ if (this.#timer !== void 0) clearInterval(this.#timer);
592
+ for (const runtime of this.#runtimes.values()) {
593
+ runtime.stopped = true;
594
+ if (runtime.timer !== void 0) clearTimeout(runtime.timer);
595
+ }
596
+ for (const dispose of this.#registrations.values()) dispose();
597
+ this.#registrations.clear();
598
+ for (const controller of this.#approvalStreams.values()) controller.abort();
599
+ this.#approvalStreams.clear();
600
+ this.#runtimes.clear();
601
+ await this.#save();
602
+ }
603
+ /** Reconcile connected hosts with the local workspace/session catalog. */
604
+ async sync() {
605
+ if (this.#disposed || this.#syncing) return;
606
+ this.#syncing = true;
607
+ try {
608
+ this.#testVisibility = await loadTestVisibility(this.#config.stateDir);
609
+ for (const host of this.#hosts.list()) {
610
+ if (host.phase !== "connected") continue;
611
+ try {
612
+ await this.#syncHost(host);
613
+ } catch (error) {
614
+ this.#ctx.logger.warn("dsh-remote-local: mirror sync for %s failed: %s", host.id, String(error));
615
+ }
616
+ }
617
+ } finally {
618
+ this.#syncing = false;
619
+ }
620
+ }
621
+ /** Resolve a local workspace anchor to its remote filesystem context. */
622
+ remoteFileContext(workspaceRoot) {
623
+ const root = canonicalPath(workspaceRoot);
624
+ for (const record of Object.values(this.#state.workspaces)) {
625
+ if (record.remoteRoot === void 0) continue;
626
+ const anchor = canonicalPath(record.anchor);
627
+ if (root === anchor || root.startsWith(`${anchor}/`)) {
628
+ return { hostId: record.hostId, remoteRoot: record.remoteRoot, anchor };
629
+ }
630
+ }
631
+ return void 0;
632
+ }
633
+ /** Whether the test-only manifest excludes this remote workspace from UI listings. */
634
+ isTestHidden(remoteWorkspaceId, remotePath) {
635
+ return isTestHidden(remotePath, remoteWorkspaceId, this.#testVisibility);
636
+ }
637
+ /** Find the local mirror workspace for one remote workspace. */
638
+ localWorkspaceId(hostId, remoteWorkspaceId) {
639
+ return this.#state.workspaces[key(hostId, remoteWorkspaceId)]?.localWorkspaceId;
640
+ }
641
+ /** Find the local mirror for one remote session. */
642
+ localSessionId(hostId, remoteSessionId) {
643
+ return this.#state.sessions[key(hostId, remoteSessionId)]?.localSessionId;
644
+ }
645
+ /** Create a mirror on demand and return its local session id. */
646
+ async ensureRemoteSession(hostId, remoteSessionId) {
647
+ const host = this.#hosts.list().find((item) => item.id === hostId);
648
+ if (host === void 0 || host.phase !== "connected") return void 0;
649
+ const existing = this.localSessionId(hostId, remoteSessionId);
650
+ if (existing !== void 0 && this.#sessions.get(existing) !== void 0) return existing;
651
+ const workspaces = await this.#hosts.workspaces(hostId);
652
+ for (const workspace of workspaces) {
653
+ if (isTestHidden(workspace.path, workspace.workspaceId, this.#testVisibility)) continue;
654
+ const sessions = await this.#hosts.sessions(hostId, workspace.workspaceId);
655
+ const summary2 = sessions.sessions.find((item) => item.sessionId === remoteSessionId);
656
+ if (summary2 === void 0) continue;
657
+ const localWorkspace2 = await this.#ensureWorkspace(host, workspace);
658
+ const record = await this.#ensureMirror(host, summary2, localWorkspace2, workspace.path);
659
+ return record?.localSessionId;
660
+ }
661
+ const all = await this.#hosts.sessions(hostId);
662
+ const summary = all.sessions.find((item) => item.sessionId === remoteSessionId);
663
+ if (summary === void 0) return void 0;
664
+ if (summary.cwd !== null && isTestHidden(summary.cwd, "", this.#testVisibility)) return void 0;
665
+ const localWorkspace = await this.#ensureUngroupedWorkspace(host, summary.cwd ?? "ungrouped");
666
+ return (await this.#ensureMirror(host, summary, localWorkspace.workspace, localWorkspace.anchor))?.localSessionId;
667
+ }
668
+ /** Route one prompt from the local Agent façade to the remote session. */
669
+ prompt(runtime, message, mode) {
670
+ const text = contentText(message);
671
+ if (text === "") return;
672
+ runtime.pendingUsers.push(message);
673
+ this.#setRunning(runtime, true);
674
+ if (runtime.stopped || runtime.timer === void 0) {
675
+ runtime.stopped = false;
676
+ runtime.lastError = null;
677
+ this.#schedule(runtime);
678
+ }
679
+ void this.#hosts.promptSession(
680
+ runtime.record.hostId,
681
+ runtime.record.remoteSessionId,
682
+ text,
683
+ mode,
684
+ latestSelection(runtime.session)
685
+ ).catch((error) => {
686
+ runtime.lastError = error instanceof Error ? error.message : String(error);
687
+ runtime.pendingUsers.shift();
688
+ this.#ctx.logger.warn("dsh-remote-local: remote prompt failed: %s", runtime.lastError);
689
+ this.#setRunning(runtime, false);
690
+ });
691
+ }
692
+ /** Route cancellation to the remote session. */
693
+ cancel(runtime) {
694
+ void this.#hosts.cancelSession(runtime.record.hostId, runtime.record.remoteSessionId).catch((error) => {
695
+ this.#ctx.logger.warn("dsh-remote-local: remote cancel failed: %s", String(error));
696
+ });
697
+ this.#setRunning(runtime, false);
698
+ }
699
+ async #syncHost(host) {
700
+ this.#ensureApprovalStream(host);
701
+ await this.#reconcileTestHidden(host);
702
+ const workspaces = await this.#hosts.workspaces(host.id);
703
+ const covered = /* @__PURE__ */ new Set();
704
+ const byPath = /* @__PURE__ */ new Map();
705
+ for (const workspace of workspaces) {
706
+ if (isTestHidden(workspace.path, workspace.workspaceId, this.#testVisibility)) {
707
+ const sessions2 = await this.#hosts.sessions(host.id, workspace.workspaceId);
708
+ for (const summary of sessions2.sessions) covered.add(summary.sessionId);
709
+ continue;
710
+ }
711
+ const local = await this.#ensureWorkspace(host, workspace);
712
+ byPath.set(workspace.path, { workspace: local, anchor: this.#anchorFor(local) });
713
+ const sessions = await this.#hosts.sessions(host.id, workspace.workspaceId);
714
+ for (const summary of sessions.sessions) {
715
+ covered.add(summary.sessionId);
716
+ await this.#ensureMirror(host, summary, local, workspace.path);
717
+ }
718
+ }
719
+ const all = await this.#hosts.sessions(host.id);
720
+ for (const summary of all.sessions) {
721
+ if (covered.has(summary.sessionId)) continue;
722
+ if (summary.cwd !== null && isTestHidden(summary.cwd, "", this.#testVisibility)) continue;
723
+ const workspace = summary.cwd === null ? void 0 : byPath.get(summary.cwd);
724
+ if (workspace !== void 0) {
725
+ await this.#ensureMirror(host, summary, workspace.workspace, workspace.anchor);
726
+ continue;
727
+ }
728
+ const ungrouped = await this.#ensureUngroupedWorkspace(host, summary.cwd ?? "ungrouped");
729
+ await this.#ensureMirror(host, summary, ungrouped.workspace, ungrouped.anchor);
730
+ }
731
+ }
732
+ /**
733
+ * Apply the test-only manifest to already-mirrored workspaces. Hidden entries
734
+ * archive local Sessions and remove the local workspace row; removing an
735
+ * entry unarchives the Sessions and lets the normal sync rebuild the row.
736
+ */
737
+ async #reconcileTestHidden(host) {
738
+ let changed = false;
739
+ const entries = Object.entries(this.#state.workspaces).filter(([, record]) => record.hostId === host.id);
740
+ for (const [ref, record] of entries) {
741
+ const hidden = record.remoteRoot !== void 0 && isTestHidden(record.remoteRoot, record.remoteWorkspaceId ?? "", this.#testVisibility);
742
+ const tracked = this.#state.testHidden.includes(ref);
743
+ if (hidden) {
744
+ if (record.remoteRoot === void 0 || tracked) continue;
745
+ for (const session of Object.values(this.#state.sessions)) {
746
+ if (session.hostId !== host.id) continue;
747
+ if (session.remoteWorkspaceId !== record.remoteWorkspaceId) continue;
748
+ await this.#archive(session.localSessionId);
749
+ }
750
+ try {
751
+ await this.#workspaceRegistry.delete(record.localWorkspaceId);
752
+ } catch (error) {
753
+ this.#ctx.logger.warn("dsh-remote-local: could not hide test workspace %s: %s", ref, String(error));
754
+ }
755
+ this.#state.testHidden.push(ref);
756
+ changed = true;
757
+ } else if (tracked) {
758
+ for (const session of Object.values(this.#state.sessions)) {
759
+ if (session.hostId !== host.id) continue;
760
+ if (session.remoteWorkspaceId !== record.remoteWorkspaceId) continue;
761
+ try {
762
+ await this.#workspaceRegistry.unarchiveSession(session.localSessionId);
763
+ } catch {
764
+ }
765
+ }
766
+ this.#state.testHidden = this.#state.testHidden.filter((item) => item !== ref);
767
+ changed = true;
768
+ }
769
+ }
770
+ if (changed) await this.#save();
771
+ }
772
+ async #ensureWorkspace(host, remote) {
773
+ const ref = key(host.id, remote.workspaceId);
774
+ const existing = this.#state.workspaces[ref];
775
+ if (existing !== void 0) {
776
+ try {
777
+ await mkdir(existing.anchor, { recursive: true });
778
+ const workspace2 = await this.#workspaceRegistry.create(existing.anchor, existing.title);
779
+ existing.localWorkspaceId = String(workspace2.id);
780
+ if (existing.remoteRoot === void 0) {
781
+ this.#state.workspaces[ref] = { ...existing, remoteRoot: remote.path };
782
+ await this.#save();
783
+ }
784
+ return workspace2;
785
+ } catch (error) {
786
+ this.#ctx.logger.warn("dsh-remote-local: mapped workspace %s unavailable: %s", ref, String(error));
787
+ }
788
+ }
789
+ const anchor = join3(this.#config.stateDir, "anchors", safe(host.id), safe(remote.workspaceId));
790
+ await mkdir(anchor, { recursive: true });
791
+ const title = `${host.label} \xB7 ${remote.title}`;
792
+ const workspace = await this.#workspaceRegistry.create(anchor, title);
793
+ this.#state.workspaces[ref] = {
794
+ hostId: host.id,
795
+ remoteWorkspaceId: remote.workspaceId,
796
+ localWorkspaceId: String(workspace.id),
797
+ anchor,
798
+ remoteRoot: remote.path,
799
+ title,
800
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
801
+ };
802
+ await this.#save();
803
+ return workspace;
804
+ }
805
+ async #ensureUngroupedWorkspace(host, cwd) {
806
+ const remoteId = `ungrouped:${createHash("sha1").update(cwd).digest("hex").slice(0, 12)}`;
807
+ const ref = key(host.id, remoteId);
808
+ const existing = this.#state.workspaces[ref];
809
+ if (existing !== void 0) {
810
+ await mkdir(existing.anchor, { recursive: true });
811
+ const workspace2 = await this.#workspaceRegistry.create(existing.anchor, existing.title);
812
+ existing.localWorkspaceId = String(workspace2.id);
813
+ if (existing.remoteRoot === void 0) {
814
+ this.#state.workspaces[ref] = { ...existing, remoteRoot: cwd };
815
+ await this.#save();
816
+ }
817
+ return { workspace: workspace2, anchor: existing.anchor };
818
+ }
819
+ const anchor = join3(this.#config.stateDir, "anchors", safe(host.id), "ungrouped", remoteId.split(":")[1] ?? "root");
820
+ await mkdir(anchor, { recursive: true });
821
+ const title = `${host.label} \xB7 ${cwd}`;
822
+ const workspace = await this.#workspaceRegistry.create(anchor, title);
823
+ this.#state.workspaces[ref] = {
824
+ hostId: host.id,
825
+ remoteWorkspaceId: null,
826
+ localWorkspaceId: String(workspace.id),
827
+ anchor,
828
+ remoteRoot: cwd,
829
+ title,
830
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
831
+ };
832
+ await this.#save();
833
+ return { workspace, anchor };
834
+ }
835
+ #ensureApprovalStream(host) {
836
+ if (this.#approvalStreams.has(host.id)) return;
837
+ const controller = new AbortController();
838
+ this.#approvalStreams.set(host.id, controller);
839
+ void (async () => {
840
+ try {
841
+ for await (const frame of this.#hosts.followApprovals(host.id, controller.signal)) {
842
+ if (frame.type === "request") void this.#handleApproval(host.id, frame.request);
843
+ }
844
+ } catch (error) {
845
+ if (!controller.signal.aborted) {
846
+ this.#ctx.logger.warn("dsh-remote-local: approval stream %s failed: %s", host.id, String(error));
847
+ }
848
+ } finally {
849
+ this.#approvalStreams.delete(host.id);
850
+ }
851
+ })();
852
+ }
853
+ async #handleApproval(hostId, request) {
854
+ const runtime = [...this.#runtimes.values()].find(
855
+ (item) => item.record.hostId === hostId && item.record.remoteSessionId === request.sessionId
856
+ );
857
+ if (runtime === void 0) {
858
+ await this.#hosts.answerApproval(hostId, request.id, "unavailable").catch(() => void 0);
859
+ return;
860
+ }
861
+ const local = new AbortController();
862
+ let outcome = "unavailable";
863
+ try {
864
+ const waterfall = this.#ctx.waterfall;
865
+ outcome = await waterfall(
866
+ scopeTarget(runtime.agent, runtime.agent),
867
+ "approval/request",
868
+ {
869
+ agent: runtime.agent,
870
+ toolName: request.toolName,
871
+ ...request.callId === void 0 ? {} : { callId: request.callId },
872
+ ...request.reason === void 0 ? {} : { reason: request.reason },
873
+ ...request.displayReason === void 0 ? {} : { displayReason: request.displayReason },
874
+ signal: local.signal
875
+ },
876
+ async () => "unavailable"
877
+ );
878
+ } catch (error) {
879
+ this.#ctx.logger.warn("dsh-remote-local: local approval bridge failed: %s", String(error));
880
+ outcome = "unavailable";
881
+ }
882
+ await this.#hosts.answerApproval(hostId, request.id, outcome).catch((error) => {
883
+ this.#ctx.logger.warn("dsh-remote-local: approval answer failed: %s", String(error));
884
+ });
885
+ }
886
+ async #ensureMirror(host, remote, workspace, _remotePath) {
887
+ const ref = key(host.id, remote.sessionId);
888
+ const existing = this.#state.sessions[ref];
889
+ if (existing !== void 0) {
890
+ const live = this.#sessions.get(existing.localSessionId);
891
+ const runtime2 = this.#runtimes.get(existing.localSessionId);
892
+ if (live !== void 0 && runtime2 !== void 0) {
893
+ await this.#attach(workspace, live.id);
894
+ return existing;
895
+ }
896
+ if (live !== void 0) {
897
+ await this.#archive(existing.localSessionId);
898
+ } else if (existing.localSessionId !== "") {
899
+ await this.#archive(existing.localSessionId);
900
+ }
901
+ }
902
+ const snapshot = await this.#hosts.snapshot(host.id, remote.sessionId, this.#config.mirrorMaxEvents);
903
+ if (snapshot.truncated && (snapshot.events[0]?.seq ?? 0) > 0) {
904
+ this.#ctx.logger.warn(
905
+ "dsh-remote-local: remote session %s is too long to mirror (%d events); open it in the remote UI",
906
+ remote.sessionId,
907
+ snapshot.cursor
908
+ );
909
+ return void 0;
910
+ }
911
+ const anchor = this.#anchorFor(workspace);
912
+ await mkdir(anchor, { recursive: true });
913
+ const localId = `session-${randomUUID()}`;
914
+ let session;
915
+ try {
916
+ const filtered = snapshot.events.filter((event) => !isMirrorNoise(event.type));
917
+ const seqMap = new Map(filtered.map((event, index) => [event.seq, index]));
918
+ const seed = filtered.map((event, index) => toSeedEvent(event, index, seqMap));
919
+ session = this.#sessions.create(localId, {
920
+ ...seed.length === 0 ? {} : { seed },
921
+ meta: { cwd: anchor }
922
+ });
923
+ } catch (error) {
924
+ this.#ctx.logger.warn("dsh-remote-local: could not seed mirror for %s: %s", remote.sessionId, String(error));
925
+ return void 0;
926
+ }
927
+ await this.#attach(workspace, session.id);
928
+ try {
929
+ await this.#sessions.flush(session);
930
+ } catch (error) {
931
+ this.#ctx.logger.debug("dsh-remote-local: mirror flush failed: %s", String(error));
932
+ }
933
+ const record = {
934
+ hostId: host.id,
935
+ remoteSessionId: remote.sessionId,
936
+ localSessionId: localId,
937
+ remoteWorkspaceId: remote.workspaceId,
938
+ anchor,
939
+ lastSeq: snapshot.cursor,
940
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
941
+ };
942
+ this.#state.sessions[ref] = record;
943
+ const runtime = {
944
+ record,
945
+ agent: null,
946
+ session,
947
+ timer: void 0,
948
+ running: false,
949
+ stopped: false,
950
+ lastError: null,
951
+ pendingUsers: [],
952
+ waiters: []
953
+ };
954
+ const options = modelOptions(snapshot.events);
955
+ const agent = new RemoteMirrorAgent(this, runtime, this.#ctx, options);
956
+ runtime.agent = agent;
957
+ this.#runtimes.set(localId, runtime);
958
+ this.#registrations.set(localId, this.#agents.enter(agent, void 0));
959
+ await this.#save();
960
+ this.#schedule(runtime);
961
+ this.#ctx.logger.info(
962
+ "dsh-remote-local: mirrored remote session %s as local %s",
963
+ remote.sessionId,
964
+ localId
965
+ );
966
+ return record;
967
+ }
968
+ #schedule(runtime) {
969
+ if (runtime.stopped || this.#disposed) return;
970
+ runtime.timer = setTimeout(() => {
971
+ void this.#poll(runtime);
972
+ }, Math.max(500, this.#config.mirrorPollMs));
973
+ }
974
+ async #poll(runtime) {
975
+ if (runtime.stopped || this.#disposed) return;
976
+ try {
977
+ const local = this.#sessions.get(runtime.session.id);
978
+ if (local === void 0) {
979
+ runtime.stopped = true;
980
+ return;
981
+ }
982
+ const snapshot = await this.#hosts.snapshot(
983
+ runtime.record.hostId,
984
+ runtime.record.remoteSessionId,
985
+ this.#config.mirrorMaxEvents
986
+ );
987
+ if (snapshot.truncated && (snapshot.events[0]?.seq ?? 0) > runtime.record.lastSeq) {
988
+ runtime.lastError = "remote log grew past the mirror window";
989
+ runtime.stopped = true;
990
+ return;
991
+ }
992
+ let sawEnd = false;
993
+ for (const event of snapshot.events) {
994
+ if (event.seq < runtime.record.lastSeq) continue;
995
+ if (event.seq !== runtime.record.lastSeq) {
996
+ throw new Error(`remote event gap at ${event.seq}, mirror ${runtime.record.lastSeq}`);
997
+ }
998
+ if (!isMirrorNoise(event.type)) appendRemoteEvent(local, event, runtime);
999
+ runtime.record.lastSeq = event.seq + 1;
1000
+ if (event.type === "turn/end") sawEnd = true;
1001
+ }
1002
+ runtime.record.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1003
+ if (sawEnd) this.#setRunning(runtime, false);
1004
+ if (!snapshot.live && runtime.record.lastSeq >= snapshot.cursor) {
1005
+ runtime.stopped = true;
1006
+ }
1007
+ await this.#save();
1008
+ } catch (error) {
1009
+ runtime.lastError = error instanceof Error ? error.message : String(error);
1010
+ this.#ctx.logger.warn(
1011
+ "dsh-remote-local: mirror poll %s failed: %s",
1012
+ runtime.record.remoteSessionId,
1013
+ runtime.lastError
1014
+ );
1015
+ }
1016
+ this.#schedule(runtime);
1017
+ }
1018
+ #setRunning(runtime, running) {
1019
+ if (runtime.running === running) return;
1020
+ runtime.running = running;
1021
+ if (!running) {
1022
+ for (const waiter of runtime.waiters.splice(0)) waiter();
1023
+ }
1024
+ }
1025
+ async #attach(workspace, sessionId) {
1026
+ try {
1027
+ await workspace.attachSession(sessionId);
1028
+ } catch (error) {
1029
+ this.#ctx.logger.debug("dsh-remote-local: attach mirror %s failed: %s", sessionId, String(error));
1030
+ }
1031
+ }
1032
+ async #archive(sessionId) {
1033
+ try {
1034
+ await this.#workspaceRegistry.archiveSession(sessionId, { stopActivity: true });
1035
+ } catch {
1036
+ }
1037
+ }
1038
+ #anchorFor(workspace) {
1039
+ for (const record of Object.values(this.#state.workspaces)) {
1040
+ if (record.localWorkspaceId === String(workspace.id)) return record.anchor;
1041
+ }
1042
+ return join3(this.#config.stateDir, "anchors", safe(String(workspace.id)));
1043
+ }
1044
+ async #load() {
1045
+ try {
1046
+ const raw = await readFile2(join3(this.#config.stateDir, "mirrors.json"), "utf8");
1047
+ const parsed = JSON.parse(raw);
1048
+ if (parsed.version === MIRROR_VERSION && parsed.sessions !== void 0 && parsed.workspaces !== void 0) {
1049
+ this.#state = {
1050
+ version: MIRROR_VERSION,
1051
+ sessions: parsed.sessions,
1052
+ workspaces: parsed.workspaces,
1053
+ testHidden: Array.isArray(parsed.testHidden) ? parsed.testHidden : []
1054
+ };
1055
+ }
1056
+ } catch {
1057
+ }
1058
+ }
1059
+ async #save() {
1060
+ const snapshot = `${JSON.stringify(this.#state, null, 2)}
1061
+ `;
1062
+ const task = this.#saveQueue.then(async () => {
1063
+ await mkdir(this.#config.stateDir, { recursive: true });
1064
+ const path = join3(this.#config.stateDir, "mirrors.json");
1065
+ const temp = `${path}.${randomUUID()}.tmp`;
1066
+ await writeFile(temp, snapshot, { mode: 384 });
1067
+ await rename(temp, path);
1068
+ });
1069
+ this.#saveQueue = task.then(() => void 0, () => void 0);
1070
+ return task;
1071
+ }
1072
+ };
1073
+ function isMirrorNoise(type) {
1074
+ return type === "agent/inbox/spliced";
1075
+ }
1076
+ function appendRemoteEvent(session, event, runtime) {
1077
+ if (event.type === "user/message" && runtime.pendingUsers.length > 0) {
1078
+ const source = event.data.source;
1079
+ if (source?.kind === "user") {
1080
+ const message = runtime.pendingUsers.shift();
1081
+ if (message !== void 0) {
1082
+ session.append("user/message", messageData(message), { surfaceOp: "append" });
1083
+ return;
1084
+ }
1085
+ }
1086
+ }
1087
+ const append = session.append.bind(session);
1088
+ if (SURFACE_TYPES.has(event.type)) {
1089
+ const opts = {
1090
+ surfaceOp: event.surfaceOp ?? "append",
1091
+ ...event.sourceEventSeqs === void 0 ? {} : { sourceEventSeqs: event.sourceEventSeqs }
1092
+ };
1093
+ append(event.type, event.data, opts);
1094
+ } else {
1095
+ append(event.type, event.data);
1096
+ }
1097
+ }
1098
+ function messageData(message) {
1099
+ if (typeof message !== "object" || message === null) return message;
1100
+ const record = message;
1101
+ return {
1102
+ ...typeof record.id === "string" ? { id: record.id } : { id: `msg-${randomUUID()}` },
1103
+ role: "user",
1104
+ content: Array.isArray(record.content) ? record.content : [],
1105
+ source: record.source ?? { kind: "user" }
1106
+ };
1107
+ }
1108
+ function toSeedEvent(event, seq, map) {
1109
+ const surfaceOp = typeof event.surfaceOp === "object" ? {
1110
+ op: "replace",
1111
+ startSeq: map.get(event.surfaceOp.startSeq) ?? 0,
1112
+ endSeq: map.get(event.surfaceOp.endSeq) ?? 0
1113
+ } : event.surfaceOp;
1114
+ const sourceEventSeqs = event.sourceEventSeqs?.map((source) => map.get(source)).filter((source) => source !== void 0);
1115
+ return {
1116
+ seq,
1117
+ type: event.type,
1118
+ time: event.time,
1119
+ data: event.data,
1120
+ ...surfaceOp === void 0 ? {} : { surfaceOp },
1121
+ ...sourceEventSeqs === void 0 || sourceEventSeqs.length === 0 ? {} : { sourceEventSeqs }
1122
+ };
1123
+ }
1124
+ function modelOptions(events) {
1125
+ for (let index = events.length - 1; index >= 0; index -= 1) {
1126
+ const event = events[index];
1127
+ if (event?.type !== "request/header") continue;
1128
+ const data = event.data;
1129
+ if (typeof data.config?.provider === "string" && typeof data.config.model === "string") {
1130
+ return {
1131
+ provider: data.config.provider,
1132
+ model: data.config.model,
1133
+ ...typeof data.config.reasoningEffort === "string" ? { reasoningEffort: data.config.reasoningEffort } : {}
1134
+ };
1135
+ }
1136
+ }
1137
+ return { provider: "deepseek-official", model: "deepseek-flash" };
1138
+ }
1139
+ function latestSelection(session) {
1140
+ const events = session.snapshotEvents();
1141
+ for (let index = events.length - 1; index >= 0; index -= 1) {
1142
+ const event = events[index];
1143
+ const record = event;
1144
+ if (record.type !== "model/selection") continue;
1145
+ const data = record.data ?? {};
1146
+ if (typeof data.provider === "string" && typeof data.model === "string") {
1147
+ return {
1148
+ provider: data.provider,
1149
+ model: data.model,
1150
+ ...typeof data.reasoningEffort === "string" ? { reasoningEffort: data.reasoningEffort } : {}
1151
+ };
1152
+ }
1153
+ }
1154
+ return void 0;
1155
+ }
1156
+ function contentText(message) {
1157
+ if (typeof message === "string") return message;
1158
+ if (typeof message !== "object" || message === null) return "";
1159
+ const content = message.content;
1160
+ if (!Array.isArray(content)) return "";
1161
+ const texts = [];
1162
+ for (const part of content) {
1163
+ if (typeof part === "string") texts.push(part);
1164
+ else if (typeof part === "object" && part !== null && part.type === "text") {
1165
+ const text = part.text;
1166
+ if (typeof text === "string") texts.push(text);
1167
+ }
1168
+ }
1169
+ return texts.join("\n").trim();
1170
+ }
1171
+ function canonicalPath(path) {
1172
+ try {
1173
+ return realpathSync(path);
1174
+ } catch {
1175
+ return path;
1176
+ }
1177
+ }
1178
+ function key(hostId, sessionId) {
1179
+ return `${hostId}:${sessionId}`;
1180
+ }
1181
+ function safe(value) {
1182
+ return createHash("sha1").update(value).digest("hex").slice(0, 16);
1183
+ }
1184
+
1185
+ // src/remote/files.ts
1186
+ import { spawn as spawn2 } from "node:child_process";
1187
+ import { isAbsolute as isAbsolute2, join as join4, relative, resolve as resolve3, sep } from "node:path";
1188
+ var HELPER = String.raw`
1189
+ import sys, json, os, base64
1190
+ req = json.load(sys.stdin)
1191
+ action = req.get('action')
1192
+ path = req.get('path')
1193
+ def stat_payload(st):
1194
+ return {'absolutePath': path, 'version': f'{st.st_mtime_ns}:{st.st_size}', 'bytes': st.st_size}
1195
+ try:
1196
+ if action == 'stat':
1197
+ st = os.stat(path)
1198
+ if not os.path.isfile(path):
1199
+ raise IsADirectoryError(path)
1200
+ print(json.dumps(stat_payload(st)))
1201
+ elif action == 'read':
1202
+ st = os.stat(path)
1203
+ if not os.path.isfile(path):
1204
+ raise IsADirectoryError(path)
1205
+ offset = max(1, int(req.get('offset', 1)))
1206
+ limit = max(1, min(int(req.get('limit', 200)), 2000))
1207
+ max_line = 32 * 1024
1208
+ max_page = 1024 * 1024
1209
+ page = []
1210
+ page_bytes = 0
1211
+ saw_more = False
1212
+ with open(path, 'rb') as handle:
1213
+ index = 0
1214
+ while True:
1215
+ line = handle.readline(max_line + 1)
1216
+ if not line:
1217
+ break
1218
+ index += 1
1219
+ if index < offset:
1220
+ if len(line) > max_line and not line.endswith(b'\n'):
1221
+ while True:
1222
+ rest = handle.readline(64 * 1024)
1223
+ if not rest or rest.endswith(b'\n'):
1224
+ break
1225
+ continue
1226
+ if len(page) >= limit or page_bytes + len(line) > max_page:
1227
+ saw_more = True
1228
+ break
1229
+ truncated = len(line) > max_line
1230
+ if truncated and not line.endswith(b'\n'):
1231
+ while True:
1232
+ rest = handle.readline(64 * 1024)
1233
+ if not rest or rest.endswith(b'\n'):
1234
+ break
1235
+ if truncated:
1236
+ line = line[:max_line]
1237
+ text = line.rstrip(b'\r\n').decode('utf-8', errors='replace' if truncated else 'strict')
1238
+ if truncated:
1239
+ text += '… [line truncated]'
1240
+ page.append(text)
1241
+ page_bytes += len(line)
1242
+ if len(page) >= limit:
1243
+ if handle.read(1):
1244
+ saw_more = True
1245
+ break
1246
+ eof = not saw_more
1247
+ text = '\n'.join(page)
1248
+ if '\x00' in text:
1249
+ raise ValueError('NUL')
1250
+ out = stat_payload(st)
1251
+ out.update({'offset': offset, 'lines': len(page), 'text': text, 'eof': eof})
1252
+ print(json.dumps(out))
1253
+ elif action == 'list':
1254
+ entries = []
1255
+ with os.scandir(path) as it:
1256
+ for entry in it:
1257
+ try:
1258
+ st = entry.stat(follow_symlinks=True)
1259
+ kind = 'directory' if entry.is_dir(follow_symlinks=True) else ('file' if entry.is_file(follow_symlinks=True) else 'other')
1260
+ size = st.st_size if kind == 'file' else None
1261
+ except OSError:
1262
+ kind = 'other'
1263
+ size = None
1264
+ item = {'name': entry.name, 'type': kind}
1265
+ if size is not None:
1266
+ item['size'] = size
1267
+ entries.append(item)
1268
+ entries.sort(key=lambda item: item['name'])
1269
+ print(json.dumps({'path': req.get('relative', ''), 'entries': entries, 'truncated': len(entries) > 2000}))
1270
+ elif action == 'bytes':
1271
+ st = os.stat(path)
1272
+ offset = max(0, int(req.get('offset', 0)))
1273
+ length = max(0, min(int(req.get('length', 5_000_000)), 5_000_000))
1274
+ with open(path, 'rb') as handle:
1275
+ handle.seek(offset)
1276
+ window = handle.read(length)
1277
+ print(json.dumps({**stat_payload(st), 'offset': offset, 'data': base64.b64encode(window).decode('ascii'), 'eof': offset + len(window) >= st.st_size}))
1278
+ else:
1279
+ raise ValueError('unknown action')
1280
+ except FileNotFoundError:
1281
+ print(json.dumps({'error': {'code': 'workspace-file/not-found', 'message': f'not found: {path}'}}))
1282
+ except IsADirectoryError:
1283
+ print(json.dumps({'error': {'code': 'workspace-file/not-regular-file', 'message': f'not a regular file: {path}'}}))
1284
+ except UnicodeDecodeError:
1285
+ print(json.dumps({'error': {'code': 'workspace-file/not-text', 'message': f'not UTF-8 text: {path}'}}))
1286
+ except Exception as error:
1287
+ print(json.dumps({'error': {'code': 'workspace-file/read-failed', 'message': str(error)}}))
1288
+ `;
1289
+ function installRemoteWorkspaceFiles(ctx, mirrors, hosts) {
1290
+ const service = ctx.get("workspaceFiles");
1291
+ if (service === void 0) return;
1292
+ const originalRead = service.read.bind(service);
1293
+ const originalReadBytes = service.readBytes.bind(service);
1294
+ const originalStat = service.stat.bind(service);
1295
+ const originalList = service.list.bind(service);
1296
+ const originalChanges = service.changes.bind(service);
1297
+ service.read = async (scope, path, range, signal) => {
1298
+ const target = targetOf(mirrors, scope.workspaceRoot, path);
1299
+ if (target === void 0) return originalRead(scope, path, range, signal);
1300
+ const result = await helper(hosts, target.hostId, {
1301
+ action: "read",
1302
+ path: target.remotePath,
1303
+ offset: range.offset ?? 1,
1304
+ limit: range.limit ?? 200
1305
+ }, signal);
1306
+ return { ...result, absolutePath: target.localPath };
1307
+ };
1308
+ service.readBytes = async (scope, path, options, signal) => {
1309
+ const target = targetOf(mirrors, scope.workspaceRoot, path);
1310
+ if (target === void 0) return originalReadBytes(scope, path, options, signal);
1311
+ const result = await helper(hosts, target.hostId, {
1312
+ action: "bytes",
1313
+ path: target.remotePath,
1314
+ offset: options.range?.offset ?? 0,
1315
+ length: options.range?.length ?? 5e6
1316
+ }, signal);
1317
+ const bytes = Buffer.from(result.data, "base64");
1318
+ return { ...result, absolutePath: target.localPath, data: bytes };
1319
+ };
1320
+ service.stat = async (scope, path, signal) => {
1321
+ const target = targetOf(mirrors, scope.workspaceRoot, path);
1322
+ if (target === void 0) return originalStat(scope, path, signal);
1323
+ const result = await helper(hosts, target.hostId, { action: "stat", path: target.remotePath }, signal);
1324
+ return { ...result, absolutePath: target.localPath };
1325
+ };
1326
+ service.list = async (scope, path, signal) => {
1327
+ const target = targetOf(mirrors, scope.workspaceRoot, path);
1328
+ if (target === void 0) return originalList(scope, path, signal);
1329
+ const result = await helper(hosts, target.hostId, {
1330
+ action: "list",
1331
+ path: target.remotePath,
1332
+ relative: target.relativePath
1333
+ }, signal);
1334
+ return { ...result, path: target.relativePath, entries: result.entries.slice(0, 2e3) };
1335
+ };
1336
+ service.changes = (scope, path, signal) => {
1337
+ const target = targetOf(mirrors, scope.workspaceRoot, path);
1338
+ if (target === void 0) return originalChanges(scope, path, signal);
1339
+ return watch(hosts, target, signal);
1340
+ };
1341
+ }
1342
+ function targetOf(mirrors, workspaceRoot, path) {
1343
+ const context = mirrors.remoteFileContext(workspaceRoot);
1344
+ if (context === void 0) return void 0;
1345
+ const localPath = isAbsolute2(path) ? resolve3(path) : resolve3(workspaceRoot, path);
1346
+ const rel = relative(workspaceRoot, localPath);
1347
+ if (rel === "" || !rel.startsWith("..") && !isAbsolute2(rel)) {
1348
+ const remotePath = rel === "" ? context.remoteRoot : join4(context.remoteRoot, rel);
1349
+ return {
1350
+ hostId: context.hostId,
1351
+ localPath: localPath === "" ? workspaceRoot : localPath,
1352
+ remotePath,
1353
+ relativePath: rel.split(sep).join("/"),
1354
+ workspaceRoot
1355
+ };
1356
+ }
1357
+ return void 0;
1358
+ }
1359
+ async function helper(hosts, hostId, request, signal) {
1360
+ const ssh = hosts.sshFor(hostId);
1361
+ const args = [
1362
+ "-o",
1363
+ "BatchMode=yes",
1364
+ "-o",
1365
+ "ConnectTimeout=10000",
1366
+ ...ssh.port === null ? [] : ["-p", String(ssh.port)],
1367
+ ssh.target,
1368
+ "python3",
1369
+ "-c",
1370
+ `'import base64;exec(base64.b64decode("${Buffer.from(HELPER).toString("base64")}"))'`
1371
+ ];
1372
+ const result = await new Promise((resolvePromise, reject) => {
1373
+ const child = spawn2(ssh.binary, args, { stdio: ["pipe", "pipe", "pipe"] });
1374
+ let stdout = "";
1375
+ let stderr = "";
1376
+ const timer = setTimeout(() => {
1377
+ child.kill("SIGKILL");
1378
+ reject(new Error("remote file helper timed out"));
1379
+ }, 3e4);
1380
+ const abort = () => {
1381
+ child.kill("SIGKILL");
1382
+ reject(new Error("remote file helper aborted"));
1383
+ };
1384
+ signal.addEventListener("abort", abort, { once: true });
1385
+ child.stdout.setEncoding("utf8");
1386
+ child.stderr.setEncoding("utf8");
1387
+ child.stdout.on("data", (chunk) => {
1388
+ stdout += chunk;
1389
+ });
1390
+ child.stderr.on("data", (chunk) => {
1391
+ stderr += chunk;
1392
+ });
1393
+ child.on("error", (error) => {
1394
+ clearTimeout(timer);
1395
+ signal.removeEventListener("abort", abort);
1396
+ reject(error);
1397
+ });
1398
+ child.on("close", (code) => {
1399
+ clearTimeout(timer);
1400
+ signal.removeEventListener("abort", abort);
1401
+ if (code !== 0) reject(new Error(stderr.trim() || `remote file helper exited ${String(code)}`));
1402
+ else resolvePromise(stdout);
1403
+ });
1404
+ child.stdin.end(JSON.stringify(request));
1405
+ });
1406
+ const line = result.trim().split("\n").at(-1) ?? "";
1407
+ let parsed;
1408
+ try {
1409
+ parsed = JSON.parse(line);
1410
+ } catch {
1411
+ throw new Error("remote file helper returned invalid JSON");
1412
+ }
1413
+ if ("error" in parsed) {
1414
+ const failure = parsed;
1415
+ throw Object.assign(new Error(failure.error.message), { code: failure.error.code });
1416
+ }
1417
+ return parsed;
1418
+ }
1419
+ async function* watch(hosts, target, signal) {
1420
+ yield { kind: "ready" };
1421
+ let last = "";
1422
+ while (!signal.aborted) {
1423
+ try {
1424
+ const stat2 = await helper(hosts, target.hostId, { action: "stat", path: target.remotePath }, signal);
1425
+ if (stat2.version !== last) {
1426
+ last = stat2.version;
1427
+ yield { kind: "change", change: { absolutePath: target.localPath, version: stat2.version } };
1428
+ }
1429
+ } catch {
1430
+ try {
1431
+ const listing = await helper(hosts, target.hostId, {
1432
+ action: "list",
1433
+ path: target.remotePath,
1434
+ relative: target.relativePath
1435
+ }, signal);
1436
+ const version = `${listing.entries.length}:${listing.entries.map((entry) => `${entry.name}:${entry.size ?? ""}`).join(",")}`;
1437
+ if (version !== last) {
1438
+ last = version;
1439
+ yield { kind: "change", change: { absolutePath: target.localPath, version } };
1440
+ }
1441
+ } catch {
1442
+ }
1443
+ }
1444
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 2e3));
1445
+ }
1446
+ }
1447
+
1448
+ // src/remote/terminal.ts
1449
+ import { spawn as spawn3 } from "node:child_process";
1450
+ import { randomUUID as randomUUID2 } from "node:crypto";
1451
+ var MAX_BUFFERED_BYTES = 512 * 1024;
1452
+ var RemoteTerminals = class {
1453
+ #ctx;
1454
+ #hosts;
1455
+ #mirrors;
1456
+ #terminals = /* @__PURE__ */ new Map();
1457
+ constructor(ctx, hosts, mirrors) {
1458
+ this.#ctx = ctx;
1459
+ this.#hosts = hosts;
1460
+ this.#mirrors = mirrors;
1461
+ }
1462
+ /** Whether this Session owns a mirrored workspace. */
1463
+ isMirrored(sessionId) {
1464
+ return this.#context(sessionId) !== void 0;
1465
+ }
1466
+ /** Return the remote cwd/limits for one Session. */
1467
+ environment(sessionId) {
1468
+ const context = this.#context(sessionId);
1469
+ return {
1470
+ cwd: context?.remoteRoot ?? "/",
1471
+ maxInputBytes: 64 * 1024,
1472
+ maxBufferedBytes: MAX_BUFFERED_BYTES,
1473
+ maxCols: 240,
1474
+ maxRows: 80,
1475
+ scrollback: 4e3
1476
+ };
1477
+ }
1478
+ /** One remote shell candidate. */
1479
+ shells() {
1480
+ return [{ path: "ssh", name: "Remote SSH", args: [] }];
1481
+ }
1482
+ /** Terminals retained for one Session. */
1483
+ list(sessionId) {
1484
+ return [...this.#terminals.values()].filter((item) => item.sessionId === sessionId).map((item) => item.info);
1485
+ }
1486
+ /** Allocate an SSH PTY in the mirrored workspace. */
1487
+ create(sessionId, request) {
1488
+ const context = this.#context(sessionId);
1489
+ if (context === void 0) throw new Error("terminal is only available for mirrored sessions");
1490
+ const id = request.id ?? randomUUID2();
1491
+ const existing = this.#terminals.get(id);
1492
+ if (existing !== void 0) return existing.info;
1493
+ const ssh = this.#hosts.sshFor(context.hostId);
1494
+ const cwd = shellQuote(context.remoteRoot);
1495
+ const args = [
1496
+ "-tt",
1497
+ "-o",
1498
+ "BatchMode=yes",
1499
+ "-o",
1500
+ "ConnectTimeout=10000",
1501
+ ...ssh.port === null ? [] : ["-p", String(ssh.port)],
1502
+ ssh.target,
1503
+ `cd ${cwd} && exec bash -l`
1504
+ ];
1505
+ const child = spawn3(ssh.binary, args, { stdio: ["pipe", "pipe", "pipe"] });
1506
+ const info = {
1507
+ id,
1508
+ title: `Remote \xB7 ${context.remoteRoot.split("/").at(-1) ?? context.remoteRoot}`,
1509
+ shell: { path: "ssh", name: "Remote SSH", args: [] },
1510
+ cwd: context.remoteRoot,
1511
+ cols: request.cols ?? 80,
1512
+ rows: request.rows ?? 24,
1513
+ state: "running",
1514
+ exitCode: null
1515
+ };
1516
+ const runtime = {
1517
+ id,
1518
+ sessionId,
1519
+ info,
1520
+ child,
1521
+ queue: [],
1522
+ waiters: [],
1523
+ sequence: 0,
1524
+ bufferedBytes: 0,
1525
+ title: info.title,
1526
+ exited: false
1527
+ };
1528
+ this.#terminals.set(id, runtime);
1529
+ child.stdout.setEncoding("utf8");
1530
+ child.stderr.setEncoding("utf8");
1531
+ child.stdout.on("data", (data) => this.#push(runtime, { type: "output", sequence: ++runtime.sequence, data }));
1532
+ child.stderr.on("data", (data) => this.#push(runtime, { type: "output", sequence: ++runtime.sequence, data }));
1533
+ child.on("exit", (code) => {
1534
+ runtime.exited = true;
1535
+ runtime.info = { ...runtime.info, state: code === 0 ? "exited" : "failed", exitCode: code };
1536
+ this.#push(runtime, { type: "state", info: runtime.info, writable: false });
1537
+ this.#closeWaiters(runtime);
1538
+ });
1539
+ child.on("error", (error) => {
1540
+ runtime.exited = true;
1541
+ runtime.info = { ...runtime.info, state: "failed", error: String(error) };
1542
+ this.#push(runtime, { type: "state", info: runtime.info, writable: false });
1543
+ });
1544
+ return info;
1545
+ }
1546
+ /** Feed input/resize into one remote PTY. */
1547
+ input(id, input) {
1548
+ const runtime = this.#terminals.get(id);
1549
+ if (runtime === void 0 || runtime.exited) return;
1550
+ if (input.type === "write") runtime.child.stdin.write(input.data);
1551
+ else {
1552
+ runtime.child.stdin.write(`stty cols ${input.cols} rows ${input.rows} 2>/dev/null || true
1553
+ `);
1554
+ }
1555
+ }
1556
+ /** Rename a retained terminal. */
1557
+ rename(id, title) {
1558
+ const runtime = this.#terminals.get(id);
1559
+ if (runtime !== void 0) runtime.title = title;
1560
+ }
1561
+ /** Close one terminal. */
1562
+ close(id) {
1563
+ const runtime = this.#terminals.get(id);
1564
+ if (runtime === void 0) return;
1565
+ runtime.child.kill("SIGHUP");
1566
+ this.#terminals.delete(id);
1567
+ }
1568
+ /** Stream frames for one attachment. */
1569
+ async *attach(id, signal) {
1570
+ const runtime = this.#terminals.get(id);
1571
+ if (runtime === void 0) throw new Error(`terminal ${id} is unavailable`);
1572
+ yield {
1573
+ type: "snapshot",
1574
+ sequence: runtime.sequence,
1575
+ screen: "",
1576
+ info: runtime.info,
1577
+ writable: !runtime.exited
1578
+ };
1579
+ while (!signal.aborted) {
1580
+ if (signal.aborted) return;
1581
+ if (runtime.queue.length > 0) {
1582
+ const frame = runtime.queue.shift();
1583
+ runtime.bufferedBytes = Math.max(0, runtime.bufferedBytes - frameSize(frame));
1584
+ yield frame;
1585
+ continue;
1586
+ }
1587
+ if (runtime.exited) return;
1588
+ const next = await new Promise((resolve4) => {
1589
+ let settled = false;
1590
+ const finish = (result) => {
1591
+ if (settled) return;
1592
+ settled = true;
1593
+ signal.removeEventListener("abort", onAbort);
1594
+ const index = runtime.waiters.indexOf(finish);
1595
+ if (index >= 0) runtime.waiters.splice(index, 1);
1596
+ resolve4(result);
1597
+ };
1598
+ const onAbort = () => finish({ done: true, value: void 0 });
1599
+ runtime.waiters.push(finish);
1600
+ signal.addEventListener("abort", onAbort, { once: true });
1601
+ });
1602
+ if (next.done) return;
1603
+ yield next.value;
1604
+ }
1605
+ }
1606
+ /** Retain acknowledgement used by reconnecting windows. */
1607
+ async *retain() {
1608
+ yield { type: "retained" };
1609
+ }
1610
+ /** Dispose every process. */
1611
+ dispose() {
1612
+ for (const runtime of this.#terminals.values()) runtime.child.kill("SIGHUP");
1613
+ this.#terminals.clear();
1614
+ }
1615
+ #context(sessionId) {
1616
+ const sessions = this.#ctx.get("sessions");
1617
+ const session = sessions?.get(sessionId);
1618
+ const cwd = session?.header.cwd;
1619
+ if (cwd === void 0) return void 0;
1620
+ return this.#mirrors.remoteFileContext(cwd);
1621
+ }
1622
+ #push(runtime, frame) {
1623
+ const waiter = runtime.waiters.shift();
1624
+ if (waiter !== void 0) {
1625
+ waiter({ done: false, value: frame });
1626
+ return;
1627
+ }
1628
+ let normalized = frame;
1629
+ let bytes = frameSize(frame);
1630
+ if (bytes > MAX_BUFFERED_BYTES) {
1631
+ if (frame.type === "output") {
1632
+ const marker = "\n[dsh-remote: terminal output truncated]\n";
1633
+ const limit = Math.max(0, MAX_BUFFERED_BYTES - Buffer.byteLength(marker));
1634
+ normalized = { ...frame, data: `${frame.data.slice(0, limit)}${marker}` };
1635
+ bytes = frameSize(normalized);
1636
+ } else {
1637
+ runtime.queue.length = 0;
1638
+ runtime.bufferedBytes = 0;
1639
+ }
1640
+ }
1641
+ while (runtime.queue.length > 0 && runtime.bufferedBytes + bytes > MAX_BUFFERED_BYTES) {
1642
+ const dropped = runtime.queue.shift();
1643
+ runtime.bufferedBytes = Math.max(0, runtime.bufferedBytes - frameSize(dropped));
1644
+ }
1645
+ runtime.queue.push(normalized);
1646
+ runtime.bufferedBytes += bytes;
1647
+ }
1648
+ #closeWaiters(runtime) {
1649
+ for (const waiter of runtime.waiters.splice(0)) waiter({ done: true, value: void 0 });
1650
+ }
1651
+ };
1652
+ function frameSize(frame) {
1653
+ return Buffer.byteLength(JSON.stringify(frame));
1654
+ }
1655
+ function shellQuote(value) {
1656
+ return `'${value.replace(/'/gu, `'\\''`)}'`;
1657
+ }
1658
+
1659
+ // src/host-api/terminal.ts
1660
+ var TERMINAL_API_PATH = "/api/dsh-remote-terminal";
1661
+ function registerTerminalApi(ctx, terminals) {
1662
+ return ctx.connection.fetch.register({
1663
+ path: TERMINAL_API_PATH,
1664
+ methods: ["POST"],
1665
+ requestBody: "buffered",
1666
+ fetch: async (request) => {
1667
+ let body;
1668
+ try {
1669
+ body = await request.json();
1670
+ } catch {
1671
+ return Response.json({ ok: false, error: { code: "request/invalid", message: "body must be JSON" } });
1672
+ }
1673
+ try {
1674
+ const action = String(body.action ?? "");
1675
+ const sessionId = String(body.sessionId ?? "");
1676
+ if (action === "context") return Response.json({ ok: true, mirrored: terminals.isMirrored(sessionId) });
1677
+ if (action === "environment") return Response.json({ ok: true, value: terminals.environment(sessionId) });
1678
+ if (action === "shells") return Response.json({ ok: true, value: terminals.shells() });
1679
+ if (action === "list") return Response.json({ ok: true, value: terminals.list(sessionId) });
1680
+ if (action === "create") {
1681
+ return Response.json({
1682
+ ok: true,
1683
+ value: terminals.create(sessionId, {
1684
+ ...typeof body.id === "string" ? { id: body.id } : {},
1685
+ ...typeof body.cols === "number" ? { cols: body.cols } : {},
1686
+ ...typeof body.rows === "number" ? { rows: body.rows } : {}
1687
+ })
1688
+ });
1689
+ }
1690
+ const id = String(body.id ?? "");
1691
+ if (action === "input") {
1692
+ terminals.input(id, { type: "write", sequence: Number(body.sequence ?? 0), data: String(body.data ?? "") });
1693
+ return Response.json({ ok: true, value: null });
1694
+ }
1695
+ if (action === "resize") {
1696
+ terminals.input(id, { type: "resize", sequence: Number(body.sequence ?? 0), cols: Number(body.cols ?? 80), rows: Number(body.rows ?? 24) });
1697
+ return Response.json({ ok: true, value: null });
1698
+ }
1699
+ if (action === "rename") {
1700
+ terminals.rename(id, String(body.title ?? ""));
1701
+ return Response.json({ ok: true, value: null });
1702
+ }
1703
+ if (action === "close") {
1704
+ terminals.close(id);
1705
+ return Response.json({ ok: true, value: null });
1706
+ }
1707
+ if (action === "retain") {
1708
+ return new Response(sse([{ type: "retained" }].map((frame) => `event: frame
1709
+ data: ${JSON.stringify(frame)}
1710
+
1711
+ `).join("")), {
1712
+ headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache" }
1713
+ });
1714
+ }
1715
+ if (action === "attach") {
1716
+ const signal = request.signal;
1717
+ const stream = new ReadableStream({
1718
+ async start(controller) {
1719
+ const encoder = new TextEncoder();
1720
+ try {
1721
+ for await (const frame of terminals.attach(id, signal)) {
1722
+ controller.enqueue(encoder.encode(`event: frame
1723
+ data: ${JSON.stringify(frame)}
1724
+
1725
+ `));
1726
+ }
1727
+ } catch (error) {
1728
+ controller.enqueue(encoder.encode(`event: error
1729
+ data: ${JSON.stringify({ message: String(error) })}
1730
+
1731
+ `));
1732
+ } finally {
1733
+ controller.close();
1734
+ }
1735
+ }
1736
+ });
1737
+ return new Response(stream, {
1738
+ headers: {
1739
+ "content-type": "text/event-stream; charset=utf-8",
1740
+ "cache-control": "no-cache, no-transform",
1741
+ connection: "keep-alive",
1742
+ "x-accel-buffering": "no"
1743
+ }
1744
+ });
1745
+ }
1746
+ return Response.json({ ok: false, error: { code: "request/invalid", message: `unknown terminal action ${action}` } });
1747
+ } catch (error) {
1748
+ return Response.json({ ok: false, error: { code: "terminal/error", message: error instanceof Error ? error.message : String(error) } });
1749
+ }
1750
+ }
1751
+ });
1752
+ }
1753
+ function sse(value) {
1754
+ return value;
1755
+ }
1756
+
1757
+ // src/service.ts
1758
+ import { createHash as createHash3, randomBytes, timingSafeEqual } from "node:crypto";
1759
+
1760
+ // ../protocol/lib/version.js
1761
+ var PROTOCOL_VERSION = 1;
1762
+ var MIN_SUPPORTED_PROTOCOL_VERSION = 1;
1763
+ var MAX_SUPPORTED_PROTOCOL_VERSION = 1;
1764
+ var AGENT_API_PREFIX = "/dsh-remote-agent/api/v1";
1765
+ var HEADER_PROTOCOL_VERSION = "x-dsh-remote-protocol";
1766
+ var HEADER_SERVER_PROTOCOL_VERSION = "x-dsh-remote-server-protocol";
1767
+ var HEADER_HOST_ID = "x-dsh-remote-host-id";
1768
+ var DEFAULT_REQUEST_TIMEOUT_MS = 15e3;
1769
+ var MAX_CONTROL_BODY_BYTES = 64 * 1024;
1770
+ function isProtocolSupported(version) {
1771
+ return Number.isInteger(version) && version >= MIN_SUPPORTED_PROTOCOL_VERSION && version <= MAX_SUPPORTED_PROTOCOL_VERSION;
1772
+ }
1773
+
1774
+ // ../protocol/lib/discovery.js
1775
+ var DISCOVERY_STATE_VERSION = 1;
1776
+ function isAgentDiscoveryState(value) {
1777
+ if (typeof value !== "object" || value === null || Array.isArray(value))
1778
+ return false;
1779
+ const record = value;
1780
+ return record.version === DISCOVERY_STATE_VERSION && typeof record.token === "string" && record.token.length >= 16 && (record.port === null || typeof record.port === "number" && Number.isInteger(record.port) && record.port > 0) && typeof record.pid === "number" && typeof record.startedAt === "string" && typeof record.updatedAt === "string" && typeof record.agentVersion === "string" && typeof record.dshVersion === "string" && (record.url === null || typeof record.url === "string") && (record.stoppedAt === void 0 || typeof record.stoppedAt === "string");
1781
+ }
1782
+ function isAgentLive(state) {
1783
+ return state.port !== null && state.stoppedAt === void 0;
1784
+ }
1785
+
1786
+ // ../protocol/lib/errors.js
1787
+ function httpStatusForCode(code) {
1788
+ switch (code) {
1789
+ case "protocol/version-mismatch":
1790
+ return 409;
1791
+ case "auth/missing":
1792
+ case "auth/invalid":
1793
+ return 401;
1794
+ case "auth/forbidden":
1795
+ return 403;
1796
+ case "request/invalid":
1797
+ return 400;
1798
+ case "request/too-large":
1799
+ return 413;
1800
+ case "route/not-found":
1801
+ case "workspace/not-found":
1802
+ case "session/not-found":
1803
+ return 404;
1804
+ case "session/not-live":
1805
+ return 409;
1806
+ case "agent/not-ready":
1807
+ return 503;
1808
+ case "internal":
1809
+ return 500;
1810
+ }
1811
+ }
1812
+ function isRetryableCode(code) {
1813
+ return code === "agent/not-ready" || code === "internal";
1814
+ }
1815
+ var RemoteProtocolError = class extends Error {
1816
+ code;
1817
+ retryable;
1818
+ details;
1819
+ httpStatus;
1820
+ constructor(init) {
1821
+ super(init.message, init.cause === void 0 ? void 0 : { cause: init.cause });
1822
+ this.name = "RemoteProtocolError";
1823
+ this.code = init.code;
1824
+ this.retryable = init.retryable ?? isRetryableCode(init.code);
1825
+ this.details = init.details;
1826
+ this.httpStatus = init.httpStatus ?? httpStatusForCode(init.code);
1827
+ }
1828
+ /** JSON-safe failure body. */
1829
+ toFailure() {
1830
+ return {
1831
+ code: this.code,
1832
+ message: this.message,
1833
+ retryable: this.retryable,
1834
+ ...this.details === void 0 ? {} : { details: this.details }
1835
+ };
1836
+ }
1837
+ };
1838
+
1839
+ // ../protocol/lib/routes.js
1840
+ var AGENT_ROUTES = {
1841
+ health: `${AGENT_API_PREFIX}/health`,
1842
+ host: `${AGENT_API_PREFIX}/host`,
1843
+ workspaces: `${AGENT_API_PREFIX}/workspaces`,
1844
+ sessions: `${AGENT_API_PREFIX}/sessions`,
1845
+ webUrl: `${AGENT_API_PREFIX}/web-url`,
1846
+ approvalEvents: `${AGENT_API_PREFIX}/approvals/events`,
1847
+ approvalAnswer: `${AGENT_API_PREFIX}/approvals/answer`
1848
+ };
1849
+ function workspaceSessionsPath(workspaceId) {
1850
+ return `${AGENT_API_PREFIX}/workspaces/${encodeURIComponent(workspaceId)}/sessions`;
1851
+ }
1852
+ function sessionPath(sessionId) {
1853
+ return `${AGENT_API_PREFIX}/sessions/${encodeURIComponent(sessionId)}`;
1854
+ }
1855
+ function sessionPromptPath(sessionId) {
1856
+ return `${sessionPath(sessionId)}/prompt`;
1857
+ }
1858
+ function sessionCancelPath(sessionId) {
1859
+ return `${sessionPath(sessionId)}/cancel`;
1860
+ }
1861
+ function sessionEventsPath(sessionId, cursor) {
1862
+ const base = `${sessionPath(sessionId)}/events`;
1863
+ return cursor === void 0 ? base : `${base}?cursor=${encodeURIComponent(String(cursor))}`;
1864
+ }
1865
+ var SESSION_PREFIX = `${AGENT_API_PREFIX}/sessions/`;
1866
+ var WORKSPACE_PREFIX = `${AGENT_API_PREFIX}/workspaces/`;
1867
+
1868
+ // ../protocol/lib/sse.js
1869
+ async function* parseSseStream(body) {
1870
+ const reader = body.getReader();
1871
+ const decoder = new TextDecoder();
1872
+ let buffer = "";
1873
+ let eventName = "";
1874
+ let dataLines = [];
1875
+ let id;
1876
+ const dispatch2 = () => {
1877
+ if (dataLines.length === 0 && eventName === "" && id === void 0)
1878
+ return void 0;
1879
+ const message = {
1880
+ event: eventName === "" ? "message" : eventName,
1881
+ data: dataLines.join("\n"),
1882
+ id
1883
+ };
1884
+ eventName = "";
1885
+ dataLines = [];
1886
+ id = void 0;
1887
+ return message;
1888
+ };
1889
+ try {
1890
+ for (; ; ) {
1891
+ const { done, value } = await reader.read();
1892
+ if (done) {
1893
+ buffer += decoder.decode();
1894
+ } else {
1895
+ buffer += decoder.decode(value, { stream: true });
1896
+ }
1897
+ let newline = buffer.indexOf("\n");
1898
+ while (newline >= 0) {
1899
+ let line = buffer.slice(0, newline);
1900
+ buffer = buffer.slice(newline + 1);
1901
+ if (line.endsWith("\r"))
1902
+ line = line.slice(0, -1);
1903
+ if (line === "") {
1904
+ const message = dispatch2();
1905
+ if (message !== void 0)
1906
+ yield message;
1907
+ } else if (line.startsWith(":")) {
1908
+ } else {
1909
+ const colon = line.indexOf(":");
1910
+ const field = colon < 0 ? line : line.slice(0, colon);
1911
+ let fieldValue = colon < 0 ? "" : line.slice(colon + 1);
1912
+ if (fieldValue.startsWith(" "))
1913
+ fieldValue = fieldValue.slice(1);
1914
+ switch (field) {
1915
+ case "event":
1916
+ eventName = fieldValue;
1917
+ break;
1918
+ case "data":
1919
+ dataLines.push(fieldValue);
1920
+ break;
1921
+ case "id":
1922
+ id = fieldValue;
1923
+ break;
1924
+ default:
1925
+ break;
1926
+ }
1927
+ }
1928
+ newline = buffer.indexOf("\n");
1929
+ }
1930
+ if (done) {
1931
+ if (buffer !== "") {
1932
+ const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
1933
+ buffer = "";
1934
+ if (line.startsWith("data:"))
1935
+ dataLines.push(line.slice(5).replace(/^ /, ""));
1936
+ else if (line.startsWith("event:"))
1937
+ eventName = line.slice(6).replace(/^ /, "");
1938
+ else if (line.startsWith("id:"))
1939
+ id = line.slice(3).replace(/^ /, "");
1940
+ }
1941
+ const message = dispatch2();
1942
+ if (message !== void 0)
1943
+ yield message;
1944
+ return;
1945
+ }
1946
+ }
1947
+ } finally {
1948
+ reader.releaseLock();
1949
+ }
1950
+ }
1951
+
1952
+ // ../protocol/lib/validation.js
1953
+ function isRecord(value) {
1954
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1955
+ }
1956
+ function isString(value) {
1957
+ return typeof value === "string";
1958
+ }
1959
+ function isStringArray(value) {
1960
+ return Array.isArray(value) && value.every(isString);
1961
+ }
1962
+ function isIsoString(value) {
1963
+ return isString(value) && !Number.isNaN(Date.parse(value));
1964
+ }
1965
+ function isHostInfo(value) {
1966
+ if (!isRecord(value))
1967
+ return false;
1968
+ return isString(value.hostId) && isString(value.hostname) && isString(value.platform) && isString(value.arch) && isString(value.dshVersion) && isString(value.agentVersion) && isString(value.home) && isIsoString(value.startedAt) && typeof value.protocolVersion === "number" && Array.isArray(value.capabilities) && value.capabilities.every(isString) && typeof value.workspaceCount === "number" && (value.modelBaseUrl === void 0 || value.modelBaseUrl === null || isString(value.modelBaseUrl)) && (value.modelKeyConfigured === void 0 || typeof value.modelKeyConfigured === "boolean") && (value.modelKeyFingerprint === void 0 || value.modelKeyFingerprint === null || isString(value.modelKeyFingerprint));
1969
+ }
1970
+ function isApprovalRequest(value) {
1971
+ if (!isRecord(value))
1972
+ return false;
1973
+ return isString(value.id) && isString(value.sessionId) && isString(value.toolName) && (value.callId === void 0 || isString(value.callId)) && (value.reason === void 0 || isString(value.reason)) && (value.displayReason === void 0 || isRecord(value.displayReason) && Object.values(value.displayReason).every(isString));
1974
+ }
1975
+ function isRemoteApprovalFrame(value) {
1976
+ if (!isRecord(value))
1977
+ return false;
1978
+ if (value.type === "ready")
1979
+ return true;
1980
+ return value.type === "request" && isApprovalRequest(value.request);
1981
+ }
1982
+ function isRemoteWorkspace(value) {
1983
+ if (!isRecord(value))
1984
+ return false;
1985
+ return isString(value.workspaceId) && isString(value.path) && isString(value.title) && isStringArray(value.sessionIds) && isIsoString(value.createdAt) && isIsoString(value.updatedAt);
1986
+ }
1987
+ function isRemoteSession(value) {
1988
+ if (!isRecord(value))
1989
+ return false;
1990
+ return isString(value.sessionId) && (value.workspaceId === null || isString(value.workspaceId)) && (value.cwd === null || isString(value.cwd)) && isIsoString(value.createdAt) && typeof value.live === "boolean" && (value.eventCount === null || typeof value.eventCount === "number");
1991
+ }
1992
+ function isRemoteEvent(value) {
1993
+ return isRecord(value) && typeof value.seq === "number" && isString(value.type) && typeof value.time === "number" && "data" in value;
1994
+ }
1995
+ function isApiFailure(value) {
1996
+ if (!isRecord(value) || value.ok !== false)
1997
+ return false;
1998
+ const error = value.error;
1999
+ return typeof value.protocolVersion === "number" && isIsoString(value.serverTime) && isRecord(error) && isString(error.code) && isString(error.message) && typeof error.retryable === "boolean";
2000
+ }
2001
+ function isHostInfoValue(value) {
2002
+ return isHostInfo(value);
2003
+ }
2004
+ function isListWorkspacesResult(value) {
2005
+ return isRecord(value) && Array.isArray(value.workspaces) && value.workspaces.every(isRemoteWorkspace);
2006
+ }
2007
+ function isListSessionsResult(value) {
2008
+ return isRecord(value) && Array.isArray(value.sessions) && value.sessions.every(isRemoteSession);
2009
+ }
2010
+ function isSessionSnapshot(value) {
2011
+ if (!isRecord(value))
2012
+ return false;
2013
+ return isString(value.hostId) && isString(value.sessionId) && typeof value.cursor === "number" && typeof value.live === "boolean" && (value.workspaceId === null || isString(value.workspaceId)) && (value.cwd === null || isString(value.cwd)) && Array.isArray(value.events) && value.events.every(isRemoteEvent) && typeof value.truncated === "boolean";
2014
+ }
2015
+ function isCreateSessionResult(value) {
2016
+ return isRecord(value) && isString(value.sessionId) && (value.agentPreset === void 0 || isString(value.agentPreset));
2017
+ }
2018
+ function isAcceptedResult(value) {
2019
+ return isRecord(value) && value.accepted === true;
2020
+ }
2021
+ function isWebUrlResult(value) {
2022
+ return isRecord(value) && isString(value.path) && value.path.startsWith("/");
2023
+ }
2024
+ function isSessionStreamFrame(value) {
2025
+ if (!isRecord(value))
2026
+ return false;
2027
+ if (value.type === "ready") {
2028
+ return isString(value.hostId) && isString(value.sessionId) && typeof value.cursor === "number" && typeof value.live === "boolean";
2029
+ }
2030
+ if (value.type === "event") {
2031
+ return typeof value.cursor === "number" && isRemoteEvent(value.event);
2032
+ }
2033
+ if (value.type === "heartbeat") {
2034
+ return typeof value.cursor === "number" && typeof value.time === "number";
2035
+ }
2036
+ if (value.type === "end") {
2037
+ return typeof value.cursor === "number" && isString(value.reason);
2038
+ }
2039
+ return false;
2040
+ }
2041
+
2042
+ // ../protocol/lib/client.js
2043
+ function normalizeBaseUrl(baseUrl) {
2044
+ const url = new URL(baseUrl);
2045
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
2046
+ throw new TypeError(`remote agent base URL must be http(s), got ${url.protocol}`);
2047
+ }
2048
+ return url.origin;
2049
+ }
2050
+ function fetchWithTimeout(input, init, fetchImpl, timeoutMs, callerSignal) {
2051
+ const timeout = new AbortController();
2052
+ const timer = setTimeout(() => timeout.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs);
2053
+ timer.unref?.();
2054
+ const signal = callerSignal === void 0 ? timeout.signal : AbortSignal.any([callerSignal, timeout.signal]);
2055
+ return fetchImpl(input, { ...init, signal }).finally(() => clearTimeout(timer));
2056
+ }
2057
+ var RemoteAgentClient = class {
2058
+ baseUrl;
2059
+ hostId;
2060
+ #token;
2061
+ #timeoutMs;
2062
+ #fetch;
2063
+ constructor(options) {
2064
+ if (options.token.trim() === "")
2065
+ throw new TypeError("remote agent token must not be empty");
2066
+ this.baseUrl = normalizeBaseUrl(options.baseUrl);
2067
+ this.hostId = options.hostId;
2068
+ this.#token = options.token;
2069
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
2070
+ this.#fetch = options.fetchImpl ?? globalThis.fetch;
2071
+ }
2072
+ /** Fetch and validate the agent health document. */
2073
+ health() {
2074
+ return this.#request(AGENT_ROUTES.health, isHostInfoValue);
2075
+ }
2076
+ /** Alias for `health`, named after the protocol capability. */
2077
+ hostInfo() {
2078
+ return this.#request(AGENT_ROUTES.host, isHostInfoValue);
2079
+ }
2080
+ /** List workspaces visible on the remote host. */
2081
+ async listWorkspaces() {
2082
+ const result = await this.#request(AGENT_ROUTES.workspaces, isListWorkspacesResult);
2083
+ return result.workspaces;
2084
+ }
2085
+ /** List sessions, optionally scoped to one remote workspace. */
2086
+ async listSessions(workspaceId) {
2087
+ const path = workspaceId === void 0 ? AGENT_ROUTES.sessions : workspaceSessionsPath(workspaceId);
2088
+ const result = await this.#request(path, isListSessionsResult);
2089
+ return result.sessions;
2090
+ }
2091
+ /** Read the newest page of a session's event log. */
2092
+ snapshotSession(sessionId, options = {}) {
2093
+ const suffix = options.limit === void 0 ? "" : `?limit=${encodeURIComponent(String(options.limit))}`;
2094
+ return this.#request(`${sessionPath(sessionId)}${suffix}`, isSessionSnapshot);
2095
+ }
2096
+ /** Register a remote workspace over the agent API. */
2097
+ createWorkspace(request) {
2098
+ return this.#request(AGENT_ROUTES.workspaces, isRemoteWorkspace, { method: "POST", body: request });
2099
+ }
2100
+ /** Create a remote session in a workspace or directory. */
2101
+ createSession(request) {
2102
+ return this.#request(AGENT_ROUTES.sessions, isCreateSessionResult, {
2103
+ method: "POST",
2104
+ body: request
2105
+ });
2106
+ }
2107
+ /** Send a text prompt to a remote session. */
2108
+ async promptSession(sessionId, request) {
2109
+ await this.#request(sessionPromptPath(sessionId), isAcceptedResult, {
2110
+ method: "POST",
2111
+ body: request
2112
+ });
2113
+ }
2114
+ /** Cancel a remote session's active turn. */
2115
+ async cancelSession(sessionId) {
2116
+ await this.#request(sessionCancelPath(sessionId), isAcceptedResult, {
2117
+ method: "POST",
2118
+ body: {}
2119
+ });
2120
+ }
2121
+ /** Follow the remote approval question stream. */
2122
+ async *followApprovals(signal) {
2123
+ const response = await this.#openStream(AGENT_ROUTES.approvalEvents, signal);
2124
+ const body = response.body;
2125
+ if (body === null) {
2126
+ throw new RemoteProtocolError({ code: "request/invalid", message: "remote agent returned an empty approval stream" });
2127
+ }
2128
+ for await (const message of parseSseStream(body)) {
2129
+ if (message.data === "")
2130
+ continue;
2131
+ let frame;
2132
+ try {
2133
+ frame = JSON.parse(message.data);
2134
+ } catch (error) {
2135
+ throw new RemoteProtocolError({ code: "request/invalid", message: "remote agent sent malformed approval JSON", cause: error });
2136
+ }
2137
+ if (!isRemoteApprovalFrame(frame)) {
2138
+ throw new RemoteProtocolError({ code: "request/invalid", message: "remote agent sent an unknown approval frame" });
2139
+ }
2140
+ yield frame;
2141
+ }
2142
+ }
2143
+ /** Answer one pending remote approval. */
2144
+ answerApproval(id, outcome) {
2145
+ return this.#request(AGENT_ROUTES.approvalAnswer, isAcceptedResult, {
2146
+ method: "POST",
2147
+ body: { id, outcome }
2148
+ });
2149
+ }
2150
+ /** Fetch a browser-authenticated path for the remote Web UI. */
2151
+ webUrl() {
2152
+ return this.#request(AGENT_ROUTES.webUrl, isWebUrlResult);
2153
+ }
2154
+ /**
2155
+ * Follow one session's live events from `cursor`.
2156
+ *
2157
+ * The caller owns reconnection. If the stream drops mid-turn the caller
2158
+ * should request a fresh snapshot and resume from its cursor.
2159
+ */
2160
+ async *followSession(sessionId, cursor, signal) {
2161
+ const response = await this.#openStream(sessionEventsPath(sessionId, cursor), signal);
2162
+ const body = response.body;
2163
+ if (body === null) {
2164
+ throw new RemoteProtocolError({
2165
+ code: "request/invalid",
2166
+ message: "remote agent returned an empty event stream"
2167
+ });
2168
+ }
2169
+ for await (const message of parseSseStream(body)) {
2170
+ if (message.data === "")
2171
+ continue;
2172
+ let frame;
2173
+ try {
2174
+ frame = JSON.parse(message.data);
2175
+ } catch (error) {
2176
+ throw new RemoteProtocolError({
2177
+ code: "request/invalid",
2178
+ message: "remote agent sent malformed event JSON",
2179
+ cause: error
2180
+ });
2181
+ }
2182
+ if (!isSessionStreamFrame(frame)) {
2183
+ throw new RemoteProtocolError({
2184
+ code: "request/invalid",
2185
+ message: "remote agent sent an unknown event frame"
2186
+ });
2187
+ }
2188
+ yield frame;
2189
+ }
2190
+ }
2191
+ /** Validate an event sequence against a cursor. */
2192
+ static guardEvent(cursor, event) {
2193
+ return cursor.accept(event);
2194
+ }
2195
+ async #openStream(path, signal) {
2196
+ const headers = this.#headers();
2197
+ headers.set("accept", "text/event-stream");
2198
+ const response = await this.#fetch(`${this.baseUrl}${path}`, { headers, signal });
2199
+ if (!response.ok)
2200
+ await this.#throwResponseError(response);
2201
+ const contentType = response.headers.get("content-type") ?? "";
2202
+ if (!contentType.startsWith("text/event-stream")) {
2203
+ throw new RemoteProtocolError({
2204
+ code: "request/invalid",
2205
+ message: `remote agent returned ${JSON.stringify(contentType)} instead of text/event-stream`
2206
+ });
2207
+ }
2208
+ return response;
2209
+ }
2210
+ async #request(path, guard, init = {}) {
2211
+ const headers = this.#headers();
2212
+ if (init.body !== void 0)
2213
+ headers.set("content-type", "application/json");
2214
+ const response = await fetchWithTimeout(`${this.baseUrl}${path}`, {
2215
+ method: init.method ?? "GET",
2216
+ headers,
2217
+ redirect: "error",
2218
+ ...init.body === void 0 ? {} : { body: JSON.stringify(init.body) }
2219
+ }, this.#fetch, this.#timeoutMs, void 0);
2220
+ if (!response.ok)
2221
+ await this.#throwResponseError(response);
2222
+ const envelope = await response.json().catch((error) => {
2223
+ throw new RemoteProtocolError({
2224
+ code: "request/invalid",
2225
+ message: "remote agent returned invalid JSON",
2226
+ cause: error
2227
+ });
2228
+ });
2229
+ if (isApiFailure(envelope)) {
2230
+ throw new RemoteProtocolError({
2231
+ code: isCode(envelope.error.code) ? envelope.error.code : "internal",
2232
+ message: envelope.error.message,
2233
+ retryable: envelope.error.retryable,
2234
+ details: isDetails(envelope.error.details) ? envelope.error.details : void 0
2235
+ });
2236
+ }
2237
+ if (!isRecord2(envelope) || envelope.ok !== true) {
2238
+ throw new RemoteProtocolError({
2239
+ code: "request/invalid",
2240
+ message: "remote agent returned an unrecognized envelope"
2241
+ });
2242
+ }
2243
+ const serverVersion = typeof envelope.protocolVersion === "number" ? envelope.protocolVersion : Number(response.headers.get(HEADER_SERVER_PROTOCOL_VERSION));
2244
+ if (!isProtocolSupported(serverVersion)) {
2245
+ throw new RemoteProtocolError({
2246
+ code: "protocol/version-mismatch",
2247
+ message: `remote agent speaks protocol ${String(serverVersion)}, this client speaks ${PROTOCOL_VERSION}`,
2248
+ details: { clientVersion: PROTOCOL_VERSION, serverVersion }
2249
+ });
2250
+ }
2251
+ if (!guard(envelope.data)) {
2252
+ throw new RemoteProtocolError({
2253
+ code: "request/invalid",
2254
+ message: "remote agent returned an unexpected response shape"
2255
+ });
2256
+ }
2257
+ return envelope.data;
2258
+ }
2259
+ async #throwResponseError(response) {
2260
+ const body = await response.json().catch(() => void 0);
2261
+ if (isApiFailure(body)) {
2262
+ throw new RemoteProtocolError({
2263
+ code: isCode(body.error.code) ? body.error.code : "internal",
2264
+ message: body.error.message,
2265
+ retryable: body.error.retryable,
2266
+ details: isDetails(body.error.details) ? body.error.details : void 0,
2267
+ httpStatus: response.status
2268
+ });
2269
+ }
2270
+ throw new RemoteProtocolError({
2271
+ code: response.status === 401 ? "auth/invalid" : "internal",
2272
+ message: `remote agent request failed with HTTP ${response.status}`,
2273
+ httpStatus: response.status
2274
+ });
2275
+ }
2276
+ #headers() {
2277
+ const headers = new Headers({
2278
+ accept: "application/json",
2279
+ authorization: `Bearer ${this.#token}`,
2280
+ [HEADER_PROTOCOL_VERSION]: String(PROTOCOL_VERSION)
2281
+ });
2282
+ if (this.hostId !== void 0)
2283
+ headers.set(HEADER_HOST_ID, this.hostId);
2284
+ return headers;
2285
+ }
2286
+ };
2287
+ function isRecord2(value) {
2288
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2289
+ }
2290
+ function isDetails(value) {
2291
+ return isRecord2(value);
2292
+ }
2293
+ var KNOWN_CODES = /* @__PURE__ */ new Set([
2294
+ "protocol/version-mismatch",
2295
+ "auth/missing",
2296
+ "auth/invalid",
2297
+ "auth/forbidden",
2298
+ "request/invalid",
2299
+ "request/too-large",
2300
+ "route/not-found",
2301
+ "workspace/not-found",
2302
+ "session/not-found",
2303
+ "session/not-live",
2304
+ "agent/not-ready",
2305
+ "internal"
2306
+ ]);
2307
+ function isCode(value) {
2308
+ return KNOWN_CODES.has(value);
2309
+ }
2310
+
2311
+ // src/credentials/proxy.ts
2312
+ import { createServer as createHttpServer, request as httpRequest } from "node:http";
2313
+ import { request as httpsRequest } from "node:https";
2314
+ var ModelCredentialProxy = class {
2315
+ #apiKey;
2316
+ #upstream;
2317
+ #host;
2318
+ #timeoutMs;
2319
+ #isTokenValid;
2320
+ #server;
2321
+ constructor(options) {
2322
+ if (options.apiKey.trim() === "") throw new TypeError("credential proxy requires a non-empty API key");
2323
+ this.#apiKey = options.apiKey;
2324
+ this.#upstream = new URL(options.upstreamBaseUrl);
2325
+ if (this.#upstream.protocol !== "http:" && this.#upstream.protocol !== "https:") {
2326
+ throw new TypeError(`credential proxy upstream must be HTTP(S), got ${this.#upstream.protocol}`);
2327
+ }
2328
+ this.#host = options.host ?? "127.0.0.1";
2329
+ this.#timeoutMs = options.timeoutMs ?? 3e5;
2330
+ this.#isTokenValid = options.isTokenValid;
2331
+ }
2332
+ /** Start on an ephemeral loopback port. */
2333
+ async start() {
2334
+ if (this.#server !== void 0) throw new Error("credential proxy already started");
2335
+ const server = createHttpServer((req, res) => this.#forward(req, res));
2336
+ this.#server = server;
2337
+ await new Promise((resolve4, reject) => {
2338
+ server.once("error", reject);
2339
+ server.listen(0, this.#host, () => resolve4());
2340
+ });
2341
+ const address = server.address();
2342
+ return { port: address.port, baseUrl: `http://${this.#host}:${address.port}` };
2343
+ }
2344
+ /** Stop and release active sockets. */
2345
+ async stop() {
2346
+ const server = this.#server;
2347
+ this.#server = void 0;
2348
+ if (server === void 0) return;
2349
+ await new Promise((resolve4) => {
2350
+ server.close(() => resolve4());
2351
+ server.closeAllConnections?.();
2352
+ });
2353
+ }
2354
+ #forward(req, res) {
2355
+ const presented = presentedToken(req);
2356
+ let authorized = false;
2357
+ try {
2358
+ authorized = presented !== void 0 && this.#isTokenValid(presented);
2359
+ } catch {
2360
+ authorized = false;
2361
+ }
2362
+ if (!authorized) {
2363
+ writeError(res, 401, "credential proxy authorization required");
2364
+ return;
2365
+ }
2366
+ let incoming;
2367
+ try {
2368
+ incoming = new URL(req.url ?? "/", "http://credential-proxy.invalid");
2369
+ } catch {
2370
+ writeError(res, 400, "invalid proxy request path");
2371
+ return;
2372
+ }
2373
+ const target = new URL(this.#upstream.origin);
2374
+ const basePath = this.#upstream.pathname.replace(/\/+$/u, "");
2375
+ let incomingPath = incoming.pathname.startsWith("/") ? incoming.pathname : `/${incoming.pathname}`;
2376
+ if (basePath !== "" && (incomingPath === basePath || incomingPath.startsWith(`${basePath}/`))) {
2377
+ incomingPath = incomingPath.slice(basePath.length) || "/";
2378
+ }
2379
+ target.pathname = `${basePath}${incomingPath}` || "/";
2380
+ target.search = incoming.search;
2381
+ const headers = {};
2382
+ for (const [name2, value] of Object.entries(req.headers)) {
2383
+ if (value === void 0) continue;
2384
+ if (name2 === "host" || name2 === "authorization" || name2 === "x-api-key" || name2 === "connection") continue;
2385
+ headers[name2] = value;
2386
+ }
2387
+ headers.authorization = `Bearer ${this.#apiKey}`;
2388
+ headers["x-api-key"] = this.#apiKey;
2389
+ const request = target.protocol === "https:" ? httpsRequest : httpRequest;
2390
+ const upstream = request(target, { method: req.method, headers }, (upstreamResponse) => {
2391
+ res.statusCode = upstreamResponse.statusCode ?? 502;
2392
+ for (const [name2, value] of Object.entries(upstreamResponse.headers)) {
2393
+ if (value !== void 0) res.setHeader(name2, value);
2394
+ }
2395
+ upstreamResponse.pipe(res);
2396
+ });
2397
+ upstream.setTimeout(this.#timeoutMs, () => {
2398
+ upstream.destroy(new Error("upstream credential proxy request timed out"));
2399
+ });
2400
+ upstream.on("error", (error) => {
2401
+ if (!res.headersSent) writeError(res, 502, "model provider request failed");
2402
+ else res.destroy(error);
2403
+ });
2404
+ req.on("aborted", () => upstream.destroy());
2405
+ req.pipe(upstream);
2406
+ }
2407
+ };
2408
+ function presentedToken(req) {
2409
+ const authorization = req.headers.authorization;
2410
+ if (typeof authorization === "string") {
2411
+ const match = /^Bearer[ \t]+(.+)$/iu.exec(authorization.trim());
2412
+ if (match?.[1] !== void 0) return match[1].trim();
2413
+ }
2414
+ const apiKey = req.headers["x-api-key"];
2415
+ if (typeof apiKey === "string" && apiKey.trim() !== "") return apiKey.trim();
2416
+ return void 0;
2417
+ }
2418
+ function writeError(res, status, message) {
2419
+ if (res.headersSent) {
2420
+ res.destroy();
2421
+ return;
2422
+ }
2423
+ res.statusCode = status;
2424
+ res.setHeader("content-type", "application/json");
2425
+ res.end(JSON.stringify({ error: { message } }));
2426
+ }
2427
+
2428
+ // src/hosts/id.ts
2429
+ import { createHash as createHash2 } from "node:crypto";
2430
+ function isHostId(value) {
2431
+ return /^[a-z0-9][a-z0-9-]{0,63}$/u.test(value);
2432
+ }
2433
+ function slugify(value) {
2434
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 40);
2435
+ return slug === "" ? "host" : slug;
2436
+ }
2437
+ function hostIdFor(canonicalDestination, label) {
2438
+ const hash = createHash2("sha256").update(canonicalDestination).digest("hex").slice(0, 8);
2439
+ return `${slugify(label ?? canonicalDestination)}-${hash}`;
2440
+ }
2441
+
2442
+ // src/hosts/store.ts
2443
+ import { chmod, mkdir as mkdir2, readFile as readFile3, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
2444
+ import { dirname as dirname2 } from "node:path";
2445
+ import { randomUUID as randomUUID3 } from "node:crypto";
2446
+ var HOST_STORE_VERSION = 1;
2447
+ function isHostRecord(value) {
2448
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
2449
+ const record = value;
2450
+ return typeof record.id === "string" && isHostId(record.id) && typeof record.label === "string" && record.label.length > 0 && record.label.length <= 120 && typeof record.sshTarget === "string" && record.sshTarget.length > 0 && (record.sshPort === null || typeof record.sshPort === "number" && Number.isInteger(record.sshPort)) && (record.remoteAgentPort === null || typeof record.remoteAgentPort === "number" && Number.isInteger(record.remoteAgentPort)) && (record.token === null || typeof record.token === "string" && record.token.length >= 16) && (record.proxyToken === void 0 || record.proxyToken === null || typeof record.proxyToken === "string" && record.proxyToken.length >= 16) && typeof record.autoConnect === "boolean" && typeof record.createdAt === "string" && typeof record.updatedAt === "string";
2451
+ }
2452
+ var HostStore = class {
2453
+ file;
2454
+ #queue = Promise.resolve();
2455
+ constructor(file) {
2456
+ this.file = file;
2457
+ }
2458
+ /** Load valid records; malformed entries are skipped with a warning. */
2459
+ async load() {
2460
+ let raw;
2461
+ try {
2462
+ raw = await readFile3(this.file, "utf8");
2463
+ } catch (error) {
2464
+ if (error.code === "ENOENT") return { hosts: [], warnings: [] };
2465
+ throw error;
2466
+ }
2467
+ let parsed;
2468
+ try {
2469
+ parsed = JSON.parse(raw);
2470
+ } catch (error) {
2471
+ throw new Error(`host registry ${this.file} is not valid JSON`, { cause: error });
2472
+ }
2473
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2474
+ throw new Error(`host registry ${this.file} is not a JSON object`);
2475
+ }
2476
+ const document = parsed;
2477
+ if (document.version !== HOST_STORE_VERSION) {
2478
+ throw new Error(`host registry ${this.file} has unsupported version ${String(document.version)}`);
2479
+ }
2480
+ if (!Array.isArray(document.hosts)) {
2481
+ throw new Error(`host registry ${this.file} has no hosts array`);
2482
+ }
2483
+ const hosts = [];
2484
+ const warnings = [];
2485
+ const seen = /* @__PURE__ */ new Set();
2486
+ for (const [index, entry] of document.hosts.entries()) {
2487
+ if (!isHostRecord(entry)) {
2488
+ warnings.push(`host registry entry ${index} is malformed and was ignored`);
2489
+ continue;
2490
+ }
2491
+ if (seen.has(entry.id)) {
2492
+ warnings.push(`host registry entry ${index} duplicates id ${entry.id} and was ignored`);
2493
+ continue;
2494
+ }
2495
+ seen.add(entry.id);
2496
+ hosts.push(entry);
2497
+ }
2498
+ return { hosts, warnings };
2499
+ }
2500
+ /** Current records. */
2501
+ async list() {
2502
+ return (await this.load()).hosts;
2503
+ }
2504
+ /** Add one record, rejecting a duplicate id or destination. */
2505
+ async add(record) {
2506
+ await this.#mutate((hosts) => {
2507
+ if (hosts.some((host) => host.id === record.id)) {
2508
+ throw new Error(`host ${record.id} already exists`);
2509
+ }
2510
+ if (hosts.some((host) => host.sshTarget === record.sshTarget)) {
2511
+ throw new Error(`host ${record.sshTarget} already exists`);
2512
+ }
2513
+ return [...hosts, record];
2514
+ });
2515
+ return record;
2516
+ }
2517
+ /** Merge a patch into one record. */
2518
+ async update(id, patch) {
2519
+ return this.#mutate((hosts) => {
2520
+ const index = hosts.findIndex((host) => host.id === id);
2521
+ if (index < 0) throw new Error(`host ${id} does not exist`);
2522
+ const next = { ...hosts[index], ...patch, id };
2523
+ if (!isHostRecord(next)) throw new Error(`host ${id} update produced an invalid record`);
2524
+ const copy = [...hosts];
2525
+ copy[index] = next;
2526
+ return copy;
2527
+ }).then((records) => records.find((host) => host.id === id));
2528
+ }
2529
+ /** Remove one record. */
2530
+ async remove(id) {
2531
+ const before = await this.list();
2532
+ if (!before.some((host) => host.id === id)) return false;
2533
+ await this.#mutate((hosts) => hosts.filter((host) => host.id !== id));
2534
+ return true;
2535
+ }
2536
+ async #mutate(change) {
2537
+ const run = async () => {
2538
+ const loaded = await this.load();
2539
+ const next = change(loaded.hosts);
2540
+ await this.#write(next);
2541
+ return next;
2542
+ };
2543
+ const task = this.#queue.then(run, run);
2544
+ this.#queue = task.then(() => void 0, () => void 0);
2545
+ return task;
2546
+ }
2547
+ async #write(hosts) {
2548
+ await mkdir2(dirname2(this.file), { recursive: true, mode: 448 });
2549
+ await chmod(dirname2(this.file), 448).catch(() => void 0);
2550
+ const temp = `${this.file}.${randomUUID3()}.tmp`;
2551
+ const document = { version: HOST_STORE_VERSION, hosts };
2552
+ await writeFile2(temp, `${JSON.stringify(document, null, 2)}
2553
+ `, { encoding: "utf8", mode: 384 });
2554
+ await chmod(temp, 384);
2555
+ await rename2(temp, this.file);
2556
+ }
2557
+ };
2558
+
2559
+ // src/hosts/types.ts
2560
+ function idleRuntime() {
2561
+ return {
2562
+ phase: "idle",
2563
+ localPort: null,
2564
+ localUrl: null,
2565
+ hostInfo: null,
2566
+ workspaceCount: null,
2567
+ error: null,
2568
+ errorCode: null,
2569
+ connectedAt: null,
2570
+ detail: null,
2571
+ modelProxy: false,
2572
+ modelDirect: false
2573
+ };
2574
+ }
2575
+ function toHostView(record, runtime) {
2576
+ return {
2577
+ id: record.id,
2578
+ label: record.label,
2579
+ sshTarget: record.sshTarget,
2580
+ sshPort: record.sshPort,
2581
+ remoteAgentPort: record.remoteAgentPort,
2582
+ autoConnect: record.autoConnect,
2583
+ createdAt: record.createdAt,
2584
+ updatedAt: record.updatedAt,
2585
+ hasToken: record.token !== null,
2586
+ phase: runtime.phase,
2587
+ localPort: runtime.localPort,
2588
+ localUrl: runtime.localUrl,
2589
+ hostInfo: runtime.hostInfo,
2590
+ workspaceCount: runtime.workspaceCount,
2591
+ error: runtime.error,
2592
+ errorCode: runtime.errorCode,
2593
+ connectedAt: runtime.connectedAt,
2594
+ detail: runtime.detail,
2595
+ modelProxy: runtime.modelProxy,
2596
+ modelDirect: runtime.modelDirect
2597
+ };
2598
+ }
2599
+
2600
+ // src/remote/provisioner.ts
2601
+ import { existsSync, readdirSync } from "node:fs";
2602
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
2603
+ import { join as join6 } from "node:path";
2604
+
2605
+ // ../installer/lib/index.js
2606
+ import { execFile } from "node:child_process";
2607
+ import { mkdtemp } from "node:fs/promises";
2608
+ import { tmpdir } from "node:os";
2609
+ import { join as join5 } from "node:path";
2610
+ import { fileURLToPath } from "node:url";
2611
+ import { promisify } from "node:util";
2612
+ import { spawn as spawn4 } from "node:child_process";
2613
+ import { execFile as execFile2 } from "node:child_process";
2614
+ import { access } from "node:fs/promises";
2615
+ var AGENT_API_PREFIX2 = "/dsh-remote-agent/api/v1";
2616
+ var MAX_CONTROL_BODY_BYTES2 = 64 * 1024;
2617
+ var AGENT_ROUTES2 = {
2618
+ health: `${AGENT_API_PREFIX2}/health`,
2619
+ host: `${AGENT_API_PREFIX2}/host`,
2620
+ workspaces: `${AGENT_API_PREFIX2}/workspaces`,
2621
+ sessions: `${AGENT_API_PREFIX2}/sessions`,
2622
+ webUrl: `${AGENT_API_PREFIX2}/web-url`,
2623
+ approvalEvents: `${AGENT_API_PREFIX2}/approvals/events`,
2624
+ approvalAnswer: `${AGENT_API_PREFIX2}/approvals/answer`
2625
+ };
2626
+ var SESSION_PREFIX2 = `${AGENT_API_PREFIX2}/sessions/`;
2627
+ var WORKSPACE_PREFIX2 = `${AGENT_API_PREFIX2}/workspaces/`;
2628
+ function shellLiteral(value) {
2629
+ return `'${value.replace(/'/gu, `'\\''`)}'`;
2630
+ }
2631
+ function shellPath(value) {
2632
+ if (value === "~") return '"$HOME"';
2633
+ if (value.startsWith("~/")) {
2634
+ const rest = value.slice(2);
2635
+ return `"$HOME/"${shellLiteral(rest)}`;
2636
+ }
2637
+ if (value.startsWith("$HOME/")) {
2638
+ const rest = value.slice("$HOME/".length);
2639
+ return `"$HOME/"${shellLiteral(rest)}`;
2640
+ }
2641
+ return shellLiteral(value);
2642
+ }
2643
+ function renderSystemdUnit(options) {
2644
+ return [
2645
+ "[Unit]",
2646
+ "Description=DSH remote agent (DeepSeek Harness Web)",
2647
+ "After=network.target",
2648
+ "",
2649
+ "[Service]",
2650
+ "Type=simple",
2651
+ 'Environment="DSH_HOME=$DSH_HOME"',
2652
+ // $PATH is expanded by the bootstrap shell after nodePathPrelude(), so the
2653
+ // systemd unit sees the discovered nvm/Volta/fnm Node bin directory too.
2654
+ 'Environment="PATH=$INSTALL_DIR/node/bin:$INSTALL_DIR/node_modules/.bin:/usr/local/bin:/usr/bin:/bin:$PATH"',
2655
+ ...options.modelBaseUrl === void 0 ? [] : ['Environment="DEEPSEEK_BASE_URL=$MODEL_BASE_URL"'],
2656
+ ...options.modelApiKey === void 0 ? [] : ['Environment="DEEPSEEK_API_KEY=$MODEL_API_KEY"'],
2657
+ 'ExecStart="$DSH_BIN" web --host 127.0.0.1 --port $PORT --no-open',
2658
+ "Restart=always",
2659
+ "RestartSec=3",
2660
+ "WorkingDirectory=$DSH_HOME",
2661
+ "",
2662
+ "[Install]",
2663
+ "WantedBy=default.target",
2664
+ ""
2665
+ ].join("\n");
2666
+ }
2667
+ function nodePathPrelude() {
2668
+ return `if ! command -v node >/dev/null 2>&1; then
2669
+ for candidate in \\
2670
+ "$HOME/.volta/bin" \\
2671
+ "$HOME/.local/share/fnm" \\
2672
+ "$HOME/.bun/bin" \\
2673
+ "$HOME/.asdf/shims" \\
2674
+ "$HOME/.local/share/mise/shims" \\
2675
+ "$HOME/.nix-profile/bin" \\
2676
+ "$HOME/.n/bin" \\
2677
+ "$HOME/.local/bin" \\
2678
+ "/opt/homebrew/bin" \\
2679
+ "/usr/local/bin"; do
2680
+ if [ -d "$candidate" ]; then PATH="$candidate:$PATH"; fi
2681
+ done
2682
+ NODE_BIN_DIR="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)"
2683
+ if [ -n "$NODE_BIN_DIR" ]; then PATH="$NODE_BIN_DIR:$PATH"; fi
2684
+ if ! command -v node >/dev/null 2>&1; then
2685
+ LOGIN_SHELL="\${SHELL:-}"
2686
+ if command -v getent >/dev/null 2>&1; then
2687
+ LOGIN_SHELL="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)"
2688
+ fi
2689
+ case "$LOGIN_SHELL" in
2690
+ */bash|*/zsh|*/sh|*/dash|*/ksh)
2691
+ NODE_BIN="$(env -i HOME="$HOME" PATH="$PATH" "$LOGIN_SHELL" -lc 'command -v node' 2>/dev/null | tail -n 1 || true)"
2692
+ if [ -n "$NODE_BIN" ] && [ -x "$NODE_BIN" ]; then PATH="$(dirname "$NODE_BIN"):$PATH"; fi
2693
+ ;;
2694
+ esac
2695
+ fi
2696
+ export PATH
2697
+ fi`;
2698
+ }
2699
+ function nodeInstallFunction() {
2700
+ return `install_node() {
2701
+ case "$(uname -s)" in
2702
+ Linux) NODE_OS=linux ;;
2703
+ Darwin) NODE_OS=darwin ;;
2704
+ *) echo "dsh-remote: unsupported OS for automatic Node.js install: $(uname -s)" >&2; return 1 ;;
2705
+ esac
2706
+ case "$(uname -m)" in
2707
+ x86_64|amd64) NODE_ARCH=x64 ;;
2708
+ aarch64|arm64) NODE_ARCH=arm64 ;;
2709
+ armv7l) NODE_ARCH=armv7l ;;
2710
+ *) echo "dsh-remote: unsupported architecture for automatic Node.js install: $(uname -m)" >&2; return 1 ;;
2711
+ esac
2712
+
2713
+ NODE_INSTALL_ROOT="$INSTALL_DIR/node"
2714
+ NODE_DOWNLOAD="$INSTALL_DIR/.node-download.$$"
2715
+ NODE_ARCHIVE="node-v$NODE_VERSION-$NODE_OS-$NODE_ARCH.tar.gz"
2716
+ NODE_URL="https://nodejs.org/dist/v$NODE_VERSION"
2717
+ rm -rf "$NODE_DOWNLOAD"
2718
+ mkdir -p "$NODE_DOWNLOAD"
2719
+ echo "dsh-remote: downloading Node.js $NODE_VERSION ($NODE_OS-$NODE_ARCH)"
2720
+ if command -v curl >/dev/null 2>&1; then
2721
+ if ! curl -fsSL --retry 3 "$NODE_URL/SHASUMS256.txt" -o "$NODE_DOWNLOAD/SHASUMS256.txt" \\
2722
+ || ! curl -fsSL --retry 3 "$NODE_URL/$NODE_ARCHIVE" -o "$NODE_DOWNLOAD/$NODE_ARCHIVE"; then
2723
+ echo "dsh-remote: could not download Node.js from $NODE_URL" >&2
2724
+ rm -rf "$NODE_DOWNLOAD"
2725
+ return 1
2726
+ fi
2727
+ elif command -v wget >/dev/null 2>&1; then
2728
+ if ! wget -q -O "$NODE_DOWNLOAD/SHASUMS256.txt" "$NODE_URL/SHASUMS256.txt" \\
2729
+ || ! wget -q -O "$NODE_DOWNLOAD/$NODE_ARCHIVE" "$NODE_URL/$NODE_ARCHIVE"; then
2730
+ echo "dsh-remote: could not download Node.js from $NODE_URL" >&2
2731
+ rm -rf "$NODE_DOWNLOAD"
2732
+ return 1
2733
+ fi
2734
+ else
2735
+ echo "dsh-remote: curl or wget is required to install Node.js automatically" >&2
2736
+ rm -rf "$NODE_DOWNLOAD"
2737
+ return 1
2738
+ fi
2739
+
2740
+ EXPECTED="$(awk -v name="$NODE_ARCHIVE" '$2 == name { print $1; exit }' "$NODE_DOWNLOAD/SHASUMS256.txt")"
2741
+ if [ -z "$EXPECTED" ]; then
2742
+ echo "dsh-remote: Node.js checksum is missing from SHASUMS256.txt" >&2
2743
+ rm -rf "$NODE_DOWNLOAD"
2744
+ return 1
2745
+ fi
2746
+ if command -v sha256sum >/dev/null 2>&1; then
2747
+ ACTUAL="$(sha256sum "$NODE_DOWNLOAD/$NODE_ARCHIVE" | awk '{ print $1 }')"
2748
+ elif command -v shasum >/dev/null 2>&1; then
2749
+ ACTUAL="$(shasum -a 256 "$NODE_DOWNLOAD/$NODE_ARCHIVE" | awk '{ print $1 }')"
2750
+ else
2751
+ echo "dsh-remote: sha256sum or shasum is required to verify Node.js" >&2
2752
+ rm -rf "$NODE_DOWNLOAD"
2753
+ return 1
2754
+ fi
2755
+ if [ "$EXPECTED" != "$ACTUAL" ]; then
2756
+ echo "dsh-remote: Node.js checksum mismatch" >&2
2757
+ rm -rf "$NODE_DOWNLOAD"
2758
+ return 1
2759
+ fi
2760
+ if ! command -v tar >/dev/null 2>&1; then
2761
+ echo "dsh-remote: tar is required to install Node.js automatically" >&2
2762
+ rm -rf "$NODE_DOWNLOAD"
2763
+ return 1
2764
+ fi
2765
+
2766
+ rm -rf "$NODE_INSTALL_ROOT"
2767
+ mkdir -p "$NODE_INSTALL_ROOT"
2768
+ if ! tar -xzf "$NODE_DOWNLOAD/$NODE_ARCHIVE" -C "$NODE_INSTALL_ROOT" --strip-components=1; then
2769
+ echo "dsh-remote: could not extract Node.js" >&2
2770
+ rm -rf "$NODE_DOWNLOAD" "$NODE_INSTALL_ROOT"
2771
+ return 1
2772
+ fi
2773
+ rm -rf "$NODE_DOWNLOAD"
2774
+ PATH="$NODE_INSTALL_ROOT/bin:$PATH"
2775
+ export PATH
2776
+ echo "dsh-remote: installed Node.js $NODE_VERSION at $NODE_INSTALL_ROOT"
2777
+ }`;
2778
+ }
2779
+ function renderBootstrapScript(options) {
2780
+ const startBlock = renderStartBlock(options);
2781
+ return `#!/usr/bin/env bash
2782
+ # dsh-remote installer bootstrap \u2014 generated, no secrets embedded.
2783
+ set -euo pipefail
2784
+
2785
+ INSTALL_DIR=${shellPath(options.installDir)}
2786
+ DSH_HOME=${shellPath(options.dshHome)}
2787
+ PROFILE=${shellLiteral(options.profile)}
2788
+ PORT=${shellLiteral(String(options.port))}
2789
+ DSH_VERSION=${shellLiteral(options.dshVersion)}
2790
+ PNPM_VERSION=${shellLiteral(options.pnpmVersion)}
2791
+ NODE_VERSION=${shellLiteral(options.nodeVersion)}
2792
+ AUTO_INSTALL_NODE=${options.autoInstallNode ? "1" : "0"}
2793
+ AGENT_TARBALL=${shellLiteral(options.agentTarball)}
2794
+ MODEL_BASE_URL=${options.modelBaseUrl === void 0 ? "''" : shellLiteral(options.modelBaseUrl)}
2795
+ MODEL_API_KEY=${options.modelApiKey === void 0 ? "''" : shellLiteral(options.modelApiKey)}
2796
+ STATE_FILE="$DSH_HOME/remote-agent/agent.json"
2797
+ WAIT_FOR_STATE=${options.start === "none" ? 0 : 1}
2798
+
2799
+ export DSH_HOME
2800
+ mkdir -p "$INSTALL_DIR" "$DSH_HOME/remote-agent"
2801
+
2802
+ ${nodePathPrelude()}
2803
+
2804
+ ${nodeInstallFunction()}
2805
+ if ! command -v node >/dev/null 2>&1 || ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)' >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
2806
+ if [ "$AUTO_INSTALL_NODE" -eq 1 ]; then
2807
+ install_node || exit 25
2808
+ fi
2809
+ fi
2810
+ if ! command -v node >/dev/null 2>&1; then
2811
+ echo "dsh-remote: Node.js is required but not on PATH; rerun with automatic Node.js installation enabled" >&2
2812
+ exit 21
2813
+ fi
2814
+ if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)'; then
2815
+ echo "dsh-remote: Node.js 22.19+ is required (found $(node --version))" >&2
2816
+ exit 22
2817
+ fi
2818
+ if ! command -v npm >/dev/null 2>&1; then
2819
+ echo "dsh-remote: npm is required; install a Node.js distribution that includes npm" >&2
2820
+ exit 23
2821
+ fi
2822
+
2823
+ export PATH="$INSTALL_DIR/node_modules/.bin:$PATH"
2824
+ if ! command -v pnpm >/dev/null 2>&1; then
2825
+ echo "dsh-remote: installing pnpm@$PNPM_VERSION into $INSTALL_DIR"
2826
+ npm install --prefix "$INSTALL_DIR" --no-audit --no-fund "pnpm@$PNPM_VERSION"
2827
+ fi
2828
+
2829
+ DSH_BIN="$INSTALL_DIR/node_modules/.bin/dsh"
2830
+ if [ ! -x "$DSH_BIN" ]; then
2831
+ echo "dsh-remote: installing @deepseek-ai/dsh@$DSH_VERSION into $INSTALL_DIR"
2832
+ npm install --prefix "$INSTALL_DIR" --no-audit --no-fund "@deepseek-ai/dsh@$DSH_VERSION"
2833
+ fi
2834
+ if [ ! -x "$DSH_BIN" ]; then
2835
+ echo "dsh-remote: dsh installation did not produce $DSH_BIN" >&2
2836
+ exit 24
2837
+ fi
2838
+
2839
+ echo "dsh-remote: adding plugin to profile $PROFILE"
2840
+ "$DSH_BIN" plugin --profile "$PROFILE" add "$AGENT_TARBALL"
2841
+
2842
+ ${startBlock}
2843
+
2844
+ if [ "$WAIT_FOR_STATE" -eq 1 ]; then
2845
+ i=0
2846
+ while [ "$i" -lt 60 ]; do
2847
+ if [ -s "$STATE_FILE" ]; then break; fi
2848
+ sleep 1
2849
+ i=$((i + 1))
2850
+ done
2851
+ fi
2852
+
2853
+ echo "dsh-remote: installed"
2854
+ echo "dsh-remote: state file $STATE_FILE"
2855
+ if [ "$WAIT_FOR_STATE" -eq 1 ] && [ -s "$STATE_FILE" ]; then
2856
+ PORT_VALUE=$(sed -n 's/.*"port": *\\([0-9][0-9]*\\).*/\\1/p' "$STATE_FILE" | head -1)
2857
+ echo "dsh-remote: remote agent port \${PORT_VALUE:-unknown}"
2858
+ else
2859
+ echo "dsh-remote: warning: the agent state file is not ready yet; inspect the remote DSH log" >&2
2860
+ fi
2861
+ `;
2862
+ }
2863
+ function renderStartBlock(options) {
2864
+ if (options.start === "none") {
2865
+ return 'echo "dsh-remote: start mode none; start dsh web yourself"';
2866
+ }
2867
+ if (options.start === "nohup") {
2868
+ return renderNohup(options);
2869
+ }
2870
+ const unit = renderSystemdUnit(options);
2871
+ return `UNIT_DIR="$HOME/.config/systemd/user"
2872
+ mkdir -p "$UNIT_DIR"
2873
+ umask 077
2874
+ cat > "$UNIT_DIR/dsh-remote-agent.service" <<DSH_REMOTE_UNIT
2875
+ ${unit}DSH_REMOTE_UNIT
2876
+ chmod 600 "$UNIT_DIR/dsh-remote-agent.service"
2877
+
2878
+ if command -v systemctl >/dev/null 2>&1 && systemctl --user daemon-reload >/dev/null 2>&1; then
2879
+ if systemctl --user enable dsh-remote-agent.service >/dev/null 2>&1 && systemctl --user restart dsh-remote-agent.service; then
2880
+ echo "dsh-remote: restarted systemd user unit dsh-remote-agent.service"
2881
+ else
2882
+ echo "dsh-remote: systemd user unit failed; falling back to nohup" >&2
2883
+ ${renderNohup(options)}
2884
+ fi
2885
+ else
2886
+ echo "dsh-remote: systemd user manager unavailable; falling back to nohup" >&2
2887
+ ${renderNohup(options)}
2888
+ fi`;
2889
+ }
2890
+ function renderNohup(options) {
2891
+ const modelEnv = options.modelBaseUrl === void 0 ? "" : `DEEPSEEK_BASE_URL="$MODEL_BASE_URL" DEEPSEEK_API_KEY="$MODEL_API_KEY" `;
2892
+ return `if [ -f "$DSH_HOME/remote-agent/web.pid" ]; then
2893
+ kill "$(cat "$DSH_HOME/remote-agent/web.pid")" 2>/dev/null || true
2894
+ fi
2895
+ ${modelEnv}nohup "$DSH_BIN" web --host 127.0.0.1 --port "$PORT" --no-open > "$DSH_HOME/remote-agent/web.log" 2>&1 &
2896
+ echo $! > "$DSH_HOME/remote-agent/web.pid"
2897
+ echo "dsh-remote: started dsh web under nohup (pid $(cat "$DSH_HOME/remote-agent/web.pid"))"`;
2898
+ }
2899
+ var SshTargetError = class extends Error {
2900
+ constructor(message) {
2901
+ super(message);
2902
+ this.name = "SshTargetError";
2903
+ }
2904
+ };
2905
+ function parseTarget(raw) {
2906
+ const input = raw.trim();
2907
+ if (input === "") throw new SshTargetError("SSH target must not be empty");
2908
+ if (/[\s\u0000-\u001f]/u.test(input)) throw new SshTargetError("SSH target must not contain whitespace");
2909
+ if (input.startsWith("-")) throw new SshTargetError('SSH target must not start with "-"');
2910
+ let user = null;
2911
+ let hostAndPort = input;
2912
+ if (input.startsWith("ssh://")) {
2913
+ let url;
2914
+ try {
2915
+ url = new URL(input);
2916
+ } catch (error) {
2917
+ throw new SshTargetError(`invalid ssh URL: ${input}`);
2918
+ }
2919
+ if (url.protocol !== "ssh:") throw new SshTargetError(`unsupported scheme: ${url.protocol}`);
2920
+ if (url.pathname !== "" && url.pathname !== "/") throw new SshTargetError("ssh URL must not contain a path");
2921
+ user = url.username === "" ? null : decodeURIComponent(url.username);
2922
+ const port2 = url.port === "" ? null : Number(url.port);
2923
+ return validate(input, user, url.hostname, port2);
2924
+ }
2925
+ const at = input.indexOf("@");
2926
+ if (at >= 0) {
2927
+ user = input.slice(0, at);
2928
+ hostAndPort = input.slice(at + 1);
2929
+ }
2930
+ let host = hostAndPort;
2931
+ let port = null;
2932
+ const colon = hostAndPort.lastIndexOf(":");
2933
+ if (colon >= 0) {
2934
+ const portText = hostAndPort.slice(colon + 1);
2935
+ if (!/^\d{1,5}$/u.test(portText)) throw new SshTargetError(`invalid SSH port: ${portText}`);
2936
+ host = hostAndPort.slice(0, colon);
2937
+ port = Number(portText);
2938
+ }
2939
+ return validate(input, user, host, port);
2940
+ }
2941
+ function validate(raw, user, host, port) {
2942
+ if (user !== null && !/^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/u.test(user)) {
2943
+ throw new SshTargetError(`invalid SSH user: ${JSON.stringify(user)}`);
2944
+ }
2945
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,252}[A-Za-z0-9])?$/u.test(host)) {
2946
+ throw new SshTargetError(`unsupported SSH host: ${JSON.stringify(host)}`);
2947
+ }
2948
+ if (port !== null && (!Number.isInteger(port) || port < 1 || port > 65535)) {
2949
+ throw new SshTargetError(`invalid SSH port: ${String(port)}`);
2950
+ }
2951
+ const destination = user === null ? host : `${user}@${host}`;
2952
+ return {
2953
+ raw,
2954
+ destination,
2955
+ port,
2956
+ canonical: port === null ? destination : `${destination}:${port}`
2957
+ };
2958
+ }
2959
+ function sshArgs(target, options) {
2960
+ return [
2961
+ "-o",
2962
+ "BatchMode=yes",
2963
+ "-o",
2964
+ `ConnectTimeout=${Math.max(1, Math.ceil(options.timeoutMs / 1e3))}`,
2965
+ "-o",
2966
+ `StrictHostKeyChecking=${options.strictHostKeyChecking}`,
2967
+ "-o",
2968
+ "ServerAliveInterval=15",
2969
+ "-o",
2970
+ "ServerAliveCountMax=3",
2971
+ ...target.port === null ? [] : ["-p", String(target.port)]
2972
+ ];
2973
+ }
2974
+ function runRemoteScript(target, script, options) {
2975
+ return new Promise((resolve4, reject) => {
2976
+ const child = spawn4(options.binary, [...sshArgs(target, options), target.destination, "bash -s"], {
2977
+ stdio: ["pipe", "pipe", "pipe"],
2978
+ windowsHide: true
2979
+ });
2980
+ const stdout = [];
2981
+ const stderr = [];
2982
+ let bytes = 0;
2983
+ let settled = false;
2984
+ const timer = setTimeout(() => {
2985
+ if (settled) return;
2986
+ child.kill("SIGKILL");
2987
+ reject(new Error(`remote bootstrap timed out after ${options.timeoutMs}ms`));
2988
+ }, options.timeoutMs);
2989
+ timer.unref?.();
2990
+ const append = (target2, chunk) => {
2991
+ bytes += chunk.length;
2992
+ if (bytes > 4 * 1024 * 1024) {
2993
+ child.kill("SIGKILL");
2994
+ return;
2995
+ }
2996
+ target2.push(chunk);
2997
+ if (options.verbose) process.stderr.write(chunk);
2998
+ };
2999
+ child.stdout.on("data", (chunk) => append(stdout, chunk));
3000
+ child.stderr.on("data", (chunk) => append(stderr, chunk));
3001
+ child.stdin.on("error", (error) => {
3002
+ if (settled) return;
3003
+ settled = true;
3004
+ clearTimeout(timer);
3005
+ reject(error);
3006
+ });
3007
+ child.once("error", (error) => {
3008
+ if (settled) return;
3009
+ settled = true;
3010
+ clearTimeout(timer);
3011
+ reject(error);
3012
+ });
3013
+ child.once("exit", (code, signal) => {
3014
+ if (settled) return;
3015
+ settled = true;
3016
+ clearTimeout(timer);
3017
+ const result = {
3018
+ stdout: Buffer.concat(stdout).toString("utf8"),
3019
+ stderr: Buffer.concat(stderr).toString("utf8"),
3020
+ code: code ?? (signal === null ? 1 : 137)
3021
+ };
3022
+ if (result.code === 0) resolve4(result);
3023
+ else reject(Object.assign(new Error(`remote bootstrap exited with ${String(result.code)}`), { result }));
3024
+ });
3025
+ child.stdin.end(script);
3026
+ });
3027
+ }
3028
+ var execFileAsync = promisify(execFile);
3029
+ async function provisionRemoteAgent(options) {
3030
+ const target = parseTarget(options.target);
3031
+ await access(options.agentTarballPath);
3032
+ const remoteTarball = `/tmp/dsh-remote-agent-${Date.now()}-${process.pid}.tgz`;
3033
+ const timeoutMs = Math.max(options.timeoutMs ?? 6e5, 6e5);
3034
+ await uploadFile2(options.agentTarballPath, remoteTarball, target, options);
3035
+ const script = renderBootstrapScript({
3036
+ profile: options.profile,
3037
+ port: 0,
3038
+ installDir: options.installDir,
3039
+ dshHome: options.dshHome,
3040
+ dshVersion: options.dshVersion,
3041
+ pnpmVersion: options.pnpmVersion,
3042
+ nodeVersion: options.nodeVersion,
3043
+ autoInstallNode: options.autoInstallNode,
3044
+ start: options.start,
3045
+ agentTarball: remoteTarball,
3046
+ ...options.modelBaseUrl === void 0 ? {} : { modelBaseUrl: options.modelBaseUrl },
3047
+ ...options.modelApiKey === void 0 ? {} : { modelApiKey: options.modelApiKey }
3048
+ });
3049
+ const result = await runRemoteScript(target, script, {
3050
+ binary: options.sshBinary,
3051
+ strictHostKeyChecking: options.strictHostKeyChecking,
3052
+ timeoutMs,
3053
+ verbose: options.verbose ?? false
3054
+ });
3055
+ return { stdout: result.stdout, stderr: result.stderr, remoteTarball };
3056
+ }
3057
+ function uploadFile2(local, remote, target, options) {
3058
+ return new Promise((resolve4, reject) => {
3059
+ const args = [
3060
+ "-o",
3061
+ `StrictHostKeyChecking=${options.strictHostKeyChecking}`,
3062
+ ...target.port === null ? [] : ["-P", String(target.port)],
3063
+ local,
3064
+ `${target.destination}:${remote}`
3065
+ ];
3066
+ const child = execFile2("scp", args, { timeout: 12e4 }, (error) => {
3067
+ if (error === null) resolve4();
3068
+ else reject(error);
3069
+ });
3070
+ if (options.verbose === true) {
3071
+ child.stderr?.on("data", (chunk) => process.stderr.write(chunk));
3072
+ }
3073
+ });
3074
+ }
3075
+
3076
+ // src/ssh/probe.ts
3077
+ import { execFile as execFile3 } from "node:child_process";
3078
+ import { promisify as promisify2 } from "node:util";
3079
+ var execFileAsync2 = promisify2(execFile3);
3080
+ function shellQuote2(value) {
3081
+ return `'${value.replace(/'/gu, `'\\''`)}'`;
3082
+ }
3083
+ function discoveryCommand(remoteStateFile) {
3084
+ const path = remoteStateFile.trim();
3085
+ if (path === "") throw new LocalError("host/invalid", "remote state file must not be empty");
3086
+ if (/[\u0000-\u001f]/u.test(path)) {
3087
+ throw new LocalError("host/invalid", "remote state file must not contain control characters");
3088
+ }
3089
+ if (path === "~") return 'cat -- "$HOME" 2>/dev/null || true';
3090
+ if (path.startsWith("~/")) {
3091
+ const rest = path.slice(2);
3092
+ return `cat -- "$HOME/${rest.replace(/([\\"$`])/gu, "\\$1")}" 2>/dev/null || true`;
3093
+ }
3094
+ return `cat -- ${shellQuote2(path)} 2>/dev/null || true`;
3095
+ }
3096
+ function baseSshArgs(target, options) {
3097
+ return [
3098
+ "-o",
3099
+ "BatchMode=yes",
3100
+ "-o",
3101
+ `ConnectTimeout=${Math.max(1, Math.ceil(options.timeoutMs / 1e3))}`,
3102
+ "-o",
3103
+ `StrictHostKeyChecking=${options.strictHostKeyChecking}`,
3104
+ "-o",
3105
+ "ServerAliveInterval=15",
3106
+ "-o",
3107
+ "ServerAliveCountMax=3",
3108
+ ...target.port === null ? [] : ["-p", String(target.port)]
3109
+ ];
3110
+ }
3111
+ async function discoverAgent(target, options) {
3112
+ const started = Date.now();
3113
+ const command = discoveryCommand(options.remoteStateFile);
3114
+ let stdout;
3115
+ let stderr;
3116
+ try {
3117
+ const result = await execFileAsync2(
3118
+ options.sshBinary,
3119
+ [...baseSshArgs(target, options), target.destination, command],
3120
+ {
3121
+ timeout: options.timeoutMs,
3122
+ maxBuffer: 256 * 1024,
3123
+ encoding: "utf8",
3124
+ windowsHide: true
3125
+ }
3126
+ );
3127
+ stdout = result.stdout;
3128
+ stderr = result.stderr;
3129
+ } catch (error) {
3130
+ const failure = error;
3131
+ const detail = (failure.stderr ?? "").trim().split("\n")[0] ?? "";
3132
+ const message = failure.killed === true ? `SSH discovery timed out after ${options.timeoutMs}ms` : detail !== "" ? `SSH discovery failed: ${detail}` : `SSH discovery failed: ${failure.message}`;
3133
+ throw new LocalError("ssh/probe-failed", message, {
3134
+ details: { host: target.canonical, ...failure.code === void 0 ? {} : { code: failure.code } },
3135
+ cause: error
3136
+ });
3137
+ }
3138
+ if (stdout.trim() === "") {
3139
+ const hint = stderr.trim() === "" ? "" : ` (${stderr.trim().split("\n")[0]})`;
3140
+ throw new LocalError(
3141
+ "agent/unavailable",
3142
+ `remote agent state file was not found at ${options.remoteStateFile}; install dsh-remote-agent and start the remote DSH web host${hint}`,
3143
+ { details: { host: target.canonical, remoteStateFile: options.remoteStateFile } }
3144
+ );
3145
+ }
3146
+ let parsed;
3147
+ try {
3148
+ parsed = JSON.parse(stdout);
3149
+ } catch (error) {
3150
+ throw new LocalError("agent/unavailable", "remote agent state file is not valid JSON", {
3151
+ details: { host: target.canonical },
3152
+ cause: error
3153
+ });
3154
+ }
3155
+ if (!isAgentDiscoveryState(parsed)) {
3156
+ throw new LocalError("agent/unavailable", "remote agent state file has an unexpected shape", {
3157
+ details: { host: target.canonical }
3158
+ });
3159
+ }
3160
+ if (!isAgentLive(parsed)) {
3161
+ throw new LocalError(
3162
+ "agent/unavailable",
3163
+ "remote agent is installed but its DSH web listener is not running; start `dsh web` on the remote host",
3164
+ { details: { host: target.canonical }, retryable: true }
3165
+ );
3166
+ }
3167
+ return { state: parsed, latencyMs: Date.now() - started };
3168
+ }
3169
+
3170
+ // src/remote/provisioner.ts
3171
+ var RemoteProvisioner = class {
3172
+ #config;
3173
+ constructor(config) {
3174
+ this.#config = config;
3175
+ }
3176
+ /**
3177
+ * Install/start the remote agent when discovery finds no live agent.
3178
+ *
3179
+ * @param target - validated SSH target.
3180
+ * @param options - optional local proxy endpoint and progress callback.
3181
+ */
3182
+ async ensureInstalled(target, options = {}) {
3183
+ const tarball = findAgentTarball();
3184
+ options.onProgress?.("\u6B63\u5728\u8FDC\u7AEF\u5B89\u88C5 DSH \u4E0E dsh-remote-agent\uFF08\u9996\u6B21\u7EA6 1\u20133 \u5206\u949F\uFF09\u2026");
3185
+ try {
3186
+ await provisionRemoteAgent({
3187
+ target: target.canonical,
3188
+ agentTarballPath: tarball,
3189
+ sshBinary: this.#config.sshBinary,
3190
+ strictHostKeyChecking: this.#config.strictHostKeyChecking,
3191
+ profile: "web",
3192
+ installDir: this.#config.remoteInstallDir,
3193
+ dshHome: this.#config.remoteDshHome,
3194
+ dshVersion: this.#config.remoteDshVersion,
3195
+ pnpmVersion: this.#config.remotePnpmVersion,
3196
+ nodeVersion: this.#config.remoteNodeVersion,
3197
+ autoInstallNode: this.#config.autoInstallNode,
3198
+ start: this.#config.remoteStart,
3199
+ timeoutMs: this.#config.provisionTimeoutMs,
3200
+ ...options.modelBaseUrl === void 0 ? {} : { modelBaseUrl: options.modelBaseUrl },
3201
+ ...options.modelApiKey === void 0 ? {} : { modelApiKey: options.modelApiKey },
3202
+ verbose: false
3203
+ });
3204
+ } catch (error) {
3205
+ const detail = error instanceof Error ? error.message : String(error);
3206
+ throw new LocalError("agent/unavailable", `\u8FDC\u7AEF\u81EA\u52A8\u5B89\u88C5\u5931\u8D25\uFF1A${detail}`, {
3207
+ details: { host: target.canonical },
3208
+ retryable: true,
3209
+ cause: error
3210
+ });
3211
+ }
3212
+ options.onProgress?.("\u8FDC\u7AEF\u5B89\u88C5\u5B8C\u6210\uFF0C\u7B49\u5F85 DSH web \u542F\u52A8\u2026");
3213
+ await this.#waitForAgent(target, options.onProgress, options.signal);
3214
+ }
3215
+ async #waitForAgent(target, onProgress, signal) {
3216
+ const deadline = Date.now() + 12e4;
3217
+ let lastError;
3218
+ while (Date.now() < deadline) {
3219
+ if (signal?.aborted === true) throw new LocalError("internal", "\u8FDC\u7AEF\u5B89\u88C5\u5DF2\u53D6\u6D88");
3220
+ try {
3221
+ const discovered = await discoverAgent(target, {
3222
+ sshBinary: this.#config.sshBinary,
3223
+ timeoutMs: this.#config.probeTimeoutMs,
3224
+ strictHostKeyChecking: this.#config.strictHostKeyChecking,
3225
+ remoteStateFile: this.#config.remoteStateFile
3226
+ });
3227
+ if (isAgentLive(discovered.state)) return;
3228
+ } catch (error) {
3229
+ lastError = error;
3230
+ onProgress?.("\u7B49\u5F85\u8FDC\u7AEF dsh web \u5C31\u7EEA\u2026");
3231
+ }
3232
+ await new Promise((resolve4) => setTimeout(resolve4, 2e3));
3233
+ }
3234
+ throw new LocalError("agent/unavailable", "\u8FDC\u7AEF DSH web \u672A\u80FD\u5728 120 \u79D2\u5185\u542F\u52A8", {
3235
+ details: { host: target.canonical, reason: lastError instanceof Error ? lastError.message : String(lastError) },
3236
+ retryable: true
3237
+ });
3238
+ }
3239
+ };
3240
+ function findAgentTarball() {
3241
+ const candidates = [
3242
+ new URL("../assets", import.meta.url),
3243
+ new URL("../../assets", import.meta.url)
3244
+ ];
3245
+ for (const candidate of candidates) {
3246
+ const dir = fileURLToPath2(candidate);
3247
+ if (!existsSync(dir)) continue;
3248
+ const tarball = readdirSync(dir).find((name2) => name2.endsWith(".tgz"));
3249
+ if (tarball !== void 0) return join6(dir, tarball);
3250
+ }
3251
+ throw new LocalError(
3252
+ "internal",
3253
+ "\u672C\u5730\u63D2\u4EF6\u6CA1\u6709\u5185\u7F6E dsh-remote-agent \u5B89\u88C5\u5305\uFF1B\u8BF7\u91CD\u65B0\u6267\u884C pnpm build \u751F\u6210 assets"
3254
+ );
3255
+ }
3256
+
3257
+ // src/ssh/target.ts
3258
+ var USER_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/u;
3259
+ var HOST_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,252}[A-Za-z0-9])?$/u;
3260
+ function parseSshTarget(raw) {
3261
+ const input = raw.trim();
3262
+ if (input === "") {
3263
+ throw new LocalError("host/invalid", "SSH target must not be empty");
3264
+ }
3265
+ if (/[\s\u0000-\u001f]/u.test(input)) {
3266
+ throw new LocalError("host/invalid", "SSH target must not contain whitespace or control characters");
3267
+ }
3268
+ if (input.startsWith("-")) {
3269
+ throw new LocalError("host/invalid", 'SSH target must not start with "-"');
3270
+ }
3271
+ let user = null;
3272
+ let hostAndPort = input;
3273
+ if (input.startsWith("ssh://")) {
3274
+ let url;
3275
+ try {
3276
+ url = new URL(input);
3277
+ } catch (error) {
3278
+ throw new LocalError("host/invalid", `invalid ssh URL: ${input}`, { cause: error });
3279
+ }
3280
+ if (url.protocol !== "ssh:") {
3281
+ throw new LocalError("host/invalid", `unsupported SSH URL scheme: ${url.protocol}`);
3282
+ }
3283
+ if (url.pathname !== "" && url.pathname !== "/") {
3284
+ throw new LocalError("host/invalid", "ssh URL must not contain a path");
3285
+ }
3286
+ if (url.username === "" && url.password !== "") {
3287
+ throw new LocalError("host/invalid", "ssh URL password is not supported; use keys or ssh-agent");
3288
+ }
3289
+ user = url.username === "" ? null : decodeURIComponent(url.username);
3290
+ if (url.hostname === "") {
3291
+ throw new LocalError("host/invalid", "ssh URL has no hostname");
3292
+ }
3293
+ const port2 = url.port === "" ? null : Number(url.port);
3294
+ return validate2({ raw: input, user, host: url.hostname, port: port2 });
3295
+ }
3296
+ const at = input.indexOf("@");
3297
+ if (at >= 0) {
3298
+ user = input.slice(0, at);
3299
+ hostAndPort = input.slice(at + 1);
3300
+ if (user === "") {
3301
+ throw new LocalError("host/invalid", "SSH target has an empty user");
3302
+ }
3303
+ }
3304
+ let host = hostAndPort;
3305
+ let port = null;
3306
+ const colon = hostAndPort.lastIndexOf(":");
3307
+ if (colon >= 0) {
3308
+ const portText = hostAndPort.slice(colon + 1);
3309
+ host = hostAndPort.slice(0, colon);
3310
+ if (!/^\d{1,5}$/u.test(portText)) {
3311
+ throw new LocalError("host/invalid", `invalid SSH port: ${JSON.stringify(portText)}`);
3312
+ }
3313
+ port = Number(portText);
3314
+ }
3315
+ return validate2({ raw: input, user, host, port });
3316
+ }
3317
+ function validate2(input) {
3318
+ if (input.user !== null && !USER_PATTERN.test(input.user)) {
3319
+ throw new LocalError("host/invalid", `invalid SSH user: ${JSON.stringify(input.user)}`);
3320
+ }
3321
+ if (!HOST_PATTERN.test(input.host)) {
3322
+ throw new LocalError("host/invalid", `unsupported SSH host: ${JSON.stringify(input.host)}`);
3323
+ }
3324
+ if (input.port !== null && (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535)) {
3325
+ throw new LocalError("host/invalid", `SSH port must be between 1 and 65535, got ${String(input.port)}`);
3326
+ }
3327
+ const destination = input.user === null ? input.host : `${input.user}@${input.host}`;
3328
+ const canonical = input.port === null ? destination : `${destination}:${input.port}`;
3329
+ return {
3330
+ raw: input.raw,
3331
+ user: input.user,
3332
+ host: input.host,
3333
+ port: input.port,
3334
+ destination,
3335
+ canonical
3336
+ };
3337
+ }
3338
+ function validateHostLabel(label) {
3339
+ const value = label.trim();
3340
+ if (value === "") throw new LocalError("host/invalid", "host label must not be empty");
3341
+ if (value.length > 120) throw new LocalError("host/invalid", "host label must be at most 120 characters");
3342
+ if (/[\u0000-\u001f]/u.test(value)) throw new LocalError("host/invalid", "host label must not contain control characters");
3343
+ return value;
3344
+ }
3345
+
3346
+ // src/ssh/tunnel.ts
3347
+ import { spawn as spawn5 } from "node:child_process";
3348
+ import { createServer, connect } from "node:net";
3349
+ function tunnelArgs(options, localPort) {
3350
+ if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) {
3351
+ throw new LocalError("host/invalid", `remote agent port is invalid: ${String(options.remotePort)}`);
3352
+ }
3353
+ return [
3354
+ ...baseSshArgs(options.target, {
3355
+ timeoutMs: options.connectTimeoutMs,
3356
+ strictHostKeyChecking: options.strictHostKeyChecking
3357
+ }),
3358
+ "-N",
3359
+ "-o",
3360
+ "ExitOnForwardFailure=yes",
3361
+ "-L",
3362
+ `127.0.0.1:${localPort}:127.0.0.1:${options.remotePort}`,
3363
+ ...options.reverseForward === void 0 ? [] : [
3364
+ "-R",
3365
+ `127.0.0.1:${options.reverseForward.remotePort}:127.0.0.1:${options.reverseForward.localPort}`
3366
+ ],
3367
+ options.target.destination
3368
+ ];
3369
+ }
3370
+ async function allocateLocalPort() {
3371
+ return new Promise((resolve4, reject) => {
3372
+ const server = createServer();
3373
+ server.once("error", reject);
3374
+ server.listen(0, "127.0.0.1", () => {
3375
+ const address = server.address();
3376
+ if (address === null || typeof address === "string") {
3377
+ server.close();
3378
+ reject(new Error("could not allocate a loopback port"));
3379
+ return;
3380
+ }
3381
+ const port = address.port;
3382
+ server.close((error) => error === void 0 ? resolve4(port) : reject(error));
3383
+ });
3384
+ });
3385
+ }
3386
+ async function waitForPort(port, timeoutMs) {
3387
+ const deadline = Date.now() + timeoutMs;
3388
+ for (; ; ) {
3389
+ const connected = await tryConnect(port);
3390
+ if (connected) return;
3391
+ if (Date.now() >= deadline) throw new Error(`timed out waiting for 127.0.0.1:${port}`);
3392
+ await new Promise((resolve4) => setTimeout(resolve4, 80));
3393
+ }
3394
+ }
3395
+ function tryConnect(port) {
3396
+ return new Promise((resolve4) => {
3397
+ const socket = connect({ host: "127.0.0.1", port });
3398
+ const finish = (value) => {
3399
+ socket.removeAllListeners();
3400
+ socket.destroy();
3401
+ resolve4(value);
3402
+ };
3403
+ socket.once("connect", () => finish(true));
3404
+ socket.once("error", () => finish(false));
3405
+ socket.setTimeout(500, () => finish(false));
3406
+ });
3407
+ }
3408
+ var Tunnel = class _Tunnel {
3409
+ localPort;
3410
+ localUrl;
3411
+ #options;
3412
+ #child;
3413
+ #stderr = [];
3414
+ #stderrBytes = 0;
3415
+ #stopping = false;
3416
+ #exited = false;
3417
+ constructor(options, localPort, child) {
3418
+ this.#options = options;
3419
+ this.localPort = localPort;
3420
+ this.localUrl = `http://127.0.0.1:${localPort}`;
3421
+ this.#child = child;
3422
+ child.stderr?.on("data", (chunk) => {
3423
+ this.#stderr.push(chunk);
3424
+ this.#stderrBytes += chunk.length;
3425
+ while (this.#stderrBytes > 8 * 1024 && this.#stderr.length > 1) {
3426
+ const removed = this.#stderr.shift();
3427
+ this.#stderrBytes -= removed?.length ?? 0;
3428
+ }
3429
+ });
3430
+ child.once("exit", (code, signal) => {
3431
+ this.#exited = true;
3432
+ if (this.#stopping) return;
3433
+ options.onExit?.({ code, signal, stderr: this.stderr });
3434
+ });
3435
+ }
3436
+ /** Start a tunnel, retrying port allocation if the first bind races. */
3437
+ static async start(options, attempts = 3) {
3438
+ let lastError;
3439
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
3440
+ const localPort = await allocateLocalPort();
3441
+ const child = spawn5(options.sshBinary, tunnelArgs(options, localPort), {
3442
+ stdio: ["ignore", "ignore", "pipe"],
3443
+ windowsHide: true
3444
+ });
3445
+ const tunnel = new _Tunnel(options, localPort, child);
3446
+ try {
3447
+ await tunnel.#awaitReady();
3448
+ return tunnel;
3449
+ } catch (error) {
3450
+ lastError = error;
3451
+ await tunnel.stop();
3452
+ }
3453
+ }
3454
+ throw lastError instanceof LocalError ? lastError : new LocalError("ssh/tunnel-failed", `could not establish SSH tunnel to ${options.target.canonical}`, {
3455
+ cause: lastError,
3456
+ retryable: true
3457
+ });
3458
+ }
3459
+ /** Child process id. */
3460
+ get pid() {
3461
+ return this.#child.pid;
3462
+ }
3463
+ /** Captured stderr tail. */
3464
+ get stderr() {
3465
+ return Buffer.concat(this.#stderr).toString("utf8").trim();
3466
+ }
3467
+ /** Stop the tunnel; idempotent. */
3468
+ async stop() {
3469
+ if (this.#stopping) return;
3470
+ this.#stopping = true;
3471
+ if (this.#exited) return;
3472
+ this.#child.kill("SIGTERM");
3473
+ const exited = await waitForExit(this.#child, 2e3);
3474
+ if (!exited) {
3475
+ this.#child.kill("SIGKILL");
3476
+ await waitForExit(this.#child, 1e3);
3477
+ }
3478
+ }
3479
+ async #awaitReady() {
3480
+ const exited = new Promise((_, reject) => {
3481
+ this.#child.once("error", (error) => {
3482
+ reject(new LocalError("ssh/tunnel-failed", `SSH tunnel failed to spawn: ${String(error)}`, { cause: error }));
3483
+ });
3484
+ this.#child.once("exit", (code, signal) => {
3485
+ if (this.#stopping) return;
3486
+ const detail = this.stderr;
3487
+ reject(new LocalError("ssh/tunnel-failed", [
3488
+ `SSH tunnel exited before it was ready (code ${String(code)}, signal ${String(signal)})`,
3489
+ detail === "" ? "" : `: ${detail.split("\n")[0]}`
3490
+ ].join("")));
3491
+ });
3492
+ });
3493
+ await Promise.race([
3494
+ waitForPort(this.localPort, this.#options.connectTimeoutMs),
3495
+ exited
3496
+ ]);
3497
+ if (this.#exited) {
3498
+ throw new LocalError("ssh/tunnel-failed", `SSH tunnel exited immediately: ${this.stderr.split("\n")[0] ?? "unknown error"}`);
3499
+ }
3500
+ }
3501
+ };
3502
+ function waitForExit(child, timeoutMs) {
3503
+ return new Promise((resolve4) => {
3504
+ if (child.exitCode !== null || child.signalCode !== null) {
3505
+ resolve4(true);
3506
+ return;
3507
+ }
3508
+ const timer = setTimeout(() => resolve4(false), timeoutMs);
3509
+ timer.unref?.();
3510
+ child.once("exit", () => {
3511
+ clearTimeout(timer);
3512
+ resolve4(true);
3513
+ });
3514
+ });
3515
+ }
3516
+
3517
+ // src/service.ts
3518
+ function fingerprintKey(value) {
3519
+ return createHash3("sha256").update(value).digest("hex");
3520
+ }
3521
+ var RemoteHostsService = class {
3522
+ ctx;
3523
+ config;
3524
+ #store;
3525
+ #provisioner;
3526
+ #resolveModelKey;
3527
+ #proxy;
3528
+ #proxyAddress;
3529
+ #directModel;
3530
+ #directModelAttempted = false;
3531
+ #modelReconciled = /* @__PURE__ */ new Set();
3532
+ #records = /* @__PURE__ */ new Map();
3533
+ #runtime = /* @__PURE__ */ new Map();
3534
+ #tunnels = /* @__PURE__ */ new Map();
3535
+ #clients = /* @__PURE__ */ new Map();
3536
+ #connectPromises = /* @__PURE__ */ new Map();
3537
+ #healthTimers = /* @__PURE__ */ new Map();
3538
+ #reconnectTimers = /* @__PURE__ */ new Map();
3539
+ #healthInFlight = /* @__PURE__ */ new Set();
3540
+ #attempts = /* @__PURE__ */ new Map();
3541
+ #manualDisconnect = /* @__PURE__ */ new Set();
3542
+ #disposed = false;
3543
+ constructor(ctx, config, options = {}) {
3544
+ this.ctx = ctx;
3545
+ this.config = config;
3546
+ this.#store = new HostStore(`${config.stateDir}/hosts.json`);
3547
+ this.#provisioner = new RemoteProvisioner(config);
3548
+ this.#resolveModelKey = options.resolveModelKey;
3549
+ }
3550
+ /** Load the registry and auto-connect opted-in hosts. */
3551
+ async start() {
3552
+ const loaded = await this.#store.load();
3553
+ for (const warning of loaded.warnings) this.ctx.logger.warn("dsh-remote: %s", warning);
3554
+ for (const record of loaded.hosts) {
3555
+ this.#records.set(record.id, record);
3556
+ this.#runtime.set(record.id, idleRuntime());
3557
+ }
3558
+ for (const record of loaded.hosts) {
3559
+ if (!record.autoConnect) continue;
3560
+ this.#manualDisconnect.delete(record.id);
3561
+ this.#setRuntime(record.id, { phase: "idle" });
3562
+ void this.connect(record.id).catch((error) => {
3563
+ this.ctx.logger.warn("dsh-remote: auto-connect %s failed: %s", record.id, String(error));
3564
+ });
3565
+ }
3566
+ }
3567
+ /** Browser/tool snapshot. */
3568
+ state() {
3569
+ return { protocolVersion: PROTOCOL_VERSION, hosts: this.list() };
3570
+ }
3571
+ /** All hosts in creation order. */
3572
+ list() {
3573
+ return [...this.#records.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt)).map((record) => this.view(record.id));
3574
+ }
3575
+ /** One host view. */
3576
+ view(id) {
3577
+ const record = this.#requireRecord(id);
3578
+ return toHostView(record, this.#runtime.get(id) ?? idleRuntime());
3579
+ }
3580
+ /** Parse a destination without touching SSH. */
3581
+ validateTarget(raw) {
3582
+ const target = parseSshTarget(raw);
3583
+ return { canonical: target.canonical, host: target.host, user: target.user, port: target.port };
3584
+ }
3585
+ /** Register a host and (by default) start connecting in the background. */
3586
+ async add(input) {
3587
+ this.#assertAlive();
3588
+ const target = parseSshTarget(input.sshTarget);
3589
+ if (this.#records.has(hostIdFor(target.canonical, input.label))) {
3590
+ throw new LocalError("host/duplicate", `host ${target.canonical} already exists`);
3591
+ }
3592
+ if ([...this.#records.values()].some((record2) => record2.sshTarget === target.canonical)) {
3593
+ throw new LocalError("host/duplicate", `host ${target.canonical} already exists`);
3594
+ }
3595
+ if (input.token !== void 0 && input.token.trim() !== "" && input.token.trim().length < 16) {
3596
+ throw new LocalError("host/invalid", "agent token must be at least 16 characters");
3597
+ }
3598
+ if (input.remoteAgentPort !== void 0 && (!Number.isInteger(input.remoteAgentPort) || input.remoteAgentPort < 1 || input.remoteAgentPort > 65535)) {
3599
+ throw new LocalError("host/invalid", "remote agent port must be between 1 and 65535");
3600
+ }
3601
+ const label = validateHostLabel(input.label ?? target.host);
3602
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3603
+ const record = {
3604
+ id: hostIdFor(target.canonical, label),
3605
+ label,
3606
+ sshTarget: target.canonical,
3607
+ sshPort: target.port,
3608
+ remoteAgentPort: input.remoteAgentPort ?? null,
3609
+ token: input.token === void 0 || input.token.trim() === "" ? null : input.token.trim(),
3610
+ autoConnect: input.autoConnect ?? true,
3611
+ createdAt: now,
3612
+ updatedAt: now
3613
+ };
3614
+ await this.#store.add(record);
3615
+ this.#records.set(record.id, record);
3616
+ this.#runtime.set(record.id, idleRuntime());
3617
+ this.#manualDisconnect.delete(record.id);
3618
+ if (record.autoConnect) {
3619
+ this.#setRuntime(record.id, { phase: "discovering" });
3620
+ void this.connect(record.id).catch((error) => {
3621
+ this.ctx.logger.warn("dsh-remote: connect %s failed: %s", record.id, String(error));
3622
+ });
3623
+ }
3624
+ return this.view(record.id);
3625
+ }
3626
+ /** Remove a host and tear down its tunnel. */
3627
+ async remove(id) {
3628
+ this.#assertAlive();
3629
+ this.#requireRecord(id);
3630
+ this.#manualDisconnect.add(id);
3631
+ this.#clearReconnect(id);
3632
+ await this.#stopTransport(id);
3633
+ await this.#store.remove(id);
3634
+ this.#records.delete(id);
3635
+ this.#runtime.delete(id);
3636
+ this.#attempts.delete(id);
3637
+ this.#manualDisconnect.delete(id);
3638
+ }
3639
+ /** Connect one host, waiting for discovery, tunnel, and health. */
3640
+ async connect(id) {
3641
+ this.#assertAlive();
3642
+ this.#requireRecord(id);
3643
+ const existing = this.#connectPromises.get(id);
3644
+ if (existing !== void 0) return existing;
3645
+ const promise = this.#doConnect(id).finally(() => {
3646
+ this.#connectPromises.delete(id);
3647
+ });
3648
+ this.#connectPromises.set(id, promise);
3649
+ return promise;
3650
+ }
3651
+ /** Disconnect one host and suppress automatic reconnection. */
3652
+ async disconnect(id) {
3653
+ this.#assertAlive();
3654
+ this.#requireRecord(id);
3655
+ this.#manualDisconnect.add(id);
3656
+ this.#clearReconnect(id);
3657
+ await this.#stopTransport(id);
3658
+ this.#attempts.delete(id);
3659
+ this.#setRuntime(id, {
3660
+ phase: "disconnected",
3661
+ localPort: null,
3662
+ localUrl: null,
3663
+ hostInfo: null,
3664
+ workspaceCount: null,
3665
+ error: null,
3666
+ errorCode: null,
3667
+ connectedAt: null
3668
+ });
3669
+ return this.view(id);
3670
+ }
3671
+ /** Re-discover the remote agent; reconnect when its port or token changed. */
3672
+ async refresh(id) {
3673
+ this.#assertAlive();
3674
+ const record = this.#requireRecord(id);
3675
+ const connected = this.#connected(id);
3676
+ if (connected === void 0) return this.connect(id);
3677
+ const target = parseSshTarget(record.sshTarget);
3678
+ const discovery = await this.#discover(target);
3679
+ if (discovery.state.port !== record.remoteAgentPort || discovery.state.token !== record.token) {
3680
+ await this.#saveRecord(id, {
3681
+ remoteAgentPort: discovery.state.port,
3682
+ token: discovery.state.token
3683
+ });
3684
+ return this.connect(id);
3685
+ }
3686
+ const health = await connected.client.health();
3687
+ this.#setConnected(id, health);
3688
+ return this.view(id);
3689
+ }
3690
+ /** Force a fresh remote install/restart, then reconnect. */
3691
+ async provision(id) {
3692
+ this.#assertAlive();
3693
+ const record = this.#requireRecord(id);
3694
+ const target = parseSshTarget(record.sshTarget);
3695
+ this.#manualDisconnect.delete(id);
3696
+ this.#clearReconnect(id);
3697
+ await this.#stopTransport(id);
3698
+ const proxy = await this.#ensureModelProxy();
3699
+ const proxyToken = proxy === void 0 ? void 0 : await this.#ensureProxyToken(id);
3700
+ const direct = proxy === void 0 ? await this.#ensureDirectModel() : void 0;
3701
+ this.#setRuntime(id, {
3702
+ ...idleRuntime(),
3703
+ phase: "installing",
3704
+ modelProxy: proxy !== void 0,
3705
+ modelDirect: direct !== void 0,
3706
+ detail: "\u6B63\u5728\u91CD\u65B0\u5B89\u88C5\u8FDC\u7AEF agent\u2026"
3707
+ });
3708
+ try {
3709
+ await this.#provisioner.ensureInstalled(target, {
3710
+ ...this.#modelEnvironment(proxy, proxyToken, direct),
3711
+ onProgress: (detail) => this.#setRuntime(id, { ...this.#runtimeFor(id), detail })
3712
+ });
3713
+ } catch (error) {
3714
+ const failure = this.#markError(id, error, "agent/unavailable");
3715
+ this.#scheduleReconnect(id);
3716
+ throw failure;
3717
+ }
3718
+ return this.connect(id);
3719
+ }
3720
+ /** List remote workspaces. */
3721
+ async workspaces(id) {
3722
+ const client = this.#requireClient(id);
3723
+ try {
3724
+ const workspaces = await client.listWorkspaces();
3725
+ this.#setRuntime(id, { workspaceCount: workspaces.length, error: null, errorCode: null });
3726
+ return workspaces;
3727
+ } catch (error) {
3728
+ throw this.#remoteFailure(id, error);
3729
+ }
3730
+ }
3731
+ /** List remote sessions, optionally within one workspace. */
3732
+ async sessions(id, workspaceId) {
3733
+ const client = this.#requireClient(id);
3734
+ try {
3735
+ const sessions = await client.listSessions(workspaceId);
3736
+ return { sessions };
3737
+ } catch (error) {
3738
+ throw this.#remoteFailure(id, error);
3739
+ }
3740
+ }
3741
+ /** Read one session snapshot. */
3742
+ async snapshot(id, sessionId, limit) {
3743
+ const client = this.#requireClient(id);
3744
+ try {
3745
+ return await client.snapshotSession(sessionId, limit === void 0 ? {} : { limit });
3746
+ } catch (error) {
3747
+ throw this.#remoteFailure(id, error);
3748
+ }
3749
+ }
3750
+ /** Register a remote workspace. */
3751
+ async createWorkspace(id, request) {
3752
+ const client = this.#requireClient(id);
3753
+ try {
3754
+ const workspace = await client.createWorkspace(request);
3755
+ this.#setRuntime(id, { workspaceCount: (this.#runtimeFor(id).workspaceCount ?? 0) + 1 });
3756
+ return workspace;
3757
+ } catch (error) {
3758
+ throw this.#remoteFailure(id, error);
3759
+ }
3760
+ }
3761
+ /** Create a remote session. */
3762
+ async createSession(id, request) {
3763
+ const client = this.#requireClient(id);
3764
+ try {
3765
+ return await client.createSession(request);
3766
+ } catch (error) {
3767
+ throw this.#remoteFailure(id, error);
3768
+ }
3769
+ }
3770
+ /** Send a prompt to a remote session. */
3771
+ async promptSession(id, sessionId, text, mode, selection) {
3772
+ const client = this.#requireClient(id);
3773
+ try {
3774
+ await client.promptSession(sessionId, {
3775
+ text,
3776
+ ...mode === void 0 ? {} : { mode },
3777
+ ...selection === void 0 ? {} : selection
3778
+ });
3779
+ return { accepted: true };
3780
+ } catch (error) {
3781
+ throw this.#remoteFailure(id, error);
3782
+ }
3783
+ }
3784
+ /** Cancel a remote session's active turn. */
3785
+ async cancelSession(id, sessionId) {
3786
+ const client = this.#requireClient(id);
3787
+ try {
3788
+ await client.cancelSession(sessionId);
3789
+ return { accepted: true };
3790
+ } catch (error) {
3791
+ throw this.#remoteFailure(id, error);
3792
+ }
3793
+ }
3794
+ /** SSH invocation facts for auxiliary remote file/terminal adapters. */
3795
+ sshFor(id) {
3796
+ const record = this.#requireRecord(id);
3797
+ const target = parseSshTarget(record.sshTarget);
3798
+ return { binary: this.config.sshBinary, target: target.destination, port: target.port };
3799
+ }
3800
+ /** Follow remote approval questions for one host. */
3801
+ async *followApprovals(id, signal) {
3802
+ const client = this.#requireClient(id);
3803
+ try {
3804
+ yield* client.followApprovals(signal);
3805
+ } catch (error) {
3806
+ throw this.#remoteFailure(id, error);
3807
+ }
3808
+ }
3809
+ /** Send one approval answer to the remote agent. */
3810
+ async answerApproval(id, approvalId, outcome) {
3811
+ const client = this.#requireClient(id);
3812
+ try {
3813
+ return await client.answerApproval(approvalId, outcome);
3814
+ } catch (error) {
3815
+ throw this.#remoteFailure(id, error);
3816
+ }
3817
+ }
3818
+ /** Resolve the remote Web UI behind this host's tunnel. */
3819
+ async webUrl(id) {
3820
+ const client = this.#requireClient(id);
3821
+ const connected = this.#connected(id);
3822
+ if (connected === void 0) throw new LocalError("host/not-connected", `host ${id} is not connected`);
3823
+ let path;
3824
+ try {
3825
+ path = await client.webUrl();
3826
+ } catch (error) {
3827
+ throw this.#remoteFailure(id, error);
3828
+ }
3829
+ return { url: `${connected.tunnel.localUrl}${path.path}` };
3830
+ }
3831
+ /** Stop every tunnel and timer. */
3832
+ async dispose() {
3833
+ if (this.#disposed) return;
3834
+ this.#disposed = true;
3835
+ for (const id of [...this.#healthTimers.keys()]) this.#clearHealth(id);
3836
+ for (const id of [...this.#reconnectTimers.keys()]) this.#clearReconnect(id);
3837
+ await Promise.all([...this.#tunnels.keys()].map((id) => this.#stopTransport(id)));
3838
+ await this.#proxy?.stop();
3839
+ this.#proxy = void 0;
3840
+ this.#proxyAddress = void 0;
3841
+ this.#directModel = void 0;
3842
+ this.#directModelAttempted = false;
3843
+ this.#records.clear();
3844
+ this.#runtime.clear();
3845
+ this.#connectPromises.clear();
3846
+ }
3847
+ async #doConnect(id) {
3848
+ this.#assertAlive();
3849
+ const record = this.#requireRecord(id);
3850
+ this.#manualDisconnect.delete(id);
3851
+ this.#clearReconnect(id);
3852
+ await this.#stopTransport(id);
3853
+ this.#setRuntime(id, {
3854
+ ...idleRuntime(),
3855
+ phase: "discovering",
3856
+ detail: "\u6B63\u5728\u901A\u8FC7 SSH \u53D1\u73B0\u8FDC\u7AEF agent\u2026"
3857
+ });
3858
+ const target = parseSshTarget(record.sshTarget);
3859
+ const proxy = await this.#ensureModelProxy();
3860
+ const proxyToken = proxy === void 0 ? void 0 : await this.#ensureProxyToken(id);
3861
+ const direct = proxy === void 0 ? await this.#ensureDirectModel() : void 0;
3862
+ this.#setRuntime(id, {
3863
+ ...this.#runtimeFor(id),
3864
+ modelProxy: proxy !== void 0,
3865
+ modelDirect: direct !== void 0
3866
+ });
3867
+ const reverseForward = proxy === void 0 ? void 0 : { remotePort: this.config.credentialProxyRemotePort, localPort: proxy.port };
3868
+ let remotePort = record.remoteAgentPort;
3869
+ let token = record.token;
3870
+ try {
3871
+ const discovery = await this.#discover(target);
3872
+ remotePort = discovery.state.port;
3873
+ token = discovery.state.token;
3874
+ if (remotePort === null) {
3875
+ throw new LocalError("agent/unavailable", "remote agent has no active listener port");
3876
+ }
3877
+ await this.#saveRecord(id, { remoteAgentPort: remotePort, token });
3878
+ } catch (error) {
3879
+ const failure = asLocalError(error, "ssh/probe-failed");
3880
+ if (this.config.autoProvision && failure.code === "agent/unavailable") {
3881
+ this.#setRuntime(id, {
3882
+ ...this.#runtimeFor(id),
3883
+ phase: "installing",
3884
+ detail: "\u6B63\u5728\u8FDC\u7AEF\u5B89\u88C5 DSH \u4E0E dsh-remote-agent\uFF0C\u9996\u6B21\u7EA6 1\u20133 \u5206\u949F\u2026"
3885
+ });
3886
+ try {
3887
+ await this.#provisioner.ensureInstalled(target, {
3888
+ ...this.#modelEnvironment(proxy, proxyToken, direct),
3889
+ onProgress: (detail) => this.#setRuntime(id, { ...this.#runtimeFor(id), detail })
3890
+ });
3891
+ const discovery = await this.#discover(target);
3892
+ remotePort = discovery.state.port;
3893
+ token = discovery.state.token;
3894
+ await this.#saveRecord(id, { remoteAgentPort: remotePort, token });
3895
+ } catch (provisionError) {
3896
+ const provisionFailure = this.#markError(id, provisionError, "agent/unavailable");
3897
+ this.#scheduleReconnect(id);
3898
+ throw provisionFailure;
3899
+ }
3900
+ } else if (token !== null && remotePort !== null) {
3901
+ this.ctx.logger.warn(
3902
+ "dsh-remote: discovery for %s failed; using cached port %s: %s",
3903
+ id,
3904
+ String(remotePort),
3905
+ String(error)
3906
+ );
3907
+ } else {
3908
+ const marked = this.#markError(id, error, failure.code);
3909
+ this.#scheduleReconnect(id);
3910
+ throw marked;
3911
+ }
3912
+ }
3913
+ if (remotePort === null || token === null) {
3914
+ const failure = this.#markError(
3915
+ id,
3916
+ new LocalError("agent/unavailable", "no remote agent token or port is available"),
3917
+ "agent/unavailable"
3918
+ );
3919
+ this.#scheduleReconnect(id);
3920
+ throw failure;
3921
+ }
3922
+ this.#setRuntime(id, {
3923
+ ...this.#runtimeFor(id),
3924
+ phase: "connecting",
3925
+ detail: "\u6B63\u5728\u5EFA\u7ACB SSH \u96A7\u9053\u5E76\u6821\u9A8C\u8FDC\u7AEF agent\u2026"
3926
+ });
3927
+ let tunnel;
3928
+ try {
3929
+ tunnel = await Tunnel.start({
3930
+ sshBinary: this.config.sshBinary,
3931
+ target,
3932
+ remotePort,
3933
+ connectTimeoutMs: this.config.connectTimeoutMs,
3934
+ strictHostKeyChecking: this.config.strictHostKeyChecking,
3935
+ ...reverseForward === void 0 ? {} : { reverseForward },
3936
+ onExit: (info) => this.#onTunnelExit(id, info)
3937
+ });
3938
+ const client = new RemoteAgentClient({
3939
+ baseUrl: tunnel.localUrl,
3940
+ token,
3941
+ hostId: id
3942
+ });
3943
+ const health = await client.health();
3944
+ if (await this.#reconcileModelEnvironment(id, target, health, proxy, proxyToken, direct, tunnel)) {
3945
+ return this.#doConnect(id);
3946
+ }
3947
+ this.#tunnels.set(id, tunnel);
3948
+ this.#clients.set(id, client);
3949
+ this.#setConnected(id, health);
3950
+ this.#startHealthTimer(id);
3951
+ this.#attempts.delete(id);
3952
+ return this.view(id);
3953
+ } catch (error) {
3954
+ await tunnel?.stop();
3955
+ const failure = this.#markError(id, error, "host/connect-failed");
3956
+ this.#scheduleReconnect(id);
3957
+ throw failure;
3958
+ }
3959
+ }
3960
+ async #reconcileModelEnvironment(id, target, health, proxy, proxyToken, direct, tunnel) {
3961
+ const desired = proxy === void 0 ? direct?.baseUrl : `http://127.0.0.1:${this.config.credentialProxyRemotePort}`;
3962
+ if (desired === void 0) return false;
3963
+ const current = health.modelBaseUrl?.replace(/\/+$/u, "") ?? null;
3964
+ const expectedKey = proxy === void 0 ? direct?.apiKey : proxyToken;
3965
+ const matches = current === desired.replace(/\/+$/u, "") && expectedKey !== void 0 && health.modelKeyFingerprint === fingerprintKey(expectedKey);
3966
+ if (matches) {
3967
+ this.#modelReconciled.delete(id);
3968
+ return false;
3969
+ }
3970
+ if (this.#modelReconciled.has(id) || !this.config.autoProvision) {
3971
+ this.ctx.logger.warn(
3972
+ "dsh-remote: model environment for %s still differs from this profile (%s vs %s)",
3973
+ id,
3974
+ current ?? "unset",
3975
+ desired
3976
+ );
3977
+ return false;
3978
+ }
3979
+ this.#modelReconciled.add(id);
3980
+ this.ctx.logger.warn(
3981
+ "dsh-remote: remote model environment for %s is %s, expected %s; reinstalling",
3982
+ id,
3983
+ current ?? "unset",
3984
+ desired
3985
+ );
3986
+ await tunnel.stop();
3987
+ try {
3988
+ await this.#provisioner.ensureInstalled(target, {
3989
+ ...this.#modelEnvironment(proxy, proxyToken, direct),
3990
+ onProgress: (detail) => this.#setRuntime(id, { ...this.#runtimeFor(id), detail })
3991
+ });
3992
+ } catch (error) {
3993
+ this.#modelReconciled.delete(id);
3994
+ throw error;
3995
+ }
3996
+ return true;
3997
+ }
3998
+ async #ensureDirectModel() {
3999
+ if (this.config.credentialProxy || !this.config.copyModelKey) return void 0;
4000
+ if (this.#directModelAttempted) return this.#directModel;
4001
+ this.#directModelAttempted = true;
4002
+ let key2;
4003
+ try {
4004
+ key2 = await this.#resolveModelKey?.();
4005
+ } catch (error) {
4006
+ this.ctx.logger.warn("dsh-remote: could not resolve local model credential: %s", String(error));
4007
+ return void 0;
4008
+ }
4009
+ if (key2 === void 0 || key2.trim() === "") {
4010
+ this.ctx.logger.warn("dsh-remote: no local DEEPSEEK_API_KEY; remote host must use its own credentials");
4011
+ return void 0;
4012
+ }
4013
+ this.#directModel = { baseUrl: this.config.remoteModelBaseUrl, apiKey: key2 };
4014
+ return this.#directModel;
4015
+ }
4016
+ #modelEnvironment(proxy, proxyToken, direct) {
4017
+ if (proxy !== void 0) {
4018
+ if (proxyToken === void 0) throw new LocalError("internal", "credential proxy token is unavailable");
4019
+ return {
4020
+ modelBaseUrl: `http://127.0.0.1:${this.config.credentialProxyRemotePort}`,
4021
+ modelApiKey: proxyToken
4022
+ };
4023
+ }
4024
+ if (direct !== void 0) return { modelBaseUrl: direct.baseUrl, modelApiKey: direct.apiKey };
4025
+ return {};
4026
+ }
4027
+ async #ensureProxyToken(id) {
4028
+ const record = this.#requireRecord(id);
4029
+ if (typeof record.proxyToken === "string" && record.proxyToken.length >= 16) return record.proxyToken;
4030
+ const token = randomBytes(32).toString("hex");
4031
+ await this.#saveRecord(id, { proxyToken: token });
4032
+ return token;
4033
+ }
4034
+ #isProxyToken(token) {
4035
+ const presented = Buffer.from(token, "utf8");
4036
+ for (const record of this.#records.values()) {
4037
+ const expected = record.proxyToken;
4038
+ if (typeof expected !== "string") continue;
4039
+ const candidate = Buffer.from(expected, "utf8");
4040
+ if (candidate.length === presented.length && timingSafeEqual(candidate, presented)) return true;
4041
+ }
4042
+ return false;
4043
+ }
4044
+ async #ensureModelProxy() {
4045
+ if (!this.config.credentialProxy) return void 0;
4046
+ if (this.#proxyAddress !== void 0) return this.#proxyAddress;
4047
+ let key2;
4048
+ try {
4049
+ key2 = await this.#resolveModelKey?.();
4050
+ } catch (error) {
4051
+ this.ctx.logger.warn("dsh-remote: could not resolve local model credential: %s", String(error));
4052
+ return void 0;
4053
+ }
4054
+ if (key2 === void 0 || key2.trim() === "") {
4055
+ this.ctx.logger.warn("dsh-remote: no local DEEPSEEK_API_KEY; remote host must use its own credentials");
4056
+ return void 0;
4057
+ }
4058
+ try {
4059
+ const proxy = new ModelCredentialProxy({
4060
+ apiKey: key2,
4061
+ upstreamBaseUrl: this.config.credentialProxyUpstream,
4062
+ isTokenValid: (token) => this.#isProxyToken(token)
4063
+ });
4064
+ const address = await proxy.start();
4065
+ this.#proxy = proxy;
4066
+ this.#proxyAddress = address;
4067
+ return address;
4068
+ } catch (error) {
4069
+ this.ctx.logger.warn("dsh-remote: could not start credential proxy: %s", String(error));
4070
+ return void 0;
4071
+ }
4072
+ }
4073
+ async #discover(target) {
4074
+ return discoverAgent(target, {
4075
+ sshBinary: this.config.sshBinary,
4076
+ timeoutMs: this.config.probeTimeoutMs,
4077
+ strictHostKeyChecking: this.config.strictHostKeyChecking,
4078
+ remoteStateFile: this.config.remoteStateFile
4079
+ });
4080
+ }
4081
+ #onTunnelExit(id, info) {
4082
+ if (this.#disposed || this.#manualDisconnect.has(id)) return;
4083
+ this.#clients.delete(id);
4084
+ this.#tunnels.delete(id);
4085
+ this.#clearHealth(id);
4086
+ const detail = info.stderr === "" ? "" : `: ${info.stderr.split("\n")[0]}`;
4087
+ this.#setRuntime(id, {
4088
+ ...this.#runtimeFor(id),
4089
+ phase: "error",
4090
+ localPort: null,
4091
+ localUrl: null,
4092
+ error: `SSH tunnel exited (code ${String(info.code)}, signal ${String(info.signal)})${detail}`,
4093
+ errorCode: "ssh/tunnel-failed",
4094
+ detail: "SSH \u96A7\u9053\u65AD\u5F00\uFF0C\u7B49\u5F85\u91CD\u8FDE\u2026"
4095
+ });
4096
+ this.#scheduleReconnect(id);
4097
+ }
4098
+ #startHealthTimer(id) {
4099
+ this.#clearHealth(id);
4100
+ const timer = setInterval(() => {
4101
+ void this.#healthCheck(id);
4102
+ }, this.config.healthIntervalMs);
4103
+ timer.unref?.();
4104
+ this.#healthTimers.set(id, timer);
4105
+ }
4106
+ async #healthCheck(id) {
4107
+ if (this.#disposed || this.#healthInFlight.has(id)) return;
4108
+ const client = this.#clients.get(id);
4109
+ if (client === void 0) return;
4110
+ this.#healthInFlight.add(id);
4111
+ try {
4112
+ const health = await client.health();
4113
+ this.#setConnected(id, health);
4114
+ } catch (error) {
4115
+ this.ctx.logger.warn("dsh-remote: health check %s failed: %s", id, String(error));
4116
+ const connected = this.#connected(id);
4117
+ await connected?.tunnel.stop();
4118
+ this.#tunnels.delete(id);
4119
+ this.#clients.delete(id);
4120
+ this.#clearHealth(id);
4121
+ this.#markError(id, error, "agent/unavailable");
4122
+ this.#scheduleReconnect(id);
4123
+ } finally {
4124
+ this.#healthInFlight.delete(id);
4125
+ }
4126
+ }
4127
+ #scheduleReconnect(id) {
4128
+ if (this.#disposed || !this.config.reconnect || this.#manualDisconnect.has(id)) return;
4129
+ if (this.#reconnectTimers.has(id)) return;
4130
+ if (!this.#records.has(id)) return;
4131
+ const attempt = (this.#attempts.get(id) ?? 0) + 1;
4132
+ this.#attempts.set(id, attempt);
4133
+ const delay = Math.min(1e3 * 2 ** Math.min(attempt - 1, 10), this.config.maxReconnectDelayMs);
4134
+ this.ctx.logger.info("dsh-remote: reconnecting %s in %dms (attempt %d)", id, delay, attempt);
4135
+ const timer = setTimeout(() => {
4136
+ this.#reconnectTimers.delete(id);
4137
+ void this.connect(id).catch((error) => {
4138
+ this.ctx.logger.warn("dsh-remote: reconnect %s failed: %s", id, String(error));
4139
+ });
4140
+ }, delay);
4141
+ timer.unref?.();
4142
+ this.#reconnectTimers.set(id, timer);
4143
+ }
4144
+ #clearReconnect(id) {
4145
+ const timer = this.#reconnectTimers.get(id);
4146
+ if (timer !== void 0) clearTimeout(timer);
4147
+ this.#reconnectTimers.delete(id);
4148
+ }
4149
+ #clearHealth(id) {
4150
+ const timer = this.#healthTimers.get(id);
4151
+ if (timer !== void 0) clearInterval(timer);
4152
+ this.#healthTimers.delete(id);
4153
+ }
4154
+ async #stopTransport(id) {
4155
+ this.#clearHealth(id);
4156
+ const tunnel = this.#tunnels.get(id);
4157
+ this.#tunnels.delete(id);
4158
+ this.#clients.delete(id);
4159
+ await tunnel?.stop();
4160
+ }
4161
+ #requireRecord(id) {
4162
+ const record = this.#records.get(id);
4163
+ if (record === void 0) throw new LocalError("host/not-found", `host ${id} does not exist`);
4164
+ return record;
4165
+ }
4166
+ #connected(id) {
4167
+ const record = this.#records.get(id);
4168
+ const tunnel = this.#tunnels.get(id);
4169
+ const client = this.#clients.get(id);
4170
+ if (record === void 0 || tunnel === void 0 || client === void 0) return void 0;
4171
+ return { record, target: parseSshTarget(record.sshTarget), tunnel, client };
4172
+ }
4173
+ #requireClient(id) {
4174
+ const client = this.#clients.get(id);
4175
+ if (client === void 0 || this.#runtime.get(id)?.phase !== "connected") {
4176
+ throw new LocalError("host/not-connected", `host ${id} is not connected; connect it first`);
4177
+ }
4178
+ return client;
4179
+ }
4180
+ #runtimeFor(id) {
4181
+ return this.#runtime.get(id) ?? idleRuntime();
4182
+ }
4183
+ #setRuntime(id, patch) {
4184
+ this.#runtime.set(id, { ...this.#runtimeFor(id), ...patch });
4185
+ }
4186
+ #setConnected(id, health) {
4187
+ const existing = this.#connected(id);
4188
+ this.#setRuntime(id, {
4189
+ phase: "connected",
4190
+ localPort: existing?.tunnel.localPort ?? this.#runtimeFor(id).localPort,
4191
+ localUrl: existing?.tunnel.localUrl ?? this.#runtimeFor(id).localUrl,
4192
+ hostInfo: health,
4193
+ workspaceCount: health.workspaceCount,
4194
+ error: null,
4195
+ errorCode: null,
4196
+ connectedAt: this.#runtimeFor(id).connectedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
4197
+ detail: this.#runtimeFor(id).modelProxy ? "\u5DF2\u8FDE\u63A5\uFF1B\u6A21\u578B\u8BF7\u6C42\u7ECF\u672C\u5730\u51ED\u636E\u4EE3\u7406\u8F6C\u53D1" : this.#runtimeFor(id).modelDirect ? "\u5DF2\u8FDE\u63A5\uFF1B\u6A21\u578B\u8BF7\u6C42\u7531\u8FDC\u7AEF\u76F4\u8FDE DeepSeek" : "\u5DF2\u8FDE\u63A5\uFF1B\u8FDC\u7AEF\u4F7F\u7528\u81EA\u8EAB\u51ED\u636E\u6216\u672A\u914D\u7F6E\u6A21\u578B key"
4198
+ });
4199
+ }
4200
+ async #saveRecord(id, patch) {
4201
+ const record = this.#requireRecord(id);
4202
+ const next = { ...record, ...patch, id, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
4203
+ await this.#store.update(id, next);
4204
+ this.#records.set(id, next);
4205
+ }
4206
+ #markError(id, error, fallback) {
4207
+ const failure = asLocalError(error, fallback);
4208
+ this.#setRuntime(id, {
4209
+ ...this.#runtimeFor(id),
4210
+ phase: "error",
4211
+ localPort: null,
4212
+ localUrl: null,
4213
+ error: failure.message,
4214
+ errorCode: failure.code,
4215
+ detail: failure.message
4216
+ });
4217
+ return failure;
4218
+ }
4219
+ #remoteFailure(id, error) {
4220
+ if (error instanceof LocalError) return error;
4221
+ if (error instanceof RemoteProtocolError) {
4222
+ const failure2 = new LocalError("agent/error", `remote agent: ${error.message}`, {
4223
+ details: { remoteCode: error.code, ...error.details },
4224
+ retryable: error.retryable,
4225
+ cause: error
4226
+ });
4227
+ this.#setRuntime(id, { error: failure2.message, errorCode: failure2.code });
4228
+ return failure2;
4229
+ }
4230
+ const failure = new LocalError("agent/unavailable", "remote agent request failed", { cause: error });
4231
+ this.#setRuntime(id, { error: failure.message, errorCode: failure.code });
4232
+ return failure;
4233
+ }
4234
+ #assertAlive() {
4235
+ if (this.#disposed) throw new LocalError("internal", "remote host service is disposed");
4236
+ }
4237
+ };
4238
+
4239
+ // src/tools.ts
4240
+ import { defineTool } from "@deepseek-ai/dsh-tools";
4241
+ var renderJson = (_args, value) => [
4242
+ { type: "text", text: JSON.stringify(value, null, 2) }
4243
+ ];
4244
+ function toJsonObject(value) {
4245
+ return JSON.parse(JSON.stringify(value));
4246
+ }
4247
+ var HOST_WRITE_TOOLS = /* @__PURE__ */ new Set(["remote_host_add", "remote_host_connect", "remote_host_provision"]);
4248
+ function installRemoteToolGuards(ctx) {
4249
+ ctx.effect(() => ctx.on("tools/pre-execute", (exec, next) => {
4250
+ if (!HOST_WRITE_TOOLS.has(exec.name)) return next();
4251
+ return Promise.resolve({
4252
+ kind: "ask",
4253
+ reason: `remote host write: ${exec.name}`,
4254
+ displayReason: {
4255
+ en: "Allow this remote-host operation? It may open an SSH connection and copy the local model credential to the remote host.",
4256
+ zh: "\u5141\u8BB8\u8FD9\u6B21\u8FDC\u7A0B\u4E3B\u673A\u64CD\u4F5C\u5417\uFF1F\u5B83\u53EF\u80FD\u901A\u8FC7 SSH \u8FDE\u63A5\uFF0C\u5E76\u628A\u672C\u5730\u6A21\u578B\u51ED\u636E\u590D\u5236\u5230\u8FDC\u7AEF\u4E3B\u673A\u3002"
4257
+ }
4258
+ });
4259
+ }), "dsh-remote-local: host-write tool approvals");
4260
+ }
4261
+ function registerRemoteTools(ctx, service) {
4262
+ ctx.tools.register(defineTool({
4263
+ name: "remote_hosts_list",
4264
+ description: "List configured remote development hosts, their SSH destinations, connection phases, and remote host facts. Use this before connecting or listing remote workspaces.",
4265
+ parameters: {},
4266
+ output: {
4267
+ schema: {
4268
+ type: "object",
4269
+ additionalProperties: false,
4270
+ properties: {
4271
+ protocolVersion: { type: "number", required: true },
4272
+ hosts: { type: "array", required: true, items: { type: "object", additionalProperties: true } }
4273
+ }
4274
+ },
4275
+ render: renderJson
4276
+ },
4277
+ async execute() {
4278
+ const state = service.state();
4279
+ return { protocolVersion: state.protocolVersion, hosts: state.hosts.map(toJsonObject) };
4280
+ }
4281
+ }));
4282
+ ctx.tools.register(defineTool({
4283
+ name: "remote_host_add",
4284
+ description: "Register an SSH host for remote development. The local plugin discovers the remote agent over SSH and opens a loopback SSH tunnel. The token is optional when discovery succeeds; never paste a token into chat unless you control it.",
4285
+ parameters: {
4286
+ sshTarget: {
4287
+ type: "string",
4288
+ required: true,
4289
+ description: "SSH destination such as user@host, host:2222, or ssh://user@host:22."
4290
+ },
4291
+ label: { type: "string", description: "Display label; defaults to the hostname." },
4292
+ token: { type: "string", description: "Optional remote agent bearer token. Prefer discovery." },
4293
+ remoteAgentPort: { type: "number", description: "Optional remote agent port when discovery is unavailable." },
4294
+ autoConnect: { type: "boolean", description: "Connect immediately. Agent tool calls default to false and require approval." }
4295
+ },
4296
+ output: {
4297
+ schema: {
4298
+ type: "object",
4299
+ additionalProperties: false,
4300
+ properties: {
4301
+ host: { type: "object", additionalProperties: true, required: true }
4302
+ }
4303
+ },
4304
+ render: renderJson
4305
+ },
4306
+ async execute(args) {
4307
+ return { host: toJsonObject(await service.add({ ...args, autoConnect: args.autoConnect ?? false })) };
4308
+ }
4309
+ }));
4310
+ ctx.tools.register(defineTool({
4311
+ name: "remote_host_connect",
4312
+ description: "Connect, reconnect, or refresh one registered remote host. Waits for SSH discovery, the tunnel, and the remote agent health check to settle.",
4313
+ parameters: {
4314
+ hostId: { type: "string", required: true, description: "Host id returned by remote_hosts_list or remote_host_add." }
4315
+ },
4316
+ output: {
4317
+ schema: {
4318
+ type: "object",
4319
+ additionalProperties: false,
4320
+ properties: {
4321
+ host: { type: "object", additionalProperties: true, required: true }
4322
+ }
4323
+ },
4324
+ render: renderJson
4325
+ },
4326
+ async execute(args) {
4327
+ return { host: toJsonObject(await service.connect(args.hostId)) };
4328
+ }
4329
+ }));
4330
+ ctx.tools.register(defineTool({
4331
+ name: "remote_host_provision",
4332
+ description: "Force a fresh remote installation/restart of dsh-remote-agent and dsh web, then reconnect. Use this to repair a broken host or update an old agent.",
4333
+ parameters: {
4334
+ hostId: { type: "string", required: true, description: "Registered host id." }
4335
+ },
4336
+ output: {
4337
+ schema: {
4338
+ type: "object",
4339
+ additionalProperties: false,
4340
+ properties: { host: { type: "object", additionalProperties: true, required: true } }
4341
+ },
4342
+ render: renderJson
4343
+ },
4344
+ async execute(args) {
4345
+ return { host: toJsonObject(await service.provision(args.hostId)) };
4346
+ }
4347
+ }));
4348
+ ctx.tools.register(defineTool({
4349
+ name: "remote_workspaces_list",
4350
+ description: "List workspaces registered on a connected remote DSH host. Use the returned workspaceId with remote_sessions_list.",
4351
+ parameters: {
4352
+ hostId: { type: "string", required: true, description: "Connected remote host id." }
4353
+ },
4354
+ output: {
4355
+ schema: {
4356
+ type: "object",
4357
+ additionalProperties: false,
4358
+ properties: {
4359
+ workspaces: { type: "array", required: true, items: { type: "object", additionalProperties: true } }
4360
+ }
4361
+ },
4362
+ render: renderJson
4363
+ },
4364
+ async execute(args) {
4365
+ return { workspaces: (await service.workspaces(args.hostId)).map(toJsonObject) };
4366
+ }
4367
+ }));
4368
+ ctx.tools.register(defineTool({
4369
+ name: "remote_workspace_add",
4370
+ description: "Register an existing remote directory as a workspace on a connected remote DSH host. Then create a session in it with remote_session_create.",
4371
+ parameters: {
4372
+ hostId: { type: "string", required: true, description: "Connected remote host id." },
4373
+ path: { type: "string", required: true, description: "Absolute remote directory path." },
4374
+ title: { type: "string", description: "Optional display title." }
4375
+ },
4376
+ output: {
4377
+ schema: {
4378
+ type: "object",
4379
+ additionalProperties: false,
4380
+ properties: {
4381
+ workspace: { type: "object", additionalProperties: true, required: true }
4382
+ }
4383
+ },
4384
+ render: renderJson
4385
+ },
4386
+ async execute(args) {
4387
+ return { workspace: toJsonObject(await service.createWorkspace(args.hostId, {
4388
+ path: args.path,
4389
+ ...args.title === void 0 ? {} : { title: args.title }
4390
+ })) };
4391
+ }
4392
+ }));
4393
+ ctx.tools.register(defineTool({
4394
+ name: "remote_sessions_list",
4395
+ description: "List sessions on a connected remote DSH host, optionally scoped to one workspace. Includes live/persisted state and event counts when available.",
4396
+ parameters: {
4397
+ hostId: { type: "string", required: true, description: "Connected remote host id." },
4398
+ workspaceId: { type: "string", description: "Optional remote workspace id." }
4399
+ },
4400
+ output: {
4401
+ schema: {
4402
+ type: "object",
4403
+ additionalProperties: false,
4404
+ properties: {
4405
+ sessions: { type: "array", required: true, items: { type: "object", additionalProperties: true } }
4406
+ }
4407
+ },
4408
+ render: renderJson
4409
+ },
4410
+ async execute(args) {
4411
+ const { sessions } = await service.sessions(args.hostId, args.workspaceId);
4412
+ return { sessions: sessions.map(toJsonObject) };
4413
+ }
4414
+ }));
4415
+ ctx.tools.register(defineTool({
4416
+ name: "remote_session_snapshot",
4417
+ description: "Read the newest page of one remote session event log. This is a read-only projection; no tool executes on the remote host.",
4418
+ parameters: {
4419
+ hostId: { type: "string", required: true, description: "Connected remote host id." },
4420
+ sessionId: { type: "string", required: true, description: "Remote session id." },
4421
+ limit: { type: "number", description: "Maximum events to return (default 500, capped by the agent)." }
4422
+ },
4423
+ output: {
4424
+ schema: {
4425
+ type: "object",
4426
+ additionalProperties: false,
4427
+ properties: {
4428
+ snapshot: { type: "object", additionalProperties: true, required: true }
4429
+ }
4430
+ },
4431
+ render: renderJson
4432
+ },
4433
+ async execute(args) {
4434
+ return { snapshot: toJsonObject(await service.snapshot(args.hostId, args.sessionId, args.limit)) };
4435
+ }
4436
+ }));
4437
+ ctx.tools.register(defineTool({
4438
+ name: "remote_session_create",
4439
+ description: "Create a session on a connected remote DSH host, either in an existing workspace or an explicit remote directory. This is a write operation on the remote host.",
4440
+ parameters: {
4441
+ hostId: { type: "string", required: true, description: "Connected remote host id." },
4442
+ workspaceId: { type: "string", description: "Existing remote workspace id." },
4443
+ cwd: { type: "string", description: "Remote directory for an ungrouped session; exclusive with workspaceId." },
4444
+ agentPreset: { type: "string", description: "Optional remote agent preset." }
4445
+ },
4446
+ output: {
4447
+ schema: {
4448
+ type: "object",
4449
+ additionalProperties: false,
4450
+ properties: {
4451
+ sessionId: { type: "string", required: true },
4452
+ agentPreset: { type: "string" }
4453
+ }
4454
+ },
4455
+ render: renderJson
4456
+ },
4457
+ async execute(args) {
4458
+ return service.createSession(args.hostId, {
4459
+ ...args.workspaceId === void 0 ? {} : { workspaceId: args.workspaceId },
4460
+ ...args.cwd === void 0 ? {} : { cwd: args.cwd },
4461
+ ...args.agentPreset === void 0 ? {} : { agentPreset: args.agentPreset }
4462
+ });
4463
+ }
4464
+ }));
4465
+ ctx.tools.register(defineTool({
4466
+ name: "remote_session_prompt",
4467
+ description: "Send a user prompt to a remote DSH session. The remote model runs on the remote host; the local model key can be proxied through the local credential proxy. This is a write operation.",
4468
+ parameters: {
4469
+ hostId: { type: "string", required: true, description: "Connected remote host id." },
4470
+ sessionId: { type: "string", required: true, description: "Remote session id." },
4471
+ text: { type: "string", required: true, description: "Prompt text." },
4472
+ mode: { type: "string", enum: ["queue", "steer"], description: "Delivery mode (default queue)." }
4473
+ },
4474
+ output: {
4475
+ schema: {
4476
+ type: "object",
4477
+ additionalProperties: false,
4478
+ properties: { accepted: { type: "boolean", required: true } }
4479
+ },
4480
+ render: renderJson
4481
+ },
4482
+ async execute(args) {
4483
+ return service.promptSession(args.hostId, args.sessionId, args.text, args.mode);
4484
+ }
4485
+ }));
4486
+ ctx.tools.register(defineTool({
4487
+ name: "remote_session_cancel",
4488
+ description: "Cancel the active turn of a remote DSH session.",
4489
+ parameters: {
4490
+ hostId: { type: "string", required: true, description: "Connected remote host id." },
4491
+ sessionId: { type: "string", required: true, description: "Remote session id." }
4492
+ },
4493
+ output: {
4494
+ schema: {
4495
+ type: "object",
4496
+ additionalProperties: false,
4497
+ properties: { accepted: { type: "boolean", required: true } }
4498
+ },
4499
+ render: renderJson
4500
+ },
4501
+ async execute(args) {
4502
+ return service.cancelSession(args.hostId, args.sessionId);
4503
+ }
4504
+ }));
4505
+ }
4506
+
4507
+ // src/index.ts
4508
+ var name = "dsh-remote-local";
4509
+ var inject = ["connection", "credentials"];
4510
+ async function apply(ctx, config = {}) {
4511
+ const resolved = resolveConfig(config);
4512
+ const service = new RemoteHostsService(ctx, resolved, {
4513
+ resolveModelKey: async () => {
4514
+ try {
4515
+ const resolvedKey = await ctx.credentials.resolve(credentialRef(resolved.modelCredentialRef));
4516
+ return resolvedKey?.value;
4517
+ } catch (error) {
4518
+ ctx.logger.warn("dsh-remote-local: could not resolve %s: %s", resolved.modelCredentialRef, String(error));
4519
+ return void 0;
4520
+ }
4521
+ }
4522
+ });
4523
+ await service.start();
4524
+ const mirrors = new RemoteMirrorService(ctx, service, resolved);
4525
+ await mirrors.start();
4526
+ const terminals = new RemoteTerminals(ctx, service, mirrors);
4527
+ ctx.effect(() => registerTerminalApi(ctx, terminals), "dsh-remote-local: remote terminal route");
4528
+ ctx.inject(["workspaceFiles"], (child) => {
4529
+ installRemoteWorkspaceFiles(child, mirrors, service);
4530
+ });
4531
+ ctx.effect(
4532
+ () => {
4533
+ const dispose = registerHostApi(ctx, service, mirrors);
4534
+ return () => {
4535
+ void dispose();
4536
+ };
4537
+ },
4538
+ "dsh-remote-local: authenticated host API"
4539
+ );
4540
+ installRemoteToolGuards(ctx);
4541
+ ctx.inject(["tools"], (child) => {
4542
+ registerRemoteTools(child, service);
4543
+ });
4544
+ ctx.effect(
4545
+ () => () => {
4546
+ terminals.dispose();
4547
+ void mirrors.dispose();
4548
+ void service.dispose();
4549
+ },
4550
+ "dsh-remote-local: tunnel and mirror lifecycle"
4551
+ );
4552
+ ctx.logger.info(
4553
+ "dsh-remote-local: %d host(s), state in %s",
4554
+ service.list().length,
4555
+ resolved.stateDir
4556
+ );
4557
+ }
4558
+ export {
4559
+ Config,
4560
+ HOST_API_PATH,
4561
+ RemoteHostsService,
4562
+ apply,
4563
+ inject,
4564
+ name
4565
+ };