dsh-ssh-tui 0.4.1 → 0.5.1

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 (50) hide show
  1. package/README.en.md +64 -39
  2. package/README.md +46 -22
  3. package/cordis.patch.yml +4 -75
  4. package/docs/screenshots/compare.png +0 -0
  5. package/docs/screenshots/headless.png +0 -0
  6. package/docs/screenshots/slow-link.gif +0 -0
  7. package/docs/screenshots/workspace.png +0 -0
  8. package/lib/approval-reviewer.js +62 -0
  9. package/lib/approval-reviewer.js.map +1 -0
  10. package/lib/auto-approval.js +124 -0
  11. package/lib/auto-approval.js.map +1 -0
  12. package/lib/display-sock.js +583 -0
  13. package/lib/display-sock.js.map +1 -0
  14. package/lib/dsh-compat.js +45 -0
  15. package/lib/dsh-compat.js.map +1 -0
  16. package/lib/i18n/en.js +108 -14
  17. package/lib/i18n/en.js.map +1 -1
  18. package/lib/i18n/index.js +2 -1
  19. package/lib/i18n/index.js.map +1 -1
  20. package/lib/i18n/zh.js +108 -14
  21. package/lib/i18n/zh.js.map +1 -1
  22. package/lib/index.js +110 -6
  23. package/lib/index.js.map +1 -1
  24. package/lib/picker.js +14 -1
  25. package/lib/picker.js.map +1 -1
  26. package/lib/provider-catalog.js +169 -0
  27. package/lib/provider-catalog.js.map +1 -0
  28. package/lib/route-memory.js +1 -1
  29. package/lib/route-memory.js.map +1 -1
  30. package/lib/session-list.js +116 -11
  31. package/lib/session-list.js.map +1 -1
  32. package/lib/session-lock.js +182 -6
  33. package/lib/session-lock.js.map +1 -1
  34. package/lib/startup.js +2 -0
  35. package/lib/startup.js.map +1 -1
  36. package/lib/subagent-model.js +1 -1
  37. package/lib/subagent-model.js.map +1 -1
  38. package/lib/tui.js +1759 -361
  39. package/lib/tui.js.map +1 -1
  40. package/lib/types/approval-reviewer.d.ts +20 -0
  41. package/lib/types/auto-approval.d.ts +42 -0
  42. package/lib/types/display-sock.d.ts +74 -0
  43. package/lib/types/dsh-compat.d.ts +30 -0
  44. package/lib/types/i18n/index.d.ts +2 -0
  45. package/lib/types/picker.d.ts +4 -0
  46. package/lib/types/provider-catalog.d.ts +35 -0
  47. package/lib/types/session-list.d.ts +8 -1
  48. package/lib/types/session-lock.d.ts +51 -0
  49. package/lib/types/tui.d.ts +208 -24
  50. package/package.json +56 -30
@@ -0,0 +1,20 @@
1
+ export interface ReviewInput {
2
+ userText: string;
3
+ segments: string[];
4
+ toolName: string;
5
+ command: string;
6
+ }
7
+ export interface ReviewVerdict {
8
+ risk: 'low' | 'medium' | 'high';
9
+ authorization: 'yes' | 'no' | 'unknown';
10
+ approved: boolean;
11
+ reason: string;
12
+ }
13
+ export declare const REVIEW_SYSTEM_PROMPT: string;
14
+ /** Assemble the compact, fence-marked user message for the reviewer. */
15
+ export declare function buildReviewUserMessage(input: ReviewInput): string;
16
+ /**
17
+ * Parse the reviewer's one-line JSON verdict. Anything unreadable, missing
18
+ * fields, or with invalid enum values returns undefined (fail-safe).
19
+ */
20
+ export declare function parseReviewOutput(text: string): ReviewVerdict | undefined;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Codex-style auto-approval classifier for the /approval auto mode.
3
+ *
4
+ * This is a UX heuristic, not a security boundary. The contract mirrors
5
+ * Codex's unattended posture: allow-shaped commands (reads, builds, tests,
6
+ * git reads) approve automatically, danger-shaped commands are REJECTED with
7
+ * the model informed (it adapts instead of paging the human), and only
8
+ * genuinely unrecognized shapes fall through to a prompt — which detaches to
9
+ * a rejection when nobody is watching. Real damage containment still comes
10
+ * from the sandbox preset and git.
11
+ */
12
+ export type AutoApprovalMode = 'off' | 'auto';
13
+ export type ApprovalDecision = 'allow' | 'deny' | 'ask';
14
+ /**
15
+ * Whole-command danger patterns, checked before anything else. A match keeps
16
+ * the interactive prompt regardless of what else the command contains.
17
+ */
18
+ export declare const DANGER_PATTERNS: RegExp[];
19
+ /**
20
+ * Segment-level allow patterns: low-risk, high-frequency reads, builds, and
21
+ * tests. A command auto-approves only when EVERY segment matches one of these.
22
+ */
23
+ export declare const ALLOW_SEGMENT_PATTERNS: RegExp[];
24
+ /**
25
+ * Classify one shell command line. Danger anywhere auto-rejects; otherwise
26
+ * the command auto-approves only when every segment is a recognized low-risk
27
+ * pattern — unknown shapes ask (and detach to a rejection when unattended).
28
+ */
29
+ export declare function classifyCommand(command: string): ApprovalDecision;
30
+ /**
31
+ * Classify one approval request. `command` is the decoded shell command when
32
+ * the pending call is a shell tool. 'deny' auto-rejects (the model reads the
33
+ * rejection and adapts); 'ask' falls through to the interactive prompt.
34
+ */
35
+ export declare function classifyApproval(toolName: string, command: string | undefined): ApprovalDecision;
36
+ /**
37
+ * Decode the shell command of a streamed tool call from its raw JSON args.
38
+ * Returns undefined for non-shell tools or unparseable args.
39
+ */
40
+ export declare function commandFromArgs(toolName: string, args: string): string | undefined;
41
+ /** Parse the /approval argument into a mode. */
42
+ export declare function parseAutoApprovalMode(raw: string): AutoApprovalMode | undefined;
@@ -0,0 +1,74 @@
1
+ export declare const FRAME_STDIN = 1;
2
+ export declare const FRAME_STDOUT = 2;
3
+ export declare const FRAME_RESIZE = 3;
4
+ export declare const FRAME_HELLO = 4;
5
+ export declare const FRAME_GOODBYE = 5;
6
+ export declare const FRAME_RTT = 6;
7
+ /**
8
+ * Drop launcher SIGTERM/SIGINT/SIGHUP so closing SSH cannot dispose the tree
9
+ * before hangup handling. Leaving the session with setsid() is best-effort:
10
+ * a TTY session leader gets EPERM and stays in the SSH process group.
11
+ */
12
+ export declare function detachFromSshSession(): void;
13
+ export declare const TUI_HOST_ENV = "DSH_TUI_HOST";
14
+ export declare function isTuiHostProcess(env?: NodeJS.ProcessEnv): boolean;
15
+ export declare function sessionSockPath(sessionId: string, dshHome?: string): string;
16
+ export declare function encodeFrame(type: number, payload?: Buffer): Buffer;
17
+ export declare function encodeResize(columns: number, rows: number): Buffer;
18
+ export declare function decodeResize(payload: Buffer): {
19
+ columns: number;
20
+ rows: number;
21
+ } | undefined;
22
+ export declare function encodeRtt(rttMs: number | undefined): Buffer;
23
+ export declare function decodeRtt(payload: Buffer): number | undefined;
24
+ /** Incremental decoder for one socket. */
25
+ export declare class FrameReader {
26
+ private buffer;
27
+ push(chunk: Buffer): Array<{
28
+ type: number;
29
+ payload: Buffer;
30
+ }>;
31
+ }
32
+ export interface DisplayHostHandlers {
33
+ onStdin(bytes: Buffer): void;
34
+ onResize(columns: number, rows: number): void;
35
+ onRtt?(rttMs: number | undefined): void;
36
+ onDetach(): void;
37
+ onAttach(): void;
38
+ }
39
+ /**
40
+ * Host-side listener. At most one Display is attached; a new hello kicks the
41
+ * previous relay so two SSH windows cannot both drive the session.
42
+ */
43
+ export declare class DisplayHost {
44
+ readonly path: string;
45
+ private readonly handlers;
46
+ private server;
47
+ private socket;
48
+ private reader;
49
+ attached: boolean;
50
+ constructor(path: string, handlers: DisplayHostHandlers);
51
+ listen(): Promise<void>;
52
+ private accept;
53
+ sendStdout(bytes: Buffer | string): boolean;
54
+ sendGoodbye(): void;
55
+ close(): Promise<void>;
56
+ }
57
+ export declare function waitForDisplaySock(path: string, timeoutMs?: number, pid?: number, errFile?: string): Promise<void>;
58
+ export declare function hostArgvForSession(sessionId: string, argv?: string[], execArgv?: string[]): string[];
59
+ /** Spawn a detached Host copy of this `dsh` invocation and return its sock path. */
60
+ export declare function spawnDetachedHost(sessionId: string): {
61
+ pid: number;
62
+ sock: string;
63
+ errFile?: string;
64
+ };
65
+ export declare function probeDisplaySock(path: string, timeoutMs?: number): Promise<boolean>;
66
+ export interface RelayResult {
67
+ /** Host sent goodbye — user exited from the attached session. */
68
+ reason: 'goodbye' | 'host-closed' | 'signal';
69
+ }
70
+ /**
71
+ * Turn this process into a Display relay until the Host hangs up or the
72
+ * local TTY dies. Restores the terminal before resolving.
73
+ */
74
+ export declare function runDisplayRelay(path: string): Promise<RelayResult>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Dual-stack shims for dsh 0.1.1-rc.2 and 0.1.2-rc.1.
3
+ *
4
+ * 0.1.2 turned the settings free functions into `SettingsProvider` methods,
5
+ * replaced `Session.events` with on-demand readers, and started branding
6
+ * namespaces at the type level only. Every shim here keeps the same runtime
7
+ * value on both hosts and picks the API shape that is actually present.
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis';
10
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
11
+ import type { SettingsNamespace, SettingsSectionHooks } from '@deepseek-ai/dsh-settings';
12
+ import type z from '@deepseek-ai/schemastery';
13
+ /**
14
+ * 0.1.1-rc.2 wraps namespaces via `settingsNamespace()`; 0.1.2 brands them at
15
+ * the type level and takes the plain string at runtime. A cast covers both.
16
+ */
17
+ export declare function settingsNamespace(value: string): SettingsNamespace;
18
+ /**
19
+ * Register a settings section: 0.1.2-rc.1 moved the free function onto the
20
+ * `settings` service as `installSection`, callable only once that service is
21
+ * injected (plugins apply before it, so `ctx.inject` must defer — same
22
+ * pattern the harness's own packages use); 0.1.1-rc.2 keeps the free
23
+ * function, which defers internally and is safe at apply time.
24
+ */
25
+ export declare function installSettingsSection<T>(ctx: Context, ns: SettingsNamespace, schema: z<T>, entry: T, hooks: SettingsSectionHooks<T>): void;
26
+ /**
27
+ * Read the full durable event log: 0.1.2-rc.1 replaced the `Session.events`
28
+ * property with on-demand readers; 0.1.1-rc.2 still exposes the property.
29
+ */
30
+ export declare function sessionEvents(session: object): readonly SessionEvent[];
@@ -14,10 +14,12 @@ export declare const UI_LOCALE_SCHEMA: z<Schemastery.ObjectS<{
14
14
  language: z<string, string>;
15
15
  skipUpdate: z<string, string>;
16
16
  view: z<string, string>;
17
+ disconnect: z<string, string>;
17
18
  }>, Schemastery.ObjectT<{
18
19
  language: z<string, string>;
19
20
  skipUpdate: z<string, string>;
20
21
  view: z<string, string>;
22
+ disconnect: z<string, string>;
21
23
  }>>;
22
24
  export declare function localeFromTag(tag: string): Locale | undefined;
23
25
  /** Pick zh/en from env, optionally after a saved settings value. */
@@ -8,6 +8,10 @@ import type { Context } from '@deepseek-ai/cordis';
8
8
  export type SessionPickerResult = {
9
9
  kind: 'resume';
10
10
  id: string;
11
+ } | {
12
+ kind: 'attach';
13
+ id: string;
14
+ sock: string;
11
15
  } | {
12
16
  kind: 'new';
13
17
  } | null;
@@ -0,0 +1,35 @@
1
+ export interface CatalogPreset {
2
+ id: string;
3
+ name: string;
4
+ baseUrl: string;
5
+ modelIds: string[];
6
+ }
7
+ /** Filter presets by a case-insensitive substring match on id or name. */
8
+ export declare function filterCatalogPresets(presets: readonly CatalogPreset[], query: string): CatalogPreset[];
9
+ /** One selectable provider row in the /setup wizard's first step. */
10
+ export interface ProviderListEntry {
11
+ /** Unique key: `template:<type>` for the pinned rows, `catalog:<id>` otherwise. */
12
+ key: string;
13
+ label: string;
14
+ detail: string;
15
+ catalog?: CatalogPreset;
16
+ }
17
+ /**
18
+ * Merge the pinned templates with the web-catalog presets into one list:
19
+ * catalog ids in `dedupeIds` drop out (the template already covers them),
20
+ * and `query` filters by case-insensitive substring on label/key/detail.
21
+ */
22
+ export declare function mergeProviderEntries(templates: readonly ProviderListEntry[], presets: readonly CatalogPreset[], dedupeIds: readonly string[], query: string): ProviderListEntry[];
23
+ /**
24
+ * Read the catalog. Anchors are module paths that plausibly sit inside the dsh
25
+ * install: the running CLI entry (`process.argv[1]`) and the plugin's module
26
+ * base. Returns undefined when no reachable install ships a usable catalog or
27
+ * the child does not answer within 10 seconds.
28
+ */
29
+ export declare function readProviderCatalog(anchors: Array<string | undefined>): Promise<CatalogPreset[] | undefined>;
30
+ /**
31
+ * Read the catalog once per process and memoize the result: the child needs a
32
+ * moment to load pi-ai, so callers warm it at startup and reuse the settled
33
+ * value.
34
+ */
35
+ export declare function loadProviderCatalog(anchors: Array<string | undefined>): Promise<CatalogPreset[] | undefined>;
@@ -4,6 +4,7 @@
4
4
  * both surfaces offer the same sessions.
5
5
  */
6
6
  import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence';
7
+ import { listAttachableHosts } from './session-lock.js';
7
8
  /** Last path segment for the footer chip (`\root\genshin\srv` → `srv`). */
8
9
  export declare function sessionCwdLabel(cwd: string): string;
9
10
  export declare function formatFooterCwd(cwd: string): string;
@@ -28,7 +29,13 @@ export interface ResumableSession {
28
29
  cwd: string;
29
30
  /** Whether the full event log could not be inspected (corrupt/unsupported). */
30
31
  unreadable?: boolean;
32
+ /** Live Host that a new SSH can attach to. */
33
+ attach?: {
34
+ pid: number;
35
+ sock: string;
36
+ state?: string;
37
+ };
31
38
  }
32
39
  /** `MM-DD HH:mm` local-time label for session lists. */
33
40
  export declare function formatSessionTime(timestamp: number): string;
34
- export declare function listResumableSessions(persistence: SessionPersistence, currentId: string): Promise<ResumableSession[]>;
41
+ export declare function listResumableSessions(persistence: SessionPersistence, currentId: string, listHosts?: typeof listAttachableHosts): Promise<ResumableSession[]>;
@@ -1,8 +1,19 @@
1
+ export type SessionLockState = 'attached' | 'paused' | 'running-detached';
2
+ export type DisconnectPolicy = 'pause' | 'continue';
3
+ export type SessionLockAgentStatus = 'idle' | 'running' | 'cancelling';
1
4
  export interface SessionLockInfo {
2
5
  pid: number;
3
6
  sessionId: string;
4
7
  startedAt: string;
8
+ /** `/proc/sys/kernel/random/boot_id` when the lock was taken (POSIX only). */
9
+ bootId?: string;
10
+ /** `/proc/<pid>/stat` starttime (field 22) of `pid` when the lock was taken. */
11
+ pidStart?: string;
5
12
  tty?: string;
13
+ sock?: string;
14
+ state?: SessionLockState;
15
+ disconnectPolicy?: DisconnectPolicy;
16
+ agentStatus?: SessionLockAgentStatus;
6
17
  }
7
18
  export declare class SessionLockHeldError extends Error {
8
19
  readonly lock: SessionLockInfo;
@@ -13,14 +24,54 @@ export declare function sessionLockPath(sessionId: string, dshHome?: string): st
13
24
  export declare function parseSessionLock(raw: string): SessionLockInfo | undefined;
14
25
  /** True when `pid` still exists on this machine (best-effort). */
15
26
  export declare function processIsAlive(pid: number): boolean;
27
+ /**
28
+ * True when `pid` is genuinely the Host process that wrote `lock`.
29
+ *
30
+ * `processIsAlive` alone is not enough: the pid may have been recorded inside
31
+ * a different pid namespace (sandbox/container) and then be recycled by an
32
+ * unrelated host process — e.g. a lock written as pid 5 in a sandbox matches
33
+ * the forever-alive pid 5 kernel thread on the host, which used to produce a
34
+ * permanent false "zombie" that blocked `--resume`. We therefore verify the
35
+ * process identity:
36
+ * - new locks carry `bootId` + `pidStart` (boot_id + /proc/<pid>/stat
37
+ * starttime): a matching pair can only be the same process on the same
38
+ * boot, so a recycled or cross-namespace pid fails the check;
39
+ * - older locks fall back to `/proc/<pid>/cmdline`: the detached Host is
40
+ * always launched with `--resume=<sessionId>` in argv, so any other
41
+ * process (kernel threads have an empty cmdline) is proven stale.
42
+ * On platforms without procfs the legacy kill(pid, 0) behavior is kept.
43
+ */
44
+ export declare function lockOwnerIsAlive(lock: SessionLockInfo): boolean;
16
45
  export declare function formatLockHeldMessage(lock: SessionLockInfo): string;
17
46
  export declare function sessionLockDisabled(env?: NodeJS.ProcessEnv): boolean;
47
+ export declare function readSessionLock(sessionId: string, dshHome?: string): Promise<{
48
+ path: string;
49
+ info: SessionLockInfo;
50
+ } | undefined>;
51
+ export declare function writeSessionLock(path: string, info: SessionLockInfo): Promise<void>;
18
52
  export declare function acquireSessionLock(sessionId: string, options?: {
19
53
  pid?: number;
20
54
  tty?: string | null;
21
55
  dshHome?: string;
56
+ sock?: string;
57
+ state?: SessionLockState;
58
+ disconnectPolicy?: DisconnectPolicy;
59
+ agentStatus?: SessionLockAgentStatus;
22
60
  }): Promise<{
23
61
  path: string;
24
62
  info: SessionLockInfo;
25
63
  }>;
26
64
  export declare function releaseSessionLock(path: string, pid?: number): Promise<void>;
65
+ export type LiveHostKind = 'attachable' | 'zombie';
66
+ export declare function inspectLiveHost(sessionId: string, dshHome?: string): Promise<{
67
+ kind: LiveHostKind;
68
+ lock: SessionLockInfo;
69
+ path: string;
70
+ sock: string;
71
+ } | undefined>;
72
+ /** Every lock file under `$DSH_HOME/tui-locks` whose Host pid is still alive. */
73
+ export declare function listAttachableHosts(dshHome?: string): Promise<Array<{
74
+ sessionId: string;
75
+ lock: SessionLockInfo;
76
+ sock: string;
77
+ }>>;