dsh-home-hosted 0.3.3 → 0.4.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.
package/lib/index.js CHANGED
@@ -1,17 +1,28 @@
1
1
  // src/index.ts
2
- import path14 from "node:path";
2
+ import path15 from "node:path";
3
3
 
4
4
  // src/config.ts
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  var Config = z.object({
7
7
  stateDir: z.string().description("Plugin state directory; defaults to $DSH_HOME/dsh-home-hosted"),
8
8
  homeHostedCommand: z.string().description("home-hosted executable to put in a generated autostart entry"),
9
- defaultEntryId: z.string().default("dsh").description("Server entry id used for the running harness")
9
+ defaultEntryId: z.string().default("dsh").description("Server entry id used for the running harness"),
10
+ instanceRoots: z.array(z.string()).default([]).description("Other home-hosted state roots (--home) to report as other panels")
10
11
  });
11
12
 
12
13
  // src/shared/contracts.ts
13
14
  var RPC_PATH = "/home-hosted";
14
15
  var RPC_VERSION = 1;
16
+ var FOREIGN_MECHANISMS = {
17
+ "servers.list": ["file", "api"],
18
+ "servers.create": ["file", "api"],
19
+ "servers.update": ["file", "api"],
20
+ "servers.delete": ["file", "api"],
21
+ "servers.start": ["cli", "api"],
22
+ "servers.stop": ["cli", "api"],
23
+ "servers.restart": ["cli", "api"],
24
+ "ui.manage": ["cli"]
25
+ };
15
26
  var ON_PORT_CONFLICT_POLICIES = ["block", "warn", "follow", "reclaim", "kill"];
16
27
  function isOnPortConflict(value) {
17
28
  return typeof value === "string" && ON_PORT_CONFLICT_POLICIES.includes(value);
@@ -30,7 +41,7 @@ var MUTATING_AGENT_TOOLS = [
30
41
  "autostart_manage",
31
42
  "ui_manage"
32
43
  ];
33
- var SETTINGS_VERSION = 2;
44
+ var SETTINGS_VERSION = 3;
34
45
  var DEFAULT_SETTINGS = {
35
46
  version: SETTINGS_VERSION,
36
47
  autostart: { enabled: false, mechanism: "auto" },
@@ -41,14 +52,15 @@ var DEFAULT_SETTINGS = {
41
52
  panel: { port: null },
42
53
  authNotice: true,
43
54
  reclaimToken: true,
55
+ instancesNotice: true,
44
56
  uiStyle: "detailed",
45
57
  cli: { prefer: "pinned" }
46
58
  };
47
59
 
48
60
  // src/service.ts
49
61
  import { Service } from "@deepseek-ai/cordis";
50
- import fs13 from "node:fs";
51
- import path13 from "node:path";
62
+ import fs15 from "node:fs";
63
+ import path14 from "node:path";
52
64
  import process10 from "node:process";
53
65
  import { fileURLToPath } from "node:url";
54
66
 
@@ -1948,9 +1960,11 @@ function createBootLadder(options = {}) {
1948
1960
  }
1949
1961
 
1950
1962
  // src/home-hosted/config-file.ts
1951
- import fs7 from "node:fs";
1963
+ import fs8 from "node:fs";
1952
1964
 
1953
1965
  // src/util/paths.ts
1966
+ import crypto from "node:crypto";
1967
+ import fs7 from "node:fs";
1954
1968
  import os3 from "node:os";
1955
1969
  import path6 from "node:path";
1956
1970
  function expandHome(value) {
@@ -1968,6 +1982,32 @@ function homeHostedHome() {
1968
1982
  const configured = process.env.HHOSTED_HOME?.trim();
1969
1983
  return configured ? path6.resolve(expandHome(configured)) : path6.join(os3.homedir(), ".home-hosted");
1970
1984
  }
1985
+ function resolveHomeHostedHome(stateDir, env = process.env, homeDir = os3.homedir()) {
1986
+ const configured = env.HHOSTED_HOME?.trim();
1987
+ if (configured)
1988
+ return { home: path6.resolve(expandHome(configured)), source: "env" };
1989
+ if (hasPanelState(path6.join(homeDir, ".home-hosted")))
1990
+ return { home: path6.join(homeDir, ".home-hosted"), source: "legacy" };
1991
+ return { home: path6.join(path6.resolve(expandHome(stateDir)), "panel"), source: "instance" };
1992
+ }
1993
+ function hasPanelState(home) {
1994
+ return ["servers.config.json", "run.json"].some((file) => {
1995
+ try {
1996
+ return fs7.statSync(path6.join(home, file)).isFile();
1997
+ } catch {
1998
+ return false;
1999
+ }
2000
+ });
2001
+ }
2002
+ function bootUnitName(stateDir) {
2003
+ if (isDefaultStateDir(stateDir))
2004
+ return "home-hosted";
2005
+ return `home-hosted-${crypto.createHash("sha256").update(path6.resolve(stateDir)).digest("hex").slice(0, 8)}`;
2006
+ }
2007
+ function isDefaultStateDir(stateDir) {
2008
+ const bare = stateDir?.trim();
2009
+ return bare === void 0 || bare.length === 0 || path6.resolve(expandHome(bare)) === path6.join(dshHome(), "dsh-home-hosted");
2010
+ }
1971
2011
  function pluginStateDir(override) {
1972
2012
  const configured = override?.trim();
1973
2013
  if (configured)
@@ -1989,7 +2029,7 @@ function readConfig(home) {
1989
2029
  const file = configFile(home);
1990
2030
  const raw = readJson(file);
1991
2031
  if (raw === null) {
1992
- const exists = fs7.existsSync(file);
2032
+ const exists = fs8.existsSync(file);
1993
2033
  return { raw: null, exists, error: exists ? "the config file could not be parsed" : null };
1994
2034
  }
1995
2035
  if (!Array.isArray(raw.servers) && raw.servers !== void 0)
@@ -2033,12 +2073,12 @@ function removeEntry(raw, id) {
2033
2073
  }
2034
2074
 
2035
2075
  // src/home-hosted/dsh-entry.ts
2036
- import fs11 from "node:fs";
2076
+ import fs12 from "node:fs";
2037
2077
  import path10 from "node:path";
2038
2078
  import process7 from "node:process";
2039
2079
 
2040
2080
  // src/home-hosted/launch.ts
2041
- import fs8 from "node:fs";
2081
+ import fs9 from "node:fs";
2042
2082
  import path7 from "node:path";
2043
2083
  import process4 from "node:process";
2044
2084
  async function which(command) {
@@ -2050,12 +2090,12 @@ async function which(command) {
2050
2090
  return first ?? null;
2051
2091
  }
2052
2092
  function resolveShimmedCli(absolute, nodePath = process4.execPath) {
2053
- if (!fs8.existsSync(absolute))
2093
+ if (!fs9.existsSync(absolute))
2054
2094
  return null;
2055
2095
  if (/\.(?:mjs|cjs|js)$/.test(absolute))
2056
2096
  return { program: nodePath, args: [absolute], cliEntry: absolute, shimPath: null, source: "entry" };
2057
2097
  const shimTarget = /^#\s*cmd-shim-target=(.+)$/m.exec(readText(absolute) ?? "")?.[1]?.trim();
2058
- if (shimTarget !== void 0 && fs8.existsSync(shimTarget))
2098
+ if (shimTarget !== void 0 && fs9.existsSync(shimTarget))
2059
2099
  return { program: nodePath, args: [shimTarget], cliEntry: shimTarget, shimPath: absolute, source: "shim-target" };
2060
2100
  return { program: absolute, args: [], cliEntry: null, shimPath: absolute, source: "shim" };
2061
2101
  }
@@ -2087,7 +2127,7 @@ function homeHostedEnv(facts, launch, extra = {}) {
2087
2127
  }
2088
2128
  function buildHomeHostedBootSpec(facts, launch, options) {
2089
2129
  const logDir = path7.join(facts.home, ".logs");
2090
- fs8.mkdirSync(logDir, { recursive: true });
2130
+ fs9.mkdirSync(logDir, { recursive: true });
2091
2131
  const args = [...launch.args, "up", "--foreground", "--home", facts.home];
2092
2132
  if (facts.projectDir)
2093
2133
  args.push("--project", facts.projectDir);
@@ -2097,7 +2137,9 @@ function buildHomeHostedBootSpec(facts, launch, options) {
2097
2137
  cwd: facts.projectDir ?? facts.home,
2098
2138
  env: homeHostedEnv(facts, launch),
2099
2139
  marker: options.marker ?? "managed by dsh-home-hosted",
2100
- unitName: "home-hosted",
2140
+ // One artifact per state root, so two plugin instances cannot overwrite or
2141
+ // delete each other's entry. The default install keeps the historical name.
2142
+ unitName: bootUnitName(options.stateDir),
2101
2143
  label: "home-hosted control panel",
2102
2144
  logDir
2103
2145
  };
@@ -2105,13 +2147,11 @@ function buildHomeHostedBootSpec(facts, launch, options) {
2105
2147
 
2106
2148
  // src/home-hosted/resolve.ts
2107
2149
  import { createRequire } from "node:module";
2108
- import fs9 from "node:fs";
2150
+ import fs10 from "node:fs";
2109
2151
  import path8 from "node:path";
2110
2152
  import process5 from "node:process";
2111
- var EXPECTED_RANGE = "^0.6.6";
2153
+ var EXPECTED_RANGE = "^0.6.7";
2112
2154
  var MIN_SUPPORTED_VERSION = "0.4.1";
2113
- var MIN_KILL_VERSION = "0.6.0";
2114
- var MIN_PERSISTENT_VERSION = "0.6.3";
2115
2155
  function pinnedManifestPath() {
2116
2156
  try {
2117
2157
  return createRequire(import.meta.url).resolve("home-hosted/package.json");
@@ -2121,7 +2161,7 @@ function pinnedManifestPath() {
2121
2161
  }
2122
2162
  function binEntryFromManifest(manifestPath) {
2123
2163
  try {
2124
- const manifest = JSON.parse(fs9.readFileSync(manifestPath, "utf8"));
2164
+ const manifest = JSON.parse(fs10.readFileSync(manifestPath, "utf8"));
2125
2165
  const declared = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["home-hosted"] ?? Object.values(manifest.bin ?? {})[0];
2126
2166
  return typeof declared === "string" && declared.length > 0 ? path8.resolve(path8.dirname(manifestPath), declared) : null;
2127
2167
  } catch {
@@ -2204,10 +2244,10 @@ async function resolveCli(options = {}) {
2204
2244
  const manifest = (options.packageManifest ?? pinnedManifestPath)();
2205
2245
  const dependencyEntry = manifest === null ? null : binEntryFromManifest(manifest);
2206
2246
  let dependency = null;
2207
- if (dependencyEntry !== null && fs9.existsSync(dependencyEntry)) {
2247
+ if (dependencyEntry !== null && fs10.existsSync(dependencyEntry)) {
2208
2248
  let version2 = null;
2209
2249
  try {
2210
- version2 = JSON.parse(fs9.readFileSync(manifest, "utf8")).version ?? null;
2250
+ version2 = JSON.parse(fs10.readFileSync(manifest, "utf8")).version ?? null;
2211
2251
  } catch {
2212
2252
  version2 = null;
2213
2253
  }
@@ -2282,7 +2322,7 @@ async function resolveCli(options = {}) {
2282
2322
  }
2283
2323
 
2284
2324
  // src/home-hosted/launcher.ts
2285
- import fs10 from "node:fs";
2325
+ import fs11 from "node:fs";
2286
2326
  import path9 from "node:path";
2287
2327
  import process6 from "node:process";
2288
2328
  function launcherDir(stateDir) {
@@ -2399,11 +2439,11 @@ process.exit(typeof result.status === 'number' ? result.status : 1)
2399
2439
  function writeLauncher(options) {
2400
2440
  const file = launcherPath(options.stateDir);
2401
2441
  const source = buildLauncherSource(options);
2402
- const previous = fs10.existsSync(file) ? fs10.readFileSync(file, "utf8") : null;
2442
+ const previous = fs11.existsSync(file) ? fs11.readFileSync(file, "utf8") : null;
2403
2443
  const changed = previous !== source;
2404
2444
  if (changed) {
2405
2445
  writeFileAtomic(file, source, 493);
2406
- fs10.chmodSync(file, 493);
2446
+ fs11.chmodSync(file, 493);
2407
2447
  }
2408
2448
  writeJsonAtomic(launcherRecordPath(options.stateDir), {
2409
2449
  entry: options.resolvedEntry,
@@ -2415,7 +2455,7 @@ function writeLauncher(options) {
2415
2455
  }
2416
2456
  async function preflightLauncher(stateDir, timeoutMs = 1e4) {
2417
2457
  const file = launcherPath(stateDir);
2418
- if (!fs10.existsSync(file))
2458
+ if (!fs11.existsSync(file))
2419
2459
  return null;
2420
2460
  const result = process6.platform === "win32" ? await run(process6.execPath, [file, "--version"], { timeoutMs }) : await run(file, ["--version"], { timeoutMs });
2421
2461
  return parseVersion(result.stdout) ?? parseVersion(result.stderr);
@@ -2439,27 +2479,69 @@ import { spawnSync } from 'node:child_process'` : `const fs = require('node:fs')
2439
2479
  const path = require('node:path')
2440
2480
  const process = require('node:process')
2441
2481
  const { spawnSync } = require('node:child_process')`;
2442
- const roots = [options.resolvedEntry === null ? null : dshSearchRoot(options.resolvedEntry), ...options.searchRoots ?? []].filter((root) => typeof root === "string" && root.length > 0);
2482
+ const searchRoots = [
2483
+ options.resolvedEntry === null ? null : dshSearchRoot(options.resolvedEntry),
2484
+ options.pinnedEntry === null || options.pinnedEntry === void 0 ? null : dshSearchRoot(options.pinnedEntry),
2485
+ ...options.searchRoots ?? []
2486
+ ].filter((root) => typeof root === "string" && root.length > 0);
2443
2487
  return `#!/usr/bin/env node
2444
2488
  // managed by dsh-home-hosted \u2014 rewritten on every plugin start; do not edit.
2445
2489
  ${prelude}
2446
2490
 
2447
2491
  const DSH_HOME = ${JSON.stringify(options.dshHome)}
2448
2492
  const RECORD = ${JSON.stringify(dshLauncherRecordPath(options.stateDir))}
2449
- const SEARCH_ROOTS = ${JSON.stringify([...new Set(roots)])}
2493
+ const SEARCH_ROOTS = ${JSON.stringify([...new Set(searchRoots)])}
2494
+ // The image this plugin was installed on, when the record had one to carry.
2495
+ const PINNED = ${JSON.stringify(options.pinnedEntry ?? null)}
2450
2496
 
2451
2497
  function readRecord() {
2452
2498
  try { return JSON.parse(fs.readFileSync(RECORD, 'utf8')) } catch { return null }
2453
2499
  }
2454
2500
 
2455
- function isEntry(file) {
2501
+ function isFile(file) {
2456
2502
  let stat = null
2457
2503
  try { stat = fs.statSync(file) } catch { return false }
2458
- return stat.isFile() && /\\.(?:mjs|cjs|js)$/.test(file)
2504
+ return stat.isFile()
2459
2505
  }
2460
2506
 
2461
- function collect(out, entry) {
2462
- if (typeof entry === 'string' && isEntry(entry) && !out.includes(entry)) out.push(entry)
2507
+ // One identity per file: a recorded path and a candidate reached through a
2508
+ // symlink must compare equal, or a macOS clone would look like a different copy.
2509
+ function canon(file) {
2510
+ try { return fs.realpathSync(file) } catch { return file }
2511
+ }
2512
+
2513
+ // Keep what can actually boot: a JavaScript entry, or a shim whose recorded
2514
+ // target is one. A bare shim found on PATH is not a script, so requiring the
2515
+ // extension would silently drop the last resort.
2516
+ //
2517
+ // Each candidate carries how it was found, which is what ordering uses:
2518
+ // TIER_REAL a dsh by construction (a manifest, or node_modules/dsh)
2519
+ // TIER_PROBE a root that merely offers a build file \u2014 any package may
2520
+ // TIER_PATH a dsh executable found on PATH
2521
+ const TIER_REAL = 2
2522
+ const TIER_PROBE = 1
2523
+ const TIER_PATH = 0
2524
+
2525
+ function collect(out, entry, source, tier) {
2526
+ const argument = typeof source === 'string' && source.length > 0 ? source : entry
2527
+ const file = typeof entry === 'string' && isFile(entry) ? canon(entry) : null
2528
+ if (file !== null && /\\.(?:mjs|cjs|js)$/.test(file)) {
2529
+ if (!out.has(file) || (out.get(file) ?? 0) < tier) out.set(file, tier)
2530
+ return
2531
+ }
2532
+ // Anything else is a shim: only its recorded target can boot, and a shim
2533
+ // without one is not a candidate at all (never a second pass over itself).
2534
+ if (typeof argument !== 'string' || !isFile(argument)) return
2535
+ let text = ''
2536
+ try { text = fs.readFileSync(argument, 'utf8') } catch { return }
2537
+ const marker = 'cmd-shim-target='
2538
+ const at = text.indexOf(marker)
2539
+ if (at === -1) return
2540
+ const target = text.slice(at + marker.length).split('\\n')[0].trim()
2541
+ if (target.length === 0) return
2542
+ const real = canon(target)
2543
+ if (!/\\.(?:mjs|cjs|js)$/.test(real)) return
2544
+ if (!out.has(real) || (out.get(real) ?? 0) < tier) out.set(real, tier)
2463
2545
  }
2464
2546
 
2465
2547
  function collectStore(root, out) {
@@ -2468,30 +2550,36 @@ function collectStore(root, out) {
2468
2550
  try { names = fs.readdirSync(store) } catch { return }
2469
2551
  for (const name of names) {
2470
2552
  if (!name.startsWith('dsh@') && !name.startsWith('@deepseek-ai+dsh@')) continue
2471
- collect(out, path.join(store, name, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))
2472
- collect(out, path.join(store, name, 'node_modules', 'dsh', 'bin', 'dsh.js'))
2553
+ collect(out, path.join(store, name, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'), null, TIER_REAL)
2554
+ collect(out, path.join(store, name, 'node_modules', 'dsh', 'bin', 'dsh.js'), null, TIER_REAL)
2473
2555
  }
2474
2556
  }
2475
2557
 
2476
2558
  function collectRoot(root, out) {
2477
- collect(out, path.join(root, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))
2478
- collect(out, path.join(root, 'node_modules', 'dsh', 'bin', 'dsh.js'))
2559
+ collect(out, path.join(root, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'), null, TIER_REAL)
2560
+ collect(out, path.join(root, 'node_modules', 'dsh', 'bin', 'dsh.js'), null, TIER_REAL)
2479
2561
  collectStore(root, out)
2562
+ // A clone keeps its build output at its own root, not under node_modules.
2563
+ for (const entry of ['lib/bin.js', 'lib/bin.mjs', 'lib/bin.cjs', 'dist/bin.js'])
2564
+ collect(out, path.join(root, entry), null, TIER_PROBE)
2480
2565
  }
2481
2566
 
2482
- function candidates() {
2483
- const out = []
2567
+ function candidates(skipPath) {
2568
+ const out = new Map()
2484
2569
  const record = readRecord()
2485
- if (record && typeof record.entry === 'string') collect(out, record.entry)
2570
+ if (record && typeof record.entry === 'string') collect(out, record.entry, record.entry, TIER_REAL)
2486
2571
  for (const root of [...(Array.isArray(record?.roots) ? record.roots : []), ...SEARCH_ROOTS]) collectRoot(root, out)
2487
2572
  collectRoot(DSH_HOME, out)
2488
2573
  let profiles = []
2489
2574
  try { profiles = fs.readdirSync(path.join(DSH_HOME, 'profiles')) } catch {}
2490
2575
  for (const name of profiles) collectRoot(path.join(DSH_HOME, 'profiles', name), out)
2491
- // Last resort: a dsh on the entry's own PATH.
2576
+ // Last resort: a dsh on the entry's own PATH. It is no longer an equal
2577
+ // candidate: a copy found there is only booted when nothing else answers.
2578
+ if (skipPath) return out
2492
2579
  for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
2493
- collect(out, path.join(dir, 'dsh'))
2494
- collect(out, path.join(dir, 'dsh.cmd'))
2580
+ const shim = path.join(dir, 'dsh')
2581
+ collect(out, shim, shim, TIER_PATH)
2582
+ collect(out, path.join(dir, 'dsh.cmd'), path.join(dir, 'dsh.cmd'), TIER_PATH)
2495
2583
  }
2496
2584
  return out
2497
2585
  }
@@ -2524,22 +2612,43 @@ function compare(a, b) {
2524
2612
  return left.pre < right.pre ? -1 : 1
2525
2613
  }
2526
2614
 
2527
- // A recorded path that still exists is usually the copy to boot, but never the
2528
- // older one: a rebuild or upgrade that landed elsewhere must win on version.
2529
- function choose() {
2530
- const recorded = readRecord()?.entry ?? null
2531
- const found = candidates().map(entry => ({ entry, recorded: entry === recorded, version: versionOf(entry) }))
2532
- const known = found.filter(item => item.version !== null)
2615
+ function findPin(pin, found) {
2616
+ if (typeof pin !== 'string' || pin.length === 0) return null
2617
+ const real = canon(pin)
2618
+ return found.find(item => item.entry === real)?.entry ?? null
2619
+ }
2620
+
2621
+ // A root that only offers a build file (lib/bin.js) is a guess: any package may
2622
+ // have one. Those stay usable but never outrank a copy that is dsh by
2623
+ // construction, or a dsh executable from PATH below them.
2624
+ function bestOfTier(found, tier) {
2625
+ const at = found.filter(item => item.tier >= tier)
2626
+ const known = at.filter(item => item.version !== null)
2533
2627
  if (known.length > 0) {
2534
- known.sort((a, b) => (compare(b.version, a.version) || (Number(b.recorded) - Number(a.recorded))))
2628
+ known.sort((a, b) => compare(b.version, a.version))
2535
2629
  return known[0].entry
2536
2630
  }
2537
- return (found.find(item => item.recorded) ?? found[0])?.entry ?? null
2631
+ return at[0]?.entry ?? null
2632
+ }
2633
+
2634
+ // Choose the image this plugin was installed on, and only then fall back to a
2635
+ // version sort: a higher version elsewhere is not an invitation to boot it.
2636
+ function choose() {
2637
+ const record = readRecord()
2638
+ const pin = typeof record?.pinned === 'string' && record.pinned.length > 0 ? record.pinned : PINNED
2639
+ const found = [...candidates(true)].map(([entry, tier]) => ({ entry, tier, version: versionOf(entry) }))
2640
+ const pinned = findPin(pin, found)
2641
+ if (pinned !== null) return pinned
2642
+ // Real dsh installs first; a bare build-file guess only when nothing else
2643
+ // exists; PATH only when nothing local answered at all.
2644
+ return bestOfTier(found, TIER_REAL)
2645
+ ?? bestOfTier(found, TIER_PROBE)
2646
+ ?? bestOfTier([...candidates(false)].map(([entry, tier]) => ({ entry, tier, version: versionOf(entry) })), TIER_PATH)
2538
2647
  }
2539
2648
 
2540
2649
  const entry = choose()
2541
2650
  if (entry === null) {
2542
- console.error('[dsh-home-hosted] no dsh entry found for ' + DSH_HOME + ' (recorded path, $DSH_HOME node_modules, profiles/*, PATH). Reinstall dsh, or restart: dsh web')
2651
+ console.error('[dsh-home-hosted] no dsh entry found for ' + DSH_HOME + ' (pinned path, recorded path, $DSH_HOME node_modules, profiles/*, PATH). Reinstall dsh, or restart: dsh web')
2543
2652
  process.exit(1)
2544
2653
  }
2545
2654
 
@@ -2552,42 +2661,61 @@ process.exit(typeof result.status === 'number' ? result.status : 1)
2552
2661
  }
2553
2662
  function writeDshLauncher(options) {
2554
2663
  const file = dshLauncherPath(options.stateDir, options.entryExtension);
2555
- const source = buildDshLauncherSource(options);
2556
- const previous = fs10.existsSync(file) ? fs10.readFileSync(file, "utf8") : null;
2557
- const changed = previous !== source;
2664
+ const previousRecord = readDshLauncherRecord(options.stateDir);
2665
+ const recordedPin = existingEntry(previousRecord?.pinned);
2666
+ const offered = existingEntry(options.pinnedEntry);
2667
+ const pinned = options.repin === true ? offered ?? recordedPin : recordedPin ?? offered;
2668
+ const source = buildDshLauncherSource({
2669
+ ...options,
2670
+ pinnedEntry: pinned === null ? options.pinnedEntry ?? null : pinned
2671
+ });
2672
+ const changed = (fs11.existsSync(file) ? fs11.readFileSync(file, "utf8") : null) !== source;
2558
2673
  if (changed) {
2559
2674
  writeFileAtomic(file, source, 493);
2560
- fs10.chmodSync(file, 493);
2675
+ fs11.chmodSync(file, 493);
2561
2676
  }
2562
- const raw = [dshSearchRoot(options.resolvedEntry), ...options.searchRoots ?? [], ...readDshLauncherRecord(options.stateDir)?.roots ?? []];
2677
+ const raw = [dshSearchRoot(options.resolvedEntry), dshSearchRoot(pinned), ...options.searchRoots ?? [], ...previousRecord?.roots ?? []];
2563
2678
  const known = raw.filter((root) => typeof root === "string" && root.length > 0 && dshRootUsable(root));
2564
2679
  const roots = [...new Set(known)];
2565
2680
  writeJsonAtomic(dshLauncherRecordPath(options.stateDir), {
2566
2681
  entry: options.resolvedEntry,
2682
+ pinned,
2683
+ pinnedAt: pinned === null ? null : pinned === recordedPin ? previousRecord?.pinnedAt ?? Date.now() : Date.now(),
2567
2684
  roots,
2568
2685
  writtenAt: Date.now()
2569
2686
  }, 384);
2570
2687
  return { path: file, changed };
2571
2688
  }
2689
+ function existingEntry(file) {
2690
+ if (typeof file !== "string" || file.length === 0)
2691
+ return null;
2692
+ if (!fs11.existsSync(file))
2693
+ return null;
2694
+ try {
2695
+ return fs11.realpathSync(file);
2696
+ } catch {
2697
+ return null;
2698
+ }
2699
+ }
2572
2700
  function dshCandidatesUnder(root) {
2573
2701
  const out = [];
2574
2702
  for (const entry of [
2575
2703
  path9.join(root, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
2576
2704
  path9.join(root, "node_modules", "dsh", "bin", "dsh.js")
2577
2705
  ]) {
2578
- if (fs10.existsSync(entry))
2706
+ if (fs11.existsSync(entry))
2579
2707
  out.push(entry);
2580
2708
  }
2581
2709
  const store = path9.join(root, "node_modules", ".pnpm");
2582
- if (fs10.existsSync(store)) {
2583
- for (const name2 of fs10.readdirSync(store)) {
2710
+ if (fs11.existsSync(store)) {
2711
+ for (const name2 of fs11.readdirSync(store)) {
2584
2712
  if (!name2.startsWith("dsh@") && !name2.startsWith("@deepseek-ai+dsh@"))
2585
2713
  continue;
2586
2714
  for (const entry of [
2587
2715
  path9.join(store, name2, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
2588
2716
  path9.join(store, name2, "node_modules", "dsh", "bin", "dsh.js")
2589
2717
  ]) {
2590
- if (fs10.existsSync(entry))
2718
+ if (fs11.existsSync(entry))
2591
2719
  out.push(entry);
2592
2720
  }
2593
2721
  }
@@ -2598,12 +2726,32 @@ function dshSearchRoot(entry) {
2598
2726
  if (typeof entry !== "string" || entry.length === 0)
2599
2727
  return null;
2600
2728
  const parts = entry.split(path9.sep);
2601
- const store = parts.indexOf(".pnpm");
2602
- const modules = store > 0 ? store - 1 : parts.lastIndexOf("node_modules");
2603
- return modules > 0 ? parts.slice(0, modules).join(path9.sep) : null;
2729
+ const store = parts.lastIndexOf(".pnpm");
2730
+ const modules = parts.lastIndexOf("node_modules");
2731
+ const root = store > 0 ? parts.lastIndexOf("node_modules", store) : modules;
2732
+ if (root > 0)
2733
+ return parts.slice(0, root).join(path9.sep);
2734
+ return ownerRoot(entry) ?? (parts.length > 2 ? parts.slice(0, -2).join(path9.sep) : null);
2735
+ }
2736
+ function ownerRoot(entry) {
2737
+ let dir = path9.dirname(entry);
2738
+ for (let hop = 0; hop < 4; hop += 1) {
2739
+ const manifestPath = path9.join(dir, "package.json");
2740
+ const manifest = readJson(manifestPath);
2741
+ if (manifest !== null) {
2742
+ const declared = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["dsh"] ?? Object.values(manifest.bin ?? {})[0];
2743
+ if (typeof declared === "string" && path9.resolve(path9.dirname(manifestPath), declared) === entry)
2744
+ return dir;
2745
+ }
2746
+ const parent = path9.dirname(dir);
2747
+ if (parent === dir)
2748
+ break;
2749
+ dir = parent;
2750
+ }
2751
+ return null;
2604
2752
  }
2605
2753
  function dshRootUsable(root) {
2606
- return dshCandidatesUnder(root).length > 0 || fs10.existsSync(path9.join(root, "node_modules", ".pnpm"));
2754
+ return dshCandidatesUnder(root).length > 0 || fs11.existsSync(path9.join(root, "node_modules", ".pnpm")) || ["lib/bin.js", "lib/bin.mjs", "lib/bin.cjs", "dist/bin.js"].some((entry) => fs11.existsSync(path9.join(root, entry)));
2607
2755
  }
2608
2756
 
2609
2757
  // src/home-hosted/dsh-entry.ts
@@ -2638,11 +2786,11 @@ function versionOf(entry) {
2638
2786
  return null;
2639
2787
  }
2640
2788
  function isEntry(file) {
2641
- return /\.[cm]?js$/.test(file) && fs11.existsSync(file) && !fs11.statSync(file).isDirectory();
2789
+ return /\.[cm]?js$/.test(file) && fs12.existsSync(file) && !fs12.statSync(file).isDirectory();
2642
2790
  }
2643
2791
  function realPath(file) {
2644
2792
  try {
2645
- return fs11.realpathSync(file);
2793
+ return fs12.realpathSync(file);
2646
2794
  } catch {
2647
2795
  return file;
2648
2796
  }
@@ -2683,7 +2831,7 @@ function findDshEntry(dshHome2) {
2683
2831
  }
2684
2832
  function readdirNames(dir) {
2685
2833
  try {
2686
- return fs11.readdirSync(dir);
2834
+ return fs12.readdirSync(dir);
2687
2835
  } catch {
2688
2836
  return [];
2689
2837
  }
@@ -2698,12 +2846,27 @@ async function resolveDshLaunch(options = {}) {
2698
2846
  stateDir,
2699
2847
  dshHome: dshHome2,
2700
2848
  resolvedEntry: local,
2849
+ // The running image is the one a boot entry must keep running: a global
2850
+ // copy that reports a higher version is not an upgrade of this install.
2851
+ pinnedEntry: local,
2852
+ repin: true,
2701
2853
  entryExtension: path10.extname(local),
2702
2854
  // Where this install sits, so a rebuilt/moved copy is found next boot.
2703
2855
  searchRoots: [dshSearchRoot(local)].filter((root) => root !== null)
2704
2856
  });
2705
2857
  return { program: process7.execPath, args: [local], cliEntry: local, shimPath: null, source: "entry", launcherPath: launcher.path };
2706
2858
  }
2859
+ if (argv1 !== void 0 && argv1 !== null && realPath(argv1) === realPath(dshLauncherPath(stateDir, path10.extname(local ?? argv1)))) {
2860
+ const pinned = readDshLauncherRecord(stateDir)?.pinned ?? null;
2861
+ if (pinned !== null) {
2862
+ const launcher = dshLauncherPath(stateDir, path10.extname(pinned));
2863
+ const launch2 = resolveShimmedCli(pinned, process7.execPath);
2864
+ const cliEntry = launch2?.cliEntry ?? pinned;
2865
+ if (readDshLauncherRecord(stateDir)?.entry !== pinned)
2866
+ writeDshLauncher({ stateDir, dshHome: dshHome2, resolvedEntry: cliEntry, pinnedEntry: pinned, entryExtension: path10.extname(pinned) });
2867
+ return { program: process7.execPath, args: [launcher], cliEntry, shimPath: null, source: "entry", launcherPath: launcher };
2868
+ }
2869
+ }
2707
2870
  const installed = findDshEntry(dshHome2);
2708
2871
  if (installed !== null) {
2709
2872
  const launcher = writeDshLauncher({ stateDir, dshHome: dshHome2, resolvedEntry: installed, entryExtension: path10.extname(installed) });
@@ -2712,6 +2875,8 @@ async function resolveDshLaunch(options = {}) {
2712
2875
  const found = await (options.findOnPath ?? which)("dsh");
2713
2876
  if (found === null)
2714
2877
  return null;
2878
+ if (readDshLauncherRecord(stateDir)?.pinned != null)
2879
+ return null;
2715
2880
  const launch = resolveShimmedCli(found, process7.execPath);
2716
2881
  return launch === null ? null : { ...launch, launcherPath: null };
2717
2882
  }
@@ -2729,25 +2894,44 @@ function entryScript(config) {
2729
2894
  const rest = config.args ?? [];
2730
2895
  return rest.find((arg) => path10.isAbsolute(arg)) ?? program;
2731
2896
  }
2897
+ function pinnedScriptFor(launch) {
2898
+ if (launch === null)
2899
+ return null;
2900
+ const launcher = launch.launcherPath;
2901
+ if (launcher !== null && launcher.length > 0)
2902
+ return launcher;
2903
+ return launch.cliEntry ?? launch.args.find((arg) => path10.isAbsolute(arg)) ?? null;
2904
+ }
2905
+ function isBareCommand(stored) {
2906
+ return !/[/\\]/.test(stored) && (stored === "dsh" || /^dsh\.(?:cmd|exe)$/i.test(stored));
2907
+ }
2732
2908
  function needsLauncherRepair(config) {
2733
2909
  const program = config.command;
2734
2910
  if (typeof program !== "string" || program.length === 0)
2735
2911
  return false;
2736
2912
  if (path10.isAbsolute(program))
2737
2913
  return true;
2738
- return (config.args ?? []).some((arg, index) => index === 0 && path10.isAbsolute(arg));
2914
+ if (isBareCommand(program))
2915
+ return true;
2916
+ return (config.args ?? []).some((arg) => path10.isAbsolute(arg));
2739
2917
  }
2740
2918
  function launcherRepair(config, launch) {
2741
- if (launch?.launcherPath == null || launch.launcherPath.length === 0)
2919
+ const pinned = pinnedScriptFor(launch);
2920
+ if (pinned === null || launch === null)
2742
2921
  return null;
2743
- const launcher = launch.launcherPath;
2744
2922
  const stored = entryScript(config);
2745
- if (stored === null || stored === launcher)
2923
+ if (stored === null || stored === pinned)
2746
2924
  return null;
2747
- if (stored !== launch.cliEntry)
2748
- return null;
2749
- const rest = (config.args ?? []).filter((arg, index) => !(index === 0 && path10.isAbsolute(arg)));
2750
- return { command: process7.execPath, args: [launcher, ...rest] };
2925
+ const bare = isBareCommand(stored);
2926
+ if (!bare) {
2927
+ const target = resolveShimmedCli(stored, process7.execPath)?.cliEntry ?? stored;
2928
+ if (target !== pinned && target !== launch.cliEntry)
2929
+ return null;
2930
+ }
2931
+ const args = config.args ?? [];
2932
+ const inline = bare ? -1 : args.findIndex((arg) => path10.isAbsolute(arg));
2933
+ const rest = inline === -1 ? args : args.filter((_, index) => index !== inline);
2934
+ return { command: process7.execPath, args: [pinned, ...rest] };
2751
2935
  }
2752
2936
  function buildDshEntry(facts) {
2753
2937
  const launch = facts.launch;
@@ -2789,6 +2973,186 @@ function buildDshEntry(facts) {
2789
2973
  };
2790
2974
  }
2791
2975
 
2976
+ // src/home-hosted/instances.ts
2977
+ import fs13 from "node:fs";
2978
+ import os4 from "node:os";
2979
+ import path11 from "node:path";
2980
+
2981
+ // src/home-hosted/runtime.ts
2982
+ import http from "node:http";
2983
+ import https from "node:https";
2984
+ import process8 from "node:process";
2985
+ function readRuntime(home) {
2986
+ const raw = readJson(runtimeFile(home));
2987
+ if (raw === null)
2988
+ return null;
2989
+ const url = typeof raw.url === "string" ? raw.url : null;
2990
+ const pid = typeof raw.pid === "number" ? raw.pid : null;
2991
+ if (url === null || pid === null)
2992
+ return null;
2993
+ return {
2994
+ version: typeof raw.version === "string" ? raw.version : "unknown",
2995
+ pid,
2996
+ url: url.replace(/\/+$/, ""),
2997
+ probeUrl: typeof raw.probeUrl === "string" ? raw.probeUrl : void 0,
2998
+ protocol: typeof raw.protocol === "string" ? raw.protocol : void 0,
2999
+ port: typeof raw.port === "number" ? raw.port : 0,
3000
+ bindHost: typeof raw.bindHost === "string" ? raw.bindHost : void 0,
3001
+ projectDir: typeof raw.projectDir === "string" ? raw.projectDir : void 0,
3002
+ dataRoot: typeof raw.dataRoot === "string" ? raw.dataRoot : void 0,
3003
+ configPath: typeof raw.configPath === "string" ? raw.configPath : void 0,
3004
+ logFile: typeof raw.logFile === "string" ? raw.logFile : void 0
3005
+ };
3006
+ }
3007
+ function pidAlive(pid) {
3008
+ try {
3009
+ process8.kill(pid, 0);
3010
+ return true;
3011
+ } catch {
3012
+ return false;
3013
+ }
3014
+ }
3015
+ async function probePanel(url, timeoutMs = 2e3) {
3016
+ const target = `${url.replace(/\/+$/, "")}/healthz`;
3017
+ return await new Promise((resolve) => {
3018
+ let settled = false;
3019
+ const done = (value) => {
3020
+ if (settled)
3021
+ return;
3022
+ settled = true;
3023
+ resolve(value);
3024
+ };
3025
+ let parsed;
3026
+ try {
3027
+ parsed = new URL(target);
3028
+ } catch {
3029
+ done(false);
3030
+ return;
3031
+ }
3032
+ const transport = parsed.protocol === "https:" ? https : http;
3033
+ const request = transport.get(
3034
+ {
3035
+ hostname: parsed.hostname,
3036
+ port: parsed.port,
3037
+ path: `${parsed.pathname}${parsed.search}`,
3038
+ timeout: timeoutMs,
3039
+ rejectUnauthorized: false
3040
+ },
3041
+ (response) => {
3042
+ response.resume();
3043
+ done(typeof response.statusCode === "number");
3044
+ }
3045
+ );
3046
+ request.on("timeout", () => {
3047
+ request.destroy();
3048
+ done(false);
3049
+ });
3050
+ request.on("error", () => done(false));
3051
+ });
3052
+ }
3053
+
3054
+ // src/home-hosted/instances.ts
3055
+ var SIBLING_PATTERN = /^\.home-hosted(?:[._-].*)?$/;
3056
+ function canonicalPath(value) {
3057
+ const resolved = path11.resolve(expandHome(value));
3058
+ try {
3059
+ return fs13.realpathSync.native(resolved);
3060
+ } catch {
3061
+ return resolved;
3062
+ }
3063
+ }
3064
+ function defaultListDir(dir) {
3065
+ try {
3066
+ return fs13.readdirSync(dir);
3067
+ } catch {
3068
+ return null;
3069
+ }
3070
+ }
3071
+ function isStateRoot(home) {
3072
+ try {
3073
+ if (!fs13.statSync(home).isDirectory())
3074
+ return false;
3075
+ } catch {
3076
+ return false;
3077
+ }
3078
+ for (const file of [configFile(home), runtimeFile(home)]) {
3079
+ try {
3080
+ if (fs13.statSync(file).isFile())
3081
+ return true;
3082
+ } catch {
3083
+ }
3084
+ }
3085
+ return false;
3086
+ }
3087
+ function viewOf(candidate, managed, envHome, hosting, alive) {
3088
+ const runtime = readRuntime(candidate.home);
3089
+ const read = readConfig(candidate.home);
3090
+ return {
3091
+ home: candidate.home,
3092
+ managed: candidate.home === managed,
3093
+ hosting: hosting && candidate.home === envHome,
3094
+ url: runtime?.url ?? null,
3095
+ port: runtime?.port ?? null,
3096
+ pid: runtime?.pid ?? null,
3097
+ version: runtime?.version ?? null,
3098
+ // A `run.json` names the process that wrote it; a recycled pid is not worth
3099
+ // chasing here, because a stale one only mislabels a panel nobody acts on.
3100
+ running: runtime !== null && alive(runtime.pid),
3101
+ projectDir: runtime?.projectDir ?? null,
3102
+ servers: read.error === null ? (read.raw?.servers ?? []).length : null,
3103
+ source: candidate.source
3104
+ };
3105
+ }
3106
+ function discoverInstances(options) {
3107
+ const managed = canonicalPath(options.managedHome);
3108
+ const listDir = options.listDir ?? defaultListDir;
3109
+ const alive = options.alive ?? pidAlive;
3110
+ const envRaw = options.envHome?.trim();
3111
+ const envHome = envRaw !== void 0 && envRaw.length > 0 ? canonicalPath(envRaw) : null;
3112
+ const hostingEntryId = options.hostingEntryId?.trim() ?? "";
3113
+ const candidates = [{ home: managed, source: "managed" }];
3114
+ if (envHome !== null)
3115
+ candidates.push({ home: envHome, source: "env" });
3116
+ for (const root of options.extraRoots ?? []) {
3117
+ if (root.trim().length > 0)
3118
+ candidates.push({ home: canonicalPath(root), source: "configured" });
3119
+ }
3120
+ const homeDir = options.homeDir ?? os4.homedir();
3121
+ for (const name2 of listDir(homeDir) ?? []) {
3122
+ if (SIBLING_PATTERN.test(name2))
3123
+ candidates.push({ home: canonicalPath(path11.join(homeDir, name2)), source: "sibling" });
3124
+ }
3125
+ const seen = /* @__PURE__ */ new Set();
3126
+ const views = [];
3127
+ for (const candidate of candidates) {
3128
+ if (seen.has(candidate.home))
3129
+ continue;
3130
+ seen.add(candidate.home);
3131
+ if (candidate.source !== "managed" && !isStateRoot(candidate.home))
3132
+ continue;
3133
+ views.push(viewOf(candidate, managed, envHome, hostingEntryId.length > 0, alive));
3134
+ }
3135
+ views.sort((left, right) => left.managed === right.managed ? left.home.localeCompare(right.home) : left.managed ? -1 : 1);
3136
+ return views;
3137
+ }
3138
+ function describeInstance(instance) {
3139
+ if (instance.url === null)
3140
+ return `${instance.home} (no runtime)`;
3141
+ return `${instance.home} (${instance.url}${instance.running ? "" : ", stopped"})`;
3142
+ }
3143
+ function instancesNoticeText(instances) {
3144
+ const managed = instances.find((instance) => instance.managed);
3145
+ const others = instances.filter((instance) => !instance.managed);
3146
+ if (managed === void 0 || others.length === 0)
3147
+ return null;
3148
+ return [
3149
+ `home-hosted: ${instances.length} panels were found on this machine, and this plugin manages only ${describeInstance(managed)}.`,
3150
+ `Other panels: ${others.map(describeInstance).join("; ")}.`,
3151
+ 'Act on the managed panel unless the user names another; a home_hosted_* call takes "instance" to name one.',
3152
+ "Naming another panel is allowed for methods that can reach it: the plugin asks the user how, then edits that panel's config file, runs the CLI against its state root, or \u2014 only if they choose it \u2014 mints a token for that panel and uses its API."
3153
+ ].join(" ");
3154
+ }
3155
+
2792
3156
  // src/home-hosted/entries.ts
2793
3157
  function defaultOnPortConflict(platform = process.platform) {
2794
3158
  return platform === "win32" ? "kill" : "follow";
@@ -2802,16 +3166,16 @@ function defaultIntent(id, platform = process.platform) {
2802
3166
  persistent: true
2803
3167
  };
2804
3168
  }
2805
- function ownedPatch(intent, live, options = {}) {
3169
+ function ownedPatch(intent, live) {
2806
3170
  const stop = live?.stop ?? {};
2807
3171
  return {
2808
3172
  autostart: intent.autostart,
2809
3173
  onPortConflict: intent.onPortConflict,
2810
- ...options.persistent === false ? {} : { persistent: intent.persistent },
3174
+ persistent: intent.persistent,
2811
3175
  stop: { ...stop, killPortHolders: intent.stopKillPortHolders }
2812
3176
  };
2813
3177
  }
2814
- function ownedDrift(live, intent, options = {}) {
3178
+ function ownedDrift(live, intent) {
2815
3179
  if (live === null)
2816
3180
  return ["missing entry"];
2817
3181
  const drift = [];
@@ -2819,7 +3183,7 @@ function ownedDrift(live, intent, options = {}) {
2819
3183
  drift.push("autostart");
2820
3184
  if (live.onPortConflict !== intent.onPortConflict)
2821
3185
  drift.push("onPortConflict");
2822
- if (options.persistent !== false && (live.persistent ?? false) !== intent.persistent)
3186
+ if ((live.persistent ?? false) !== intent.persistent)
2823
3187
  drift.push("persistent");
2824
3188
  const killPortHolders = live.stop?.killPortHolders;
2825
3189
  if (killPortHolders !== intent.stopKillPortHolders)
@@ -2852,9 +3216,9 @@ function restorePatch(live, snapshot) {
2852
3216
 
2853
3217
  // src/home-hosted/panel-control.ts
2854
3218
  import { spawn as spawn2 } from "node:child_process";
2855
- import fs12 from "node:fs";
2856
- import path11 from "node:path";
2857
- import process8 from "node:process";
3219
+ import fs14 from "node:fs";
3220
+ import path12 from "node:path";
3221
+ import process9 from "node:process";
2858
3222
  function cliArgs(deps, command) {
2859
3223
  const args = [...deps.launch?.args ?? [], command, "--home", deps.home];
2860
3224
  if (deps.projectDir)
@@ -2862,10 +3226,13 @@ function cliArgs(deps, command) {
2862
3226
  return args;
2863
3227
  }
2864
3228
  async function runCli(deps, command) {
3229
+ const args = cliArgs(deps, command);
3230
+ if (deps.exec !== void 0)
3231
+ return await deps.exec(args, deps.env);
2865
3232
  const launch = deps.launch;
2866
3233
  if (launch === null)
2867
3234
  return { code: null, stdout: "", stderr: "no home-hosted CLI is available" };
2868
- return await run(launch.program, cliArgs(deps, command), {
3235
+ return await run(launch.program, args, {
2869
3236
  env: deps.env,
2870
3237
  timeoutMs: deps.timeoutMs ?? 9e4
2871
3238
  });
@@ -2879,14 +3246,22 @@ async function startPanel(deps) {
2879
3246
  const match = /https?:\/\/[^\s]+/.exec(result.stdout);
2880
3247
  return { ok: true, detail: "the panel is answering", url: match?.[0] ?? null };
2881
3248
  }
3249
+ async function stopPanel(deps) {
3250
+ const result = await runCli(deps, "down");
3251
+ if (result.code !== 0) {
3252
+ const detail = result.stderr.trim() || result.stdout.trim() || `the CLI exited ${String(result.code)}`;
3253
+ return { ok: false, detail: `the panel did not stop: ${detail}` };
3254
+ }
3255
+ return { ok: true, detail: "the panel stopped; the servers it supervised stopped with it" };
3256
+ }
2882
3257
  function takeoverHelperPath(stateDir) {
2883
- return path11.join(launcherDir(stateDir), "panel-takeover.mjs");
3258
+ return path12.join(launcherDir(stateDir), "panel-takeover.mjs");
2884
3259
  }
2885
3260
  function takeoverLogPath(stateDir) {
2886
- return path11.join(launcherDir(stateDir), "panel-takeover.log");
3261
+ return path12.join(launcherDir(stateDir), "panel-takeover.log");
2887
3262
  }
2888
3263
  function buildTakeoverSource(deps, oldPid, marker = "managed by dsh-home-hosted") {
2889
- const program = deps.launch?.program ?? process8.execPath;
3264
+ const program = deps.launch?.program ?? process9.execPath;
2890
3265
  const args = deps.launch === null ? [] : [...deps.launch.args, "up", "--home", deps.home, ...deps.projectDir ? ["--project", deps.projectDir] : []];
2891
3266
  const downArgs = [...deps.launch?.args ?? [], "down", "--home", deps.home];
2892
3267
  return `#!/usr/bin/env node
@@ -2941,7 +3316,7 @@ log('started with exit ' + String(result.status))
2941
3316
  function writeTakeoverHelper(deps, oldPid) {
2942
3317
  const file = takeoverHelperPath(deps.stateDir);
2943
3318
  writeFileAtomic(file, buildTakeoverSource(deps, oldPid), 493);
2944
- fs12.chmodSync(file, 493);
3319
+ fs14.chmodSync(file, 493);
2945
3320
  return file;
2946
3321
  }
2947
3322
  function spawnDetached(program, args) {
@@ -2951,7 +3326,7 @@ function spawnTakeover(deps, oldPid, spawnChild = spawnDetached) {
2951
3326
  const helper = writeTakeoverHelper(deps, oldPid);
2952
3327
  let child;
2953
3328
  try {
2954
- child = spawnChild(process8.execPath, [helper]);
3329
+ child = spawnChild(process9.execPath, [helper]);
2955
3330
  } catch (error) {
2956
3331
  return { ok: false, detail: `could not start the panel-replacement helper: ${error instanceof Error ? error.message : String(error)}` };
2957
3332
  }
@@ -2993,10 +3368,10 @@ var PanelClient = class {
2993
3368
  this.token = options.token;
2994
3369
  this.timeoutMs = options.timeoutMs ?? 1e4;
2995
3370
  }
2996
- async request(method, path15, body) {
3371
+ async request(method, path16, body) {
2997
3372
  let response;
2998
3373
  try {
2999
- response = await fetch(`${this.baseUrl}${path15}`, {
3374
+ response = await fetch(`${this.baseUrl}${path16}`, {
3000
3375
  method,
3001
3376
  headers: {
3002
3377
  authorization: `Bearer ${this.token}`,
@@ -3073,91 +3448,21 @@ async function verifyToken(baseUrl, token, timeoutMs = 5e3) {
3073
3448
  return await probeToken(baseUrl, token, timeoutMs) === "ok";
3074
3449
  }
3075
3450
 
3076
- // src/home-hosted/runtime.ts
3077
- import http from "node:http";
3078
- import https from "node:https";
3079
- import process9 from "node:process";
3080
- function readRuntime(home) {
3081
- const raw = readJson(runtimeFile(home));
3082
- if (raw === null)
3083
- return null;
3084
- const url = typeof raw.url === "string" ? raw.url : null;
3085
- const pid = typeof raw.pid === "number" ? raw.pid : null;
3086
- if (url === null || pid === null)
3087
- return null;
3088
- return {
3089
- version: typeof raw.version === "string" ? raw.version : "unknown",
3090
- pid,
3091
- url: url.replace(/\/+$/, ""),
3092
- probeUrl: typeof raw.probeUrl === "string" ? raw.probeUrl : void 0,
3093
- protocol: typeof raw.protocol === "string" ? raw.protocol : void 0,
3094
- port: typeof raw.port === "number" ? raw.port : 0,
3095
- bindHost: typeof raw.bindHost === "string" ? raw.bindHost : void 0,
3096
- projectDir: typeof raw.projectDir === "string" ? raw.projectDir : void 0,
3097
- dataRoot: typeof raw.dataRoot === "string" ? raw.dataRoot : void 0,
3098
- configPath: typeof raw.configPath === "string" ? raw.configPath : void 0,
3099
- logFile: typeof raw.logFile === "string" ? raw.logFile : void 0
3100
- };
3101
- }
3102
- function pidAlive(pid) {
3103
- try {
3104
- process9.kill(pid, 0);
3105
- return true;
3106
- } catch {
3107
- return false;
3108
- }
3109
- }
3110
- async function probePanel(url, timeoutMs = 2e3) {
3111
- const target = `${url.replace(/\/+$/, "")}/healthz`;
3112
- return await new Promise((resolve) => {
3113
- let settled = false;
3114
- const done = (value) => {
3115
- if (settled)
3116
- return;
3117
- settled = true;
3118
- resolve(value);
3119
- };
3120
- let parsed;
3121
- try {
3122
- parsed = new URL(target);
3123
- } catch {
3124
- done(false);
3125
- return;
3126
- }
3127
- const transport = parsed.protocol === "https:" ? https : http;
3128
- const request = transport.get(
3129
- {
3130
- hostname: parsed.hostname,
3131
- port: parsed.port,
3132
- path: `${parsed.pathname}${parsed.search}`,
3133
- timeout: timeoutMs,
3134
- rejectUnauthorized: false
3135
- },
3136
- (response) => {
3137
- response.resume();
3138
- done(typeof response.statusCode === "number");
3139
- }
3140
- );
3141
- request.on("timeout", () => {
3142
- request.destroy();
3143
- done(false);
3144
- });
3145
- request.on("error", () => done(false));
3146
- });
3147
- }
3148
-
3149
3451
  // src/home-hosted/token.ts
3150
- import { randomBytes } from "node:crypto";
3151
- import path12 from "node:path";
3152
- function storedTokenPath(stateDir) {
3153
- return path12.join(stateDir, "panel-token");
3452
+ import { createHash, randomBytes } from "node:crypto";
3453
+ import path13 from "node:path";
3454
+ function tokenSlot(home) {
3455
+ return createHash("sha256").update(path13.resolve(home)).digest("hex").slice(0, 16);
3154
3456
  }
3155
- function readStoredToken(stateDir) {
3156
- const text = readText(storedTokenPath(stateDir))?.trim();
3457
+ function storedTokenPath(stateDir, slot) {
3458
+ return slot === void 0 ? path13.join(stateDir, "panel-token") : path13.join(stateDir, "panel-tokens", `${slot}.token`);
3459
+ }
3460
+ function readStoredToken(stateDir, slot) {
3461
+ const text = readText(storedTokenPath(stateDir, slot))?.trim();
3157
3462
  return text !== void 0 && text.length > 0 ? text : null;
3158
3463
  }
3159
- function storeToken(stateDir, token) {
3160
- writeFileAtomic(storedTokenPath(stateDir), `${token}
3464
+ function storeToken(stateDir, token, slot) {
3465
+ writeFileAtomic(storedTokenPath(stateDir, slot), `${token}
3161
3466
  `, 384);
3162
3467
  }
3163
3468
  function generateToken() {
@@ -3168,8 +3473,8 @@ function apiTokenEnrolled(home) {
3168
3473
  return secrets !== null && secrets.apiToken !== null && secrets.apiToken !== void 0;
3169
3474
  }
3170
3475
  var queue = /* @__PURE__ */ new Map();
3171
- function serialised(stateDir, work) {
3172
- const key = path12.resolve(stateDir);
3476
+ function serialised(stateDir, slot, work) {
3477
+ const key = `${path13.resolve(stateDir)}\0${slot ?? ""}`;
3173
3478
  const previous = queue.get(key) ?? Promise.resolve();
3174
3479
  const next = previous.then(work, work);
3175
3480
  const settled = next.then(() => void 0, () => void 0);
@@ -3180,10 +3485,10 @@ function serialised(stateDir, work) {
3180
3485
  return next;
3181
3486
  }
3182
3487
  async function ensureToken(options) {
3183
- return await serialised(options.stateDir, async () => await enrollToken(options));
3488
+ return await serialised(options.stateDir, options.slot, async () => await enrollToken(options));
3184
3489
  }
3185
3490
  async function reclaimToken(options) {
3186
- return await serialised(options.stateDir, async () => await enrollFreshToken(options));
3491
+ return await serialised(options.stateDir, options.slot, async () => await enrollFreshToken(options));
3187
3492
  }
3188
3493
  async function runCli2(exec, args, env) {
3189
3494
  try {
@@ -3199,7 +3504,7 @@ async function runCli2(exec, args, env) {
3199
3504
  }
3200
3505
  }
3201
3506
  async function enrollToken(options) {
3202
- const stored = readStoredToken(options.stateDir);
3507
+ const stored = readStoredToken(options.stateDir, options.slot);
3203
3508
  if (stored !== null)
3204
3509
  return { token: stored, enrolled: false, detail: "using the stored panel token" };
3205
3510
  if (apiTokenEnrolled(options.home)) {
@@ -3218,7 +3523,7 @@ async function enrollToken(options) {
3218
3523
  detail: `could not enrol an API token: ${enrolled.failure ?? "the CLI did not answer"}`
3219
3524
  };
3220
3525
  }
3221
- storeToken(options.stateDir, token);
3526
+ storeToken(options.stateDir, token, options.slot);
3222
3527
  return { token, enrolled: true, detail: "enrolled a new panel API token" };
3223
3528
  }
3224
3529
  async function enrollFreshToken(options) {
@@ -3230,7 +3535,7 @@ async function enrollFreshToken(options) {
3230
3535
  detail: `could not enrol a fresh API token: ${enrolled.failure ?? "the CLI did not answer"}; the previously stored token was kept`
3231
3536
  };
3232
3537
  }
3233
- storeToken(options.stateDir, token);
3538
+ storeToken(options.stateDir, token, options.slot);
3234
3539
  return { token, detail: "enrolled a fresh panel API token" };
3235
3540
  }
3236
3541
 
@@ -3238,9 +3543,10 @@ async function enrollFreshToken(options) {
3238
3543
  var ENTRY_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
3239
3544
  var TOKEN_PROBE_TIMEOUT_MS = 3e3;
3240
3545
  var ENTRY_RECOVERY_INTERVAL_MS = 3e4;
3546
+ var INSTANCES_CACHE_MS = 1e4;
3241
3547
  function pluginRoot() {
3242
3548
  try {
3243
- return path13.dirname(path13.dirname(fileURLToPath(import.meta.url)));
3549
+ return path14.dirname(path14.dirname(fileURLToPath(import.meta.url)));
3244
3550
  } catch {
3245
3551
  return null;
3246
3552
  }
@@ -3252,11 +3558,14 @@ var HomeHostedError = class extends Error {
3252
3558
  this.name = "HomeHostedError";
3253
3559
  }
3254
3560
  };
3561
+ function foreignView(entry) {
3562
+ return { id: entry.id, status: "unknown", pid: null, url: null, config: entry };
3563
+ }
3255
3564
  var HomeHostedService = class extends Service {
3256
3565
  constructor(ctx, options) {
3257
3566
  super(ctx, "homeHosted");
3258
3567
  this.options = options;
3259
- this.snapshotsFile = path13.join(options.stateDir, "snapshots.json");
3568
+ this.snapshotsFile = path14.join(options.stateDir, "snapshots.json");
3260
3569
  }
3261
3570
  clientCache = null;
3262
3571
  /** The last token this service proved against a panel, so a poll does not re-probe it. */
@@ -3264,6 +3573,8 @@ var HomeHostedService = class extends Service {
3264
3573
  tokenDetail = "";
3265
3574
  /** When a missing managed entry was last put back, so a poll cannot become a write loop. */
3266
3575
  entryRecoveryAt = 0;
3576
+ /** The last panel inventory, so a model-step assembly never reads the disk. */
3577
+ instanceCache = null;
3267
3578
  snapshotsFile;
3268
3579
  async resolveDsh(dshHome2) {
3269
3580
  return await (this.options.resolveDsh ?? ((options) => resolveDshLaunch(options)))({
@@ -3272,6 +3583,185 @@ var HomeHostedService = class extends Service {
3272
3583
  });
3273
3584
  }
3274
3585
  // -------------------------------------------------------------------------
3586
+ // Panels on this machine
3587
+ // -------------------------------------------------------------------------
3588
+ /**
3589
+ * The panel inventory, cached for a short while: the page polls status and
3590
+ * the prompt provider reads it at every assembly. `refresh` (or an expired
3591
+ * cache) re-reads the disk.
3592
+ */
3593
+ async instances(refresh = false) {
3594
+ if (refresh || this.instanceCache === null || Date.now() - this.instanceCache.at >= INSTANCES_CACHE_MS)
3595
+ return this.measureInstances();
3596
+ return this.instanceCache.list;
3597
+ }
3598
+ /**
3599
+ * The inventory for a caller that cannot await — a prompt provider runs
3600
+ * synchronously before a model step. Discovery is synchronous too, so this
3601
+ * re-measures a stale cache in place and never blocks the step.
3602
+ */
3603
+ instancesNow() {
3604
+ if (this.instanceCache !== null && Date.now() - this.instanceCache.at < INSTANCES_CACHE_MS)
3605
+ return this.instanceCache.list;
3606
+ return this.measureInstances();
3607
+ }
3608
+ measureInstances() {
3609
+ const now = Date.now();
3610
+ try {
3611
+ this.instanceCache = {
3612
+ at: now,
3613
+ list: discoverInstances({
3614
+ managedHome: this.options.home,
3615
+ extraRoots: this.options.instanceRoots,
3616
+ envHome: this.options.envHome === void 0 ? process10.env.HHOSTED_HOME ?? null : this.options.envHome,
3617
+ homeDir: this.options.homeDir,
3618
+ hostingEntryId: this.selfEntryId()
3619
+ })
3620
+ };
3621
+ } catch {
3622
+ this.instanceCache = { at: now, list: [] };
3623
+ }
3624
+ return this.instanceCache.list;
3625
+ }
3626
+ /** The inventory as last measured, without touching the disk. */
3627
+ instancesSnapshot() {
3628
+ return this.instanceCache?.list ?? [];
3629
+ }
3630
+ // -------------------------------------------------------------------------
3631
+ // Acting on another panel
3632
+ // -------------------------------------------------------------------------
3633
+ /**
3634
+ * The state root a call targets: the managed one unless it names another panel
3635
+ * discovery reported. A caller-supplied path is never acted on directly, so a
3636
+ * stray string cannot become a way to write anywhere on the disk.
3637
+ */
3638
+ async targetHome(requested) {
3639
+ if (requested === void 0 || requested === null || requested === "")
3640
+ return { home: this.options.home, foreign: false };
3641
+ if (typeof requested !== "string")
3642
+ throw new HomeHostedError("a panel target must be a state root path", "INSTANCE_INVALID");
3643
+ if (requested.trim().length === 0)
3644
+ return { home: this.options.home, foreign: false };
3645
+ const home = canonicalPath(requested);
3646
+ if (home === canonicalPath(this.options.home))
3647
+ return { home: this.options.home, foreign: false };
3648
+ const instances = await this.instances();
3649
+ if (!instances.some((instance) => instance.home === home && !instance.managed))
3650
+ throw new HomeHostedError(`"${requested}" is not a home-hosted panel this machine reports`, "INSTANCE_UNKNOWN");
3651
+ return { home, foreign: true };
3652
+ }
3653
+ /**
3654
+ * How a call reaches another panel. `via` names it; without one the endpoint's
3655
+ * least invasive mechanism is used, which is the order the shared list keeps.
3656
+ */
3657
+ mechanismFor(endpoint, requested) {
3658
+ const allowed = FOREIGN_MECHANISMS[endpoint];
3659
+ if (allowed === void 0)
3660
+ throw new HomeHostedError(`"${endpoint}" cannot be aimed at another panel`, "INSTANCE_UNSUPPORTED");
3661
+ if (requested === void 0 || requested === null)
3662
+ return allowed[0];
3663
+ if (typeof requested === "string" && allowed.includes(requested))
3664
+ return requested;
3665
+ throw new HomeHostedError(
3666
+ `"${String(requested)}" is not a way to reach another panel for ${endpoint}; use one of ${allowed.join(", ")}`,
3667
+ "MECHANISM_UNSUPPORTED"
3668
+ );
3669
+ }
3670
+ /** Whether a token this plugin already holds for a panel still works. */
3671
+ async foreignTokenWorks(home) {
3672
+ const runtime = readRuntime(home);
3673
+ if (runtime === null || runtime.url.length === 0)
3674
+ return false;
3675
+ const stored = readStoredToken(this.options.stateDir, tokenSlot(home));
3676
+ return stored !== null && await verifyToken(runtime.url, stored);
3677
+ }
3678
+ /**
3679
+ * A client for another panel: the plugin's own credential for that state root,
3680
+ * minted and enrolled the first time it is needed, proved against the panel,
3681
+ * and replaced when the panel no longer accepts it.
3682
+ *
3683
+ * home-hosted keeps only a hash, so an existing token this plugin does not hold
3684
+ * can only be replaced — which is what choosing this mechanism means.
3685
+ */
3686
+ async foreignClient(home) {
3687
+ const runtime = readRuntime(home);
3688
+ if (runtime === null || runtime.url.length === 0)
3689
+ throw new HomeHostedError(`no panel is running at ${home}, so its API cannot be used`, "PANEL_UNAVAILABLE");
3690
+ const slot = tokenSlot(home);
3691
+ const exec = async (args, env) => await this.cliExec(args, env);
3692
+ const stored = readStoredToken(this.options.stateDir, slot);
3693
+ if (stored !== null && await verifyToken(runtime.url, stored))
3694
+ return new PanelClient({ baseUrl: runtime.url, token: stored });
3695
+ const enrolled = stored !== null || apiTokenEnrolled(home) ? await reclaimToken({ home, stateDir: this.options.stateDir, exec, slot }) : await ensureToken({ home, stateDir: this.options.stateDir, exec, slot });
3696
+ if (enrolled.token === null)
3697
+ throw new HomeHostedError(enrolled.detail, "TOKEN_UNAVAILABLE");
3698
+ return new PanelClient({ baseUrl: runtime.url, token: enrolled.token });
3699
+ }
3700
+ /** Another panel's own config, refusing anything that is not a readable one. */
3701
+ readForeignConfig(home) {
3702
+ const read = readConfig(home);
3703
+ if (read.error !== null || read.raw === null)
3704
+ throw new HomeHostedError(read.error ?? `no servers config in ${home}`, "CONFIG_UNREADABLE");
3705
+ return read.raw;
3706
+ }
3707
+ /** Another panel's entries as its config holds them: live status needs its API token. */
3708
+ listForeign(home) {
3709
+ return (this.readForeignConfig(home).servers ?? []).map((entry) => foreignView(entry));
3710
+ }
3711
+ createForeign(home, entry) {
3712
+ const raw = this.readForeignConfig(home);
3713
+ if (findEntry(raw, entry.id) !== null)
3714
+ throw new HomeHostedError(`"${entry.id}" already exists in ${home}/servers.config.json`, "DUPLICATE_SERVER");
3715
+ writeConfig(home, upsertEntry(raw, { ...entry, autostart: false }), this.writtenBy(home));
3716
+ const added = this.readForeignConfig(home);
3717
+ writeConfig(home, patchEntry(added, entry.id, { autostart: entry.autostart === true }), this.writtenBy(home));
3718
+ return this.writtenForeignEntry(home, entry.id);
3719
+ }
3720
+ updateForeign(home, id, patch) {
3721
+ const raw = this.readForeignConfig(home);
3722
+ if (findEntry(raw, id) === null)
3723
+ throw new HomeHostedError(`no server "${id}" exists in ${home}/servers.config.json`, "UNKNOWN_SERVER");
3724
+ const next = patchEntry(raw, id, { ...patch, id });
3725
+ writeConfig(home, next, this.writtenBy(home));
3726
+ return this.writtenForeignEntry(home, id);
3727
+ }
3728
+ /** What a write actually left on disk, never what it was asked to write. */
3729
+ writtenForeignEntry(home, id) {
3730
+ const entry = findEntry(this.readForeignConfig(home), id);
3731
+ if (entry === null)
3732
+ throw new HomeHostedError(`the write to ${home}/servers.config.json did not take`, "FOREIGN_WRITE_LOST");
3733
+ return foreignView(entry);
3734
+ }
3735
+ deleteForeign(home, id) {
3736
+ const raw = this.readForeignConfig(home);
3737
+ if (findEntry(raw, id) === null)
3738
+ throw new HomeHostedError(`no server "${id}" exists in ${home}/servers.config.json`, "UNKNOWN_SERVER");
3739
+ writeConfig(home, removeEntry(raw, id), this.writtenBy(home));
3740
+ return { id, home, via: "file" };
3741
+ }
3742
+ /**
3743
+ * Start, stop or restart one entry through that panel's own CLI: this plugin
3744
+ * holds no API token for a panel it does not manage, and a config edit cannot
3745
+ * start anything. The CLI has no server restart, so a restart is a stop
3746
+ * followed by a start — a stop that is not followed by one is reported.
3747
+ */
3748
+ async lifecycleForeign(home, action, id) {
3749
+ const once = async (command) => {
3750
+ const result = await this.cliExec([command, id, "--home", home], { ...process10.env, HHOSTED_HOME: home });
3751
+ if (result.code !== 0) {
3752
+ const output = [result.stderr, result.stdout, result.error].filter((part) => typeof part === "string" && part.trim().length > 0).join("\n").trim();
3753
+ throw new HomeHostedError(output.length > 0 ? output : `home-hosted ${command} ${id} exited ${String(result.code)}`, "CLI_FAILED");
3754
+ }
3755
+ };
3756
+ if (action === "restart") {
3757
+ await once("stop");
3758
+ await once("start");
3759
+ } else {
3760
+ await once(action);
3761
+ }
3762
+ return { home, id, action, via: "cli" };
3763
+ }
3764
+ // -------------------------------------------------------------------------
3275
3765
  // Facts
3276
3766
  // -------------------------------------------------------------------------
3277
3767
  runtime() {
@@ -3283,8 +3773,8 @@ var HomeHostedService = class extends Service {
3283
3773
  return id !== void 0 && id.length > 0 ? id : null;
3284
3774
  }
3285
3775
  /** What the config's `meta.writtenBy` names: the panel release when we can read it. */
3286
- writtenBy() {
3287
- return this.runtime()?.version ?? "dsh-home-hosted";
3776
+ writtenBy(home = this.options.home) {
3777
+ return readRuntime(home)?.version ?? "dsh-home-hosted";
3288
3778
  }
3289
3779
  webServer() {
3290
3780
  const service = this.ctx.get("webServer");
@@ -3326,7 +3816,8 @@ var HomeHostedService = class extends Service {
3326
3816
  home: this.options.home,
3327
3817
  projectDir,
3328
3818
  stateDir: this.options.stateDir,
3329
- env: launch === null ? {} : homeHostedEnv({ home: this.options.home, projectDir }, launch)
3819
+ env: launch === null ? {} : homeHostedEnv({ home: this.options.home, projectDir }, launch),
3820
+ exec: this.options.execCli
3330
3821
  };
3331
3822
  }
3332
3823
  async cliExec(args, env) {
@@ -3548,7 +4039,7 @@ var HomeHostedService = class extends Service {
3548
4039
  snapshots[intent.id] = live === null ? { id: intent.id } : snapshotOwned(live.config);
3549
4040
  this.saveSnapshots(snapshots);
3550
4041
  }
3551
- const patch = ownedPatch(intent, live?.config ?? null, { persistent: await this.supportsPersistent() });
4042
+ const patch = ownedPatch(intent, live?.config ?? null);
3552
4043
  const repair = intent.id === this.options.defaultEntryId ? await this.dshCommandRepair(live?.config ?? null) : null;
3553
4044
  const client = await this.tryClient();
3554
4045
  if (client !== null) {
@@ -3589,35 +4080,6 @@ var HomeHostedService = class extends Service {
3589
4080
  const runtime = this.runtime();
3590
4081
  return runtime !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url));
3591
4082
  }
3592
- /**
3593
- * The version whose schema will parse what we write: the answering panel, or
3594
- * the CLI that will parse it next. `kill` did not exist before 0.6.0, and an
3595
- * older panel refuses to *boot* with it — so this is checked before any write.
3596
- */
3597
- async configVersion() {
3598
- const runtime = this.runtime();
3599
- if (runtime !== null && runtime.version !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url)))
3600
- return runtime.version;
3601
- const { resolution } = await this.cli();
3602
- return resolution.status.version;
3603
- }
3604
- /** Whether the panel whose schema parses our write knows `persistent`. */
3605
- async supportsPersistent() {
3606
- const version = await this.configVersion();
3607
- return version !== null && compareVersions(version, MIN_PERSISTENT_VERSION) >= 0;
3608
- }
3609
- async assertPolicySupported(intent) {
3610
- if (intent.onPortConflict !== "kill")
3611
- return;
3612
- const version = await this.configVersion();
3613
- if (version === null || compareVersions(version, MIN_KILL_VERSION) >= 0)
3614
- return;
3615
- const { resolution } = await this.cli();
3616
- throw new HomeHostedError(
3617
- `the home-hosted that would parse this config is ${version}, which has no "kill" policy (added in ${MIN_KILL_VERSION}); replace it with ${resolution.status.version ?? "the pinned copy"} from this page, or set the entry's on-port-conflict policy to block`,
3618
- "KILL_UNSUPPORTED"
3619
- );
3620
- }
3621
4083
  async applyIntents(intents) {
3622
4084
  for (const raw of intents) {
3623
4085
  if (!ENTRY_ID_PATTERN.test(raw.id))
@@ -3636,7 +4098,6 @@ var HomeHostedService = class extends Service {
3636
4098
  stopKillPortHolders: raw.stopKillPortHolders ?? fallback.stopKillPortHolders,
3637
4099
  persistent: raw.persistent ?? fallback.persistent
3638
4100
  };
3639
- await this.assertPolicySupported(intent);
3640
4101
  await this.writeOwned(intent);
3641
4102
  const current = this.options.settings.get();
3642
4103
  const entries = current.entries.filter((entry) => entry.id !== intent.id);
@@ -3701,21 +4162,22 @@ var HomeHostedService = class extends Service {
3701
4162
  return await this.entriesStatus();
3702
4163
  }
3703
4164
  /** Drive the panel's own UI through its CLI, and report what is installed. */
3704
- async uiManage(action, file) {
4165
+ async uiManage(action, file, target) {
4166
+ const home = target ?? this.options.home;
3705
4167
  const cli = action === "status" ? null : await this.cli();
3706
4168
  const runUi = async (args) => {
3707
4169
  const launch = cli?.resolution.launch ?? null;
3708
4170
  if (launch === null)
3709
4171
  throw new HomeHostedError(cli?.resolution.status.detail ?? "no home-hosted CLI could be resolved", "CLI_NOT_FOUND");
3710
- const result = await run(launch.program, [...launch.args, ...args, "--home", this.options.home], {
3711
- env: { ...process10.env, HHOSTED_HOME: this.options.home },
4172
+ const result = await run(launch.program, [...launch.args, ...args, "--home", home], {
4173
+ env: { ...process10.env, HHOSTED_HOME: home },
3712
4174
  timeoutMs: 3e5
3713
4175
  });
3714
4176
  const output = [result.error, result.stdout, result.stderr].filter((part) => typeof part === "string" && part.trim().length > 0).join("\n").trim();
3715
4177
  return { code: result.code, output };
3716
4178
  };
3717
4179
  const active = () => {
3718
- const meta = readJson(path13.join(this.options.home, ".ui", "ui.json"));
4180
+ const meta = readJson(path14.join(home, ".ui", "ui.json"));
3719
4181
  return meta === null ? null : { name: meta.name ?? null, version: meta.version ?? null, repo: meta.repo ?? null, tag: meta.tag ?? null };
3720
4182
  };
3721
4183
  switch (action) {
@@ -3734,7 +4196,7 @@ var HomeHostedService = class extends Service {
3734
4196
  case "switch": {
3735
4197
  if (file === void 0 || file.trim().length === 0)
3736
4198
  throw new HomeHostedError("switching the UI needs the zip to install", "UI_FILE_REQUIRED");
3737
- if (!fs13.existsSync(file))
4199
+ if (!fs15.existsSync(file))
3738
4200
  throw new HomeHostedError(`no file at ${file}`, "UI_FILE_MISSING");
3739
4201
  const result = await runUi(["ui-switch", "--file", file, "--yes"]);
3740
4202
  return { ok: result.code === 0, detail: result.code === 0 ? "the UI was replaced" : "the switch did not run", ui: active(), output: result.output };
@@ -3773,7 +4235,6 @@ var HomeHostedService = class extends Service {
3773
4235
  const settings = this.options.settings.get();
3774
4236
  const live = await this.liveEntries();
3775
4237
  const snapshots = this.snapshots();
3776
- const persistent = await this.supportsPersistent();
3777
4238
  const ids = /* @__PURE__ */ new Set([this.options.defaultEntryId, ...settings.entries.map((entry) => entry.id)]);
3778
4239
  return [...ids].map((id) => {
3779
4240
  const intent = this.options.settings.intentFor(id);
@@ -3782,7 +4243,7 @@ var HomeHostedService = class extends Service {
3782
4243
  intent,
3783
4244
  exists: entry !== null,
3784
4245
  managed: snapshots[id] !== void 0,
3785
- drift: entry === null ? ["missing entry"] : ownedDrift(entry.config, intent, { persistent }),
4246
+ drift: entry === null ? ["missing entry"] : ownedDrift(entry.config, intent),
3786
4247
  live: entry?.view ?? null,
3787
4248
  snapshot: snapshots[id] ?? null
3788
4249
  };
@@ -3908,6 +4369,28 @@ var HomeHostedService = class extends Service {
3908
4369
  this.clientCache = null;
3909
4370
  return result;
3910
4371
  }
4372
+ /**
4373
+ * Stop the panel the plugin drives, and every server it supervises with it.
4374
+ *
4375
+ * Deliberately not a replacement for the takeover guard: stopping is safe to
4376
+ * lose (an `autostart` entry comes back), while replacing is not. If this
4377
+ * process is one of those servers the answer may never arrive, so the page
4378
+ * treats a dropped call as "the panel is stopping" rather than a failure.
4379
+ */
4380
+ async stopPanelNow() {
4381
+ const { resolution } = await this.cli();
4382
+ const runtime = this.runtime();
4383
+ if (runtime === null || !await probePanel(runtime.url)) {
4384
+ return { ok: true, detail: "no panel is answering, so there was nothing to stop" };
4385
+ }
4386
+ const deps = await this.panelControlDeps();
4387
+ if (deps.launch === null)
4388
+ throw new HomeHostedError(resolution.status.detail, "CLI_NOT_FOUND");
4389
+ const result = await stopPanel(deps);
4390
+ this.clientCache = null;
4391
+ this.tokenProof = null;
4392
+ return result;
4393
+ }
3911
4394
  /**
3912
4395
  * Replace an answering panel with the preferred copy.
3913
4396
  *
@@ -3988,7 +4471,7 @@ var HomeHostedService = class extends Service {
3988
4471
  if (status.state !== "enabled-failing" && status.state !== "installed-disabled")
3989
4472
  return;
3990
4473
  const fileBacked = status.mechanism === "systemd-user" || status.mechanism === "systemd-system" || status.mechanism === "xdg-autostart" || status.mechanism === "launchd-agent" || status.mechanism === "launchd-daemon";
3991
- if (fileBacked && (status.unitPath === null || !fs13.existsSync(status.unitPath)))
4474
+ if (fileBacked && (status.unitPath === null || !fs15.existsSync(status.unitPath)))
3992
4475
  return;
3993
4476
  try {
3994
4477
  await this.installBoot();
@@ -3998,7 +4481,7 @@ var HomeHostedService = class extends Service {
3998
4481
  // -------------------------------------------------------------------------
3999
4482
  // Status
4000
4483
  // -------------------------------------------------------------------------
4001
- async status() {
4484
+ async status(refresh = false) {
4002
4485
  const client = await this.tryClient();
4003
4486
  const panel = await this.panelStatus();
4004
4487
  const { resolution, launcher, launcherVersion } = await this.cli();
@@ -4026,6 +4509,10 @@ var HomeHostedService = class extends Service {
4026
4509
  return {
4027
4510
  defaultEntryId: this.options.defaultEntryId,
4028
4511
  panel,
4512
+ panelRoot: this.options.home,
4513
+ panelRootSource: this.options.panelHomeSource ?? "instance",
4514
+ bootUnitName: bootUnitName(this.options.stateDir),
4515
+ instances: await this.instances(refresh),
4029
4516
  boot: await this.bootStatus(),
4030
4517
  // A deleted managed entry is put back here as well as at startup: whoever
4031
4518
  // deleted it is looking at the page that reports it missing.
@@ -4041,28 +4528,54 @@ var HomeHostedService = class extends Service {
4041
4528
  // -------------------------------------------------------------------------
4042
4529
  async call(endpoint, payload) {
4043
4530
  const input = payload ?? {};
4531
+ if (typeof input.home === "string" && input.home.trim().length > 0 && FOREIGN_MECHANISMS[endpoint] === void 0) {
4532
+ throw new HomeHostedError(
4533
+ `"${endpoint}" is about the panel this plugin manages and cannot be aimed at another one`,
4534
+ "INSTANCE_UNSUPPORTED"
4535
+ );
4536
+ }
4044
4537
  switch (endpoint) {
4045
4538
  case "status":
4046
- return await this.status();
4539
+ return await this.status(input.refresh === true);
4047
4540
  case "settings.update": {
4048
4541
  const patch = input.patch ?? {};
4049
4542
  this.options.settings.update(patch);
4050
4543
  return await this.status();
4051
4544
  }
4052
- case "servers.list":
4053
- return await (await this.requireClient()).listServers();
4545
+ case "servers.list": {
4546
+ const target = await this.targetHome(input.home);
4547
+ if (!target.foreign)
4548
+ return await (await this.requireClient()).listServers();
4549
+ const via = this.mechanismFor("servers.list", input.via);
4550
+ if (via === "api" || input.via === void 0 && await this.foreignTokenWorks(target.home))
4551
+ return await (await this.foreignClient(target.home)).listServers();
4552
+ return this.listForeign(target.home);
4553
+ }
4054
4554
  case "servers.get":
4055
4555
  return await (await this.requireClient()).getServer(String(input.id));
4056
4556
  case "servers.create": {
4057
4557
  const entry = input.entry;
4058
4558
  if (typeof entry?.id !== "string" || !ENTRY_ID_PATTERN.test(entry.id))
4059
4559
  throw new HomeHostedError("a server entry needs an id matching ^[a-z0-9][a-z0-9_-]*$", "INVALID_ID");
4060
- return await (await this.requireClient()).createServer(entry);
4560
+ const target = await this.targetHome(input.home);
4561
+ if (!target.foreign)
4562
+ return await (await this.requireClient()).createServer(entry);
4563
+ return this.mechanismFor("servers.create", input.via) === "api" ? await (await this.foreignClient(target.home)).createServer(entry) : this.createForeign(target.home, entry);
4564
+ }
4565
+ case "servers.update": {
4566
+ const target = await this.targetHome(input.home);
4567
+ const id = String(input.id);
4568
+ const patch = input.patch ?? {};
4569
+ if (!target.foreign)
4570
+ return await (await this.requireClient()).updateServer(id, patch);
4571
+ return this.mechanismFor("servers.update", input.via) === "api" ? await (await this.foreignClient(target.home)).updateServer(id, patch) : this.updateForeign(target.home, id, patch);
4061
4572
  }
4062
- case "servers.update":
4063
- return await (await this.requireClient()).updateServer(String(input.id), input.patch ?? {});
4064
4573
  case "servers.delete": {
4065
4574
  const id = String(input.id);
4575
+ const target = await this.targetHome(input.home);
4576
+ if (target.foreign) {
4577
+ return this.mechanismFor("servers.delete", input.via) === "api" ? await (await this.foreignClient(target.home)).deleteServer(id).then(() => ({ id, home: target.home, via: "api" })) : this.deleteForeign(target.home, id);
4578
+ }
4066
4579
  if (id === this.selfEntryId()) {
4067
4580
  throw new HomeHostedError(
4068
4581
  `"${id}" is the entry this very process runs as, and deleting it stops this session; pause it (autostart off) or restore it instead`,
@@ -4072,15 +4585,45 @@ var HomeHostedService = class extends Service {
4072
4585
  await (await this.requireClient()).deleteServer(id);
4073
4586
  return { id };
4074
4587
  }
4075
- case "servers.start":
4076
- await (await this.requireClient()).startServer(String(input.id));
4588
+ case "servers.start": {
4589
+ const target = await this.targetHome(input.home);
4590
+ const id = String(input.id);
4591
+ if (target.foreign) {
4592
+ if (this.mechanismFor("servers.start", input.via) === "api") {
4593
+ await (await this.foreignClient(target.home)).startServer(id);
4594
+ return { home: target.home, id, action: "start", via: "api" };
4595
+ }
4596
+ return await this.lifecycleForeign(target.home, "start", id);
4597
+ }
4598
+ await (await this.requireClient()).startServer(id);
4077
4599
  return await this.entriesStatus();
4078
- case "servers.stop":
4079
- await (await this.requireClient()).stopServer(String(input.id));
4600
+ }
4601
+ case "servers.stop": {
4602
+ const target = await this.targetHome(input.home);
4603
+ const id = String(input.id);
4604
+ if (target.foreign) {
4605
+ if (this.mechanismFor("servers.stop", input.via) === "api") {
4606
+ await (await this.foreignClient(target.home)).stopServer(id);
4607
+ return { home: target.home, id, action: "stop", via: "api" };
4608
+ }
4609
+ return await this.lifecycleForeign(target.home, "stop", id);
4610
+ }
4611
+ await (await this.requireClient()).stopServer(id);
4080
4612
  return await this.entriesStatus();
4081
- case "servers.restart":
4082
- await (await this.requireClient()).restartServer(String(input.id));
4613
+ }
4614
+ case "servers.restart": {
4615
+ const target = await this.targetHome(input.home);
4616
+ const id = String(input.id);
4617
+ if (target.foreign) {
4618
+ if (this.mechanismFor("servers.restart", input.via) === "api") {
4619
+ await (await this.foreignClient(target.home)).restartServer(id);
4620
+ return { home: target.home, id, action: "restart", via: "api" };
4621
+ }
4622
+ return await this.lifecycleForeign(target.home, "restart", id);
4623
+ }
4624
+ await (await this.requireClient()).restartServer(id);
4083
4625
  return await this.entriesStatus();
4626
+ }
4084
4627
  case "servers.freePort":
4085
4628
  return await (await this.requireClient()).freePort(String(input.id));
4086
4629
  case "entries.apply": {
@@ -4091,8 +4634,16 @@ var HomeHostedService = class extends Service {
4091
4634
  return await this.removeManagedEntry(String(input.id));
4092
4635
  case "entries.restore":
4093
4636
  return await this.restoreEntry(String(input.id));
4094
- case "ui.manage":
4095
- return await this.uiManage(input.action, typeof input.file === "string" ? input.file : void 0);
4637
+ case "ui.manage": {
4638
+ const target = await this.targetHome(input.home);
4639
+ if (target.foreign)
4640
+ this.mechanismFor("ui.manage", input.via);
4641
+ return await this.uiManage(
4642
+ input.action,
4643
+ typeof input.file === "string" ? input.file : void 0,
4644
+ target.foreign ? target.home : void 0
4645
+ );
4646
+ }
4096
4647
  case "boot.install":
4097
4648
  return await this.installBoot(input.mechanism);
4098
4649
  case "boot.uninstall":
@@ -4101,6 +4652,8 @@ var HomeHostedService = class extends Service {
4101
4652
  return await this.bootStatus();
4102
4653
  case "panel.start":
4103
4654
  return await this.startPanelNow();
4655
+ case "panel.stop":
4656
+ return await this.stopPanelNow();
4104
4657
  case "panel.takeover":
4105
4658
  return await this.takeoverPanel(input.force === true);
4106
4659
  case "panel.reclaimToken":
@@ -4170,6 +4723,24 @@ function registerRpc(ctx, service) {
4170
4723
  });
4171
4724
  }
4172
4725
 
4726
+ // src/home-hosted/instances-notice.ts
4727
+ var INSTANCES_CONTEXT_ORDER = 130;
4728
+ function instancesContextText(instances, enabled) {
4729
+ return enabled ? instancesNoticeText(instances) ?? "" : "";
4730
+ }
4731
+ function registerInstancesNotice(ctx, deps) {
4732
+ ctx.inject(["systemPrompt"], (scoped) => {
4733
+ const prompt = scoped.systemPrompt;
4734
+ if (typeof prompt?.context !== "function")
4735
+ return;
4736
+ scoped.effect(() => prompt.context({
4737
+ name: "dsh-home-hosted:instances",
4738
+ order: INSTANCES_CONTEXT_ORDER,
4739
+ text: () => instancesContextText(deps.instances(), deps.enabled())
4740
+ }), "dsh-home-hosted: instance notice");
4741
+ });
4742
+ }
4743
+
4173
4744
  // src/home-hosted/web-notice.ts
4174
4745
  var STOCK_UNAUTHORIZED = "dsh web authentication required; reopen the URL printed by dsh web.";
4175
4746
  function authNoticeBody(logUrl) {
@@ -4269,6 +4840,7 @@ var LEGACY_TOOL_NAMES = {
4269
4840
  function isLegacyDefaultAllowlist(names) {
4270
4841
  return names.length === 2 && names.includes("status") && names.includes("servers_list");
4271
4842
  }
4843
+ var PRE_MERGE_SETTINGS_VERSION = 2;
4272
4844
  var BOOT_MECHANISMS = ["auto", "systemd-user", "systemd-system", "launchd-agent", "launchd-daemon", "xdg-autostart", "windows-run", "windows-task", "container", "unsupported"];
4273
4845
  function isBootMechanism(value) {
4274
4846
  return typeof value === "string" && BOOT_MECHANISMS.includes(value);
@@ -4276,7 +4848,7 @@ function isBootMechanism(value) {
4276
4848
  function normalize(raw, fallbackEntryId) {
4277
4849
  const entries = Array.isArray(raw?.entries) ? raw.entries.map((intent) => normalizeIntent(intent, fallbackEntryId)) : [];
4278
4850
  const declared = Array.isArray(raw?.agentTools?.allow) ? raw.agentTools.allow : null;
4279
- const legacyFile = (typeof raw?.version === "number" ? raw.version : 1) < SETTINGS_VERSION;
4851
+ const legacyFile = (typeof raw?.version === "number" ? raw.version : 1) < PRE_MERGE_SETTINGS_VERSION;
4280
4852
  const migrated = declared === null ? null : [...new Set(declared.map((name2) => legacyFile ? LEGACY_TOOL_NAMES[name2] ?? name2 : name2).filter(knownTool))];
4281
4853
  const choseNothing = legacyFile && declared !== null && migrated !== null && isLegacyDefaultAllowlist(declared);
4282
4854
  const allow = migrated === null || choseNothing ? [...DEFAULT_SETTINGS.agentTools.allow] : migrated;
@@ -4300,6 +4872,8 @@ function normalize(raw, fallbackEntryId) {
4300
4872
  authNotice: raw?.authNotice === void 0 ? DEFAULT_SETTINGS.authNotice : raw.authNotice === true,
4301
4873
  // Absent means on: a stale token is a fault to repair, not a setting to opt into.
4302
4874
  reclaimToken: raw?.reclaimToken === void 0 ? DEFAULT_SETTINGS.reclaimToken : raw.reclaimToken === true,
4875
+ // Absent means on: a plugin that owns one panel should say so by default.
4876
+ instancesNotice: raw?.instancesNotice === void 0 ? DEFAULT_SETTINGS.instancesNotice : raw.instancesNotice === true,
4303
4877
  uiStyle: raw?.uiStyle === "compact" ? "compact" : "detailed",
4304
4878
  agentTools: {
4305
4879
  // Absent means the default (on), and so does the pair the previous
@@ -4338,6 +4912,7 @@ var SettingsStore = class {
4338
4912
  panel: { ...this.current.panel, ...patch.panel ?? {} },
4339
4913
  authNotice: patch.authNotice ?? this.current.authNotice,
4340
4914
  reclaimToken: patch.reclaimToken ?? this.current.reclaimToken,
4915
+ instancesNotice: patch.instancesNotice ?? this.current.instancesNotice,
4341
4916
  uiStyle: patch.uiStyle ?? this.current.uiStyle,
4342
4917
  agentTools: { ...this.current.agentTools, ...patch.agentTools ?? {} },
4343
4918
  cli: { ...this.current.cli, ...patch.cli ?? {} }
@@ -4369,6 +4944,10 @@ function sandboxMode(ctx, exec) {
4369
4944
  return null;
4370
4945
  }
4371
4946
  }
4947
+ function withTarget(payload, home, via) {
4948
+ const target = via === null ? { home } : { home, via };
4949
+ return typeof payload === "object" && payload !== null ? { ...payload, ...target } : target;
4950
+ }
4372
4951
  function stringArg(input, key) {
4373
4952
  const value = input[key];
4374
4953
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
@@ -4377,20 +4956,62 @@ function jsonArg(input, key) {
4377
4956
  const value = input[key];
4378
4957
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
4379
4958
  }
4959
+ function jsonArgError(input, key, purpose) {
4960
+ const value = input[key];
4961
+ const received = value === void 0 ? "missing" : Array.isArray(value) ? "array" : typeof value;
4962
+ return new Error(`${key} must be a JSON object ${purpose} (received: ${received})`);
4963
+ }
4964
+ var INSTANCE_PARAM = {
4965
+ type: "string",
4966
+ description: "Home-hosted panel to act on: a state root (--home) or URL reported by home_hosted_status. Omit for the panel this plugin manages; naming another panel asks the user how to reach it before it changes anything."
4967
+ };
4968
+ var ENTRY_FIELDS = {
4969
+ id: { type: "string", required: true, description: "Entry id, ^[a-z0-9][a-z0-9_-]*$" },
4970
+ command: { type: "string", description: "Executable to run" },
4971
+ args: { type: "array", items: { type: "string" }, description: "Arguments" },
4972
+ cwd: { type: "string", description: "Working directory" },
4973
+ label: { type: "string", description: "Human label" },
4974
+ enabled: { type: "boolean", description: "Whether the entry may run" },
4975
+ autostart: { type: "boolean", description: "Start it when the panel starts" },
4976
+ // The panel's schema is `1..65535 | null`, and a patch that clears a port sends it.
4977
+ port: { oneOf: [{ type: "number" }, { type: "null" }], description: "Port the panel tracks for this entry, or null" },
4978
+ bind: { type: "string", description: "Bind host or local/lan" },
4979
+ onPortConflict: { type: "string", enum: [...ON_PORT_CONFLICT_POLICIES], description: "Policy when the port is taken" },
4980
+ stop: { type: "object", additionalProperties: true, description: "Stop policy, e.g. killPortHolders" },
4981
+ health: { type: "object", additionalProperties: true, description: "Health check block" },
4982
+ restart: { type: "object", additionalProperties: true, description: "Restart policy block" },
4983
+ env: { type: "object", additionalProperties: true, description: "Environment variables" },
4984
+ dataEnvs: { type: "object", additionalProperties: true, description: "Environment entries the panel fills in" }
4985
+ };
4986
+ var ENTRY_PARAM = {
4987
+ type: "object",
4988
+ additionalProperties: true,
4989
+ description: "A home-hosted server entry: at least id and command",
4990
+ properties: ENTRY_FIELDS
4991
+ };
4992
+ var PATCH_PARAM = {
4993
+ type: "object",
4994
+ additionalProperties: true,
4995
+ description: "Fields to change on an existing entry; only the given keys are touched",
4996
+ // The same fields, none required and without `id`: the entry patched is the one
4997
+ // the `id` argument names, and the panel rejects a patch that carries an id.
4998
+ properties: Object.fromEntries(Object.entries(ENTRY_FIELDS).filter(([key]) => key !== "id"))
4999
+ };
4380
5000
  var TOOL_SPECS = {
4381
5001
  status: {
4382
- description: "Report the home-hosted panel state, its boot-autostart entry, and the entries this plugin manages.",
4383
- parameters: {},
5002
+ description: "Report the home-hosted panel state, every home-hosted panel found on this machine, its boot-autostart entry, and the entries this plugin manages. This describes the managed panel only; its payload lists the others.",
5003
+ parameters: { instance: INSTANCE_PARAM },
4384
5004
  run: () => ({ endpoint: "status", payload: {} })
4385
5005
  },
4386
5006
  servers_list: {
4387
- description: "List every server home-hosted supervises, with status, pid and url.",
4388
- parameters: {},
5007
+ description: "List every server home-hosted supervises, with status, pid and url. A named panel is read from its config file, or through its API when this plugin already holds a token for that panel; naming the API mechanism mints one.",
5008
+ parameters: { instance: INSTANCE_PARAM },
4389
5009
  run: () => ({ endpoint: "servers.list", payload: {} })
4390
5010
  },
4391
5011
  servers_lifecycle: {
4392
- description: "Start, stop or restart a server supervised by home-hosted. Restarting the entry this session runs as ends the session.",
5012
+ description: "Start, stop or restart a server supervised by home-hosted. Restarting the entry this session runs as ends the session. A named panel is driven through the home-hosted CLI aimed at its state root, or through its API, and a CLI restart is a stop then a start.",
4393
5013
  parameters: {
5014
+ instance: INSTANCE_PARAM,
4394
5015
  action: { type: "string", required: true, description: "start, stop or restart" },
4395
5016
  id: { type: "string", required: true, description: "Server entry id" }
4396
5017
  },
@@ -4405,27 +5026,31 @@ var TOOL_SPECS = {
4405
5026
  }
4406
5027
  },
4407
5028
  servers_edit: {
4408
- description: "Create, update or delete a home-hosted server entry. Delete is refused for the entry this session runs as.",
5029
+ description: "Create, update or delete a home-hosted server entry. Delete is refused for the entry this session runs as. A named panel is edited through its servers.config.json, or through its API with a token this plugin mints.",
4409
5030
  parameters: {
5031
+ instance: INSTANCE_PARAM,
4410
5032
  action: { type: "string", required: true, description: "create, update or delete" },
4411
5033
  id: { type: "string", description: "Server entry id (update, delete)" },
4412
- entry: { type: "json", description: "A full home-hosted server entry, including its id and command (create)" },
4413
- patch: { type: "json", description: "Fields to change (update)" }
5034
+ entry: ENTRY_PARAM,
5035
+ patch: PATCH_PARAM
4414
5036
  },
4415
5037
  run: (input) => {
4416
5038
  const action = stringArg(input, "action");
4417
5039
  if (action === "create") {
4418
5040
  const entry = jsonArg(input, "entry");
4419
5041
  if (entry === null)
4420
- throw new Error("entry is required to create a server");
5042
+ throw jsonArgError(input, "entry", "to create a server");
4421
5043
  return { endpoint: "servers.create", payload: { entry } };
4422
5044
  }
4423
5045
  if (action === "update") {
4424
5046
  const id = stringArg(input, "id");
4425
5047
  const patch = jsonArg(input, "patch");
4426
- if (id === null || patch === null)
4427
- throw new Error("id and patch are required to update a server");
4428
- return { endpoint: "servers.update", payload: { id, patch } };
5048
+ if (id === null)
5049
+ throw new Error("id is required to update a server");
5050
+ if (patch === null)
5051
+ throw jsonArgError(input, "patch", "to update a server");
5052
+ const { id: _key, ...fields } = patch;
5053
+ return { endpoint: "servers.update", payload: { id, patch: fields } };
4429
5054
  }
4430
5055
  if (action === "delete") {
4431
5056
  const id = stringArg(input, "id");
@@ -4437,8 +5062,9 @@ var TOOL_SPECS = {
4437
5062
  }
4438
5063
  },
4439
5064
  autostart_manage: {
4440
- description: "Install or remove the OS entry that starts home-hosted at boot or login.",
5065
+ description: "Install or remove the OS entry that starts home-hosted at boot or login. The entry is machine-wide and always starts the panel this plugin manages.",
4441
5066
  parameters: {
5067
+ instance: INSTANCE_PARAM,
4442
5068
  action: { type: "string", required: true, description: "install or uninstall" },
4443
5069
  mechanism: { type: "string", description: "Explicit mechanism, e.g. launchd-daemon or systemd-system; omit to use the plugin setting" }
4444
5070
  },
@@ -4451,8 +5077,9 @@ var TOOL_SPECS = {
4451
5077
  }
4452
5078
  },
4453
5079
  ui_manage: {
4454
- description: "Inspect or change the home-hosted panel's own web UI: status, update, revert to stock, or switch to a local zip.",
5080
+ description: "Inspect or change the home-hosted panel's own web UI: status, update, revert to stock, or switch to a local zip. A named panel's UI is driven through the home-hosted CLI aimed at its state root.",
4455
5081
  parameters: {
5082
+ instance: INSTANCE_PARAM,
4456
5083
  action: { type: "string", required: true, description: "status, update, revert or switch" },
4457
5084
  file: { type: "string", description: "Absolute path to a UI zip (switch)" }
4458
5085
  },
@@ -4471,6 +5098,198 @@ function toolNameFor(name2) {
4471
5098
  var READ_ONLY_ACTIONS = {
4472
5099
  ui_manage: (input) => stringArg(input, "action") === "status"
4473
5100
  };
5101
+ var CANCEL_LABEL = "Cancel";
5102
+ var NO_ANSWERER_CODES = /* @__PURE__ */ new Set(["NO_PROVIDER", "DELEGATED_CALLER", "CALLER_NOT_LIVE"]);
5103
+ var MECHANISM_WORDING = {
5104
+ file: { option: "Edit its config file", phrase: "by editing its servers.config.json" },
5105
+ cli: { option: "Run the home-hosted CLI", phrase: "by running the home-hosted CLI against its state root" },
5106
+ api: { option: "Generate a token and use its API", phrase: "by enrolling a token for it and using its API" }
5107
+ };
5108
+ var NO_FOREIGN_REASON = {
5109
+ "status": "home_hosted_status describes the panel this plugin manages, and it already lists every panel found on the machine.",
5110
+ "boot.install": "the OS boot entry starts the panel this plugin manages; it is one machine-wide entry and cannot be aimed at another panel.",
5111
+ "boot.uninstall": "the OS boot entry is one machine-wide entry and belongs to the panel this plugin manages."
5112
+ };
5113
+ async function inventoryOf(service) {
5114
+ const reader = service.instances;
5115
+ if (typeof reader !== "function")
5116
+ return [];
5117
+ try {
5118
+ return await reader.call(service);
5119
+ } catch {
5120
+ return [];
5121
+ }
5122
+ }
5123
+ function matchInstance(instances, wanted) {
5124
+ const target = wanted.trim();
5125
+ if (target.length === 0)
5126
+ return null;
5127
+ const asPath = canonicalPath(target);
5128
+ return instances.find(
5129
+ (instance) => instance.home === asPath || instance.url === target || instance.projectDir !== null && instance.projectDir === target
5130
+ ) ?? null;
5131
+ }
5132
+ function inventoryText(instances) {
5133
+ return instances.map((instance) => `${instance.managed ? "managed: " : ""}${describeInstance(instance)}`).join("; ");
5134
+ }
5135
+ function refusalForUnknownPanel(instances, wanted) {
5136
+ return `refused: no home-hosted panel at "${wanted}" was found on this machine. Panels found: ${inventoryText(instances)}. Ask the user which panel they mean, or read home_hosted_status for the inventory.`;
5137
+ }
5138
+ function refusalForUnreachablePanel(instances, target, endpoint) {
5139
+ const reason = NO_FOREIGN_REASON[endpoint] ?? `this plugin has no way to change another panel's state for ${endpoint}`;
5140
+ return `refused: ${describeInstance(target)} is another home-hosted panel, and ${reason} Panels found: ${inventoryText(instances)}.`;
5141
+ }
5142
+ function answerOf(answer2, id) {
5143
+ const item = (Array.isArray(answer2?.answers) ? answer2.answers : []).find((entry) => entry.id === id);
5144
+ return {
5145
+ selected: Array.isArray(item?.selected) ? item.selected : [],
5146
+ custom: typeof item?.custom === "string" && item.custom.trim().length > 0 ? item.custom.trim() : null
5147
+ };
5148
+ }
5149
+ async function planTarget(service, settings, endpoint, requested, mutating) {
5150
+ const instances = await inventoryOf(service);
5151
+ if (instances.length === 0) {
5152
+ return requested === null ? { ok: true, home: null, ask: null } : { ok: false, text: `refused: this host could not list the home-hosted panels on this machine, so "${requested}" cannot be checked; ask the user, or call without instance to act on the panel this plugin manages.` };
5153
+ }
5154
+ const managed = instances.find((instance) => instance.managed);
5155
+ const mechanisms = FOREIGN_MECHANISMS[endpoint];
5156
+ if (requested !== null) {
5157
+ const target = matchInstance(instances, requested);
5158
+ if (target === null)
5159
+ return { ok: false, text: refusalForUnknownPanel(instances, requested) };
5160
+ if (target.managed)
5161
+ return { ok: true, home: null, ask: null };
5162
+ if (mechanisms === void 0)
5163
+ return { ok: false, text: refusalForUnreachablePanel(instances, target, endpoint) };
5164
+ return mutating ? { ok: true, home: target.home, ask: "foreign", mechanisms } : { ok: true, home: target.home, ask: null };
5165
+ }
5166
+ if (!mutating || !settings.get().instancesNotice || managed === void 0 || mechanisms === void 0 || instances.length < 2)
5167
+ return { ok: true, home: null, ask: null };
5168
+ return { ok: true, home: null, ask: "which", mechanisms };
5169
+ }
5170
+ async function ask(ctx, exec, questions, item) {
5171
+ try {
5172
+ const answer2 = await questions.ask({
5173
+ questions: [{ id: item.id, header: "home-hosted", question: item.question, detail: item.detail, options: item.options }],
5174
+ agent: exec?.agent,
5175
+ signal: exec?.signal
5176
+ });
5177
+ const { selected, custom } = answerOf(answer2, item.id);
5178
+ return { ok: true, selected, custom };
5179
+ } catch (error) {
5180
+ return { ok: false, code: errorCode(error), error };
5181
+ }
5182
+ }
5183
+ async function askForeignPanel(ctx, exec, instances, target, mechanisms) {
5184
+ const questions = ctx.get("userQuestions");
5185
+ const first = mechanisms[0];
5186
+ if (typeof questions?.ask !== "function")
5187
+ return { ok: true, home: target.home, via: first };
5188
+ const answer2 = await ask(ctx, exec, questions, {
5189
+ id: "home-hosted-foreign-panel",
5190
+ question: `${target.home} is not a panel this plugin manages. How should it be changed?`,
5191
+ detail: inventoryText(instances),
5192
+ options: [
5193
+ ...mechanisms.map((mechanism) => ({
5194
+ label: MECHANISM_WORDING[mechanism].option,
5195
+ description: `${MECHANISM_WORDING[mechanism].phrase}${mechanism === "api" ? "; that mints a token for it, replacing any it had" : "; this plugin holds no API token for that panel"}`
5196
+ })),
5197
+ { label: CANCEL_LABEL, description: "Change nothing; say which panel you meant instead." }
5198
+ ]
5199
+ });
5200
+ if (!answer2.ok) {
5201
+ if (answer2.code === "ASK_ABORTED")
5202
+ return { ok: false, text: "cancelled: the user dismissed the panel question to speak instead; stop and wait for their message." };
5203
+ if (answer2.code !== null && NO_ANSWERER_CODES.has(answer2.code))
5204
+ return { ok: true, home: target.home, via: first };
5205
+ return { ok: false, text: `refused: the panel question could not be asked (${messageOf(answer2.error)}); ask the user before changing another panel.` };
5206
+ }
5207
+ if (answer2.selected.includes(CANCEL_LABEL))
5208
+ return { ok: false, text: `cancelled: the user did not confirm changing ${target.home}; ask which panel they meant.` };
5209
+ const chosen = answer2.selected.find((label) => label !== CANCEL_LABEL);
5210
+ if (chosen !== void 0) {
5211
+ const mechanism = mechanisms.find((candidate) => MECHANISM_WORDING[candidate].option === chosen);
5212
+ if (mechanism !== void 0)
5213
+ return { ok: true, home: target.home, via: mechanism };
5214
+ }
5215
+ if (chosen === void 0 && answer2.custom !== null) {
5216
+ const mechanism = mechanisms.find((candidate) => matchesMechanism(answer2.custom, candidate));
5217
+ if (mechanism !== void 0)
5218
+ return { ok: true, home: target.home, via: mechanism };
5219
+ }
5220
+ return { ok: false, text: `cancelled: the user did not confirm changing ${target.home}; ask which panel they meant.` };
5221
+ }
5222
+ function matchesMechanism(text, mechanism) {
5223
+ const value = text.toLowerCase();
5224
+ if (mechanism === "file")
5225
+ return /file|config/.test(value);
5226
+ if (mechanism === "cli")
5227
+ return /cli|command|terminal/.test(value);
5228
+ return /api|token/.test(value);
5229
+ }
5230
+ async function askWhichPanel(ctx, exec, instances, managed, others, mechanisms) {
5231
+ const questions = ctx.get("userQuestions");
5232
+ if (typeof questions?.ask !== "function")
5233
+ return { ok: true, home: null, via: null };
5234
+ const answer2 = await ask(ctx, exec, questions, {
5235
+ id: "home-hosted-instance",
5236
+ question: `${instances.length} home-hosted panels were found on this machine. Act on the panel this plugin manages?`,
5237
+ detail: inventoryText(instances),
5238
+ options: [
5239
+ { label: managed.home, description: `managed by this plugin${managed.url === null ? "" : ` \xB7 ${managed.url}`}` },
5240
+ ...others.map((instance) => ({
5241
+ label: instance.home,
5242
+ description: `not managed by this plugin${instance.url === null ? "" : ` \xB7 ${instance.url}`} \xB7 ${mechanisms.map((candidate) => MECHANISM_WORDING[candidate].phrase).join(", or ")}`
5243
+ })),
5244
+ { label: CANCEL_LABEL, description: "Do nothing; say which panel you meant instead." }
5245
+ ]
5246
+ });
5247
+ if (!answer2.ok) {
5248
+ if (answer2.code === "ASK_ABORTED")
5249
+ return { ok: false, text: "cancelled: the user dismissed the panel question to speak instead; stop and wait for their message." };
5250
+ if (answer2.code !== null && NO_ANSWERER_CODES.has(answer2.code))
5251
+ return { ok: true, home: null, via: null };
5252
+ return { ok: false, text: `refused: the panel question could not be asked (${messageOf(answer2.error)}); ask the user which panel they mean before acting.` };
5253
+ }
5254
+ if (answer2.selected.includes(CANCEL_LABEL))
5255
+ return { ok: false, text: "cancelled: the user chose not to pick a panel; ask them which one they mean." };
5256
+ const chosen = answer2.selected.find((label) => label !== CANCEL_LABEL);
5257
+ if (chosen !== void 0) {
5258
+ const instance = matchInstance(instances, chosen);
5259
+ if (instance === null)
5260
+ return { ok: false, text: `cancelled: the answer "${chosen}" names no panel; ask the user which panel they mean.` };
5261
+ return instance.managed ? { ok: true, home: null, via: null } : { ok: true, home: instance.home, via: null };
5262
+ }
5263
+ if (answer2.custom !== null) {
5264
+ const instance = matchInstance(instances, answer2.custom);
5265
+ if (instance?.managed === true)
5266
+ return { ok: true, home: null, via: null };
5267
+ if (instance !== null)
5268
+ return { ok: true, home: instance.home, via: null };
5269
+ return { ok: false, text: `cancelled: the user answered "${answer2.custom}", which names no panel; ask them which panel they mean.` };
5270
+ }
5271
+ return { ok: false, text: "cancelled: the panel question was not answered; ask the user which panel they mean before acting." };
5272
+ }
5273
+ async function confirmPlan(ctx, exec, service, plan) {
5274
+ if (plan.ask === null)
5275
+ return { ok: true, home: plan.home, via: null };
5276
+ const instances = await inventoryOf(service);
5277
+ const managed = instances.find((instance) => instance.managed);
5278
+ if (plan.ask === "foreign") {
5279
+ const target = instances.find((instance) => instance.home === plan.home);
5280
+ return target === void 0 ? { ok: false, text: refusalForUnknownPanel(instances, plan.home) } : await askForeignPanel(ctx, exec, instances, target, plan.mechanisms);
5281
+ }
5282
+ if (managed === void 0)
5283
+ return { ok: true, home: null, via: null };
5284
+ const others = instances.filter((instance) => !instance.managed);
5285
+ const picked = await askWhichPanel(ctx, exec, instances, managed, others, plan.mechanisms);
5286
+ if (!picked.ok || picked.home === null)
5287
+ return picked;
5288
+ const chosen = instances.find((instance) => instance.home === picked.home);
5289
+ if (chosen === void 0 || chosen.managed)
5290
+ return picked;
5291
+ return await askForeignPanel(ctx, exec, instances, chosen, plan.mechanisms);
5292
+ }
4474
5293
  var TOKEN_REFUSAL_CODES = /* @__PURE__ */ new Set([
4475
5294
  "AUTH_REQUIRED",
4476
5295
  "AUTH_UNARMED",
@@ -4527,8 +5346,17 @@ function registerOne(ctx, service, settings, name2) {
4527
5346
  },
4528
5347
  async execute(args, exec) {
4529
5348
  const input = args ?? {};
5349
+ let request;
5350
+ try {
5351
+ request = spec.run(input);
5352
+ } catch (error) {
5353
+ return failureText(error);
5354
+ }
4530
5355
  const mode = sandboxMode(ctx, exec);
4531
5356
  const mutating = mutatingTool && !(READ_ONLY_ACTIONS[name2]?.(input) ?? false);
5357
+ const plan = await planTarget(service, settings, request.endpoint, stringArg(input, "instance"), mutating);
5358
+ if (!plan.ok)
5359
+ return plan.text;
4532
5360
  if (mutating && mode !== "danger-full-access") {
4533
5361
  const remedy = "Set the session to Full access (danger-full-access), or use a session where approvals can be answered.";
4534
5362
  const approval = ctx.get("approval");
@@ -4542,16 +5370,17 @@ function registerOne(ctx, service, settings, name2) {
4542
5370
  if (outcome !== "allowed-once")
4543
5371
  return `refused: approval answered "${outcome}" (session sandbox: ${mode ?? "unknown"}). ${remedy}`;
4544
5372
  }
4545
- let request;
4546
- try {
4547
- request = spec.run(input);
4548
- } catch (error) {
4549
- return failureText(error);
4550
- }
5373
+ const target = await confirmPlan(ctx, exec, service, plan);
5374
+ if (!target.ok)
5375
+ return target.text;
5376
+ const payload = target.home === null ? request.payload : withTarget(request.payload, target.home, target.via ?? FOREIGN_MECHANISMS[request.endpoint]?.[0] ?? null);
5377
+ const landing = target.home === null ? "" : `
5378
+
5379
+ home-hosted: ${plan.ask === null ? "read from" : "acted on"} ${target.home}, not the panel this plugin manages${target.via === null ? "" : `, ${MECHANISM_WORDING[target.via].phrase}`}.`;
4551
5380
  try {
4552
- return JSON.stringify(await service.call(request.endpoint, request.payload), null, 2);
5381
+ return `${JSON.stringify(await service.call(request.endpoint, payload), null, 2)}${landing}`;
4553
5382
  } catch (error) {
4554
- if (!settings.get().reclaimToken || !await tokenRefused(service, error))
5383
+ if (target.home !== null || !settings.get().reclaimToken || !await tokenRefused(service, error))
4555
5384
  return failureText(error);
4556
5385
  try {
4557
5386
  await service.call("panel.reclaimToken", {});
@@ -4559,7 +5388,7 @@ function registerOne(ctx, service, settings, name2) {
4559
5388
  return `${failureText(error)} (token reclaim failed: ${messageOf(reclaimError)})`;
4560
5389
  }
4561
5390
  try {
4562
- return JSON.stringify(await service.call(request.endpoint, request.payload), null, 2);
5391
+ return `${JSON.stringify(await service.call(request.endpoint, payload), null, 2)}${landing}`;
4563
5392
  } catch (retryError) {
4564
5393
  return failureText(retryError);
4565
5394
  }
@@ -4602,21 +5431,31 @@ function apply(ctx, config) {
4602
5431
  const resolved = {
4603
5432
  stateDir: config?.stateDir,
4604
5433
  homeHostedCommand: config?.homeHostedCommand,
4605
- defaultEntryId: config?.defaultEntryId ?? "dsh"
5434
+ defaultEntryId: config?.defaultEntryId ?? "dsh",
5435
+ instanceRoots: config?.instanceRoots ?? []
4606
5436
  };
4607
5437
  const stateDir = pluginStateDir(resolved.stateDir);
4608
5438
  ensureDir(stateDir, 448);
4609
- const settings = new SettingsStore(path14.join(stateDir, "settings.json"), resolved.defaultEntryId);
5439
+ const panel = resolveHomeHostedHome(stateDir);
5440
+ const settings = new SettingsStore(path15.join(stateDir, "settings.json"), resolved.defaultEntryId);
4610
5441
  const service = new HomeHostedService(ctx, {
4611
- home: homeHostedHome(),
5442
+ home: panel.home,
5443
+ panelHomeSource: panel.source,
4612
5444
  stateDir,
4613
5445
  homeHostedCommand: resolved.homeHostedCommand,
4614
5446
  defaultEntryId: resolved.defaultEntryId,
5447
+ instanceRoots: resolved.instanceRoots,
4615
5448
  settings
4616
5449
  });
4617
5450
  registerRpc(ctx, service);
4618
5451
  registerAgentTools(ctx, service, settings);
5452
+ registerInstancesNotice(ctx, {
5453
+ instances: () => service.instancesNow(),
5454
+ enabled: () => settings.get().instancesNotice
5455
+ });
4619
5456
  registerAuthNotice(ctx, () => settings.get().authNotice ? service.panelLogUrl() : null);
5457
+ void service.instances().catch(() => {
5458
+ });
4620
5459
  ctx.effect(() => {
4621
5460
  const timer = setTimeout(() => {
4622
5461
  void service.reconcile().catch(() => {