zerogterm 0.9.0-alpha.1 → 0.9.2-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,9 +38,9 @@ See the project walkthrough on [YouTube](https://youtu.be/4aJZCxLHD14).
38
38
 
39
39
  ## Release status
40
40
 
41
- ZeroG Terminal is currently a public alpha. The current release is `0.9.0-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.
41
+ ZeroG Terminal is currently a public alpha. The current release is `0.9.2-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
42
 
43
- The GitHub Releases page provides a Windows x64 installer and portable executable 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.
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
44
 
45
45
  ## Terminal shortcuts
46
46
 
@@ -396,6 +396,59 @@ npm install --global zerogterm
396
396
  zerogterm
397
397
  ```
398
398
 
399
+ ## Connecting an AI agent through MCP
400
+
401
+ ZeroG Terminal can expose the running desktop instance as a local MCP server. This
402
+ lets an MCP-capable AI agent inspect workspaces and sessions, restore or create
403
+ project workspaces, open SSH sessions, and request commands for your approval.
404
+
405
+ 1. Start ZeroG Terminal with MCP enabled:
406
+
407
+ ```bash
408
+ ZEROG_MCP_ENABLED=1 npx zerogterm
409
+ ```
410
+
411
+ On Windows PowerShell, use:
412
+
413
+ ```powershell
414
+ $env:ZEROG_MCP_ENABLED = "1"
415
+ npx zerogterm
416
+ ```
417
+
418
+ 2. Open **Settings → AI & voice → MCP** in ZeroG Terminal. Copy the displayed
419
+ endpoint and bearer token into your MCP client. The default endpoint is
420
+ `http://127.0.0.1:60056/mcp`.
421
+
422
+ MCP clients that support Streamable HTTP generally need the equivalent of:
423
+
424
+ ```json
425
+ {
426
+ "url": "http://127.0.0.1:60056/mcp",
427
+ "headers": {
428
+ "Authorization": "Bearer <token-from-zero-g-settings>"
429
+ }
430
+ }
431
+ ```
432
+
433
+ The exact configuration key differs between clients; use its Streamable HTTP
434
+ or remote MCP server setting rather than a stdio command. The token belongs to
435
+ the current ZeroG process, is held only in memory, and must be copied again
436
+ after ZeroG restarts. Never put it in source control, prompts, issue reports,
437
+ or shared configuration.
438
+
439
+ 3. Ask the agent to call `zerog_get_connection_status`, then
440
+ `zerog_acquire_control` with a client ID and the capabilities it needs. Read
441
+ capabilities require no write access; workspace changes and command requests
442
+ require the corresponding lease capabilities. The lease is single-client and
443
+ expires unless renewed.
444
+
445
+ Every command request is shown in ZeroG Terminal for exact user approval before
446
+ anything is written to a shell. Rejection, cancellation, takeover, credential
447
+ prompts, uncertain prompts, and unsupported command shapes fail closed. MCP does
448
+ not receive terminal output, credentials, or keystroke-level control. See the
449
+ [full MCP protocol and security guide](docs/mcp.md) for tools, limits, and
450
+ troubleshooting.
451
+
399
452
  ## Development
400
453
 
401
454
  ```bash
@@ -414,12 +467,21 @@ required whichever shell you start them from.
414
467
  for Windows and macOS, so a C/C++ toolchain is a Linux requirement rather than a
415
468
  general one — there, `npm install` compiles it.
416
469
 
417
- ### Windows desktop release
470
+ ### Desktop releases
471
+
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
+
474
+ #### Windows
475
+
476
+ The Windows x64 installer adds ZeroG Terminal to the current user's applications. The portable `.exe` can be run without installation.
477
+
478
+ These binaries are unsigned, so Windows SmartScreen may show a warning on first launch. If you downloaded the release from the official repository, choose **More info → Run anyway**.
479
+
480
+ #### Linux
418
481
 
419
- Download the latest Windows x64 installer from [GitHub Releases](https://github.com/HappyMonkeyAI/ZeroGTerm/releases). The installer adds ZeroG Terminal to the current user's applications. The portable `.exe` can be run without installation.
482
+ The Linux release provides an x64 AppImage and Debian package built for Ubuntu 22.04 or newer. Make the AppImage executable after downloading it, then launch it from the same directory. If the AppImage reports that FUSE2 is unavailable, install `libfuse2` on Ubuntu 22.04 or `libfuse2t64` on Ubuntu 24.04 and newer. The `.deb` package can be installed through Ubuntu's software tools or APT and does not require FUSE2.
420
483
 
421
- These binaries are unsigned alpha builds, so Windows SmartScreen may show a warning on first launch. If you downloaded the release from the official repository, choose **More info → Run anyway**. 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.
422
- ### Linux
484
+ The host still needs a C/C++ toolchain when developing from source:
423
485
 
424
486
  ```bash
425
487
  sudo dnf install screen make gcc-c++ python3
@@ -0,0 +1,166 @@
1
+ // Loopback listener for the hooks of agents that ZeroG launched itself.
2
+ //
3
+ // Each launched agent gets its own settings file (passed with --settings, so no user or
4
+ // project settings are edited) pointing four hook events at a URL that carries a random
5
+ // per-agent token. The token is the only credential: the listener binds to 127.0.0.1
6
+ // only, and a request for an unknown token is a 404 that reveals nothing. The token also
7
+ // identifies the session, so a hook cannot claim to be a different pane.
8
+ //
9
+ // The hook response is an empty JSON object: ZeroG observes the agent, it never steers it
10
+ // (no blocking, no injected context).
11
+ import { createServer } from 'node:http';
12
+ import { randomBytes } from 'node:crypto';
13
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ export const HOOK_EVENTS = ['UserPromptSubmit', 'Stop', 'Notification', 'SessionEnd'];
16
+ /** A Stop payload carries the whole final message, so allow generously but not without bound. */
17
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
18
+ /** Settings JSON that points each observed hook event at `url`. Exported for tests. */
19
+ export function buildHookSettings(url) {
20
+ const hooks = {};
21
+ for (const event of HOOK_EVENTS)
22
+ hooks[event] = [{ hooks: [{ type: 'http', url }] }];
23
+ return { hooks };
24
+ }
25
+ /** Turn a raw hook payload into the small event the controller understands, or undefined to ignore it. */
26
+ export function parseHookPayload(sessionId, payload) {
27
+ if (typeof payload !== 'object' || payload === null)
28
+ return undefined;
29
+ const record = payload;
30
+ const name = record.hook_event_name;
31
+ if (typeof name !== 'string' || !HOOK_EVENTS.includes(name))
32
+ return undefined;
33
+ const text = (key) => (typeof record[key] === 'string' ? record[key] : undefined);
34
+ return {
35
+ sessionId,
36
+ event: name,
37
+ message: text('last_assistant_message'),
38
+ notificationType: text('notification_type'),
39
+ reason: text('reason')
40
+ };
41
+ }
42
+ export class AgentHookServer {
43
+ options;
44
+ byToken = new Map();
45
+ bySession = new Map();
46
+ http;
47
+ port = 0;
48
+ constructor(options) {
49
+ this.options = options;
50
+ }
51
+ /** Start listening on an ephemeral loopback port. Safe to call twice. */
52
+ async start() {
53
+ if (this.http)
54
+ return;
55
+ const server = createServer((req, res) => this.handle(req, res));
56
+ await new Promise((resolve, reject) => {
57
+ server.once('error', reject);
58
+ server.listen(0, '127.0.0.1', () => {
59
+ server.off('error', reject);
60
+ resolve();
61
+ });
62
+ });
63
+ const address = server.address();
64
+ this.port = typeof address === 'object' && address ? address.port : 0;
65
+ this.http = server;
66
+ }
67
+ async stop() {
68
+ const server = this.http;
69
+ this.http = undefined;
70
+ const sessions = Array.from(this.bySession.keys());
71
+ await Promise.all(sessions.map((sessionId) => this.unregister(sessionId)));
72
+ if (!server)
73
+ return;
74
+ await new Promise((resolve) => {
75
+ server.close(() => resolve());
76
+ server.closeAllConnections?.();
77
+ });
78
+ }
79
+ /** Issue a token and a settings file for one launched agent. Replaces any earlier registration for the session. */
80
+ async register(sessionId) {
81
+ if (!this.http)
82
+ await this.start();
83
+ await this.unregister(sessionId);
84
+ const token = randomBytes(24).toString('hex');
85
+ const settingsPath = join(this.options.settingsDir, `agent-${token.slice(0, 12)}.json`);
86
+ const url = `http://127.0.0.1:${this.port}/hook/${token}`;
87
+ await mkdir(this.options.settingsDir, { recursive: true });
88
+ // The file holds the token, so keep it to the owner where the platform supports modes.
89
+ await writeFile(settingsPath, JSON.stringify(buildHookSettings(url)), { mode: 0o600 });
90
+ this.byToken.set(token, { sessionId, settingsPath });
91
+ this.bySession.set(sessionId, token);
92
+ return { sessionId, settingsPath };
93
+ }
94
+ async unregister(sessionId) {
95
+ const token = this.bySession.get(sessionId);
96
+ if (!token)
97
+ return;
98
+ this.bySession.delete(sessionId);
99
+ const registration = this.byToken.get(token);
100
+ this.byToken.delete(token);
101
+ if (registration)
102
+ await rm(registration.settingsPath, { force: true });
103
+ }
104
+ handle(req, res) {
105
+ const reply = (status, body = '{}') => {
106
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
107
+ res.end(body);
108
+ };
109
+ const match = /^\/hook\/([0-9a-f]{48})$/.exec((req.url ?? '').split('?')[0]);
110
+ const registration = match ? this.byToken.get(match[1]) : undefined;
111
+ if (!registration)
112
+ return reply(404, '{"error":"not found"}');
113
+ if (req.method !== 'POST')
114
+ return reply(405, '{"error":"method not allowed"}');
115
+ const chunks = [];
116
+ let size = 0;
117
+ let tooLarge = false;
118
+ req.on('data', (chunk) => {
119
+ size += chunk.length;
120
+ if (size > MAX_BODY_BYTES) {
121
+ // Past the limit a chunk is counted and dropped, never kept, so a larger
122
+ // body costs no more memory than the limit. The request is read to its end
123
+ // rather than torn down so the 413 below actually reaches the sender, and
124
+ // Node's own requestTimeout (five minutes) bounds a sender that never
125
+ // finishes — one that also needs this agent's token to get this far.
126
+ tooLarge = true;
127
+ return;
128
+ }
129
+ chunks.push(chunk);
130
+ });
131
+ req.on('end', () => {
132
+ if (tooLarge)
133
+ return reply(413, '{"error":"payload too large"}');
134
+ try {
135
+ const event = parseHookPayload(registration.sessionId, JSON.parse(Buffer.concat(chunks).toString('utf8')));
136
+ if (event)
137
+ this.options.onEvent(event);
138
+ }
139
+ catch {
140
+ return reply(400, '{"error":"bad request"}');
141
+ }
142
+ reply(200);
143
+ });
144
+ req.on('error', () => reply(400, '{"error":"bad request"}'));
145
+ }
146
+ }
147
+ /**
148
+ * The command typed into the pane: the user's agent command plus the settings file.
149
+ *
150
+ * Forward slashes keep the path safe inside double quotes in PowerShell, cmd and bash alike.
151
+ * The command comes from a user setting, so it must be a single plain line.
152
+ */
153
+ export function buildLaunchCommand(agentCommand, settingsPath) {
154
+ const command = agentCommand.trim();
155
+ if (!command || command.length > 200)
156
+ throw new Error('The AI command must be 1 to 200 characters.');
157
+ for (const ch of command) {
158
+ const code = ch.codePointAt(0) ?? 0;
159
+ if (code < 0x20 || code === 0x7f)
160
+ throw new Error('The AI command must be a single line with no control characters.');
161
+ }
162
+ const path = settingsPath.replaceAll('\\', '/');
163
+ if (path.includes('"'))
164
+ throw new Error('The settings path cannot contain a quote.');
165
+ return `${command} --settings "${path}"`;
166
+ }
@@ -0,0 +1,31 @@
1
+ // What makes a file safe to put in the editor's text box, shared by the local and
2
+ // the remote read so the two cannot disagree about it.
3
+ import { MAX_EDIT_BYTES } from '../shared/editing.js';
4
+ export function megabytes(bytes) {
5
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
6
+ }
7
+ /** Throws unless a file of this size may be opened. */
8
+ export function checkEditableSize(size) {
9
+ if (size > MAX_EDIT_BYTES) {
10
+ throw new Error(`That file is ${megabytes(size)}; the editor opens files up to ${megabytes(MAX_EDIT_BYTES)}.`);
11
+ }
12
+ }
13
+ /**
14
+ * The file's bytes as text, or the reason it cannot be edited.
15
+ *
16
+ * Refused rather than coaxed: a NUL byte (the usual mark of a binary) and bytes
17
+ * that are not valid UTF-8 would each be damaged by a round trip through a text
18
+ * box, and a refused open costs nothing where a corrupted save does. The BOM is
19
+ * kept as a character so a save writes it back.
20
+ */
21
+ export function decodeEditable(bytes) {
22
+ checkEditableSize(bytes.length);
23
+ if (bytes.includes(0))
24
+ throw new Error('That looks like a binary file, so it was not opened.');
25
+ try {
26
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
27
+ }
28
+ catch {
29
+ throw new Error('That file is not valid UTF-8, so it was not opened.');
30
+ }
31
+ }
@@ -0,0 +1,110 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ const SCHEMA_VERSION = 1;
5
+ const MAX_HOSTS = 256;
6
+ // Session names cap at 48, so a longer remembered label could never be reused.
7
+ const MAX_LABEL = 48;
8
+ /**
9
+ * Main-process-only, best-effort memory of the session label last given to each
10
+ * host, so the SSH dialog can offer it again instead of the user retyping
11
+ * "dev server" every time.
12
+ *
13
+ * Stores a host and a label — never a user name, a credential, or a command —
14
+ * the same guarantee the other stores make, and for the same reason: this is
15
+ * plain JSON in the user's profile. The renderer decides what a host is called
16
+ * for this purpose; the file only guarantees what comes back is well formed.
17
+ */
18
+ export class HostLabelStore {
19
+ filePath;
20
+ file = { version: SCHEMA_VERSION, hosts: [] };
21
+ loaded = false;
22
+ writeQueue = Promise.resolve();
23
+ constructor(options) {
24
+ this.filePath = options.filePath;
25
+ }
26
+ async load() {
27
+ await this.ensureLoaded();
28
+ return normalizeFile(this.file) ?? { version: SCHEMA_VERSION, hosts: [] };
29
+ }
30
+ async save(input) {
31
+ await this.ensureLoaded();
32
+ // Validated on the way in as well as out: this is an IPC boundary, and a
33
+ // stored bad value would come back on every launch from then on.
34
+ const normalized = normalizeFile(input);
35
+ if (!normalized)
36
+ throw new Error('Invalid host label list.');
37
+ this.file = normalized;
38
+ await this.persist();
39
+ return normalized;
40
+ }
41
+ async ensureLoaded() {
42
+ if (this.loaded)
43
+ return;
44
+ this.loaded = true;
45
+ try {
46
+ const parsed = JSON.parse(await readFile(this.filePath, 'utf8'));
47
+ const normalized = normalizeFile(parsed);
48
+ if (normalized)
49
+ this.file = normalized;
50
+ }
51
+ catch {
52
+ this.file = { version: SCHEMA_VERSION, hosts: [] };
53
+ }
54
+ }
55
+ async persist() {
56
+ const snapshot = this.file;
57
+ this.writeQueue = this.writeQueue.then(async () => {
58
+ try {
59
+ await mkdir(dirname(this.filePath), { recursive: true });
60
+ const temp = join(dirname(this.filePath), `.host-labels.tmp-${process.pid}-${randomUUID()}`);
61
+ await writeFile(temp, JSON.stringify(snapshot, null, 2), { encoding: 'utf8', mode: 0o600 });
62
+ await rename(temp, this.filePath);
63
+ }
64
+ catch {
65
+ // Remembering a label must never affect terminal operation.
66
+ }
67
+ });
68
+ await this.writeQueue;
69
+ }
70
+ }
71
+ /** Strip control characters and cap length; the file is user-editable. */
72
+ function safeText(value, limit) {
73
+ return value.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, limit);
74
+ }
75
+ function normalizeEntry(value) {
76
+ if (!value || typeof value !== 'object')
77
+ return undefined;
78
+ const item = value;
79
+ if (typeof item.host !== 'string' || typeof item.label !== 'string')
80
+ return undefined;
81
+ const host = safeText(item.host, 256).toLowerCase();
82
+ const label = safeText(item.label, MAX_LABEL);
83
+ if (!host || !label)
84
+ return undefined;
85
+ return { host, label };
86
+ }
87
+ export function normalizeFile(value) {
88
+ if (!value || typeof value !== 'object')
89
+ return undefined;
90
+ const item = value;
91
+ if (item.version !== SCHEMA_VERSION)
92
+ return undefined;
93
+ if (!Array.isArray(item.hosts))
94
+ return undefined;
95
+ // Later entries win and an entry is only kept once, so a hand-edited file
96
+ // that repeats a host settles on its last word rather than its first.
97
+ const byHost = new Map();
98
+ for (const raw of item.hosts) {
99
+ const entry = normalizeEntry(raw);
100
+ if (!entry)
101
+ continue;
102
+ byHost.delete(entry.host);
103
+ byHost.set(entry.host, entry);
104
+ }
105
+ // Oldest first, so the cap drops the host not touched for longest.
106
+ return { version: SCHEMA_VERSION, hosts: [...byHost.values()].slice(-MAX_HOSTS) };
107
+ }
108
+ export function defaultHostLabelPath(userDataPath) {
109
+ return join(userDataPath, 'host-labels.json');
110
+ }
@@ -1,14 +1,18 @@
1
1
  // The local half of the transfer panel: directory listings and the three edits
2
- // the panel can make locally.
2
+ // the panel can make locally, plus reading and writing one text file for the
3
+ // built-in editor.
3
4
  //
4
- // Nothing here reads file *contents*. The renderer is sandboxed and has no
5
- // filesystem access of its own, and this module is deliberately the narrowest
6
- // widening of that boundary that a file browser needs: names, sizes, kinds, and
7
- // explicit create/rename/delete on a path the user pointed at.
5
+ // The renderer is sandboxed and has no filesystem access of its own, and this
6
+ // module is deliberately the narrowest widening of that boundary that a file
7
+ // browser needs: names, sizes, kinds, and explicit create/rename/delete on a path
8
+ // the user pointed at. File *contents* are only ever read or written by the
9
+ // editor's two functions at the bottom, one file at a time, with limits.
8
10
  import { constants } from 'node:fs';
9
- import { access, lstat, mkdir, readdir, rename, rm, rmdir } from 'node:fs/promises';
11
+ import { access, lstat, mkdir, readFile, readdir, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
10
12
  import { homedir } from 'node:os';
11
13
  import { isAbsolute, join, normalize, resolve } from 'node:path';
14
+ import { EDIT_CONFLICT_MESSAGE, MAX_EDIT_BYTES } from '../shared/editing.js';
15
+ import { checkEditableSize, decodeEditable, megabytes } from './editable.js';
12
16
  import { sortEntries } from '../shared/files.js';
13
17
  /**
14
18
  * A path this process will act on.
@@ -113,3 +117,50 @@ export async function removeLocalEntry(path, kind) {
113
117
  else
114
118
  await rm(target, { force: false });
115
119
  }
120
+ /**
121
+ * Read one file as text for the editor, or say why it cannot be edited (see
122
+ * decodeEditable for what is refused).
123
+ *
124
+ * Follows a symlink, as opening it in any editor would.
125
+ */
126
+ export async function readLocalFile(path) {
127
+ const target = resolveLocalPath(path);
128
+ const info = await stat(target);
129
+ if (!info.isFile())
130
+ throw new Error('That is not a file.');
131
+ // Checked before reading, so a huge file is refused without being loaded.
132
+ checkEditableSize(info.size);
133
+ const bytes = await readFile(target);
134
+ return { path: target, text: decodeEditable(bytes), size: bytes.length, mtimeMs: info.mtimeMs };
135
+ }
136
+ /**
137
+ * Save the editor's text back over the file it came from.
138
+ *
139
+ * Refused when the file's modified time is not the one it was opened with, so a
140
+ * change made meanwhile — an agent writing the same `.env` in the pane behind —
141
+ * is flagged instead of silently lost. `overwrite` is the user saying they have
142
+ * seen that and mean it.
143
+ *
144
+ * Written in place rather than through a temp file and a rename, which would
145
+ * replace a symlink with a plain file and reset the file's owner and mode. The
146
+ * price is that a write interrupted halfway leaves a short file, which for the
147
+ * small text files this opens is the lesser harm.
148
+ */
149
+ export async function writeLocalFile(path, text, expectedMtimeMs, overwrite = false) {
150
+ const target = resolveLocalPath(path);
151
+ if (typeof text !== 'string')
152
+ throw new Error('There is no text to save.');
153
+ if (Buffer.byteLength(text, 'utf8') > MAX_EDIT_BYTES) {
154
+ throw new Error(`That is more than ${megabytes(MAX_EDIT_BYTES)}, which is more than the editor saves.`);
155
+ }
156
+ const current = await stat(target).then((info) => info, () => undefined);
157
+ if (!current)
158
+ throw new Error('That file no longer exists, so it was not saved.');
159
+ if (!current.isFile())
160
+ throw new Error('That is not a file.');
161
+ if (!overwrite && current.mtimeMs !== expectedMtimeMs)
162
+ throw new Error(EDIT_CONFLICT_MESSAGE);
163
+ await writeFile(target, text, 'utf8');
164
+ const saved = await stat(target);
165
+ return { size: saved.size, mtimeMs: saved.mtimeMs };
166
+ }