mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
refactor(desktop): extract terminal (PTY) IPC handlers from main.cjs into terminal-ipc.cjs
Third main.cjs cluster peel. The four hermes:terminal:* handlers (start, write,
resize, dispose) move verbatim into electron/terminal-ipc.cjs behind a
registerTerminalIpc({ ipcMain, nodePty, terminalSessions, ... }) registrar. The
PTY runtime, the shared session registry (also used by app-quit cleanup), and the
shell-spec/env/cwd helpers (deep Windows-PATH + app-path coupling) stay in the
main process and are injected, so the module owns only the request wiring.
Channel names unchanged → preload + renderer untouched. Adds
electron/terminal-ipc.test.cjs (surface invariant + unknown-session no-throw +
PTY-unavailable error).
This commit is contained in:
parent
f3ce17bf9e
commit
880f5837a1
3 changed files with 171 additions and 66 deletions
|
|
@ -58,6 +58,7 @@ const {
|
|||
} = require('./update-relaunch.cjs')
|
||||
const { registerGitIpc } = require('./git-ipc.cjs')
|
||||
const { registerFsIpc } = require('./fs-ipc.cjs')
|
||||
const { registerTerminalIpc } = require('./terminal-ipc.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
|
||||
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||
|
|
@ -6904,74 +6905,20 @@ registerFsIpc({ ipcMain, directoryExists, expandUserPath })
|
|||
// stay here (Windows PATH discovery) and are injected into the registrar.
|
||||
registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary })
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
||||
}
|
||||
|
||||
ensureSpawnHelperExecutable()
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const { args, command, name } = terminalShellCommand()
|
||||
const cwd = safeTerminalCwd(payload?.cwd)
|
||||
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
|
||||
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
|
||||
const ptyProcess = nodePty.spawn(command, args, {
|
||||
cols,
|
||||
cwd,
|
||||
env: terminalShellEnv(),
|
||||
name: 'xterm-256color',
|
||||
rows
|
||||
})
|
||||
|
||||
terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id })
|
||||
|
||||
const send = (suffix, payload) => {
|
||||
if (event.sender.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
event.sender.send(terminalChannel(id, suffix), payload)
|
||||
}
|
||||
|
||||
ptyProcess.onData(data => send('data', data))
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
terminalSessions.delete(id)
|
||||
send('exit', { code: exitCode, signal: signal || null })
|
||||
})
|
||||
event.sender.once('destroyed', () => disposeTerminalSession(id))
|
||||
|
||||
return { cwd, id, shell: name }
|
||||
// Terminal/PTY IPC lives in terminal-ipc.cjs; the PTY runtime, session
|
||||
// registry, and shell helpers stay in the main process and are injected.
|
||||
registerTerminalIpc({
|
||||
disposeTerminalSession,
|
||||
ensureSpawnHelperExecutable,
|
||||
ipcMain,
|
||||
nodePty,
|
||||
safeTerminalCwd,
|
||||
terminalChannel,
|
||||
terminalSessions,
|
||||
terminalShellCommand,
|
||||
terminalShellEnv
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:write', (_event, id, data) => {
|
||||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
sessionInfo.pty.write(String(data || ''))
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => {
|
||||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80)
|
||||
const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24)
|
||||
|
||||
sessionInfo.pty.resize(cols, rows)
|
||||
|
||||
return true
|
||||
})
|
||||
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
|
||||
|
||||
ipcMain.handle('hermes:updates:check', async () =>
|
||||
checkUpdates().catch(error => ({
|
||||
supported: true,
|
||||
|
|
|
|||
89
apps/desktop/electron/terminal-ipc.cjs
Normal file
89
apps/desktop/electron/terminal-ipc.cjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
'use strict'
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
// Terminal (PTY) IPC: start / write / resize / dispose. The PTY runtime, the
|
||||
// shared session registry, and the shell-spec/env/cwd helpers all live in the
|
||||
// main process (deep Windows-PATH + app-path coupling) and are injected, so this
|
||||
// module only owns the request wiring.
|
||||
function registerTerminalIpc({
|
||||
disposeTerminalSession,
|
||||
ensureSpawnHelperExecutable,
|
||||
ipcMain,
|
||||
nodePty,
|
||||
safeTerminalCwd,
|
||||
terminalChannel,
|
||||
terminalSessions,
|
||||
terminalShellCommand,
|
||||
terminalShellEnv
|
||||
}) {
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
||||
}
|
||||
|
||||
ensureSpawnHelperExecutable()
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const { args, command, name } = terminalShellCommand()
|
||||
const cwd = safeTerminalCwd(payload?.cwd)
|
||||
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
|
||||
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
|
||||
const ptyProcess = nodePty.spawn(command, args, {
|
||||
cols,
|
||||
cwd,
|
||||
env: terminalShellEnv(),
|
||||
name: 'xterm-256color',
|
||||
rows
|
||||
})
|
||||
|
||||
terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id })
|
||||
|
||||
const send = (suffix, payload) => {
|
||||
if (event.sender.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
event.sender.send(terminalChannel(id, suffix), payload)
|
||||
}
|
||||
|
||||
ptyProcess.onData(data => send('data', data))
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
terminalSessions.delete(id)
|
||||
send('exit', { code: exitCode, signal: signal || null })
|
||||
})
|
||||
event.sender.once('destroyed', () => disposeTerminalSession(id))
|
||||
|
||||
return { cwd, id, shell: name }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:write', (_event, id, data) => {
|
||||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
sessionInfo.pty.write(String(data || ''))
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => {
|
||||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80)
|
||||
const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24)
|
||||
|
||||
sessionInfo.pty.resize(cols, rows)
|
||||
|
||||
return true
|
||||
})
|
||||
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
|
||||
}
|
||||
|
||||
module.exports = { registerTerminalIpc }
|
||||
69
apps/desktop/electron/terminal-ipc.test.cjs
Normal file
69
apps/desktop/electron/terminal-ipc.test.cjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerTerminalIpc } = require('./terminal-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deps(overrides = {}) {
|
||||
return {
|
||||
disposeTerminalSession: () => true,
|
||||
ensureSpawnHelperExecutable: () => {},
|
||||
nodePty: { spawn: () => ({ onData() {}, onExit() {} }) },
|
||||
safeTerminalCwd: c => c || '/',
|
||||
terminalChannel: (id, suffix) => `hermes:terminal:${id}:${suffix}`,
|
||||
terminalSessions: new Map(),
|
||||
terminalShellCommand: () => ({ args: [], command: 'sh', name: 'sh' }),
|
||||
terminalShellEnv: () => ({}),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('registerTerminalIpc wires only hermes:terminal:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerTerminalIpc({ ipcMain, ...deps() })
|
||||
|
||||
assert.ok(ipcMain.handlers.size >= 4, `expected the full terminal surface, got ${ipcMain.handlers.size}`)
|
||||
|
||||
for (const [channel, handler] of ipcMain.handlers) {
|
||||
assert.match(channel, /^hermes:terminal:/, `${channel} is not a terminal channel`)
|
||||
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
|
||||
}
|
||||
|
||||
for (const channel of ['hermes:terminal:start', 'hermes:terminal:write', 'hermes:terminal:resize']) {
|
||||
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('write / resize on an unknown session id return false instead of throwing', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerTerminalIpc({ ipcMain, ...deps() })
|
||||
|
||||
assert.equal(await ipcMain.handlers.get('hermes:terminal:write')({}, 'nope', 'x'), false)
|
||||
assert.equal(await ipcMain.handlers.get('hermes:terminal:resize')({}, 'nope', {}), false)
|
||||
})
|
||||
|
||||
test('start surfaces a clear error when the PTY runtime is unavailable', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerTerminalIpc({ ipcMain, ...deps({ nodePty: null }) })
|
||||
|
||||
await assert.rejects(
|
||||
() => ipcMain.handlers.get('hermes:terminal:start')({ sender: {} }, {}),
|
||||
/PTY support is unavailable/
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue