dsh-home-hosted 0.2.2 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -40,13 +40,14 @@ var DEFAULT_SETTINGS = {
40
40
  agentTools: { enabled: true, allow: [...AGENT_TOOL_NAMES] },
41
41
  panel: { port: null },
42
42
  authNotice: true,
43
+ reclaimToken: true,
43
44
  uiStyle: "detailed",
44
45
  cli: { prefer: "pinned" }
45
46
  };
46
47
 
47
48
  // src/service.ts
48
49
  import { Service } from "@deepseek-ai/cordis";
49
- import fs12 from "node:fs";
50
+ import fs13 from "node:fs";
50
51
  import path13 from "node:path";
51
52
  import process10 from "node:process";
52
53
  import { fileURLToPath } from "node:url";
@@ -2032,8 +2033,9 @@ function removeEntry(raw, id) {
2032
2033
  }
2033
2034
 
2034
2035
  // src/home-hosted/dsh-entry.ts
2035
- import path8 from "node:path";
2036
- import process5 from "node:process";
2036
+ import fs11 from "node:fs";
2037
+ import path10 from "node:path";
2038
+ import process7 from "node:process";
2037
2039
 
2038
2040
  // src/home-hosted/launch.ts
2039
2041
  import fs8 from "node:fs";
@@ -2101,144 +2103,12 @@ function buildHomeHostedBootSpec(facts, launch, options) {
2101
2103
  };
2102
2104
  }
2103
2105
 
2104
- // src/home-hosted/dsh-entry.ts
2105
- function detectProfile(argv, env = {}) {
2106
- for (let index = 0; index < argv.length; index += 1) {
2107
- const arg = argv[index];
2108
- if (arg === "--profile" && typeof argv[index + 1] === "string" && !argv[index + 1].startsWith("-"))
2109
- return argv[index + 1];
2110
- if (arg?.startsWith("--profile="))
2111
- return arg.slice("--profile=".length);
2112
- }
2113
- const dir = env.DSH_PROFILE_DIR;
2114
- if (typeof dir === "string" && dir.length > 0) {
2115
- const base = path8.basename(dir);
2116
- if (base.length > 0 && base !== "." && base !== path8.sep)
2117
- return base;
2118
- }
2119
- const app = argv.slice(2).find((candidate) => candidate !== void 0 && !candidate.startsWith("-"));
2120
- if (app !== void 0 && app.length > 0)
2121
- return app;
2122
- const fromEnv = env.DSH_PROFILE;
2123
- return typeof fromEnv === "string" && fromEnv.length > 0 ? fromEnv : "web";
2124
- }
2125
- async function resolveDshLaunch() {
2126
- const fromArgv = process5.argv[1];
2127
- if (typeof fromArgv === "string" && /\.[cm]?js$/.test(fromArgv) && path8.isAbsolute(fromArgv))
2128
- return { program: process5.execPath, args: [fromArgv], cliEntry: fromArgv, shimPath: null, source: "entry" };
2129
- const found = await which("dsh");
2130
- return found === null ? null : resolveShimmedCli(found, process5.execPath);
2131
- }
2132
- function buildDshEntry(facts) {
2133
- const launch = facts.launch;
2134
- const entryArgs = launch?.args ?? [];
2135
- const profile = facts.profile === null || facts.profile === void 0 ? "web" : facts.profile;
2136
- const appArgs = [];
2137
- if (facts.port !== null) {
2138
- appArgs.push(
2139
- "--port",
2140
- "{port}",
2141
- "--host",
2142
- "{host}",
2143
- "--no-open",
2144
- "--trusted-host",
2145
- `localhost:{port}`
2146
- );
2147
- if (facts.host === "0.0.0.0")
2148
- appArgs.push("--trusted-host", "{lanIp}:{port}");
2149
- }
2150
- const launcherArgs = profile === "web" ? ["web"] : ["--profile", profile];
2151
- const args = [...entryArgs, ...launcherArgs, ...appArgs];
2152
- return {
2153
- id: facts.id,
2154
- label: "DSH web",
2155
- enabled: true,
2156
- autostart: true,
2157
- command: launch?.program ?? "dsh",
2158
- args,
2159
- bind: facts.host === "0.0.0.0" ? "lan" : "local",
2160
- port: facts.port,
2161
- cwd: process5.cwd(),
2162
- env: {},
2163
- dataEnvs: { DSH_HOME: facts.dshHome },
2164
- onPortConflict: "kill",
2165
- stop: { killPortHolders: true },
2166
- health: {
2167
- enabled: true,
2168
- mode: "http",
2169
- // 401 is normal here: the panel answers the login route when auth is on.
2170
- http: { path: "/", method: "GET", expectStatusBelow: 500, expectBody: "" }
2171
- }
2172
- };
2173
- }
2174
-
2175
- // src/home-hosted/entries.ts
2176
- function defaultOnPortConflict(platform = process.platform) {
2177
- return platform === "win32" ? "kill" : "follow";
2178
- }
2179
- function defaultIntent(id, platform = process.platform) {
2180
- return {
2181
- id,
2182
- autostart: true,
2183
- onPortConflict: defaultOnPortConflict(platform),
2184
- stopKillPortHolders: true,
2185
- persistent: true
2186
- };
2187
- }
2188
- function ownedPatch(intent, live, options = {}) {
2189
- const stop = live?.stop ?? {};
2190
- return {
2191
- autostart: intent.autostart,
2192
- onPortConflict: intent.onPortConflict,
2193
- ...options.persistent === false ? {} : { persistent: intent.persistent },
2194
- stop: { ...stop, killPortHolders: intent.stopKillPortHolders }
2195
- };
2196
- }
2197
- function ownedDrift(live, intent, options = {}) {
2198
- if (live === null)
2199
- return ["missing entry"];
2200
- const drift = [];
2201
- if (live.autostart !== intent.autostart)
2202
- drift.push("autostart");
2203
- if (live.onPortConflict !== intent.onPortConflict)
2204
- drift.push("onPortConflict");
2205
- if (options.persistent !== false && (live.persistent ?? false) !== intent.persistent)
2206
- drift.push("persistent");
2207
- const killPortHolders = live.stop?.killPortHolders;
2208
- if (killPortHolders !== intent.stopKillPortHolders)
2209
- drift.push("stop.killPortHolders");
2210
- return drift;
2211
- }
2212
- function snapshotOwned(live) {
2213
- const killPortHolders = live.stop?.killPortHolders;
2214
- return {
2215
- id: live.id,
2216
- autostart: typeof live.autostart === "boolean" ? live.autostart : false,
2217
- onPortConflict: isOnPortConflict(live.onPortConflict) ? live.onPortConflict : "block",
2218
- persistent: live.persistent === true,
2219
- stop: { killPortHolders: typeof killPortHolders === "boolean" ? killPortHolders : false }
2220
- };
2221
- }
2222
- function restorePatch(live, snapshot) {
2223
- const stop = live.stop ?? {};
2224
- const previous = snapshot?.stop ?? {};
2225
- return {
2226
- autostart: snapshot?.autostart ?? false,
2227
- onPortConflict: snapshot?.onPortConflict ?? "block",
2228
- persistent: snapshot?.persistent === true,
2229
- stop: {
2230
- ...stop,
2231
- killPortHolders: typeof previous.killPortHolders === "boolean" ? previous.killPortHolders : false
2232
- }
2233
- };
2234
- }
2235
-
2236
2106
  // src/home-hosted/resolve.ts
2237
2107
  import { createRequire } from "node:module";
2238
2108
  import fs9 from "node:fs";
2239
- import path9 from "node:path";
2240
- import process6 from "node:process";
2241
- var EXPECTED_RANGE = "^0.6.4";
2109
+ import path8 from "node:path";
2110
+ import process5 from "node:process";
2111
+ var EXPECTED_RANGE = "^0.6.6";
2242
2112
  var MIN_SUPPORTED_VERSION = "0.4.1";
2243
2113
  var MIN_KILL_VERSION = "0.6.0";
2244
2114
  var MIN_PERSISTENT_VERSION = "0.6.3";
@@ -2253,7 +2123,7 @@ function binEntryFromManifest(manifestPath) {
2253
2123
  try {
2254
2124
  const manifest = JSON.parse(fs9.readFileSync(manifestPath, "utf8"));
2255
2125
  const declared = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["home-hosted"] ?? Object.values(manifest.bin ?? {})[0];
2256
- return typeof declared === "string" && declared.length > 0 ? path9.resolve(path9.dirname(manifestPath), declared) : null;
2126
+ return typeof declared === "string" && declared.length > 0 ? path8.resolve(path8.dirname(manifestPath), declared) : null;
2257
2127
  } catch {
2258
2128
  return null;
2259
2129
  }
@@ -2309,7 +2179,7 @@ async function resolveCli(options = {}) {
2309
2179
  const override = options.override?.trim();
2310
2180
  let unusableOverride = null;
2311
2181
  if (override !== void 0 && override.length > 0) {
2312
- const launch = resolveShimmedCli(path9.resolve(override));
2182
+ const launch = resolveShimmedCli(path8.resolve(override));
2313
2183
  if (launch === null) {
2314
2184
  unusableOverride = override;
2315
2185
  } else {
@@ -2343,13 +2213,13 @@ async function resolveCli(options = {}) {
2343
2213
  }
2344
2214
  dependency = {
2345
2215
  candidate: { source: "dependency", path: dependencyEntry, version: version2 },
2346
- launch: { program: process6.execPath, args: [dependencyEntry], cliEntry: dependencyEntry, shimPath: null, source: "entry" }
2216
+ launch: { program: process5.execPath, args: [dependencyEntry], cliEntry: dependencyEntry, shimPath: null, source: "entry" }
2347
2217
  };
2348
2218
  }
2349
2219
  let global = null;
2350
2220
  const found = await locate("home-hosted") ?? await locate("hh");
2351
2221
  if (found !== null) {
2352
- const launch = resolveShimmedCli(path9.resolve(found));
2222
+ const launch = resolveShimmedCli(path8.resolve(found));
2353
2223
  if (launch !== null) {
2354
2224
  global = {
2355
2225
  candidate: { source: "path", path: launch.cliEntry ?? launch.shimPath ?? launch.program, version: await readVersion(launch) },
@@ -2413,16 +2283,16 @@ async function resolveCli(options = {}) {
2413
2283
 
2414
2284
  // src/home-hosted/launcher.ts
2415
2285
  import fs10 from "node:fs";
2416
- import path10 from "node:path";
2417
- import process7 from "node:process";
2286
+ import path9 from "node:path";
2287
+ import process6 from "node:process";
2418
2288
  function launcherDir(stateDir) {
2419
- return path10.join(stateDir, "bin");
2289
+ return path9.join(stateDir, "bin");
2420
2290
  }
2421
2291
  function launcherPath(stateDir) {
2422
- return path10.join(launcherDir(stateDir), "home-hosted.mjs");
2292
+ return path9.join(launcherDir(stateDir), "home-hosted.mjs");
2423
2293
  }
2424
2294
  function launcherRecordPath(stateDir) {
2425
- return path10.join(launcherDir(stateDir), "resolved.json");
2295
+ return path9.join(launcherDir(stateDir), "resolved.json");
2426
2296
  }
2427
2297
  function buildLauncherSource(options) {
2428
2298
  const marker = options.marker ?? "managed by dsh-home-hosted";
@@ -2547,13 +2417,444 @@ async function preflightLauncher(stateDir, timeoutMs = 1e4) {
2547
2417
  const file = launcherPath(stateDir);
2548
2418
  if (!fs10.existsSync(file))
2549
2419
  return null;
2550
- const result = process7.platform === "win32" ? await run(process7.execPath, [file, "--version"], { timeoutMs }) : await run(file, ["--version"], { timeoutMs });
2420
+ const result = process6.platform === "win32" ? await run(process6.execPath, [file, "--version"], { timeoutMs }) : await run(file, ["--version"], { timeoutMs });
2551
2421
  return parseVersion(result.stdout) ?? parseVersion(result.stderr);
2552
2422
  }
2423
+ function dshLauncherRecordPath(stateDir) {
2424
+ return path9.join(launcherDir(stateDir), "dsh-resolved.json");
2425
+ }
2426
+ function dshLauncherPath(stateDir, entryExtension) {
2427
+ const extension = (entryExtension ?? "").toLowerCase() === ".cjs" ? ".cjs" : ".mjs";
2428
+ return path9.join(launcherDir(stateDir), `dsh${extension}`);
2429
+ }
2430
+ function readDshLauncherRecord(stateDir) {
2431
+ return readJson(dshLauncherRecordPath(stateDir));
2432
+ }
2433
+ function buildDshLauncherSource(options) {
2434
+ const esm = (options.entryExtension ?? "").toLowerCase() !== ".cjs";
2435
+ const prelude = esm ? `import fs from 'node:fs'
2436
+ import path from 'node:path'
2437
+ import process from 'node:process'
2438
+ import { spawnSync } from 'node:child_process'` : `const fs = require('node:fs')
2439
+ const path = require('node:path')
2440
+ const process = require('node:process')
2441
+ 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);
2443
+ return `#!/usr/bin/env node
2444
+ // managed by dsh-home-hosted \u2014 rewritten on every plugin start; do not edit.
2445
+ ${prelude}
2446
+
2447
+ const DSH_HOME = ${JSON.stringify(options.dshHome)}
2448
+ const RECORD = ${JSON.stringify(dshLauncherRecordPath(options.stateDir))}
2449
+ const SEARCH_ROOTS = ${JSON.stringify([...new Set(roots)])}
2450
+
2451
+ function readRecord() {
2452
+ try { return JSON.parse(fs.readFileSync(RECORD, 'utf8')) } catch { return null }
2453
+ }
2454
+
2455
+ function isEntry(file) {
2456
+ let stat = null
2457
+ try { stat = fs.statSync(file) } catch { return false }
2458
+ return stat.isFile() && /\\.(?:mjs|cjs|js)$/.test(file)
2459
+ }
2460
+
2461
+ function collect(out, entry) {
2462
+ if (typeof entry === 'string' && isEntry(entry) && !out.includes(entry)) out.push(entry)
2463
+ }
2464
+
2465
+ function collectStore(root, out) {
2466
+ const store = path.join(root, 'node_modules', '.pnpm')
2467
+ let names = []
2468
+ try { names = fs.readdirSync(store) } catch { return }
2469
+ for (const name of names) {
2470
+ 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'))
2473
+ }
2474
+ }
2475
+
2476
+ 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'))
2479
+ collectStore(root, out)
2480
+ }
2481
+
2482
+ function candidates() {
2483
+ const out = []
2484
+ const record = readRecord()
2485
+ if (record && typeof record.entry === 'string') collect(out, record.entry)
2486
+ for (const root of [...(Array.isArray(record?.roots) ? record.roots : []), ...SEARCH_ROOTS]) collectRoot(root, out)
2487
+ collectRoot(DSH_HOME, out)
2488
+ let profiles = []
2489
+ try { profiles = fs.readdirSync(path.join(DSH_HOME, 'profiles')) } catch {}
2490
+ for (const name of profiles) collectRoot(path.join(DSH_HOME, 'profiles', name), out)
2491
+ // Last resort: a dsh on the entry's own PATH.
2492
+ 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'))
2495
+ }
2496
+ return out
2497
+ }
2498
+
2499
+ function versionOf(entry) {
2500
+ let dir = path.dirname(entry)
2501
+ for (let hop = 0; hop < 4; hop += 1) {
2502
+ try {
2503
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
2504
+ if (typeof manifest.version === 'string') return manifest.version
2505
+ } catch {}
2506
+ dir = path.dirname(dir)
2507
+ }
2508
+ return null
2509
+ }
2510
+
2511
+ function compare(a, b) {
2512
+ const split = value => {
2513
+ const [core, pre = null] = String(value).split('-', 2)
2514
+ return { parts: core.split('.').map(part => Number.parseInt(part, 10) || 0), pre }
2515
+ }
2516
+ const left = split(a); const right = split(b)
2517
+ for (let i = 0; i < 3; i += 1) {
2518
+ const diff = (left.parts[i] ?? 0) - (right.parts[i] ?? 0)
2519
+ if (diff !== 0) return diff < 0 ? -1 : 1
2520
+ }
2521
+ if (left.pre === right.pre) return 0
2522
+ if (left.pre === null) return 1
2523
+ if (right.pre === null) return -1
2524
+ return left.pre < right.pre ? -1 : 1
2525
+ }
2526
+
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)
2533
+ if (known.length > 0) {
2534
+ known.sort((a, b) => (compare(b.version, a.version) || (Number(b.recorded) - Number(a.recorded))))
2535
+ return known[0].entry
2536
+ }
2537
+ return (found.find(item => item.recorded) ?? found[0])?.entry ?? null
2538
+ }
2539
+
2540
+ const entry = choose()
2541
+ 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')
2543
+ process.exit(1)
2544
+ }
2545
+
2546
+ const args = process.argv.slice(2)
2547
+ const result = /\\.(mjs|cjs|js)$/.test(entry)
2548
+ ? spawnSync(process.execPath, [entry, ...args], { stdio: 'inherit' })
2549
+ : spawnSync(entry, args, { stdio: 'inherit', shell: process.platform === 'win32' && /\\.(cmd|bat)$/i.test(entry) })
2550
+ process.exit(typeof result.status === 'number' ? result.status : 1)
2551
+ `;
2552
+ }
2553
+ function writeDshLauncher(options) {
2554
+ 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;
2558
+ if (changed) {
2559
+ writeFileAtomic(file, source, 493);
2560
+ fs10.chmodSync(file, 493);
2561
+ }
2562
+ const raw = [dshSearchRoot(options.resolvedEntry), ...options.searchRoots ?? [], ...readDshLauncherRecord(options.stateDir)?.roots ?? []];
2563
+ const known = raw.filter((root) => typeof root === "string" && root.length > 0 && dshRootUsable(root));
2564
+ const roots = [...new Set(known)];
2565
+ writeJsonAtomic(dshLauncherRecordPath(options.stateDir), {
2566
+ entry: options.resolvedEntry,
2567
+ roots,
2568
+ writtenAt: Date.now()
2569
+ }, 384);
2570
+ return { path: file, changed };
2571
+ }
2572
+ function dshCandidatesUnder(root) {
2573
+ const out = [];
2574
+ for (const entry of [
2575
+ path9.join(root, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
2576
+ path9.join(root, "node_modules", "dsh", "bin", "dsh.js")
2577
+ ]) {
2578
+ if (fs10.existsSync(entry))
2579
+ out.push(entry);
2580
+ }
2581
+ const store = path9.join(root, "node_modules", ".pnpm");
2582
+ if (fs10.existsSync(store)) {
2583
+ for (const name2 of fs10.readdirSync(store)) {
2584
+ if (!name2.startsWith("dsh@") && !name2.startsWith("@deepseek-ai+dsh@"))
2585
+ continue;
2586
+ for (const entry of [
2587
+ path9.join(store, name2, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
2588
+ path9.join(store, name2, "node_modules", "dsh", "bin", "dsh.js")
2589
+ ]) {
2590
+ if (fs10.existsSync(entry))
2591
+ out.push(entry);
2592
+ }
2593
+ }
2594
+ }
2595
+ return out;
2596
+ }
2597
+ function dshSearchRoot(entry) {
2598
+ if (typeof entry !== "string" || entry.length === 0)
2599
+ return null;
2600
+ 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;
2604
+ }
2605
+ function dshRootUsable(root) {
2606
+ return dshCandidatesUnder(root).length > 0 || fs10.existsSync(path9.join(root, "node_modules", ".pnpm"));
2607
+ }
2608
+
2609
+ // src/home-hosted/dsh-entry.ts
2610
+ function detectProfile(argv, env = {}) {
2611
+ for (let index = 0; index < argv.length; index += 1) {
2612
+ const arg = argv[index];
2613
+ if (arg === "--profile" && typeof argv[index + 1] === "string" && !argv[index + 1].startsWith("-"))
2614
+ return argv[index + 1];
2615
+ if (arg?.startsWith("--profile="))
2616
+ return arg.slice("--profile=".length);
2617
+ }
2618
+ const dir = env.DSH_PROFILE_DIR;
2619
+ if (typeof dir === "string" && dir.length > 0) {
2620
+ const base = path10.basename(dir);
2621
+ if (base.length > 0 && base !== "." && base !== path10.sep)
2622
+ return base;
2623
+ }
2624
+ const app = argv.slice(2).find((candidate) => candidate !== void 0 && !candidate.startsWith("-"));
2625
+ if (app !== void 0 && app.length > 0)
2626
+ return app;
2627
+ const fromEnv = env.DSH_PROFILE;
2628
+ return typeof fromEnv === "string" && fromEnv.length > 0 ? fromEnv : "web";
2629
+ }
2630
+ function versionOf(entry) {
2631
+ let dir = path10.dirname(entry);
2632
+ for (let hop = 0; hop < 4; hop += 1) {
2633
+ const manifest = readJson(path10.join(dir, "package.json"));
2634
+ if (typeof manifest?.version === "string")
2635
+ return manifest.version;
2636
+ dir = path10.dirname(dir);
2637
+ }
2638
+ return null;
2639
+ }
2640
+ function isEntry(file) {
2641
+ return /\.[cm]?js$/.test(file) && fs11.existsSync(file) && !fs11.statSync(file).isDirectory();
2642
+ }
2643
+ function realPath(file) {
2644
+ try {
2645
+ return fs11.realpathSync(file);
2646
+ } catch {
2647
+ return file;
2648
+ }
2649
+ }
2650
+ function cloneEntryFrom(argv1) {
2651
+ if (!isEntry(argv1))
2652
+ return null;
2653
+ const root = path10.dirname(path10.dirname(argv1));
2654
+ for (const candidate of [
2655
+ path10.join(root, "lib", "bin.js"),
2656
+ path10.join(root, "lib", "bin.mjs"),
2657
+ path10.join(root, "lib", "bin.cjs"),
2658
+ path10.join(root, "dist", "bin.js"),
2659
+ path10.join(root, "bin", "dsh.js"),
2660
+ path10.join(root, "bin", "dsh.mjs")
2661
+ ]) {
2662
+ if (isEntry(candidate))
2663
+ return candidate;
2664
+ }
2665
+ return argv1;
2666
+ }
2667
+ function launchedEntry(argv1) {
2668
+ const real = realPath(argv1);
2669
+ const direct = cloneEntryFrom(real);
2670
+ if (direct !== null)
2671
+ return direct;
2672
+ const shimmed = resolveShimmedCli(real, process7.execPath);
2673
+ return shimmed?.cliEntry == null ? null : cloneEntryFrom(shimmed.cliEntry);
2674
+ }
2675
+ function findDshEntry(dshHome2) {
2676
+ const found = [];
2677
+ found.push(...dshCandidatesUnder(dshHome2));
2678
+ for (const name2 of readdirNames(path10.join(dshHome2, "profiles")))
2679
+ found.push(...dshCandidatesUnder(path10.join(dshHome2, "profiles", name2)));
2680
+ const withVersion = found.map((entry) => ({ entry, version: versionOf(entry) }));
2681
+ withVersion.sort((a, b) => compareVersions(b.version ?? "0.0.0", a.version ?? "0.0.0"));
2682
+ return withVersion[0]?.entry ?? null;
2683
+ }
2684
+ function readdirNames(dir) {
2685
+ try {
2686
+ return fs11.readdirSync(dir);
2687
+ } catch {
2688
+ return [];
2689
+ }
2690
+ }
2691
+ async function resolveDshLaunch(options = {}) {
2692
+ const argv1 = options.argv1 === void 0 ? process7.argv[1] : options.argv1;
2693
+ const dshHome2 = options.dshHome ?? dshHome();
2694
+ const stateDir = options.stateDir ?? pluginStateDir();
2695
+ const local = typeof argv1 === "string" && argv1.length > 0 ? launchedEntry(argv1) : null;
2696
+ if (local !== null && realPath(local) !== realPath(dshLauncherPath(stateDir, path10.extname(local)))) {
2697
+ const launcher = writeDshLauncher({
2698
+ stateDir,
2699
+ dshHome: dshHome2,
2700
+ resolvedEntry: local,
2701
+ entryExtension: path10.extname(local),
2702
+ // Where this install sits, so a rebuilt/moved copy is found next boot.
2703
+ searchRoots: [dshSearchRoot(local)].filter((root) => root !== null)
2704
+ });
2705
+ return { program: process7.execPath, args: [local], cliEntry: local, shimPath: null, source: "entry", launcherPath: launcher.path };
2706
+ }
2707
+ const installed = findDshEntry(dshHome2);
2708
+ if (installed !== null) {
2709
+ const launcher = writeDshLauncher({ stateDir, dshHome: dshHome2, resolvedEntry: installed, entryExtension: path10.extname(installed) });
2710
+ return { program: process7.execPath, args: [installed], cliEntry: installed, shimPath: null, source: "entry", launcherPath: launcher.path };
2711
+ }
2712
+ const found = await (options.findOnPath ?? which)("dsh");
2713
+ if (found === null)
2714
+ return null;
2715
+ const launch = resolveShimmedCli(found, process7.execPath);
2716
+ return launch === null ? null : { ...launch, launcherPath: null };
2717
+ }
2718
+ function buildCommand(launch, launcherPath2) {
2719
+ if (launcherPath2 !== null && launcherPath2.length > 0)
2720
+ return { command: process7.execPath, entryArgs: [launcherPath2] };
2721
+ return { command: launch.program, entryArgs: launch.args };
2722
+ }
2723
+ function entryScript(config) {
2724
+ const program = config.command;
2725
+ if (typeof program !== "string" || program.length === 0)
2726
+ return null;
2727
+ if (!path10.isAbsolute(program))
2728
+ return program;
2729
+ const rest = config.args ?? [];
2730
+ return rest.find((arg) => path10.isAbsolute(arg)) ?? program;
2731
+ }
2732
+ function needsLauncherRepair(config) {
2733
+ const program = config.command;
2734
+ if (typeof program !== "string" || program.length === 0)
2735
+ return false;
2736
+ if (path10.isAbsolute(program))
2737
+ return true;
2738
+ return (config.args ?? []).some((arg, index) => index === 0 && path10.isAbsolute(arg));
2739
+ }
2740
+ function launcherRepair(config, launch) {
2741
+ if (launch?.launcherPath == null || launch.launcherPath.length === 0)
2742
+ return null;
2743
+ const launcher = launch.launcherPath;
2744
+ const stored = entryScript(config);
2745
+ if (stored === null || stored === launcher)
2746
+ 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] };
2751
+ }
2752
+ function buildDshEntry(facts) {
2753
+ const launch = facts.launch;
2754
+ const profile = facts.profile === null || facts.profile === void 0 ? "web" : facts.profile;
2755
+ const appArgs = [];
2756
+ if (facts.port !== null) {
2757
+ appArgs.push(
2758
+ "--port",
2759
+ "{port}",
2760
+ "--host",
2761
+ "{host}",
2762
+ "--no-open",
2763
+ "--trusted-host",
2764
+ `localhost:{port}`
2765
+ );
2766
+ if (facts.host === "0.0.0.0")
2767
+ appArgs.push("--trusted-host", "{lanIp}:{port}");
2768
+ }
2769
+ const launcherArgs = profile === "web" ? ["web"] : ["--profile", profile];
2770
+ const generated = launch === null ? { command: "dsh", entryArgs: [] } : buildCommand(launch, facts.launcherPath ?? null);
2771
+ return {
2772
+ id: facts.id,
2773
+ label: "DSH web",
2774
+ enabled: true,
2775
+ autostart: true,
2776
+ command: generated.command,
2777
+ args: [...generated.entryArgs, ...launcherArgs, ...appArgs],
2778
+ bind: facts.host === "0.0.0.0" ? "lan" : "local",
2779
+ port: facts.port,
2780
+ cwd: process7.cwd(),
2781
+ env: {},
2782
+ dataEnvs: { DSH_HOME: facts.dshHome },
2783
+ onPortConflict: "kill",
2784
+ stop: { killPortHolders: true },
2785
+ health: {
2786
+ enabled: true,
2787
+ mode: "http",
2788
+ // 401 is normal here: the panel answers the login route when auth is on.
2789
+ http: { path: "/", method: "GET", expectStatusBelow: 500, expectBody: "" }
2790
+ }
2791
+ };
2792
+ }
2793
+
2794
+ // src/home-hosted/entries.ts
2795
+ function defaultOnPortConflict(platform = process.platform) {
2796
+ return platform === "win32" ? "kill" : "follow";
2797
+ }
2798
+ function defaultIntent(id, platform = process.platform) {
2799
+ return {
2800
+ id,
2801
+ autostart: true,
2802
+ onPortConflict: defaultOnPortConflict(platform),
2803
+ stopKillPortHolders: true,
2804
+ persistent: true
2805
+ };
2806
+ }
2807
+ function ownedPatch(intent, live, options = {}) {
2808
+ const stop = live?.stop ?? {};
2809
+ return {
2810
+ autostart: intent.autostart,
2811
+ onPortConflict: intent.onPortConflict,
2812
+ ...options.persistent === false ? {} : { persistent: intent.persistent },
2813
+ stop: { ...stop, killPortHolders: intent.stopKillPortHolders }
2814
+ };
2815
+ }
2816
+ function ownedDrift(live, intent, options = {}) {
2817
+ if (live === null)
2818
+ return ["missing entry"];
2819
+ const drift = [];
2820
+ if (live.autostart !== intent.autostart)
2821
+ drift.push("autostart");
2822
+ if (live.onPortConflict !== intent.onPortConflict)
2823
+ drift.push("onPortConflict");
2824
+ if (options.persistent !== false && (live.persistent ?? false) !== intent.persistent)
2825
+ drift.push("persistent");
2826
+ const killPortHolders = live.stop?.killPortHolders;
2827
+ if (killPortHolders !== intent.stopKillPortHolders)
2828
+ drift.push("stop.killPortHolders");
2829
+ return drift;
2830
+ }
2831
+ function snapshotOwned(live) {
2832
+ const killPortHolders = live.stop?.killPortHolders;
2833
+ return {
2834
+ id: live.id,
2835
+ autostart: typeof live.autostart === "boolean" ? live.autostart : false,
2836
+ onPortConflict: isOnPortConflict(live.onPortConflict) ? live.onPortConflict : "block",
2837
+ persistent: live.persistent === true,
2838
+ stop: { killPortHolders: typeof killPortHolders === "boolean" ? killPortHolders : false }
2839
+ };
2840
+ }
2841
+ function restorePatch(live, snapshot) {
2842
+ const stop = live.stop ?? {};
2843
+ const previous = snapshot?.stop ?? {};
2844
+ return {
2845
+ autostart: snapshot?.autostart ?? false,
2846
+ onPortConflict: snapshot?.onPortConflict ?? "block",
2847
+ persistent: snapshot?.persistent === true,
2848
+ stop: {
2849
+ ...stop,
2850
+ killPortHolders: typeof previous.killPortHolders === "boolean" ? previous.killPortHolders : false
2851
+ }
2852
+ };
2853
+ }
2553
2854
 
2554
2855
  // src/home-hosted/panel-control.ts
2555
2856
  import { spawn as spawn2 } from "node:child_process";
2556
- import fs11 from "node:fs";
2857
+ import fs12 from "node:fs";
2557
2858
  import path11 from "node:path";
2558
2859
  import process8 from "node:process";
2559
2860
  function cliArgs(deps, command) {
@@ -2642,7 +2943,7 @@ log('started with exit ' + String(result.status))
2642
2943
  function writeTakeoverHelper(deps, oldPid) {
2643
2944
  const file = takeoverHelperPath(deps.stateDir);
2644
2945
  writeFileAtomic(file, buildTakeoverSource(deps, oldPid), 493);
2645
- fs11.chmodSync(file, 493);
2946
+ fs12.chmodSync(file, 493);
2646
2947
  return file;
2647
2948
  }
2648
2949
  function spawnDetached(program, args) {
@@ -2760,14 +3061,19 @@ var PanelClient = class {
2760
3061
  return await this.request("POST", `/api/servers/${encodeURIComponent(id)}/free-port`);
2761
3062
  }
2762
3063
  };
2763
- async function verifyToken(baseUrl, token, timeoutMs = 5e3) {
3064
+ async function probeToken(baseUrl, token, timeoutMs = 5e3) {
2764
3065
  try {
2765
3066
  await new PanelClient({ baseUrl, token, timeoutMs }).listServers();
2766
- return true;
2767
- } catch {
2768
- return false;
3067
+ return "ok";
3068
+ } catch (error) {
3069
+ if (error instanceof PanelError && (error.code === "AUTH_REQUIRED" || error.status === 401 || error.status === 403))
3070
+ return "refused";
3071
+ return "unreachable";
2769
3072
  }
2770
3073
  }
3074
+ async function verifyToken(baseUrl, token, timeoutMs = 5e3) {
3075
+ return await probeToken(baseUrl, token, timeoutMs) === "ok";
3076
+ }
2771
3077
 
2772
3078
  // src/home-hosted/runtime.ts
2773
3079
  import http from "node:http";
@@ -2863,18 +3169,35 @@ function apiTokenEnrolled(home) {
2863
3169
  const secrets = readJson(secretsFile(home));
2864
3170
  return secrets !== null && secrets.apiToken !== null && secrets.apiToken !== void 0;
2865
3171
  }
2866
- var inFlight = /* @__PURE__ */ new Map();
3172
+ var queue = /* @__PURE__ */ new Map();
3173
+ function serialised(stateDir, work) {
3174
+ const key = path12.resolve(stateDir);
3175
+ const previous = queue.get(key) ?? Promise.resolve();
3176
+ const next = previous.then(work, work);
3177
+ const settled = next.then(() => void 0, () => void 0);
3178
+ queue.set(key, settled);
3179
+ void settled.then(() => {
3180
+ if (queue.get(key) === settled) queue.delete(key);
3181
+ });
3182
+ return next;
3183
+ }
2867
3184
  async function ensureToken(options) {
2868
- const key = path12.resolve(options.stateDir);
2869
- const pending = inFlight.get(key);
2870
- if (pending !== void 0)
2871
- return await pending;
2872
- const attempt = enrollToken(options);
2873
- inFlight.set(key, attempt);
3185
+ return await serialised(options.stateDir, async () => await enrollToken(options));
3186
+ }
3187
+ async function reclaimToken(options) {
3188
+ return await serialised(options.stateDir, async () => await enrollFreshToken(options));
3189
+ }
3190
+ async function runCli2(exec, args, env) {
2874
3191
  try {
2875
- return await attempt;
2876
- } finally {
2877
- inFlight.delete(key);
3192
+ const result = await exec(args, env);
3193
+ if (result.code !== 0) {
3194
+ return {
3195
+ failure: result.error?.trim() || result.stderr.trim() || result.stdout.trim() || `exit ${String(result.code)}`
3196
+ };
3197
+ }
3198
+ return { result };
3199
+ } catch (error) {
3200
+ return { failure: error instanceof Error ? error.message : String(error) };
2878
3201
  }
2879
3202
  }
2880
3203
  async function enrollToken(options) {
@@ -2889,20 +3212,34 @@ async function enrollToken(options) {
2889
3212
  };
2890
3213
  }
2891
3214
  const token = generateToken();
2892
- const result = await options.exec(["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
2893
- if (result.code !== 0) {
3215
+ const enrolled = await runCli2(options.exec, ["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
3216
+ if (enrolled.result === void 0) {
2894
3217
  return {
2895
3218
  token: null,
2896
3219
  enrolled: false,
2897
- detail: `could not enrol an API token: ${result.stderr.trim() || result.stdout.trim() || `exit ${String(result.code)}`}`
3220
+ detail: `could not enrol an API token: ${enrolled.failure ?? "the CLI did not answer"}`
2898
3221
  };
2899
3222
  }
2900
3223
  storeToken(options.stateDir, token);
2901
3224
  return { token, enrolled: true, detail: "enrolled a new panel API token" };
2902
3225
  }
3226
+ async function enrollFreshToken(options) {
3227
+ const token = generateToken();
3228
+ const enrolled = await runCli2(options.exec, ["--home", options.home, "set-token"], { HHOSTED_TOKEN: token });
3229
+ if (enrolled.result === void 0) {
3230
+ return {
3231
+ token: null,
3232
+ detail: `could not enrol a fresh API token: ${enrolled.failure ?? "the CLI did not answer"}; the previously stored token was kept`
3233
+ };
3234
+ }
3235
+ storeToken(options.stateDir, token);
3236
+ return { token, detail: "enrolled a fresh panel API token" };
3237
+ }
2903
3238
 
2904
3239
  // src/service.ts
2905
3240
  var ENTRY_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
3241
+ var TOKEN_PROBE_TIMEOUT_MS = 3e3;
3242
+ var ENTRY_RECOVERY_INTERVAL_MS = 3e4;
2906
3243
  function pluginRoot() {
2907
3244
  try {
2908
3245
  return path13.dirname(path13.dirname(fileURLToPath(import.meta.url)));
@@ -2924,8 +3261,18 @@ var HomeHostedService = class extends Service {
2924
3261
  this.snapshotsFile = path13.join(options.stateDir, "snapshots.json");
2925
3262
  }
2926
3263
  clientCache = null;
3264
+ /** The last token this service proved against a panel, so a poll does not re-probe it. */
3265
+ tokenProof = null;
2927
3266
  tokenDetail = "";
3267
+ /** When a missing managed entry was last put back, so a poll cannot become a write loop. */
3268
+ entryRecoveryAt = 0;
2928
3269
  snapshotsFile;
3270
+ async resolveDsh(dshHome2) {
3271
+ return await (this.options.resolveDsh ?? ((options) => resolveDshLaunch(options)))({
3272
+ dshHome: dshHome2,
3273
+ stateDir: this.options.stateDir
3274
+ });
3275
+ }
2929
3276
  // -------------------------------------------------------------------------
2930
3277
  // Facts
2931
3278
  // -------------------------------------------------------------------------
@@ -3019,14 +3366,40 @@ var HomeHostedService = class extends Service {
3019
3366
  throw new HomeHostedError(read.error, "CONFIG_UNREADABLE");
3020
3367
  writeConfig(this.options.home, patchControl(read.raw ?? {}, { port }), this.writtenBy());
3021
3368
  }
3369
+ /** Whether this exact token was already proved against this exact panel. */
3370
+ tokenProven(url, token) {
3371
+ return this.tokenProof !== null && this.tokenProof.until > Date.now() && this.tokenProof.url === url && this.tokenProof.token === token;
3372
+ }
3373
+ proveToken(url, token) {
3374
+ this.tokenProof = { url, token, until: Date.now() + 3e4 };
3375
+ }
3022
3376
  async panelStatus() {
3023
3377
  const runtime = this.runtime();
3024
3378
  const stored = readStoredToken(this.options.stateDir);
3025
3379
  const enrolledOnDisk = apiTokenEnrolled(this.options.home);
3026
3380
  const reachable = runtime !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url));
3027
3381
  const answered = runtime !== null && await probePanel(runtime.url);
3028
- const token = stored !== null ? "enrolled" : enrolledOnDisk ? "present" : "absent";
3029
- const writeVia = answered && stored !== null ? "api" : "file";
3382
+ let token = "unknown";
3383
+ let tokenVerified = false;
3384
+ if (answered) {
3385
+ if (stored === null) {
3386
+ token = enrolledOnDisk ? "present" : "absent";
3387
+ } else if (this.tokenProven(runtime.url, stored)) {
3388
+ token = "enrolled";
3389
+ tokenVerified = true;
3390
+ } else {
3391
+ const probe = await probeToken(runtime.url, stored, TOKEN_PROBE_TIMEOUT_MS);
3392
+ if (probe === "ok") {
3393
+ token = "enrolled";
3394
+ tokenVerified = true;
3395
+ this.proveToken(runtime.url, stored);
3396
+ } else if (probe === "refused") {
3397
+ token = "stale";
3398
+ tokenVerified = true;
3399
+ }
3400
+ }
3401
+ }
3402
+ const writeVia = token === "enrolled" ? "api" : "file";
3030
3403
  return {
3031
3404
  home: this.options.home,
3032
3405
  reachable: answered,
@@ -3036,9 +3409,55 @@ var HomeHostedService = class extends Service {
3036
3409
  pid: runtime?.pid ?? null,
3037
3410
  writeVia,
3038
3411
  token,
3039
- detail: answered ? stored !== null ? this.tokenDetail || "the panel is answering and this plugin holds a token" : enrolledOnDisk ? "the panel is answering, but home-hosted already holds an API token this plugin does not have" : "the panel is answering; a token will be enrolled on the first write" : runtime === null ? "no run.json: the panel is not running, so entries are written straight to servers.config.json" : reachable ? "the panel process is alive but is not answering" : "the panel is not running"
3412
+ tokenVerified,
3413
+ detail: answered ? token === "enrolled" ? this.tokenDetail || "the panel is answering and this plugin holds a token" : token === "stale" ? "the panel refused this plugin's API token, so writes go straight to servers.config.json; regenerate the token" : token === "present" ? "the panel is answering, but home-hosted already holds an API token this plugin does not have" : token === "absent" ? "the panel is answering but no API token is enrolled for it yet; writes go to servers.config.json until one is" : "the panel answered, but the plugin's token could not be checked" : runtime === null ? "no run.json: the panel is not running, so entries are written straight to servers.config.json" : token === "present" || token === "stale" ? "the panel process is up but does not answer with this plugin's API token, so this page cannot reach it" : "the panel process is alive but is not answering"
3040
3414
  };
3041
3415
  }
3416
+ /**
3417
+ * Replace the panel's API token with a fresh one and prove it.
3418
+ *
3419
+ * home-hosted keeps only a hash, so a token this plugin does not hold cannot
3420
+ * be recovered: a new token replaces the old hash in one CLI write, and it is
3421
+ * proved against the answering panel before being reported.
3422
+ *
3423
+ * The panel is not required to be *answering*: a missing or refused token is a
3424
+ * common reason it cannot be reached at all, and refusing the repair for that
3425
+ * reason would leave no way out from the page. Only a panel that was never
3426
+ * started is refused — there is nothing to enrol a token against yet.
3427
+ */
3428
+ async reclaimPanelToken() {
3429
+ const runtime = this.runtime();
3430
+ if (runtime === null) {
3431
+ throw new HomeHostedError(
3432
+ "the panel has not been started, so there is nothing to enrol a token against; start it first",
3433
+ "PANEL_UNAVAILABLE"
3434
+ );
3435
+ }
3436
+ const answering = await probePanel(runtime.url);
3437
+ const result = await reclaimToken({
3438
+ home: this.options.home,
3439
+ stateDir: this.options.stateDir,
3440
+ exec: (args, env) => this.cliExec(args, env)
3441
+ });
3442
+ this.clientCache = null;
3443
+ this.tokenProof = null;
3444
+ this.tokenDetail = result.detail;
3445
+ if (result.token === null)
3446
+ throw new HomeHostedError(result.detail, "TOKEN_RECLAIM_FAILED");
3447
+ if (!answering) {
3448
+ this.tokenDetail = `${result.detail}; the panel is not answering, so it could not be proved yet`;
3449
+ return await this.status();
3450
+ }
3451
+ const probe = await probeToken(runtime.url, result.token, TOKEN_PROBE_TIMEOUT_MS);
3452
+ if (probe !== "ok") {
3453
+ throw new HomeHostedError(
3454
+ probe === "refused" ? "a fresh token was enrolled, but the panel refused it; check the panel log before trying again" : "a fresh token was enrolled, but the panel stopped answering before it could be verified",
3455
+ "TOKEN_RECLAIM_UNVERIFIED"
3456
+ );
3457
+ }
3458
+ this.proveToken(runtime.url, result.token);
3459
+ return await this.status();
3460
+ }
3042
3461
  async tryClient() {
3043
3462
  if (this.clientCache !== null && this.clientCache.until > Date.now())
3044
3463
  return this.clientCache.client;
@@ -3057,6 +3476,7 @@ var HomeHostedService = class extends Service {
3057
3476
  return null;
3058
3477
  const client = new PanelClient({ baseUrl: runtime.url, token: ensured.token });
3059
3478
  this.clientCache = { client, until: Date.now() + 3e4 };
3479
+ this.proveToken(runtime.url, ensured.token);
3060
3480
  return client;
3061
3481
  }
3062
3482
  async requireClient() {
@@ -3095,20 +3515,35 @@ var HomeHostedService = class extends Service {
3095
3515
  map.set(entry.id, { view: { id: entry.id, status: "unknown", pid: null, url: null, config: entry }, config: entry });
3096
3516
  return map;
3097
3517
  }
3098
- async createEntry(intent, patch, live) {
3518
+ async createEntry(intent, patch) {
3099
3519
  if (intent.id !== this.options.defaultEntryId)
3100
3520
  return null;
3101
- const dsh = await resolveDshLaunch();
3521
+ const harnessHome = process10.env.DSH_HOME ?? dshHome();
3522
+ const dsh = await this.resolveDsh(harnessHome);
3102
3523
  const { port, host } = this.webServer();
3103
3524
  const generated = buildDshEntry({
3104
3525
  id: intent.id,
3105
3526
  port,
3106
3527
  host,
3107
3528
  profile: detectProfile(process10.argv, process10.env),
3108
- dshHome: process10.env.DSH_HOME ?? dshHome(),
3109
- launch: dsh
3529
+ dshHome: harnessHome,
3530
+ launch: dsh,
3531
+ launcherPath: dsh?.launcherPath ?? null
3110
3532
  });
3111
- return { ...live ?? generated, ...generated, ...patch };
3533
+ return { ...generated, ...patch };
3534
+ }
3535
+ /**
3536
+ * An entry written before the launcher existed still runs a clone's build
3537
+ * output directly. Point it at the stable launcher, or the fix would only
3538
+ * ever apply to freshly created entries — the plugin never rewrites an
3539
+ * existing entry's command.
3540
+ */
3541
+ async dshCommandRepair(live) {
3542
+ if (live === null || !needsLauncherRepair(live))
3543
+ return null;
3544
+ const harnessHome = process10.env.DSH_HOME ?? dshHome();
3545
+ const dsh = await this.resolveDsh(harnessHome);
3546
+ return launcherRepair(live, dsh);
3112
3547
  }
3113
3548
  async writeOwned(intent) {
3114
3549
  const live = (await this.liveEntries()).get(intent.id) ?? null;
@@ -3118,13 +3553,14 @@ var HomeHostedService = class extends Service {
3118
3553
  this.saveSnapshots(snapshots);
3119
3554
  }
3120
3555
  const patch = ownedPatch(intent, live?.config ?? null, { persistent: await this.supportsPersistent() });
3556
+ const repair = intent.id === this.options.defaultEntryId ? await this.dshCommandRepair(live?.config ?? null) : null;
3121
3557
  const client = await this.tryClient();
3122
3558
  if (client !== null) {
3123
3559
  if (live !== null) {
3124
- await client.updateServer(intent.id, patch);
3560
+ await client.updateServer(intent.id, { ...patch, ...repair ?? {} });
3125
3561
  return;
3126
3562
  }
3127
- const entry2 = await this.createEntry(intent, patch, null);
3563
+ const entry2 = await this.createEntry(intent, patch);
3128
3564
  if (entry2 === null) {
3129
3565
  throw new HomeHostedError(
3130
3566
  `no server "${intent.id}" exists; create it first, then this plugin can adopt its autostart and conflict policy`,
@@ -3139,10 +3575,10 @@ var HomeHostedService = class extends Service {
3139
3575
  throw new HomeHostedError(read.error, "CONFIG_UNREADABLE");
3140
3576
  let raw = read.raw ?? {};
3141
3577
  if (findEntry(raw, intent.id) !== null) {
3142
- writeConfig(this.options.home, patchEntry(raw, intent.id, patch), this.writtenBy());
3578
+ writeConfig(this.options.home, patchEntry(raw, intent.id, { ...patch, ...repair ?? {} }), this.writtenBy());
3143
3579
  return;
3144
3580
  }
3145
- const entry = await this.createEntry(intent, patch, null);
3581
+ const entry = await this.createEntry(intent, patch);
3146
3582
  if (entry === null)
3147
3583
  throw new HomeHostedError(`no server "${intent.id}" exists in ${this.options.home}/servers.config.json`, "ENTRY_MISSING");
3148
3584
  if (await this.panelRunning()) {
@@ -3211,8 +3647,9 @@ var HomeHostedService = class extends Service {
3211
3647
  entries.push(intent);
3212
3648
  this.options.settings.update({
3213
3649
  // The page's toggle reads this flag, so managing the harness has to record
3214
- // itself; restoring or removing that entry clears it again.
3215
- ...intent.id === this.options.defaultEntryId ? { manageDsh: true } : {},
3650
+ // itself; restoring or removing that entry clears it again. A paused intent
3651
+ // (`autostart: false`) is not management, or the toggle could never go off.
3652
+ ...intent.id === this.options.defaultEntryId ? { manageDsh: intent.autostart } : {},
3216
3653
  entries
3217
3654
  });
3218
3655
  }
@@ -3301,7 +3738,7 @@ var HomeHostedService = class extends Service {
3301
3738
  case "switch": {
3302
3739
  if (file === void 0 || file.trim().length === 0)
3303
3740
  throw new HomeHostedError("switching the UI needs the zip to install", "UI_FILE_REQUIRED");
3304
- if (!fs12.existsSync(file))
3741
+ if (!fs13.existsSync(file))
3305
3742
  throw new HomeHostedError(`no file at ${file}`, "UI_FILE_MISSING");
3306
3743
  const result = await runUi(["ui-switch", "--file", file, "--yes"]);
3307
3744
  return { ok: result.code === 0, detail: result.code === 0 ? "the UI was replaced" : "the switch did not run", ui: active(), output: result.output };
@@ -3519,13 +3956,43 @@ var HomeHostedService = class extends Service {
3519
3956
  * an explicit click or an approved tool call, not as a side effect of startup.
3520
3957
  */
3521
3958
  async reconcile() {
3959
+ await this.ensureManagedEntry();
3960
+ await this.repairBootEntry();
3961
+ }
3962
+ /**
3963
+ * Put back a managed entry that no longer exists, while its intent still wants
3964
+ * autostart. A paused intent is a deliberate stop and is left alone.
3965
+ *
3966
+ * Called from startup `reconcile()` as well as the status read, because a person
3967
+ * who deletes the entry from the panel is looking at the page right then — not
3968
+ * at the next plugin start. A failed attempt is not repeated for a while, so a
3969
+ * config that cannot be written does not turn every poll into a write.
3970
+ */
3971
+ async ensureManagedEntry() {
3972
+ const { settings } = this.options;
3973
+ const id = this.options.defaultEntryId;
3974
+ if (!settings.get().manageDsh || !settings.intentFor(id).autostart)
3975
+ return;
3976
+ const now = Date.now();
3977
+ if (now - this.entryRecoveryAt < ENTRY_RECOVERY_INTERVAL_MS)
3978
+ return;
3979
+ this.entryRecoveryAt = now;
3980
+ if ((await this.entriesStatus()).every((entry) => entry.intent.id !== id || entry.exists))
3981
+ return;
3982
+ try {
3983
+ await this.writeOwned({ ...settings.intentFor(id), autostart: true });
3984
+ } catch {
3985
+ }
3986
+ }
3987
+ /** Re-assert the boot entry when the OS still has it but it stopped working. */
3988
+ async repairBootEntry() {
3522
3989
  if (!this.options.settings.get().autostart.enabled)
3523
3990
  return;
3524
3991
  const status = await this.bootStatus();
3525
3992
  if (status.state !== "enabled-failing" && status.state !== "installed-disabled")
3526
3993
  return;
3527
3994
  const fileBacked = status.mechanism === "systemd-user" || status.mechanism === "systemd-system" || status.mechanism === "xdg-autostart" || status.mechanism === "launchd-agent" || status.mechanism === "launchd-daemon";
3528
- if (fileBacked && (status.unitPath === null || !fs12.existsSync(status.unitPath)))
3995
+ if (fileBacked && (status.unitPath === null || !fs13.existsSync(status.unitPath)))
3529
3996
  return;
3530
3997
  try {
3531
3998
  await this.installBoot();
@@ -3564,7 +4031,9 @@ var HomeHostedService = class extends Service {
3564
4031
  defaultEntryId: this.options.defaultEntryId,
3565
4032
  panel,
3566
4033
  boot: await this.bootStatus(),
3567
- entries: await this.entriesStatus(),
4034
+ // A deleted managed entry is put back here as well as at startup: whoever
4035
+ // deleted it is looking at the page that reports it missing.
4036
+ entries: await this.ensureManagedEntry().then(async () => await this.entriesStatus()),
3568
4037
  servers,
3569
4038
  settings: this.options.settings.get(),
3570
4039
  cli,
@@ -3638,6 +4107,8 @@ var HomeHostedService = class extends Service {
3638
4107
  return await this.startPanelNow();
3639
4108
  case "panel.takeover":
3640
4109
  return await this.takeoverPanel(input.force === true);
4110
+ case "panel.reclaimToken":
4111
+ return await this.reclaimPanelToken();
3641
4112
  case "cli.installGlobal":
3642
4113
  return await this.installGlobalCli();
3643
4114
  default:
@@ -3831,6 +4302,8 @@ function normalize(raw, fallbackEntryId) {
3831
4302
  port: typeof raw?.panel?.port === "number" && Number.isInteger(raw.panel.port) && raw.panel.port >= 1 && raw.panel.port <= 65535 ? raw.panel.port : null
3832
4303
  },
3833
4304
  authNotice: raw?.authNotice === void 0 ? DEFAULT_SETTINGS.authNotice : raw.authNotice === true,
4305
+ // Absent means on: a stale token is a fault to repair, not a setting to opt into.
4306
+ reclaimToken: raw?.reclaimToken === void 0 ? DEFAULT_SETTINGS.reclaimToken : raw.reclaimToken === true,
3834
4307
  uiStyle: raw?.uiStyle === "compact" ? "compact" : "detailed",
3835
4308
  agentTools: {
3836
4309
  // Absent means the default (on), and so does the pair the previous
@@ -3868,6 +4341,7 @@ var SettingsStore = class {
3868
4341
  entries: patch.entries ?? this.current.entries,
3869
4342
  panel: { ...this.current.panel, ...patch.panel ?? {} },
3870
4343
  authNotice: patch.authNotice ?? this.current.authNotice,
4344
+ reclaimToken: patch.reclaimToken ?? this.current.reclaimToken,
3871
4345
  uiStyle: patch.uiStyle ?? this.current.uiStyle,
3872
4346
  agentTools: { ...this.current.agentTools, ...patch.agentTools ?? {} },
3873
4347
  cli: { ...this.current.cli, ...patch.cli ?? {} }
@@ -4001,7 +4475,49 @@ function toolNameFor(name2) {
4001
4475
  var READ_ONLY_ACTIONS = {
4002
4476
  ui_manage: (input) => stringArg(input, "action") === "status"
4003
4477
  };
4004
- function registerOne(ctx, service, name2) {
4478
+ var TOKEN_REFUSAL_CODES = /* @__PURE__ */ new Set([
4479
+ "AUTH_REQUIRED",
4480
+ "AUTH_UNARMED",
4481
+ "UNAUTHORIZED",
4482
+ "FORBIDDEN",
4483
+ "INVALID_TOKEN",
4484
+ "TOKEN_STALE",
4485
+ "TOKEN_REFUSED"
4486
+ ]);
4487
+ function errorCode(error) {
4488
+ const code = error?.code;
4489
+ return typeof code === "string" ? code : null;
4490
+ }
4491
+ function errorStatus(error) {
4492
+ const status = error?.status;
4493
+ return typeof status === "number" ? status : null;
4494
+ }
4495
+ function refusedByPanel(error) {
4496
+ const status = errorStatus(error);
4497
+ if (status === 401 || status === 403)
4498
+ return true;
4499
+ const code = errorCode(error);
4500
+ return code !== null && TOKEN_REFUSAL_CODES.has(code);
4501
+ }
4502
+ async function tokenRefused(service, error) {
4503
+ if (refusedByPanel(error))
4504
+ return true;
4505
+ if (errorCode(error) !== "PANEL_UNAVAILABLE")
4506
+ return false;
4507
+ try {
4508
+ const status = await service.call("status", {});
4509
+ return status?.panel.token === "stale";
4510
+ } catch {
4511
+ return false;
4512
+ }
4513
+ }
4514
+ function messageOf(error) {
4515
+ return error instanceof Error ? error.message : String(error);
4516
+ }
4517
+ function failureText(error) {
4518
+ return `failed: ${messageOf(error)}`;
4519
+ }
4520
+ function registerOne(ctx, service, settings, name2) {
4005
4521
  const spec = TOOL_SPECS[name2];
4006
4522
  const toolName = toolNameFor(name2);
4007
4523
  const mutatingTool = MUTATING_AGENT_TOOLS.includes(name2);
@@ -4030,11 +4546,27 @@ function registerOne(ctx, service, name2) {
4030
4546
  if (outcome !== "allowed-once")
4031
4547
  return `refused: approval answered "${outcome}" (session sandbox: ${mode ?? "unknown"}). ${remedy}`;
4032
4548
  }
4549
+ let request;
4033
4550
  try {
4034
- const { endpoint, payload } = spec.run(input);
4035
- return JSON.stringify(await service.call(endpoint, payload), null, 2);
4551
+ request = spec.run(input);
4036
4552
  } catch (error) {
4037
- return `failed: ${error instanceof Error ? error.message : String(error)}`;
4553
+ return failureText(error);
4554
+ }
4555
+ try {
4556
+ return JSON.stringify(await service.call(request.endpoint, request.payload), null, 2);
4557
+ } catch (error) {
4558
+ if (!settings.get().reclaimToken || !await tokenRefused(service, error))
4559
+ return failureText(error);
4560
+ try {
4561
+ await service.call("panel.reclaimToken", {});
4562
+ } catch (reclaimError) {
4563
+ return `${failureText(error)} (token reclaim failed: ${messageOf(reclaimError)})`;
4564
+ }
4565
+ try {
4566
+ return JSON.stringify(await service.call(request.endpoint, request.payload), null, 2);
4567
+ } catch (retryError) {
4568
+ return failureText(retryError);
4569
+ }
4038
4570
  }
4039
4571
  }
4040
4572
  }));
@@ -4050,7 +4582,7 @@ function registerAgentTools(ctx, service, settings) {
4050
4582
  return;
4051
4583
  for (const name2 of current.agentTools.allow) {
4052
4584
  try {
4053
- disposers.push(registerOne(scoped, service, name2));
4585
+ disposers.push(registerOne(scoped, service, settings, name2));
4054
4586
  } catch {
4055
4587
  }
4056
4588
  }