zerogterm 0.7.0-alpha2 → 0.7.0-alpha3

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.
@@ -0,0 +1,161 @@
1
+ // Turning a forwarding request into an ssh command line, and reading back
2
+ // whether the tunnel came up.
3
+ //
4
+ // Kept apart from the service so the part that decides what `ssh` is asked to do
5
+ // can be tested exhaustively without spawning anything. That matters more here
6
+ // than usual: every field lands in an argv element, and the whole point of the
7
+ // feature is to open a listening socket.
8
+ import { parseSshTarget, sshExecutable } from './session-service.js';
9
+ import { isSshHostName } from './ssh-inventory.js';
10
+ /** Where traffic goes when the request does not say. */
11
+ const DEFAULT_DESTINATION_HOST = 'localhost';
12
+ /**
13
+ * How long to wait for a tunnel to declare itself before assuming it is up.
14
+ *
15
+ * Only a fallback: an ssh that has authenticated and gone quiet without saying
16
+ * it started listening is almost certainly listening. The explicit signals in
17
+ * readForwardOutcome are what normally settle this, and they arrive in
18
+ * milliseconds.
19
+ */
20
+ export const FORWARD_SETTLE_MS = 1500;
21
+ function requirePort(value, what) {
22
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
23
+ throw new Error(`${what} must be a whole number between 1 and 65535.`);
24
+ }
25
+ return value;
26
+ }
27
+ function requireDirection(value) {
28
+ if (value !== 'local' && value !== 'remote')
29
+ throw new Error('A forward must be local or remote.');
30
+ return value;
31
+ }
32
+ function requireBind(value) {
33
+ if (value !== 'loopback' && value !== 'all')
34
+ throw new Error('A forward must bind to loopback or to all interfaces.');
35
+ return value;
36
+ }
37
+ /**
38
+ * The host on the receiving side of the tunnel.
39
+ *
40
+ * Vetted with the same rule as a HostName in ~/.ssh/config, because it is
41
+ * concatenated into the forward spec — one argv element — and a value beginning
42
+ * with '-' would be read by ssh's getopt as an option instead.
43
+ *
44
+ * Colons are then refused on top of that rule, which a HostName is allowed to
45
+ * carry for an IPv6 literal. ssh splits a forward spec on colons, so an
46
+ * unbracketed IPv6 address there does not mean what it looks like: `[::1]` is
47
+ * the syntax, and accepting a bare `::1` would silently build a spec describing
48
+ * a different tunnel. Bracketed literals are a separate job, so this says no
49
+ * rather than guessing.
50
+ */
51
+ function requireDestinationHost(value) {
52
+ if (value === undefined || value === '')
53
+ return DEFAULT_DESTINATION_HOST;
54
+ if (typeof value !== 'string' || !isSshHostName(value)) {
55
+ throw new Error('The destination host must be a hostname or IP address.');
56
+ }
57
+ if (value.includes(':')) {
58
+ throw new Error('An IPv6 destination is not supported yet. Use a hostname or an IPv4 address.');
59
+ }
60
+ return value;
61
+ }
62
+ /**
63
+ * The bind address to put in the spec, or nothing.
64
+ *
65
+ * The two directions are not symmetric here, and it is the server's rule that
66
+ * makes them differ. A `-L` forward binds on this machine, so the address is
67
+ * ours to state and is always stated. A `-R` forward binds on the remote, where
68
+ * sshd's GatewayPorts decides: the default (`no`) binds loopback and rejects a
69
+ * client-specified address outright, so loopback has to be expressed by saying
70
+ * nothing at all. `*` is used for the wide case because it is accepted under
71
+ * both `yes` and `clientspecified`.
72
+ */
73
+ function bindPrefix(direction, bind) {
74
+ if (direction === 'local')
75
+ return bind === 'all' ? '0.0.0.0:' : '127.0.0.1:';
76
+ return bind === 'all' ? '*:' : '';
77
+ }
78
+ /**
79
+ * Build the command line for a forward.
80
+ *
81
+ * Two options carry most of the weight:
82
+ *
83
+ * `ExitOnForwardFailure=yes` — without it, ssh stays connected when the forward
84
+ * cannot bind, and there is no way to tell a working tunnel from a useless one
85
+ * short of trying to use it. With it, a forward that fails is a process that
86
+ * exits, and the UI can say so.
87
+ *
88
+ * `-v` — so that the tunnel being up is something ssh said rather than something
89
+ * we assumed. `-N` produces no output of its own on success, so without this the
90
+ * only signal available is the absence of an error.
91
+ */
92
+ export function buildForwardArgs(request, options = {}) {
93
+ const direction = requireDirection(request.direction);
94
+ const bind = requireBind(request.bind);
95
+ const listenPort = requirePort(request.listenPort, 'The port to open');
96
+ const destinationPort = requirePort(request.destinationPort, 'The port to forward to');
97
+ const destinationHost = requireDestinationHost(request.destinationHost);
98
+ const { destination, port } = parseSshTarget(request.target);
99
+ const spec = `${bindPrefix(direction, bind)}${listenPort}:${destinationHost}:${destinationPort}`;
100
+ const args = ['-v', '-N', '-o', 'ExitOnForwardFailure=yes', '-o', 'ConnectTimeout=20'];
101
+ if (port)
102
+ args.push('-p', port);
103
+ args.push(direction === 'local' ? '-L' : '-R', spec);
104
+ // '--' ends option parsing, so the destination can never be read as a flag.
105
+ args.push('--', destination);
106
+ return { file: sshExecutable(options), args, spec };
107
+ }
108
+ const FAILURES = [
109
+ {
110
+ pattern: /bind \[?([^\]\s]+)\]?:(\d+): (.+)/,
111
+ reason: (match) => `Could not bind ${match[1]}:${match[2]} — ${match[3].trim()}.`
112
+ },
113
+ {
114
+ pattern: /Warning: remote port forwarding failed for listen port (\d+)/,
115
+ reason: (match) => `The remote host refused to open port ${match[1]}. Its sshd may need GatewayPorts enabled to bind beyond loopback.`
116
+ },
117
+ {
118
+ pattern: /channel_setup_fwd_listener_tcpip: cannot listen to port: (\d+)/,
119
+ reason: (match) => `Could not listen on port ${match[1]}.`
120
+ },
121
+ { pattern: /Permission denied \(([^)]*)\)/, reason: (match) => `Authentication failed (${match[1]}).` },
122
+ { pattern: /(?:ssh: )?Could not resolve hostname ([^\s:]+)/, reason: (match) => `Could not resolve ${match[1]}.` },
123
+ { pattern: /Connection (?:refused|timed out|closed) by ([^\s]+)/, reason: (match) => `The host ${match[1]} refused the connection.` },
124
+ { pattern: /(ssh: connect to host [^\r\n]+)/, reason: (match) => `${match[1].trim()}.` },
125
+ { pattern: /Host key verification failed/, reason: () => 'Host key verification failed.' }
126
+ ];
127
+ /**
128
+ * What the client's output so far says about the tunnel.
129
+ *
130
+ * Read from the whole buffer rather than the tail: `-v` is talkative, and the one
131
+ * line that matters is followed by many that do not. Failure is checked before
132
+ * success because ssh reports a partial success — one forward of several — while
133
+ * still exiting.
134
+ */
135
+ export function readForwardOutcome(buffer, direction) {
136
+ for (const { pattern, reason } of FAILURES) {
137
+ const match = buffer.match(pattern);
138
+ if (match)
139
+ return { kind: 'failed', reason: reason(match) };
140
+ }
141
+ const success = direction === 'local'
142
+ ? /Local forwarding listening on \S+ port (\d+)/
143
+ : /remote forward success for: listen (\d+)/;
144
+ if (success.test(buffer))
145
+ return { kind: 'open' };
146
+ // Not proof of a tunnel, but it does mean the connection got that far — the
147
+ // service uses it to start the settle timer rather than waiting the full
148
+ // timeout on a host that never answered at all.
149
+ if (/Authentication succeeded|debug1: Entering interactive session/.test(buffer)) {
150
+ return { kind: 'authenticated' };
151
+ }
152
+ return null;
153
+ }
154
+ /** How a forward reads in the UI and in a log line. */
155
+ export function describeForward(request) {
156
+ const destinationHost = request.destinationHost || DEFAULT_DESTINATION_HOST;
157
+ const listener = request.bind === 'all' ? '0.0.0.0' : 'localhost';
158
+ return request.direction === 'local'
159
+ ? `${listener}:${request.listenPort} → ${destinationHost}:${request.destinationPort} on ${request.target}`
160
+ : `${request.target}:${request.listenPort} → ${destinationHost}:${request.destinationPort} here`;
161
+ }
@@ -0,0 +1,230 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createRequire } from 'node:module';
3
+ import { homedir } from 'node:os';
4
+ import { FORWARD_SETTLE_MS, buildForwardArgs, describeForward, readForwardOutcome } from './port-forward-protocol.js';
5
+ import { detectPrompt } from './sftp-protocol.js';
6
+ // node-pty is a native addon with no ESM entry point, and this file is compiled
7
+ // as a module. Same shim as session-service.ts and sftp-service.ts.
8
+ const require = createRequire(import.meta.url);
9
+ /** A ceiling on live tunnels, so a stuck panel cannot spawn clients forever. */
10
+ const MAX_FORWARDS = 16;
11
+ /** Nothing at all from the client within this long means the host never answered. */
12
+ const FIRST_BYTE_MS = 20000;
13
+ /** Kept small: this is a debug log, not a terminal, and only the tail matters. */
14
+ const BUFFER_CHARS = 8000;
15
+ /** How the client's own line endings behave, as with the transfer side. */
16
+ const TERMINATOR = '\n';
17
+ /**
18
+ * The `ssh -N` processes behind shared ports.
19
+ *
20
+ * One process per tunnel. It cannot be otherwise: a forward cannot be added to
21
+ * the connection a terminal pane already holds without either typing `~C`
22
+ * escapes into the pty the user is typing in, or ControlMaster, which Windows
23
+ * OpenSSH does not have. One process each also makes closing exact — kill that
24
+ * process and that tunnel is gone, with nothing else disturbed.
25
+ *
26
+ * The pty is not for a remote tty; `-N` runs no remote command. It is so `ssh`
27
+ * has a terminal to ask for a password on, the same reason the transfer side
28
+ * uses one, which is why prompt handling here is `detectPrompt` unchanged.
29
+ */
30
+ export class PortForwardService {
31
+ forwards = new Map();
32
+ spawnPty;
33
+ onEvent;
34
+ settleMs;
35
+ constructor(options = {}) {
36
+ this.spawnPty = options.spawnPty ?? ((file, args, ptyOptions) => require('node-pty').spawn(file, args, ptyOptions));
37
+ this.onEvent = options.onEvent ?? (() => undefined);
38
+ this.settleMs = options.settleMs ?? FORWARD_SETTLE_MS;
39
+ }
40
+ list() {
41
+ return [...this.forwards.values()].map((forward) => ({ ...forward.info }));
42
+ }
43
+ /**
44
+ * Open a tunnel, resolving once it is actually listening.
45
+ *
46
+ * A password or host-key question arrives as an event while this is still
47
+ * pending, and is answered with answerPrompt() — the same shape as the
48
+ * transfer panel, because it is the same client asking.
49
+ */
50
+ async open(request) {
51
+ // Built before anything is spawned, so an invalid request is a sentence
52
+ // rather than a process that dies a moment later.
53
+ const { file, args, spec } = buildForwardArgs(request);
54
+ const existing = this.findConflict(request);
55
+ if (existing) {
56
+ throw new Error(`Port ${request.listenPort} is already shared ${existing.direction === 'local' ? 'from' : 'to'} ${existing.target}.`);
57
+ }
58
+ if (this.forwards.size >= MAX_FORWARDS) {
59
+ throw new Error('Too many shared ports. Close one and try again.');
60
+ }
61
+ const id = request.id ?? `forward:${randomUUID()}`;
62
+ // Logged because which client got picked, and with which spec, is the first
63
+ // thing worth knowing when a tunnel does not work on someone else's machine.
64
+ console.log('[zerog] forward open', { file, args });
65
+ const info = { ...request, id, status: 'connecting' };
66
+ const pty = this.spawnPty(file, args, {
67
+ name: 'xterm-256color',
68
+ cols: 120,
69
+ rows: 30,
70
+ cwd: homedir(),
71
+ env: process.env
72
+ });
73
+ const forward = { info, pty, buffer: '', settle: null, settleTimer: undefined, firstByteTimer: undefined, closed: false };
74
+ this.forwards.set(id, forward);
75
+ pty.onData((data) => this.receive(forward, data));
76
+ pty.onExit(() => this.handleExit(forward));
77
+ this.emitStatus(forward);
78
+ return new Promise((resolve, reject) => {
79
+ forward.settle = { resolve, reject };
80
+ forward.firstByteTimer = setTimeout(() => {
81
+ this.fail(forward, `${describeForward(request)} did not answer.`);
82
+ }, FIRST_BYTE_MS);
83
+ });
84
+ }
85
+ answerPrompt(forwardId, answer) {
86
+ const forward = this.forwards.get(forwardId);
87
+ if (!forward)
88
+ throw new Error('That shared port is no longer open.');
89
+ if (/[\r\n]/.test(answer))
90
+ throw new Error('An answer must be a single line.');
91
+ lastQuestion.delete(forwardId);
92
+ forward.pty.write(`${answer}${TERMINATOR}`);
93
+ }
94
+ /**
95
+ * Close a tunnel.
96
+ *
97
+ * There is no graceful handshake to attempt: `ssh -N` is doing nothing but
98
+ * holding the forward open, so killing it is the clean way to let it go.
99
+ */
100
+ close(forwardId) {
101
+ const forward = this.forwards.get(forwardId);
102
+ if (!forward)
103
+ return;
104
+ this.destroy(forward, 'Closed.');
105
+ }
106
+ closeAll() {
107
+ for (const id of [...this.forwards.keys()])
108
+ this.close(id);
109
+ }
110
+ /**
111
+ * A tunnel that already binds what this request wants.
112
+ *
113
+ * Checked here rather than only in the renderer because a second listener on
114
+ * the same port cannot work, and spawning a client to find that out gives the
115
+ * user an ssh error instead of a sentence. A local forward's port is bound on
116
+ * this machine and a remote forward's on the far host, so the two only
117
+ * conflict with their own kind — and a remote one only per host.
118
+ */
119
+ findConflict(request) {
120
+ return this.list().find((candidate) => {
121
+ if (candidate.id === request.id)
122
+ return false;
123
+ if (candidate.listenPort !== request.listenPort)
124
+ return false;
125
+ if (candidate.direction !== request.direction)
126
+ return false;
127
+ return request.direction === 'local' || candidate.target === request.target;
128
+ });
129
+ }
130
+ receive(forward, data) {
131
+ if (forward.closed)
132
+ return;
133
+ clearTimeout(forward.firstByteTimer);
134
+ forward.firstByteTimer = undefined;
135
+ forward.buffer = (forward.buffer + data).slice(-BUFFER_CHARS);
136
+ const question = detectPrompt(forward.buffer);
137
+ if (question) {
138
+ // Only once per question: a rejected password is followed by the very same
139
+ // prompt text, and the user has to be given the field again.
140
+ if (lastQuestion.get(forward.info.id) !== question.text) {
141
+ lastQuestion.set(forward.info.id, question.text);
142
+ this.onEvent({ type: 'prompt', forwardId: forward.info.id, prompt: question });
143
+ }
144
+ return;
145
+ }
146
+ const outcome = readForwardOutcome(forward.buffer, forward.info.direction);
147
+ if (!outcome)
148
+ return;
149
+ if (outcome.kind === 'failed') {
150
+ this.fail(forward, outcome.reason);
151
+ return;
152
+ }
153
+ if (outcome.kind === 'open') {
154
+ this.succeed(forward);
155
+ return;
156
+ }
157
+ // Authenticated but not yet listening. Give the client a moment to say it
158
+ // started, and take silence as success rather than failing a tunnel that an
159
+ // ssh build words differently from the one this was written against.
160
+ if (!forward.settleTimer) {
161
+ forward.settleTimer = setTimeout(() => {
162
+ if (!forward.closed && forward.info.status === 'connecting')
163
+ this.succeed(forward);
164
+ }, this.settleMs);
165
+ }
166
+ }
167
+ succeed(forward) {
168
+ clearTimeout(forward.settleTimer);
169
+ forward.settleTimer = undefined;
170
+ forward.info.status = 'open';
171
+ delete forward.info.message;
172
+ this.emitStatus(forward);
173
+ const settle = forward.settle;
174
+ forward.settle = null;
175
+ settle?.resolve({ ...forward.info });
176
+ }
177
+ fail(forward, reason) {
178
+ const settle = forward.settle;
179
+ forward.settle = null;
180
+ forward.info.status = 'error';
181
+ forward.info.message = reason;
182
+ this.emitStatus(forward);
183
+ this.destroy(forward, reason, { keepStatus: true });
184
+ if (settle)
185
+ settle.reject(new Error(reason));
186
+ }
187
+ /**
188
+ * The client exited on its own.
189
+ *
190
+ * With ExitOnForwardFailure this is how a forward that could not bind reports
191
+ * itself, so the buffer is worth one more read before falling back to a
192
+ * generic message.
193
+ */
194
+ handleExit(forward) {
195
+ if (forward.closed)
196
+ return;
197
+ const outcome = readForwardOutcome(forward.buffer, forward.info.direction);
198
+ const reason = outcome?.kind === 'failed'
199
+ ? outcome.reason
200
+ : forward.info.status === 'open'
201
+ ? 'The connection closed.'
202
+ : `Could not share port ${forward.info.listenPort}.`;
203
+ this.fail(forward, reason);
204
+ }
205
+ destroy(forward, message, options = {}) {
206
+ if (forward.closed)
207
+ return;
208
+ forward.closed = true;
209
+ clearTimeout(forward.settleTimer);
210
+ clearTimeout(forward.firstByteTimer);
211
+ try {
212
+ forward.pty.kill();
213
+ }
214
+ catch {
215
+ /* already gone */
216
+ }
217
+ this.forwards.delete(forward.info.id);
218
+ lastQuestion.delete(forward.info.id);
219
+ if (!options.keepStatus) {
220
+ forward.info.status = 'idle';
221
+ this.emitStatus(forward);
222
+ }
223
+ this.onEvent({ type: 'closed', forwardId: forward.info.id, message });
224
+ }
225
+ emitStatus(forward) {
226
+ this.onEvent({ type: 'status', forward: { ...forward.info } });
227
+ }
228
+ }
229
+ /** The last question each tunnel asked, so it is only surfaced once. */
230
+ const lastQuestion = new Map();
@@ -0,0 +1,133 @@
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_FORWARDS = 64;
6
+ /**
7
+ * Main-process-only, best-effort memory of which ports were shared.
8
+ *
9
+ * Stores where a tunnel went and how wide it was bound — never a credential, a
10
+ * cwd, or command arguments — the same guarantee SessionHistoryStore and
11
+ * WorkspaceStore make, and for the same reason: this is plain JSON in the user's
12
+ * profile.
13
+ *
14
+ * Nothing here reopens a tunnel. Restored forwards come back as rows waiting to
15
+ * be clicked, so launching the app never dials out to a host on its own.
16
+ */
17
+ export class PortForwardStore {
18
+ filePath;
19
+ file = { version: SCHEMA_VERSION, forwards: [] };
20
+ loaded = false;
21
+ writeQueue = Promise.resolve();
22
+ constructor(options) {
23
+ this.filePath = options.filePath;
24
+ }
25
+ async load() {
26
+ await this.ensureLoaded();
27
+ return normalizeFile(this.file) ?? { version: SCHEMA_VERSION, forwards: [] };
28
+ }
29
+ async save(input) {
30
+ await this.ensureLoaded();
31
+ // Validated on the way in as well as out: the renderer is the only caller,
32
+ // but this is an IPC boundary, and a stored bad value would come back on
33
+ // every launch from then on.
34
+ const normalized = normalizeFile(input);
35
+ if (!normalized)
36
+ throw new Error('Invalid shared port 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, forwards: [] };
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), `.port-forwards.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 port 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 = 256) {
73
+ return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, limit);
74
+ }
75
+ function port(value) {
76
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535)
77
+ return undefined;
78
+ return value;
79
+ }
80
+ function normalizeForward(value) {
81
+ if (!value || typeof value !== 'object')
82
+ return undefined;
83
+ const item = value;
84
+ if (typeof item.id !== 'string' || typeof item.target !== 'string')
85
+ return undefined;
86
+ if (item.direction !== 'local' && item.direction !== 'remote')
87
+ return undefined;
88
+ // A missing or unrecognised bind reads as loopback, never as the wide one: a
89
+ // hand-edited or truncated file must not be able to widen a forward silently.
90
+ const bind = item.bind === 'all' ? 'all' : 'loopback';
91
+ const listenPort = port(item.listenPort);
92
+ const destinationPort = port(item.destinationPort);
93
+ const id = safeText(item.id, 64);
94
+ const target = safeText(item.target, 256);
95
+ if (!id || !target || listenPort === undefined || destinationPort === undefined)
96
+ return undefined;
97
+ const forward = { id, target, direction: item.direction, listenPort, destinationPort, bind };
98
+ // Left off entirely when absent, so the protocol's own default applies rather
99
+ // than an empty string reaching the forward spec.
100
+ const destinationHost = typeof item.destinationHost === 'string' ? safeText(item.destinationHost, 253) : '';
101
+ if (destinationHost)
102
+ forward.destinationHost = destinationHost;
103
+ return forward;
104
+ }
105
+ export function normalizeFile(value) {
106
+ if (!value || typeof value !== 'object')
107
+ return undefined;
108
+ const item = value;
109
+ if (item.version !== SCHEMA_VERSION)
110
+ return undefined;
111
+ if (!Array.isArray(item.forwards))
112
+ return undefined;
113
+ const forwards = [];
114
+ const seen = new Set();
115
+ for (const raw of item.forwards) {
116
+ const forward = normalizeForward(raw);
117
+ if (!forward || seen.has(forward.id))
118
+ continue;
119
+ seen.add(forward.id);
120
+ forwards.push(forward);
121
+ if (forwards.length >= MAX_FORWARDS)
122
+ break;
123
+ }
124
+ return { version: SCHEMA_VERSION, forwards };
125
+ }
126
+ /** What is worth remembering about a live tunnel: everything but how it is doing. */
127
+ export function forgetStatus(forward) {
128
+ const { status, message, ...rest } = forward;
129
+ return rest;
130
+ }
131
+ export function defaultPortForwardPath(userDataPath) {
132
+ return join(userDataPath, 'port-forwards.json');
133
+ }
@@ -4,6 +4,23 @@ const api = {
4
4
  listSessions: () => ipcRenderer.invoke('sessions:list'),
5
5
  listHistory: () => ipcRenderer.invoke('sessions:history'),
6
6
  removeHistory: (entryId) => ipcRenderer.invoke('sessions:historyRemove', entryId),
7
+ listCommandHistory: () => ipcRenderer.invoke('commands:list'),
8
+ recordCommand: (record) => ipcRenderer.invoke('commands:record', record),
9
+ pickCommand: (id) => ipcRenderer.invoke('commands:pick', id),
10
+ clearCommandHistory: () => ipcRenderer.invoke('commands:clear'),
11
+ listForwards: () => ipcRenderer.invoke('forwards:list'),
12
+ openForward: (request) => ipcRenderer.invoke('forwards:open', request),
13
+ closeForward: (id) => ipcRenderer.invoke('forwards:close', id),
14
+ answerForwardPrompt: (id, answer) => ipcRenderer.invoke('forwards:answerPrompt', id, answer),
15
+ loadForwards: () => ipcRenderer.invoke('forwards:load'),
16
+ saveForwards: (file) => ipcRenderer.invoke('forwards:save', file),
17
+ onForwardEvent: (callback) => {
18
+ const listener = (_event, payload) => callback(payload);
19
+ ipcRenderer.on('forwards:event', listener);
20
+ return () => ipcRenderer.removeListener('forwards:event', listener);
21
+ },
22
+ loadWorkspaces: () => ipcRenderer.invoke('workspaces:load'),
23
+ saveWorkspaces: (file) => ipcRenderer.invoke('workspaces:save', file),
7
24
  listBackends: () => ipcRenderer.invoke('sessions:backends'),
8
25
  listWslDistributions: () => ipcRenderer.invoke('sessions:wslDistributions'),
9
26
  createLocalSession: (request) => ipcRenderer.invoke('sessions:createLocal', request),
@@ -27,9 +44,17 @@ const api = {
27
44
  ipcRenderer.on('terminal:status', listener);
28
45
  return () => ipcRenderer.removeListener('terminal:status', listener);
29
46
  },
30
- requestAiCommand: () => ipcRenderer.invoke('ai:suggest'),
47
+ requestAiCommand: (config, request) => ipcRenderer.invoke('ai:suggest', config, request),
48
+ listAiModels: (baseUrl) => ipcRenderer.invoke('ai:models', baseUrl),
49
+ testAiEndpoint: (config) => ipcRenderer.invoke('ai:test', config),
50
+ cancelAiRequest: () => ipcRenderer.invoke('ai:cancel'),
51
+ aiApiKeyStatus: () => ipcRenderer.invoke('aiKey:status'),
52
+ saveAiApiKey: (key) => ipcRenderer.invoke('aiKey:save', key),
53
+ clearAiApiKey: () => ipcRenderer.invoke('aiKey:clear'),
31
54
  listLocalDirectory: (path) => ipcRenderer.invoke('fs:listLocal', path),
55
+ appVersion: () => ipcRenderer.invoke('app:version'),
32
56
  localHome: () => ipcRenderer.invoke('fs:localHome'),
57
+ wslHome: (distribution) => ipcRenderer.invoke('fs:wslHome', distribution),
33
58
  createLocalDirectory: (path) => ipcRenderer.invoke('fs:mkdirLocal', path),
34
59
  renameLocalEntry: (from, to) => ipcRenderer.invoke('fs:renameLocal', from, to),
35
60
  removeLocalEntry: (path, kind) => ipcRenderer.invoke('fs:removeLocal', path, kind),
@@ -47,6 +72,10 @@ const api = {
47
72
  ipcRenderer.on('sftp:event', listener);
48
73
  return () => ipcRenderer.removeListener('sftp:event', listener);
49
74
  },
75
+ speechApiKeyStatus: () => ipcRenderer.invoke('speechKey:status'),
76
+ saveSpeechApiKey: (key) => ipcRenderer.invoke('speechKey:save', key),
77
+ clearSpeechApiKey: () => ipcRenderer.invoke('speechKey:clear'),
78
+ readSpeechApiKey: () => ipcRenderer.invoke('speechKey:read'),
50
79
  openExternal: (url) => ipcRenderer.invoke('links:openExternal', url),
51
80
  onLinkRefused: (callback) => {
52
81
  const listener = (_event, reason) => callback(reason);