Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
},
"metadata": {
"description": "Run jobs on Codex, Grok Build, ZCode and Kimi Code from Claude Code like Claude agents.",
"version": "3.0.0-dev.24"
"version": "3.0.0-dev.25"
},
"plugins": [
{
"name": "relay",
"description": "Run jobs on Codex, Grok Build, ZCode and Kimi Code from Claude Code like Claude agents.",
"version": "3.0.0-dev.24",
"version": "3.0.0-dev.25",
"author": {
"name": "Libar"
},
Expand Down
20 changes: 9 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ The plugin's hooks module answers `/relay:status`, `/relay:result`, `/relay:canc
tokens spent. The answer starts with the command as you typed it, then the companion's lines in a
code block, as it prints them, then a job's report as Markdown, or the job list as a table.
`claude -p "/relay:result <job>"` exits with the companion's exit code (0 completed with a report;
3 failed, dead, completed without a report, or messages not yet run; 4 queued or running; 5 cancelled), with
3 failed, dead, completed without a report, or messages not yet run; 4 queued or running; 5 cancelled; 6 not started), with
`--output-format text` or `json`; under `stream-json` Claude Code exits 0. These four run only when
you type them. When you ask in plain words, Claude reads jobs with the companion in Bash, which a
session in `default` permission mode asks you about, and cancels a job by sending its agent the
Expand Down Expand Up @@ -350,14 +350,12 @@ Defaults use physical total memory `T` in bytes, `GiB = 2^30` bytes and
`max`/`ultra` on other models, never enters this pool. Opaque defaults remain unknown;
only identified selections are covered.
- At most `Q = max(8, 2F)` resident waiting workers, including workers being spawned, are kept.
Worker creation holds `64 MiB` for 30 seconds against the same host/scope budget. A full
worker budget or insufficient memory leaves durable work without a worker and returns its
original job ID. Keep calling `wait`: the submitter/adopter's wait, message or relay-hook
loop retries worker creation at most once per admission poll (3 seconds by default).
A bare `start` can remain dormant until someone waits; there is no background queue service.
`status` only reads jobs: use `wait <job>` to adopt deferred work. A workerless job with no
spawn retry for 15 minutes stops deferring the stop-time review gate; it remains adoptable.
The limit bounds workers, not all submissions, native transcripts or waiting clients.
Worker creation holds `64 MiB` for 30 seconds against the same host/scope budget.
A job is accepted only once its own worker owns it; a job that cannot get a worker within
a minute is reported as not started (exit 6), and nothing runs later. There is no durable queue.
Workflow relays wait at spawn as queued until a waiting place frees, without a model turn
or a job waiter. Plain `relay:job` calls wait up to 30 seconds, then report capacity.
Use a Workflow for large batches. Accepted jobs keep running after the caller ends.

Linux reads `MemAvailable` (with a labelled Node >=22 fallback). macOS uses the
`kern.memorystatus_level` percentage of total memory, with a labelled `vm_stat` free+inactive
Expand Down Expand Up @@ -436,8 +434,8 @@ the relay's final text against the job's result instead. The PostToolUse hook ca
relay that `TaskStop` stopped. The hooks module (`hooks/mod/register.js`) adds an `agent.spawn` hook,
which checks a `relay:job` prompt's header and refuses the spawn with the parser's message when the
header is bad, and a `session.send` hook: when a message is sent to a relay, it ends the relay's
current wait, and the guard delivers the message at the relay's next call. The module only checks
headers and wakes a waiting relay: delivery runs on command hooks and the transcript. Its
current wait, and the guard delivers the message at the relay's next call. The module checks
headers, gates relay starts and wakes a waiting relay: delivery runs on command hooks and the transcript. Its
`command.run` hooks answer `/relay:status`, `/relay:result`, `/relay:cancel` and `/relay:transfer`,
and its status line counts the repository's running and dead jobs and owed messages
([design.md](docs/design.md#the-status-line)).
Expand Down
20 changes: 11 additions & 9 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,16 @@ A Stop during the launch handoff may prevent dispatch; a later waiter can retry

## When a job waits

A job's block can show `state: queued` and a `queued:` line explaining why it is waiting. This is pending work,
before it starts or between turns. While its relay agent or the companion's `wait` command is waiting, the job
starts by itself when the reason clears. Leave that call running; do not launch the job again. If you started a
job directly without an agent or a `wait` call, ask Claude to wait for that job by its id. `/relay:status` shows
progress but does not start waiting jobs. You can still cancel the job or send it a message by its id.
A job's block can show `state: queued` and a `queued:` line explaining why its live worker is
waiting, before a backend starts or between turns. Its worker starts the work when the reason clears,
even if you close the waiting call. Leave the job running; collect its result by id.

In Claude Code's Workflow panel a relay agent can look idle for minutes: it sits in relay's `wait`
call, which returns within seconds of the job ending.
When relay's start queue is full, Workflow agents wait in the panel as queued, using no model tokens
and creating no job waiter until a place frees. A plain `relay:job` call waits up to 30 seconds, then
reports that relay is at capacity. Use a Workflow for large batches. A direct start that cannot get
its own worker within a minute reports `state: not started` (exit 6); nothing runs later. Submit it
again when capacity is available. A launcher that ends before handing off ownership shows as dead.
A legacy workerless queue is also dead; `wait --auto-resume` can recover it explicitly.

The load guard shares capacity across your sessions. New backend starts and replacements are spaced
at least 5 seconds apart; turns on a running backend are not paced.
Expand All @@ -193,7 +195,7 @@ On macOS, tracking and cleanup use process groups plus sampled descendants; memo
running tools are not paused or limited. A detached child that orphans between samples can escape
tracking on systems without scopes.

A queued job still occupies its Claude agent and, in a Workflow, one of that run's agent slots. A Workflow can
After admission, a queued job occupies its Claude agent and one Workflow agent slot. A row held at spawn has no running relay model or waiter. A Workflow can
run up to 16 agents at once, fewer with fewer CPUs; `CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS` changes that
limit. Additional agents wait. At Claude's usage limit, Agent-tool relays fail; Workflows pause when
auto-continue is on. Their backend jobs keep running. Ask Claude to collect those jobs or resume the paused
Expand Down Expand Up @@ -328,7 +330,7 @@ counts them:
⚠ relay: 2 running · 1 queued (memory low) · 1 dead · 3 owed
```

- `queued` counts pending jobs and shows a queue reason. A job waiting to start is queued, even before its
- `queued` counts pending jobs with a live worker and shows a queue reason. A job waiting to start is queued while its
background process starts.
- `running` counts the jobs that are starting or running.
- `dead` counts jobs whose worker died or never came up; a message or the companion's `resume` continues a dead job
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@libar-dev/relay-plugin-cc",
"version": "3.0.0-dev.24",
"version": "3.0.0-dev.25",
"private": true,
"type": "module",
"description": "Run jobs on Codex, Grok Build, ZCode and Kimi Code from Claude Code like Claude agents.",
Expand Down
2 changes: 1 addition & 1 deletion plugins/relay/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "relay",
"version": "3.0.0-dev.24",
"version": "3.0.0-dev.25",
"description": "Run jobs on Codex, Grok Build, ZCode and Kimi Code from Claude Code like Claude agents.",
"author": {
"name": "Libar"
Expand Down
12 changes: 12 additions & 0 deletions plugins/relay/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ its entries here.

Changed:

- Job acceptance requires a live worker lease within one minute; refused starts exit 6 and never run later.
Workflow relays wait at spawn for a waiting place, including stall retries at their first step;
plain relay calls report capacity after 30 seconds. Large batches should use Workflows.
- Foreground `task` workers run independently of the caller; Ctrl-C leaves a dispatched job running.
- Jobs own their execution groups and stop their tools at backend loss, cancellation and completion.
Linux workers use per-job scopes whenever a user manager is available, and set OOM score 500.
Expand All @@ -21,6 +24,15 @@ Changed:

Fixed:

- Recovery preserves a cancellation or successor worker that wins during the dispatch handoff.
- Delivered corrections retain their automatic recovery allowance if the worker exits during acknowledgement.
- Unreadable message storage fails explicitly and preserves owed work; it cannot be mistaken for an empty inbox at completion.
- Continuations and recovery use the same bounded launch path. Undelivered pending messages are
withdrawn rather than acknowledged without a worker, and interrupted recovery keeps its message id.
- Repeated launch failures stop after three attempts and show the error and count. Handoff retries
preserve owed continuations, and messages racing worker exit retry without a five-second delay.
- Nested workers publish their identity independently of admission storage, including with the guard off.
Admission retains time per wait reason and reports the longest hold after admission.
- Repeated cleanup preserves a predecessor's first observed death, keeping the recorded
ordering of predecessor exit and replacement startup intact.
- Worker heartbeats keep process accounting active with the load guard off without creating
Expand Down
2 changes: 1 addition & 1 deletion plugins/relay/agents/job.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Run with Bash:
node "${CLAUDE_PLUGIN_ROOT}/scripts/companion.mjs" start --from-relay
```

If the output has a line `job: <id>`, remember the id and go to 2. Otherwise the start failed: hand back the start output at once, every line, nothing before it and nothing after it, and run nothing else, unless a hook's reason tells you to.
If the output starts with `state: not started`, hand back that output at once, every line, and run nothing else. Otherwise, if the output has a line `job: <id>`, remember the id and go to 2. Otherwise the start failed: hand back the start output at once, every line, nothing before it and nothing after it, and run nothing else, unless a hook's reason tells you to.

## 2. Wait

Expand Down
88 changes: 73 additions & 15 deletions plugins/relay/hooks/mod/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,30 @@ async function drawJob($, { Box, Text, Button, Input, Markdown }, job, board) {
return h(Box, { flexDirection: "column", marginTop: 1 }, ...nodes);
}

// Host process waits do not spend the hook's ten-second JavaScript budget.
// Cancellation must reach the registered catch: a swallowed abort could spawn work
// after the user stopped it. Ordinary gate failures still fall back to start's bound.
async function waitForPlace($, workflow, agentId, transcript) {
for (;;) {
const argv = ["node", $.plugin.root + "/scripts/companion.mjs", "gate", "--wait-seconds", workflow ? "540" : "30"];
if (agentId) argv.push("--agent", agentId, "--retry");
if (transcript) argv.push("--transcript", transcript);
let result;
try { result = await $.process.run(argv, { timeoutMs: workflow ? 600000 : 40000 }); }
catch (error) {
if (["abort", "cancelled", "timeout"].includes(error?.kind) || error?.name === "AbortError" || /abort|cancel|timeout/i.test(String(error?.message ?? error))) throw error;
return {};
}
if (result.exitCode !== 0) return {};
const output = String(result.stdout ?? "").trim();
const pass = /^pass(?: ([a-f0-9-]{36}))?$/.exec(output);
if (pass) return { reservation: pass[1] ?? null };
const hold = /^hold (.+)$/.exec(output);
if (!hold) return {};
if (!workflow) return { deny: "relay is at capacity: " + hold[1] + ". Try again in a minute, or start these jobs from a Workflow, which waits for a place." };
}
}

export function register(on) {
// One timer and one read in flight per load of the module. `fastUntil` is the last tick of the
// every-tick reads a relay event (a relay:job spawn, a wake, one of the commands below) asks for.
Expand Down Expand Up @@ -1089,26 +1113,59 @@ export function register(on) {
return next(e);
});

// In-memory identities only avoid duplicate hook work; all places and their
// expiry live under the coordinator's admission lock, never in mod counters.
const spawned = new Set();
const relayAgents = new Map();
const binding = new Map();
on("agent.spawn", async ($, e, next) => {
if (e?.subagentType === "relay:job") {
poller.fastUntil = poller.ticks + CLEARED_TICKS;
}
if (!isCheckedSpawn(e)) {
return next(e);
}
let refusal = null;
try {
refusal = await headerRefusal($, e);
} catch {
// A check that could not run says nothing about the header; the relay's start checks it.
refusal = null;
if (e?.subagentType !== "relay:job") return next(e);
poller.fastUntil = poller.ticks + CLEARED_TICKS;
if (e.resume != null) { if (e.resume.agentId) spawned.add(e.resume.agentId); return next(e); }
if (isCheckedSpawn(e)) {
let refusal = null;
try { refusal = await headerRefusal($, e); } catch {}
if (refusal !== null) return { deny: refusal };
}
if (refusal !== null) {
// Claude Code puts its own "spawn denied by a plugin" before the reason.
return { deny: refusal };
const gate = await waitForPlace($, e.workflow != null);
if (gate.deny) return gate;
const result = await next(e);
if (result?.agentId) {
spawned.add(result.agentId);
if (gate.reservation) {
try {
const pending = $.process.run(["node", $.plugin.root + "/scripts/companion.mjs", "gate", "--bind", gate.reservation, "--agent", result.agentId], { timeoutMs: 10000 });
binding.set(result.agentId, pending);
await pending;
} catch {
// The agent is already running; start's bounded admission remains authoritative.
} finally { binding.delete(result.agentId); }
}
}
return result;
}).catch(($, e, next) => {
if (next.called) return next(e);
return { deny: "relay admission was interrupted. Try again, or resume the Workflow." };
});

// Workflow stall retries bypass agent.spawn. Track each actual attempt and
// hold its first model step using the same host-process gate.
on("classic.SubagentStart", async ($, e, next) => {
if (e.agent_type === "relay:job") relayAgents.set(e.agent_id, e.transcript_path);
return next(e);
});
on("turn.step", async function* ($, e, next) {
if (e.index !== 0 || !relayAgents.has(e.agentId)) return yield* next(e);
if (binding.has(e.agentId)) { try { await binding.get(e.agentId); } catch {} }
if (!spawned.delete(e.agentId)) await waitForPlace($, true, e.agentId, relayAgents.get(e.agentId));
return yield* next(e);
}).catch(async function* ($, e, next) {
// The engine reuses an in-flight next result here; it does not run the model twice.
if (next.called) return yield* next(e);
const answer = "state: not started\nerror: relay admission was interrupted; resume the Workflow.";
yield { kind: "text", index: 0, text: answer };
return { turnId: e.turnId, index: e.index, answer, toolUses: [], stopReason: "end_turn", usage: null };
});

on("session.send", async ($, e, next) => {
const result = await next(e);
Expand Down Expand Up @@ -1161,6 +1218,7 @@ export function register(on) {
// after it has returned, and a slow or stuck listing never holds Claude Code's stop.
on("turn.complete", async ($, e, next) => {
const completed = await next(e);
if (e.agentId != null) { relayAgents.delete(e.agentId); spawned.delete(e.agentId); binding.delete(e.agentId); }
if (e.agentId != null && e.reason === "aborted") {
try {
$.clock.after(0, () => {
Expand Down
Loading
Loading