zerogterm 0.9.4-alpha.1 → 1.0.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ZeroG Terminal is an Electron workspace manager for persistent terminal and SSH sessions on Linux and Windows, with smart features to help keep you productive. It is designed with AI tooling in mind: leave Codex, Claude Code, or other terminal-based agents running in persistent sessions, then return to them without repeating setup and resume commands.
4
4
 
5
- ZeroG Terminal is an alpha project, but it is already useful as a multi-session terminal and SSH workspace. It uses `screen` where available for resumable sessions and falls back to a direct shell PTY when `screen` is not installed.
5
+ ZeroG Terminal is a multi-session terminal and SSH workspace. It uses `screen` where available for resumable sessions and falls back to a direct shell PTY when `screen` is not installed.
6
6
 
7
7
  ## Features
8
8
 
@@ -15,7 +15,8 @@ ZeroG Terminal is an alpha project, but it is already useful as a multi-session
15
15
  - SSH sessions for hosts, `user@host`, and `user@host:port` targets.
16
16
  - SSH configuration discovery from known connections, including remote `screen` session discovery. In the sidebar's Connections tab, clicking a saved connection opens the connect dialog with the host filled in, and double-clicking it skips the dialog and opens the host in a new pane.
17
17
  - Reconnect to existing local or remote `screen` sessions from the Screens view.
18
- - Remote screen attachment that waits for SSH readiness before sending commands, including host and port-aware matching.
18
+ - Remote screen discovery for the selected SSH destination, including manually entered hosts, with refresh and visible failure states. Background discovery uses key/agent authentication; password-only hosts cannot be queried through the terminal connection.
19
+ - Remote screen attachment launched directly through SSH, preserving the account, custom port and SSH config alias, with authentication and host-key prompts left to the user.
19
20
  - Shared ports over SSH, in a Ports view opened from the rail: forward a port on a remote host so it answers on this machine, or a port here so it answers on the remote. Each tunnel is its own SSH connection, so a host needs no terminal open first. Ports bind to loopback unless you widen them, and are remembered between launches.
20
21
  - A directory browser inside a pane, opened from the folder button in its title bar: the pane splits, terminal on one side and the directories on the other, with a draggable divider. Double-click a folder to take the shell there. It works on SSH panes and on local ones, WSL included, follows a `cd` you type by hand, and is remembered per pane when you switch workspaces or relaunch.
21
22
  - An SFTP transfer panel, opened from the ⇅ button above the panes: local files on the left, the active SSH session's host on the right, with upload, download, new folder, rename, and delete. It connects to the host that session is already using and opens at the directory its shell is standing in, so a file can go straight to the project being worked on.
@@ -38,9 +39,9 @@ See the project walkthrough on [YouTube](https://youtu.be/4aJZCxLHD14).
38
39
 
39
40
  ## Release status
40
41
 
41
- ZeroG Terminal is currently a public alpha. The current release is `0.9.4-alpha.1`; the version history is tracked in [versions.txt](versions.txt). The running version is shown beside the wordmark in the title bar, read from the app itself rather than written into the interface, so it is accurate in a packaged build too.
42
+ ZeroG Terminal 1.0.0 is the first stable release. The current release is `1.0.0`; the version history is tracked in [versions.txt](versions.txt). The running version is shown beside the wordmark in the title bar, read from the app itself rather than written into the interface, so it is accurate in a packaged build too.
42
43
 
43
- The GitHub Releases page provides a Windows x64 installer and portable executable plus a Linux x64 AppImage and Debian package for each desktop release. These alpha builds are intended for early adopters and testing rather than production use. The npm package remains available for developers who prefer to launch ZeroG from Node.js.
44
+ The GitHub Releases page provides a Windows x64 installer and portable executable plus a Linux x64 AppImage and Debian package for each desktop release. The npm package remains available for developers who prefer to launch ZeroG from Node.js.
44
45
 
45
46
  ## Terminal shortcuts
46
47
 
@@ -386,13 +387,13 @@ how the threshold gets tuned for a particular microphone.
386
387
  The simplest way to try the published package is through `npx`:
387
388
 
388
389
  ```bash
389
- npx zerogterm
390
+ npx --package=electron@43 --package=zerogterm zerogterm
390
391
  ```
391
392
 
392
- The package downloads the application and launches it. To use the launcher repeatedly without downloading on each invocation, install it globally:
393
+ The npm launcher needs Electron alongside ZeroG Terminal; the command above installs both into the temporary npx environment. To use the launcher repeatedly without downloading on each invocation, install it globally:
393
394
 
394
395
  ```bash
395
- npm install --global zerogterm
396
+ npm install --global electron@43 zerogterm
396
397
  zerogterm
397
398
  ```
398
399
 
@@ -405,14 +406,14 @@ project workspaces, open SSH sessions, and request commands for your approval.
405
406
  1. Start ZeroG Terminal with MCP enabled:
406
407
 
407
408
  ```bash
408
- ZEROG_MCP_ENABLED=1 npx zerogterm
409
+ ZEROG_MCP_ENABLED=1 npx --package=electron@43 --package=zerogterm zerogterm
409
410
  ```
410
411
 
411
412
  On Windows PowerShell, use:
412
413
 
413
414
  ```powershell
414
415
  $env:ZEROG_MCP_ENABLED = "1"
415
- npx zerogterm
416
+ npx --package=electron@43 --package=zerogterm zerogterm
416
417
  ```
417
418
 
418
419
  2. Open **Settings → AI & voice → MCP** in ZeroG Terminal. Copy the displayed
@@ -469,7 +470,7 @@ general one — there, `npm install` compiles it.
469
470
 
470
471
  ### Desktop releases
471
472
 
472
- Download the latest release for your platform from [GitHub Releases](https://github.com/HappyMonkeyAI/ZeroGTerm/releases). These alpha builds are intended for early adopters and testing rather than production use. Review the release notes before installing; SSH, WSL, PowerShell, Git Bash, and any remote `screen` sessions are provided by the host system rather than bundled with the app.
473
+ Download the latest release for your platform from [GitHub Releases](https://github.com/HappyMonkeyAI/ZeroGTerm/releases). Review the release notes before installing; SSH, WSL, PowerShell, Git Bash, and any remote `screen` sessions are provided by the host system rather than bundled with the app.
473
474
 
474
475
  #### Windows
475
476
 
@@ -502,14 +503,37 @@ picker), and Git Bash where Git for Windows is installed.
502
503
  do not survive app exit. Remote SSH sessions are unaffected by the local shell
503
504
  backend, and a remote host with `screen` still gives persistent sessions there.
504
505
 
506
+ The pinned `node-pty` dependency receives a small ConPTY teardown patch during
507
+ install and build (`scripts/patch-node-pty.mjs`). Review that patch before changing
508
+ its version. Run `node scripts/pty-shutdown-smoke.cjs` on Windows to check forced
509
+ and graceful shutdown with a real child process. Release evaluation evidence and
510
+ remaining sign-off items are in [the release check report](docs/release-readiness-screen-check.md).
511
+
512
+ Windows source packaging rebuilds native dependencies. Install Visual Studio 2022
513
+ Build Tools with the C++ desktop workload by importing [`.vsconfig`](.vsconfig),
514
+ then run `powershell -ExecutionPolicy Bypass -File scripts/ensure-windows-build-tools.ps1`.
515
+ The helper checks the selected MSVC toolset and installs its missing Spectre libraries
516
+ through Visual Studio Installer if needed; Windows may request administrator approval.
517
+ Then run `npm run package:win`. The normal native rebuild and installed application
518
+ have been verified; compiler protections remain enabled.
519
+
520
+ Linux packaging uses `/opt/ZeroGTerminal` to avoid Electron's sandbox launcher issue
521
+ with spaces in installation paths; the application menu still says **ZeroG Terminal**.
522
+ The Debian package declares its audio and graphics runtime libraries explicitly.
523
+
524
+ The Electron downloader has a scoped `global-agent` override to remove the vulnerable
525
+ `sprintf-js` dependency chain. `node scripts/check-downloader-proxy.mjs` verifies proxy
526
+ routing and `NO_PROXY` bypass using local fixtures. CI runs this check and the full audit.
527
+
528
+
505
529
  ## Verification
506
530
 
507
531
  The current main branch has the following local verification coverage:
508
532
 
509
533
  - `npm run typecheck`: passes.
510
- - `npm test`: passes (251 tests covering session service behaviour and PTY sizing, shell discovery, SSH inventory and argument validation, remote-screen parsing and prompt readiness, session history, the session dialog, settings, terminal clipboard and OSC 52 handling, dialog dismissal, the speech and voice helpers, external link handling, and the SFTP transfer path — command quoting, listing and error parsing, authentication prompts, local filesystem operations, and working-directory detection; one further test needs a real `screen` and is opt-in through `ZEROG_LIVE_SCREEN=1`).
534
+ - `npm test`: 64 test files; 1,169 passed and 3 platform skips on Windows, all 1,172 passed on Linux with `ZEROG_LIVE_SCREEN=1`.
511
535
  - `npm run build`: passes and writes `dist/main` plus `dist/renderer`.
512
- - `npm audit --omit=dev`: production dependency auditing is part of the project quality checks.
536
+ - `npm audit --audit-level=moderate`: zero advisories in the verified dependency tree.
513
537
 
514
538
  ## License
515
539
 
package/bin/zerogterm.cjs CHANGED
@@ -2,7 +2,13 @@
2
2
 
3
3
  const { spawn } = require('node:child_process');
4
4
  const path = require('node:path');
5
- const electron = require('electron');
5
+ let electron;
6
+ try {
7
+ electron = require('electron');
8
+ } catch {
9
+ console.error('Electron runtime was not found. Install both packages: npm install --global electron@43 zerogterm');
10
+ process.exit(1);
11
+ }
6
12
 
7
13
  const packageRoot = path.resolve(__dirname, '..');
8
14
  const child = spawn(electron, [packageRoot, ...process.argv.slice(2)], {
@@ -1,9 +1,10 @@
1
+ import { shutdownPtys } from './pty-lifecycle.js';
1
2
  import { app, BrowserWindow, clipboard, ipcMain, Menu, safeStorage, session, shell } from 'electron';
2
3
  import { join } from 'node:path';
3
4
  import { randomUUID } from 'node:crypto';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { writeClipboardText } from './clipboard.js';
6
- import { ScreenService, parseWslDistributions } from './session-service.js';
7
+ import { ScreenService, sshExecutable, parseWslDistributions } from './session-service.js';
7
8
  import { discoverShellBackends } from './shell-catalog.js';
8
9
  import { SessionHistoryStore, defaultHistoryPath } from './session-history.js';
9
10
  import { CommandHistoryStore, defaultCommandHistoryPath } from './command-history-store.js';
@@ -517,7 +518,9 @@ ipcMain.handle('sessions:createSsh', async (_event, request) => {
517
518
  throw new Error('createSshSession requires { target: string }');
518
519
  }
519
520
  const name = typeof request.name === 'string' ? request.name : undefined;
520
- return service.createSsh(request.target, name);
521
+ if (request.screenName !== undefined && typeof request.screenName !== 'string')
522
+ throw new Error('Invalid screen name');
523
+ return service.createSsh(request.target, name, request.screenName);
521
524
  });
522
525
  ipcMain.handle('connections:listKnown', () => listKnownConnections());
523
526
  ipcMain.handle('screens:discoverRemote', async (_event, input) => {
@@ -526,11 +529,15 @@ ipcMain.handle('screens:discoverRemote', async (_event, input) => {
526
529
  const { execFile } = await import('node:child_process');
527
530
  const { promisify } = await import('node:util');
528
531
  try {
529
- const { stdout } = await promisify(execFile)(command.file, command.args);
532
+ const { stdout } = await promisify(execFile)(sshExecutable(), command.args, { timeout: 15000, maxBuffer: 256 * 1024 });
530
533
  return parseRemoteScreenList(stdout, connection.alias);
531
534
  }
532
535
  catch (error) {
533
- return { status: 'unavailable', host: connection.alias, reason: error?.code === 'ENOENT' ? 'ssh-unavailable' : 'host-unreachable', sessions: [] };
536
+ // screen exits with 1 when there are no sockets. Other failures must reach
537
+ // the UI rather than pretending that the host has no running sessions.
538
+ if (error?.code === 1 && /No Sockets found/i.test(error.stdout ?? ''))
539
+ return [];
540
+ throw new Error('Screen discovery failed. Check SSH key/agent access and that screen is installed on the host.');
534
541
  }
535
542
  });
536
543
  ipcMain.handle('screens:attachRemote', (_event, input, screenName) => {
@@ -783,8 +790,27 @@ app.whenReady().then(() => {
783
790
  createWindow();
784
791
  });
785
792
  });
786
- app.on('before-quit', () => {
787
- void agentHooks.stop();
793
+ let shutdownStarted = false;
794
+ let shutdownComplete = false;
795
+ app.on('before-quit', (event) => {
796
+ if (shutdownComplete)
797
+ return;
798
+ event.preventDefault();
799
+ if (shutdownStarted)
800
+ return;
801
+ shutdownStarted = true;
802
+ service.detachAll();
803
+ sftp.closeAll();
804
+ forwards.closeAll();
805
+ void Promise.allSettled([shutdownPtys(), agentHooks.stop()]).then((results) => {
806
+ for (const result of results) {
807
+ if (result.status === 'rejected')
808
+ console.error('[zerog] shutdown failed', result.reason);
809
+ }
810
+ }).finally(() => {
811
+ shutdownComplete = true;
812
+ app.quit();
813
+ });
788
814
  });
789
815
  app.on('window-all-closed', () => {
790
816
  if (process.platform !== 'darwin')
@@ -1,11 +1,8 @@
1
+ import { spawnManagedPty } from './pty-lifecycle.js';
1
2
  import { randomUUID } from 'node:crypto';
2
- import { createRequire } from 'node:module';
3
3
  import { homedir } from 'node:os';
4
4
  import { FORWARD_SETTLE_MS, buildForwardArgs, describeForward, readForwardOutcome } from './port-forward-protocol.js';
5
5
  import { detectPrompt } from './sftp-protocol.js';
6
- // node-pty is a native addon with no ESM entry point, and this file is compiled
7
- // as a module. Same shim as session-service.ts and sftp-service.ts.
8
- const require = createRequire(import.meta.url);
9
6
  /** A ceiling on live tunnels, so a stuck panel cannot spawn clients forever. */
10
7
  const MAX_FORWARDS = 16;
11
8
  /** Nothing at all from the client within this long means the host never answered. */
@@ -33,7 +30,7 @@ export class PortForwardService {
33
30
  onEvent;
34
31
  settleMs;
35
32
  constructor(options = {}) {
36
- this.spawnPty = options.spawnPty ?? ((file, args, ptyOptions) => require('node-pty').spawn(file, args, ptyOptions));
33
+ this.spawnPty = options.spawnPty ?? spawnManagedPty;
37
34
  this.onEvent = options.onEvent ?? (() => undefined);
38
35
  this.settleMs = options.settleMs ?? FORWARD_SETTLE_MS;
39
36
  }
@@ -0,0 +1,47 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+ /** All three PTY owners must finish native teardown before Electron exits. */
4
+ export class PtyLifecycle {
5
+ spawn;
6
+ closing = false;
7
+ processes = new Map();
8
+ constructor(spawn) {
9
+ this.spawn = spawn;
10
+ }
11
+ start = (file, args, options) => {
12
+ if (this.closing)
13
+ throw new Error('The application is shutting down.');
14
+ const process = this.spawn(file, args, options);
15
+ let exited;
16
+ this.processes.set(process, new Promise((resolve) => { exited = resolve; }));
17
+ process.onExit(() => { this.processes.delete(process); exited(); });
18
+ return process;
19
+ };
20
+ async shutdown(timeoutMs = 6000) {
21
+ this.closing = true;
22
+ const waiting = [...this.processes.values()];
23
+ for (const process of this.processes.keys()) {
24
+ try {
25
+ process.kill();
26
+ }
27
+ catch { /* exit callback may already be queued */ }
28
+ }
29
+ if (!waiting.length)
30
+ return;
31
+ let timer;
32
+ try {
33
+ await Promise.race([
34
+ Promise.all(waiting),
35
+ new Promise((_, reject) => {
36
+ timer = setTimeout(() => reject(new Error('PTY shutdown timed out.')), timeoutMs);
37
+ })
38
+ ]);
39
+ }
40
+ finally {
41
+ clearTimeout(timer);
42
+ }
43
+ }
44
+ }
45
+ const lifecycle = new PtyLifecycle((file, args, options) => require('node-pty').spawn(file, args, options));
46
+ export const spawnManagedPty = lifecycle.start;
47
+ export const shutdownPtys = () => lifecycle.shutdown();
@@ -1,11 +1,10 @@
1
+ import { spawnManagedPty } from './pty-lifecycle.js';
1
2
  import { execFile } from 'node:child_process';
2
3
  import { randomUUID } from 'node:crypto';
3
- import { createRequire } from 'node:module';
4
4
  import { homedir } from 'node:os';
5
5
  import { promisify } from 'node:util';
6
6
  import { WSL_HOME, defaultShellBackend, findOpenSshTool, isLocalShellBackend, ptyStartDirectory, resolveShellBackend, withWslStartDirectory } from './shell-catalog.js';
7
7
  const execFileAsync = promisify(execFile);
8
- const require = createRequire(import.meta.url);
9
8
  const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
10
9
  /**
11
10
  * host, user@host, host:port, user@host:port — no shell metacharacters.
@@ -125,14 +124,6 @@ export function parseScreenList(output) {
125
124
  ];
126
125
  });
127
126
  }
128
- function loadPty() {
129
- try {
130
- return require('node-pty');
131
- }
132
- catch {
133
- throw new Error('PTY support is not installed. Run: npm install node-pty (needs make/gcc-c++).');
134
- }
135
- }
136
127
  export class ScreenService {
137
128
  ptys = new Map();
138
129
  sshSessions = new Map();
@@ -150,7 +141,7 @@ export class ScreenService {
150
141
  spawnPty;
151
142
  constructor(options = {}) {
152
143
  this.onEvent = options.onEvent;
153
- this.spawnPty = options.spawnPty ?? ((file, args, ptyOptions) => loadPty().spawn(file, args, ptyOptions));
144
+ this.spawnPty = options.spawnPty ?? spawnManagedPty;
154
145
  }
155
146
  async available() {
156
147
  try {
@@ -221,8 +212,10 @@ export class ScreenService {
221
212
  this.onEvent?.('created', session, true);
222
213
  return session;
223
214
  }
224
- async createSsh(targetInput, name) {
215
+ async createSsh(targetInput, name, screenName) {
225
216
  const { target } = validateSshTarget(targetInput);
217
+ if (screenName !== undefined)
218
+ validateSessionName(screenName);
226
219
  const safeName = validateSessionName(name?.trim() || target.replace(/[^a-zA-Z0-9_.-]+/g, '-').slice(0, 40));
227
220
  const session = {
228
221
  id: `ssh:${randomUUID()}`,
@@ -233,7 +226,8 @@ export class ScreenService {
233
226
  status: 'detached',
234
227
  lastSeen: new Date().toISOString(),
235
228
  sshTarget: target,
236
- backend: 'ssh',
229
+ backend: screenName ? 'screen' : 'ssh',
230
+ ...(screenName ? { screenName, persistence: 'screen' } : {}),
237
231
  scope: 'remote',
238
232
  source: 'active'
239
233
  };
@@ -285,6 +279,8 @@ export class ScreenService {
285
279
  throw new Error(`Unknown session: ${id}`);
286
280
  }
287
281
  const { args } = validateSshTarget(session.sshTarget);
282
+ if (session.screenName)
283
+ args.push('screen', '-x', validateSessionName(session.screenName));
288
284
  session.status = 'connected';
289
285
  session.lastSeen = new Date().toISOString();
290
286
  try {
@@ -327,7 +323,9 @@ export class ScreenService {
327
323
  cols,
328
324
  rows,
329
325
  cwd,
330
- env: process.env
326
+ // ConPTY does not set TERM from node-pty's name option. OpenSSH needs
327
+ // it to request a remote terminal that screen can recognise.
328
+ env: { ...process.env, TERM: 'xterm-256color' }
331
329
  });
332
330
  const handle = {
333
331
  write: (data) => proc.write(data),
@@ -341,9 +339,13 @@ export class ScreenService {
341
339
  }
342
340
  }
343
341
  };
344
- this.ptys.set(sessionId, { handle, kind: sessionId.startsWith('ssh:') ? 'ssh' : 'local', cols, rows });
342
+ const record = { handle, kind: sessionId.startsWith('ssh:') ? 'ssh' : 'local', cols, rows };
343
+ this.ptys.set(sessionId, record);
345
344
  proc.onData(onData);
346
345
  proc.onExit(() => {
346
+ // A detached PTY can exit after a new attachment already owns this id.
347
+ if (this.ptys.get(sessionId) !== record)
348
+ return;
347
349
  this.ptys.delete(sessionId);
348
350
  const fallback = this.fallbackLocalSessions.get(sessionId);
349
351
  if (fallback) {
@@ -1,3 +1,4 @@
1
+ import { spawnManagedPty } from './pty-lifecycle.js';
1
2
  // The stateful half of SFTP support: one live `sftp` client per remote host,
2
3
  // with commands serialised onto it and its answers turned into data by
3
4
  // sftp-protocol.ts.
@@ -10,9 +11,7 @@
10
11
  import { randomUUID } from 'node:crypto';
11
12
  import { homedir } from 'node:os';
12
13
  import { baseName, joinRemote, sortEntries } from '../shared/files.js';
13
- import { createRequire } from 'node:module';
14
14
  import { buildSftpArgs, detectFatal, detectPrompt, endsWithPrompt, findError, isSafeRemotePath, parseListing, parseProgress, lastClientMessage, parsePwd, quoteLocalPath, quoteRemotePath, responseBody } from './sftp-protocol.js';
15
- const require = createRequire(import.meta.url);
16
15
  /**
17
16
  * Wide enough that a long `ls -l` record is never wrapped by the pty, which
18
17
  * would split one file's row across two lines and lose its name. Rows are
@@ -72,7 +71,7 @@ export class SftpService {
72
71
  spawnPty;
73
72
  onEvent;
74
73
  constructor(options = {}) {
75
- this.spawnPty = options.spawnPty ?? ((file, args, ptyOptions) => require('node-pty').spawn(file, args, ptyOptions));
74
+ this.spawnPty = options.spawnPty ?? spawnManagedPty;
76
75
  this.onEvent = options.onEvent ?? (() => undefined);
77
76
  }
78
77
  /**
@@ -102,26 +102,28 @@ export function validateKnownConnection(input) {
102
102
  }
103
103
  function sshArgs(connection) {
104
104
  const c = validateKnownConnection(connection);
105
- const destination = c.user ? `${c.user}@${c.hostName ?? c.alias}` : (c.hostName ?? c.alias);
105
+ // Keep config aliases intact so IdentityFile, ProxyJump and Match still apply.
106
+ const host = c.source ? c.alias : (c.hostName ?? c.alias);
107
+ const destination = c.user ? `${c.user}@${host}` : host;
106
108
  const args = [];
107
109
  if (c.port)
108
110
  args.push('-p', String(c.port));
109
- args.push(destination, '--');
111
+ args.push('--', destination);
110
112
  return args;
111
113
  }
112
114
  export function buildRemoteScreenDiscoveryArgs(connection) {
113
- return { file: 'ssh', args: [...sshArgs(connection), 'screen', '-ls'] };
115
+ return { file: 'ssh', args: ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', ...sshArgs(connection), 'screen', '-ls'] };
114
116
  }
115
117
  export function buildRemoteScreenAttachArgs(connection, screenName) {
116
118
  if (!SCREEN.test(screenName))
117
119
  throw new Error('Invalid screen session name.');
118
- return { file: 'ssh', args: [...sshArgs(connection), 'screen', '-x', screenName] };
120
+ return { file: 'ssh', args: ['-tt', ...sshArgs(connection), 'screen', '-x', screenName] };
119
121
  }
120
122
  export function parseRemoteScreenList(output, host) {
121
123
  if (!TOKEN.test(host))
122
124
  throw new Error('Invalid remote host identity.');
123
125
  return output.split(/\r?\n/).flatMap((line) => {
124
- const match = line.trim().match(/^\d+\.(\S+)\s+\(([^)]+)\)/);
126
+ const match = line.trim().match(/^\d+\.(\S+)\s+(?:\([^)]*\)\s+)*\((Attached|Detached)\)\s*$/i);
125
127
  if (!match || !SCREEN.test(match[1]))
126
128
  return [];
127
129
  const name = match[1];