references/dispatch-and-poll.md
# Dispatch and poll
`scripts/relay.mjs` wraps Kimi's headless prompt mode, captures its structured stream, and writes a
`result.json`. Run one command, then read one file.
## Before the first run
```bash
command -v kimi
kimi --version
kimi login
```
Install with `brew install kimi-code` on macOS/Linux or use a native installer from the
[official Kimi Code documentation](https://moonshotai.github.io/kimi-code/en/). `kimi login` uses a
device-code flow without opening the TUI; `/login` is also available inside the TUI.
## Dispatching
```bash
node "<skill-dir>/scripts/relay.mjs" --brief brief.txt --cd /path/to/repo
```
`<skill-dir>` is the installed folder containing this skill's `SKILL.md`.
| Flag | Effect |
| --- | --- |
| `--brief <file>` | Brief path. Omit it to read the brief from stdin. |
| `--cd <dir>` | Working root and child process cwd (default: current directory). |
| `--lane <name>` | Fleet lane from `delegate-setup` config. Applies that lane's dials; fails if the lane's `implementer` is not this relay. Explicit dial flags win. |
| `--model <alias>` | Kimi model alias for this run (default: Kimi's own `default_model`). |
| `--session <id>` | Resume a specific Kimi session; send only the delta brief. |
| `--resume-last` | Resume the most recent Kimi session for this cwd (`kimi --continue`); send only the delta brief. |
| `--add-dir <dir>` | Add an extra workspace directory. Repeatable. Edits there are not reported in `touchedFiles`. |
| `--timeout <dur>` | Relay watchdog (default: `30m`; h/m/s strings). Kimi has no timeout flag. |
| `--out-dir <dir>` | Artifact directory (default: a fresh directory under the system temp dir). |
| `-h`, `--help` | Print the relay's header help. |
`--session` and `--resume-last` are mutually exclusive. The child cwd pins the primary workspace;
`--add-dir` adds extra workspaces only.
Headless `-p` mode always uses Kimi's auto permission mode. Kimi rejects `--prompt` combined with
`--yolo`, `--auto`, or `--plan`, so the relay passes no autonomy flags and has no `--read-only` or
`--full-access` option. Inspect `touchedFiles` and the diff after every run.
## Artifacts and result fields
Artifacts live outside the repo by default, so they do not appear in `touchedFiles`; an `--out-dir`
inside the worktree can make the artifacts appear there:
- `brief.txt` - the exact brief.
- `events.jsonl` - raw Kimi stdout events.
- `final.txt` - assistant text joined with a blank line between chunks; absent if none was emitted.
- `stderr.txt` - complete stderr.
- `result.json` - the stable `delegate-relay.result.v1` contract.
`result.json` fields:
- `schema`, `tool` (`"kimi"`), `status` (`completed` | `failed` | `timeout` | `aborted` | `kimi_unavailable`), `exitCode`, and
`signal` (`null` unless the child died on a signal).
- `workdir`, `model` (the model alias or `null`), `resumed`, `kimiVersion`, `sessionId`, `startedAt`,
and `finishedAt`.
- `briefPath`, `finalPath`, `eventsPath`, and `stderrPath`.
- `finalMessage` - assistant `content` strings joined with `"\n\n"`; tool calls and tool results are
excluded.
- `touchedFiles` - `git status --porcelain` lines for the **final working tree under `--cd` only**,
not an attribution of Kimi's edits: anything already dirty before dispatch shows up too, and edits
Kimi makes inside `--add-dir` workspaces do not show up at all - inspect those trees yourself.
Dispatch from a clean tree when you want the list to read as "what Kimi changed". `null` means git
could not report; `[]` means git ran and the tree is clean.
- `stderrTail` - the last 20 non-empty stderr lines on any run that did not complete (`failed`, `timeout`, `aborted`), except a launch failure, which reports `failed` with no `stderrTail`.
- `error` - present for launch failures, when the relay watchdog fires (`timeout`), and on an `aborted` run.
Kimi's stream carries no token usage, so `result.json` has no `usage` field.
## Waiting for completion
The helper blocks. Use the orchestrator's background-command facility, or background it in a shell and
poll for `result.json`. The run is done only when the process exits and the file contains a `status`.
A pre-run usage error exits 2 and writes no result. A missing `kimi` exits 127 and writes
`status: "kimi_unavailable"`.
## When a run misbehaves
- **`status: "kimi_unavailable"` (exit 127):** install the native Kimi Code CLI, authenticate with
`kimi login`, and re-dispatch.
- **an `error` mentioning `version preflight` (`failed`, or `timeout` at exit 124):** the bounded
`kimi --version` probe exited non-zero or hung past its cap (10s, or `--timeout` when shorter), so
kimi was never dispatched; only the relay's own artifacts may already exist under `--out-dir`.
Check the install by running `kimi --version` yourself.
- **`status: "failed"`:** read `stderrTail`, `stderrPath`, and the tail of `events.jsonl`. A common
cause is an unconfigured model alias: `error: failed to run prompt: config.invalid: Model "<x>" is not configured in config.toml…`
- **`status: "aborted"`:** the relay itself was killed (its parent's timeout, a stopped task, a
closed terminal) and forwarded the kill to kimi. The result is written before the relay exits;
inspect the working tree before re-dispatching. On native Windows a hard kill of the relay is
uncatchable (Node supports no `SIGTERM` handler there), so this status may never get written -
a relay process that is gone without a `result.json` is an aborted run; inspect the working
tree and `events.jsonl` directly.
- **`status: "failed"` with `signal: "SIGKILL"`:** the host killed the process, commonly through the
OOM killer or a supervisor timeout. This is not a Kimi error; check host memory and re-dispatch, or
split the task into smaller briefs.
- **`status: "timeout"`:** the `--timeout` watchdog killed the run; `error` reads
`kimi did not finish within --timeout <dur>; killed by the relay watchdog`. Increase `--timeout` or
split the task. The relay sends SIGTERM, waits 10 seconds, then sends SIGKILL if needed.
- **Empty `finalMessage`:** inspect `touchedFiles` and the diff. Add a
`<structured_output_contract>` to the next brief to require a closing report.
## Recovering lost work
`events.jsonl` in the run directory records every event the implementer streamed. If finished
work is lost — the run killed late, or the working tree damaged afterward — read the event log
before re-dispatching: it identifies which files and tool commands were involved, which scopes
what needs redoing. Whether it also carries the edit contents depends on what the CLI streams,
so treat any reconstruction as unverified until it matches a working-tree diff — when the tree
still holds the work, preserve the tree rather than replaying the log.
## What the relay runs
The argv is equivalent to:
```bash
kimi --output-format stream-json [--model <alias>] [--session <id> | --continue] \
[--add-dir <dir> ...] --prompt=<brief>
```
The prompt rides argv and is visible in the host process list. The relay rejects briefs over 120 KB
before launch because the OS caps a single argument. It spawns the native `kimi` binary directly with
the selected `--cd` as cwd; no shell or Kimi timeout flag is involved.
## The commit boundary
The relay never commits. Kimi edits the working tree; the orchestrator reviews, re-runs the gates, and
commits. See [review-and-land.md](review-and-land.md).
references/review-and-land.md
# Review and land
Kimi did the typing; you own the judgment. Verify against reality, never the self-report, and read the
diff as generated code because a green gate cannot catch every failure mode.
## Check tests before trusting gates
If the diff touches existing tests, review those edits first:
- Treat unbriefed test edits as a contract change, not part of the fix.
- Treat newly skipped, disabled, or commented-out tests as failing until proven otherwise.
- Treat loosened assertions the same way: contains/truthy replacing exact matches, broadened error
types, and widened tolerances all weaken the gate.
## Re-run the gates yourself
`result.json` carries Kimi's claims, not evidence. Re-run the project's actual test, lint, and build
commands in the working tree and read their output. Passing is necessary, not sufficient.
For changes with a specialized verification shape:
- **Migrations or schema:** round-trip them and check for drift.
- **Removals or renames:** grep for dangling references.
- **Stateful behavior:** exercise the behavior, not just compilation.
## Read the diff against the brief
Start with `touchedFiles`, open the diff, and compare it to the brief:
- **Scope creep** - changes the brief excluded.
- **Scope shortfall** - missed behavior, edges, or cleanup.
- **Quiet judgment calls** - defensible but unasked decisions that need review.
## The implementer sweep
Check every diff for patterns gates often miss:
- Hardcoded success or fixture data on a real-work path.
- Catch-all error handling that returns a default instead of propagating or recovering.
- Imports, dependencies, methods, and signatures not present in the installed version.
- Unused imports, uncalled helpers, unreachable branches, and scaffolding comments.
- A second client, error idiom, or logging style beside the repo's existing one.
- Tests that assert internals instead of behavior, or near-duplicate test bodies.
- Optional parameters, config flags, and abstractions with no caller.
- Guards for impossible cases that hide trust-boundary validation.
Send anything blocking back to Kimi as a delta brief, or fix it in the tree, and report either choice
to the human. Run relevant guard skills if installed.
## The commit boundary
When the gates pass and the diff holds, **the orchestrator commits**, never the implementer. Write a
clear message describing what landed.
From dispatch until that commit, the uncommitted working tree is the authoritative copy of the
implementer's work — the only one you can commit from, and often the only copy at all. Never run `git checkout`, `reset`, `clean`, or a branch switch in the
workspace between those two points — however messy an interrupted run looks, inspect it first:
`git status`, `git diff`, `git diff --cached` for anything the implementer staged (plain
`git diff` is blind to the index), and open any untracked files (`??` in `git status`) directly —
they are the implementer's new files, and no diff shows their contents. The tree is evidence,
not clutter. After that inspection the
verdict can legitimately be to discard — work built on a premise you have since corrected, for
example — and then `git checkout`/`clean` is the right tool. The ban is on reflexive cleanup
before anyone has looked.
## Rework: send the delta
Continue the same session with only the correction:
```bash
echo "The fix is right, but the test mocks the DB session. Use the real migrated fixture and remove the
unused import." | node "<skill-dir>/scripts/relay.mjs" --resume-last --cd /path/to/repo
```
Use `--session <id>` instead when resuming the specific id recorded in `result.json`. Kimi itself
rejects combining `--continue` and `--session`; the relay rejects `--resume-last` plus `--session`
before launch. Rework gets the same gate rerun, test review, diff review, and implementer sweep.
Headless Kimi always uses auto permission mode and has no CLI-enforced read-only mode. Confirm
`touchedFiles` after every fresh or resumed run.
## Surface, do not absorb
The human opted into delegation, so committing verified, gate-passing work is the contract. Keep them
in the loop when the work changes shape:
- Report design decisions and defensible-but-unrequested turns.
- Note non-blocking nitpicks you did not block on.
- Stop and ask if correct completion requires going beyond the brief.
For a queue, keep these notes in the progress file described in
[multi-task-queues.md](multi-task-queues.md).
scripts/relay.mjs
#!/usr/bin/env node
/**
* delegate-skills · kimi-delegate · relay.mjs
*
* Dispatch a self-contained brief to the Kimi Code CLI (`kimi -p`), capture
* the run, and write a structured result the orchestrating agent can review.
* The orchestrator runs this one command and reads the result JSON - every
* Kimi-specific mechanic lives in here, which keeps the skill
* orchestrator-agnostic. Verified against kimi CLI 0.24.0 on macOS.
*
* Trust posture: relay.mjs itself makes no network calls, reads or writes no
* credentials, and sends no telemetry; it has no dependencies (Node built-ins
* only). It shells out only to `kimi` and `git`. The `kimi` process it launches
* does authenticate - exactly as you do at the terminal. Read this file before
* you run it.
*
* Note: `kimi -p` takes the prompt as a command-line argument, so the brief is
* visible in the host process list (`ps`, /proc). On a shared machine keep
* secrets out of the brief - reference them by a path or environment variable
* the workspace can read.
*
* It deliberately does NOT commit. Committing is always the orchestrator's job
* - after it reviews the diff and re-runs the project gates.
*
* Headless `-p` mode always uses Kimi's auto permission mode. Kimi rejects
* `--yolo`, `--auto`, and `--plan` when combined with `--prompt`, so this relay
* passes no autonomy flags and offers no read-only mode. The diff reported in
* `touchedFiles`, not a flag, is the guarantee of what changed.
*
* Kimi's supported Homebrew and official-installer distributions provide a
* native binary on every platform. The npm-installed `kimi` on Windows is a
* `.cmd` shim this relay does not launch; use the native install there.
*
* Usage:
* node relay.mjs --brief <file> [options]
* cat brief.txt | node relay.mjs [options]
*
* Options:
* --brief <file> Path to the brief. If omitted, read it from stdin.
* --cd <dir> Working root for Kimi (default: current directory).
* --lane <name> Fleet lane from delegate-setup config (dials apply; explicit flags win).
* --model <alias> Kimi model alias (default: Kimi's own default_model).
* --session <id> Resume a specific Kimi session; send only the delta brief.
* --resume-last Resume the most recent Kimi session for this cwd;
* send only the delta brief.
* --add-dir <dir> Add an extra workspace directory. Repeatable.
* --timeout <dur> Relay-side watchdog (default: 30m). Kimi has no
* timeout flag; durations use h/m/s strings.
* --out-dir <dir> Where to write run artifacts (default: a fresh dir
* under the system temp dir).
* -h, --help Show this help.
*
* Result: written to <out-dir>/result.json and summarized on stdout -
* status, exitCode, signal, kimiVersion, sessionId, finalMessage (Kimi's own
* report), touchedFiles (git porcelain, null if git cannot report), and paths
* to brief.txt, final.txt, events.jsonl, and stderr.txt.
*
* Exit codes: a pre-run usage error (bad/missing args, empty brief) exits 2
* before any run and writes no result file; a missing `kimi` binary exits 127;
* otherwise the exit code mirrors Kimi's own (0 success, non-zero failure). If
* the child dies on a signal, the exit code is 128 plus the signal number and
* `result.json` records the signal. Once the brief validates, `result.json` is
* written on every outcome - completed, failed, timeout (the --timeout watchdog
* fired), aborted (the relay itself was killed and forwarded the kill to kimi),
* or kimi_unavailable.
*/
import {spawn, execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync, appendFileSync } from "node:fs";
import {join, resolve, basename, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { constants, tmpdir } from "node:os";
import { StringDecoder } from "node:string_decoder";
const MAX_BUFFERED_CHARS = 1_048_576;
const DEFAULT_TIMEOUT = "30m";
const VERSION_PROBE_TIMEOUT_MS = 10_000;
const MAX_TIMER_MS = 2_147_483_647;
const IMPLEMENTER_KEY = "kimi";
function makeEventScanner(onObject) {
let buf = "";
let index = 0;
let depth = 0;
let start = -1;
let inString = false;
let escaped = false;
return (chunk) => {
if (!chunk) return;
buf += chunk;
for (;;) {
while (index < buf.length) {
const ch = buf[index];
// Only track strings inside an object (depth > 0). At depth 0 we are
// skipping a junk prefix, and an unmatched `"` there must not swallow the
// real `{...}` that follows in the same chunk.
if (inString) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === '"') inString = false;
} else if (ch === '"') {
if (depth > 0) inString = true;
} else if (ch === "{") {
if (depth === 0) start = index;
depth += 1;
} else if (ch === "}") {
if (depth > 0) {
depth -= 1;
if (depth === 0 && start !== -1) {
const slice = buf.slice(start, index + 1);
try { onObject(JSON.parse(slice)); } catch { /* skip malformed */ }
start = -1;
}
}
}
index += 1;
}
if (depth === 0 || start === -1 || buf.length - start <= MAX_BUFFERED_CHARS) break;
// A complete object may exceed the retained-input cap within this chunk.
// Drop only an oversized partial, then rescan its suffix so a later
// concatenated event is not lost.
buf = buf.slice(start + MAX_BUFFERED_CHARS);
index = 0;
start = -1;
depth = 0;
inString = false;
escaped = false;
}
if (depth > 0 && start !== -1) {
if (start > 0) {
buf = buf.slice(start);
index -= start;
start = 0;
}
} else {
buf = "";
index = 0;
start = -1;
}
};
}
function applyFleetLane(opts, flagged) {
if (!opts.lane) return;
const script = join(dirname(fileURLToPath(import.meta.url)), "../../delegate-setup/scripts/lane.mjs");
if (!existsSync(script)) {
fail("--lane requires the delegate-setup skill installed beside this relay");
}
const r = spawnSync(
process.execPath,
[script, "resolve", "--cwd", opts.cd, "--lane", opts.lane, "--implementer", IMPLEMENTER_KEY],
{ encoding: "utf8", env: process.env },
);
if (r.error) fail(`lane resolve failed: ${r.error.message}`);
if (r.status !== 0) {
fail((r.stderr || "lane resolve failed").trim().replace(/^lane\.mjs:\s*/, ""));
}
let resolved;
try {
const lines = (r.stdout || "").trim().split("\n").filter(Boolean);
resolved = JSON.parse(lines[lines.length - 1]);
} catch {
fail("lane resolve returned invalid JSON");
}
opts.laneSource = resolved.source;
for (const [field, value] of Object.entries(resolved.dials || {})) {
if (flagged.has(field)) continue;
if (field === "autonomy" && (flagged.has("autonomy") || flagged.has("sandbox") || flagged.has("readOnly"))) continue;
if (field === "agent" && (flagged.has("agent") || flagged.has("readOnly"))) continue;
if (field === "sandbox" && (flagged.has("sandbox") || flagged.has("readOnly"))) continue;
if (field === "permissionMode" && (flagged.has("permissionMode") || flagged.has("readOnly"))) continue;
if (field === "planOnly" && (flagged.has("planOnly") || flagged.has("readOnly"))) continue;
if (field === "readOnly" && flagged.has("readOnly")) continue;
if (field === "force" && flagged.has("force")) continue;
opts[field] = value;
}
}
function fail(message, code = 2) {
process.stderr.write(`relay: ${message}\n`);
process.exit(code);
}
function parseArgs(argv) {
const flagged = new Set();
const opts = {
lane: null,
laneSource: null,
brief: null,
cd: process.cwd(),
model: null,
session: null,
resumeLast: false,
addDirs: [],
timeout: DEFAULT_TIMEOUT,
outDir: null,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
const value = argv[i + 1];
if (value === undefined) fail(`${arg} requires a value`);
i += 1;
return value;
};
switch (arg) {
case "-h":
case "--help":
process.stdout.write(headerComment());
process.exit(0);
break;
case "--brief": opts.brief = next(); break;
case "--cd": opts.cd = resolve(next()); break;
case "--lane": opts.lane = next(); break;
case "--model": opts.model = next(); flagged.add("model"); break;
case "--session": opts.session = next(); break;
case "--resume-last": opts.resumeLast = true; break;
case "--add-dir": opts.addDirs.push(next()); break;
case "--timeout": opts.timeout = next(); flagged.add("timeout"); break;
case "--out-dir": opts.outDir = resolve(next()); break;
default:
fail(`unknown option: ${arg}`);
}
}
applyFleetLane(opts, flagged);
if (opts.resumeLast && opts.session) {
fail("--resume-last and --session are mutually exclusive; pass only one");
}
// kimi resolves a relative --add-dir against ITS cwd, so resolve against --cd
// (not the relay's own cwd) - and only after the loop, since --add-dir may
// appear before --cd on the command line. resolve() passes absolutes through.
opts.addDirs = opts.addDirs.map((dir) => resolve(opts.cd, dir));
// The watchdog is relay-only (kimi has no timeout flag), so a malformed
// --timeout must fail loudly here - a silent 30m fallback would be wrong.
if (parseDuration(opts.timeout) === null) {
fail(`--timeout "${opts.timeout}" is invalid or too long; use a positive h/m/s duration no longer than about 24 days`);
}
return opts;
}
function headerComment() {
// The leading block comment doubles as --help text.
const src = readFileSync(new URL(import.meta.url), "utf8");
const match = src.match(/\/\*\*([\s\S]*?)\*\//);
if (!match) return "relay.mjs - dispatch a brief to kimi -p\n";
return `${match[1].replace(/^\s*\* ?/gm, "").trim()}\n`;
}
function readBrief(opts) {
if (opts.brief) {
if (!existsSync(opts.brief)) fail(`brief file not found: ${opts.brief}`);
return readFileSync(opts.brief, "utf8");
}
if (process.stdin.isTTY) {
fail("no --brief given and stdin is a TTY; pass --brief <file> or pipe the brief on stdin");
}
let stdin = "";
try {
stdin = readFileSync(0, "utf8");
} catch {
stdin = "";
}
return stdin;
}
function killChild(child, signal = "SIGTERM") {
if (!child || !child.pid) return;
if (process.platform === "win32") {
if (signal !== "SIGTERM") return;
try {
execFileSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
stdio: ["ignore", "ignore", "inherit"],
});
} catch {
// The process tree already exited.
}
return;
}
try {
process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
// The process group already exited.
}
}
}
function versionProbeTimeout(opts) {
// The watchdog is only armed once kimi is running, so the preflight needs a bound of its
// own: a `kimi --version` that never returns would wedge the relay here, before any
// result.json exists, and --timeout could not reach it.
return Math.min(parseDuration(opts.timeout), VERSION_PROBE_TIMEOUT_MS);
}
function kimiVersion(probeTimeoutMs) {
try {
const out = execFileSync("kimi", ["--version"], {
encoding: "utf8",
timeout: probeTimeoutMs,
killSignal: "SIGKILL",
}).trim();
return { version: out || "unknown", error: null };
} catch (err) {
// Only a missing binary means "unavailable"; any other version-probe
// failure must not masquerade as exit 127.
if (err && err.code === "ENOENT") return { version: null, error: null };
// A hung probe we killed, or a real non-zero exit, means kimi is installed but not
// usable. Dispatching anyway would send the brief to a CLI already known to be broken.
return { version: null, error: err };
}
}
function parseDuration(duration) {
const match = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(duration);
if (!match || (!match[1] && !match[2] && !match[3])) return null;
try {
const seconds =
BigInt(match[1] || 0) * 3600n +
BigInt(match[2] || 0) * 60n +
BigInt(match[3] || 0);
const milliseconds = seconds * 1000n;
if (milliseconds <= 0n || milliseconds > BigInt(MAX_TIMER_MS)) return null;
return Number(milliseconds);
} catch {
return null;
}
}
function gitTouchedFiles(cwd) {
try {
const output = execFileSync("git", ["status", "--porcelain"], {
cwd,
encoding: "utf8",
timeout: 10_000,
killSignal: "SIGKILL",
stdio: ["ignore", "pipe", "ignore"],
maxBuffer: 64 * 1024 * 1024,
});
return output.split("\n").map((line) => line.trimEnd()).filter(Boolean);
} catch {
return null;
}
}
function timestamp() {
return new Date().toISOString().replace(/[:.]/g, "-");
}
function buildArgv(opts, brief) {
const argv = ["--output-format", "stream-json"];
if (opts.model) argv.push("-m", opts.model);
if (opts.session) argv.push("--session", opts.session);
else if (opts.resumeLast) argv.push("--continue");
for (const dir of opts.addDirs) argv.push("--add-dir", dir);
// Use --prompt=<brief>, not a separate ["--prompt", brief] pair: the equals
// form binds a brief that starts with "-" instead of letting it parse as a flag.
argv.push(`--prompt=${brief}`);
return argv;
}
function prepareRunDir(opts, brief) {
const startedAt = new Date().toISOString();
const outDir = opts.outDir || join(tmpdir(), "delegate-relay", `${basename(opts.cd) || "repo"}-${timestamp()}`);
mkdirSync(outDir, { recursive: true });
const run = {
startedAt,
briefPath: join(outDir, "brief.txt"),
finalPath: join(outDir, "final.txt"),
eventsPath: join(outDir, "events.jsonl"),
stderrPath: join(outDir, "stderr.txt"),
resultPath: join(outDir, "result.json"),
};
writeFileSync(run.briefPath, brief, "utf8");
writeFileSync(run.eventsPath, "", "utf8");
writeFileSync(run.stderrPath, "", "utf8");
return run;
}
function makeResultWriter(opts, version, run) {
return (extra) => {
const result = {
schema: "delegate-relay.result.v1",
lane: opts.lane,
laneSource: opts.laneSource,
tool: "kimi",
workdir: opts.cd,
model: opts.model,
resumed: Boolean(opts.resumeLast || opts.session),
kimiVersion: version,
startedAt: run.startedAt,
finishedAt: new Date().toISOString(),
briefPath: run.briefPath,
finalPath: existsSync(run.finalPath) ? run.finalPath : null,
eventsPath: run.eventsPath,
stderrPath: run.stderrPath,
...extra,
};
// Publish atomically so a polling orchestrator never reads a half-written file
// (same idiom as claude-delegate's writeJsonAtomic and qoder-delegate).
const temporary = `${run.resultPath}.${process.pid}.tmp`;
writeFileSync(temporary, `${JSON.stringify(result, null, 2)}\n`, "utf8");
renameSync(temporary, run.resultPath);
return result;
};
}
function reportUnavailable(writeResult, resultPath) {
const result = writeResult({
status: "kimi_unavailable",
exitCode: 127,
signal: null,
sessionId: null,
finalMessage: "",
touchedFiles: null,
});
printSummary(result, resultPath);
process.stderr.write("relay: `kimi` not found on PATH. Install Kimi Code and run `kimi login`.\n");
process.exit(127);
}
function reportVersionFailure(opts, writeResult, run, error, probeTimeoutMs) {
const timedOut = error?.code === "ETIMEDOUT";
const stderr = String(error?.stderr || "").trim();
if (stderr) writeFileSync(run.stderrPath, `${stderr}\n`, "utf8");
const message = timedOut
? `kimi --version preflight timed out after ${probeTimeoutMs}ms; Kimi was not dispatched`
: `kimi --version preflight failed${Number.isInteger(error?.status) ? ` with exit ${error.status}` : ""}; Kimi was not dispatched`;
const result = writeResult({
status: timedOut ? "timeout" : "failed",
exitCode: timedOut ? 124 : Number.isInteger(error?.status) ? error.status : 1,
signal: null,
sessionId: null,
finalMessage: "",
touchedFiles: gitTouchedFiles(opts.cd),
stderrTail: stderr ? stderr.split("\n").slice(-20) : [],
error: message,
});
printSummary(result, run.resultPath);
process.stderr.write(`relay: ${message}\n`);
process.exit(result.exitCode);
}
function dispatchToKimi(opts, brief, run, writeResult) {
const child = spawn("kimi", buildArgv(opts, brief), {
cwd: opts.cd,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32", // POSIX: lead a new process group so killChild can fell the whole tree
});
let sessionId = null;
const textChunks = [];
const stderrTail = [];
const scan = makeEventScanner((event) => {
if (event.role === "assistant" && typeof event.content === "string") {
textChunks.push(event.content);
}
if (event.role === "meta" && event.type === "session.resume_hint" && typeof event.session_id === "string") {
sessionId = event.session_id;
}
});
// Decode across chunk boundaries: a multibyte UTF-8 character split between
// two data events would otherwise decode as U+FFFD and corrupt the report.
// Files get the raw bytes; only in-memory parsing goes through the decoders.
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
child.stdout.on("data", (chunk) => {
appendFileSync(run.eventsPath, chunk);
scan(stdoutDecoder.write(chunk));
});
child.stderr.on("data", (chunk) => {
process.stderr.write(chunk);
appendFileSync(run.stderrPath, chunk);
const text = stderrDecoder.write(chunk);
for (const line of text.split("\n")) {
if (line.trim()) stderrTail.push(line.trimEnd());
}
while (stderrTail.length > 20) stderrTail.shift();
});
const assembleFinal = () => {
const message = textChunks.join("\n\n");
if (message) writeFileSync(run.finalPath, message, "utf8");
return message;
};
let settled = false;
let watchdogFired = false;
let sigkillTimer = null;
const timeoutMs = parseDuration(opts.timeout) ?? parseDuration(DEFAULT_TIMEOUT);
const watchdogTimer = setTimeout(() => {
watchdogFired = true;
child.once("exit", () => {
child.stdout.destroy();
child.stderr.destroy();
});
killChild(child);
sigkillTimer = setTimeout(() => {
if (!settled) killChild(child, "SIGKILL");
}, 10_000);
}, timeoutMs);
// The relay's own death must still produce a result: without this, a kill from the
// orchestrator's side (its command timeout, a stopped task, a closed terminal) writes
// no result.json and leaves the kimi child running or dying mid-edit with nothing
// recording why. SIGTERM/SIGHUP registration is a no-op on Windows; SIGINT works there.
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
process.on(sig, () => {
if (settled) return;
settled = true;
clearTimeout(watchdogTimer);
if (sigkillTimer) clearTimeout(sigkillTimer);
const abortedFields = {
status: "aborted",
exitCode: 128 + (constants.signals[sig] || 15),
signal: sig,
sessionId,
finalMessage: assembleFinal(),
touchedFiles: gitTouchedFiles(opts.cd),
stderrTail: stderrTail.slice(-20),
error: `the relay was killed by ${sig}; kimi was terminated with it — inspect the working tree before re-dispatching`,
};
const result = writeResult(abortedFields);
printSummary(result, run.resultPath);
killChild(child);
setTimeout(() => {
killChild(child, "SIGKILL");
// the child may flush files during the grace window; refresh the snapshot so the
// artifact matches the tree the orchestrator will actually find
writeResult({ ...abortedFields, touchedFiles: gitTouchedFiles(opts.cd) });
process.exit(result.exitCode);
}, 2000);
});
}
child.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(watchdogTimer);
if (sigkillTimer) clearTimeout(sigkillTimer);
const result = writeResult({
status: "failed",
exitCode: 1,
signal: null,
sessionId,
finalMessage: assembleFinal(),
touchedFiles: gitTouchedFiles(opts.cd),
stderrTail: stderrTail.slice(-20),
error: String(err && err.message ? err.message : err),
});
printSummary(result, run.resultPath);
process.exit(1);
});
child.on("close", (code, signal) => {
if (settled) return;
settled = true;
clearTimeout(watchdogTimer);
if (sigkillTimer) clearTimeout(sigkillTimer);
// a descendant that ignored SIGTERM must not outlive the timeout report: once the
// parent is down, sweep the group (no-op where taskkill already felled the tree)
if (watchdogFired) killChild(child, "SIGKILL");
// A timed-out run is failed even if kimi handles SIGTERM by exiting 0 -
// orchestrators key off status and the relay exit code.
const succeeded = code === 0 && !watchdogFired;
const mapped = code ?? (constants.signals[signal] ? 128 + constants.signals[signal] : 1);
const exitCode = succeeded ? 0 : mapped === 0 ? 1 : mapped;
const result = writeResult({
status: succeeded ? "completed" : watchdogFired ? "timeout" : "failed",
exitCode,
signal: signal ?? null,
sessionId,
finalMessage: assembleFinal(),
touchedFiles: gitTouchedFiles(opts.cd),
...(succeeded ? {} : { stderrTail: stderrTail.slice(-20) }),
...(watchdogFired ? { error: `kimi did not finish within --timeout ${opts.timeout}; killed by the relay watchdog` } : {}),
});
printSummary(result, run.resultPath);
process.exit(result.exitCode);
});
}
function main() {
const opts = parseArgs(process.argv.slice(2));
const brief = readBrief(opts);
if (!brief.trim()) fail("empty brief (pass --brief <file> or pipe the brief on stdin)");
// kimi --prompt takes the prompt as a CLI argument, so the brief rides argv.
// The OS caps one argument (~128KB on Linux via MAX_ARG_STRLEN); reject a
// huge brief early instead of allowing an opaque E2BIG spawn failure.
const briefBytes = Buffer.byteLength(brief, "utf8");
const MAX_BRIEF_BYTES = 120 * 1024;
if (briefBytes > MAX_BRIEF_BYTES) {
fail(`brief is ${Math.round(briefBytes / 1024)}KB; kimi passes the prompt as a CLI argument, which the OS caps (~128KB on Linux). Trim it, or have kimi read large context from the workspace instead of inlining it.`);
}
// Prepare the run dir before probing, so a preflight that times out or fails still has
// somewhere to publish result.json rather than exiting silently.
const run = prepareRunDir(opts, brief);
const probeTimeoutMs = versionProbeTimeout(opts);
const probe = kimiVersion(probeTimeoutMs);
const writeResult = makeResultWriter(opts, probe.version, run);
if (!probe.version && !probe.error) {
reportUnavailable(writeResult, run.resultPath);
return;
}
if (probe.error) {
reportVersionFailure(opts, writeResult, run, probe.error, probeTimeoutMs);
return;
}
dispatchToKimi(opts, brief, run, writeResult);
}
function printSummary(result, resultPath) {
const lines = [];
lines.push("");
lines.push(`relay: ${result.status} (exit ${result.exitCode}${result.signal ? `, killed by ${result.signal}` : ""}) · kimi ${result.kimiVersion ?? "?"}`);
if (result.signal === "SIGKILL" && result.status === "failed") lines.push("hint: the host killed the process (commonly the OOM killer or a supervisor timeout) — this is not a kimi error; check host memory and re-dispatch, or split the task into smaller briefs.");
if (result.signal === "SIGTERM" && result.status === "failed") lines.push("hint: something outside the relay terminated kimi (a supervisor, the session ending, or a manual kill) — when the relay itself does the killing it reports status \"timeout\" or \"aborted\" instead; inspect the working tree before re-dispatching.");
if (result.resumed) lines.push("mode: resumed an existing session");
if (result.sessionId) lines.push(`session id (resume with: --session ${result.sessionId}): ${result.sessionId}`);
const touched = result.touchedFiles;
if (touched === null) {
lines.push("touched files: git unavailable - inspect the working tree directly");
} else {
lines.push(`touched files: ${touched.length}`);
for (const file of touched.slice(0, 40)) lines.push(` ${file}`);
if (touched.length > 40) lines.push(` ... and ${touched.length - 40} more`);
}
if (result.stderrTail && result.stderrTail.length) {
lines.push("last stderr:");
for (const line of result.stderrTail.slice(-8)) lines.push(` ${line}`);
}
lines.push("");
lines.push("--- kimi final report ---");
lines.push(result.finalMessage || "(no final message captured)");
lines.push("--- end report ---");
lines.push("");
lines.push(`result: ${resultPath}`);
lines.push("relay does not commit. Review the diff, re-run the project gates yourself, then commit from the orchestrator.");
process.stdout.write(`${lines.join("\n")}\n`);
}
main();
SKILL.md
---
name: kimi-delegate
description: >-
Delegate a coding task to the Kimi Code CLI (`kimi`) as a background implementer, then review its
diff and land it yourself. Use this whenever the user wants to hand implementation work to Kimi -
phrasings like "have Kimi implement X", "delegate this to Kimi", "run it through Kimi Code", or
"use Kimi to implement/fix/refactor" - or wants to run a queue of coding tasks through Kimi while
staying the reviewer. DO NOT USE for tasks small enough to do inline, or when the user wants the code
written directly without delegating.
license: MIT
metadata:
version: 0.5.0
---
# Kimi Delegate
You are the **orchestrator**. Hand a bounded coding task to a separate **implementer** - the Kimi Code
CLI - then review what it produced and land it yourself. You write the brief and own the judgment;
Kimi does the typing in its own session; you verify and commit.
The loop needs only a shell command and file access, so any comparable orchestrator can drive it.
## When NOT to use this
- The task is small enough to do inline; delegation overhead is not worth it.
- The `kimi` CLI is not installed or authenticated.
- You need a CLI-enforced read-only implementer. Headless Kimi has no read-only mode.
## Prerequisites (check once)
1. Install Kimi Code with `brew install kimi-code` on macOS/Linux, or use the native installer from
the [official Kimi Code documentation](https://moonshotai.github.io/kimi-code/en/).
2. Authenticate with `kimi login` (device-code flow, no TUI), or use `/login` in the TUI.
3. Confirm `kimi --version` succeeds.
4. Work in, or point `--cd` at, the target git repository.
## Choose the model alias
Kimi uses `default_model` from its `config.toml` when `--model` is omitted. To choose another model
alias, pass `--model <alias from your kimi config>`. Model aliases are user-defined config keys; use
one the human has configured rather than inventing one.
## The loop
Run these five steps per task. Steps 1, 4, and 5 require judgment; 2 and 3 are mechanical.
### 1. Write the brief
Kimi sees only the text you send plus what it can inspect in the workspace - no chat history or shared
context. Include the goal, current state, what to change, what to leave untouched, the project's
**actual** gates, and a report contract. Tell Kimi not to commit. Keep one task per brief. See
[references/writing-the-brief.md](references/writing-the-brief.md).
### 2. Dispatch
Use the bundled helper. It wraps Kimi's headless prompt mode, captures the structured event stream,
and writes `result.json`. (`<skill-dir>` is the installed folder containing this `SKILL.md`.)
```bash
node "<skill-dir>/scripts/relay.mjs" --brief brief.txt --cd /path/to/repo
# choose a configured model alias: add --model <alias from your kimi config>
# resume the most recent session: add --resume-last (delta brief only)
# resume a specific session: add --session <id> (delta brief only)
# hard time limit (watchdog): add --timeout 2h (the 30m default suits short runs; implementation briefs routinely need 1-2h)
# see all options: node .../relay.mjs --help
```
The child process's cwd pins the workspace. Use repeatable `--add-dir` flags only for extra workspace
directories. The relay writes artifacts under the system temp dir by default and never commits. See
[references/dispatch-and-poll.md](references/dispatch-and-poll.md).
### 3. Wait for completion
The helper blocks until Kimi finishes. Run it with the orchestrator's background-command facility, or
background it in the shell and poll for `result.json`. A pre-run usage error exits 2 and writes no
result; a missing `kimi` exits 127 and writes `status: "kimi_unavailable"`.
Trust process state and the working tree over a progress display. Completion means the process exited
and `result.json` exists. Kimi's full report is the `finalMessage` field in `result.json` (also printed
in full on stdout between the report markers).
### 4. Review - do not trust the self-report
Treat Kimi's final message and gate claims as claims:
- Re-run the project's gates yourself.
- Read the diff against the brief, starting with `touchedFiles`.
- Run relevant guard skills if installed.
- Round-trip migrations and grep for dangling references after removals or renames.
See [references/review-and-land.md](references/review-and-land.md).
### 5. Land it
The implementer edits the working tree; **the orchestrator commits.** Commit only after the gates pass
and the diff holds. If rework is needed, send a delta brief with `--resume-last` or `--session <id>`,
then review again.
## Autonomy and permissions
In headless `-p` mode, Kimi always runs in **auto permission mode** and never asks for approval. Kimi
rejects `--prompt` combined with `--yolo`, `--auto`, or `--plan`, so the relay passes none of them and
offers no `--read-only` or `--full-access` option. There is no CLI-enforced read-only mode: inspect
`touchedFiles` and the diff after every run. That diff, not a flag, is the guarantee of what changed.
## Authorization model
Delegation is something the human opts into. Once they have ("run this queue", "proceed"), committing
verified, gate-passing work is the agreed contract. Two limits remain: **surface, don't absorb**
(report Kimi's design decisions, defensible-but-unasked turns, and non-blocking nitpicks) and **stop
for scope changes** (if correct completion needs going beyond the brief, ask instead of expanding the
mandate). See [references/review-and-land.md](references/review-and-land.md).
## References
- [references/writing-the-brief.md](references/writing-the-brief.md) - structure, report contract,
real gates, argv delivery, and delta briefs.
- [references/dispatch-and-poll.md](references/dispatch-and-poll.md) - flags, artifacts,
`result.json`, polling, and failure recovery.
- [references/review-and-land.md](references/review-and-land.md) - review checklist, commit boundary,
and rework through Kimi sessions.
- [references/multi-task-queues.md](references/multi-task-queues.md) - sequential queues, constraint
carry-forward, progress tracking, and the final coherence pass.