Split external automation manager

This commit is contained in:
Neil
2026-08-29 02:48:27 -07:00
parent ce25e7f3bb
commit 06d3c47b28
8 changed files with 490 additions and 443 deletions
-1
View File
@@ -3,7 +3,6 @@
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
inline src/main/agent-hooks/server.ts
inline src/main/automations/external-manager.ts
inline src/main/browser/agent-browser-bridge.ts
inline src/main/browser/browser-cookie-import.ts
inline src/main/browser/browser-manager.ts
@@ -0,0 +1,163 @@
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type {
ExternalAutomationManager,
ExternalAutomationProvider
} from '../../shared/automations-types'
import type { SshTarget } from '../../shared/ssh-types'
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
import { readHermesCronOutputRunsPage } from './hermes-cron-output'
import { isExternalAutomationCommandOnPath } from './external-manager-local-command'
import { externalAutomationRelayErrorMessage } from './external-manager-relay'
const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
const HERMES_CRON_DIR = join(HERMES_HOME, 'cron')
const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json')
const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json')
const PROVIDER_LABELS: Record<ExternalAutomationProvider, string> = {
hermes: 'Hermes',
openclaw: 'OpenClaw'
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
async function readLocalHermesJobs(): Promise<unknown[]> {
if (!existsSync(HERMES_JOBS_FILE)) {
return []
}
const content = await readFile(HERMES_JOBS_FILE, 'utf-8')
const parsed = JSON.parse(content) as unknown
const jobs = Array.isArray(parsed)
? parsed
: isRecord(parsed) && Array.isArray(parsed.jobs)
? parsed.jobs
: []
return Promise.all(
jobs.map(async (job) => {
if (!isRecord(job)) {
return job
}
const jobId = typeof job.id === 'string' ? job.id : null
if (!jobId) {
return job
}
const runsPage = await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: 0 })
return {
...job,
run_count: runsPage.total,
runs: []
}
})
)
}
async function readLocalOpenClawJobs(): Promise<unknown[]> {
if (!existsSync(OPENCLAW_JOBS_FILE)) {
return []
}
const content = await readFile(OPENCLAW_JOBS_FILE, 'utf-8')
const parsed = JSON.parse(content) as unknown
return isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : []
}
function mapProviderJobs(
provider: ExternalAutomationProvider,
managerId: string,
jobs: unknown[]
): ExternalAutomationManager['jobs'] {
return provider === 'hermes' ? mapHermesJobs(managerId, jobs) : mapOpenClawJobs(managerId, jobs)
}
export async function listLocalManager(
provider: ExternalAutomationProvider
): Promise<ExternalAutomationManager | null> {
const [availableResult, jobsResult] = await Promise.allSettled([
isExternalAutomationCommandOnPath(provider),
provider === 'hermes' ? readLocalHermesJobs() : readLocalOpenClawJobs()
])
const available = availableResult.status === 'fulfilled' && availableResult.value
const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : []
const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null
if (!available && jobs.length === 0 && !readError) {
return null
}
const managerId = `${provider}:local`
const providerLabel = PROVIDER_LABELS[provider]
return {
id: managerId,
provider,
label: `${providerLabel} on this computer`,
targetLabel: 'this computer',
target: { type: 'local' },
status: readError ? 'unavailable' : 'available',
error:
readError ??
(available
? null
: `${providerLabel} jobs were found, but the ${provider} CLI is not on PATH.`),
canManage: !readError && available,
jobs: mapProviderJobs(provider, managerId, jobs)
}
}
export async function listRemoteManager(
target: SshTarget,
provider: ExternalAutomationProvider
): Promise<ExternalAutomationManager> {
const providerLabel = PROVIDER_LABELS[provider]
const managerProviderId = `${provider}:ssh:${target.id}`
const mux = getActiveMultiplexer(target.id)
if (!mux || mux.isDisposed()) {
return {
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
targetLabel: target.label,
target: { type: 'ssh', connectionId: target.id },
status: 'unavailable',
error: 'SSH target is not connected.',
canManage: false,
jobs: []
}
}
try {
const result = (await mux.request('externalAutomations.list', { provider })) as {
jobs?: unknown[]
hermesAvailable?: boolean
openclawAvailable?: boolean
error?: string | null
}
const commandAvailable =
provider === 'hermes' ? result.hermesAvailable === true : result.openclawAvailable === true
const readError = result.error ?? null
return {
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
targetLabel: target.label,
target: { type: 'ssh', connectionId: target.id },
status: readError ? 'unavailable' : 'available',
error:
readError ?? (commandAvailable ? null : `${providerLabel} CLI is not on the remote PATH.`),
canManage: !readError && commandAvailable,
jobs: mapProviderJobs(provider, managerProviderId, result.jobs ?? [])
}
} catch (error) {
return {
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
targetLabel: target.label,
target: { type: 'ssh', connectionId: target.id },
status: 'unavailable',
error: externalAutomationRelayErrorMessage(error),
canManage: false,
jobs: []
}
}
}
@@ -0,0 +1,7 @@
const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
export function assertExternalAutomationJobId(jobId: string): void {
if (!EXTERNAL_JOB_ID_PATTERN.test(jobId)) {
throw new Error('Invalid external automation job ID.')
}
}
@@ -0,0 +1,45 @@
import { runProcess } from '../../shared/child-process/run-process'
const LOCAL_COMMAND_LOOKUP_TIMEOUT_MS = 5_000
const LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS = 30_000
function runLocalProviderCommand(
command: string,
args: string[],
options: { timeoutMs: number; timeoutMessage: string }
): Promise<void> {
return runProcess({ program: command, args, timeoutMs: options.timeoutMs }).then((result) => {
if (result.timedOut) {
throw new Error(options.timeoutMessage)
}
if (result.code !== 0) {
throw new Error(
result.stderr.trim() || `Command exited with code ${result.code ?? 'unknown'}.`
)
}
})
}
export async function isExternalAutomationCommandOnPath(command: string): Promise<boolean> {
const finder = process.platform === 'win32' ? 'where' : 'which'
try {
// Why: these probes run while loading Automations; a wedged PATH shim must
// not keep the list IPC pending forever.
await runLocalProviderCommand(finder, [command], {
timeoutMs: LOCAL_COMMAND_LOOKUP_TIMEOUT_MS,
timeoutMessage: `Command lookup timed out after ${LOCAL_COMMAND_LOOKUP_TIMEOUT_MS}ms.`
})
return true
} catch {
return false
}
}
// Why: local automation mutations back UI actions; a wedged CLI must not keep
// create/edit/run/delete pending after Node signals a timeout.
export function runLocalAutomationCommand(command: string, args: string[]): Promise<void> {
return runLocalProviderCommand(command, args, {
timeoutMs: LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS,
timeoutMessage: `Local automation command timed out after ${LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS}ms.`
})
}
@@ -0,0 +1,139 @@
import type {
ExternalAutomationAction,
ExternalAutomationActionInput,
ExternalAutomationCreateInput,
ExternalAutomationProvider,
ExternalAutomationUpdateInput
} from '../../shared/automations-types'
import { assertExternalAutomationJobId } from './external-manager-job-id'
import { runLocalAutomationCommand } from './external-manager-local-command'
import { requireExternalAutomationMultiplexer } from './external-manager-relay'
import { clearHermesCronOutputRunCountCache } from './hermes-cron-output'
const PROVIDER_ACTION_COMMANDS: Record<
ExternalAutomationProvider,
Record<ExternalAutomationAction, string>
> = {
hermes: { pause: 'pause', resume: 'resume', run: 'run', delete: 'remove' },
openclaw: { pause: 'disable', resume: 'enable', run: 'run', delete: 'rm' }
}
/** Fails closed like its neighbours: an unlisted or inherited key is not an action. */
function providerActionCommand(
provider: ExternalAutomationProvider,
action: ExternalAutomationAction
): string {
const commands = Object.hasOwn(PROVIDER_ACTION_COMMANDS, provider)
? PROVIDER_ACTION_COMMANDS[provider]
: null
const command = commands && Object.hasOwn(commands, action) ? commands[action] : null
if (typeof command !== 'string') {
throw new Error('Unsupported external automation action.')
}
return command
}
function normalizeHermesCronMutationInput(input: ExternalAutomationCreateInput): {
name: string
prompt: string
schedule: string
workdir: string | null
} {
if (input.provider !== 'hermes') {
throw new Error('Only Hermes cron creation and editing are supported.')
}
const name = input.name.trim()
const prompt = input.prompt.trim()
const schedule = input.schedule.trim()
const workdir = input.workdir?.trim() || null
if (!prompt) {
throw new Error('Hermes cron requires a prompt.')
}
if (!schedule) {
throw new Error('Hermes cron requires a schedule.')
}
return {
name: name || prompt.slice(0, 50).trim() || 'Hermes cron',
prompt,
schedule,
workdir
}
}
/** Edit args when `jobId` is given, create args otherwise; both are argv, never a shell string. */
function hermesCronMutationArgs(
jobId: string | null,
input: { name: string; prompt: string; schedule: string; workdir: string | null }
): string[] {
const args = jobId
? ['cron', 'edit', jobId, '--schedule', input.schedule, '--prompt', input.prompt]
: ['cron', 'create', input.schedule, input.prompt]
args.push('--name', input.name)
if (!jobId) {
args.push('--deliver', 'local')
}
if (input.workdir) {
args.push('--workdir', input.workdir)
}
return args
}
export async function createExternalAutomation(
input: ExternalAutomationCreateInput
): Promise<void> {
const normalized = normalizeHermesCronMutationInput(input)
if (input.target.type === 'local') {
await runLocalAutomationCommand('hermes', hermesCronMutationArgs(null, normalized))
clearHermesCronOutputRunCountCache()
return
}
await requireExternalAutomationMultiplexer(input.target.connectionId).request(
'externalAutomations.create',
{
provider: input.provider,
...normalized
}
)
}
export async function updateExternalAutomation(
input: ExternalAutomationUpdateInput
): Promise<void> {
assertExternalAutomationJobId(input.jobId)
const normalized = normalizeHermesCronMutationInput(input)
if (input.target.type === 'local') {
await runLocalAutomationCommand('hermes', hermesCronMutationArgs(input.jobId, normalized))
clearHermesCronOutputRunCountCache(input.jobId)
return
}
await requireExternalAutomationMultiplexer(input.target.connectionId).request(
'externalAutomations.update',
{
provider: input.provider,
jobId: input.jobId,
...normalized
}
)
}
export async function runExternalAutomationAction(
input: ExternalAutomationActionInput
): Promise<void> {
assertExternalAutomationJobId(input.jobId)
const command = providerActionCommand(input.provider, input.action)
if (input.target.type === 'local') {
await runLocalAutomationCommand(input.provider, ['cron', command, input.jobId])
if (input.provider === 'hermes') {
clearHermesCronOutputRunCountCache(input.jobId)
}
return
}
await requireExternalAutomationMultiplexer(input.target.connectionId).request(
'externalAutomations.act',
{
provider: input.provider,
action: input.action,
jobId: input.jobId
}
)
}
@@ -0,0 +1,35 @@
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
export function requireExternalAutomationMultiplexer(
connectionId: string
): NonNullable<ReturnType<typeof getActiveMultiplexer>> {
const mux = getActiveMultiplexer(connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`SSH target "${connectionId}" is not connected.`)
}
return mux
}
/**
* True only for a structured JSON-RPC `-32601`, never for error prose.
*
* A `-32601` object means the peer parsed the request and declined the method,
* so it is positive evidence the connection is healthy. Matching the words
* "method not found" instead would let a genuinely broken relay be reported as
* a missing capability, hiding a dead connection behind a capability notice.
*/
export function isRelayMethodNotFoundError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
(error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound
)
}
export function externalAutomationRelayErrorMessage(error: unknown): string {
if (isRelayMethodNotFoundError(error)) {
return 'Remote relay does not support external automation management. Reconnect the SSH target to deploy the latest relay.'
}
return error instanceof Error ? error.message : String(error)
}
@@ -0,0 +1,81 @@
import type {
ExternalAutomationProvider,
ExternalAutomationRunsInput,
ExternalAutomationRunsPage
} from '../../shared/automations-types'
import {
EXTERNAL_AUTOMATION_SCOPE_CODES,
ExternalAutomationScopeError
} from '../../shared/external-automation-scope'
import { mapHermesJobs } from './external-job-mappers'
import { assertExternalAutomationJobId } from './external-manager-job-id'
import {
isRelayMethodNotFoundError,
requireExternalAutomationMultiplexer
} from './external-manager-relay'
import { readHermesCronOutputRunsPage } from './hermes-cron-output'
/**
* Runs the relay's paged runs call, translating "no such method" into a code the
* renderer can act on.
*
* The structured code exists only here: Electron IPC preserves nothing but the
* message, so a renderer that re-derived this from the host's prose would be
* guessing. Translating at the boundary that can still see the code is what lets
* the caller fall back to job-carried runs without matching free text.
*/
async function requestRemoteExternalRuns(
connectionId: string,
params: { provider: ExternalAutomationProvider; jobId: string; page: number; pageSize: number }
): Promise<{ total?: number; runs?: unknown[] }> {
try {
return (await requireExternalAutomationMultiplexer(connectionId).request(
'externalAutomations.runs',
params
)) as {
total?: number
runs?: unknown[]
}
} catch (error) {
if (isRelayMethodNotFoundError(error)) {
throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported)
}
throw error
}
}
export async function listExternalAutomationRuns(
input: ExternalAutomationRunsInput
): Promise<ExternalAutomationRunsPage> {
assertExternalAutomationJobId(input.jobId)
const page = Number.isFinite(input.page) ? Math.max(1, Math.floor(input.page)) : 1
const pageSize = Number.isFinite(input.pageSize)
? Math.min(100, Math.max(1, Math.floor(input.pageSize)))
: 25
const identity = {
managerId: input.managerId,
provider: input.provider,
target: input.target,
jobId: input.jobId,
page,
pageSize
}
if (input.provider !== 'hermes') {
return { ...identity, total: 0, runs: [] }
}
const result =
input.target.type === 'local'
? await readHermesCronOutputRunsPage(input.jobId, { page, pageSize })
: await requestRemoteExternalRuns(input.target.connectionId, {
provider: input.provider,
jobId: input.jobId,
page,
pageSize
})
return {
...identity,
total: typeof result.total === 'number' && Number.isFinite(result.total) ? result.total : 0,
runs:
mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs ?? [] }])[0]?.runs ?? []
}
}
+20 -442
View File
@@ -1,34 +1,16 @@
/* eslint-disable max-lines -- Why: external automation discovery, pagination,
* and lifecycle routing share provider/target validation and remote relay fallbacks. */
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { runProcess } from '../../shared/child-process/run-process'
import type {
ExternalAutomationAction,
ExternalAutomationActionInput,
ExternalAutomationCreateInput,
ExternalAutomationManager,
ExternalAutomationProvider,
ExternalAutomationRunsInput,
ExternalAutomationRunsPage,
ExternalAutomationUpdateInput
ExternalAutomationRunsPage
} from '../../shared/automations-types'
import {
EXTERNAL_AUTOMATION_SCOPE_CODES,
ExternalAutomationScopeError,
type ScopedExternalAutomationRequest,
type ScopedExternalManagerActionRequest,
type ScopedExternalManagerCreateRequest,
type ScopedExternalManagerListRequest,
type ScopedExternalManagerMutationFields,
type ScopedExternalManagerRunsRequest,
type ScopedExternalManagerUpdateRequest
import type {
ScopedExternalAutomationRequest,
ScopedExternalManagerActionRequest,
ScopedExternalManagerCreateRequest,
ScopedExternalManagerListRequest,
ScopedExternalManagerMutationFields,
ScopedExternalManagerRunsRequest,
ScopedExternalManagerUpdateRequest
} from '../../shared/external-automation-scope'
import type { SshTarget } from '../../shared/ssh-types'
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
import {
resolveExternalAutomationScope,
type DesktopSshTargetRegistry,
@@ -40,423 +22,19 @@ import {
type ExternalAutomationManagerCacheEntry
} from './external-automation-manager-cache'
import type { ExternalAutomationProbeScheduler } from './external-automation-probe-scheduler'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
import { listLocalManager, listRemoteManager } from './external-manager-discovery'
import {
clearHermesCronOutputRunCountCache,
readHermesCronOutputRunsPage
} from './hermes-cron-output'
createExternalAutomation,
runExternalAutomationAction,
updateExternalAutomation
} from './external-manager-mutations'
import { listExternalAutomationRuns } from './external-manager-runs'
const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
const HERMES_CRON_DIR = join(HERMES_HOME, 'cron')
const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json')
const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json')
const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
const LOCAL_COMMAND_LOOKUP_TIMEOUT_MS = 5_000
const LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS = 30_000
const PROVIDER_LABELS: Record<ExternalAutomationProvider, string> = {
hermes: 'Hermes',
openclaw: 'OpenClaw'
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function runLocalProviderCommand(
command: string,
args: string[],
options: { timeoutMs: number; timeoutMessage: string }
): Promise<void> {
return runProcess({ program: command, args, timeoutMs: options.timeoutMs }).then((result) => {
if (result.timedOut) {
throw new Error(options.timeoutMessage)
}
if (result.code !== 0) {
throw new Error(
result.stderr.trim() || `Command exited with code ${result.code ?? 'unknown'}.`
)
}
})
}
async function isCommandOnPath(command: string): Promise<boolean> {
const finder = process.platform === 'win32' ? 'where' : 'which'
try {
// Why: these probes run while loading Automations; a wedged PATH shim must
// not keep the list IPC pending forever.
await runLocalProviderCommand(finder, [command], {
timeoutMs: LOCAL_COMMAND_LOOKUP_TIMEOUT_MS,
timeoutMessage: `Command lookup timed out after ${LOCAL_COMMAND_LOOKUP_TIMEOUT_MS}ms.`
})
return true
} catch {
return false
}
}
// Why: local automation mutations back UI actions; a wedged CLI must not keep
// create/edit/run/delete pending after Node signals a timeout.
function runLocalAutomationCommand(command: string, args: string[]): Promise<void> {
return runLocalProviderCommand(command, args, {
timeoutMs: LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS,
timeoutMessage: `Local automation command timed out after ${LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS}ms.`
})
}
async function readLocalHermesJobs(): Promise<unknown[]> {
if (!existsSync(HERMES_JOBS_FILE)) {
return []
}
const content = await readFile(HERMES_JOBS_FILE, 'utf-8')
const parsed = JSON.parse(content) as unknown
const jobs = Array.isArray(parsed)
? parsed
: isRecord(parsed) && Array.isArray(parsed.jobs)
? parsed.jobs
: []
return Promise.all(
jobs.map(async (job) => {
if (!isRecord(job)) {
return job
}
const jobId = typeof job.id === 'string' ? job.id : null
if (!jobId) {
return job
}
const runsPage = await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: 0 })
return {
...job,
run_count: runsPage.total,
runs: []
}
})
)
}
async function readLocalOpenClawJobs(): Promise<unknown[]> {
if (!existsSync(OPENCLAW_JOBS_FILE)) {
return []
}
const content = await readFile(OPENCLAW_JOBS_FILE, 'utf-8')
const parsed = JSON.parse(content) as unknown
return isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : []
}
function mapProviderJobs(
provider: ExternalAutomationProvider,
managerId: string,
jobs: unknown[]
): ExternalAutomationManager['jobs'] {
return provider === 'hermes' ? mapHermesJobs(managerId, jobs) : mapOpenClawJobs(managerId, jobs)
}
async function listLocalManager(
provider: ExternalAutomationProvider
): Promise<ExternalAutomationManager | null> {
const [availableResult, jobsResult] = await Promise.allSettled([
isCommandOnPath(provider),
provider === 'hermes' ? readLocalHermesJobs() : readLocalOpenClawJobs()
])
const available = availableResult.status === 'fulfilled' && availableResult.value
const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : []
const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null
if (!available && jobs.length === 0 && !readError) {
return null
}
const managerId = `${provider}:local`
const providerLabel = PROVIDER_LABELS[provider]
return {
id: managerId,
provider,
label: `${providerLabel} on this computer`,
targetLabel: 'this computer',
target: { type: 'local' },
status: readError ? 'unavailable' : 'available',
error:
readError ??
(available
? null
: `${providerLabel} jobs were found, but the ${provider} CLI is not on PATH.`),
canManage: !readError && available,
jobs: mapProviderJobs(provider, managerId, jobs)
}
}
function requireMultiplexer(
connectionId: string
): NonNullable<ReturnType<typeof getActiveMultiplexer>> {
const mux = getActiveMultiplexer(connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`SSH target "${connectionId}" is not connected.`)
}
return mux
}
/**
* True only for a structured JSON-RPC `-32601`, never for error prose.
*
* A `-32601` object means the peer parsed the request and declined the method,
* so it is positive evidence the connection is healthy. Matching the words
* "method not found" instead would let a genuinely broken relay be reported as
* a missing capability, hiding a dead connection behind a capability notice.
*/
function isRelayMethodNotFoundError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
(error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound
)
}
function remoteRelayErrorMessage(error: unknown): string {
if (isRelayMethodNotFoundError(error)) {
return 'Remote relay does not support external automation management. Reconnect the SSH target to deploy the latest relay.'
}
return error instanceof Error ? error.message : String(error)
}
async function listRemoteManager(
target: SshTarget,
provider: ExternalAutomationProvider
): Promise<ExternalAutomationManager> {
const providerLabel = PROVIDER_LABELS[provider]
const managerProviderId = `${provider}:ssh:${target.id}`
const mux = getActiveMultiplexer(target.id)
if (!mux || mux.isDisposed()) {
return {
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
targetLabel: target.label,
target: { type: 'ssh', connectionId: target.id },
status: 'unavailable',
error: 'SSH target is not connected.',
canManage: false,
jobs: []
}
}
try {
const result = (await mux.request('externalAutomations.list', { provider })) as {
jobs?: unknown[]
hermesAvailable?: boolean
openclawAvailable?: boolean
error?: string | null
}
const commandAvailable =
provider === 'hermes' ? result.hermesAvailable === true : result.openclawAvailable === true
const readError = result.error ?? null
return {
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
targetLabel: target.label,
target: { type: 'ssh', connectionId: target.id },
status: readError ? 'unavailable' : 'available',
error:
readError ?? (commandAvailable ? null : `${providerLabel} CLI is not on the remote PATH.`),
canManage: !readError && commandAvailable,
jobs: mapProviderJobs(provider, managerProviderId, result.jobs ?? [])
}
} catch (error) {
return {
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
targetLabel: target.label,
target: { type: 'ssh', connectionId: target.id },
status: 'unavailable',
error: remoteRelayErrorMessage(error),
canManage: false,
jobs: []
}
}
}
/**
* Runs the relay's paged runs call, translating "no such method" into a code the
* renderer can act on.
*
* The structured code exists only here: Electron IPC preserves nothing but the
* message, so a renderer that re-derived this from the host's prose would be
* guessing. Translating at the boundary that can still see the code is what lets
* the caller fall back to job-carried runs without matching free text.
*/
async function requestRemoteExternalRuns(
connectionId: string,
params: { provider: ExternalAutomationProvider; jobId: string; page: number; pageSize: number }
): Promise<{ total?: number; runs?: unknown[] }> {
try {
return (await requireMultiplexer(connectionId).request('externalAutomations.runs', params)) as {
total?: number
runs?: unknown[]
}
} catch (error) {
if (isRelayMethodNotFoundError(error)) {
throw new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported)
}
throw error
}
}
export async function listExternalAutomationRuns(
input: ExternalAutomationRunsInput
): Promise<ExternalAutomationRunsPage> {
if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) {
throw new Error('Invalid external automation job ID.')
}
const page = Number.isFinite(input.page) ? Math.max(1, Math.floor(input.page)) : 1
const pageSize = Number.isFinite(input.pageSize)
? Math.min(100, Math.max(1, Math.floor(input.pageSize)))
: 25
const identity = {
managerId: input.managerId,
provider: input.provider,
target: input.target,
jobId: input.jobId,
page,
pageSize
}
if (input.provider !== 'hermes') {
return { ...identity, total: 0, runs: [] }
}
const result =
input.target.type === 'local'
? await readHermesCronOutputRunsPage(input.jobId, { page, pageSize })
: await requestRemoteExternalRuns(input.target.connectionId, {
provider: input.provider,
jobId: input.jobId,
page,
pageSize
})
return {
...identity,
total: typeof result.total === 'number' && Number.isFinite(result.total) ? result.total : 0,
runs:
mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs ?? [] }])[0]?.runs ?? []
}
}
const PROVIDER_ACTION_COMMANDS: Record<
ExternalAutomationProvider,
Record<ExternalAutomationAction, string>
> = {
hermes: { pause: 'pause', resume: 'resume', run: 'run', delete: 'remove' },
openclaw: { pause: 'disable', resume: 'enable', run: 'run', delete: 'rm' }
}
/** Fails closed like its neighbours: an unlisted or inherited key is not an action. */
function providerActionCommand(
provider: ExternalAutomationProvider,
action: ExternalAutomationAction
): string {
const commands = Object.hasOwn(PROVIDER_ACTION_COMMANDS, provider)
? PROVIDER_ACTION_COMMANDS[provider]
: null
const command = commands && Object.hasOwn(commands, action) ? commands[action] : null
if (typeof command !== 'string') {
throw new Error('Unsupported external automation action.')
}
return command
}
function normalizeHermesCronMutationInput(input: ExternalAutomationCreateInput): {
name: string
prompt: string
schedule: string
workdir: string | null
} {
if (input.provider !== 'hermes') {
throw new Error('Only Hermes cron creation and editing are supported.')
}
const name = input.name.trim()
const prompt = input.prompt.trim()
const schedule = input.schedule.trim()
const workdir = input.workdir?.trim() || null
if (!prompt) {
throw new Error('Hermes cron requires a prompt.')
}
if (!schedule) {
throw new Error('Hermes cron requires a schedule.')
}
return {
name: name || prompt.slice(0, 50).trim() || 'Hermes cron',
prompt,
schedule,
workdir
}
}
/** Edit args when `jobId` is given, create args otherwise; both are argv, never a shell string. */
function hermesCronMutationArgs(
jobId: string | null,
input: { name: string; prompt: string; schedule: string; workdir: string | null }
): string[] {
const args = jobId
? ['cron', 'edit', jobId, '--schedule', input.schedule, '--prompt', input.prompt]
: ['cron', 'create', input.schedule, input.prompt]
args.push('--name', input.name)
if (!jobId) {
args.push('--deliver', 'local')
}
if (input.workdir) {
args.push('--workdir', input.workdir)
}
return args
}
export async function createExternalAutomation(
input: ExternalAutomationCreateInput
): Promise<void> {
const normalized = normalizeHermesCronMutationInput(input)
if (input.target.type === 'local') {
await runLocalAutomationCommand('hermes', hermesCronMutationArgs(null, normalized))
clearHermesCronOutputRunCountCache()
return
}
await requireMultiplexer(input.target.connectionId).request('externalAutomations.create', {
provider: input.provider,
...normalized
})
}
export async function updateExternalAutomation(
input: ExternalAutomationUpdateInput
): Promise<void> {
if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) {
throw new Error('Invalid external automation job ID.')
}
const normalized = normalizeHermesCronMutationInput(input)
if (input.target.type === 'local') {
await runLocalAutomationCommand('hermes', hermesCronMutationArgs(input.jobId, normalized))
clearHermesCronOutputRunCountCache(input.jobId)
return
}
await requireMultiplexer(input.target.connectionId).request('externalAutomations.update', {
provider: input.provider,
jobId: input.jobId,
...normalized
})
}
export async function runExternalAutomationAction(
input: ExternalAutomationActionInput
): Promise<void> {
if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) {
throw new Error('Invalid external automation job ID.')
}
const command = providerActionCommand(input.provider, input.action)
if (input.target.type === 'local') {
await runLocalAutomationCommand(input.provider, ['cron', command, input.jobId])
if (input.provider === 'hermes') {
clearHermesCronOutputRunCountCache(input.jobId)
}
return
}
await requireMultiplexer(input.target.connectionId).request('externalAutomations.act', {
provider: input.provider,
action: input.action,
jobId: input.jobId
})
export {
createExternalAutomation,
listExternalAutomationRuns,
runExternalAutomationAction,
updateExternalAutomation
}
export type ScopedExternalAutomationDeps = {