zerogterm 0.9.1-alpha.1 → 0.9.4-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
@@ -22,7 +22,7 @@ ZeroG Terminal is an alpha project, but it is already useful as a multi-session
22
22
  - Session history for reconnecting to sessions after a relaunch, with bounded structured history and no stored secrets.
23
23
  - A ranked command palette on `Ctrl+Shift+R`, in the spirit of [McFly](https://github.com/cantino/mcfly): commands you have run, ranked by directory, host, recency, frequency, whether they worked, and whether you picked them before. Off by default — it is the one feature that stores what you typed — and it refuses anything that looks like it carries a credential.
24
24
  - Workspaces for grouping sessions and quickly switching between projects or tasks. Drag the tabs beside the wordmark to put them in the order you want; `Ctrl+Shift+1` … `9` follow that order, and it is remembered between launches. Right-click a tab to rename it, duplicate it, move it, or close it. Each workspace keeps its own panes, layout, focused terminal, and maximized pane, so switching to one restores the arrangement you left it in. Workspaces survive a relaunch: local `screen` terminals reattach on their own, and SSH panes come back as ghost rows that reconnect when clicked, rather than dialling out to a host on startup.
25
- - Session overview, collapsible sidebar, keyboard shortcuts, and light/dark themes.
25
+ - Session overview, collapsible sidebar, keyboard shortcuts, and nine colour themes: Tokyo Night, Light, Dracula, Nord, Solarized Dark, Monokai, One Dark, Synthwave and High contrast. The palettes are matched by hand to those open-source themes.
26
26
  - xterm.js terminal rendering with scrollback preservation while changing layouts.
27
27
  - Voice input, either with Whisper ONNX inside the app through Transformers.js or through an OpenAI-compatible transcription server you point it at — on this machine, on the LAN, or hosted; transcribed text is typed into the selected terminal without automatic execution.
28
28
  - A per-pane proceed button that sends a configurable phrase — `OK, proceed` by default — for waving an agent on without typing the same reply again.
@@ -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.1-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.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
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
 
@@ -467,12 +467,21 @@ required whichever shell you start them from.
467
467
  for Windows and macOS, so a C/C++ toolchain is a Linux requirement rather than a
468
468
  general one — there, `npm install` compiles it.
469
469
 
470
- ### Windows desktop release
470
+ ### Desktop releases
471
471
 
472
- 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.
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
473
 
474
- 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.
475
- ### Linux
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
481
+
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.
483
+
484
+ The host still needs a C/C++ toolchain when developing from source:
476
485
 
477
486
  ```bash
478
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
+ }
@@ -10,8 +10,10 @@ import { CommandHistoryStore, defaultCommandHistoryPath } from './command-histor
10
10
  import { WorkspaceStore, defaultWorkspacePath } from './workspace-store.js';
11
11
  import { PortForwardService } from './port-forward-service.js';
12
12
  import { PortForwardStore, defaultPortForwardPath } from './port-forward-store.js';
13
+ import { readRemoteFile, writeRemoteFile } from './remote-file.js';
14
+ import { HostLabelStore, defaultHostLabelPath } from './host-label-store.js';
13
15
  import { buildRemoteScreenAttachArgs, buildRemoteScreenDiscoveryArgs, listKnownConnections, parseRemoteScreenList, validateKnownConnection } from './ssh-inventory.js';
14
- import { createLocalDirectory, listLocalDirectory, localHome, removeLocalEntry, renameLocalEntry } from './local-fs.js';
16
+ import { createLocalDirectory, listLocalDirectory, localHome, readLocalFile, removeLocalEntry, renameLocalEntry, writeLocalFile } from './local-fs.js';
15
17
  import { wslHomeDirectory } from './wsl-home.js';
16
18
  import { decideExternalLink, isApplicationUrl } from './external-links.js';
17
19
  import { SftpService } from './sftp-service.js';
@@ -24,6 +26,8 @@ import { McpAudit } from './mcp-audit.js';
24
26
  import { classifyMcpPrompt } from './prompt-classifier.js';
25
27
  import { isSafeRemoteCommand } from './mcp-execution-policy.js';
26
28
  import { requireWorkspaceName } from './mcp-protocol.js';
29
+ import { PaneLinkController, pasteAndSubmit } from './pane-link.js';
30
+ import { AgentHookServer, buildLaunchCommand } from './agent-hook-server.js';
27
31
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
28
32
  const history = new SessionHistoryStore({ filePath: defaultHistoryPath(app.getPath('userData')) });
29
33
  // The only store holding what the user typed. Nothing reaches it that has not
@@ -32,6 +36,7 @@ const history = new SessionHistoryStore({ filePath: defaultHistoryPath(app.getPa
32
36
  const commands = new CommandHistoryStore({ filePath: defaultCommandHistoryPath(app.getPath('userData')) });
33
37
  const workspaceStore = new WorkspaceStore({ filePath: defaultWorkspacePath(app.getPath('userData')) });
34
38
  const forwardStore = new PortForwardStore({ filePath: defaultPortForwardPath(app.getPath('userData')) });
39
+ const hostLabelStore = new HostLabelStore({ filePath: defaultHostLabelPath(app.getPath('userData')) });
35
40
  // Tunnels outlive the Ports view being closed: authenticating again is a real
36
41
  // cost to pay for having looked away.
37
42
  const forwards = new PortForwardService({ onEvent: (event) => win?.webContents.send('forwards:event', event) });
@@ -67,6 +72,46 @@ function clearMcpExecution(requestId, sessionId) {
67
72
  }
68
73
  }
69
74
  let mcpHost;
75
+ // Pane link: agents ZeroG launches report their turns over loopback hooks, and the
76
+ // controller relays a finished reply into the linked pane. See src/main/pane-link.ts.
77
+ const paneLinks = new PaneLinkController({
78
+ deliver: (sessionId, text) => pasteAndSubmit((id, data) => service.write(id, data), sessionId, text),
79
+ onChange: (snapshot) => win?.webContents.send('paneLinks:changed', snapshot)
80
+ });
81
+ const agentHooks = new AgentHookServer({
82
+ settingsDir: join(app.getPath('userData'), 'pane-link'),
83
+ onEvent: (event) => paneLinks.handle(event)
84
+ });
85
+ ipcMain.handle('paneLinks:launchAgent', async (_event, sessionId, agentCommand) => {
86
+ const id = requireString(sessionId, 'A pane');
87
+ const command = requireString(agentCommand, 'The AI command');
88
+ const info = (await service.list()).find((candidate) => candidate.id === id);
89
+ if (!info)
90
+ throw new Error('That pane is not available.');
91
+ // The hook URL is a Windows/Linux loopback address. An SSH host or a WSL2 guest cannot reach it.
92
+ if (info.kind !== 'local' || info.backend === 'wsl') {
93
+ throw new Error('Linked agents run in local panes only (not SSH or WSL).');
94
+ }
95
+ const { settingsPath } = await agentHooks.register(id);
96
+ try {
97
+ const line = buildLaunchCommand(command, settingsPath);
98
+ paneLinks.registerAgent(id, info.name);
99
+ // One leading space: a freshly opened shell was seen to swallow the first
100
+ // keystroke ("laude: command not found"). If it is the space that goes, the
101
+ // command is intact; if nothing goes, a leading space is harmless in bash,
102
+ // PowerShell and cmd.
103
+ service.write(id, ` ${line}\r`);
104
+ }
105
+ catch (error) {
106
+ await agentHooks.unregister(id);
107
+ throw error;
108
+ }
109
+ });
110
+ ipcMain.handle('paneLinks:list', () => paneLinks.snapshot());
111
+ ipcMain.handle('paneLinks:link', (_event, a, b, cap) => paneLinks.link(requireString(a, 'A pane'), requireString(b, 'A pane'), typeof cap === 'number' ? cap : undefined));
112
+ ipcMain.handle('paneLinks:relayLast', (_event, linkId, from) => paneLinks.relayLast(requireString(linkId, 'A link'), requireString(from, 'A pane')));
113
+ ipcMain.handle('paneLinks:unlink', (_event, linkId) => paneLinks.unlink(requireString(linkId, 'A link')));
114
+ ipcMain.handle('paneLinks:resume', (_event, linkId) => paneLinks.resume(requireString(linkId, 'A link')));
70
115
  async function restoreWorkspace(workspaceId) {
71
116
  const file = await workspaceStore.load();
72
117
  const workspace = file.workspaces.find((item) => item.id === workspaceId);
@@ -319,6 +364,8 @@ ipcMain.handle('forwards:close', (_event, id) => forwards.close(requireString(id
319
364
  ipcMain.handle('forwards:answerPrompt', (_event, id, answer) => forwards.answerPrompt(requireString(id, 'A shared port'), requireString(answer, 'An answer')));
320
365
  ipcMain.handle('forwards:load', () => forwardStore.load());
321
366
  ipcMain.handle('forwards:save', (_event, file) => forwardStore.save(file));
367
+ ipcMain.handle('hostLabels:load', () => hostLabelStore.load());
368
+ ipcMain.handle('hostLabels:save', (_event, file) => hostLabelStore.save(file));
322
369
  ipcMain.handle('workspaces:load', () => workspaceStore.load());
323
370
  ipcMain.handle('workspaces:save', (_event, file) => workspaceStore.save(file));
324
371
  ipcMain.handle('mcp:status', () => mcpControl.status());
@@ -524,6 +571,8 @@ ipcMain.handle('sessions:close', (_event, id) => {
524
571
  if (typeof id !== 'string' || !id)
525
572
  throw new Error('closeSession requires a session id');
526
573
  service.close(id);
574
+ paneLinks.unregisterAgent(id);
575
+ void agentHooks.unregister(id);
527
576
  });
528
577
  ipcMain.on('terminal:write', (_event, sessionId, data) => {
529
578
  if (typeof sessionId === 'string' && typeof data === 'string')
@@ -647,6 +696,14 @@ ipcMain.handle('fs:wslHome', async (_event, distribution) => {
647
696
  ipcMain.handle('fs:listLocal', (_event, path) => listLocalDirectory(typeof path === 'string' && path ? path : undefined));
648
697
  ipcMain.handle('fs:mkdirLocal', (_event, path) => createLocalDirectory(requireString(path, 'A folder path')));
649
698
  ipcMain.handle('fs:renameLocal', (_event, from, to) => renameLocalEntry(requireString(from, 'The current path'), requireString(to, 'The new path')));
699
+ ipcMain.handle('fs:readLocalFile', (_event, path) => readLocalFile(requireString(path, 'A file path')));
700
+ ipcMain.handle('fs:writeLocalFile', (_event, path, text, expectedMtimeMs, overwrite) => {
701
+ if (typeof text !== 'string')
702
+ throw new Error('There is no text to save.');
703
+ if (typeof expectedMtimeMs !== 'number' || !Number.isFinite(expectedMtimeMs))
704
+ throw new Error('When the file was opened is required.');
705
+ return writeLocalFile(requireString(path, 'A file path'), text, expectedMtimeMs, overwrite === true);
706
+ });
650
707
  ipcMain.handle('fs:removeLocal', (_event, path, kind) => removeLocalEntry(requireString(path, 'A path'), requireEntryKind(kind)));
651
708
  ipcMain.handle('sftp:open', (_event, target, cwd) => sftp.open(requireString(target, 'An SSH target'), typeof cwd === 'string' && cwd ? cwd : undefined));
652
709
  ipcMain.handle('sftp:list', (_event, id, path) => sftp.list(requireString(id, 'A transfer connection'), typeof path === 'string' && path ? path : undefined));
@@ -662,6 +719,12 @@ ipcMain.handle('sftp:answerPrompt', (_event, id, answer) => {
662
719
  throw new Error('An answer is required.');
663
720
  sftp.answerPrompt(requireString(id, 'A transfer connection'), answer);
664
721
  });
722
+ ipcMain.handle('sftp:readFile', (_event, id, path) => readRemoteFile(sftp, requireString(id, 'A transfer connection'), requireString(path, 'A remote file')));
723
+ ipcMain.handle('sftp:writeFile', (_event, id, path, text, opened, overwrite) => {
724
+ if (typeof text !== 'string' || typeof opened !== 'string')
725
+ throw new Error('There is no text to save.');
726
+ return writeRemoteFile(sftp, requireString(id, 'A transfer connection'), requireString(path, 'A remote file'), text, opened, overwrite === true);
727
+ });
665
728
  ipcMain.handle('sftp:close', (_event, id) => sftp.close(requireString(id, 'A transfer connection')));
666
729
  // The key is read here, per request, and never handed to the renderer.
667
730
  const ai = new AiService({ readApiKey: () => secrets.get(AI_API_KEY) });
@@ -720,6 +783,9 @@ app.whenReady().then(() => {
720
783
  createWindow();
721
784
  });
722
785
  });
786
+ app.on('before-quit', () => {
787
+ void agentHooks.stop();
788
+ });
723
789
  app.on('window-all-closed', () => {
724
790
  if (process.platform !== 'darwin')
725
791
  app.quit();