dsh-home-hosted 0.3.2 → 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.
@@ -11,5 +11,11 @@ export interface Config {
11
11
  homeHostedCommand?: string;
12
12
  /** The entry this plugin manages for the running harness. */
13
13
  defaultEntryId: string;
14
+ /**
15
+ * Extra home-hosted state roots to report as other panels. Discovery already
16
+ * knows the managed root, `$HHOSTED_HOME` and the `~/.home-hosted*` siblings;
17
+ * a panel run from a project with its own `--home` is declared here.
18
+ */
19
+ instanceRoots: string[];
14
20
  }
15
21
  export declare const Config: z<Config>;
@@ -12,7 +12,6 @@ export declare function detectProfile(argv: readonly string[], env?: Record<stri
12
12
  export interface DshFacts {
13
13
  id: string;
14
14
  port: number | null;
15
- host: string;
16
15
  profile: string | null;
17
16
  dshHome: string;
18
17
  launch: CliLaunch | null;
@@ -57,19 +56,26 @@ export declare function findDshEntry(dshHome: string): string | null;
57
56
  */
58
57
  export declare function resolveDshLaunch(options?: ResolveDshLaunchOptions): Promise<DshLaunch | null>;
59
58
  /**
60
- * Re-point an entry that boots a local build directly at the stable launcher.
59
+ * Re-point an entry at the stable launcher for the pinned image.
61
60
  *
62
- * Only this shape qualifies: the entry runs the same script this process does,
63
- * and that script is not already the launcher. An upgrade cannot repair the
64
- * entry it created in an earlier release otherwise — the plugin never rewrites
65
- * an existing entry's command.
61
+ * Qualifying shapes, and only these: the entry runs the script this pattern
62
+ * pins (a local build recorded in an earlier release), reaches that script
63
+ * through a package-manager shim, or names a bare `dsh`/`dsh.cmd` that a later
64
+ * PATH lookup would answer with a different copy. An entry that runs a
65
+ * *different* dsh is somebody's deliberate choice and is never touched.
66
+ *
67
+ * An entry this process was booted through is also left alone: repairing it
68
+ * would point it at itself, since that is already the launcher.
66
69
  */
70
+ export declare function pinnedScriptFor(launch: DshLaunch | null): string | null;
67
71
  /**
68
72
  * Whether an entry *could* need that repair, without resolving anything.
69
73
  *
70
74
  * Resolving a local dsh walks the harness home, so the write path asks this
71
- * first: a global `dsh` on PATH, or an entry already on the launcher, is
72
- * settled here and never triggers the walk.
75
+ * first. Almost every shape can need one — an absolute command, an absolute
76
+ * first argument, a bare `dsh` — and the cheap answer is therefore "yes" here
77
+ * and a full comparison in `launcherRepair`, which is what decides whether
78
+ * anything is actually rewritten.
73
79
  */
74
80
  export declare function needsLauncherRepair(config: {
75
81
  command?: string;
@@ -12,24 +12,10 @@ import type { EntryIntent, OnPortConflict, ServerEntry, ServerEntryPatch } from
12
12
  */
13
13
  export declare function defaultOnPortConflict(platform?: NodeJS.Platform): OnPortConflict;
14
14
  export declare function defaultIntent(id: string, platform?: NodeJS.Platform): EntryIntent;
15
- /**
16
- * The owned keys as a patch, preserving any sibling keys inside `stop`.
17
- *
18
- * `persistent` is omitted unless the panel that would parse the write knows the
19
- * key: an older schema drops it and warns, so writing one is noise at best.
20
- */
21
- export declare function ownedPatch(intent: EntryIntent, live?: ServerEntry | null, options?: {
22
- persistent?: boolean;
23
- }): ServerEntryPatch;
24
- /**
25
- * Owned keys whose live value differs from the intent.
26
- *
27
- * The intent keeps what the person asked for; a key the panel cannot parse is
28
- * not drift, or every entry would report one on an older panel.
29
- */
30
- export declare function ownedDrift(live: ServerEntry | null, intent: EntryIntent, options?: {
31
- persistent?: boolean;
32
- }): string[];
15
+ /** The owned keys as a patch, preserving any sibling keys inside `stop`. */
16
+ export declare function ownedPatch(intent: EntryIntent, live?: ServerEntry | null): ServerEntryPatch;
17
+ /** Owned keys whose live value differs from the intent. */
18
+ export declare function ownedDrift(live: ServerEntry | null, intent: EntryIntent): string[];
33
19
  /**
34
20
  * Only the owned keys, as they were before this plugin touched the entry.
35
21
  *
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Telling the agent which home-hosted panels exist.
3
+ *
4
+ * A plugin that owns one panel must not look like the only one on the machine.
5
+ * When discovery finds more than one, the inventory is contributed as dynamic
6
+ * runtime context (`ctx.systemPrompt.context`), so every model step knows the
7
+ * managed panel, the others, and that this plugin cannot write to them.
8
+ *
9
+ * The contribution reads the service's cached inventory: assembly happens
10
+ * before a model step and must never block on the disk.
11
+ */
12
+ import type { Context } from '@deepseek-ai/cordis';
13
+ import type { InstanceView } from '../shared/contracts.js';
14
+ /** The subset of `ctx.systemPrompt` this uses; absent on a host without it. */
15
+ export interface SystemPromptLike {
16
+ context: (contribution: {
17
+ name: string;
18
+ order: number;
19
+ text: string | ((context: unknown) => string);
20
+ }) => () => void;
21
+ }
22
+ export interface InstancesNoticeDeps {
23
+ /** The inventory the plugin already measured; never a disk read at assembly. */
24
+ instances: () => readonly InstanceView[];
25
+ /** The `instancesNotice` setting, read per assembly. */
26
+ enabled: () => boolean;
27
+ }
28
+ /**
29
+ * After the first-party runtime contexts (sandbox 110, approval 115,
30
+ * subagent delegation 120) and before anything a repository owns.
31
+ */
32
+ export declare const INSTANCES_CONTEXT_ORDER = 130;
33
+ /** The exact text one assembly contributes, or `''` for nothing. */
34
+ export declare function instancesContextText(instances: readonly InstanceView[], enabled: boolean): string;
35
+ export declare function registerInstancesNotice(ctx: Context, deps: InstancesNoticeDeps): void;
@@ -0,0 +1,34 @@
1
+ import type { InstanceView } from '../shared/contracts.js';
2
+ export interface InstanceDiscoveryOptions {
3
+ /** The state root this plugin drives. */
4
+ managedHome: string;
5
+ /** Extra roots an operator declared in the plugin row. */
6
+ extraRoots?: readonly string[];
7
+ /** Test seam: this process's `$HHOSTED_HOME`. */
8
+ envHome?: string | null;
9
+ /** Test seam: `HHOSTED_SERVER_ID`, or null when the panel does not supervise us. */
10
+ hostingEntryId?: string | null;
11
+ /** Test seam: the user's home directory. */
12
+ homeDir?: string;
13
+ /** Test seam: directory listing. */
14
+ listDir?: (dir: string) => string[] | null;
15
+ /** Test seam: whether a pid is alive. */
16
+ alive?: (pid: number) => boolean;
17
+ }
18
+ /** A path as one identity: `~` expanded, symlinks resolved (macOS `/var`). */
19
+ export declare function canonicalPath(value: string): string;
20
+ /** Every panel found, the managed one first and the rest by state root. */
21
+ export declare function discoverInstances(options: InstanceDiscoveryOptions): InstanceView[];
22
+ /**
23
+ * One line naming a panel, for a prompt note or a refusal. A root whose
24
+ * `run.json` is gone has no runtime to report — home-hosted drops a stale one —
25
+ * so it is never called "stopped".
26
+ */
27
+ export declare function describeInstance(instance: InstanceView): string;
28
+ /**
29
+ * The note the agent is given when the machine holds more than one panel.
30
+ *
31
+ * `null` for a single panel: the plugin's preference is only worth stating when
32
+ * there is something to prefer it over.
33
+ */
34
+ export declare function instancesNoticeText(instances: readonly InstanceView[]): string | null;
@@ -40,6 +40,18 @@ export interface DshLauncherOptions {
40
40
  dshHome: string;
41
41
  /** Where the dsh entry is right now; recorded as the fast path. */
42
42
  resolvedEntry: string | null;
43
+ /**
44
+ * The image this plugin was installed on: the dsh a boot entry must keep
45
+ * running. Recorded and checked before anything else, so a copy that merely
46
+ * reports a higher version never wins the boot.
47
+ */
48
+ pinnedEntry?: string | null;
49
+ /**
50
+ * Adopt `pinnedEntry` even when the record already pins something else. Set
51
+ * only where the caller *is* the running image; a process booted through the
52
+ * launcher must carry the pin it started under instead of replacing it.
53
+ */
54
+ repin?: boolean;
43
55
  /** The extension the launcher must match: a `.cjs` entry gets a `.cjs` launcher. */
44
56
  entryExtension?: string | null;
45
57
  /**
@@ -53,8 +65,15 @@ export interface DshLauncherWrite {
53
65
  path: string;
54
66
  changed: boolean;
55
67
  }
56
- interface DshLauncherRecord {
68
+ export interface DshLauncherRecord {
57
69
  entry: string | null;
70
+ /**
71
+ * The running image, while it still exists. A path here is an instruction:
72
+ * `choose()` runs this one and never sorts it against another copy.
73
+ */
74
+ pinned?: string | null;
75
+ /** When that image was last confirmed. */
76
+ pinnedAt?: number | null;
58
77
  /** Where that entry's install sat, kept across rewrites while it stays valid. */
59
78
  roots: string[];
60
79
  writtenAt: number;
@@ -1,7 +1,10 @@
1
1
  import type { ChildProcess } from 'node:child_process';
2
2
  import type { PanelControlResult } from '../shared/contracts.js';
3
3
  import { run } from '../util/exec.js';
4
+ import type { RunResult } from '../util/exec.js';
4
5
  import type { CliLaunch } from './launch.js';
6
+ /** How the panel's CLI is run: the resolved argv plus an environment. */
7
+ export type PanelCliExec = (args: string[], env: Record<string, string | undefined>) => Promise<RunResult>;
5
8
  export interface PanelControlDeps {
6
9
  /** The CLI to run, already resolved. */
7
10
  launch: CliLaunch | null;
@@ -11,9 +14,20 @@ export interface PanelControlDeps {
11
14
  /** Environment for the spawned CLI (absolute PATH, HOME, HHOSTED_HOME). */
12
15
  env: Record<string, string>;
13
16
  timeoutMs?: number;
17
+ /** Test seam: run the resolved CLI without spawning it. */
18
+ exec?: PanelCliExec;
14
19
  }
15
20
  /** Start the preferred CLI as a detached panel; the CLI itself does the detaching. */
16
21
  export declare function startPanel(deps: PanelControlDeps): Promise<PanelControlResult>;
22
+ /**
23
+ * Stop the panel through its own CLI.
24
+ *
25
+ * The panel is the parent of every server it supervises, so this stops those
26
+ * too — including the dsh this plugin may be running in. That is the point of
27
+ * the button, not a hazard to hide: the caller's UI says so. An entry with
28
+ * `autostart` is started again on the next panel start; it is not restarted here.
29
+ */
30
+ export declare function stopPanel(deps: PanelControlDeps): Promise<PanelControlResult>;
17
31
  export declare function takeoverHelperPath(stateDir: string): string;
18
32
  export declare function takeoverLogPath(stateDir: string): string;
19
33
  /** The generated helper's source; exported so a test can inspect it. */
@@ -1,13 +1,9 @@
1
1
  import type { CliStatus } from '../shared/contracts.js';
2
2
  import type { CliLaunch } from './launch.js';
3
3
  /** The range the plugin ships and is tested against. */
4
- export declare const EXPECTED_RANGE = "^0.6.6";
4
+ export declare const EXPECTED_RANGE = "^0.6.7";
5
5
  /** Oldest release whose API and config schema this plugin relies on. */
6
6
  export declare const MIN_SUPPORTED_VERSION = "0.4.1";
7
- /** Oldest release whose config schema accepts `onPortConflict: kill`. */
8
- export declare const MIN_KILL_VERSION = "0.6.0";
9
- /** `persistent` (the nanny that keeps an entry alive across panel restarts). */
10
- export declare const MIN_PERSISTENT_VERSION = "0.6.3";
11
7
  export type CliPreference = 'pinned' | 'global';
12
8
  export interface CliResolution {
13
9
  launch: CliLaunch | null;
@@ -4,6 +4,8 @@ export interface EnsureTokenOptions {
4
4
  home: string;
5
5
  stateDir: string;
6
6
  exec: CliExecutor;
7
+ /** Which panel this credential is for; omitted is the panel the plugin manages. */
8
+ slot?: string;
7
9
  }
8
10
  export interface EnsureTokenResult {
9
11
  token: string | null;
@@ -15,14 +17,17 @@ export interface ReclaimTokenOptions {
15
17
  home: string;
16
18
  stateDir: string;
17
19
  exec: CliExecutor;
20
+ slot?: string;
18
21
  }
19
22
  export interface ReclaimTokenResult {
20
23
  token: string | null;
21
24
  detail: string;
22
25
  }
23
- export declare function storedTokenPath(stateDir: string): string;
24
- export declare function readStoredToken(stateDir: string): string | null;
25
- export declare function storeToken(stateDir: string, token: string): void;
26
+ /** A stable, unguessable file name for one state root's credential. */
27
+ export declare function tokenSlot(home: string): string;
28
+ export declare function storedTokenPath(stateDir: string, slot?: string): string;
29
+ export declare function readStoredToken(stateDir: string, slot?: string): string | null;
30
+ export declare function storeToken(stateDir: string, token: string, slot?: string): void;
26
31
  export declare function generateToken(): string;
27
32
  /** Whether home-hosted already holds an API token (only its hash is on disk). */
28
33
  export declare function apiTokenEnrolled(home: string): boolean;
@@ -7,7 +7,7 @@
7
7
  * only when the panel is not answering.
8
8
  */
9
9
  import { Service, type Context } from '@deepseek-ai/cordis';
10
- import type { BootMechanism, BootStatus, HomeHostedStatus, ManagedEntryStatus, PanelControlResult, PanelStatus, RpcEndpoint, UiAction, UiResult } from './shared/contracts.js';
10
+ import type { BootMechanism, BootStatus, HomeHostedStatus, InstanceView, ManagedEntryStatus, PanelControlResult, PanelStatus, RpcEndpoint, UiAction, UiResult } from './shared/contracts.js';
11
11
  import type { BootSpec } from './boot/types.js';
12
12
  import type { DshLaunch } from './home-hosted/dsh-entry.js';
13
13
  import type { SettingsStore } from './settings.js';
@@ -35,9 +35,17 @@ export declare class HomeHostedError extends Error {
35
35
  }
36
36
  export interface HomeHostedServiceOptions {
37
37
  home: string;
38
+ /** How that root was chosen: an explicit env var, an adopted legacy panel, or this instance's own. */
39
+ panelHomeSource?: 'env' | 'legacy' | 'instance';
38
40
  stateDir: string;
39
41
  homeHostedCommand?: string;
40
42
  defaultEntryId: string;
43
+ /** Other home-hosted state roots to report as panels; discovery finds the rest. */
44
+ instanceRoots?: readonly string[];
45
+ /** Test seam: this process's `$HHOSTED_HOME`; defaults to the environment. */
46
+ envHome?: string | null;
47
+ /** Test seam: where `~/.home-hosted*` siblings are looked for; defaults to the user's home. */
48
+ homeDir?: string;
41
49
  settings: SettingsStore;
42
50
  /** Test seam: run the home-hosted CLI without spawning it. */
43
51
  execCli?: (args: string[], env: Record<string, string | undefined>) => Promise<RunResult>;
@@ -57,9 +65,64 @@ export declare class HomeHostedService extends Service {
57
65
  private tokenDetail;
58
66
  /** When a missing managed entry was last put back, so a poll cannot become a write loop. */
59
67
  private entryRecoveryAt;
68
+ /** The last panel inventory, so a model-step assembly never reads the disk. */
69
+ private instanceCache;
60
70
  private readonly snapshotsFile;
61
71
  constructor(ctx: Context, options: HomeHostedServiceOptions);
62
72
  private resolveDsh;
73
+ /**
74
+ * The panel inventory, cached for a short while: the page polls status and
75
+ * the prompt provider reads it at every assembly. `refresh` (or an expired
76
+ * cache) re-reads the disk.
77
+ */
78
+ instances(refresh?: boolean): Promise<InstanceView[]>;
79
+ /**
80
+ * The inventory for a caller that cannot await — a prompt provider runs
81
+ * synchronously before a model step. Discovery is synchronous too, so this
82
+ * re-measures a stale cache in place and never blocks the step.
83
+ */
84
+ instancesNow(): InstanceView[];
85
+ private measureInstances;
86
+ /** The inventory as last measured, without touching the disk. */
87
+ instancesSnapshot(): InstanceView[];
88
+ /**
89
+ * The state root a call targets: the managed one unless it names another panel
90
+ * discovery reported. A caller-supplied path is never acted on directly, so a
91
+ * stray string cannot become a way to write anywhere on the disk.
92
+ */
93
+ private targetHome;
94
+ /**
95
+ * How a call reaches another panel. `via` names it; without one the endpoint's
96
+ * least invasive mechanism is used, which is the order the shared list keeps.
97
+ */
98
+ private mechanismFor;
99
+ /** Whether a token this plugin already holds for a panel still works. */
100
+ private foreignTokenWorks;
101
+ /**
102
+ * A client for another panel: the plugin's own credential for that state root,
103
+ * minted and enrolled the first time it is needed, proved against the panel,
104
+ * and replaced when the panel no longer accepts it.
105
+ *
106
+ * home-hosted keeps only a hash, so an existing token this plugin does not hold
107
+ * can only be replaced — which is what choosing this mechanism means.
108
+ */
109
+ private foreignClient;
110
+ /** Another panel's own config, refusing anything that is not a readable one. */
111
+ private readForeignConfig;
112
+ /** Another panel's entries as its config holds them: live status needs its API token. */
113
+ private listForeign;
114
+ private createForeign;
115
+ private updateForeign;
116
+ /** What a write actually left on disk, never what it was asked to write. */
117
+ private writtenForeignEntry;
118
+ private deleteForeign;
119
+ /**
120
+ * Start, stop or restart one entry through that panel's own CLI: this plugin
121
+ * holds no API token for a panel it does not manage, and a config edit cannot
122
+ * start anything. The CLI has no server restart, so a restart is a stop
123
+ * followed by a start — a stop that is not followed by one is reported.
124
+ */
125
+ private lifecycleForeign;
63
126
  private runtime;
64
127
  /** The entry id this very process was started as, when the panel supervises us. */
65
128
  selfEntryId(): string | null;
@@ -112,15 +175,6 @@ export declare class HomeHostedService extends Service {
112
175
  private dshCommandRepair;
113
176
  private writeOwned;
114
177
  private panelRunning;
115
- /**
116
- * The version whose schema will parse what we write: the answering panel, or
117
- * the CLI that will parse it next. `kill` did not exist before 0.6.0, and an
118
- * older panel refuses to *boot* with it — so this is checked before any write.
119
- */
120
- private configVersion;
121
- /** Whether the panel whose schema parses our write knows `persistent`. */
122
- private supportsPersistent;
123
- private assertPolicySupported;
124
178
  private applyIntents;
125
179
  /**
126
180
  * Stop managing an entry: restore what it was when we only adopted it, or
@@ -131,7 +185,7 @@ export declare class HomeHostedService extends Service {
131
185
  /** Turn off the autostart intent for one entry, keeping the entry itself. */
132
186
  private pauseManagedEntry;
133
187
  /** Drive the panel's own UI through its CLI, and report what is installed. */
134
- uiManage(action: UiAction, file?: string): Promise<UiResult>;
188
+ uiManage(action: UiAction, file?: string, target?: string): Promise<UiResult>;
135
189
  private restoreEntry;
136
190
  entriesStatus(): Promise<ManagedEntryStatus[]>;
137
191
  private ladder;
@@ -147,6 +201,15 @@ export declare class HomeHostedService extends Service {
147
201
  }>;
148
202
  /** Out-of-the-box start: run the preferred CLI's `up`, which detaches itself. */
149
203
  startPanelNow(): Promise<PanelControlResult>;
204
+ /**
205
+ * Stop the panel the plugin drives, and every server it supervises with it.
206
+ *
207
+ * Deliberately not a replacement for the takeover guard: stopping is safe to
208
+ * lose (an `autostart` entry comes back), while replacing is not. If this
209
+ * process is one of those servers the answer may never arrive, so the page
210
+ * treats a dropped call as "the panel is stopping" rather than a failure.
211
+ */
212
+ stopPanelNow(): Promise<PanelControlResult>;
150
213
  /**
151
214
  * Replace an answering panel with the preferred copy.
152
215
  *
@@ -179,7 +242,7 @@ export declare class HomeHostedService extends Service {
179
242
  private ensureManagedEntry;
180
243
  /** Re-assert the boot entry when the OS still has it but it stopped working. */
181
244
  private repairBootEntry;
182
- status(): Promise<HomeHostedStatus>;
245
+ status(refresh?: boolean): Promise<HomeHostedStatus>;
183
246
  call(endpoint: RpcEndpoint, payload: unknown): Promise<unknown>;
184
247
  }
185
248
  declare module '@deepseek-ai/cordis' {
@@ -21,7 +21,7 @@ export type Envelope<T> = {
21
21
  error: RpcError;
22
22
  };
23
23
  /** Endpoints the browser and the agent tools both speak. */
24
- export type RpcEndpoint = 'status' | 'servers.list' | 'servers.get' | 'servers.create' | 'servers.update' | 'servers.delete' | 'servers.start' | 'servers.stop' | 'servers.restart' | 'servers.freePort' | 'entries.apply' | 'entries.remove' | 'entries.restore' | 'boot.install' | 'boot.uninstall' | 'boot.verify' | 'panel.start' | 'panel.takeover' | 'panel.reclaimToken' | 'cli.installGlobal' | 'ui.manage' | 'settings.update';
24
+ export type RpcEndpoint = 'status' | 'servers.list' | 'servers.get' | 'servers.create' | 'servers.update' | 'servers.delete' | 'servers.start' | 'servers.stop' | 'servers.restart' | 'servers.freePort' | 'entries.apply' | 'entries.remove' | 'entries.restore' | 'boot.install' | 'boot.uninstall' | 'boot.verify' | 'panel.start' | 'panel.stop' | 'panel.takeover' | 'panel.reclaimToken' | 'cli.installGlobal' | 'ui.manage' | 'settings.update';
25
25
  /** A settings write sends only the changed subtree; the host merges group by group. */
26
26
  export interface SettingsPatch {
27
27
  autostart?: Partial<PluginSettings['autostart']>;
@@ -30,36 +30,69 @@ export interface SettingsPatch {
30
30
  panel?: Partial<PluginSettings['panel']>;
31
31
  authNotice?: boolean;
32
32
  reclaimToken?: boolean;
33
+ instancesNotice?: boolean;
33
34
  uiStyle?: UiStyle;
34
35
  agentTools?: Partial<PluginSettings['agentTools']>;
35
36
  cli?: Partial<PluginSettings['cli']>;
36
37
  }
38
+ /**
39
+ * A state root to act on instead of the panel this plugin manages. Only a panel
40
+ * discovery reports is accepted, and only on the endpoints {@link FOREIGN_MECHANISMS}
41
+ * lists.
42
+ */
43
+ export interface ForeignTarget {
44
+ /** A discovered panel's `--home`; omitted means the panel this plugin manages. */
45
+ home?: string;
46
+ /**
47
+ * How to reach it; omitted means the endpoint's first mechanism. Only a value
48
+ * {@link FOREIGN_MECHANISMS} lists for that endpoint is accepted.
49
+ */
50
+ via?: ForeignMechanism;
51
+ }
52
+ /**
53
+ * How this plugin can act on a panel it does not manage.
54
+ *
55
+ * `file` edits that panel's `servers.config.json`, `cli` runs the home-hosted
56
+ * CLI against its state root, and `api` enrols a token for it and drives its own
57
+ * HTTP API — the only way to reach some operations, and the only one that gives
58
+ * live status.
59
+ */
60
+ export type ForeignMechanism = 'file' | 'cli' | 'api';
61
+ /**
62
+ * Which ways each endpoint has to reach another panel, least invasive first (the
63
+ * first entry is the default when a caller names no mechanism). A config edit
64
+ * needs no credential, the CLI needs none either, and `api` mints one.
65
+ *
66
+ * Anything absent here is about this machine or about the managed panel itself
67
+ * and is never aimed elsewhere.
68
+ */
69
+ export declare const FOREIGN_MECHANISMS: Partial<Record<RpcEndpoint, readonly ForeignMechanism[]>>;
37
70
  export interface EndpointPayloads {
38
- /** `refresh` is accepted and ignored: every status call re-reads live state. */
71
+ /** `refresh` bypasses the short panel-inventory cache; status is live either way. */
39
72
  'status': {
40
73
  refresh?: boolean;
41
74
  };
42
- 'servers.list': Record<string, never>;
75
+ 'servers.list': ForeignTarget;
43
76
  'servers.get': {
44
77
  id: string;
45
78
  };
46
- 'servers.create': {
79
+ 'servers.create': ForeignTarget & {
47
80
  entry: ServerEntry;
48
81
  };
49
- 'servers.update': {
82
+ 'servers.update': ForeignTarget & {
50
83
  id: string;
51
84
  patch: ServerEntryPatch;
52
85
  };
53
- 'servers.delete': {
86
+ 'servers.delete': ForeignTarget & {
54
87
  id: string;
55
88
  };
56
- 'servers.start': {
89
+ 'servers.start': ForeignTarget & {
57
90
  id: string;
58
91
  };
59
- 'servers.stop': {
92
+ 'servers.stop': ForeignTarget & {
60
93
  id: string;
61
94
  };
62
- 'servers.restart': {
95
+ 'servers.restart': ForeignTarget & {
63
96
  id: string;
64
97
  };
65
98
  'servers.freePort': {
@@ -84,6 +117,11 @@ export interface EndpointPayloads {
84
117
  'boot.verify': Record<string, never>;
85
118
  /** Start the preferred CLI as a detached panel; refused while one already answers. */
86
119
  'panel.start': Record<string, never>;
120
+ /**
121
+ * Stop the answering panel through its own CLI. The servers it supervises stop
122
+ * with it, which can include the dsh this plugin is running in.
123
+ */
124
+ 'panel.stop': Record<string, never>;
87
125
  /** Stop the answering panel and start the preferred copy instead. */
88
126
  'panel.takeover': {
89
127
  force?: boolean;
@@ -96,7 +134,7 @@ export interface EndpointPayloads {
96
134
  /** Install the pinned range as a global CLI, so the `global` preference can use it. */
97
135
  'cli.installGlobal': Record<string, never>;
98
136
  /** Drive the panel's own UI: status, update, revert, or switch to a local build. */
99
- 'ui.manage': {
137
+ 'ui.manage': ForeignTarget & {
100
138
  action: UiAction;
101
139
  file?: string;
102
140
  };
@@ -237,6 +275,35 @@ export interface CliStatus {
237
275
  global?: CliCandidate | null;
238
276
  detail: string;
239
277
  }
278
+ /**
279
+ * `home-hosted` is not one panel per machine: a person can install it per
280
+ * project, or run several panels from one install with different `--home`
281
+ * roots. The plugin drives exactly one of them — the root it resolved.
282
+ */
283
+ export type InstanceSource = 'managed' | 'env' | 'sibling' | 'configured';
284
+ /** One home-hosted panel: its state root, and the facts readable from it. */
285
+ export interface InstanceView {
286
+ /** The state root itself: what `--home` / `$HHOSTED_HOME` names. */
287
+ home: string;
288
+ /**
289
+ * The panel this plugin drives. Every tool call targets it unless the call
290
+ * names another panel, and the plugin never writes to another one.
291
+ */
292
+ managed: boolean;
293
+ /** This dsh process runs as one of this panel's entries (`HHOSTED_SERVER_ID`). */
294
+ hosting: boolean;
295
+ url: string | null;
296
+ port: number | null;
297
+ pid: number | null;
298
+ version: string | null;
299
+ /** A `run.json` whose pid is still alive. */
300
+ running: boolean;
301
+ projectDir: string | null;
302
+ /** Entries in that panel's `servers.config.json`; `null` when it is unreadable. */
303
+ servers: number | null;
304
+ /** How discovery found this root. */
305
+ source: InstanceSource;
306
+ }
240
307
  export type BootMechanism = 'systemd-user' | 'systemd-system' | 'xdg-autostart' | 'launchd-agent' | 'launchd-daemon' | 'windows-run' | 'windows-task' | 'container' | 'unsupported';
241
308
  export type BootState = 'not-installed' | 'installed-disabled' | 'enabled-running' | 'enabled-failing' | 'unsupported';
242
309
  export interface BootCandidate {
@@ -269,7 +336,7 @@ export declare const AGENT_TOOL_NAMES: readonly AgentToolName[];
269
336
  */
270
337
  export declare const MUTATING_AGENT_TOOLS: readonly AgentToolName[];
271
338
  /** The shape this release writes; a file without it was written by 0.1.x. */
272
- export declare const SETTINGS_VERSION = 2;
339
+ export declare const SETTINGS_VERSION = 3;
273
340
  export type UiStyle = 'detailed' | 'compact';
274
341
  export interface PluginSettings {
275
342
  version: number;
@@ -299,6 +366,12 @@ export interface PluginSettings {
299
366
  * cleared it, or minted their own) should not keep an agent tool from working.
300
367
  */
301
368
  reclaimToken: boolean;
369
+ /**
370
+ * Tell the agent how many home-hosted panels exist on this machine, and ask
371
+ * the user which one to act on when there is more than one. On by default: a
372
+ * plugin that owns one panel must not look like the only one there is.
373
+ */
374
+ instancesNotice: boolean;
302
375
  /** `detailed` shows cards and open disclosures; `compact` folds both away. */
303
376
  uiStyle: UiStyle;
304
377
  cli: {
@@ -321,6 +394,14 @@ export interface HomeHostedStatus {
321
394
  /** The entry id this plugin manages; the page must not assume "dsh". */
322
395
  defaultEntryId: string;
323
396
  panel: PanelStatus;
397
+ /** The panel root this plugin instance drives, so a second install is visible. */
398
+ panelRoot?: string;
399
+ /** How that root was chosen; `legacy` means an existing `~/.home-hosted` panel was adopted. */
400
+ panelRootSource?: 'env' | 'legacy' | 'instance';
401
+ /** The OS boot artifact name this state root owns. */
402
+ bootUnitName?: string;
403
+ /** Every home-hosted panel found on this machine, the managed one first. */
404
+ instances?: InstanceView[];
324
405
  boot: BootStatus;
325
406
  entries: ManagedEntryStatus[];
326
407
  servers: ServerEntryView[];
@@ -3,6 +3,27 @@ export declare function expandHome(value: string): string;
3
3
  export declare function dshHome(): string;
4
4
  /** home-hosted state root: `$HHOSTED_HOME`, else `~/.home-hosted`. */
5
5
  export declare function homeHostedHome(): string;
6
+ /**
7
+ * The panel root one plugin instance drives.
8
+ *
9
+ * Two dsh installs share `~/.dsh` far more often than they should, and a shared
10
+ * panel root means two plugins editing one `servers.config.json` and one boot
11
+ * entry. So each instance normally gets its own root beside its own state.
12
+ *
13
+ * An explicit `$HHOSTED_HOME` is an instruction and always wins. So is a panel
14
+ * already living at the machine-wide default: an upgrade must never point a
15
+ * running panel somewhere else, or it would look like every server vanished.
16
+ */
17
+ export declare function resolveHomeHostedHome(stateDir: string, env?: NodeJS.ProcessEnv, homeDir?: string): {
18
+ home: string;
19
+ source: 'env' | 'legacy' | 'instance';
20
+ };
21
+ /**
22
+ * The boot artifact one state root owns: the default keeps the historical name,
23
+ * and every other instance gets its own, so two plugins cannot overwrite one
24
+ * unit or delete the other's entry.
25
+ */
26
+ export declare function bootUnitName(stateDir: string): string;
6
27
  /** Where this plugin keeps its own durable state (settings, snapshots, token). */
7
28
  export declare function pluginStateDir(override?: string): string;
8
29
  /** home-hosted's own tokens file, written by the panel at startup. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-home-hosted",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "DeepSeek Harness (dsh) plugin: start your dsh web server automatically at boot, and manage home-hosted's panel and servers from inside dsh.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -117,6 +117,6 @@
117
117
  "vitest": "^2.1.8"
118
118
  },
119
119
  "dependencies": {
120
- "home-hosted": "^0.6.6"
120
+ "home-hosted": "^0.6.7"
121
121
  }
122
122
  }