Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 53 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ Make a [Herdr](https://herdr.dev) session a participating member of an
forwarded into a Relay channel, and other agents in that workspace can query your
fleet's live state through a typed `herdr.session_summary` action.

The same plugin can also turn a project-scoped local Relay broker into a Herdr
workspace: the fleet picker opens one attached pane per live broker agent and
projects the broker's authoritative state onto each pane.

This is a connector, not a notifier. If you want a push notification on your
phone when an agent blocks, several plugins do that well — see
[Related plugins](#related-plugins). Use this one when the thing that should
Expand All @@ -24,23 +28,32 @@ distinct members of the same workspace.

## What it does, and what it will not do

Forwarded:
The `bridge` entrypoint forwards:

- `pane.agent_status_changed` for workspaces you explicitly allowlist
- aggregate status counts, on request, via `herdr.session_summary`

Never touched:
The optional `fleet` entrypoint:

- reads `agent-relay node agent list` from the active Herdr project's cwd
- creates a `Relay fleet` workspace with one attached pane per live broker agent
- reports broker `current_state` through Herdr's `pane.report_agent` API

Never touched by the bridge entrypoint:

- pane output, scrollback, working directory, environment, or terminal titles
- prompts, keystrokes, shell commands, or raw socket control

The bridge has no write path into your panes at all. It reads status metadata and
session snapshots over Herdr's local API and nothing else.
The bridge entrypoint has no write path into your panes. The fleet entrypoint is
an explicit control surface: invoking it creates a workspace, launches local
attach commands, and reports agent metadata, but it does not read scrollback or
send prompts or keystrokes.

## Requirements

- Herdr 0.7.5 or newer
- Node 22 or newer
- `agent-relay` with a running project-scoped local broker for the fleet picker

No Agent Relay account, API key, or signup is needed to start — setup creates a
free workspace for you.
Expand Down Expand Up @@ -114,6 +127,41 @@ herdr plugin pane open --plugin agent-relay.herdr-bridge --entrypoint bridge
The bridge runs only while that pane is open — there is no startup hook. Closing
the pane stops it and drains any in-flight deliveries first.

## Open the local broker fleet

The fleet entrypoints are available only on Linux and macOS; they cannot be
opened on Windows.

Focus the Chief project workspace in Herdr, then run one command:

```sh
herdr plugin pane open --plugin agent-relay.herdr-bridge --entrypoint fleet
```
Comment thread
khaliqgant marked this conversation as resolved.

The picker takes the project directory from the active Herdr workspace context,
runs `agent-relay node agent list` there, and creates a new `Relay fleet`
workspace. Every live broker agent gets its own tab, launched with that same cwd
and attached in `drive` mode. To target a different project or attach read-only:

```sh
herdr plugin pane open --plugin agent-relay.herdr-bridge --entrypoint fleet \
--env HERDR_RELAY_PROJECT_DIR=/absolute/path/to/chief \
--env HERDR_RELAY_ATTACH_MODE=view
```

The resident chief-of-staff is launched through `scripts/chief.sh brain`, so the
documented Chief bootstrap starts the broker and agent when needed. Other panes
run `agent-relay node agent attach <name> --mode <mode>` directly.

Each pane polls the broker every five seconds and reports only changed states.
`idle` and `working` map directly, `blocked` and `blocked_on_send` map to
`blocked`, and every other value (including `done`) maps to `unknown`; Herdr's
pane state enum has no `done`. If the broker cannot be reached, the picker
prints a short recovery message naming the project and the commands that can
start it instead of surfacing the raw connection-file error. The failed picker
pane stays open until you press Enter, so the recovery message does not
disappear with the process.

## Querying from Relay

Any agent in the workspace can call:
Expand Down Expand Up @@ -158,7 +206,7 @@ later start reclaims the lock only when its owner PID is gone; a live or
unidentifiable owner fails closed.

Herdr plugins run as your OS user and are not sandboxed. Review the manifest and
`dist/` before installing — it is four small files.
`dist/` before installing.

## Related plugins

Expand Down
110 changes: 110 additions & 0 deletions dist/fleet-agent.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { spawn } from 'node:child_process';
import { pathToFileURL } from 'node:url';

import {
attachCommand,
attachMode,
createStatusProjector,
listBrokerAgents,
} from './fleet.mjs';
import { requestHerdr } from './herdr-socket.mjs';

export const DEFAULT_POLL_INTERVAL_MS = 5_000;

function waitForChild(child) {
return new Promise((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code, signal) => resolve({ code, signal }));
});
}

export async function runFleetAgent({
environment = process.env,
listAgents = listBrokerAgents,
request = requestHerdr,
spawnProcess = spawn,
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
} = {}) {
const socketPath = environment.HERDR_SOCKET_PATH;
const paneId = environment.HERDR_PANE_ID;
const agentName = environment.HERDR_RELAY_AGENT_NAME;
const agentLabel = environment.HERDR_RELAY_AGENT_LABEL || 'agent-relay';
if (!socketPath || !paneId) throw new Error('Herdr did not provide the fleet pane context');
if (!agentName) throw new Error('Fleet pane did not receive an Agent Relay agent name');

const projectDir = process.cwd();
const mode = attachMode(environment.HERDR_RELAY_ATTACH_MODE);
const projector = createStatusProjector({
agentName,
initialBrokerState:
environment.HERDR_RELAY_INITIAL_STATE === undefined
? undefined
: environment.HERDR_RELAY_INITIAL_STATE,
loadAgents: () => listAgents(projectDir),
report: ({ state, message }) =>
request(socketPath, 'pane.report_agent', {
pane_id: paneId,
source: 'fleet-picker',
agent: agentLabel,
state,
message,
}),
});

await projector.poll();
const invocation = attachCommand({
agentName,
mode,
residentChief: environment.HERDR_RELAY_RESIDENT_CHIEF === '1',
});
const child = spawnProcess(invocation.command, invocation.args, {
cwd: projectDir,
env: environment,
stdio: 'inherit',
});

let stopped = false;
let timer;
const schedule = () => {
if (stopped) return;
timer = setTimeout(async () => {
try {
await projector.poll();
} catch {
// Keep the attach usable while Herdr is restarting. Because the
// projector advances only after a successful report, the next poll
// retries the same broker transition.
} finally {
schedule();
}
}, pollIntervalMs);
timer.unref?.();
};
schedule();

try {
const result = await waitForChild(child);
if (result.code && result.code !== 0) {
throw new Error(`${agentName} attach exited with status ${result.code}`);
}
return result;
} finally {
stopped = true;
if (timer) clearTimeout(timer);
}
}

export function isDirectEntrypoint(moduleUrl, argv1) {
return Boolean(argv1) && moduleUrl === pathToFileURL(argv1).href;
}

export async function main() {
try {
await runFleetAgent();
} catch (error) {
console.error(`Agent Relay fleet attach failed: ${error.message}`);
process.exitCode = 1;
}
}

if (isDirectEntrypoint(import.meta.url, process.argv[1])) await main();
116 changes: 116 additions & 0 deletions dist/fleet-picker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { pathToFileURL } from 'node:url';

import {
attachMode,
chiefAgentName,
fleetProjectDir,
listBrokerAgents,
relayAgentLabel,
} from './fleet.mjs';
import { requestHerdr } from './herdr-socket.mjs';

export const FLEET_WORKSPACE_LABEL = 'Relay fleet';
export const FLEET_AGENT_ENTRYPOINT = 'fleet-agent';

function createdWorkspace(response) {
const workspace = response?.result?.workspace;
const rootPane = response?.result?.root_pane;
if (typeof workspace?.workspace_id !== 'string' || typeof rootPane?.pane_id !== 'string') {
throw new Error('Herdr did not return the created fleet workspace');
}
return { workspaceId: workspace.workspace_id, rootPaneId: rootPane.pane_id };
}

function openedPane(response) {
const pane = response?.result?.plugin_pane?.pane;
if (typeof pane?.pane_id !== 'string') throw new Error('Herdr did not return the opened fleet pane');
return pane;
}

export async function runFleetPicker({
environment = process.env,
listAgents = listBrokerAgents,
findChief = chiefAgentName,
request = requestHerdr,
logger = console,
} = {}) {
const socketPath = environment.HERDR_SOCKET_PATH;
if (!socketPath) throw new Error('Herdr did not provide HERDR_SOCKET_PATH');
const pluginId = environment.HERDR_PLUGIN_ID;
if (!pluginId) throw new Error('Herdr did not provide HERDR_PLUGIN_ID');

const projectDir = fleetProjectDir(environment);
const mode = attachMode(environment.HERDR_RELAY_ATTACH_MODE);
const agents = await listAgents(projectDir);
if (!agents.length) {
throw new Error(`No live Agent Relay broker agents were found for ${projectDir}`);
}
const residentChief = await findChief(projectDir);

const created = createdWorkspace(
await request(socketPath, 'workspace.create', {
cwd: projectDir,
label: FLEET_WORKSPACE_LABEL,
focus: false,
})
);
const opened = [];
try {
for (const agent of agents) {
const label = relayAgentLabel(agent);
const response = await request(socketPath, 'plugin.pane.open', {
plugin_id: pluginId,
entrypoint: FLEET_AGENT_ENTRYPOINT,
placement: 'tab',
workspace_id: created.workspaceId,
cwd: projectDir,
focus: false,
env: {
HERDR_RELAY_AGENT_NAME: agent.name,
HERDR_RELAY_AGENT_LABEL: label,
HERDR_RELAY_ATTACH_MODE: mode,
HERDR_RELAY_RESIDENT_CHIEF: agent.name === residentChief ? '1' : '0',
},
});
const pane = openedPane(response);
opened.push({ agent: agent.name, paneId: pane.pane_id });

await request(socketPath, 'pane.rename', { pane_id: pane.pane_id, label: agent.name });
}

await request(socketPath, 'pane.close', { pane_id: created.rootPaneId });
await request(socketPath, 'workspace.focus', { workspace_id: created.workspaceId });
} catch (error) {
await request(socketPath, 'workspace.close', { workspace_id: created.workspaceId }).catch(() => undefined);
throw error;
}

logger.log(
`Opened ${opened.length} live Agent Relay agent pane(s) in ${FLEET_WORKSPACE_LABEL} from ${projectDir}.`
);
return { projectDir, workspaceId: created.workspaceId, panes: opened };
}

export function isDirectEntrypoint(moduleUrl, argv1) {
return Boolean(argv1) && moduleUrl === pathToFileURL(argv1).href;
}

export function waitForDismiss(input = process.stdin, output = process.stdout) {
if (!input.isTTY) return Promise.resolve();
output.write('\nPress Enter to close this pane.\n');
input.setEncoding('utf8');
input.resume();
return new Promise((resolve) => input.once('data', resolve));
}

export async function main({ dismiss = waitForDismiss, pickerOptions } = {}) {
try {
await runFleetPicker(pickerOptions);
} catch (error) {
console.error(`Agent Relay fleet picker failed: ${error.message}`);
process.exitCode = 1;
await dismiss();
}
}

if (isDirectEntrypoint(import.meta.url, process.argv[1])) await main();
Loading
Loading