zback 1.3.0 → 1.4.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/DISCLOSURE CHANGED
@@ -2,7 +2,7 @@ ZBack is an opt-in remote shell administration tool for devices owned or explici
2
2
 
3
3
  Security-sensitive functionality:
4
4
  - A user manually starts the ZBack command and approves remote shell administration on the first run. The decision is stored in a local user-scoped file; subsequent commands do not require repeated prompts.
5
- - A background Node.js worker may persist after the invoking terminal detaches. It can be stopped with `zback --stop`.
5
+ - The agent runs in the foreground by default and stops on Ctrl+C. An explicitly requested background worker may persist after the terminal detaches and can be stopped with `zback --stop`.
6
6
  - Remote shell commands execute with the operating-system permissions of the account that launched the agent.
7
7
  - The worker connects outbound to the configured secure WebSocket endpoint. It does not open an inbound shell listener.
8
8
  - The server exposes an MCP endpoint that routes authorized commands to connected workers when the caller presents the private session capability.
package/README.md CHANGED
@@ -1,17 +1,19 @@
1
1
  # ZBack CLI
2
2
 
3
- ZBack is a deliberately opt-in remote administration agent for systems you own or are authorized to manage. The package uses Node.js 22 or later, has **no third-party runtime dependencies** and runs no installation scripts.
3
+ ZBack is an opt-in remote administration agent for systems you own or are authorized to manage. The package uses Node.js 22 or later, has **no third-party runtime dependencies** and runs no installation scripts.
4
4
 
5
5
  > **Security:** ZBack grants remote shell access with your local user privileges to anyone who possesses its private session ID. Treat that ID as a password. Do not publish it, paste it in issue reports, or share screenshots containing it. Do not run ZBack as root or as an account with unnecessary permissions.
6
6
 
7
7
  ## Install and start
8
8
 
9
9
  ```sh
10
- npm install -g zback@1.2.0
10
+ npm install -g zback@1.2.2
11
11
  zback
12
12
  ```
13
13
 
14
- On the first run, ZBack explains the remote-shell capability and asks for authorization once. It stores that decision locally, then creates a private agent credential. There are no prompts for each command.
14
+ On the first run, ZBack presents a [Y]/[N] shell authorization question once. It stores that decision locally, then creates a private agent credential. There are no prompts for each command.
15
+
16
+ Terminal status markers are colored on supported interactive terminals: yellow `[+]` for progress, red `[×]` for errors and gray `>` for contextual hints. Plain text is used when output is redirected. The command history updates as commands complete and shows each command's exit code and cumulative successes/errors. Connection problems are summarized once per incident while reconnect attempts continue automatically.
15
17
 
16
18
  The default server is `wss://zb.soymaycol.icu/ws`. To use your own backend:
17
19
 
@@ -25,21 +27,21 @@ For development only:
25
27
  zback --url ws://127.0.0.1:8787/ws
26
28
  ```
27
29
 
28
- The client and backend must both be **version 1.2.0** because the connection handshake changed. The backend must be deployed before a remote client can connect.
30
+ The client requires **backend 1.2.0 or later**. The client rejects older servers rather than accepting unstable legacy session IDs. Deploy the updated backend before connecting a remote client.
29
31
 
30
32
  ## Terminal and process lifecycle
31
33
 
32
34
  | Command | Behavior |
33
35
  | --- | --- |
34
- | `zback` | Start if needed and attach the terminal to a live event display |
35
- | `zback --background` | Start the agent without keeping a live terminal display |
36
+ | `zback` | Keep the agent in the foreground; display the session ID, MCP endpoint, colored connection notices, and command execution history |
37
+ | `zback --background` | Explicitly start a detached agent |
36
38
  | `zback --status` | Show connection status, process ID and private session ID |
37
39
  | `zback --logs` | Display recent connection and command activity |
38
- | `zback --follow` | Attach to live events from the existing background agent |
40
+ | `zback --follow` | Watch new events from an existing background agent without replaying old logs |
39
41
  | `zback --stop` | Stop the background agent |
40
42
  | `zback --reset-key` | Change the device identity after the agent is stopped |
41
43
 
42
- `Ctrl+C` while viewing events **detaches the display**, but the agent stays running. Use `zback --stop` to disconnect. The server session ID stays the same over ordinary network reconnections and backend restarts, as long as the private local agent credential remains unchanged.
44
+ `Ctrl+C` in the default foreground mode **stops the agent**. In background mode, `Ctrl+C` detaches the log display only. Use `zback --stop` to disconnect a background agent. Before upgrading from version 1.1.x or an earlier running version, stop its existing background worker with `zback --stop`. The server session ID stays the same over ordinary network reconnections and backend restarts, as long as the private local agent credential remains unchanged.
43
45
 
44
46
  After a device restart, ZBack does **not** silently install an autostart service. Launch it again when needed. Android/Termux process and battery restrictions can still terminate background tasks; a Node.js program cannot override the operating system's lifecycle.
45
47
 
@@ -56,7 +58,7 @@ Files are under `~/.local/state/zback` on Linux/Termux and `%LOCALAPPDATA%/zback
56
58
 
57
59
  ## MCP
58
60
 
59
- The companion backend serves `POST /mcp` with `zback_status` and `zback_execute`. The client prints a **private** session ID for passing to these tools. Only grant your own session ID to trusted applications. MCP clients, LLMs and tools that receive the ID can perform any command your operating-system account can perform.
61
+ The companion backend serves `POST /mcp` with `zback_status` and `zback_execute`. On connection, the client displays the MCP endpoint and a **private** session ID, which is supplied as the `id` argument to either tool. Only grant your own session ID to trusted applications. MCP clients, LLMs and tools that receive the ID can perform any command your operating-system account can perform.
60
62
 
61
63
  ## Security disclosure and review
62
64
 
package/bin/zback.js CHANGED
@@ -6,18 +6,19 @@ import { createInterface } from 'node:readline/promises';
6
6
  import { stdin, stdout } from 'node:process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { connectAgent, execute } from '../src/agent.js';
9
+ import { createTerminal, createEventReporter } from '../src/terminal.js';
9
10
  import { activeSession, agentKey, consentFile, hasConsent, initStateDir, logFile, readJson, resetKey, sameSession, saveJson, settingsFile, stateFile, alive } from '../src/runtime.js';
10
11
 
11
12
  const ENTRY = fileURLToPath(import.meta.url);
12
- const VERSION = '1.2.0';
13
+ const VERSION = '1.2.2';
13
14
  const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
14
15
 
15
16
  function help() {
16
17
  console.log(`ZBack ${VERSION} | Authorized remote shell administration
17
18
 
18
19
  Usage:
19
- zback Start the agent and display live events
20
- zback --background Start without attaching the terminal
20
+ zback Start in the foreground and display live events
21
+ zback --background Run as a background agent
21
22
  zback --status Show agent status, PID and private session ID
22
23
  zback --logs Show recent agent events
23
24
  zback --follow Follow live events from a running agent
@@ -34,8 +35,8 @@ Options:
34
35
 
35
36
  ZBack runs shell commands with your account privileges. Initial interactive
36
37
  consent is required once, not for every command. Keep the session ID private.
37
- Ctrl+C detaches the live display; the agent continues running.
38
- Use --stop to actually disconnect the agent.`);
38
+ Ctrl+C stops a foreground agent. Background mode must be requested explicitly.
39
+ Use --stop to disconnect a background agent.`);
39
40
  }
40
41
 
41
42
  export function parseArgs(args) {
@@ -70,17 +71,17 @@ export function parseArgs(args) {
70
71
  return { ...options, action };
71
72
  }
72
73
 
73
- async function confirmOnce() {
74
+ async function confirmOnce(terminal) {
74
75
  if (hasConsent()) return true;
75
76
  if (!stdin.isTTY || !stdout.isTTY) throw new Error('First-run shell authorization requires an interactive terminal');
76
- console.log('ZBack permits remote shell commands through a private session credential.');
77
- console.log('These commands run as your current user. Do not share your session ID.');
77
+ terminal.question();
78
78
  const rl = createInterface({ input: stdin, output: stdout });
79
79
  try {
80
- const answer = await rl.question('Authorize remote shell administration on this device? [y/N] ');
81
- if (!['y', 'yes'].includes(answer.trim().toLowerCase())) return false;
82
- saveJson(consentFile, { approved: true, at: new Date().toISOString() });
83
- return true;
80
+ const answer = await rl.question('Your choice [y/N]: ');
81
+ const approved = ['y', 'yes'].includes(answer.trim().toLowerCase());
82
+ if (approved) saveJson(consentFile, { approved: true, at: new Date().toISOString() });
83
+ terminal.consent(approved);
84
+ return approved;
84
85
  } finally { rl.close(); }
85
86
  }
86
87
 
@@ -91,20 +92,44 @@ async function worker() {
91
92
  const started = process.env.ZBACK_WORKER_NONCE;
92
93
  if (!started) throw new Error('Worker must be launched through zback');
93
94
  const key = agentKey();
95
+ const terminal = createTerminal();
96
+ const reporter = createEventReporter(terminal);
97
+ let lastId = null;
94
98
  const mark = data => {
95
99
  const previous = readJson(stateFile);
96
100
  if (previous && previous.status !== 'stopped' && previous.pid !== process.pid && alive(previous.pid)) return;
97
- saveJson(stateFile, { pid: process.pid, started, updated: new Date().toISOString(), ...data });
101
+ saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
102
+ if (data.status === 'connected') {
103
+ reporter.clearProblem();
104
+ if (!lastId) terminal.connected(data.id, mcpEndpoint(settings.url));
105
+ else terminal.restored();
106
+ lastId = data.id;
107
+ }
98
108
  };
99
109
  mark({ status: 'connecting', id: null });
100
- try { await connectAgent(settings, mark, key); }
101
- finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id: null }); }
110
+ try { await connectAgent(settings, mark, key, reporter.event); }
111
+ finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id: lastId }); }
112
+ }
113
+
114
+ function mcpEndpoint(url) {
115
+ const endpoint = new URL(url);
116
+ endpoint.protocol = endpoint.protocol === 'wss:' ? 'https:' : 'http:';
117
+ endpoint.pathname = '/mcp';
118
+ return endpoint.toString();
119
+ }
120
+
121
+ function showConnection(id, url) {
122
+ console.log(`Session ID: ${id}`);
123
+ console.log(`MCP endpoint: ${mcpEndpoint(url)}`);
124
+ console.log('MCP tools: zback_status, zback_execute (provide the ID as the id argument)');
125
+ console.log('Keep the session ID private; it grants remote shell access.');
102
126
  }
103
127
 
104
128
  function showStatus() {
105
129
  const state = activeSession();
106
130
  if (!state) { console.log('Status: stopped'); return false; }
107
- console.log(`Status: ${state.status}\nPID: ${state.pid}${state.id ? `\nSession ID: ${state.id}` : ''}\nUpdated: ${state.updated || 'unknown'}`);
131
+ console.log(`Status: ${state.status}\nPID: ${state.pid}\nUpdated: ${state.updated || 'unknown'}`);
132
+ if (state.id) showConnection(state.id, readJson(settingsFile)?.url || DEFAULT_URL);
108
133
  return true;
109
134
  }
110
135
 
@@ -122,8 +147,7 @@ function recentLog() {
122
147
 
123
148
  async function follow() {
124
149
  if (!stdin.isTTY || !stdout.isTTY) return;
125
- console.log('Live agent events. Ctrl+C detaches; the agent stays active.');
126
- recentLog();
150
+ console.log('Monitoring background agent. Ctrl+C closes this view; the agent stays active.');
127
151
  let position = existsSync(logFile) ? statSync(logFile).size : 0;
128
152
  await new Promise(resolve => {
129
153
  const readNew = () => {
@@ -151,16 +175,45 @@ async function follow() {
151
175
  }
152
176
 
153
177
  async function start(options) {
178
+ const terminal = createTerminal();
154
179
  const existing = activeSession();
155
180
  if (existing) {
156
- console.log(existing.id ? `Session ID: ${existing.id}` : `ZBack is ${existing.status} (PID ${existing.pid}).`);
181
+ if (existing.version !== VERSION) {
182
+ terminal.error('An older ZBack agent is still running. Run zback --stop before starting this version.');
183
+ process.exitCode = 1;
184
+ return;
185
+ }
186
+ terminal.section('+', `Agent already running (${existing.status}, PID ${existing.pid}).`);
187
+ if (existing.id) terminal.connected(existing.id, mcpEndpoint(readJson(settingsFile)?.url || DEFAULT_URL));
157
188
  if (!options.background) await follow();
158
189
  return;
159
190
  }
160
- if (!(await confirmOnce())) { console.log('Authorization declined'); return; }
191
+ if (!(await confirmOnce(terminal))) return;
161
192
  initStateDir();
162
- agentKey();
193
+ const key = agentKey();
163
194
  saveJson(settingsFile, { url: options.url, cwd: options.cwd, timeout: options.timeout, maxOutput: options.maxOutput, reconnect: options.reconnect });
195
+ terminal.connecting();
196
+ if (!options.background) {
197
+ const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
198
+ let lastId = null;
199
+ const reporter = createEventReporter(terminal);
200
+ const mark = data => {
201
+ saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
202
+ if (data.status === 'connected') {
203
+ reporter.clearProblem();
204
+ if (!lastId) terminal.connected(data.id, mcpEndpoint(options.url));
205
+ else terminal.restored();
206
+ lastId = data.id;
207
+ }
208
+ };
209
+ mark({ status: 'connecting', id: null });
210
+ try { await connectAgent(options, mark, key, reporter.event); }
211
+ finally {
212
+ if (sameSession(process.pid, started)) saveJson(stateFile, { pid: process.pid, started, version: VERSION, status: 'stopped', id: lastId, updated: new Date().toISOString() });
213
+ terminal.stopped();
214
+ }
215
+ return;
216
+ }
164
217
  const fd = openSync(logFile, 'a', 0o600);
165
218
  const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
166
219
  let child;
@@ -178,14 +231,13 @@ async function start(options) {
178
231
  await new Promise(resolve => setTimeout(resolve, 100));
179
232
  const state = readJson(stateFile);
180
233
  if (state?.pid === child.pid && state.started === started && state.status === 'connected' && state.id) {
181
- console.log(`Session ID: ${state.id}`);
234
+ terminal.connected(state.id, mcpEndpoint(options.url));
182
235
  connected = true;
183
236
  break;
184
237
  }
185
- if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error(`Agent exited unexpectedly. Inspect logs with zback --logs`);
238
+ if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error('Agent exited unexpectedly. Inspect logs with zback --logs');
186
239
  }
187
- if (!connected) console.log(`Agent started (PID ${child.pid}). Waiting for backend; use zback --status for current state.`);
188
- if (!options.background) await follow();
240
+ if (!connected) terminal.hint(`Agent started (PID ${child.pid}). Run zback --status to inspect the connection.`);
189
241
  }
190
242
 
191
243
  function stop() {
@@ -213,7 +265,7 @@ export async function main(args = process.argv.slice(2)) {
213
265
  if (options.action === 'follow') { showStatus(); return follow(); }
214
266
  if (options.action === 'reset-key') return rotate();
215
267
  return await start(options);
216
- } catch (error) { console.error(`ZBack error: ${error.message}`); process.exitCode = 1; }
268
+ } catch (error) { createTerminal().error(error.message); process.exitCode = 1; }
217
269
  }
218
270
 
219
271
  if (process.argv[1] && realpathSync(process.argv[1]) === ENTRY) main();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zback",
3
- "version": "1.3.0",
4
- "description": "Opt-in remote shell administration with a persistent WebSocket agent, audit logs and a private session credential",
3
+ "version": "1.4.0",
4
+ "description": "Opt-in foreground remote shell administration with a persistent WebSocket agent and private session credential",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "zback": "bin/zback.js"
package/src/agent.js CHANGED
@@ -2,7 +2,6 @@ import { spawn } from 'node:child_process';
2
2
 
3
3
  const children = new Set();
4
4
  const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
5
- const log = message => console.log(`[${new Date().toISOString()}] ${message}`);
6
5
 
7
6
  function killTree(child) {
8
7
  if (!child?.pid) return;
@@ -54,16 +53,15 @@ export function execute(command, cwd, timeoutMs, maxOutput) {
54
53
  });
55
54
  }
56
55
 
57
- export async function connectAgent(options, onState, token) {
56
+ export async function connectAgent(options, onState, token, onEvent = () => {}) {
58
57
  let stopped = false;
59
58
  let retries = 0;
60
59
  let socket;
61
60
  let cancelDelay;
62
- let currentState = '';
63
- const update = (status, data = {}) => {
64
- onState({ status, ...data });
65
- if (currentState !== status) { log(`Agent status: ${status}`); currentState = status; }
66
- };
61
+ let lastId = null;
62
+ let lastError = null;
63
+ const update = (status, data = {}) => onState({ status, ...data });
64
+ const emit = event => { try { onEvent(event); } catch { /* UI failures must not interrupt command execution. */ } };
67
65
  const shutdown = () => {
68
66
  stopped = true;
69
67
  if (cancelDelay) cancelDelay();
@@ -75,7 +73,7 @@ export async function connectAgent(options, onState, token) {
75
73
  try {
76
74
  while (!stopped) {
77
75
  try {
78
- update(retries ? 'reconnecting' : 'connecting');
76
+ update(retries ? 'reconnecting' : 'connecting', { id: lastId });
79
77
  const current = new WebSocket(options.url);
80
78
  socket = current;
81
79
  await new Promise((resolve, reject) => {
@@ -94,14 +92,31 @@ export async function connectAgent(options, onState, token) {
94
92
  const messageListener = event => {
95
93
  let message;
96
94
  try { message = JSON.parse(event.data); }
97
- catch { log('Ignored invalid server message'); return; }
98
- if (message.type === 'welcome' && typeof message.id === 'string') {
95
+ catch { emit({ type: 'protocol-warning', problem: 'Received an invalid server message' }); return; }
96
+ if (message.type === 'welcome') {
99
97
  if (welcomed) return;
98
+ if (message.protocolVersion !== 2 || typeof message.id !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(message.id)) {
99
+ clearTimeout(timer);
100
+ current.close(1000, 'Incompatible protocol');
101
+ const mismatch = new Error('Incompatible backend. Deploy ZBack backend 1.2.0 or later; this server issues legacy session IDs.');
102
+ mismatch.code = 'INCOMPATIBLE_BACKEND';
103
+ reject(mismatch);
104
+ return;
105
+ }
106
+ if (lastId && message.id !== lastId) {
107
+ clearTimeout(timer);
108
+ current.close(1000, 'Session identifier changed');
109
+ const mismatch = new Error('The server returned a different session identifier after reconnecting');
110
+ mismatch.code = 'SESSION_MISMATCH';
111
+ reject(mismatch);
112
+ return;
113
+ }
100
114
  welcomed = true;
101
115
  clearTimeout(timer);
102
116
  retries = 0;
117
+ lastId = message.id;
118
+ lastError = null;
103
119
  update('connected', { id: message.id });
104
- log('Connected and ready for authorized requests');
105
120
  return;
106
121
  }
107
122
  if (!welcomed || message.type !== 'command' || typeof message.requestId !== 'string') return;
@@ -110,26 +125,27 @@ export async function connectAgent(options, onState, token) {
110
125
  const command = message.command;
111
126
  if (typeof command !== 'string' || !command.trim() || command.length > 4096 || command.includes('\0')) return;
112
127
  const timeout = Math.min(options.timeout, Number.isInteger(message.timeoutMs) && message.timeoutMs > 0 ? message.timeoutMs : options.timeout);
113
- log(`Running authorized request ${message.requestId}`);
114
128
  const result = await execute(command, options.cwd, timeout, options.maxOutput);
115
- log(`Request ${message.requestId}: ${result.timedOut ? 'timed out' : result.ok ? 'completed' : 'failed'}`);
129
+ emit({ type: 'command-result', command, result });
116
130
  if (current.readyState === WebSocket.OPEN && !stopped) current.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
117
- }).catch(error => log(`Request processing error: ${error.message}`));
131
+ }).catch(error => emit({ type: 'agent-error', problem: `Request processing failed: ${error.message}` }));
118
132
  };
119
133
  current.addEventListener('message', messageListener);
120
134
  current.addEventListener('close', event => {
121
135
  clearTimeout(timer);
122
136
  if (!welcomed) reject(new Error(`Backend rejected connection (WebSocket ${event.code}: ${event.reason || 'no reason'})`));
123
- else { log(`Connection closed (${event.code}: ${event.reason || 'connection lost'})`); resolve(); }
137
+ else { if (!stopped) emit({ type: 'connection-lost', problem: 'Connection lost' }); resolve(); }
124
138
  }, { once: true });
125
139
  current.addEventListener('error', () => { if (!welcomed) { clearTimeout(timer); reject(new Error('WebSocket connection failed')); } }, { once: true });
126
140
  });
127
- } catch (error) { if (!stopped) log(`Connection error: ${error.message}`); }
141
+ } catch (error) {
142
+ if (!stopped && ['INCOMPATIBLE_BACKEND', 'SESSION_MISMATCH'].includes(error.code)) throw error;
143
+ if (!stopped && error.message !== lastError) { emit({ type: 'connection-error', problem: error.message }); lastError = error.message; }
144
+ }
128
145
  killRunningCommands();
129
146
  if (stopped || !options.reconnect) break;
130
- update('reconnecting', { id: null });
147
+ update('reconnecting', { id: lastId });
131
148
  const delay = Math.min(30000, 1000 * 2 ** Math.min(retries++, 5));
132
- log(`Retrying connection in ${Math.ceil(delay / 1000)}s`);
133
149
  await new Promise(resolve => {
134
150
  const timer = setTimeout(() => { cancelDelay = null; resolve(); }, delay);
135
151
  cancelDelay = () => { clearTimeout(timer); cancelDelay = null; resolve(); };
@@ -138,6 +154,6 @@ export async function connectAgent(options, onState, token) {
138
154
  } finally {
139
155
  process.removeListener('SIGINT', shutdown);
140
156
  process.removeListener('SIGTERM', shutdown);
141
- update('stopped', { id: null });
157
+ update('stopped', { id: lastId });
142
158
  }
143
159
  }
package/src/runtime.js CHANGED
@@ -46,7 +46,7 @@ export function alive(pid) {
46
46
  try {
47
47
  if (readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1]?.[0] === 'Z') return false;
48
48
  const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
49
- if (!cmd.includes('--worker') || !cmd.includes('zback.js')) return false;
49
+ if (!/(?:^|[\x00/\\])zback(?:\.js)?(?:\x00|$)/.test(cmd)) return false;
50
50
  } catch {}
51
51
  }
52
52
  return true;
@@ -0,0 +1,98 @@
1
+ const ANSI = Object.freeze({ yellow: '\x1b[33m', red: '\x1b[31m', gray: '\x1b[90m', white: '\x1b[37m', reset: '\x1b[0m' });
2
+
3
+ // Never allow server-supplied text to inject terminal control sequences.
4
+ export function terminalText(value, maxLength = 240) {
5
+ const text = String(value ?? 'unknown')
6
+ .replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)?)/g, '')
7
+ .replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ')
8
+ .replace(/\s+/g, ' ')
9
+ .trim();
10
+ return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
11
+ }
12
+
13
+ export function createTerminal({ output = process.stdout, color = Boolean(output.isTTY && !process.env.NO_COLOR) } = {}) {
14
+ const tint = (s, name) => color ? `${ANSI[name]}${s}${ANSI.reset}` : s;
15
+ const write = line => output.write(`${line}\n`);
16
+ const section = (symbol, text, tone = 'yellow') => write(`${tint(`[${symbol}]`, tone)} ${tint(text, 'white')}`);
17
+ const detail = line => write(`${tint(' -', 'gray')} ${tint(line, 'white')}`);
18
+ const hint = line => write(`${tint('>', 'gray')} ${tint(line, 'gray')}`);
19
+ const gap = () => write('');
20
+
21
+ return {
22
+ section, detail, hint, gap,
23
+ question() {
24
+ section('?', 'Shell access authorization');
25
+ write(tint('Do you allow ZBack to provide remote shell access to holders of your private session ID?', 'white'));
26
+ write(`${tint('[Y]', 'yellow')} ${tint('Yes, I accept', 'white')} ${tint('[N]', 'yellow')} ${tint('No, I do not accept', 'white')}`);
27
+ hint('Your decision is saved. Individual commands will not trigger additional prompts.');
28
+ },
29
+ consent(approved) {
30
+ hint(approved ? 'Selected: Yes. Remote shell access is authorized.' : 'Selected: No. Remote shell access remains disabled.');
31
+ gap();
32
+ },
33
+ connecting() {
34
+ section('+', 'Connecting to the server, hang on a minute.');
35
+ hint('This may take a moment depending on your internet connection.');
36
+ gap();
37
+ },
38
+ connected(id, endpoint) {
39
+ section('+', 'Setting up our environment');
40
+ detail(`Session Identifier: "${terminalText(id, 90)}"`);
41
+ detail(`MCP Server: "${terminalText(endpoint, 512)}"`);
42
+ detail('MCP Tools: "zback_status", "zback_execute"');
43
+ hint('Enjoy being able to have a model manage your environment. Keep your ID private.');
44
+ gap();
45
+ },
46
+ restored() {
47
+ section('+', 'Connection restored.');
48
+ hint('Your session identifier has not changed.');
49
+ gap();
50
+ },
51
+ error(problem, retrying = false) {
52
+ section('×', 'Oops, an error has occurred.', 'red');
53
+ detail(`Problem: "${terminalText(problem, 240)}"`);
54
+ hint(retrying ? 'This error is being handled. Reconnecting automatically, please wait.' : 'The connection cannot continue. Check the problem above.');
55
+ gap();
56
+ },
57
+ stopped() {
58
+ section('+', 'Disconnected.');
59
+ },
60
+ command(result, totals) {
61
+ if (totals.count === 1) section('+', 'History of executed commands');
62
+ const code = Number.isInteger(result.code) ? result.code : result.timedOut ? 'timeout' : 'unknown';
63
+ detail(`${JSON.stringify(terminalText(result.command))} was executed with exit code ${code}.`);
64
+ const countWord = totals.count === 1 ? 'command was' : 'commands were';
65
+ const errorWord = totals.errors === 1 ? 'an error' : 'errors';
66
+ hint(`Currently, ${totals.count} ${countWord} executed, ${totals.errors} with ${errorWord} and ${totals.successes} successfully.`);
67
+ }
68
+ };
69
+ }
70
+
71
+ export function createCommandTracker(terminal) {
72
+ const totals = { count: 0, successes: 0, errors: 0 };
73
+ return event => {
74
+ if (event.type !== 'command-result') return;
75
+ totals.count++;
76
+ if (event.result.ok) totals.successes++;
77
+ else totals.errors++;
78
+ terminal.command({ command: event.command, ...event.result }, totals);
79
+ };
80
+ }
81
+
82
+ export function createEventReporter(terminal) {
83
+ const reportCommand = createCommandTracker(terminal);
84
+ let activeProblem = null;
85
+ return {
86
+ clearProblem() { activeProblem = null; },
87
+ event(event) {
88
+ if (event.type === 'command-result') return reportCommand(event);
89
+ if (event.type === 'connection-lost' || event.type === 'connection-error') {
90
+ // A dropped connection may lead to many retries; report the incident once.
91
+ if (activeProblem !== null) return;
92
+ activeProblem = event.problem;
93
+ return terminal.error(event.problem, true);
94
+ }
95
+ if (event.type === 'agent-error' || event.type === 'protocol-warning') terminal.error(event.problem, false);
96
+ }
97
+ };
98
+ }