moqi-tui 0.2.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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +782 -0
  3. package/bin/moqi.mjs +40 -0
  4. package/cordis.patch.yml +41 -0
  5. package/lib/cross-find.js +217 -0
  6. package/lib/file-index.js +121 -0
  7. package/lib/fleet-sources.js +114 -0
  8. package/lib/index.js +3999 -0
  9. package/lib/persist.js +194 -0
  10. package/lib/plugins.js +371 -0
  11. package/lib/presence.js +144 -0
  12. package/lib/rename.js +35 -0
  13. package/lib/rewind.js +94 -0
  14. package/lib/sessions-store.js +134 -0
  15. package/lib/startup.js +92 -0
  16. package/lib/tui/atfile.js +154 -0
  17. package/lib/tui/export.js +48 -0
  18. package/lib/tui/fleet.js +346 -0
  19. package/lib/tui/i18n.js +201 -0
  20. package/lib/tui/jobs.js +65 -0
  21. package/lib/tui/keys.js +205 -0
  22. package/lib/tui/markdown.js +368 -0
  23. package/lib/tui/mcp.js +95 -0
  24. package/lib/tui/panels.js +231 -0
  25. package/lib/tui/screen.js +156 -0
  26. package/lib/tui/state.js +502 -0
  27. package/lib/tui/stream.js +109 -0
  28. package/lib/tui/text.js +173 -0
  29. package/lib/tui/theme.js +183 -0
  30. package/lib/tui/themes.js +153 -0
  31. package/lib/tui/tooldetail.js +140 -0
  32. package/lib/tui/view.js +830 -0
  33. package/lib/tui/vim.js +222 -0
  34. package/lib/tui-host-core.js +141 -0
  35. package/lib/tui-host.js +48 -0
  36. package/lib/types/cross-find.d.ts +66 -0
  37. package/lib/types/file-index.d.ts +34 -0
  38. package/lib/types/fleet-sources.d.ts +34 -0
  39. package/lib/types/index.d.ts +51 -0
  40. package/lib/types/persist.d.ts +116 -0
  41. package/lib/types/plugins.d.ts +218 -0
  42. package/lib/types/presence.d.ts +48 -0
  43. package/lib/types/rename.d.ts +32 -0
  44. package/lib/types/rewind.d.ts +75 -0
  45. package/lib/types/sessions-store.d.ts +46 -0
  46. package/lib/types/startup.d.ts +45 -0
  47. package/lib/types/tui/atfile.d.ts +90 -0
  48. package/lib/types/tui/export.d.ts +18 -0
  49. package/lib/types/tui/fleet.d.ts +209 -0
  50. package/lib/types/tui/i18n.d.ts +34 -0
  51. package/lib/types/tui/jobs.d.ts +28 -0
  52. package/lib/types/tui/keys.d.ts +52 -0
  53. package/lib/types/tui/markdown.d.ts +14 -0
  54. package/lib/types/tui/mcp.d.ts +34 -0
  55. package/lib/types/tui/panels.d.ts +125 -0
  56. package/lib/types/tui/screen.d.ts +79 -0
  57. package/lib/types/tui/state.d.ts +323 -0
  58. package/lib/types/tui/stream.d.ts +78 -0
  59. package/lib/types/tui/text.d.ts +28 -0
  60. package/lib/types/tui/theme.d.ts +87 -0
  61. package/lib/types/tui/themes.d.ts +70 -0
  62. package/lib/types/tui/tooldetail.d.ts +45 -0
  63. package/lib/types/tui/view.d.ts +163 -0
  64. package/lib/types/tui/vim.d.ts +64 -0
  65. package/lib/types/tui-host-core.d.ts +62 -0
  66. package/lib/types/tui-host.d.ts +42 -0
  67. package/lib/types/version.d.ts +8 -0
  68. package/lib/types/voice.d.ts +227 -0
  69. package/lib/version.js +32 -0
  70. package/lib/voice.js +405 -0
  71. package/package.json +119 -0
  72. package/scripts/harness-root.mjs +88 -0
  73. package/scripts/install-profile.mjs +133 -0
package/bin/moqi.mjs ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `moqi` launcher: install (or refresh) the `tui` profile, then
4
+ * hand off to `dsh --profile tui`.
5
+ *
6
+ * Works identically from a git checkout and a global npm install — the
7
+ * package root is resolved from this file's own location, and the profile's
8
+ * `link:` dependency points at whichever copy is running.
9
+ */
10
+ import { execFileSync } from 'node:child_process'
11
+ import { dirname, resolve } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url))
15
+ const root = resolve(here, '..')
16
+
17
+ const args = process.argv.slice(2)
18
+ const command = args[0] === 'install' ? 'install' : 'run'
19
+
20
+ try {
21
+ // The installer is plain Node with no build step, so it runs from source.
22
+ execFileSync(process.execPath, [resolve(root, 'scripts', 'install-profile.mjs'), 'tui'], {
23
+ stdio: 'inherit',
24
+ })
25
+ } catch {
26
+ process.exit(1)
27
+ }
28
+
29
+ if (command === 'install') {
30
+ console.log('moqi: profile installed — start it with `dsh --profile tui`')
31
+ process.exit(0)
32
+ }
33
+
34
+ // Hand the terminal over to dsh itself; the exit code is the app's.
35
+ try {
36
+ const dsh = process.env['DSH_BIN'] ?? 'dsh'
37
+ execFileSync(dsh, ['--profile', 'tui', ...args], { stdio: 'inherit' })
38
+ } catch (error) {
39
+ process.exitCode = typeof error?.status === 'number' ? error.status : 1
40
+ }
@@ -0,0 +1,41 @@
1
+ # The moqi bundle patch: an interactive terminal app over dsh-base.
2
+ #
3
+ # It mounts no Host, HTTP server, or browser plugin — the terminal is the only
4
+ # surface. A startup provider parses this app's own flags out of the shared
5
+ # cmdline snapshot, and the app row consumes them lazily the way the shipped
6
+ # headless bundle does.
7
+
8
+ - id: system-prompt
9
+ config:
10
+ personaSuffix: Your working directory is {{cwd}}.
11
+ personaPrefix: >-
12
+ You are a coding agent powered by the {{model}} model.
13
+
14
+ - id: tools
15
+ config:
16
+ mode: !!js process.env.DSH_TOOLS_MODE
17
+
18
+ - insert:
19
+ - id: tui-startup
20
+ name: 'moqi-tui/startup'
21
+
22
+ - id: tui-app
23
+ name: 'moqi-tui'
24
+ inject: [tuiStartup]
25
+ config:
26
+ resumeSessionId: !!js ctx.tuiStartup.resumeSessionId
27
+ model: !!js ctx.tuiStartup.model
28
+ thinking: !!js ctx.tuiStartup.thinking
29
+ contextLimit: !!js ctx.tuiStartup.contextLimit
30
+ mouse: !!js ctx.tuiStartup.mouse
31
+ bell: !!js ctx.tuiStartup.bell
32
+ peers: !!js ctx.tuiStartup.peers
33
+ restore: !!js ctx.tuiStartup.restore
34
+ voiceModel: !!js ctx.tuiStartup.voiceModel
35
+ voiceBin: !!js ctx.tuiStartup.voiceBin
36
+ dispatchProfile: !!js ctx.tuiStartup.dispatchProfile
37
+
38
+ # The terminal owns the screen; a hot reload repainting underneath it would
39
+ # corrupt the alternate buffer.
40
+ - id: hmr
41
+ disabled: true
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Cross-session search: `/find --sessions`.
3
+ *
4
+ * Stored sessions live under `$DSH_HOME/sessions/<projectKey>/<sessionId>/` as
5
+ * either a plain `.jsonl` log or a zstd-compressed one. This module walks them,
6
+ * reads whichever form it finds, and returns the matching lines with enough
7
+ * context to recognize the conversation — no index, no cache, and nothing
8
+ * written, so it can never corrupt a log another process is appending to.
9
+ *
10
+ * The zstd decoder is feature-detected: on a Node without it, compressed logs
11
+ * are skipped and reported as such rather than silently ignored.
12
+ * @module
13
+ */
14
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { zstdDecompressSync } from 'node:zlib';
17
+ export const DEFAULT_LIMITS = {
18
+ maxSessions: 400,
19
+ maxHits: 80,
20
+ maxBytes: 4_000_000,
21
+ };
22
+ /** Whether this build of Node can decompress a zstd log. */
23
+ export function zstdAvailable() {
24
+ return typeof zstdDecompressSync === 'function';
25
+ }
26
+ /** Extract printable message text from one JSONL record, or `''`. */
27
+ function textOfRecord(raw) {
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(raw);
31
+ }
32
+ catch {
33
+ return { text: '' };
34
+ }
35
+ const record = parsed;
36
+ const type = String(record.type ?? '');
37
+ const role = type === 'user/message' ? 'user' : type === 'assistant/message' ? 'assistant' : undefined;
38
+ if (role === undefined)
39
+ return { text: '' };
40
+ const blocks = record.data?.message?.content;
41
+ if (!Array.isArray(blocks))
42
+ return { text: '' };
43
+ const text = blocks
44
+ .filter((block) => {
45
+ const candidate = block;
46
+ return candidate.type === 'text' && typeof candidate.text === 'string';
47
+ })
48
+ .map((block) => block.text)
49
+ .join(' ');
50
+ return { text, ...(role === undefined ? {} : { role }) };
51
+ }
52
+ /**
53
+ * Parse a whole JSONL session body into its visible messages.
54
+ *
55
+ * Shared by cross-session search and the fleet preview, so both read a log the
56
+ * same way — including a foreign device's log, which may be an older format
57
+ * whose unknown records are simply skipped.
58
+ */
59
+ export function parseLogMessages(body) {
60
+ const messages = [];
61
+ for (const raw of body.split('\n')) {
62
+ if (!raw.includes('"'))
63
+ continue;
64
+ const { text, role } = textOfRecord(raw);
65
+ if (text === '' || role === undefined)
66
+ continue;
67
+ messages.push({ role, text });
68
+ }
69
+ return messages;
70
+ }
71
+ /** Whether a buffer carries the zstd frame magic. */
72
+ export function isZstdFrame(bytes) {
73
+ return bytes.length >= 4 && bytes[0] === 0x28 && bytes[1] === 0xb5 && bytes[2] === 0x2f && bytes[3] === 0xfd;
74
+ }
75
+ /** Decode raw log bytes, whichever form they arrived in. */
76
+ export function decodeLogBytes(bytes) {
77
+ if (!isZstdFrame(bytes))
78
+ return Buffer.from(bytes).toString('utf8');
79
+ if (!zstdAvailable())
80
+ return undefined;
81
+ try {
82
+ return zstdDecompressSync(bytes).toString('utf8');
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
88
+ /** Read one log file, decompressing when it is zstd, bounded by `maxBytes`. */
89
+ function readLog(path, maxBytes) {
90
+ try {
91
+ const raw = readFileSync(path);
92
+ if (path.endsWith('.zstd')) {
93
+ if (!zstdAvailable())
94
+ return undefined;
95
+ // A compressed frame cannot be decoded from a prefix, so the whole file
96
+ // is decoded — but a log beyond the byte ceiling is skipped rather than
97
+ // held in memory twice.
98
+ if (raw.byteLength > maxBytes)
99
+ return undefined;
100
+ try {
101
+ return zstdDecompressSync(raw).toString('utf8');
102
+ }
103
+ catch {
104
+ return undefined;
105
+ }
106
+ }
107
+ const slice = raw.byteLength > maxBytes ? raw.subarray(0, maxBytes) : raw;
108
+ return slice.toString('utf8');
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ /** Session directories under the store root, most recently modified first. */
115
+ function sessionDirs(root) {
116
+ const found = [];
117
+ let projects;
118
+ try {
119
+ projects = readdirSync(root);
120
+ }
121
+ catch {
122
+ return found;
123
+ }
124
+ for (const project of projects) {
125
+ const projectDir = join(root, project);
126
+ let entries;
127
+ try {
128
+ entries = readdirSync(projectDir);
129
+ }
130
+ catch {
131
+ continue;
132
+ }
133
+ for (const id of entries) {
134
+ if (!id.startsWith('session-'))
135
+ continue;
136
+ const dir = join(projectDir, id);
137
+ try {
138
+ if (!statSync(dir).isDirectory())
139
+ continue;
140
+ found.push({ project, dir, id, mtime: statSync(dir).mtimeMs });
141
+ }
142
+ catch {
143
+ continue;
144
+ }
145
+ }
146
+ }
147
+ found.sort((a, b) => b.mtime - a.mtime);
148
+ return found;
149
+ }
150
+ /** The log file inside a session directory, whichever format it is. */
151
+ function logFile(dir) {
152
+ let entries;
153
+ try {
154
+ entries = readdirSync(dir);
155
+ }
156
+ catch {
157
+ return undefined;
158
+ }
159
+ const zstd = entries.find((name) => name.endsWith('.jsonl.zstd'));
160
+ if (zstd !== undefined)
161
+ return join(dir, zstd);
162
+ const plain = entries.find((name) => name.endsWith('.jsonl'));
163
+ return plain === undefined ? undefined : join(dir, plain);
164
+ }
165
+ /**
166
+ * Search every stored session for a case-insensitive substring.
167
+ *
168
+ * @returns matching lines in session order (newest session first), capped by
169
+ * the limits. Skipped compressed logs on a Node without zstd mean fewer
170
+ * results, never wrong ones.
171
+ */
172
+ export function searchSessions(root, query, limits = DEFAULT_LIMITS) {
173
+ const needle = query.trim().toLowerCase();
174
+ if (needle === '')
175
+ return { hits: [], scanned: 0, skippedCompressed: 0 };
176
+ const hits = [];
177
+ let scanned = 0;
178
+ let skippedCompressed = 0;
179
+ for (const candidate of sessionDirs(root)) {
180
+ if (scanned >= limits.maxSessions || hits.length >= limits.maxHits)
181
+ break;
182
+ const path = logFile(candidate.dir);
183
+ if (path === undefined)
184
+ continue;
185
+ if (path.endsWith('.zstd') && !zstdAvailable()) {
186
+ skippedCompressed += 1;
187
+ continue;
188
+ }
189
+ const body = readLog(path, limits.maxBytes);
190
+ if (body === undefined)
191
+ continue;
192
+ scanned += 1;
193
+ for (const raw of body.split('\n')) {
194
+ if (hits.length >= limits.maxHits)
195
+ break;
196
+ if (!raw.includes('"'))
197
+ continue;
198
+ const { text, role } = textOfRecord(raw);
199
+ if (text === '')
200
+ continue;
201
+ const context = text.replace(/\s+/g, ' ').trim();
202
+ const index = context.toLowerCase().indexOf(needle);
203
+ if (index === -1)
204
+ continue;
205
+ const at = Math.max(index - 30, 0);
206
+ const snippet = context.slice(at, at + 140);
207
+ hits.push({
208
+ sessionId: candidate.id,
209
+ project: candidate.project.replace(/^--|--$/g, ''),
210
+ line: at > 0 ? `…${snippet}` : snippet,
211
+ ...(role === undefined ? {} : { role }),
212
+ path,
213
+ });
214
+ }
215
+ }
216
+ return { hits, scanned, skippedCompressed };
217
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * A bounded, cached walk of the workspace for `@` file completion.
3
+ *
4
+ * The listing is refreshable rather than watched: completion is an
5
+ * interactive nicety, so a few seconds of staleness after a checkout or a
6
+ * build costs nothing, while a filesystem watcher would cost a handle and a
7
+ * failure mode.
8
+ * @module
9
+ */
10
+ import { readdirSync, statSync } from 'node:fs';
11
+ import { join, relative, sep } from 'node:path';
12
+ import { SKIP_DIRECTORIES } from "./tui/atfile.js";
13
+ /** Hard ceiling on walked entries, so a giant monorepo cannot stall a keystroke. */
14
+ const MAX_ENTRIES = 4000;
15
+ /** How deep the walk descends. */
16
+ const MAX_DEPTH = 8;
17
+ /** Milliseconds a listing stays fresh. */
18
+ const FRESH_MS = 5000;
19
+ /** Files ending here are never offered; nothing good comes of picking one. */
20
+ const SKIP_SUFFIXES = ['.lock', '.min.js', '.map'];
21
+ /** One workspace's file list, recomputed lazily on demand. */
22
+ export class FileIndex {
23
+ root;
24
+ entries = [];
25
+ readAt = 0;
26
+ reading = false;
27
+ constructor(root) {
28
+ this.root = root;
29
+ }
30
+ /** Every workspace-relative file path, oldest acceptable snapshot or fresh. */
31
+ list() {
32
+ if (Date.now() - this.readAt > FRESH_MS && !this.reading)
33
+ this.refresh();
34
+ return this.entries;
35
+ }
36
+ /** Synchronously rebuild the listing. Errors leave the previous snapshot. */
37
+ refresh() {
38
+ if (this.reading)
39
+ return;
40
+ this.reading = true;
41
+ try {
42
+ const found = [];
43
+ this.walk(this.root, 0, found);
44
+ this.entries = found;
45
+ this.readAt = Date.now();
46
+ }
47
+ catch {
48
+ // An unreadable workspace keeps the last good listing (or empty).
49
+ }
50
+ finally {
51
+ this.reading = false;
52
+ }
53
+ }
54
+ walk(directory, depth, found) {
55
+ if (depth >= MAX_DEPTH || found.length >= MAX_ENTRIES)
56
+ return;
57
+ let names;
58
+ try {
59
+ names = readdirSync(directory);
60
+ }
61
+ catch {
62
+ return;
63
+ }
64
+ for (const name of names) {
65
+ if (found.length >= MAX_ENTRIES)
66
+ return;
67
+ if (name.startsWith('.') && name !== '.github')
68
+ continue;
69
+ const full = join(directory, name);
70
+ let stats;
71
+ try {
72
+ stats = statSync(full);
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ if (stats.isDirectory()) {
78
+ if (SKIP_DIRECTORIES.has(name))
79
+ continue;
80
+ this.walk(full, depth + 1, found);
81
+ }
82
+ else if (stats.isFile()) {
83
+ if (SKIP_SUFFIXES.some((suffix) => name.endsWith(suffix)))
84
+ continue;
85
+ const rel = relative(this.root, full);
86
+ found.push(sep === '/' ? rel : rel.replaceAll(sep, '/'));
87
+ }
88
+ }
89
+ }
90
+ /**
91
+ * List one directory for a path-shaped query, relative to the workspace
92
+ * root. `.` and `..` are offered alongside the entries so navigation works
93
+ * the way a shell expects.
94
+ */
95
+ listDir(relativePath) {
96
+ const base = relativePath === '' ? this.root : join(this.root, relativePath);
97
+ let names;
98
+ try {
99
+ names = readdirSync(base);
100
+ }
101
+ catch {
102
+ return { files: [] };
103
+ }
104
+ const prefix = relativePath === '' ? '' : `${relativePath.replaceAll(sep, '/')}/`;
105
+ const files = [];
106
+ for (const name of names.slice(0, MAX_ENTRIES)) {
107
+ if (name.startsWith('.'))
108
+ continue;
109
+ let stats;
110
+ try {
111
+ stats = statSync(join(base, name));
112
+ }
113
+ catch {
114
+ continue;
115
+ }
116
+ files.push({ path: `${prefix}${name}`, directory: stats.isDirectory() });
117
+ }
118
+ files.sort((a, b) => Number(b.directory) - Number(a.directory) || a.path.localeCompare(b.path));
119
+ return { files };
120
+ }
121
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Collecting presence records from this device and its peers.
3
+ *
4
+ * The transport is SSH, for one reason: it is the only channel in this setup
5
+ * that is already authenticated, already encrypted, and already working. The
6
+ * Harness's own web server offers no TLS, no authentication and no origin
7
+ * policy, and binding it off loopback would publish every route on the
8
+ * network — so the overview never does that.
9
+ *
10
+ * Every remote read is one short, non-interactive command. Nothing is
11
+ * installed on the peer beyond dsh itself, and nothing listens anywhere.
12
+ * @module
13
+ */
14
+ import { execFile } from 'node:child_process';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { readPresenceDir } from "./presence.js";
18
+ import { isPresenceRecord, } from "./tui/fleet.js";
19
+ /** How long a peer may take before it is reported unreachable. */
20
+ const SSH_TIMEOUT_MS = 6000;
21
+ /** Resolve this device's Harness home the way the Harness itself does. */
22
+ export function localDshHome(env = process.env) {
23
+ const configured = env['DSH_HOME'];
24
+ if (configured !== undefined && configured.trim() !== '')
25
+ return configured;
26
+ return join(homedir(), '.dsh');
27
+ }
28
+ /** Read this device's records. */
29
+ export function collectLocal(dshHome = localDshHome()) {
30
+ return {
31
+ host: 'local',
32
+ local: true,
33
+ records: readPresenceDir(join(dshHome, 'tui-presence')),
34
+ };
35
+ }
36
+ /**
37
+ * Read one peer's records over SSH.
38
+ *
39
+ * `BatchMode=yes` matters: a peer whose key is missing must fail fast with an
40
+ * error in the overview rather than blocking the UI on a password prompt.
41
+ */
42
+ export async function collectPeer(peer) {
43
+ const home = peer.dshHome ?? '$HOME/.dsh';
44
+ // cat of a glob that matches nothing would fail; guard it in the shell.
45
+ const remote = `d="${home}/tui-presence"; [ -d "$d" ] && cat "$d"/*.json 2>/dev/null || true`;
46
+ try {
47
+ const stdout = await ssh(peer.host, remote);
48
+ return { host: peer.host, local: false, records: parseRecords(stdout) };
49
+ }
50
+ catch (error) {
51
+ return {
52
+ host: peer.host,
53
+ local: false,
54
+ records: [],
55
+ error: describe(error),
56
+ };
57
+ }
58
+ }
59
+ /** Read every device in parallel; one slow peer must not hold up the rest. */
60
+ export async function collectFleet(peers, dshHome = localDshHome()) {
61
+ const remote = await Promise.all(peers.map((peer) => collectPeer(peer)));
62
+ return [collectLocal(dshHome), ...remote];
63
+ }
64
+ /** Parse concatenated JSON records, tolerating whatever else is in the stream. */
65
+ function parseRecords(stdout) {
66
+ const records = [];
67
+ // Records are written one JSON object per file with a trailing newline, so
68
+ // concatenation yields one object per line in practice; fall back to a
69
+ // brace scan if a peer wrote them pretty-printed.
70
+ for (const line of stdout.split('\n')) {
71
+ const trimmed = line.trim();
72
+ if (trimmed === '' || !trimmed.startsWith('{'))
73
+ continue;
74
+ try {
75
+ const parsed = JSON.parse(trimmed);
76
+ if (isPresenceRecord(parsed))
77
+ records.push(parsed);
78
+ }
79
+ catch {
80
+ // Ignore a partial line rather than losing the whole peer.
81
+ }
82
+ }
83
+ return records;
84
+ }
85
+ /** Run one non-interactive SSH command. */
86
+ function ssh(host, command) {
87
+ return new Promise((resolve, reject) => {
88
+ execFile('ssh', [
89
+ '-o',
90
+ 'BatchMode=yes',
91
+ '-o',
92
+ `ConnectTimeout=${String(Math.ceil(SSH_TIMEOUT_MS / 1000))}`,
93
+ host,
94
+ command,
95
+ ], { timeout: SSH_TIMEOUT_MS, encoding: 'utf8' }, (error, stdout, stderr) => {
96
+ if (error !== null) {
97
+ // ssh puts the useful line on stderr; node's own message is the whole
98
+ // command echoed back, which is noise in a one-line overview.
99
+ const detail = String(stderr)
100
+ .split('\n')
101
+ .map((line) => line.replace(/^ssh: /, '').trim())
102
+ .find((line) => line !== '');
103
+ const timedOut = error.code === 'ETIMEDOUT';
104
+ reject(new Error(detail ?? (timedOut ? 'timed out' : 'unreachable')));
105
+ return;
106
+ }
107
+ resolve(String(stdout));
108
+ });
109
+ });
110
+ }
111
+ /** A one-line, human-readable form of anything thrown. */
112
+ function describe(error) {
113
+ return error instanceof Error ? error.message : String(error);
114
+ }