@linxin666/dsh-ssh 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/lib/client.js +303 -459
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +707 -466
  4. package/lib/types/client/api.d.ts +27 -4
  5. package/lib/types/client/native-panel.d.ts +70 -0
  6. package/lib/types/client/panel/SshPanel.d.ts +1 -3
  7. package/lib/types/client/panel/TerminalTab.d.ts +6 -1
  8. package/lib/types/client/panel/controller.d.ts +71 -4
  9. package/lib/types/engine/terminal-sessions.d.ts +67 -0
  10. package/lib/types/mount-once.d.ts +38 -7
  11. package/lib/types/protocol.d.ts +12 -1
  12. package/lib/types/routes.d.ts +2 -0
  13. package/package.json +19 -16
  14. package/src/client/api.ts +52 -22
  15. package/src/client/index.ts +30 -8
  16. package/src/client/native-panel.tsx +141 -0
  17. package/src/client/panel/SshPanel.tsx +25 -28
  18. package/src/client/panel/TerminalTab.tsx +54 -14
  19. package/src/client/panel/controller.ts +126 -7
  20. package/src/client/panel/panel.module.css +8 -128
  21. package/src/engine/terminal-sessions.ts +280 -0
  22. package/src/index.ts +4 -1
  23. package/src/mount-once.ts +107 -13
  24. package/src/protocol.ts +6 -1
  25. package/src/routes.ts +26 -107
  26. package/lib/types/client/body-mutations.d.ts +0 -45
  27. package/lib/types/client/mount.d.ts +0 -27
  28. package/lib/types/client/panel-mount-core.d.ts +0 -56
  29. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  30. package/lib/types/client/sidebar-entry.d.ts +0 -26
  31. package/src/client/body-mutations.ts +0 -133
  32. package/src/client/mount.tsx +0 -47
  33. package/src/client/panel-mount-core.ts +0 -155
  34. package/src/client/sidebar-entry-core.ts +0 -237
  35. package/src/client/sidebar-entry.ts +0 -51
@@ -9,8 +9,8 @@ export declare class SshApiError extends Error {
9
9
  }
10
10
  /** One open terminal connection (WebSocket JSON frames). */
11
11
  export interface TerminalConnection {
12
- /** Fired on the ready frame (shell is up). */
13
- onReady: (() => void) | undefined;
12
+ /** Fired on the ready frame (shell is up); carries the host session id and alias. */
13
+ onReady: ((sessionId: string, alias: string) => void) | undefined;
14
14
  /** Fired on every output frame. */
15
15
  onOutput: ((data: string) => void) | undefined;
16
16
  /** Fired on the exit frame (or transport error). */
@@ -26,7 +26,13 @@ export interface TerminalConnection {
26
26
  resize(cols: number, rows: number): void;
27
27
  /** Send interactive 2FA response codes to the remote server. */
28
28
  sendAuthResponse(responses: string[]): void;
29
- /** Close the socket and the remote session. */
29
+ /**
30
+ * Leave the socket while the host keeps the remote shell alive, so a later
31
+ * {@link SshApi.attachTerminal} picks the session up where it left off.
32
+ * This is what a view does on unmount; it is not a disconnect.
33
+ */
34
+ detach(): void;
35
+ /** Close the socket and end the remote session. */
30
36
  close(): void;
31
37
  }
32
38
  /** The browser half's only data entry point. */
@@ -74,6 +80,23 @@ export declare class SshApi {
74
80
  streamed: boolean;
75
81
  bytes: number;
76
82
  }>;
77
- /** Open a WebSocket terminal session. */
83
+ /**
84
+ * Open a new host terminal session for the alias.
85
+ * @param alias - the configured host alias.
86
+ * @param cols - initial PTY width.
87
+ * @param rows - initial PTY height.
88
+ * @returns the live connection; its ready callback carries the session id
89
+ * {@link attachTerminal} uses after a view detach.
90
+ */
78
91
  openTerminal(alias: string, cols: number, rows: number): TerminalConnection;
92
+ /**
93
+ * Reattach to a host-side session that outlived its view (a panel switch, a
94
+ * page navigation). The host replays its scrollback before the ready frame.
95
+ * @param sessionId - the id the previous connection reported on ready.
96
+ * @param cols - the view's current width.
97
+ * @param rows - the view's current height.
98
+ */
99
+ attachTerminal(sessionId: string, cols: number, rows: number): TerminalConnection;
100
+ /** One terminal socket over either an alias (open) or a session id (attach). */
101
+ private terminalSocket;
79
102
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Native panel registration for the SSH panel.
3
+ *
4
+ * The panel is an official-style center-column page, not a DOM takeover: it
5
+ * contributes a row into the sidebar shell's own global panel list
6
+ * (`sidebar.panellist`) and its page into the layout's keyed `main` slot. The
7
+ * shell then owns the row box, the label, the active highlight, the collapsed
8
+ * rail and the panel switch, exactly as it does for the shipped Plugins and
9
+ * Schedule pages and for the task board and the skill center.
10
+ *
11
+ * Both registrations go through `ctx.slots.inject`, which fires only once the
12
+ * owning shell entry has declared the seat: load order between this plugin and
13
+ * ui-layout / ui-sidebar therefore does not matter, and neither does a shell
14
+ * that cannot serve the seats (the callback simply never runs).
15
+ *
16
+ * The terminal's PTY shell lives on the host and outlives this page (see
17
+ * engine/terminal-sessions.ts), so the layout unmounting a deselected page
18
+ * costs the view its xterm instance, not the remote session: the terminal tab
19
+ * reattaches by session id when the page mounts again.
20
+ *
21
+ * @module @linxin666/dsh-client-ssh/client
22
+ */
23
+ import type { Context as ClientContext } from '@deepseek-ai/cordis';
24
+ import type { SshApi } from './api.ts';
25
+ import { SSH_PANEL_ID, type PanelController } from './panel/controller.ts';
26
+ import { type TerminalFontSource } from './panel/helpers.ts';
27
+ /** The panel id shared by the sidebar row and the main-slot occupant. */
28
+ export { SSH_PANEL_ID };
29
+ /**
30
+ * The sidebar row glyph the shell asks for at its own size and active state.
31
+ * The shell owns the button, label, tooltip and rail geometry; this component
32
+ * draws only the glyph, like every other panel row.
33
+ *
34
+ * The glyph carries `data-dsh-panel-entry` because it is the only DOM this
35
+ * panel's own code owns inside that shell-owned row: the L2 contract (skins)
36
+ * resolves which row belongs to which plugin through it, since the shell
37
+ * stamps no per-entry hook of its own (see contracts/semantic-attrs-v1.md).
38
+ * @param props - the shell's icon share: square edge and selection state.
39
+ * @returns the decorative terminal glyph.
40
+ */
41
+ export declare function SshPanelIcon({ size }: {
42
+ size: number;
43
+ active: boolean;
44
+ }): React.ReactElement;
45
+ /**
46
+ * The main-slot page. The layout mounts it only while this panel is selected;
47
+ * the wrapper carries the pinned `data-dsh-ssh-view` semantic anchor (L2
48
+ * contract, skins) the takeover container used to own.
49
+ * @param props - the framework main-slot share plus this entry's injected face.
50
+ * @returns the SSH panel page.
51
+ */
52
+ export declare function SshPanelPage({ controller, api, terminalFont }: {
53
+ controller: PanelController;
54
+ api: SshApi;
55
+ terminalFont?: TerminalFontSource;
56
+ }): React.ReactElement;
57
+ /**
58
+ * Register the SSH panel's sidebar row and center-column page.
59
+ *
60
+ * Both seats are declared by shell plugins this package does not depend on at
61
+ * runtime, so each registration is wrapped in `ctx.slots.inject`: the callback
62
+ * runs only after the owning entry declares the seat, and a shell that never
63
+ * declares it leaves the panel simply absent instead of failing boot.
64
+ * @param ctx - client root context (services: slots).
65
+ * @param controller - the controller the page and the row drive.
66
+ * @param api - the SSH API client the page operates through.
67
+ * @param terminalFont - live terminal-font setting source (issue #577).
68
+ * @returns disposer releasing both registrations.
69
+ */
70
+ export declare function registerSshPanel(ctx: ClientContext, controller: PanelController, api: SshApi, terminalFont?: TerminalFontSource): () => void;
@@ -1,11 +1,9 @@
1
1
  import type { SshApi } from '../api.ts';
2
2
  import type { PanelController } from './controller.ts';
3
3
  import { type TerminalFontSource } from './helpers.ts';
4
- /** The panel's tab identifiers. */
5
- export type SshTab = 'hosts' | 'terminal' | 'transfer' | 'tunnels' | 'cluster';
6
4
  /** Panel shell props. */
7
5
  export interface SshPanelProps {
8
- /** The panel state owner (open/close/toggle). */
6
+ /** The panel state owner (open/close/toggle, active tab, terminal session). */
9
7
  controller: PanelController;
10
8
  /** The SSH API client every tab operates through. */
11
9
  api: SshApi;
@@ -1,8 +1,13 @@
1
1
  import type { SshApi } from '../api.ts';
2
+ import type { PanelController } from './controller.ts';
2
3
  import { type TerminalFontSource } from './helpers.ts';
3
4
  /** Terminal tab props. */
4
5
  export interface TerminalTabProps {
5
6
  api: SshApi;
7
+ /** The panel controller that owns the live host session id across mounts. */
8
+ controller: PanelController;
9
+ /** Host session to reattach to on mount (survives a panel switch). */
10
+ sessionId?: string;
6
11
  /** Alias preselected by a "connect" action from the hosts tab. */
7
12
  presetAlias?: string;
8
13
  /** Monotonic id of the connect request (re-applies presetAlias). */
@@ -14,4 +19,4 @@ export interface TerminalTabProps {
14
19
  terminalFont?: TerminalFontSource;
15
20
  }
16
21
  /** The xterm terminal view. */
17
- export declare function TerminalTab({ api, presetAlias, requestId, terminalFont }: TerminalTabProps): import("react").JSX.Element;
22
+ export declare function TerminalTab({ api, controller, sessionId, presetAlias, requestId, terminalFont }: TerminalTabProps): import("react").JSX.Element;
@@ -1,22 +1,89 @@
1
1
  /**
2
- * SSH panel controller: the single owner of the panel's open/closed state.
2
+ * SSH panel controller: the single owner of the panel's view state.
3
3
  *
4
4
  * Framework-free (structural runtime faces, task-board core/controller.ts
5
- * style) so the DOM mounts and the React panel share one tiny subscription
5
+ * style) so the sidebar row and the React panel share one tiny subscription
6
6
  * surface. The state lives only for the browser session (no persistence).
7
+ *
8
+ * The active tab, the hosts-tab connect request and the live terminal session
9
+ * id live here rather than inside the panel components: the layout mounts a
10
+ * keyed page only while its panel is selected, so component-local state would
11
+ * reset the tab on every panel switch and lose the id a reattach needs.
7
12
  */
13
+ /** The panel's tab identifiers. */
14
+ export type SshTab = 'hosts' | 'terminal' | 'transfer' | 'tunnels' | 'cluster';
15
+ /** A "connect" action from the hosts tab, handed to the terminal tab. */
16
+ export interface ConnectRequest {
17
+ alias: string;
18
+ /** Monotonic id so a repeated connect for the same alias still re-applies. */
19
+ nonce: number;
20
+ }
21
+ /** Stable id shared by the sidebar panel row and the main-slot page. */
22
+ export declare const SSH_PANEL_ID = "ssh";
8
23
  /** Immutable controller snapshot for UI subscriptions. */
9
24
  export interface PanelControllerSnapshot {
25
+ /** Whether the center column shows this panel. */
10
26
  panelOpen: boolean;
27
+ /** The tab the panel is showing. */
28
+ activeTab: SshTab;
29
+ /** Pending hosts-tab connect request, or null. */
30
+ connectRequest: ConnectRequest | null;
31
+ /** Host-side terminal session the terminal tab should reattach to, if any. */
32
+ terminalSessionId: string | undefined;
33
+ }
34
+ /**
35
+ * The layout's panel-navigation face: selecting the panel is the layout's
36
+ * business, and this is the one call that asks for it (null returns the column
37
+ * to the conversation). Optional, so a shell-less composition keeps the pure
38
+ * state transitions testable.
39
+ */
40
+ export interface PanelNavigationFace {
41
+ /** Select this panel, or null to hand the column back to the conversation. */
42
+ select(panelId: string | null): void;
43
+ }
44
+ /** Controller dependencies. */
45
+ export interface PanelControllerDeps {
46
+ /** Layout panel selection; absent keeps the controller state-only. */
47
+ panel?: PanelNavigationFace;
11
48
  }
12
- /** The panel state owner the sidebar entry toggles and the view renders from. */
49
+ /** The panel state owner the sidebar row and the view both read. */
13
50
  export declare class PanelController {
51
+ private readonly deps;
14
52
  private panelOpen;
53
+ private activeTab;
54
+ private connectRequest;
55
+ private terminalSessionId;
15
56
  private listeners;
57
+ /** Cached so getSnapshot is referentially stable between changes. */
58
+ private snapshot;
59
+ constructor(deps?: PanelControllerDeps);
16
60
  getSnapshot(): PanelControllerSnapshot;
17
61
  subscribe(fn: () => void): () => void;
62
+ /**
63
+ * Show the panel. The layout owns which panel the column renders, so the
64
+ * state flip and the selection travel together; the selection is requested
65
+ * after the snapshot flips, so a subscriber never observes "open" while the
66
+ * shell still shows the conversation.
67
+ */
18
68
  open(): void;
69
+ /** Return the column to the conversation, asking the layout explicitly. */
19
70
  close(): void;
20
71
  toggle(): void;
21
- private notify;
72
+ /**
73
+ * Reflect a selection made outside this controller (the user clicked another
74
+ * sidebar row, or the layout dropped the panel id).
75
+ * @param panelId - the layout's current panel id, or null for the conversation.
76
+ */
77
+ syncPanelSelection(panelId: string | null): void;
78
+ /** Switch tabs, keeping every other piece of view state. */
79
+ setActiveTab(tab: SshTab): void;
80
+ /** Hand the terminal tab a host to connect to (hosts-tab "connect" action). */
81
+ requestConnect(alias: string): void;
82
+ /** Record the host-side session so a later page mount can reattach. */
83
+ setTerminalSession(sessionId: string): void;
84
+ /** Forget the session (it exited, or the user disconnected). */
85
+ clearTerminalSession(): void;
86
+ private commit;
87
+ /** Ask the layout to select a panel; a shell without the face is a no-op. */
88
+ private selectPanel;
22
89
  }
@@ -0,0 +1,67 @@
1
+ import { WebSocket } from 'ws';
2
+ import type { KeyboardInteractiveHandler } from './connection-pool.ts';
3
+ import type { ShellSession } from './pty.ts';
4
+ /** What the registry needs from the engine (swappable in tests). */
5
+ export interface TerminalSessionDeps {
6
+ /** Open one PTY shell for the alias (engine.openShell). */
7
+ openShell(alias: string, size: {
8
+ cols: number;
9
+ rows: number;
10
+ }, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<ShellSession>;
11
+ /** @returns the current time (defaults to Date.now). */
12
+ now?: () => number;
13
+ /** Bounded scrollback per session. */
14
+ maxBufferBytes?: number;
15
+ /** Idle window before a detached session is reaped. */
16
+ idleMs?: number;
17
+ /** Grace window an exited session is kept for reattaching views. */
18
+ exitGraceMs?: number;
19
+ }
20
+ /**
21
+ * The host's terminal session table: open one session per connect, attach any
22
+ * number of sockets, and keep the shell alive across detach.
23
+ */
24
+ export declare class TerminalSessionRegistry {
25
+ private readonly sessions;
26
+ private readonly deps;
27
+ private readonly maxBufferBytes;
28
+ private readonly idleMs;
29
+ private readonly exitGraceMs;
30
+ constructor(deps: TerminalSessionDeps);
31
+ /** Open a new session for the alias and attach the requesting socket to it. */
32
+ open(ws: WebSocket, alias: string, size: {
33
+ cols: number;
34
+ rows: number;
35
+ }): Promise<void>;
36
+ /**
37
+ * Attach a socket to an existing session, replaying its scrollback first.
38
+ * @returns false when the session is unknown (expired or already reaped).
39
+ */
40
+ attach(ws: WebSocket, sessionId: string, size: {
41
+ cols: number;
42
+ rows: number;
43
+ }): boolean;
44
+ /** End every session (host teardown, plugin dispose). */
45
+ dispose(): void;
46
+ /** Number of live sessions (introspection for tests and diagnostics). */
47
+ size(): number;
48
+ /** Wire one socket's frames and lifecycle onto a session. */
49
+ private bind;
50
+ /** Remove a socket from its session, keeping the shell alive. */
51
+ private detach;
52
+ /** End the session a socket is attached to (explicit close from the view). */
53
+ private end;
54
+ /** Mark a session exited and keep it briefly so a reattaching view sees it. */
55
+ private finish;
56
+ /** Append output to the replay buffer, trimmed to the byte cap. */
57
+ private pushOutput;
58
+ /** Broadcast one frame to every attached socket, honoring backpressure. */
59
+ private send;
60
+ private sendTo;
61
+ /** Pause while any attached socket is backed up; resume once all drained. */
62
+ private applyBackpressure;
63
+ /** Answer a keyboard-interactive challenge through whoever is attached. */
64
+ private keyboardInteractive;
65
+ private scheduleReap;
66
+ private clearReap;
67
+ }
@@ -4,19 +4,50 @@
4
4
  * the loader accepts a standalone install of the same package side by side;
5
5
  * without this guard the second instance would still re-register the same
6
6
  * webserver routes, tools, settings namespaces, and system-prompt sections
7
- * and fail the boot. mountOnce makes the second host apply a no-op for the
8
- * lifetime of the first instance (the browser half is already deduped by
9
- * package name in the client module host).
7
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
8
+ * no-op for as long as the first instance lives (the browser half is already
9
+ * deduped by package name in the client module host).
10
+ *
11
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
12
+ * long after the refused mount ran its course: the Host reloads a profile by
13
+ * creating the new loader entries before the old ones are torn down (a
14
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
15
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
16
+ * previous entry still owns the name. Dropping that refused mount lost the
17
+ * plugin for good - the previous entry then disposed its own mount, releasing
18
+ * the name with nobody left to take it, and the family row stayed listed as
19
+ * active while its host routes 404ed (the task-board panel showed
20
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
21
+ * restart recovered it).
22
+ *
23
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
24
+ * the holder releases the name - if the waiting fiber is still alive then. The
25
+ * single-instance guarantee is unchanged: exactly one mount is live per
26
+ * package name, and the replay re-enters the guard so a later mount still
27
+ * dedupes against it.
10
28
  *
11
29
  * The registry rides a global symbol so two module instances of the same
12
- * package (npm copy vs repository link) still share one verdict. cordis
13
- * `ctx.effect` runs its callback immediately and treats the callback's
30
+ * package (npm copy vs repository link) still share one verdict. That symbol
31
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
32
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
33
+ * dsh-community-plugins) are separate repositories carrying their own copy of
34
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
35
+ * must keep the shape every published copy reads (a `Set` of package names
36
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
37
+ * under their own additive key, and the registry reads back a `Set` even when
38
+ * some other build left a different value there. Changing `MOUNTED`'s shape
39
+ * in place broke that contract once: a satellite's legacy copy created a
40
+ * `Set`, the family's new copy read it as a `Map`, and every family row
41
+ * mounted after it failed with "claims.get is not a function".
42
+ *
43
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
14
44
  * return value as the fiber disposer, so the unmarker is returned, not run.
15
45
  */
16
46
  /**
17
47
  * Wrap a cordis plugin apply so the package runs at most once per process.
18
- * The first mount registers normally and unmarks when its fiber disposes;
19
- * any later mount of the same package name is a no-op.
48
+ * The first mount registers normally and releases the name when its fiber
49
+ * disposes; a mount refused while that name is held waits for the release and
50
+ * then runs, unless its own fiber disposes first.
20
51
  * @param packageName - npm package identity shared by every install source.
21
52
  * @param fn - the original plugin apply.
22
53
  * @returns an apply of the same shape.
@@ -172,9 +172,12 @@ export interface ApiErrorBody {
172
172
  error: string;
173
173
  }
174
174
  /** WebSocket terminal protocol frames (host -> client and client -> host). */
175
- export type TerminalServerFrame = {
175
+ export type TerminalServerFrame =
176
+ /** The shell is up; `sessionId` reattaches to it after a view detach. */
177
+ {
176
178
  type: 'ready';
177
179
  alias: string;
180
+ sessionId: string;
178
181
  } | {
179
182
  type: 'output';
180
183
  data: string;
@@ -201,6 +204,14 @@ export type TerminalClientFrame = {
201
204
  } | {
202
205
  type: 'auth_response';
203
206
  responses: string[];
207
+ }
208
+ /** Leave the socket while the host keeps the shell alive for a reattach. */
209
+ | {
210
+ type: 'detach';
211
+ }
212
+ /** End the session outright (the view's disconnect control). */
213
+ | {
214
+ type: 'close';
204
215
  };
205
216
  /** Route paths the client calls (shared literals). */
206
217
  export declare const SSH_API_BASE: "/api/dsh-ssh";
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver';
10
10
  import type { SshEngine } from './engine.ts';
11
+ import { TerminalSessionRegistry } from './engine/terminal-sessions.ts';
11
12
  import type { HostStore } from './store.ts';
12
13
  /** Route family dependencies. */
13
14
  export interface SshRoutesDeps {
@@ -28,4 +29,5 @@ export interface SshRoutesDeps {
28
29
  export declare function makeRoutes(deps: SshRoutesDeps): {
29
30
  routes: WebRoute[];
30
31
  upgrade: WebUpgradeRoute;
32
+ terminalSessions: TerminalSessionRegistry;
31
33
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-ssh",
3
3
  "description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.4.1",
4
+ "version": "0.4.3",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
7
7
  "engines": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dsh": {
29
29
  "engines": {
30
- "dsh": ">=0.1.7-rc.1"
30
+ "dsh": ">=0.1.7-rc.2"
31
31
  },
32
32
  "bundle": {
33
33
  "patch": "./cordis.patch.yml"
@@ -36,7 +36,8 @@
36
36
  "inject": [
37
37
  "@deepseek-ai/dsh-client-locale",
38
38
  "@deepseek-ai/dsh-client-ui-renderer",
39
- "@deepseek-ai/dsh-client-ui-settings"
39
+ "@deepseek-ai/dsh-client-ui-settings",
40
+ "@deepseek-ai/dsh-client-ui-layout"
40
41
  ],
41
42
  "platform": "web"
42
43
  }
@@ -49,24 +50,26 @@
49
50
  "ws": "^8.18.0"
50
51
  },
51
52
  "peerDependencies": {
52
- "@deepseek-ai/dsh": ">=0.1.7-rc.1",
53
+ "@deepseek-ai/dsh": ">=0.1.7-rc.2",
53
54
  "react": "^18.2.0",
54
55
  "react-dom": "^18.2.0"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@deepseek-ai/cordis": "^4.0.4",
58
- "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.1",
59
- "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
60
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1",
61
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.1",
62
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
63
- "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
64
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.1",
65
- "@deepseek-ai/dsh-scope": "^0.1.7-rc.1",
66
- "@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
67
- "@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.1",
68
- "@deepseek-ai/dsh-timeout": "^0.1.7-rc.1",
69
- "@deepseek-ai/dsh-tools": "^0.1.7-rc.1",
59
+ "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.2",
60
+ "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.2",
61
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.2",
62
+ "@deepseek-ai/dsh-client-ui-layout": "^0.1.7-rc.2",
63
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.2",
64
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.7-rc.2",
65
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.2",
66
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
67
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
68
+ "@deepseek-ai/dsh-scope": "^0.1.7-rc.2",
69
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.2",
70
+ "@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.2",
71
+ "@deepseek-ai/dsh-timeout": "^0.1.7-rc.2",
72
+ "@deepseek-ai/dsh-tools": "^0.1.7-rc.2",
70
73
  "@types/node": "^22.20.0",
71
74
  "@types/react": "~18.3.1",
72
75
  "@types/react-dom": "^18.3.5",
package/src/client/api.ts CHANGED
@@ -78,8 +78,8 @@ function query(params: Record<string, string | number | undefined>): string {
78
78
 
79
79
  /** One open terminal connection (WebSocket JSON frames). */
80
80
  export interface TerminalConnection {
81
- /** Fired on the ready frame (shell is up). */
82
- onReady: (() => void) | undefined
81
+ /** Fired on the ready frame (shell is up); carries the host session id and alias. */
82
+ onReady: ((sessionId: string, alias: string) => void) | undefined
83
83
  /** Fired on every output frame. */
84
84
  onOutput: ((data: string) => void) | undefined
85
85
  /** Fired on the exit frame (or transport error). */
@@ -92,7 +92,13 @@ export interface TerminalConnection {
92
92
  resize(cols: number, rows: number): void
93
93
  /** Send interactive 2FA response codes to the remote server. */
94
94
  sendAuthResponse(responses: string[]): void
95
- /** Close the socket and the remote session. */
95
+ /**
96
+ * Leave the socket while the host keeps the remote shell alive, so a later
97
+ * {@link SshApi.attachTerminal} picks the session up where it left off.
98
+ * This is what a view does on unmount; it is not a disconnect.
99
+ */
100
+ detach(): void
101
+ /** Close the socket and end the remote session. */
96
102
  close(): void
97
103
  }
98
104
 
@@ -344,32 +350,56 @@ export class SshApi {
344
350
  }
345
351
 
346
352
  // ------------------------------------------------------------ terminal
347
- /** Open a WebSocket terminal session. */
353
+ /**
354
+ * Open a new host terminal session for the alias.
355
+ * @param alias - the configured host alias.
356
+ * @param cols - initial PTY width.
357
+ * @param rows - initial PTY height.
358
+ * @returns the live connection; its ready callback carries the session id
359
+ * {@link attachTerminal} uses after a view detach.
360
+ */
348
361
  openTerminal(alias: string, cols: number, rows: number): TerminalConnection {
362
+ return this.terminalSocket(query({ alias, cols, rows }))
363
+ }
364
+
365
+ /**
366
+ * Reattach to a host-side session that outlived its view (a panel switch, a
367
+ * page navigation). The host replays its scrollback before the ready frame.
368
+ * @param sessionId - the id the previous connection reported on ready.
369
+ * @param cols - the view's current width.
370
+ * @param rows - the view's current height.
371
+ */
372
+ attachTerminal(sessionId: string, cols: number, rows: number): TerminalConnection {
373
+ return this.terminalSocket(query({ session: sessionId, cols, rows }))
374
+ }
375
+
376
+ /** One terminal socket over either an alias (open) or a session id (attach). */
377
+ private terminalSocket(search: string): TerminalConnection {
349
378
  const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
350
- const url = scheme + '://' + window.location.host + SSH_API.terminal + query({ alias, cols, rows })
379
+ const url = scheme + '://' + window.location.host + SSH_API.terminal + search
351
380
  const socket = new WebSocket(url)
381
+ // A deliberate detach/close must not surface as a transport error: the
382
+ // view tears down silently, and only an UNEXPECTED close reports exit.
383
+ let leaving = false
384
+ const sendFrame = (frame: TerminalClientFrame): void => {
385
+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame))
386
+ }
352
387
  const connection: TerminalConnection = {
353
388
  onReady: undefined,
354
389
  onOutput: undefined,
355
390
  onExit: undefined,
356
391
  onAuthPrompt: undefined,
357
- send: (data) => {
358
- if (socket.readyState === WebSocket.OPEN) {
359
- socket.send(JSON.stringify({ type: 'input', data } satisfies TerminalClientFrame))
360
- }
361
- },
362
- resize: (cols, rows) => {
363
- if (socket.readyState === WebSocket.OPEN) {
364
- socket.send(JSON.stringify({ type: 'resize', cols, rows } satisfies TerminalClientFrame))
365
- }
366
- },
367
- sendAuthResponse: (responses) => {
368
- if (socket.readyState === WebSocket.OPEN) {
369
- socket.send(JSON.stringify({ type: 'auth_response', responses } satisfies TerminalClientFrame))
370
- }
392
+ send: (data) => { sendFrame({ type: 'input', data }) },
393
+ resize: (cols, rows) => { sendFrame({ type: 'resize', cols, rows }) },
394
+ sendAuthResponse: (responses) => { sendFrame({ type: 'auth_response', responses }) },
395
+ detach: () => {
396
+ leaving = true
397
+ sendFrame({ type: 'detach' })
398
+ try { socket.close() } catch { /* already closed */ }
371
399
  },
372
400
  close: () => {
401
+ leaving = true
402
+ sendFrame({ type: 'close' })
373
403
  try { socket.close() } catch { /* already closed */ }
374
404
  },
375
405
  }
@@ -380,13 +410,13 @@ export class SshApi {
380
410
  } catch {
381
411
  return
382
412
  }
383
- if (frame.type === 'ready') connection.onReady?.()
413
+ if (frame.type === 'ready') connection.onReady?.(frame.sessionId, frame.alias)
384
414
  else if (frame.type === 'output') connection.onOutput?.(frame.data)
385
415
  else if (frame.type === 'exit') connection.onExit?.(frame.code, frame.error)
386
416
  else if (frame.type === 'auth_prompt') connection.onAuthPrompt?.(frame.name, frame.instructions, frame.prompts)
387
417
  }
388
- socket.onclose = () => { connection.onExit?.(null, 'connection closed') }
389
- socket.onerror = () => { connection.onExit?.(null, 'connection error') }
418
+ socket.onclose = () => { if (!leaving) connection.onExit?.(null, 'connection closed') }
419
+ socket.onerror = () => { if (!leaving) connection.onExit?.(null, 'connection error') }
390
420
  return connection
391
421
  }
392
422
  }