zerogterm 0.9.1-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 +15 -6
- package/dist/main/main/agent-hook-server.js +166 -0
- package/dist/main/main/editable.js +31 -0
- package/dist/main/main/host-label-store.js +110 -0
- package/dist/main/main/local-fs.js +57 -6
- package/dist/main/main/main.js +67 -1
- package/dist/main/main/pane-link.js +335 -0
- package/dist/main/main/preload.cjs +17 -0
- package/dist/main/main/remote-file.js +97 -0
- package/dist/main/main/sftp-service.js +12 -0
- package/dist/main/main/workspace-store.js +7 -0
- package/dist/main/shared/editing.js +11 -0
- package/dist/main/shared/files.js +11 -0
- package/dist/renderer/assets/index-B7wWHnX0.js +149 -0
- package/dist/renderer/assets/index-DJfpGOKA.css +1 -0
- package/dist/renderer/index.html +2 -2
- package/package.json +25 -1
- package/dist/renderer/assets/index-CknqVqkF.js +0 -142
- package/dist/renderer/assets/index-axEdZFb8.css +0 -1
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.
|
|
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
|
|
|
@@ -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
|
-
###
|
|
470
|
+
### Desktop releases
|
|
471
471
|
|
|
472
|
-
Download the latest
|
|
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
|
-
|
|
475
|
-
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
+
}
|
package/dist/main/main/main.js
CHANGED
|
@@ -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();
|