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.
@@ -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
- ipcMain.handle('mcp:start', async () => {
385
- mcpHost ??= new McpServerHost({
386
- control: mcpControl,
387
- providers: {
388
- listWorkspaces: () => workspaceStore.load(),
389
- listSessions: () => service.list(),
390
- createWorkspace,
391
- restoreWorkspace,
392
- createSshSession: async (request) => {
393
- const session = await service.createSsh(request.target, request.name);
394
- const attached = service.attach(session.id, (data) => {
395
- const requestId = mcpActiveExecutions.get(session.id);
396
- if (requestId) {
397
- const output = `${mcpExecutionOutput.get(requestId) ?? ''}${data}`.slice(0, 16 * 1024);
398
- mcpExecutionOutput.set(requestId, output);
399
- if ((output.match(/ExitCode=\d+/g) ?? []).length >= 2) {
400
- const result = mcpExecutions.finishRunning(requestId, 'completed', output, false, 'Command completed.');
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
- mcpAudit.record({ at: Date.now(), requestId: result.requestId, clientId: result.clientId, capability: 'session:execute', sessionId: result.sessionId, state: 'requested', command: result.displayCommand });
425
- win?.webContents.send('mcp:execution', result);
426
- return result;
427
- },
428
- getCommandResult: async (request) => mcpExecutions.get(request.requestId, request.clientId),
429
- cancelCommand: async (request) => {
430
- const current = mcpExecutions.get(request.requestId, request.clientId);
431
- const result = mcpExecutions.cancel(request.requestId, request.clientId);
432
- const session = 'sessionId' in current ? (await service.list()).find((item) => item.id === current.sessionId) : undefined;
433
- if (session?.kind === 'local')
434
- service.write(session.id, '\u0003');
435
- clearMcpExecution(request.requestId);
436
- return result;
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
+ }