agent-dispatch
Tier 1. No titan dependencies.
npm install @titan-design/agent-dispatchThe problem it solves
agent-chat has no library entry point that spawns an agent, and its broker serves no spawn route over HTTP. The supported programmatic surface is the agent-chat CLI. Shelling out to it safely has three traps: PATH is not what a launchd job expects, process.env leaks into the child, and a brief passed in argv is readable by every local process through ps.
This package is the one client that handles all three. dispatchToAgentChat sends the brief on stdin under --brief-stdin, so no brief text ever reaches argv, and first refuses any profile whose installed file is not "surface": "headless", because agent-chat puts an interactive agent's brief in claude's argv. execSafe runs an absolute path with a minimal environment and no shell.
When to reach for it
A product that must start an agent-chat agent under a named profile, or give an ended agent's Claude Code session one more message, from code. To run one headless Claude turn in-process with a typed result, use agent instead. To record which process owns a running execution, use agent-lifecycle.
Example
Verified against 0.0.0.
import { BrokerUnavailableError, assertInstalledHeadless, dataFence, dispatchToAgentChat, execSafe, listAgents, messageAgent, minimalEnv, parkAgent, resolveBinaryPath, resumeAgent, resumeArgs, retire } from "@titan-design/agent-dispatch";
const profiles = ["headless-implementer", "headless-reviewer"];
await dispatchToAgentChat(
{
agentChatBinPath: "/opt/homebrew/bin/agent-chat",
peerName: "item-42",
profile: "headless-implementer",
brief: "Fix the squeaky floor",
briefing: "my-initiative",
cwd: "/path/to/repo",
},
15_000,
profiles,
);
// { peerName: "item-42" } once the broker accepts the spawn
assertInstalledHeadless("headless-reviewer"); // throws DispatchError unless <agent-chat home>/profiles/headless-reviewer.json is headless
const ended = (await listAgents("/opt/homebrew/bin/agent-chat", 15_000)).filter((a) => a.presence === "exited");
await retire("/opt/homebrew/bin/agent-chat", "item-42", 15_000); // { name: "item-42", caveats: [] }
await parkAgent("/opt/homebrew/bin/agent-chat", "item-7", 60_000); // { name: "item-7", lines: ["Parked item-7.", ...] }
await messageAgent("/opt/homebrew/bin/agent-chat", "item-9", "CI is red; the log tail follows", 15_000); // a live agent's next turn
try {
await resumeAgent("/opt/homebrew/bin/agent-chat", "item-42", dataFence("CI log", logTail), 15_000);
// { name: "item-42", lines: ["Resumed item-42 on its existing conversation, ...", ...] }
} catch (err) {
if (!(err instanceof BrokerUnavailableError)) throw err;
// nothing reached the broker; retry later
}
const claude = resolveBinaryPath("/opt/homebrew/bin/claude", "claude");
const turn = execSafe(claude, resumeArgs(sessionId, "CI is red; see the log"), minimalEnv(), 600_000, worktree);Limits
The ./limits subpath holds the spend limits a budget gate reads, as data rather than charter prose or constants. One versioned block keys pools by name, one per Claude account, and adds per-profile, per-seat and time-boxed layers:
import { checkLimits, liftQuestion, resolveLimits } from "@titan-design/agent-dispatch/limits";
import { loadLimits, readGrants } from "@titan-design/agent-dispatch/limits/node";
const parsed = loadLimits("/path/to/host-config.json", { key: "limits" });
const { grants } = readGrants("/path/to/limits-grants.json");
const result = resolveLimits(parsed, {
pool: "agents",
profile: "bd-implementer",
now: new Date(),
grants,
answeredQuestions: new Set(ownerAnswerIds),
fiveHourResetsAt: reading.resetsAt,
});
if (!result.open) refuse(result.reason); // a malformed or unknown pool fails closed
// result.limits: { ceiling_five_hour: 95, reserve_seven_day: 14, sonnet_band_points: 0, ...,
// sources: { ceiling_five_hour: "pool", ... }, applied: ["override:<decision>"] }
checkLimits(parsed, { now: new Date() }); // [{ kind: "expired_override", message: "..." }, ...]Each field resolves independently, the later layer winning: LIMIT_DEFAULTS, then defaults, the pool, the profile on every pool, the profile × pool entry, active overrides[] (later entry wins; null per-day lifts the cap), and answered grants. Then the ceiling is clamped to 0-100, the reserve to 0-100 and every margin to 0 or more.
- An override carries an
until. Past it the override stops applying andcheckLimitsreports it, so a standing value comes back with no edit. - A malformed pool closes that pool, a malformed profile closes spawns of that profile, a malformed seat closes that seat, and a malformed override or grant is ignored. Only a bad envelope (wrong
version, not an object) throwsLimitsConfigError. - Seat caps are a separate stop:
per_run_pointsis added andper_day_pointsonly ever lowers the pool's. liftQuestionasks once per pool per five-hour window when a pool with aliftreaches its ceiling.grantFromAnswerturns a yes into a grant until that window resets. The resolver accepts a grant only if its question id is inansweredQuestions, it ends byfiveHourResetsAt, and it clamps the grant to the pool'slift.
What it deliberately does not do
It holds no profile names, derives no peer names and installs no profile files: those are product policy, so the allowlist is an argument. The limits subpath owns no file path, reads no usage, writes no grant and posts no question: the product passes paths, readings and owner answers in. It does not wait for the agent's work, decide whether an agent is live before a resume, or talk to the broker's socket directly.
Gotchas
- A zero exit from
dispatchToAgentChatmeans "started", not "done". dispatchToAgentChatreads<profile>.jsonfrom$AGENT_CHAT_HOME/profiles(else$HOME/.agent-chat/profiles) before the spawn (orprofilesDirwhen given) and refuses one that is missing or not headless. Tests that spawn through a fake binary must install that file. The broker re-reads the profile afterwards, so a write in between still reaches it until agent-chat's surface lock lands.- A spawn that hangs throws
DispatchTimeoutError, not plainDispatchError: the broker may already have started the agent, so read the roster before re-dispatching. - A failed spawn throws
DispatchErrorwith the CLI's stdout reason (broker refusals, including a name held by a live agent) or else its stderr (usage errors, such as an agent-chat too old to know--brief-stdin). execSafereturns a non-zero exit instead of throwing. It throwsExecTimeoutErroron a timeout, because a timed-out child may already have acted, andExecErrorwhen the binary cannot start.listAgentsneeds an agent-chat withagent ls --json; an older one fails with its usage error. A row missing a required field is skipped, not guessed at; an optional field with the wrong type is dropped from its row.retirethrowsDispatchTimeoutError(aDispatchError) when the CLI hangs: the broker may already have retired the agent, so read the roster before retrying.parkAgentrequires a timeout (DEFAULT_PARK_TIMEOUT_MSis 60 s). Only Shepherd passes{ noFixRoundCheck: true }, which needs an agent-chat that knows--no-fix-round-check.parkAgentthrowsDispatchErrorwhen the broker refuses a live, dirty, unpushed or shared tree. A broker that predates the verb (CC-282) times out on the CLI side and comes back as a refusal naming a broker restart.BrokerUnavailableErroris raised only for agent-chat's own unreachable-broker lines on stderr ("could not reach or start the agent-chat broker", a restart's "was NOT sent", or a bareconnect ECONNREFUSED|ENOENT <socket>). A refusal that quotes that text elsewhere stays a plainDispatchError.- Calls set
AGENT_CHAT_NO_AUTOSTART=1. An agent-chat that does not honour it yet still starts a broker when none is running. resumeAgentputs the message in argv, wherepsshows it. Fence untrusted text withdataFenceand keep secrets out of it.messageAgentreaches a live agent only, and the broker delivers the text from the human seat. For an ended agent useresumeAgent. The text is in argv.resumeArgsputs the message in argv. Resuming a live agent starts a second process on the same transcript; check liveness first.
Where it came from
Ported unchanged from relay's daemon/src/dispatch.ts, exec.ts and the resumeArgs builder in session.ts (TP-460), with their tests. relay's daemon now depends on the package and its copy is deleted; relay keeps only its profile policy in agent-profiles.ts. listAgents and retire were added afterwards for Shepherd, then resumeAgent, configDir, BrokerUnavailableError and dataFence (ported from agent-chat's burndown brief) by TP-518, parkAgent by TP-548, messageAgent by TP-728, and assertInstalledHeadless (ported from relay's daemon/src/profile-guard.ts, PR #59) by TP-470. The ./limits subpath (TP-2067) replaces the pools: and funds: blocks of a coordinator charter.