feat(agentctl): wait-event op, DM/voice scenario steps, voiceJoined matcher

Add TCP wait-event for sidecar payloads, notifySidecar event log, headless
joinFirstVoice/openDmPeer steps, and voiceJoined wait spec (fixes boolean
myVoiceChannelId mismatch).

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-05-23 05:22:14 -04:00
co-authored by Cursor
parent 9f771389d4
commit dadfe21cc8
6 changed files with 129 additions and 3 deletions
+11 -1
View File
@@ -63,6 +63,8 @@ One JSON object per line (newline-delimited).
| `ipc` | `payload` | UI IPC object (`type`, …) |
| `view` | `light?` | Current platform view |
| `wait` | `match`, `timeoutMs?`, `light?` | Block until view matches partial spec |
| `wait-event` | `match`, `timeoutMs?`, `since?` | Block until sidecar `error` / `log` event |
| `run` | `scenario` | Run scenario JSON on attached worker |
| `log-tail` | `lines?` | Last N lines of `pearcord.log` |
| `shutdown` | — | Close agentctl server |
@@ -77,8 +79,11 @@ One JSON object per line (newline-delimited).
```json
{ "event": "state", "view": { ... } }
{ "event": "sidecar", "payload": { "type": "error", "message": "..." }, "at": 1734567890123 }
```
Set `PEARCORD_AGENTCTL_TOKEN` to require the same secret on every request when the server is enabled.
## Environment
| Variable | Default | Role |
@@ -100,7 +105,7 @@ See [docs/AGENTCTL.md](../../docs/AGENTCTL.md) for full reference, security note
## Security
- Binds **localhost only** — not exposed to the LAN.
- No authentication yet; do not enable on shared machines without firewall rules.
- Optional `PEARCORD_AGENTCTL_TOKEN` shared secret; still localhost-only.
- Can execute any UI IPC type (moderation, guild admin, etc.) — same power as the logged-in user session.
## Smoke tests
@@ -108,9 +113,14 @@ See [docs/AGENTCTL.md](../../docs/AGENTCTL.md) for full reference, security note
```bash
cd apps/pearcord
npm run test:agentctl-headless
npm run test:agentctl-slash
npm run test:agentctl-dm-voice
npm run test:agentctl-wait-event
npm run test:agentctl-server-wire
```
Scenario step extras (headless + attached `run`): `joinFirstVoice`, `openDmPeer`, `messagesMin`, `messageContentIncludes` on `wait`.
## Dependencies
- `pearcord-platform`, `pearcord-ui-flow`, `pearcord-log`
+24 -1
View File
@@ -32,6 +32,11 @@ function matchView (view, spec) {
if (!hay.includes(String(expected))) return false
continue
}
if (key === 'voiceJoined') {
if (expected && !view?.myVoiceChannelId) return false
if (!expected && view?.myVoiceChannelId) return false
continue
}
const actual = getPath(view, key)
if (
expected &&
@@ -49,7 +54,25 @@ function matchView (view, spec) {
return true
}
/**
* @param {object} evt `{ event: 'sidecar', payload: { type, ... } }`
* @param {object} spec `{ sidecarType?: 'error'|'log', level?: string, messageIncludes?: string }`
*/
function matchSidecarEvent (evt, spec) {
if (!spec || typeof spec !== 'object') return true
if (evt?.event !== 'sidecar') return false
const payload = evt.payload || {}
if (spec.sidecarType && payload.type !== spec.sidecarType) return false
if (spec.level && payload.record?.level !== spec.level) return false
if (spec.messageIncludes) {
const msg = payload.message || payload.record?.msg || ''
if (!String(msg).includes(String(spec.messageIncludes))) return false
}
return true
}
module.exports = {
getPath,
matchView
matchView,
matchSidecarEvent
}
+9
View File
@@ -13,6 +13,7 @@ Usage:
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>
@@ -120,6 +121,14 @@ async function main () {
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')
+10
View File
@@ -150,6 +150,16 @@ class AgentClient {
return res
}
async waitEvent (match, timeoutMs = 15000, since = 0) {
const res = await this.request(
'wait-event',
{ match, timeoutMs, since },
timeoutMs + 2000
)
if (!res.ok) throw new Error(res.error || 'wait-event failed')
return res.event
}
drainEvents () {
const out = this._events.slice()
this._events.length = 0
+17
View File
@@ -100,6 +100,23 @@ class HeadlessSession {
const view = await this.ipc(step.ipc)
results.push({ ipc: step.ipc, view })
}
if (step.joinFirstVoice) {
const ch = (this._lastView?.channels || []).find(
(c) => c.type === 'voice' || c.type === 'stage'
)
if (!ch) throw new Error('no voice or stage channel in view')
const view = await this.ipc({ type: 'join-voice', channelId: ch.id })
results.push({ joinFirstVoice: true, channelId: ch.id, view })
}
if (step.openDmPeer) {
const peer = step.openDmPeer
const view = await this.ipc({
type: 'open-dm',
peerUserId: peer.userId,
peerDisplayName: peer.displayName || 'DM Peer'
})
results.push({ openDmPeer: peer, view })
}
if (step.wait) {
const view = await this.wait(step.wait, step.timeoutMs)
results.push({ wait: step.wait, view })
+58 -1
View File
@@ -4,7 +4,7 @@ const net = require('bare-tcp')
const fs = require('bare-fs')
const { createLogger } = require('pearcord-log')
const { parseLine, encodeLine } = require('./protocol')
const { matchView } = require('./assert')
const { matchView, matchSidecarEvent } = require('./assert')
const { resolveAgentctlAddress, resolveLogPath } = require('./resolve')
class AgentServer {
@@ -25,6 +25,9 @@ class AgentServer {
this._clients = new Set()
this._lastView = null
this._address = null
/** @type {Array<object>} */
this._eventLog = []
this._eventLogMax = 200
}
get lastView () {
@@ -58,6 +61,17 @@ class AgentServer {
this.broadcast({ event: 'state', view: this._lastView })
}
notifySidecar (payload) {
const evt = { event: 'sidecar', payload, at: Date.now() }
this._pushEvent(evt)
this.broadcast(evt)
}
_pushEvent (evt) {
this._eventLog.push(evt)
while (this._eventLog.length > this._eventLogMax) this._eventLog.shift()
}
async listen (addr = resolveAgentctlAddress()) {
const self = this
this._server = net.createServer((socket) => self._onConnection(socket))
@@ -128,6 +142,25 @@ class AgentServer {
await this.refreshView(false)
results.push({ ipc: step.ipc, ok: true })
}
if (step.joinFirstVoice) {
const ch = (this._lastView?.channels || []).find(
(c) => c.type === 'voice' || c.type === 'stage'
)
if (!ch) throw new Error('no voice or stage channel in view')
await this.dispatchIpc({ type: 'join-voice', channelId: ch.id })
await this.refreshView(false)
results.push({ joinFirstVoice: true, channelId: ch.id })
}
if (step.openDmPeer) {
const peer = step.openDmPeer
await this.dispatchIpc({
type: 'open-dm',
peerUserId: peer.userId,
peerDisplayName: peer.displayName || 'DM Peer'
})
await this.refreshView(false)
results.push({ openDmPeer: peer })
}
if (step.wait) {
const timeoutMs = Number(step.timeoutMs) || 15000
const light = !!step.light
@@ -223,6 +256,30 @@ class AgentServer {
return
}
if (req.op === 'wait-event') {
const timeoutMs = Number(req.timeoutMs) || 15000
const spec = req.match || {}
const since = Number(req.since) || 0
const deadline = Date.now() + timeoutMs
for (const evt of this._eventLog) {
if ((evt.at || 0) >= since && matchSidecarEvent(evt, spec)) {
this._reply(socket, { ...base, ok: true, event: evt })
return
}
}
while (Date.now() < deadline) {
await sleep(50)
for (const evt of this._eventLog) {
if ((evt.at || 0) >= since && matchSidecarEvent(evt, spec)) {
this._reply(socket, { ...base, ok: true, event: evt })
return
}
}
}
this._reply(socket, { ...base, ok: false, error: 'wait-event timeout' })
return
}
if (req.op === 'run') {
const scenarioPath = req.scenario
if (!scenarioPath) {