zerogterm 0.9.0-alpha.1 → 0.9.2-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -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 +122 -104
- 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-BovKjPi0.css +0 -1
- package/dist/renderer/assets/index-DYB0NFWF.js +0 -142
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());
|
|
@@ -381,61 +428,64 @@ ipcMain.handle('mcp:execution:cancel', (_event, requestId) => {
|
|
|
381
428
|
win?.webContents.send('mcp:execution', result);
|
|
382
429
|
return result;
|
|
383
430
|
});
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
clearMcpExecution(requestId, session.id);
|
|
402
|
-
win?.webContents.send('mcp:execution', result);
|
|
403
|
-
}
|
|
431
|
+
function createMcpProviders() {
|
|
432
|
+
return {
|
|
433
|
+
listWorkspaces: () => workspaceStore.load(),
|
|
434
|
+
listSessions: () => service.list(),
|
|
435
|
+
createWorkspace,
|
|
436
|
+
restoreWorkspace,
|
|
437
|
+
createSshSession: async (request) => {
|
|
438
|
+
const session = await service.createSsh(request.target, request.name);
|
|
439
|
+
const attached = service.attach(session.id, (data) => {
|
|
440
|
+
const requestId = mcpActiveExecutions.get(session.id);
|
|
441
|
+
if (requestId) {
|
|
442
|
+
const output = `${mcpExecutionOutput.get(requestId) ?? ''}${data}`.slice(0, 16 * 1024);
|
|
443
|
+
mcpExecutionOutput.set(requestId, output);
|
|
444
|
+
if ((output.match(/ExitCode=\d+/g) ?? []).length >= 2) {
|
|
445
|
+
const result = mcpExecutions.finishRunning(requestId, 'completed', output, false, 'Command completed.');
|
|
446
|
+
clearMcpExecution(requestId, session.id);
|
|
447
|
+
win?.webContents.send('mcp:execution', result);
|
|
404
448
|
}
|
|
405
|
-
win?.webContents.send('terminal:data', session.id, data);
|
|
406
|
-
}, (message) => win?.webContents.send('terminal:status', session.id, message));
|
|
407
|
-
win?.webContents.send('mcp:ssh-session-created', { session: attached });
|
|
408
|
-
return attached;
|
|
409
|
-
},
|
|
410
|
-
createProjectWorkspace,
|
|
411
|
-
requestCommand: async (request) => {
|
|
412
|
-
const sessions = await service.list();
|
|
413
|
-
const session = sessions.find((item) => item.id === request.sessionId);
|
|
414
|
-
if (!session)
|
|
415
|
-
throw new Error('Unknown session.');
|
|
416
|
-
const result = mcpExecutions.create({ clientId: request.clientId, sessionId: request.sessionId, sessionKind: session.kind, command: request.command, policy: { allowRemoteSessions: session.kind === 'ssh', ...(request.timeoutMs === undefined ? {} : { maxRuntimeMs: request.timeoutMs }), ...(request.outputBytes === undefined ? {} : { maxOutputBytes: request.outputBytes }) } });
|
|
417
|
-
const safeRemote = session.kind === 'ssh' && isSafeRemoteCommand(result.command);
|
|
418
|
-
if (safeRemote) {
|
|
419
|
-
const approved = mcpExecutions.approveAutomatically(result.requestId);
|
|
420
|
-
mcpAudit.record({ at: Date.now(), requestId: approved.requestId, clientId: approved.clientId, capability: 'session:execute', sessionId: approved.sessionId, state: 'approved', command: approved.displayCommand });
|
|
421
|
-
await runMcpExecution(approved.requestId);
|
|
422
|
-
return mcpExecutions.get(approved.requestId, request.clientId);
|
|
423
449
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
450
|
+
win?.webContents.send('terminal:data', session.id, data);
|
|
451
|
+
}, (message) => win?.webContents.send('terminal:status', session.id, message));
|
|
452
|
+
win?.webContents.send('mcp:ssh-session-created', { session: attached });
|
|
453
|
+
return attached;
|
|
454
|
+
},
|
|
455
|
+
createProjectWorkspace,
|
|
456
|
+
requestCommand: async (request) => {
|
|
457
|
+
const sessions = await service.list();
|
|
458
|
+
const session = sessions.find((item) => item.id === request.sessionId);
|
|
459
|
+
if (!session)
|
|
460
|
+
throw new Error('Unknown session.');
|
|
461
|
+
const result = mcpExecutions.create({ clientId: request.clientId, sessionId: request.sessionId, sessionKind: session.kind, command: request.command, policy: { allowRemoteSessions: session.kind === 'ssh', ...(request.timeoutMs === undefined ? {} : { maxRuntimeMs: request.timeoutMs }), ...(request.outputBytes === undefined ? {} : { maxOutputBytes: request.outputBytes }) } });
|
|
462
|
+
const safeRemote = session.kind === 'ssh' && isSafeRemoteCommand(result.command);
|
|
463
|
+
if (safeRemote) {
|
|
464
|
+
const approved = mcpExecutions.approveAutomatically(result.requestId);
|
|
465
|
+
mcpAudit.record({ at: Date.now(), requestId: approved.requestId, clientId: approved.clientId, capability: 'session:execute', sessionId: approved.sessionId, state: 'approved', command: approved.displayCommand });
|
|
466
|
+
await runMcpExecution(approved.requestId);
|
|
467
|
+
return mcpExecutions.get(approved.requestId, request.clientId);
|
|
437
468
|
}
|
|
469
|
+
mcpAudit.record({ at: Date.now(), requestId: result.requestId, clientId: result.clientId, capability: 'session:execute', sessionId: result.sessionId, state: 'requested', command: result.displayCommand });
|
|
470
|
+
win?.webContents.send('mcp:execution', result);
|
|
471
|
+
return result;
|
|
438
472
|
},
|
|
473
|
+
getCommandResult: async (request) => mcpExecutions.get(request.requestId, request.clientId),
|
|
474
|
+
cancelCommand: async (request) => {
|
|
475
|
+
const current = mcpExecutions.get(request.requestId, request.clientId);
|
|
476
|
+
const result = mcpExecutions.cancel(request.requestId, request.clientId);
|
|
477
|
+
const session = 'sessionId' in current ? (await service.list()).find((item) => item.id === current.sessionId) : undefined;
|
|
478
|
+
if (session?.kind === 'local')
|
|
479
|
+
service.write(session.id, '\u0003');
|
|
480
|
+
clearMcpExecution(request.requestId);
|
|
481
|
+
return result;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
ipcMain.handle('mcp:start', async () => {
|
|
486
|
+
mcpHost ??= new McpServerHost({
|
|
487
|
+
control: mcpControl,
|
|
488
|
+
providers: createMcpProviders(),
|
|
439
489
|
version: app.getVersion()
|
|
440
490
|
});
|
|
441
491
|
return mcpHost.start();
|
|
@@ -521,6 +571,8 @@ ipcMain.handle('sessions:close', (_event, id) => {
|
|
|
521
571
|
if (typeof id !== 'string' || !id)
|
|
522
572
|
throw new Error('closeSession requires a session id');
|
|
523
573
|
service.close(id);
|
|
574
|
+
paneLinks.unregisterAgent(id);
|
|
575
|
+
void agentHooks.unregister(id);
|
|
524
576
|
});
|
|
525
577
|
ipcMain.on('terminal:write', (_event, sessionId, data) => {
|
|
526
578
|
if (typeof sessionId === 'string' && typeof data === 'string')
|
|
@@ -644,6 +696,14 @@ ipcMain.handle('fs:wslHome', async (_event, distribution) => {
|
|
|
644
696
|
ipcMain.handle('fs:listLocal', (_event, path) => listLocalDirectory(typeof path === 'string' && path ? path : undefined));
|
|
645
697
|
ipcMain.handle('fs:mkdirLocal', (_event, path) => createLocalDirectory(requireString(path, 'A folder path')));
|
|
646
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
|
+
});
|
|
647
707
|
ipcMain.handle('fs:removeLocal', (_event, path, kind) => removeLocalEntry(requireString(path, 'A path'), requireEntryKind(kind)));
|
|
648
708
|
ipcMain.handle('sftp:open', (_event, target, cwd) => sftp.open(requireString(target, 'An SSH target'), typeof cwd === 'string' && cwd ? cwd : undefined));
|
|
649
709
|
ipcMain.handle('sftp:list', (_event, id, path) => sftp.list(requireString(id, 'A transfer connection'), typeof path === 'string' && path ? path : undefined));
|
|
@@ -659,6 +719,12 @@ ipcMain.handle('sftp:answerPrompt', (_event, id, answer) => {
|
|
|
659
719
|
throw new Error('An answer is required.');
|
|
660
720
|
sftp.answerPrompt(requireString(id, 'A transfer connection'), answer);
|
|
661
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
|
+
});
|
|
662
728
|
ipcMain.handle('sftp:close', (_event, id) => sftp.close(requireString(id, 'A transfer connection')));
|
|
663
729
|
// The key is read here, per request, and never handed to the renderer.
|
|
664
730
|
const ai = new AiService({ readApiKey: () => secrets.get(AI_API_KEY) });
|
|
@@ -707,58 +773,7 @@ app.whenReady().then(() => {
|
|
|
707
773
|
if (process.env.ZEROG_MCP_ENABLED === '1') {
|
|
708
774
|
mcpHost ??= new McpServerHost({
|
|
709
775
|
control: mcpControl,
|
|
710
|
-
providers:
|
|
711
|
-
listWorkspaces: () => workspaceStore.load(),
|
|
712
|
-
listSessions: () => service.list(),
|
|
713
|
-
createWorkspace,
|
|
714
|
-
restoreWorkspace,
|
|
715
|
-
createSshSession: async (request) => {
|
|
716
|
-
const session = await service.createSsh(request.target, request.name);
|
|
717
|
-
const attached = service.attach(session.id, (data) => {
|
|
718
|
-
const requestId = mcpActiveExecutions.get(session.id);
|
|
719
|
-
if (requestId) {
|
|
720
|
-
const output = `${mcpExecutionOutput.get(requestId) ?? ''}${data}`.slice(0, 16 * 1024);
|
|
721
|
-
mcpExecutionOutput.set(requestId, output);
|
|
722
|
-
if ((output.match(/ExitCode=\d+/g) ?? []).length >= 2) {
|
|
723
|
-
const result = mcpExecutions.finishRunning(requestId, 'completed', output, false, 'Command completed.');
|
|
724
|
-
clearMcpExecution(requestId, session.id);
|
|
725
|
-
win?.webContents.send('mcp:execution', result);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
win?.webContents.send('terminal:data', session.id, data);
|
|
729
|
-
}, (message) => win?.webContents.send('terminal:status', session.id, message));
|
|
730
|
-
win?.webContents.send('mcp:ssh-session-created', { session: attached });
|
|
731
|
-
return attached;
|
|
732
|
-
},
|
|
733
|
-
createProjectWorkspace,
|
|
734
|
-
requestCommand: async (request) => {
|
|
735
|
-
const sessions = await service.list();
|
|
736
|
-
const session = sessions.find((item) => item.id === request.sessionId);
|
|
737
|
-
if (!session)
|
|
738
|
-
throw new Error('Unknown session.');
|
|
739
|
-
const result = mcpExecutions.create({ clientId: request.clientId, sessionId: request.sessionId, sessionKind: session.kind, command: request.command, policy: { allowRemoteSessions: session.kind === 'ssh', ...(request.timeoutMs === undefined ? {} : { maxRuntimeMs: request.timeoutMs }), ...(request.outputBytes === undefined ? {} : { maxOutputBytes: request.outputBytes }) } });
|
|
740
|
-
const safeRemote = session.kind === 'ssh' && isSafeRemoteCommand(result.command);
|
|
741
|
-
if (safeRemote) {
|
|
742
|
-
const approved = mcpExecutions.approveAutomatically(result.requestId);
|
|
743
|
-
mcpAudit.record({ at: Date.now(), requestId: approved.requestId, clientId: approved.clientId, capability: 'session:execute', sessionId: approved.sessionId, state: 'approved', command: approved.displayCommand });
|
|
744
|
-
await runMcpExecution(approved.requestId);
|
|
745
|
-
return mcpExecutions.get(approved.requestId, request.clientId);
|
|
746
|
-
}
|
|
747
|
-
mcpAudit.record({ at: Date.now(), requestId: result.requestId, clientId: result.clientId, capability: 'session:execute', sessionId: result.sessionId, state: 'requested', command: result.displayCommand });
|
|
748
|
-
win?.webContents.send('mcp:execution', result);
|
|
749
|
-
return result;
|
|
750
|
-
},
|
|
751
|
-
getCommandResult: async (request) => mcpExecutions.get(request.requestId, request.clientId),
|
|
752
|
-
cancelCommand: async (request) => {
|
|
753
|
-
const current = mcpExecutions.get(request.requestId, request.clientId);
|
|
754
|
-
const result = mcpExecutions.cancel(request.requestId, request.clientId);
|
|
755
|
-
const session = 'sessionId' in current ? (await service.list()).find((item) => item.id === current.sessionId) : undefined;
|
|
756
|
-
if (session?.kind === 'local')
|
|
757
|
-
service.write(session.id, '\u0003');
|
|
758
|
-
clearMcpExecution(request.requestId);
|
|
759
|
-
return result;
|
|
760
|
-
}
|
|
761
|
-
},
|
|
776
|
+
providers: createMcpProviders(),
|
|
762
777
|
version: app.getVersion()
|
|
763
778
|
});
|
|
764
779
|
void mcpHost.start();
|
|
@@ -768,6 +783,9 @@ app.whenReady().then(() => {
|
|
|
768
783
|
createWindow();
|
|
769
784
|
});
|
|
770
785
|
});
|
|
786
|
+
app.on('before-quit', () => {
|
|
787
|
+
void agentHooks.stop();
|
|
788
|
+
});
|
|
771
789
|
app.on('window-all-closed', () => {
|
|
772
790
|
if (process.platform !== 'darwin')
|
|
773
791
|
app.quit();
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Pane link: relay one agent's finished reply into another agent's prompt.
|
|
2
|
+
//
|
|
3
|
+
// This module is the decision logic only. It owns no sockets and no PTYs: the hook
|
|
4
|
+
// server feeds it what the agents report (a prompt was submitted, a turn finished, the
|
|
5
|
+
// agent needs attention, the session ended) and it answers by asking for text to be
|
|
6
|
+
// delivered to a session. Keeping it that small is what lets every safety rule below be
|
|
7
|
+
// exercised in a unit test with a fake clock.
|
|
8
|
+
//
|
|
9
|
+
// Why hook events and not terminal output: a throwaway prototype that guessed turn ends
|
|
10
|
+
// from a quiet timer and scraped the reply off the screen declared turns finished while
|
|
11
|
+
// the agent was still thinking and forwarded spinner glyphs and echoed prompts. The Stop
|
|
12
|
+
// hook reports the turn end exactly and carries the final message verbatim.
|
|
13
|
+
//
|
|
14
|
+
// Safety rules enforced here (CONTEXT.md: no autonomous agent loops without an explicit
|
|
15
|
+
// design):
|
|
16
|
+
// - A link exists only because the user asked for it, between two agents ZeroG itself
|
|
17
|
+
// launched, and it ends on the user's say-so, on the turn cap, or when either agent exits.
|
|
18
|
+
// - Text is only delivered to an agent that is idle. If the partner is busy the link
|
|
19
|
+
// pauses and holds the message; it never types into a running turn.
|
|
20
|
+
// - Relayed text is untrusted input to the receiving agent. It is stripped of control
|
|
21
|
+
// characters (an ESC would let a reply close the bracketed paste early and have the rest
|
|
22
|
+
// typed as keystrokes), size-capped, and labelled as coming from another agent.
|
|
23
|
+
// - A relay that never gets an answer (permission prompt left open, Esc interrupt, API
|
|
24
|
+
// error: none of which fire Stop) pauses after a stall timeout instead of waiting forever.
|
|
25
|
+
export const DEFAULT_RELAY_CAP = 6;
|
|
26
|
+
export const MAX_RELAY_CAP = 50;
|
|
27
|
+
export const DEFAULT_STALL_MS = 10 * 60 * 1000;
|
|
28
|
+
export const MAX_RELAY_CHARS = 16000;
|
|
29
|
+
const TRUNCATION_NOTE = '\n[Truncated by ZeroG pane link.]';
|
|
30
|
+
/** Session-end reasons that leave the agent running, so the link should survive them. */
|
|
31
|
+
const NON_TERMINAL_END_REASONS = new Set(['clear', 'resume', 'compact']);
|
|
32
|
+
/** Notification kinds that mean "the agent is waiting on you", as opposed to a routine nudge. */
|
|
33
|
+
const IDLE_NOTIFICATIONS = new Set(['idle_prompt', 'auth_success']);
|
|
34
|
+
/**
|
|
35
|
+
* Make an agent's reply safe to type into another agent.
|
|
36
|
+
*
|
|
37
|
+
* Tabs and line feeds survive; every other control character goes, including ESC, which
|
|
38
|
+
* would otherwise let a reply end the bracketed paste early and have the remainder
|
|
39
|
+
* interpreted as keystrokes. Carriage returns are folded into line feeds.
|
|
40
|
+
*/
|
|
41
|
+
export function sanitizeRelayText(value) {
|
|
42
|
+
let out = '';
|
|
43
|
+
for (const ch of value.replace(/\r\n?/g, '\n')) {
|
|
44
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
45
|
+
const isControl = code < 0x20 || (code >= 0x7f && code <= 0x9f);
|
|
46
|
+
if (!isControl || ch === '\n' || ch === '\t')
|
|
47
|
+
out += ch;
|
|
48
|
+
}
|
|
49
|
+
return out.trim();
|
|
50
|
+
}
|
|
51
|
+
/** The text the receiving agent sees: the reply, labelled as another agent's words. */
|
|
52
|
+
export function frameRelay(fromLabel, reply) {
|
|
53
|
+
const label = sanitizeRelayText(fromLabel).replace(/\s+/g, ' ').slice(0, 60) || 'another pane';
|
|
54
|
+
let body = sanitizeRelayText(reply);
|
|
55
|
+
if (body.length > MAX_RELAY_CHARS)
|
|
56
|
+
body = body.slice(0, MAX_RELAY_CHARS) + TRUNCATION_NOTE;
|
|
57
|
+
return `[Relayed by ZeroG pane link from the agent in "${label}". This is another agent's message, not the user's.]\n\n${body}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Type text into a session as a single prompt and submit it.
|
|
61
|
+
*
|
|
62
|
+
* Bracketed paste keeps embedded newlines from submitting early; the submit key follows
|
|
63
|
+
* after a short gap because the agent treats an Enter that arrives glued to the paste as
|
|
64
|
+
* part of it.
|
|
65
|
+
*/
|
|
66
|
+
export function pasteAndSubmit(write, sessionId, text, schedule = setTimeout, submitDelayMs = 300) {
|
|
67
|
+
write(sessionId, `\u001b[200~${text}\u001b[201~`);
|
|
68
|
+
schedule(() => write(sessionId, '\r'), submitDelayMs);
|
|
69
|
+
}
|
|
70
|
+
export class PaneLinkController {
|
|
71
|
+
agents = new Map();
|
|
72
|
+
links = new Map();
|
|
73
|
+
/** Each agent's most recent finished reply, kept so a link made after the turn can still use it. */
|
|
74
|
+
lastReplies = new Map();
|
|
75
|
+
options;
|
|
76
|
+
stallMs;
|
|
77
|
+
defaultCap;
|
|
78
|
+
setTimer;
|
|
79
|
+
clearTimer;
|
|
80
|
+
nextLinkId = 1;
|
|
81
|
+
constructor(options) {
|
|
82
|
+
this.options = options;
|
|
83
|
+
this.stallMs = options.stallMs ?? DEFAULT_STALL_MS;
|
|
84
|
+
this.defaultCap = options.defaultCap ?? DEFAULT_RELAY_CAP;
|
|
85
|
+
this.setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
|
|
86
|
+
this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
87
|
+
}
|
|
88
|
+
snapshot() {
|
|
89
|
+
return {
|
|
90
|
+
agents: Array.from(this.agents.values()).map((agent) => ({ ...agent })),
|
|
91
|
+
links: Array.from(this.links.values()).map((link) => this.describe(link))
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** An agent ZeroG launched in this session now reports hook events. */
|
|
95
|
+
registerAgent(sessionId, label) {
|
|
96
|
+
const existing = this.agents.get(sessionId);
|
|
97
|
+
if (existing) {
|
|
98
|
+
existing.label = label;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
this.agents.set(sessionId, { sessionId, label, phase: 'idle', needsAttention: false, hasReply: false });
|
|
102
|
+
}
|
|
103
|
+
this.emit();
|
|
104
|
+
}
|
|
105
|
+
/** The session is gone (pane closed, agent unregistered): end its link and forget it. */
|
|
106
|
+
unregisterAgent(sessionId) {
|
|
107
|
+
const agent = this.agents.get(sessionId);
|
|
108
|
+
if (!agent)
|
|
109
|
+
return;
|
|
110
|
+
if (agent.linkId)
|
|
111
|
+
this.breakLink(agent.linkId, `The agent in "${agent.label}" exited.`);
|
|
112
|
+
this.agents.delete(sessionId);
|
|
113
|
+
this.lastReplies.delete(sessionId);
|
|
114
|
+
this.emit();
|
|
115
|
+
}
|
|
116
|
+
/** Link two idle agents. Throws a user-readable message when the link is not allowed. */
|
|
117
|
+
link(aSessionId, bSessionId, cap) {
|
|
118
|
+
if (aSessionId === bSessionId)
|
|
119
|
+
throw new Error('Choose two different panes to link.');
|
|
120
|
+
const a = this.agents.get(aSessionId);
|
|
121
|
+
const b = this.agents.get(bSessionId);
|
|
122
|
+
if (!a || !b)
|
|
123
|
+
throw new Error('Both panes must be running an agent started with "Start linked agent".');
|
|
124
|
+
if (a.linkId || b.linkId)
|
|
125
|
+
throw new Error('A pane can only be in one link at a time. Break the existing link first.');
|
|
126
|
+
if (a.phase !== 'idle' || b.phase !== 'idle')
|
|
127
|
+
throw new Error('Wait for both agents to finish their current turn before linking.');
|
|
128
|
+
// A broken link stays listed so its reason can be read; starting a new one retires it.
|
|
129
|
+
for (const stale of Array.from(this.links.values())) {
|
|
130
|
+
if (stale.status === 'broken' && [stale.a, stale.b].some((id) => id === aSessionId || id === bSessionId))
|
|
131
|
+
this.links.delete(stale.id);
|
|
132
|
+
}
|
|
133
|
+
const relays = cap ?? this.defaultCap;
|
|
134
|
+
if (!Number.isInteger(relays) || relays < 1 || relays > MAX_RELAY_CAP) {
|
|
135
|
+
throw new Error(`The relay limit must be a whole number from 1 to ${MAX_RELAY_CAP}.`);
|
|
136
|
+
}
|
|
137
|
+
const link = { id: `link-${this.nextLinkId++}`, a: aSessionId, b: bSessionId, status: 'active', turns: 0, cap: relays };
|
|
138
|
+
this.links.set(link.id, link);
|
|
139
|
+
a.linkId = link.id;
|
|
140
|
+
b.linkId = link.id;
|
|
141
|
+
this.emit();
|
|
142
|
+
return this.describe(link);
|
|
143
|
+
}
|
|
144
|
+
/** The user breaks the link. Removes it entirely. */
|
|
145
|
+
unlink(linkId) {
|
|
146
|
+
const link = this.links.get(linkId);
|
|
147
|
+
if (!link)
|
|
148
|
+
return;
|
|
149
|
+
this.dropLink(link);
|
|
150
|
+
this.emit();
|
|
151
|
+
}
|
|
152
|
+
/** Re-arm a paused link, delivering the held reply if there is one and the partner is ready. */
|
|
153
|
+
resume(linkId) {
|
|
154
|
+
const link = this.links.get(linkId);
|
|
155
|
+
if (!link)
|
|
156
|
+
throw new Error('That link no longer exists.');
|
|
157
|
+
if (link.status !== 'paused')
|
|
158
|
+
return this.describe(link);
|
|
159
|
+
const held = link.held;
|
|
160
|
+
link.status = 'active';
|
|
161
|
+
link.reason = undefined;
|
|
162
|
+
link.waitingOn = undefined;
|
|
163
|
+
if (held) {
|
|
164
|
+
link.held = undefined;
|
|
165
|
+
this.relay(link, held.from, held.text);
|
|
166
|
+
}
|
|
167
|
+
this.emit();
|
|
168
|
+
return this.describe(link);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Relay an agent's most recent reply now. For a link made after the agent's turn
|
|
172
|
+
* finished (nothing else would ever trigger it) and for resuming by hand.
|
|
173
|
+
*/
|
|
174
|
+
relayLast(linkId, fromSessionId) {
|
|
175
|
+
const link = this.links.get(linkId);
|
|
176
|
+
if (!link)
|
|
177
|
+
throw new Error('That link no longer exists.');
|
|
178
|
+
if (link.status === 'broken')
|
|
179
|
+
throw new Error('That link has ended. Link the panes again first.');
|
|
180
|
+
if (fromSessionId !== link.a && fromSessionId !== link.b)
|
|
181
|
+
throw new Error('That pane is not part of this link.');
|
|
182
|
+
const reply = this.lastReplies.get(fromSessionId);
|
|
183
|
+
if (!reply)
|
|
184
|
+
throw new Error('That agent has not finished a reply yet.');
|
|
185
|
+
if (link.waitingOn)
|
|
186
|
+
throw new Error('Wait for the agent that is already working on a relayed message.');
|
|
187
|
+
link.status = 'active';
|
|
188
|
+
link.reason = undefined;
|
|
189
|
+
link.held = undefined;
|
|
190
|
+
this.relay(link, fromSessionId, reply);
|
|
191
|
+
this.emit();
|
|
192
|
+
return this.describe(link);
|
|
193
|
+
}
|
|
194
|
+
/** Feed an event reported by an agent's hooks. */
|
|
195
|
+
handle(event) {
|
|
196
|
+
const agent = this.agents.get(event.sessionId);
|
|
197
|
+
if (!agent)
|
|
198
|
+
return;
|
|
199
|
+
const link = agent.linkId ? this.links.get(agent.linkId) : undefined;
|
|
200
|
+
switch (event.event) {
|
|
201
|
+
case 'UserPromptSubmit':
|
|
202
|
+
agent.phase = 'busy';
|
|
203
|
+
agent.needsAttention = false;
|
|
204
|
+
break;
|
|
205
|
+
case 'Notification':
|
|
206
|
+
if (!IDLE_NOTIFICATIONS.has(event.notificationType ?? ''))
|
|
207
|
+
agent.needsAttention = true;
|
|
208
|
+
break;
|
|
209
|
+
case 'SessionEnd':
|
|
210
|
+
if (NON_TERMINAL_END_REASONS.has(event.reason ?? ''))
|
|
211
|
+
break;
|
|
212
|
+
if (link)
|
|
213
|
+
this.breakLink(link.id, `The agent in "${agent.label}" exited.`);
|
|
214
|
+
this.agents.delete(agent.sessionId);
|
|
215
|
+
break;
|
|
216
|
+
case 'Stop':
|
|
217
|
+
agent.phase = 'idle';
|
|
218
|
+
agent.needsAttention = false;
|
|
219
|
+
if (sanitizeRelayText(event.message ?? '')) {
|
|
220
|
+
this.lastReplies.set(agent.sessionId, event.message ?? '');
|
|
221
|
+
agent.hasReply = true;
|
|
222
|
+
}
|
|
223
|
+
if (link)
|
|
224
|
+
this.onTurnFinished(link, agent, event.message ?? '');
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
this.emit();
|
|
228
|
+
}
|
|
229
|
+
onTurnFinished(link, agent, message) {
|
|
230
|
+
if (link.status !== 'active')
|
|
231
|
+
return;
|
|
232
|
+
// We are waiting on the other agent, so this turn is not one we should relay (the user
|
|
233
|
+
// is driving this pane by hand). The expected answer will still arrive from the other side.
|
|
234
|
+
if (link.waitingOn && link.waitingOn !== agent.sessionId)
|
|
235
|
+
return;
|
|
236
|
+
this.cancelStall(link);
|
|
237
|
+
link.waitingOn = undefined;
|
|
238
|
+
if (!sanitizeRelayText(message)) {
|
|
239
|
+
this.pause(link, `The agent in "${agent.label}" finished without a reply to relay.`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (link.turns >= link.cap) {
|
|
243
|
+
this.breakLink(link.id, `Reached the limit of ${link.cap} relays.`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.relay(link, agent.sessionId, message);
|
|
247
|
+
}
|
|
248
|
+
relay(link, fromSessionId, message) {
|
|
249
|
+
const from = this.agents.get(fromSessionId);
|
|
250
|
+
const toSessionId = link.a === fromSessionId ? link.b : link.a;
|
|
251
|
+
const to = this.agents.get(toSessionId);
|
|
252
|
+
if (!from || !to) {
|
|
253
|
+
this.breakLink(link.id, 'One of the linked agents is gone.');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (link.turns >= link.cap) {
|
|
257
|
+
this.breakLink(link.id, `Reached the limit of ${link.cap} relays.`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (to.phase !== 'idle' || to.needsAttention) {
|
|
261
|
+
link.held = { from: fromSessionId, text: message };
|
|
262
|
+
this.pause(link, to.needsAttention
|
|
263
|
+
? `The agent in "${to.label}" is waiting for you. Answer it, then resume.`
|
|
264
|
+
: `The agent in "${to.label}" is busy. Resume once it finishes.`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
to.phase = 'busy';
|
|
268
|
+
link.turns += 1;
|
|
269
|
+
link.waitingOn = toSessionId;
|
|
270
|
+
this.options.deliver(toSessionId, frameRelay(from.label, message));
|
|
271
|
+
this.armStall(link, to.label);
|
|
272
|
+
}
|
|
273
|
+
pause(link, reason) {
|
|
274
|
+
this.cancelStall(link);
|
|
275
|
+
link.status = 'paused';
|
|
276
|
+
link.reason = reason;
|
|
277
|
+
}
|
|
278
|
+
breakLink(linkId, reason) {
|
|
279
|
+
const link = this.links.get(linkId);
|
|
280
|
+
if (!link)
|
|
281
|
+
return;
|
|
282
|
+
this.cancelStall(link);
|
|
283
|
+
link.status = 'broken';
|
|
284
|
+
link.reason = reason;
|
|
285
|
+
link.waitingOn = undefined;
|
|
286
|
+
link.held = undefined;
|
|
287
|
+
for (const sessionId of [link.a, link.b]) {
|
|
288
|
+
const agent = this.agents.get(sessionId);
|
|
289
|
+
if (agent?.linkId === link.id)
|
|
290
|
+
agent.linkId = undefined;
|
|
291
|
+
}
|
|
292
|
+
// A broken link stays listed so the user can read why it ended; it no longer holds either pane.
|
|
293
|
+
}
|
|
294
|
+
dropLink(link) {
|
|
295
|
+
this.cancelStall(link);
|
|
296
|
+
for (const sessionId of [link.a, link.b]) {
|
|
297
|
+
const agent = this.agents.get(sessionId);
|
|
298
|
+
if (agent?.linkId === link.id)
|
|
299
|
+
agent.linkId = undefined;
|
|
300
|
+
}
|
|
301
|
+
this.links.delete(link.id);
|
|
302
|
+
}
|
|
303
|
+
armStall(link, waitingLabel) {
|
|
304
|
+
this.cancelStall(link);
|
|
305
|
+
link.stallTimer = this.setTimer(() => {
|
|
306
|
+
link.stallTimer = undefined;
|
|
307
|
+
if (link.status !== 'active')
|
|
308
|
+
return;
|
|
309
|
+
this.pause(link, `No reply from "${waitingLabel}". It may be waiting for approval or was interrupted.`);
|
|
310
|
+
this.emit();
|
|
311
|
+
}, this.stallMs);
|
|
312
|
+
}
|
|
313
|
+
cancelStall(link) {
|
|
314
|
+
if (link.stallTimer !== undefined) {
|
|
315
|
+
this.clearTimer(link.stallTimer);
|
|
316
|
+
link.stallTimer = undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
describe(link) {
|
|
320
|
+
return {
|
|
321
|
+
id: link.id,
|
|
322
|
+
a: link.a,
|
|
323
|
+
b: link.b,
|
|
324
|
+
status: link.status,
|
|
325
|
+
turns: link.turns,
|
|
326
|
+
cap: link.cap,
|
|
327
|
+
reason: link.reason,
|
|
328
|
+
waitingOn: link.waitingOn,
|
|
329
|
+
holding: link.held !== undefined
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
emit() {
|
|
333
|
+
this.options.onChange?.(this.snapshot());
|
|
334
|
+
}
|
|
335
|
+
}
|