Files
pearcord-agentctl/bin/agentctl.cjs
T
Raven ScottandCursor 1a40cd8b88 Restore agentctl runtime compatibility across Node and Bare.
Add a runtime helper and gate module loading so preflight/doctor/ping run in Node without pulling Bare-only modules, while preserving Bare paths for headless flows and worker-attached startup.

Co-authored-by: Cursor <[email protected]>
2026-06-02 10:56:39 -04:00

410 lines
13 KiB
JavaScript
Executable File

#!/usr/bin/env bare
'use strict'
const { isBareRuntime } = require('../runtime')
const fs = isBareRuntime() ? require('bare-fs') : require('fs')
const path = isBareRuntime() ? require('bare-path') : require('path')
const { resolveAgentctlAddress, resolveLogPath } = require('../resolve')
function loadRuntimeClientApi () {
const { AgentClient } = require('../client')
return { AgentClient }
}
function loadRuntimeHeadlessApi () {
const { HeadlessSession, PearcordMeshCluster } = require('..')
return { HeadlessSession, PearcordMeshCluster }
}
function usage () {
console.log(`pearcord-agentctl — control a running Pearcord worker or run headless IPC
Usage:
agentctl preflight
agentctl doctor [--host HOST] [--port PORT]
agentctl ping [--host HOST] [--port PORT] [--retries N] [--retry-delay MS] [--wait-listening]
agentctl wait-ready [--host HOST] [--port PORT] [--timeout MS]
agentctl view [--light] [--host HOST] [--port PORT]
agentctl ipc '<json>' [--host HOST] [--port PORT]
agentctl wait '<match-json>' [--timeout MS] [--host HOST] [--port PORT]
agentctl wait-event '<match-json>' [--timeout MS] [--since MS]
agentctl log-tail [--lines N] [--host HOST] [--port PORT]
agentctl run <scenario.json> [--timeout MS] [--host HOST] [--port PORT]
agentctl headless run <scenario.json>
agentctl headless mesh run <scenario.json> [--peers N]
agentctl headless ipc '<json>'
agentctl mesh run <scenario.json> [--peers N]
Environment:
PEARCORD_AGENTCTL=1 Enable TCP server in apps/pearcord worker
PEARCORD_AGENTCTL_HOST Default 127.0.0.1
PEARCORD_AGENTCTL_PORT Default 39482
PEARCORD_AGENTCTL_TOKEN Optional shared secret for TCP ops
PEARCORD_STORAGE Storage root (~/.config/pearcord)
Start Pearcord with agentctl:
PEARCORD_AGENTCTL=1 pear run
`)
}
function parseArgs (argv) {
const out = {
_: [],
host: null,
port: null,
light: false,
timeout: 15000,
lines: 40,
retries: 3,
retryDelay: 500,
waitListening: false
}
for (let i = 2; i < argv.length; i++) {
const a = argv[i]
if (a === '--host') out.host = argv[++i]
else if (a === '--port') out.port = Number(argv[++i])
else if (a === '--light') out.light = true
else if (a === '--timeout') out.timeout = Number(argv[++i])
else if (a === '--lines') out.lines = Number(argv[++i])
else if (a === '--retries') out.retries = Math.max(1, Number(argv[++i]) || 1)
else if (a === '--retry-delay') out.retryDelay = Math.max(50, Number(argv[++i]) || 500)
else if (a === '--wait-listening') out.waitListening = true
else if (a === '-h' || a === '--help') out.help = true
else out._.push(a)
}
return out
}
function clientFromArgs (args) {
const { AgentClient } = loadRuntimeClientApi()
const def = resolveAgentctlAddress()
return new AgentClient({
host: args.host || def.host,
port: args.port || def.port
})
}
function sleep (ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function classifyCliError (err) {
const msg = String(err?.message || err || '')
if (msg.includes('Cannot find module')) return 'EMODULE_MISSING'
if (msg.includes('startup timeout')) return 'ESTARTUP_TIMEOUT'
if (msg.includes('connect timeout')) return 'ECONN_TIMEOUT'
if (msg.includes('request timeout')) return 'EREQUEST_TIMEOUT'
if (msg.includes('ECONNREFUSED') || msg.includes('connection refused')) return 'ECONN_REFUSED'
if (msg.includes('EHOSTUNREACH')) return 'EHOST_UNREACHABLE'
return 'EUNKNOWN'
}
function remediationFor (code, details = {}) {
if (code === 'EMODULE_MISSING') {
return [
'Install local module links first:',
'cd ../../modules/pearcord-platform && npm install',
'cd ../pearcord-agentctl && npm install'
]
}
if (code === 'ECONN_REFUSED') {
const hints = [
'Ensure Pearcord is running with agentctl enabled:',
'PEARCORD_AGENTCTL=1 pear run'
]
if (!process.env.PEARCORD_AGENTCTL) {
hints.push('Current shell does not set PEARCORD_AGENTCTL=1.')
}
hints.push(`Verify host/port (${details.host}:${details.port}) and retry with --host/--port if needed.`)
return hints
}
if (code === 'ECONN_TIMEOUT' || code === 'EREQUEST_TIMEOUT') {
return [
'Worker startup is still in progress or blocked.',
'Retry with --wait-listening or increase --timeout.',
'Check logs with: agentctl log-tail --lines 120'
]
}
if (code === 'ESTARTUP_TIMEOUT') {
return [
'Agentctl server did not become reachable before timeout.',
'Launch worker with PEARCORD_AGENTCTL=1 pear run and retry wait-ready.',
'Check logs with: agentctl log-tail --lines 120'
]
}
if (code === 'EHOST_UNREACHABLE') {
return ['The host is unreachable; verify --host value and local network config.']
}
return ['Inspect pearcord.log and retry with --wait-listening.']
}
function printCliError (err, details = {}) {
const code = classifyCliError(err)
const payload = {
ok: false,
errorCode: code,
message: String(err?.message || err || 'unknown error'),
hints: remediationFor(code, details)
}
console.error(JSON.stringify(payload, null, 2))
}
function runPreflightChecks () {
const required = [
'pearcord-platform',
'pearcord-ui-flow',
'pearcord-log',
'pearcord-guild-sidecar'
]
const results = required.map((name) => {
try {
require.resolve(name)
return { name, ok: true }
} catch (err) {
return { name, ok: false, error: err?.message || String(err) }
}
})
const failed = results.filter((r) => !r.ok)
const out = { ok: failed.length === 0, checks: results }
if (failed.length) {
out.errorCode = 'EMODULE_MISSING'
out.hints = remediationFor('EMODULE_MISSING')
}
return out
}
function doctorFromArgs (args) {
const def = resolveAgentctlAddress()
const host = args.host || def.host
const port = args.port || def.port
const listeningEvidence = findListeningEvidence(host, port)
return {
ok: true,
mode: process.env.PEARCORD_AGENTCTL === '1' ? 'attached-enabled' : 'attached-disabled',
expectedRuntime: {
host,
port,
tokenMode: process.env.PEARCORD_AGENTCTL_TOKEN ? 'required' : 'off'
},
listeningEvidence,
env: {
PEARCORD_AGENTCTL: process.env.PEARCORD_AGENTCTL || null,
PEARCORD_AGENTCTL_HOST: process.env.PEARCORD_AGENTCTL_HOST || null,
PEARCORD_AGENTCTL_PORT: process.env.PEARCORD_AGENTCTL_PORT || null,
PEARCORD_AGENTCTL_TOKEN: process.env.PEARCORD_AGENTCTL_TOKEN ? '[set]' : null
}
}
}
function findListeningEvidence (host, port) {
const logPath = resolveLogPath()
let text = ''
try {
text = fs.readFileSync(logPath, 'utf8')
} catch {
return { seen: false, logPath, reason: 'log-not-found' }
}
const lines = text.split('\n')
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]
if (!line || !line.includes('agentctl listening')) continue
const hostMatch = line.includes(`"host":"${host}"`) || line.includes(`host":"${host}"`)
const portMatch = line.includes(`"port":${port}`) || line.includes(`port":${port}`)
return {
seen: true,
hostMatches: hostMatch,
portMatches: portMatch,
matchesExpected: !!hostMatch && !!portMatch,
logPath,
line: line.trim()
}
}
return { seen: false, logPath, reason: 'no-agentctl-listening-line' }
}
async function pingWithRetry (client, args) {
const attempts = args.waitListening ? Math.max(args.retries, 8) : args.retries
let lastErr = null
for (let i = 1; i <= attempts; i++) {
try {
const res = await client.ping()
return { ...res, attempts: i }
} catch (err) {
lastErr = err
if (i < attempts) await sleep(Math.min(args.retryDelay * i, 2000))
}
}
throw lastErr
}
async function main () {
const args = parseArgs(process.argv)
if (args.help || !args._.length) {
usage()
process.exit(args.help ? 0 : 1)
}
const cmd = args._[0]
if (cmd === 'preflight') {
const out = runPreflightChecks()
console.log(JSON.stringify(out, null, 2))
if (!out.ok) process.exit(2)
return
}
if (cmd === 'doctor') {
console.log(JSON.stringify(doctorFromArgs(args), null, 2))
return
}
if (cmd === 'headless') {
const { HeadlessSession, PearcordMeshCluster } = loadRuntimeHeadlessApi()
const sub = args._[1]
if (sub === 'mesh') {
const meshSub = args._[2]
if (meshSub !== 'run') throw new Error('use: headless mesh run <scenario.json>')
const file = args._[3]
if (!file) throw new Error('scenario.json path required')
const cluster = new PearcordMeshCluster({ peerCount: 3 })
try {
const result = await cluster.runScenario(path.resolve(file))
console.log(JSON.stringify({ ok: true, result }, null, 2))
} finally {
await cluster.close()
}
return
}
const session = new HeadlessSession()
await session.start()
try {
if (sub === 'run') {
const file = args._[2]
if (!file) throw new Error('scenario.json path required')
const results = await session.runScenario(path.resolve(file))
console.log(JSON.stringify({ ok: true, steps: results.length, view: session.lastView }, null, 2))
return
}
if (sub === 'ipc') {
const raw = args._[2]
if (!raw) throw new Error('ipc json required')
const payload = JSON.parse(raw)
const view = await session.ipc(payload)
console.log(JSON.stringify({ ok: true, view }, null, 2))
return
}
throw new Error(`unknown headless subcommand: ${sub}`)
} finally {
await session.close()
}
}
if (cmd === 'mesh') {
const { PearcordMeshCluster } = loadRuntimeHeadlessApi()
const meshSub = args._[1]
if (meshSub !== 'run') throw new Error('use: mesh run <scenario.json>')
const file = args._[2]
if (!file) throw new Error('scenario.json path required')
const cluster = new PearcordMeshCluster({ peerCount: 3 })
try {
const result = await cluster.runScenario(path.resolve(file))
console.log(JSON.stringify({ ok: true, result }, null, 2))
} finally {
await cluster.close()
}
return
}
const client = clientFromArgs(args)
try {
if (cmd === 'wait-ready') {
const deadline = Date.now() + Math.max(args.timeout, 1000)
let attempts = 0
while (Date.now() < deadline) {
attempts++
try {
const res = await client.ping()
console.log(JSON.stringify({
ok: true,
ready: true,
attempts,
host: client.host,
port: client.port,
tokenMode: client.token ? 'required' : 'off',
response: res
}, null, 2))
return
} catch {
await sleep(400)
}
}
throw new Error('agentctl startup timeout: worker did not become ready')
}
if (cmd === 'ping') {
const res = await pingWithRetry(client, args)
console.log(JSON.stringify({
...res,
host: client.host,
port: client.port,
tokenMode: client.token ? 'required' : 'off'
}, null, 2))
return
}
if (cmd === 'view') {
const view = await client.view(args.light)
console.log(JSON.stringify({ ok: true, view }, null, 2))
return
}
if (cmd === 'ipc') {
const raw = args._[1]
if (!raw) throw new Error('ipc json required')
const payload = JSON.parse(raw)
const res = await client.ipc(payload)
console.log(JSON.stringify(res, null, 2))
return
}
if (cmd === 'wait') {
const raw = args._[1]
if (!raw) throw new Error('match json required')
const match = JSON.parse(raw)
const view = await client.wait(match, args.timeout, args.light)
console.log(JSON.stringify({ ok: true, view }, null, 2))
return
}
if (cmd === 'log-tail') {
const res = await client.logTail(args.lines)
console.log(JSON.stringify(res, null, 2))
return
}
if (cmd === 'wait-event') {
const raw = args._[1]
if (!raw) throw new Error('match json required')
const match = JSON.parse(raw)
const event = await client.waitEvent(match, args.timeout, 0)
console.log(JSON.stringify({ ok: true, event }, null, 2))
return
}
if (cmd === 'run') {
const file = args._[1]
if (!file) throw new Error('scenario.json path required')
const res = await client.run(path.resolve(file), args.timeout || 120000)
console.log(JSON.stringify(res, null, 2))
return
}
throw new Error(`unknown command: ${cmd}`)
} finally {
client.close()
}
}
main().catch((err) => {
const argv = typeof process !== 'undefined' && Array.isArray(process.argv)
? process.argv
: ['agentctl']
const args = parseArgs(argv)
const def = resolveAgentctlAddress()
printCliError(err, {
host: args.host || def.host,
port: args.port || def.port
})
if (typeof process !== 'undefined' && typeof process.exit === 'function') process.exit(1)
throw err
})