Skip to content

agent-dispatch ​

Tier 1. No titan dependencies.

sh
npm install @titan-design/agent-dispatch

The 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.

ts
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:

ts
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 and checkLimits reports 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) throws LimitsConfigError.
  • Seat caps are a separate stop: per_run_points is added and per_day_points only ever lowers the pool's.
  • liftQuestion asks once per pool per five-hour window when a pool with a lift reaches its ceiling. grantFromAnswer turns a yes into a grant until that window resets. The resolver accepts a grant only if its question id is in answeredQuestions, it ends by fiveHourResetsAt, and it clamps the grant to the pool's lift.

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 dispatchToAgentChat means "started", not "done".
  • dispatchToAgentChat reads <profile>.json from $AGENT_CHAT_HOME/profiles (else $HOME/.agent-chat/profiles) before the spawn (or profilesDir when 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 plain DispatchError: the broker may already have started the agent, so read the roster before re-dispatching.
  • A failed spawn throws DispatchError with 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).
  • execSafe returns a non-zero exit instead of throwing. It throws ExecTimeoutError on a timeout, because a timed-out child may already have acted, and ExecError when the binary cannot start.
  • listAgents needs an agent-chat with agent 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.
  • retire throws DispatchTimeoutError (a DispatchError) when the CLI hangs: the broker may already have retired the agent, so read the roster before retrying.
  • parkAgent requires a timeout (DEFAULT_PARK_TIMEOUT_MS is 60 s). Only Shepherd passes { noFixRoundCheck: true }, which needs an agent-chat that knows --no-fix-round-check.
  • parkAgent throws DispatchError when 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.
  • BrokerUnavailableError is 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 bare connect ECONNREFUSED|ENOENT <socket>). A refusal that quotes that text elsewhere stays a plain DispatchError.
  • Calls set AGENT_CHAT_NO_AUTOSTART=1. An agent-chat that does not honour it yet still starts a broker when none is running.
  • resumeAgent puts the message in argv, where ps shows it. Fence untrusted text with dataFence and keep secrets out of it.
  • messageAgent reaches a live agent only, and the broker delivers the text from the human seat. For an ended agent use resumeAgent. The text is in argv.
  • resumeArgs puts 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.

MIT licensed