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,148 @@
1
+ // The request to an OpenAI-compatible endpoint.
2
+ //
3
+ // In the main process rather than the renderer, for two reasons. A renderer
4
+ // fetch is a cross-origin request, and Ollama refuses those unless
5
+ // OLLAMA_ORIGINS is set — so the most obvious local setup would fail for a
6
+ // reason the user cannot see. And the API key never has to enter the renderer at
7
+ // all: it is read here, used for one request, and not held.
8
+ //
9
+ // Bounded and cancellable, as CONTEXT.md asks of AI output capture: every call
10
+ // carries an AbortController and a timeout, so a server that accepts a
11
+ // connection and then says nothing cannot leave a dialog waiting forever.
12
+ import { AI_TIMEOUT_MS, ERROR_BODY_CHARS, buildSuggestionRequest, chatCompletionsUrl, modelsUrl, parseModelList, parseSuggestion } from './ai-protocol.js';
13
+ import { isSupportedEndpoint } from '../shared/endpoints.js';
14
+ export class AiService {
15
+ readApiKey;
16
+ fetchImpl;
17
+ timeoutMs;
18
+ /** The request in flight, so a new one supersedes it rather than racing it. */
19
+ inFlight = null;
20
+ constructor(options) {
21
+ this.readApiKey = options.readApiKey;
22
+ this.fetchImpl = options.fetch ?? ((url, init) => fetch(url, init));
23
+ this.timeoutMs = options.timeoutMs ?? AI_TIMEOUT_MS;
24
+ }
25
+ /** Abandon whatever is in flight. The dialog closing is a reason to. */
26
+ cancel() {
27
+ this.inFlight?.abort();
28
+ this.inFlight = null;
29
+ }
30
+ async suggest(config, request) {
31
+ requireEndpoint(config.baseUrl);
32
+ const body = buildSuggestionRequest({
33
+ prompt: request.prompt,
34
+ model: config.model,
35
+ context: request.context
36
+ });
37
+ // Only one suggestion is ever wanted at a time, and the old one's answer
38
+ // would arrive against a dialog that has moved on.
39
+ this.cancel();
40
+ const controller = new AbortController();
41
+ this.inFlight = controller;
42
+ try {
43
+ const payload = await this.send(chatCompletionsUrl(config.baseUrl), body, controller);
44
+ return parseSuggestion(payload);
45
+ }
46
+ finally {
47
+ if (this.inFlight === controller)
48
+ this.inFlight = null;
49
+ }
50
+ }
51
+ async listModels(baseUrl) {
52
+ requireEndpoint(baseUrl);
53
+ const controller = new AbortController();
54
+ const payload = await this.send(modelsUrl(baseUrl), undefined, controller);
55
+ return parseModelList(payload);
56
+ }
57
+ /**
58
+ * Ask the endpoint for one token, and report what happened in a sentence.
59
+ *
60
+ * A real completion rather than a reachability check: a server can accept a
61
+ * connection, list models, and still refuse to run the model that is
62
+ * configured. That is the failure worth catching before the user needs it.
63
+ */
64
+ async test(config) {
65
+ try {
66
+ requireEndpoint(config.baseUrl);
67
+ if (!config.model.trim())
68
+ throw new Error('Choose a model first.');
69
+ const controller = new AbortController();
70
+ const payload = await this.send(chatCompletionsUrl(config.baseUrl), { model: config.model.trim(), max_tokens: 8, messages: [{ role: 'user', content: 'Reply with: ok' }] }, controller);
71
+ const suggestion = parseSuggestion(payload);
72
+ // A reply that did not parse is still a working endpoint: the test is
73
+ // whether the model answered, not whether it answered in the shape a
74
+ // suggestion needs.
75
+ return { ok: true, message: `${config.model} replied${suggestion.explanation ? `: ${trim(suggestion.explanation)}` : '.'}` };
76
+ }
77
+ catch (error) {
78
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
79
+ }
80
+ }
81
+ async send(url, body, controller) {
82
+ const key = await this.readApiKey();
83
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
84
+ try {
85
+ const response = await this.fetchImpl(url, {
86
+ method: body === undefined ? 'GET' : 'POST',
87
+ headers: {
88
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
89
+ // Only when there is one: a local Ollama wants no header at all, and
90
+ // an empty Bearer is worse than none.
91
+ ...(key ? { Authorization: `Bearer ${key}` } : {})
92
+ },
93
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
94
+ signal: controller.signal
95
+ });
96
+ if (!response.ok)
97
+ throw new Error(await describeFailure(response));
98
+ try {
99
+ return await response.json();
100
+ }
101
+ catch {
102
+ // A server that answers 200 with HTML is usually a proxy or a wrong
103
+ // path, and saying so beats "Unexpected token <".
104
+ throw new Error('The endpoint answered with something other than JSON. Check the base URL ends in /v1.');
105
+ }
106
+ }
107
+ catch (error) {
108
+ if (error instanceof Error && error.name === 'AbortError') {
109
+ throw new Error(`The endpoint did not answer within ${Math.round(this.timeoutMs / 1000)}s.`);
110
+ }
111
+ if (error instanceof TypeError) {
112
+ // What fetch throws when nothing is listening, with a message that names
113
+ // no host and helps nobody.
114
+ throw new Error(`Could not reach ${url}. Is the server running?`);
115
+ }
116
+ throw error;
117
+ }
118
+ finally {
119
+ clearTimeout(timer);
120
+ }
121
+ }
122
+ }
123
+ function requireEndpoint(baseUrl) {
124
+ if (!isSupportedEndpoint(baseUrl)) {
125
+ throw new Error('Set an http(s) base URL in Settings, such as http://127.0.0.1:11434/v1.');
126
+ }
127
+ }
128
+ /**
129
+ * A failing response, as a sentence.
130
+ *
131
+ * The status alone does not distinguish a wrong key from a missing model, and
132
+ * these servers put the difference in the body — so the body is read, trimmed,
133
+ * and quoted.
134
+ */
135
+ async function describeFailure(response) {
136
+ const detail = await response.text().then(trim).catch(() => '');
137
+ if (response.status === 401 || response.status === 403) {
138
+ return `The endpoint refused the key (${response.status}).${detail ? ` ${detail}` : ''}`;
139
+ }
140
+ if (response.status === 404) {
141
+ return `Not found (404). Check the base URL ends in /v1 and the model exists.${detail ? ` ${detail}` : ''}`;
142
+ }
143
+ return `The endpoint returned ${response.status}.${detail ? ` ${detail}` : ''}`;
144
+ }
145
+ function trim(text) {
146
+ const clean = text.replace(/\s+/g, ' ').trim();
147
+ return clean.length > ERROR_BODY_CHARS ? `${clean.slice(0, ERROR_BODY_CHARS - 1)}…` : clean;
148
+ }
@@ -0,0 +1,231 @@
1
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ const SCHEMA_VERSION = 1;
5
+ /**
6
+ * How many commands to keep.
7
+ *
8
+ * Large enough that a month of work fits, small enough that the whole file is
9
+ * read, ranked and written in memory without anyone noticing. JSON per
10
+ * CONTEXT.md's answered question 7; SQLite remains the option that doc says it
11
+ * is, if this ceiling ever becomes the thing that hurts.
12
+ */
13
+ const MAX_ENTRIES = 5000;
14
+ const MAX_COMMAND_CHARS = 1000;
15
+ /**
16
+ * The command history on disk.
17
+ *
18
+ * The only ZeroG store that holds what the user typed, which is why it is off
19
+ * until asked for and why nothing reaches it that has not been through
20
+ * command-redaction. That check runs in the renderer, next to the capture; this
21
+ * side enforces the shape and the ceiling.
22
+ */
23
+ export class CommandHistoryStore {
24
+ filePath;
25
+ file = { version: SCHEMA_VERSION, entries: [] };
26
+ loaded = false;
27
+ writeQueue = Promise.resolve();
28
+ constructor(options) {
29
+ this.filePath = options.filePath;
30
+ this.now = options.now ?? (() => new Date());
31
+ }
32
+ now;
33
+ async list() {
34
+ await this.ensureLoaded();
35
+ return this.file.entries.map((entry) => ({ ...entry }));
36
+ }
37
+ /**
38
+ * Record a run, or note another one of something already known.
39
+ *
40
+ * Upserted on command *and* directory, so `runs` counts what it says it does
41
+ * and the directory a command belongs to is not overwritten by the next place
42
+ * it happens to be typed.
43
+ */
44
+ async record(input) {
45
+ await this.ensureLoaded();
46
+ const command = safeText(input.command, MAX_COMMAND_CHARS);
47
+ if (!command)
48
+ return null;
49
+ const cwd = optionalText(input.cwd, 1024);
50
+ const existing = this.file.entries.find((entry) => entry.command === command && entry.cwd === cwd);
51
+ const lastRun = this.now().toISOString();
52
+ if (existing) {
53
+ existing.runs += 1;
54
+ existing.lastRun = lastRun;
55
+ // The latest outcome replaces the last, including replacing a known status
56
+ // with none: a command that used to work and now reports nothing should
57
+ // not keep claiming success.
58
+ if (input.exitCode === undefined)
59
+ delete existing.exitCode;
60
+ else
61
+ existing.exitCode = input.exitCode;
62
+ await this.persist();
63
+ return { ...existing };
64
+ }
65
+ const entry = {
66
+ id: `cmd:${randomUUID()}`,
67
+ command,
68
+ ...(cwd ? { cwd } : {}),
69
+ ...(optionalText(input.host, 256) ? { host: String(input.host) } : {}),
70
+ ...(input.kind === 'ssh' || input.kind === 'local' ? { kind: input.kind } : {}),
71
+ ...(input.exitCode === undefined ? {} : { exitCode: input.exitCode }),
72
+ lastRun,
73
+ runs: 1,
74
+ picks: 0
75
+ };
76
+ this.file.entries.push(entry);
77
+ this.evict();
78
+ await this.persist();
79
+ return { ...entry };
80
+ }
81
+ /**
82
+ * Note that an entry was chosen from the palette.
83
+ *
84
+ * McFly's most useful signal, and the reason the ranking improves with use:
85
+ * being picked says more about what someone wants than being run does, because
86
+ * running happens by habit and picking happens on purpose.
87
+ */
88
+ async pick(id) {
89
+ await this.ensureLoaded();
90
+ const entry = this.file.entries.find((candidate) => candidate.id === id);
91
+ if (!entry)
92
+ return;
93
+ entry.picks += 1;
94
+ entry.lastRun = this.now().toISOString();
95
+ await this.persist();
96
+ }
97
+ /** Forget everything, and take the file with it. */
98
+ async clear() {
99
+ this.loaded = true;
100
+ this.file = { version: SCHEMA_VERSION, entries: [] };
101
+ // Emptied *and* removed: a file left holding `{"entries":[]}` looks like a
102
+ // feature still running, and "clear my history" should leave nothing behind.
103
+ this.writeQueue = this.writeQueue.then(async () => {
104
+ try {
105
+ await unlink(this.filePath);
106
+ }
107
+ catch {
108
+ /* never existed */
109
+ }
110
+ });
111
+ await this.writeQueue;
112
+ }
113
+ /**
114
+ * Drop the least valuable entries once over the ceiling.
115
+ *
116
+ * Least recently run goes first, but anything ever picked from the palette is
117
+ * kept ahead of anything never picked: a command someone deliberately chose
118
+ * three weeks ago is worth more than one that scrolled past yesterday.
119
+ */
120
+ evict() {
121
+ if (this.file.entries.length <= MAX_ENTRIES)
122
+ return;
123
+ const ranked = [...this.file.entries].sort((a, b) => {
124
+ if ((a.picks > 0) !== (b.picks > 0))
125
+ return a.picks > 0 ? -1 : 1;
126
+ return b.lastRun.localeCompare(a.lastRun);
127
+ });
128
+ this.file.entries = ranked.slice(0, MAX_ENTRIES);
129
+ }
130
+ async ensureLoaded() {
131
+ if (this.loaded)
132
+ return;
133
+ this.loaded = true;
134
+ try {
135
+ const parsed = JSON.parse(await readFile(this.filePath, 'utf8'));
136
+ const normalized = normalizeFile(parsed);
137
+ if (normalized)
138
+ this.file = normalized;
139
+ }
140
+ catch {
141
+ this.file = { version: SCHEMA_VERSION, entries: [] };
142
+ }
143
+ }
144
+ async persist() {
145
+ const snapshot = this.file;
146
+ this.writeQueue = this.writeQueue.then(async () => {
147
+ try {
148
+ await mkdir(dirname(this.filePath), { recursive: true });
149
+ const temp = join(dirname(this.filePath), `.command-history.tmp-${process.pid}-${randomUUID()}`);
150
+ await writeFile(temp, JSON.stringify(snapshot, null, 1), { encoding: 'utf8', mode: 0o600 });
151
+ await rename(temp, this.filePath);
152
+ }
153
+ catch {
154
+ // History must never affect terminal operation.
155
+ }
156
+ });
157
+ await this.writeQueue;
158
+ }
159
+ }
160
+ /** Strip control characters and cap length; the file is user-editable. */
161
+ function safeText(value, limit) {
162
+ if (typeof value !== 'string')
163
+ return '';
164
+ return value.replace(CONTROL_CHARACTERS, '').trim().slice(0, limit);
165
+ }
166
+ function optionalText(value, limit) {
167
+ const text = safeText(value, limit);
168
+ return text ? text : undefined;
169
+ }
170
+ // Built from character codes rather than a regex literal: a control character
171
+ // inside a literal is invisible in the source and easily destroyed by a later
172
+ // edit. remote-screens.ts does the same, for the same reason.
173
+ const CONTROL_CHARACTERS = new RegExp('[' + String.fromCharCode(0) + '-' + String.fromCharCode(31) + String.fromCharCode(127) + ']', 'g');
174
+ function normalizeEntry(value) {
175
+ if (!value || typeof value !== 'object')
176
+ return undefined;
177
+ const item = value;
178
+ const id = safeText(item.id, 64);
179
+ const command = safeText(item.command, MAX_COMMAND_CHARS);
180
+ const lastRun = safeText(item.lastRun, 40);
181
+ if (!id || !command || !Number.isFinite(Date.parse(lastRun)))
182
+ return undefined;
183
+ const entry = {
184
+ id,
185
+ command,
186
+ lastRun: new Date(Date.parse(lastRun)).toISOString(),
187
+ runs: count(item.runs, 1),
188
+ picks: count(item.picks, 0)
189
+ };
190
+ const cwd = optionalText(item.cwd, 1024);
191
+ const host = optionalText(item.host, 256);
192
+ if (cwd)
193
+ entry.cwd = cwd;
194
+ if (host)
195
+ entry.host = host;
196
+ if (item.kind === 'ssh' || item.kind === 'local')
197
+ entry.kind = item.kind;
198
+ if (typeof item.exitCode === 'number' && Number.isInteger(item.exitCode) && item.exitCode >= 0 && item.exitCode <= 255) {
199
+ entry.exitCode = item.exitCode;
200
+ }
201
+ return entry;
202
+ }
203
+ function count(value, fallback) {
204
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0)
205
+ return fallback;
206
+ return Math.min(value, Number.MAX_SAFE_INTEGER);
207
+ }
208
+ export function normalizeFile(value) {
209
+ if (!value || typeof value !== 'object')
210
+ return undefined;
211
+ const item = value;
212
+ if (item.version !== SCHEMA_VERSION)
213
+ return undefined;
214
+ if (!Array.isArray(item.entries))
215
+ return undefined;
216
+ const entries = [];
217
+ const seen = new Set();
218
+ for (const raw of item.entries) {
219
+ const entry = normalizeEntry(raw);
220
+ if (!entry || seen.has(entry.id))
221
+ continue;
222
+ seen.add(entry.id);
223
+ entries.push(entry);
224
+ if (entries.length >= MAX_ENTRIES)
225
+ break;
226
+ }
227
+ return { version: SCHEMA_VERSION, entries };
228
+ }
229
+ export function defaultCommandHistoryPath(userDataPath) {
230
+ return join(userDataPath, 'command-history.json');
231
+ }
@@ -1,21 +1,40 @@
1
- import { app, BrowserWindow, clipboard, ipcMain, Menu, session, shell } from 'electron';
1
+ import { app, BrowserWindow, clipboard, ipcMain, Menu, safeStorage, session, shell } from 'electron';
2
2
  import { join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { writeClipboardText } from './clipboard.js';
5
5
  import { ScreenService, parseWslDistributions } from './session-service.js';
6
6
  import { discoverShellBackends } from './shell-catalog.js';
7
7
  import { SessionHistoryStore, defaultHistoryPath } from './session-history.js';
8
+ import { CommandHistoryStore, defaultCommandHistoryPath } from './command-history-store.js';
9
+ import { WorkspaceStore, defaultWorkspacePath } from './workspace-store.js';
10
+ import { PortForwardService } from './port-forward-service.js';
11
+ import { PortForwardStore, defaultPortForwardPath } from './port-forward-store.js';
8
12
  import { buildRemoteScreenAttachArgs, buildRemoteScreenDiscoveryArgs, listKnownConnections, parseRemoteScreenList, validateKnownConnection } from './ssh-inventory.js';
9
13
  import { createLocalDirectory, listLocalDirectory, localHome, removeLocalEntry, renameLocalEntry } from './local-fs.js';
14
+ import { wslHomeDirectory } from './wsl-home.js';
10
15
  import { decideExternalLink, isApplicationUrl } from './external-links.js';
11
16
  import { SftpService } from './sftp-service.js';
17
+ import { AI_API_KEY, SPEECH_API_KEY, SecretStore, defaultSecretsPath } from './secret-store.js';
18
+ import { AiService } from './ai-service.js';
12
19
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
13
20
  const history = new SessionHistoryStore({ filePath: defaultHistoryPath(app.getPath('userData')) });
21
+ // The only store holding what the user typed. Nothing reaches it that has not
22
+ // been through command-redaction in the renderer, and it holds nothing at all
23
+ // until the setting is turned on.
24
+ const commands = new CommandHistoryStore({ filePath: defaultCommandHistoryPath(app.getPath('userData')) });
25
+ const workspaceStore = new WorkspaceStore({ filePath: defaultWorkspacePath(app.getPath('userData')) });
26
+ const forwardStore = new PortForwardStore({ filePath: defaultPortForwardPath(app.getPath('userData')) });
27
+ // Tunnels outlive the Ports view being closed: authenticating again is a real
28
+ // cost to pay for having looked away.
29
+ const forwards = new PortForwardService({ onEvent: (event) => win?.webContents.send('forwards:event', event) });
14
30
  const service = new ScreenService({ onEvent: (event, session, available) => { void history.record(event, session, available); } });
15
31
  let win;
16
32
  // Transfer connections outlive any single panel opening, so the panel can be
17
33
  // closed and reopened without re-authenticating to the host.
18
34
  const sftp = new SftpService({ onEvent: (event) => win?.webContents.send('sftp:event', event) });
35
+ // API keys for speech servers. safeStorage is only usable after the app is
36
+ // ready, which every IPC call here already is.
37
+ const secrets = new SecretStore({ filePath: defaultSecretsPath(app.getPath('userData')), crypto: safeStorage });
19
38
  /** A pane's measured size, as it arrives from the renderer. */
20
39
  function parsePtySize(value) {
21
40
  if (!value || typeof value !== 'object')
@@ -91,6 +110,9 @@ function createWindow() {
91
110
  win = undefined;
92
111
  service.detachAll();
93
112
  sftp.closeAll();
113
+ // A tunnel is a listening socket on someone's machine. It does not outlive
114
+ // the window that opened it.
115
+ forwards.closeAll();
94
116
  });
95
117
  }
96
118
  function isRecord(value) {
@@ -128,6 +150,18 @@ ipcMain.handle('links:openExternal', (_event, url) => {
128
150
  ipcMain.handle('sessions:list', () => service.list());
129
151
  ipcMain.handle('sessions:history', () => history.list());
130
152
  ipcMain.handle('sessions:historyRemove', (_event, entryId) => history.remove(entryId));
153
+ ipcMain.handle('commands:list', () => commands.list());
154
+ ipcMain.handle('commands:record', (_event, record) => commands.record(requireCommandRecord(record)));
155
+ ipcMain.handle('commands:pick', (_event, id) => commands.pick(requireString(id, 'A command')));
156
+ ipcMain.handle('commands:clear', () => commands.clear());
157
+ ipcMain.handle('forwards:list', () => forwards.list());
158
+ ipcMain.handle('forwards:open', (_event, request) => forwards.open(requireForwardRequest(request)));
159
+ ipcMain.handle('forwards:close', (_event, id) => forwards.close(requireString(id, 'A shared port')));
160
+ ipcMain.handle('forwards:answerPrompt', (_event, id, answer) => forwards.answerPrompt(requireString(id, 'A shared port'), requireString(answer, 'An answer')));
161
+ ipcMain.handle('forwards:load', () => forwardStore.load());
162
+ ipcMain.handle('forwards:save', (_event, file) => forwardStore.save(file));
163
+ ipcMain.handle('workspaces:load', () => workspaceStore.load());
164
+ ipcMain.handle('workspaces:save', (_event, file) => workspaceStore.save(file));
131
165
  ipcMain.handle('sessions:backends', () => discoverShellBackends());
132
166
  ipcMain.handle('sessions:wslDistributions', async () => {
133
167
  try {
@@ -211,12 +245,100 @@ function requireString(value, field) {
211
245
  throw new Error(`${field} is required.`);
212
246
  return value;
213
247
  }
248
+ /**
249
+ * A captured command, shaped.
250
+ *
251
+ * Only the shape is checked here. Whether the text is safe to store was decided
252
+ * in the renderer by command-redaction, next to the capture that produced it —
253
+ * that is where the rules and their tests live.
254
+ */
255
+ function requireCommandRecord(value) {
256
+ if (!isRecord(value))
257
+ throw new Error('A command is required.');
258
+ const text = (field) => (typeof field === 'string' && field ? field : undefined);
259
+ return {
260
+ command: requireString(value.command, 'A command'),
261
+ ...(text(value.cwd) ? { cwd: String(value.cwd) } : {}),
262
+ ...(text(value.host) ? { host: String(value.host) } : {}),
263
+ ...(value.kind === 'ssh' || value.kind === 'local' ? { kind: value.kind } : {}),
264
+ ...(typeof value.exitCode === 'number' ? { exitCode: value.exitCode } : {})
265
+ };
266
+ }
267
+ function requireAiConfig(value) {
268
+ if (!isRecord(value))
269
+ throw new Error('An AI endpoint is required.');
270
+ return {
271
+ baseUrl: typeof value.baseUrl === 'string' ? value.baseUrl : '',
272
+ model: typeof value.model === 'string' ? value.model : ''
273
+ };
274
+ }
275
+ /**
276
+ * A suggestion request, shaped.
277
+ *
278
+ * The prompt and the context are checked for type here and for content by
279
+ * buildSuggestionRequest, which is where those rules are tested. The captured
280
+ * output is deliberately not inspected: it is untrusted by design, and
281
+ * ai-protocol is what makes it safe to include.
282
+ */
283
+ function requireSuggestionRequest(value) {
284
+ if (!isRecord(value))
285
+ throw new Error('A request is required.');
286
+ const context = isRecord(value.context) ? value.context : {};
287
+ const text = (field) => (typeof field === 'string' && field ? field : undefined);
288
+ return {
289
+ prompt: typeof value.prompt === 'string' ? value.prompt : '',
290
+ ...(text(value.sessionId) ? { sessionId: String(value.sessionId) } : {}),
291
+ context: {
292
+ ...(text(context.shell) ? { shell: String(context.shell) } : {}),
293
+ ...(text(context.cwd) ? { cwd: String(context.cwd) } : {}),
294
+ ...(text(context.host) ? { host: String(context.host) } : {}),
295
+ ...(context.kind === 'ssh' || context.kind === 'local' ? { kind: context.kind } : {}),
296
+ ...(text(context.output) ? { output: String(context.output) } : {})
297
+ }
298
+ };
299
+ }
300
+ /**
301
+ * A forwarding request, shaped.
302
+ *
303
+ * Only the shape is checked here: the values are vetted by buildForwardArgs,
304
+ * which is where the rules live and where they are tested, and which throws a
305
+ * sentence the renderer can show as-is.
306
+ */
307
+ function requireForwardRequest(value) {
308
+ if (!isRecord(value))
309
+ throw new Error('A shared port is required.');
310
+ return {
311
+ target: requireString(value.target, 'An SSH target'),
312
+ direction: value.direction,
313
+ bind: value.bind,
314
+ listenPort: value.listenPort,
315
+ destinationPort: value.destinationPort,
316
+ ...(typeof value.destinationHost === 'string' && value.destinationHost ? { destinationHost: value.destinationHost } : {}),
317
+ ...(typeof value.id === 'string' && value.id ? { id: value.id } : {})
318
+ };
319
+ }
214
320
  function requireEntryKind(value) {
215
321
  if (value === 'file' || value === 'directory' || value === 'symlink')
216
322
  return value;
217
323
  throw new Error('An entry kind of file, directory, or symlink is required.');
218
324
  }
325
+ // Electron's own reading of package.json, so the title bar shows what is
326
+ // actually running rather than a string compiled in from the repo.
327
+ ipcMain.handle('app:version', () => app.getVersion());
219
328
  ipcMain.handle('fs:localHome', () => localHome());
329
+ // Cached on success only, so a distribution that was not running when it was
330
+ // first asked can answer later.
331
+ const wslHomes = new Map();
332
+ ipcMain.handle('fs:wslHome', async (_event, distribution) => {
333
+ const name = typeof distribution === 'string' ? distribution : '';
334
+ const known = wslHomes.get(name);
335
+ if (known)
336
+ return known;
337
+ const home = await wslHomeDirectory(name);
338
+ if (home)
339
+ wslHomes.set(name, home);
340
+ return home;
341
+ });
220
342
  ipcMain.handle('fs:listLocal', (_event, path) => listLocalDirectory(typeof path === 'string' && path ? path : undefined));
221
343
  ipcMain.handle('fs:mkdirLocal', (_event, path) => createLocalDirectory(requireString(path, 'A folder path')));
222
344
  ipcMain.handle('fs:renameLocal', (_event, from, to) => renameLocalEntry(requireString(from, 'The current path'), requireString(to, 'The new path')));
@@ -236,10 +358,39 @@ ipcMain.handle('sftp:answerPrompt', (_event, id, answer) => {
236
358
  sftp.answerPrompt(requireString(id, 'A transfer connection'), answer);
237
359
  });
238
360
  ipcMain.handle('sftp:close', (_event, id) => sftp.close(requireString(id, 'A transfer connection')));
239
- ipcMain.handle('ai:suggest', () => ({
240
- command: 'git status --short',
241
- explanation: 'Read-only preview of changed files in the active workspace.'
242
- }));
361
+ // The key is read here, per request, and never handed to the renderer.
362
+ const ai = new AiService({ readApiKey: () => secrets.get(AI_API_KEY) });
363
+ ipcMain.handle('ai:suggest', (_event, config, request) => ai.suggest(requireAiConfig(config), requireSuggestionRequest(request)));
364
+ ipcMain.handle('ai:models', (_event, baseUrl) => ai.listModels(requireString(baseUrl, 'A base URL')));
365
+ ipcMain.handle('ai:test', (_event, config) => ai.test(requireAiConfig(config)));
366
+ ipcMain.handle('ai:cancel', () => ai.cancel());
367
+ async function aiKeyStatus() {
368
+ return { stored: await secrets.has(AI_API_KEY), encryptionAvailable: secrets.encryptionAvailable() };
369
+ }
370
+ ipcMain.handle('aiKey:status', () => aiKeyStatus());
371
+ ipcMain.handle('aiKey:save', async (_event, key) => {
372
+ await secrets.set(AI_API_KEY, typeof key === 'string' ? key.trim() : '');
373
+ return aiKeyStatus();
374
+ });
375
+ ipcMain.handle('aiKey:clear', async () => {
376
+ await secrets.clear(AI_API_KEY);
377
+ return aiKeyStatus();
378
+ });
379
+ async function speechKeyStatus() {
380
+ return { stored: await secrets.has(SPEECH_API_KEY), encryptionAvailable: secrets.encryptionAvailable() };
381
+ }
382
+ ipcMain.handle('speechKey:status', () => speechKeyStatus());
383
+ ipcMain.handle('speechKey:save', async (_event, key) => {
384
+ // An empty key means "clear", which SecretStore already does — but the
385
+ // string still has to be a string, and a pasted key often carries newlines.
386
+ await secrets.set(SPEECH_API_KEY, typeof key === 'string' ? key.trim() : '');
387
+ return speechKeyStatus();
388
+ });
389
+ ipcMain.handle('speechKey:clear', async () => {
390
+ await secrets.clear(SPEECH_API_KEY);
391
+ return speechKeyStatus();
392
+ });
393
+ ipcMain.handle('speechKey:read', () => secrets.get(SPEECH_API_KEY));
243
394
  app.whenReady().then(() => {
244
395
  // Voice input captures the local microphone only. Electron denies all
245
396
  // permission requests unless a handler answers, so grant media explicitly.