@linxin666/dsh-ssh 0.1.13 → 0.1.15

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.
@@ -1,4 +1,14 @@
1
1
  import type { SshApi } from '../api.ts';
2
+ import type { TunnelInfo } from '../../protocol.ts';
3
+ /** Live-tunnel polling interval while the tab is mounted (ms). */
4
+ export declare const TUNNEL_POLL_MS = 5000;
5
+ /**
6
+ * Return `next` only when the tunnel list changed in a user-visible way
7
+ * (identity, ordering or any renderable field), else `null` so a poll tick
8
+ * with no real change keeps the previous reference and React skips the
9
+ * re-render. `prev === null` (first load) always accepts the list.
10
+ */
11
+ export declare function diffTunnels(prev: TunnelInfo[] | null, next: TunnelInfo[]): TunnelInfo[] | null;
2
12
  /** Tunnels tab props. */
3
13
  export interface TunnelsTabProps {
4
14
  api: SshApi;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Cluster execution: run one command concurrently across many hosts (all, or
3
+ * filtered by explicit aliases / environment / tags with ALL semantics).
4
+ */
5
+ import type { ClusterResult } from '../protocol.ts';
6
+ import { type PoolEngine } from './connection-pool.ts';
7
+ interface ClusterOptions {
8
+ command: string;
9
+ aliases?: string[];
10
+ environment?: string;
11
+ tags?: string[];
12
+ timeoutMs?: number;
13
+ maxWorkers?: number;
14
+ }
15
+ /** Run one command against many hosts concurrently. */
16
+ export declare function cluster(engine: PoolEngine, options: ClusterOptions): Promise<ClusterResult[]>;
17
+ export {};
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Connection pool: per-alias persistent ssh2 connections with multi-hop jump
3
+ * support, the acquire / dispose / sweep lifecycle, and the pooled exec path.
4
+ */
5
+ import { Client, type ConnectConfig } from 'ssh2';
6
+ import type { ExecResult, SshHostEntry } from '../protocol.ts';
7
+ import { type HostStore } from '../store.ts';
8
+ /** Default engine knobs. */
9
+ export interface EngineOptions {
10
+ /** Connections idle longer than this are closed (ms). */
11
+ idleTimeoutMs?: number;
12
+ /** SSH handshake timeout (ms). */
13
+ connectTimeoutMs?: number;
14
+ /** Keepalive ping interval (ms). */
15
+ keepaliveIntervalMs?: number;
16
+ /** Cap on captured stdout/stderr bytes per exec (ms). */
17
+ maxOutputBytes?: number;
18
+ /** Default exec timeout (ms). */
19
+ defaultExecTimeoutMs?: number;
20
+ /** Default cluster concurrency. */
21
+ defaultMaxWorkers?: number;
22
+ /** SFTP concurrent channel count for transfers. */
23
+ sftpConcurrency?: number;
24
+ }
25
+ /** Default engine knobs (applied when an option is omitted). */
26
+ export declare const DEFAULTS: Required<EngineOptions>;
27
+ /** One pooled connection record. */
28
+ export interface PoolRecord {
29
+ client: Client;
30
+ /** Jump-chain clients kept alive under the target. */
31
+ hops: Client[];
32
+ idleAt: number;
33
+ /** Pinned connections (tunnels) are never swept. */
34
+ pinned: boolean;
35
+ broken: boolean;
36
+ /** Operations currently running on this connection (sweep guard). */
37
+ inFlight: number;
38
+ }
39
+ /**
40
+ * The slice of the engine the pool and exec paths need. The host class
41
+ * (engine.ts facade) satisfies this structurally.
42
+ */
43
+ export interface PoolEngine {
44
+ readonly store: HostStore;
45
+ readonly opts: Required<EngineOptions>;
46
+ readonly pool: Map<string, PoolRecord>;
47
+ readonly acquireQueue: Map<string, Promise<PoolRecord>>;
48
+ }
49
+ /** Build the ssh2 connect config for one entry (key read from disk). */
50
+ export declare function buildConnectConfig(entry: SshHostEntry, sock?: ConnectConfig['sock']): ConnectConfig;
51
+ /** Cap captured output at the configured byte budget (marks truncation). */
52
+ export declare function appendOutput(target: {
53
+ text: string;
54
+ truncated: boolean;
55
+ }, chunk: Buffer, maxBytes: number): void;
56
+ /**
57
+ * Build one full jump chain for an entry: hop clients connected through in
58
+ * order, each forwarding a stream to the next destination, ending with the
59
+ * target client. Shared by the pool and standalone shell sessions.
60
+ */
61
+ export declare function connectChain(engine: PoolEngine, entry: SshHostEntry): Promise<{
62
+ client: Client;
63
+ hops: Client[];
64
+ }>;
65
+ /** Connect (or reuse) the pooled chain for one alias; pins nothing. */
66
+ export declare function acquire(engine: PoolEngine, alias: string): Promise<PoolRecord>;
67
+ /**
68
+ * Tear down one alias's record. When `record` is given and no longer the
69
+ * pooled record for the alias (a concurrent acquire replaced it), nothing
70
+ * is torn down — the connection belongs to someone else now.
71
+ */
72
+ export declare function disposeRecord(engine: PoolEngine, alias: string, record?: PoolRecord): void;
73
+ /** Close connections idle beyond the threshold (skips pinned and in-flight). */
74
+ export declare function sweepPool(engine: PoolEngine): void;
75
+ /**
76
+ * Run `fn` with a live client for `alias`, reconnecting (up to the
77
+ * attempt budget) when the connection broke mid-flight.
78
+ */
79
+ export declare function withClient<T>(engine: PoolEngine, alias: string, fn: (client: Client) => Promise<T>, attempts?: number): Promise<T>;
80
+ /** Run one command on `alias` (reusing the pooled connection). */
81
+ export declare function execCommand(engine: PoolEngine, alias: string, command: string, timeoutMs?: number): Promise<ExecResult>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * PTY shell sessions for the web terminal: a standalone (non-pooled)
3
+ * ssh2 connection with a long-lived shell channel, resize, and pausable
4
+ * output delivery.
5
+ */
6
+ import { type PoolEngine } from './connection-pool.ts';
7
+ /** A live PTY shell session. */
8
+ export interface ShellSession {
9
+ /** Assign to receive remote output. */
10
+ onData?: (data: Buffer) => void;
11
+ /** Assign to be notified when the channel closes. */
12
+ onExit?: (code: number | null, error?: string) => void;
13
+ /** Write raw input to the shell. */
14
+ send(data: string): void;
15
+ /** Resize the remote PTY. */
16
+ resize(cols: number, rows: number): void;
17
+ /** Close the session and its channel. */
18
+ close(): void;
19
+ /** Pause remote output delivery (transport backpressure). */
20
+ pause(): void;
21
+ /** Resume remote output delivery. */
22
+ resume(): void;
23
+ }
24
+ /**
25
+ * Open a PTY shell session for the web terminal (standalone connection).
26
+ * The shell is a long-lived exclusive stream: it uses its own connection so
27
+ * closing it can never tear down a pooled exec/tunnel sharing the alias.
28
+ */
29
+ export declare function openShell(engine: PoolEngine, alias: string, size: {
30
+ cols: number;
31
+ rows: number;
32
+ }): Promise<ShellSession>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * SFTP transfers: upload (file or recursive tree), single-file download, and
3
+ * remote directory listing. Every channel is opened once per operation and
4
+ * released exactly once so sshd's MaxSessions cap is never exhausted.
5
+ */
6
+ import type { RemoteDirEntry, TransferProgress } from '../protocol.ts';
7
+ import { type PoolEngine } from './connection-pool.ts';
8
+ /** Walk a local directory, collecting relative paths of every file. */
9
+ export declare function walkLocalDir(root: string): string[];
10
+ /** Upload one local file (or directory tree) to a remote path. */
11
+ export declare function upload(engine: PoolEngine, alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void): Promise<{
12
+ bytes: number;
13
+ files: number;
14
+ }>;
15
+ /** Download one remote file to a local path. */
16
+ export declare function download(engine: PoolEngine, alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void): Promise<{
17
+ bytes: number;
18
+ }>;
19
+ /** List a remote directory (file browser). */
20
+ export declare function ls(engine: PoolEngine, alias: string, path: string): Promise<RemoteDirEntry[]>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Local port-forward tunnels: one loopback-only listener per tunnel, pinned
3
+ * to a pooled connection so an idle sweep never closes it, with per-tunnel
4
+ * socket tracking and stop (single / alias-scoped / all).
5
+ */
6
+ import { type Server as NetServer, type Socket } from 'node:net';
7
+ import type { TunnelInfo } from '../protocol.ts';
8
+ import { type PoolEngine } from './connection-pool.ts';
9
+ /** One active tunnel record (server + pinned client + live sockets). */
10
+ export interface TunnelRecord {
11
+ info: TunnelInfo;
12
+ server: NetServer;
13
+ alias: string;
14
+ sockets: Set<Socket>;
15
+ }
16
+ /** The engine slice the tunnel module needs (adds the tunnel registry). */
17
+ export interface TunnelEngine extends PoolEngine {
18
+ readonly tunnels: Map<string, TunnelRecord>;
19
+ nextTunnelId: number;
20
+ }
21
+ /** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
22
+ export declare function startTunnel(engine: TunnelEngine, alias: string, options: {
23
+ remotePort: number;
24
+ remoteHost?: string;
25
+ localPort?: number;
26
+ }): Promise<TunnelInfo>;
27
+ /** All active tunnels. */
28
+ export declare function listTunnels(engine: TunnelEngine): TunnelInfo[];
29
+ /** Stop one tunnel (closes the listener, live sockets, and the pinned connection). */
30
+ export declare function stopTunnel(engine: TunnelEngine, id: string): boolean;
31
+ /** Stop all tunnels (optionally for one alias). */
32
+ export declare function stopAllTunnels(engine: TunnelEngine, alias?: string): number;
@@ -1,56 +1,31 @@
1
1
  /**
2
- * The SSH engine: a per-alias persistent connection pool (ssh2) with
2
+ * The SSH engine facade: a per-alias persistent connection pool (ssh2) with
3
3
  * multi-hop jump support, command execution, PTY shells, SFTP transfers,
4
- * local port-forward tunnels and cluster execution — the DSH counterpart of
5
- * ssh-skill's daemon + scripts, living entirely in the host process.
4
+ * local port-forward tunnels and cluster execution. The heavy lifting lives
5
+ * in the engine/ modules (connection-pool, pty, sftp, tunnel, cluster); this
6
+ * class composes them behind one new SshEngine instance per plugin apply.
6
7
  */
7
8
  import type { ClusterResult, ExecResult, SshHostSummary, TestResult, TransferProgress, TunnelInfo } from './protocol.ts';
8
- import { type HostStore } from './store.ts';
9
- /** Default engine knobs. */
10
- export interface EngineOptions {
11
- /** Connections idle longer than this are closed (ms). */
12
- idleTimeoutMs?: number;
13
- /** SSH handshake timeout (ms). */
14
- connectTimeoutMs?: number;
15
- /** Keepalive ping interval (ms). */
16
- keepaliveIntervalMs?: number;
17
- /** Cap on captured stdout/stderr bytes per exec (ms). */
18
- maxOutputBytes?: number;
19
- /** Default exec timeout (ms). */
20
- defaultExecTimeoutMs?: number;
21
- /** Default cluster concurrency. */
22
- defaultMaxWorkers?: number;
23
- /** SFTP concurrent channel count for transfers. */
24
- sftpConcurrency?: number;
25
- }
26
- /** A live PTY shell session. */
27
- export interface ShellSession {
28
- /** Assign to receive remote output. */
29
- onData?: (data: Buffer) => void;
30
- /** Assign to be notified when the channel closes. */
31
- onExit?: (code: number | null, error?: string) => void;
32
- /** Write raw input to the shell. */
33
- send(data: string): void;
34
- /** Resize the remote PTY. */
35
- resize(cols: number, rows: number): void;
36
- /** Close the session and its channel. */
37
- close(): void;
38
- /** Pause remote output delivery (transport backpressure). */
39
- pause(): void;
40
- /** Resume remote output delivery. */
41
- resume(): void;
42
- }
9
+ import type { HostStore } from './store.ts';
10
+ import { type EngineOptions, type PoolRecord } from './engine/connection-pool.ts';
11
+ import { type ShellSession } from './engine/pty.ts';
12
+ import { type TunnelRecord } from './engine/tunnel.ts';
13
+ export type { EngineOptions } from './engine/connection-pool.ts';
14
+ export type { ShellSession } from './engine/pty.ts';
15
+ export type { PoolRecord } from './engine/connection-pool.ts';
16
+ export type { TunnelRecord } from './engine/tunnel.ts';
43
17
  /**
44
18
  * The engine. Owns the pool, tunnels, and all operations. One instance per
45
19
  * plugin apply; dispose() closes every connection.
46
20
  */
47
21
  export declare class SshEngine {
48
- private readonly store;
49
- private readonly opts;
50
- private readonly pool;
51
- private readonly tunnels;
22
+ readonly store: HostStore;
23
+ readonly opts: Required<EngineOptions>;
24
+ readonly pool: Map<string, PoolRecord>;
25
+ readonly acquireQueue: Map<string, Promise<PoolRecord>>;
26
+ readonly tunnels: Map<string, TunnelRecord>;
27
+ nextTunnelId: number;
52
28
  private sweepTimer;
53
- private nextTunnelId;
54
29
  /**
55
30
  * @param store - the host config store.
56
31
  * @param options - engine knobs (defaults applied).
@@ -60,30 +35,6 @@ export declare class SshEngine {
60
35
  list(query?: string): SshHostSummary[];
61
36
  /** One host summary by alias. */
62
37
  find(alias: string): SshHostSummary | undefined;
63
- /**
64
- * Run `fn` with a live client for `alias`, reconnecting (up to the
65
- * attempt budget) when the connection broke mid-flight.
66
- */
67
- private withClient;
68
- /**
69
- * Build one full jump chain for an entry: hop clients connected through in
70
- * order, each forwarding a stream to the next destination, ending with the
71
- * target client. Shared by the pool and standalone shell sessions.
72
- */
73
- private connectChain;
74
- /** In-flight acquire promises, deduped per alias (concurrent first use). */
75
- private readonly acquireQueue;
76
- /** Connect (or reuse) the pooled chain for one alias; pins nothing. */
77
- private acquire;
78
- private doAcquire;
79
- /**
80
- * Tear down one alias's record. When `record` is given and no longer the
81
- * pooled record for the alias (a concurrent acquire replaced it), nothing
82
- * is torn down — the connection belongs to someone else now.
83
- */
84
- private disposeRecord;
85
- /** Close connections idle beyond the threshold (skips pinned and in-flight). */
86
- private sweep;
87
38
  /** Run one command on `alias` (reusing the pooled connection). */
88
39
  exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult>;
89
40
  /** Run one command against many hosts concurrently. */
@@ -111,18 +62,6 @@ export declare class SshEngine {
111
62
  }>;
112
63
  /** List a remote directory (file browser). */
113
64
  ls(alias: string, path: string): Promise<import('./protocol.ts').RemoteDirEntry[]>;
114
- /**
115
- * Open one SFTP channel, run the operation, and release the channel exactly
116
- * once when the operation settles (success or error). ssh2 keeps each
117
- * subsystem channel open until end(); without this, every transfer leaks a
118
- * channel until sshd's MaxSessions cap makes all later opens fail.
119
- */
120
- private withSftp;
121
- private sftp;
122
- /** Create a remote directory chain (stat-then-mkdir per segment). */
123
- private ensureRemoteDir;
124
- private fastPut;
125
- private fastGet;
126
65
  /** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
127
66
  startTunnel(alias: string, options: {
128
67
  remotePort: number;
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.1.13",
4
+ "version": "0.1.15",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -6,7 +6,7 @@
6
6
  * once per page load (module-level guard).
7
7
  */
8
8
  import { useEffect, useRef, useState } from 'react'
9
- import { Terminal } from '@xterm/xterm'
9
+ import { Terminal, type IDisposable } from '@xterm/xterm'
10
10
  import { FitAddon } from '@xterm/addon-fit'
11
11
  import type { SshApi, TerminalConnection } from '../api.ts'
12
12
  import type { SshHostSummary } from '../../protocol.ts'
@@ -53,6 +53,7 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
53
53
  const termRef = useRef<Terminal | null>(null)
54
54
  const fitRef = useRef<FitAddon | null>(null)
55
55
  const connRef = useRef<TerminalConnection | null>(null)
56
+ const dataSubRef = useRef<IDisposable | null>(null)
56
57
 
57
58
  useEffect(() => { ensureXtermCss() }, [])
58
59
 
@@ -84,6 +85,11 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
84
85
  connection.onExit = undefined
85
86
  connection.close()
86
87
  }
88
+ // Release the xterm input subscription explicitly and dispose the
89
+ // terminal so no listener (or the terminal Renderer) survives a
90
+ // disconnect or the tab unmounting.
91
+ dataSubRef.current?.dispose()
92
+ dataSubRef.current = null
87
93
  termRef.current?.dispose()
88
94
  termRef.current = null
89
95
  fitRef.current = null
@@ -128,13 +134,14 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
128
134
  fitRef.current = fit
129
135
  connRef.current = connection
130
136
  let settled = false
131
- const dataSub = term.onData(data => { connection.send(data) })
137
+ dataSubRef.current = term.onData(data => { connection.send(data) })
132
138
  connection.onReady = () => { setStatus({ kind: 'connected', alias: target }) }
133
139
  connection.onOutput = data => { term.write(data) }
134
140
  connection.onExit = (code, error) => {
135
141
  if (settled) return
136
142
  settled = true
137
- dataSub.dispose()
143
+ dataSubRef.current?.dispose()
144
+ dataSubRef.current = null
138
145
  term.options.disableStdin = true
139
146
  connRef.current = null
140
147
  // Keep the last output visible; input is now disabled.
@@ -9,6 +9,31 @@ import type { SshHostSummary, TunnelInfo } from '../../protocol.ts'
9
9
  import { errorMessage, tt } from './helpers.ts'
10
10
  import css from './panel.module.css'
11
11
 
12
+ /** Live-tunnel polling interval while the tab is mounted (ms). */
13
+ export const TUNNEL_POLL_MS = 5000
14
+
15
+ /**
16
+ * Return `next` only when the tunnel list changed in a user-visible way
17
+ * (identity, ordering or any renderable field), else `null` so a poll tick
18
+ * with no real change keeps the previous reference and React skips the
19
+ * re-render. `prev === null` (first load) always accepts the list.
20
+ */
21
+ export function diffTunnels(prev: TunnelInfo[] | null, next: TunnelInfo[]): TunnelInfo[] | null {
22
+ if (prev === null) return next
23
+ if (prev.length !== next.length) return next
24
+ for (let index = 0; index < prev.length; index += 1) {
25
+ const a = prev[index]
26
+ const b = next[index]
27
+ if (a.id !== b.id || a.alias !== b.alias || a.state !== b.state
28
+ || a.localPort !== b.localPort || a.remoteHost !== b.remoteHost
29
+ || a.remotePort !== b.remotePort || a.startedAt !== b.startedAt
30
+ || a.error !== b.error) {
31
+ return next
32
+ }
33
+ }
34
+ return null
35
+ }
36
+
12
37
  /** Tunnels tab props. */
13
38
  export interface TunnelsTabProps {
14
39
  api: SshApi
@@ -40,8 +65,10 @@ export function TunnelsTab({ api }: TunnelsTabProps) {
40
65
  return () => { disposed = true }
41
66
  }, [api])
42
67
 
43
- // Live list with a 5s heartbeat while mounted. Every load carries a
44
- // sequence number so stale responses never overwrite newer state.
68
+ // Live list with a TUNNEL_POLL_MS heartbeat while mounted. Every load
69
+ // carries a sequence number so stale responses never overwrite newer
70
+ // state, and the list is diff-set so an unchanged poll tick keeps the
71
+ // previous state reference (no wasted re-render).
45
72
  const seqRef = useRef(0)
46
73
  useEffect(() => {
47
74
  const load = async (): Promise<void> => {
@@ -49,7 +76,7 @@ export function TunnelsTab({ api }: TunnelsTabProps) {
49
76
  try {
50
77
  const list = await api.listTunnels()
51
78
  if (seq !== seqRef.current) return
52
- setTunnels(list)
79
+ setTunnels(prev => diffTunnels(prev, list) ?? prev)
53
80
  setError(null)
54
81
  } catch (cause) {
55
82
  if (seq !== seqRef.current) return
@@ -57,7 +84,7 @@ export function TunnelsTab({ api }: TunnelsTabProps) {
57
84
  }
58
85
  }
59
86
  void load()
60
- const timer = setInterval(() => { void load() }, 5000)
87
+ const timer = setInterval(() => { void load() }, TUNNEL_POLL_MS)
61
88
  return () => { clearInterval(timer) }
62
89
  }, [api])
63
90
 
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Cluster execution: run one command concurrently across many hosts (all, or
3
+ * filtered by explicit aliases / environment / tags with ALL semantics).
4
+ */
5
+
6
+ import type { ClusterResult } from '../protocol.ts'
7
+ import { execCommand, type PoolEngine } from './connection-pool.ts'
8
+
9
+ interface ClusterOptions {
10
+ command: string
11
+ aliases?: string[]
12
+ environment?: string
13
+ tags?: string[]
14
+ timeoutMs?: number
15
+ maxWorkers?: number
16
+ }
17
+
18
+ /** Run one command against many hosts concurrently. */
19
+ export async function cluster(engine: PoolEngine, options: ClusterOptions): Promise<ClusterResult[]> {
20
+ let targets = engine.store.list()
21
+ if (options.aliases !== undefined && options.aliases.length > 0) {
22
+ targets = targets.filter(entry => options.aliases!.includes(entry.alias))
23
+ }
24
+ if (options.environment !== undefined && options.environment !== '') {
25
+ targets = targets.filter(entry => entry.environment === options.environment)
26
+ }
27
+ if (options.tags !== undefined && options.tags.length > 0) {
28
+ // ALL semantics (matches the ssh_cluster tool description).
29
+ targets = targets.filter(entry => options.tags!.every(tag => entry.tags.includes(tag)))
30
+ }
31
+ if (targets.length === 0) return []
32
+ if (options.maxWorkers !== undefined && (!Number.isInteger(options.maxWorkers) || options.maxWorkers < 1)) {
33
+ throw new Error('maxWorkers must be a positive integer')
34
+ }
35
+ const workers = Math.min(engine.opts.defaultMaxWorkers, options.maxWorkers ?? engine.opts.defaultMaxWorkers, targets.length)
36
+ const results: ClusterResult[] = []
37
+ const queue = [...targets]
38
+ const run = async (): Promise<void> => {
39
+ while (queue.length > 0) {
40
+ const entry = queue.shift()!
41
+ try {
42
+ const result = await execCommand(engine, entry.alias, options.command, options.timeoutMs)
43
+ results.push({ alias: entry.alias, ok: result.success, exitCode: result.exitCode, timedOut: result.timedOut, stdout: result.stdout, stderr: result.stderr, durationMs: result.durationMs })
44
+ } catch (error) {
45
+ results.push({ alias: entry.alias, ok: false, error: error instanceof Error ? error.message : String(error) })
46
+ }
47
+ }
48
+ }
49
+ await Promise.all(Array.from({ length: workers }, () => run()))
50
+ return results
51
+ }