feat: add pearcord-agentctl agent control plane (v0.1.0)

TCP server and headless session for driving Pearcord via the same IPC
surface as the UI, with CLI, view assertions, and log tail support.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-05-23 04:56:06 -04:00
co-authored by Cursor
commit 2b4d6139d8
12 changed files with 1179 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bare
'use strict'
const fs = require('bare-fs')
const path = require('bare-path')
const { AgentClient, HeadlessSession, resolveAgentctlAddress } = require('..')
function usage () {
console.log(`pearcord-agentctl — control a running Pearcord worker or run headless IPC
Usage:
agentctl ping [--host HOST] [--port PORT]
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 log-tail [--lines N] [--host HOST] [--port PORT]
agentctl headless run <scenario.json>
agentctl headless ipc '<json>'
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_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 }
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 === '-h' || a === '--help') out.help = true
else out._.push(a)
}
return out
}
function clientFromArgs (args) {
const def = resolveAgentctlAddress()
return new AgentClient({
host: args.host || def.host,
port: args.port || def.port
})
}
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 === 'headless') {
const sub = args._[1]
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()
}
}
const client = clientFromArgs(args)
try {
if (cmd === 'ping') {
const res = await client.ping()
console.log(JSON.stringify(res, 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
}
throw new Error(`unknown command: ${cmd}`)
} finally {
client.close()
}
}
main().catch((err) => {
console.error(err?.message || err)
process.exit(1)
})