Skip to content

Latest commit

 

History

History
559 lines (478 loc) · 28.7 KB

File metadata and controls

559 lines (478 loc) · 28.7 KB
title Round 2: Multi-Agent Surface
description Round 2: the multi-agent surface (actors, branches, detached agents) under failure.

Consumer usability review, round 2: the multi-agent surface

Date: 2026-07-16 · Chidori: 3.6.0, built from source at 1694faa · Perspective: the same kind of user as round 1 — a developer whose provider is DeepSeek — but this time building on the features round 1 explicitly could not vouch for: actors, branching, detached agents, and the crash-recovery story when all of them are in play.

Round 1 established that the linear story works (provider onboarding, replay, resume guards, human-in-the-loop over HTTP) and its fixes have landed — this round could feel them: CHIDORI_OPENAI_COMPAT_URL worked first try, the truncation warning fired at exactly the right moment, cost lines say unknown instead of $0, and error frames now point at the failing await, not run(. Credit where due: the polish shipped.

This round asks the next question: can a consumer actually build the multi-agent systems the README sells? Short answer: yes on the happy path — strikingly so — and the durability engine underneath is real. But the moment a supervised thing fails, the supervision surface tells you nothing, retries nothing, and in one case bricks the agent. The failure paths of the fault-tolerance features are where the consumer trust burns down.

What was built

A multi-agent newsroom (~150-line editor + 2 worker modules + 2 strategy modules + 2 real HN tools), all on live deepseek-v4-flash:

  1. The editor plans two research angles (one prompt).
  2. Two supervised researcher actors (actors.spawn, restart: "resume") work the angles concurrently against the Hacker News Algolia API, streaming progress messages to the parent (actors.send / receive).
  3. A critic actor reviews the combined dossier.
  4. chidori.branch forks two synthesis strategies (exec brief vs. narrative feature, concurrency: 2) from the same anchored state; one more prompt picks the winner.
  5. chidori.input() gates publication; workspace.write publishes.

Plus a detached news-desk service (agents.spawn, hibernating signal(["story","digest_now","close"]) loop with a 24h digest alarm), driven over chidori serve's HTTP surface, and a flaky-upstream lab (a local HTTP server that 500s once then 200s) to test restart: "resume" semantics honestly. Sources in the appendix.

The numbers first

Scenario Result
Full newsroom pipeline, first attempt Completed first try. 106s, 175 recorded calls, 17 prompts, 28 real tool calls, 15.7k in / 3.9k out tokens
Replay of that run (invalid API key on purpose) Byte-identical, 79ms, zero provider calls — actors and branches included
Branch edit-and-rerun from stored anchor Worked (once the model default was worked around)
Detached desk: wake→triage→hibernate cycles Worked; journal grew 3→6→9 records, alarm deadline persisted
Detached desk: kill -9 the server, restart, request digest Digest correctly covered state from before the kill
kill -9 the newsroom mid-fan-out, chidori resume Failed after 8 minutes (see Finding 1)
Actor restart: "resume" against a flaky upstream (500 once, then 200) Never retried the upstream: 1 hit across 3 attempts (see Finding 2)
Detached agent whose spawning chidori run exited mid-execution Bricked permanently (see Finding 4)

The two halves of that table are the review in miniature: the recording half of the durability engine is superb; the recovery half — the reason a consumer reaches for supervision options at all — failed every non-trivial test I threw at it.


Finding 1: a --trusted run cannot be crash-resumed

The README's crash-recovery pitch — "kill the process mid-run and resume exactly where it left off" — held in round 1 for a linear agent. For the newsroom it does not hold, and the reason is a missing flag:

  • The run was started chidori run … --tools tools --trusted.
  • SIGKILL mid-fan-out (43 parent records journaled; the actors' in-flight work correctly discarded per the documented at-least-once window).
  • chidori resume newsroom.ts <id> --model deepseek-v4-flash then ran for 8 minutes and died with newsroom: researchers timed out.

What happened, reconstructed from cmd_resume and the journals: resume accepts neither --tools nor --trusted. Tools happen to load anyway (an undocumented <dir>/tools convention). But the resume engine is built with no policy at all — deny-by-default — so every re-spawned researcher's first chidori.tool call was refused, each actor burned its full restart budget re-hitting a deterministic denial, and the parent sat in receive() until its own 480s timeout. The consumer paid for the re-spawned researchers' live prompts twice (once per restart wave) and got nothing.

The trust decision was made at run time, by a human, at a terminal. The resume of the same run should at minimum offer the same flags (--trusted, --tools), and arguably should default to the recorded run's policy. Right now the marquee scenario — crash recovery of a real tool-using agent — is unreachable from the CLI for exactly the runs that need it.

Related paper cut discovered on the way: the README's own zero-cost-replay command (chidori resume agent.ts <run-id>) fails for any run started with --model: the model default (claude-sonnet-4-6) diverges from the recorded prompt args, and the error says "The agent code (or its inputs) changed since the checkpoint was saved" — it didn't — and recommends CHIDORI_REPLAY_LAX=1, which is the wrong fix (the right one is re-passing --model). The run knows its model; the manifest should carry it so resume, branch-rerun, and fleet wakes stop guessing (see Finding 5).

Finding 2: restart: "resume" cannot retry a flaky upstream through a tool

This is the deepest one, because it defeats the stated purpose of the resume restart strategy ("the failing call re-executes live").

Lab setup: a tool whose fetch hits a local server that returns 500 on the first request and 200 forever after; the worker actor does one LLM call then the tool call, spawned with restart: "resume", maxRestarts: 2. Expected: attempt 1 fails on the 500; restart replays the (cached) prompt, re-executes the tool, gets the 200, completes with restarts: 1.

Observed: status: "failed", restarts: 2 — and the upstream server received exactly one request across all three attempts.

The journal explains it: the tool's inner fetch is recorded as its own nested http record, and a fetch that returns a 500 is a successful http effect — response received, effect complete. So when the restart strips the crash frontier, it strips the failed tool record but keeps the completed http record beneath it; the re-executed tool replays the cached 500 and throws identically, forever. The restart budget burns with no possibility of a different outcome, and nothing tells you the "retry" never touched the network.

For the most common real-world flake there is — an upstream 5xx/timeout inside a tool — supervision with resume is currently a slower way to fail. The frontier strip needs to cascade to the failed call's nested effects (they were consumed by the failing iteration; replaying them is exactly the "re-firing a recorded call" the docs promise not to do in reverse), or tools need a first-class way to say "this result is a failure, don't cache it as done".

Finding 3: actors die silently — a receive()-driven parent starves

The natural way to write a fan-out/fan-in (it's what the shipped actor_pipeline.ts teaches) is: spawn workers, then receive() results until you have N. The newsroom does exactly this. When both researchers failed during the broken resume of Finding 1, the parent learned nothing: no message, no exception, no wake-up — it blocked until its own 480s timeout, because an actor's death delivers nothing to anyone.

The model this borrows from solved this decades ago: Erlang processes have links and monitors; a supervisor gets a DOWN message the moment a child dies. Chidori has the mailbox machinery already — a runtime-delivered { name: "__chidori.down__", payload: { pid, error } } (or an onSettle option on spawn, or letting receive fail fast when every possible sender has settled — the machinery for that exists in the no-live-actors check) would turn a 480-second silent starvation into an immediate, actionable signal. Until then, every collection loop must be written defensively with join({timeoutMs}) polling instead of the message-driven style the examples teach.

Finding 4: a detached agent can wedge permanently, and its mailbox is quicksand

Reproduced twice: chidori run spawn_desk.ts --trusted spawns the desk, sends it a tip, and exits — while the desk is still mid-first-execution (status: "running"). The docs say live agents die with the process and "lose nothing". What actually happened:

  • chidori serve boots, prints Re-armed 1 detached agent(s) from the registry, takes the desk's lease — and then no worker process ever runs the desk. The lease silently expires five minutes later.
  • The registry (and GET /agents/detached/news-desk) reports running forever — status is a stored descriptor, not a liveness probe — with waitingFor: null, an empty journal, and an unconsumed mailbox.
  • A new POST /send dutifully returns {"delivered": true} and queues the message; nothing will ever read it. There is no error anywhere: not in serve's log, not in the status, not in the journal.

The only recovery is POST /stop + respawn — and the respawn is a new run id, so every message queued to the wedged incarnation (my EU-story tip, still sitting in the old run's signals/inbox.json) is silently orphaned. "Durable mailbox any party can deliver into" is the pitch; deliveries during a wedge are accepted and then stranded.

Three separable asks: (a) whatever killed the boot-time wake of a mid-run agent needs to fail loudly (registry → failed, error in the descriptor); (b) status for a supposedly-running agent should be checked against an actual lease/worker, not parroted from disk; (c) a named respawn should inherit (or at least warn about) the predecessor's unconsumed inbox.

Once past the wedge, the lifecycle genuinely shines — wake-on-send, re-hibernate, a 24h alarm deadline that survived kill -9 of the server, and a digest that correctly folded in state from before the kill. This is the best feature in the framework wearing the worst failure mode.

Finding 5: the run's model doesn't travel with the run

The same trap fired three independent times, in three costumes:

  1. chidori resume → replay divergence with a misleading "code changed" error (Finding 1).
  2. chidori branch-rerun → live 400 from DeepSeek: "you passed claude-sonnet-4-6" — the rerun forgot the run's --model and branch-rerun has no --model flag at all (env var only).
  3. Any fleet wake under a server started without --model would do the same to a detached agent's next prompt.

A run whose every prompt was recorded with model: deepseek-v4-flash knows its model. Stamp it in the run manifest and make every out-of-band re-entry (resume, branch-rerun, branch-resume, registry wakes) default to it. For a user on the two blessed vendors this bug is invisible; for everyone else it's a recurring toll booth.

Finding 6: the multi-actor trace is write-only

chidori trace on the newsroom run prints 175 lines in one flat list: parent records interleaved with #1000000000001-style 13-digit sequence numbers, no indication of which actor a record belongs to, no grouping, no tree. Half the parent's lines are the receive/log spam of its own progress loop. Finding "what did researcher-2 actually do" means grepping seq prefixes by hand (and knowing the range-carving scheme from docs/actors.md). The OTLP story is presumably better, but the built-in tool — the one a consumer debugs with at 2am — hasn't caught up with the process model: it needs per-actor grouping/labels (actors.spawn takes a name; the trace never shows it), a tree view, and a --actor <pid> filter.

Also in the "observability debt" bucket:

  • Prompt-cache telemetry is journaled but invisible. The records carry cache_read_tokens (DeepSeek's automatic prefix cache was hitting — 512 tokens on one call), but neither trace nor stats prints a word about cache. For a framework that sells "structural prompt caching built in", the operator cannot see whether it works.
  • Unknown-model pricing is a dead end. Est cost: unknown (no pricing data for: deepseek-v4-flash) is honest (round 1 fix), but there is still no way to teach it — no CHIDORI_PRICING env/config. One JSON map away from useful.
  • stats ignores everything below the parent? No — it aggregates fine; but it has no per-actor / per-branch breakdown either.

Finding 7: assorted first-day friction, ranked

  • chidori serve requires an agent file even when you only want the fleet. The docs call the server "the natural home for a fleet", but you cannot host one without also exposing some session agent; there's no chidori serve --fleet-only, and no server-side way to spawn a detached agent (spawning is run-only, so "deploy a service" means "write a spawner run and execute it once").
  • chidori.input() can't show the human what they're approving. The approval gate takes a prompt string only; my draft had to be inlined into the prompt text as a ----fenced blob to be reviewable at the terminal. An attachment/document option (rendered by the CLI, carried in pending_prompt over HTTP) matches how approval gates actually get used.
  • The built-in tool loop is a secret. prompt(text, { tools, maxTurns }) runs a provider-side tool loop — exactly what most agents want — but its only documentation is one line in llm.txt ("maxTurns: cap on provider tool-use turns"); every doc and example teaches the hand-rolled respond()/toolResult loop instead. I wrote 40 lines of loop I may not have needed; I still don't know the differences (does it journal per-turn? honor type: "progress" streams? surface toolCalls?) because nothing says.
  • No lock on a run dir. A wedged resume I forgot to kill and a second resume of the same run ran concurrently against the same .chidori/runs/<id> with no complaint. Detached agents have leases; plain runs have nothing.
  • SIGKILL eats the run's stdout. Output (including the run id line) is block-buffered when redirected to a file, so a crashed run's log is 0 bytes; the run id must be recovered from ls .chidori/runs. Line-buffer stdout (or eprintln the run id at start, which is where the crash-recovery user needs it anyway).
  • SSRF guard vs. local tools. fetch from a tool to 127.0.0.1 is refused by default — right default, excellent error (it names CHIDORI_HTTP_ALLOW_HOSTS) — but anyone whose tool talks to a local service (Ollama sidecars, local indexes) hits it even under --trusted. Worth one line in the tools doc. (Provider calls to localhost are unaffected — verified — so local-LLM users are fine.)
  • Docs drift. docs/branching-execution.md says the branch store is branches/op-<branch seq>/; it's actually zero-padded (op-00000003000000000002), which costs a confused minute mid-debug.

What worked — and it's the hard part, again

Fairness requires the same list rigor as the complaints:

  • The whole newsroom composed first-try on a non-blessed provider. Actors messaging the parent while branches fork strategy modules while tools hit real HTTP — plain TypeScript, no graph, no YAML — and DeepSeek reasoning-model tool-calling just worked through the compat provider.
  • Replay absorbed the entire process tree. 175 calls including two actors' folded histories and a 2-way branch fan-out: 79ms, invalid API key, byte-identical output. Nobody else in this space has this as a one-liner.
  • Detached-agent durability is real. Hibernation held zero threads; the alarm deadline and triage state survived kill -9 of the server; the digest after restart knew everything from before it. The Durable-Objects-shaped model on a laptop, as advertised.
  • Branch edit-and-rerun is the agent-iteration workflow I've wanted: tweak one strategy's stored source, re-run only that branch from the identical anchored state, compare.
  • Round 1's fixes held up in anger: the truncation warning fired on the first reasoning-model response that hit its cap; the endpoint-named provider errors ("OpenAI-compatible endpoint api.deepseek.com") de-confused every failure; input() honored its default at EOF through the approval gate.

Status: fixes shipped

Every finding above was addressed on this branch, in the same series of commits as this update, and re-verified against the original scenarios:

  • Finding 1 (crash-resume)chidori resume accepts --trusted/--untrusted, journals live continuation into the same run dir, and takes the run's lease against concurrent drivers. (No --tools flag exists anymore on any command: the tool model moved to in-VM defineTool handles, so there is nothing for resume to load.) Investigating the re-verification exposed the actual root cause beneath the policy gap: replayed spawn_actor records never re-created actors unless a live send/join addressed them — a receive-driven fan-in never does, so crash-resumed pipelines starved regardless of policy. Live receive now re-materializes every recorded-but-unsettled actor (regression-tested). Re-run: SIGKILL mid-fan-out → resume --trusted → completed and published in 61s; then a bare chidori resume with an invalid key replayed all 192 calls byte-identically in 0.16s with no flags at all.
  • Finding 2 (flaky restart) — the crash-frontier strip cascades to the frontier's nested effects (shared by actors and detached agents; regression-tested). Re-run of the flaky lab: upstream hit exactly twice, restarts: 1, completed.
  • Finding 3 (silent actor death) — a failed/paused settle delivers a __chidori.down__ monitor message to the owner's mailbox, and a receive (even with timeoutMs) fails fast with guidance once every spawned actor has settled with nothing matching queued. Both regression-tested.
  • Finding 4 (detached wedge) — a wake that finds a dead process's lease now waits it out and takes over (standing down only for a holder that actively renews); the alarm timer doubles as a reconciler that re-drives running-with-no-worker agents; a queued send wakes them too. Status reports live (is a worker actually executing here) and surfaces the real error on failure. Replacement spawns migrate the settled predecessor's unconsumed inbox — mail follows the name. Re-run of the wedge repro: self-healed after lease expiry, consumed the tip delivered during the wedge window, hibernated with its alarm armed.
  • Finding 5 (model doesn't travel) — the run's resolved model is recorded in its manifest and applied by resume, branch-resume, branch-rerun, and the server's resume/replay/approve routes; detached agents carry theirs in the registry descriptor (spawn option model, defaulting to the spawner's). A model-mismatch divergence now names the differing field and both models instead of claiming "the agent code changed".
  • Finding 6 (observability)chidori trace labels every record with its owner (researcher-1 (actor-1), branch:exec-brief), prints an owners roster, and shows range-relative offsets instead of 13-digit seqs; trace and stats display prompt-cache read/write totals and price with cache-aware rates; CHIDORI_PRICING teaches the cost tables any model. The 192-call newsroom run now traces with a real dollar figure ($0.011356) and its 18,944 cached tokens visible.
  • Finding 7 (assorted)chidori.input() takes details (the CLI renders it above the prompt; paused sessions expose pending_details); chidori serve without a file hosts the fleet alone and rejects defaultless sessions with guidance; chidori run announces the run id on stderr at start (it now survives SIGKILL); the built-in prompt({tools, maxTurns}) tool loop, the SSRF allowlist for local-service tools, and the zero-padded branch-store path are documented (core-concepts, llm.txt, branching doc); the README resume example is accurate again.

One fix begat a guard worth noting: because resume now persists, an early-diverged resume attempt could have truncated a journal with its shorter log — the persister refuses shorter-than-durable checkpoint writes (resume --until-seq time travel opts out explicitly).

The consumer verdict

Would I build on this today? For a single durable agent with human-in-the-loop — yes, without hesitation; that path is now smooth end-to-end, and the replay/checkpoint-test story is a genuine unlock. For the multi-agent surface, the primitives are the right primitives and the happy path is shockingly good — but I'd be knowingly signing up to hand-roll the safety net the framework advertises: defensive join({timeoutMs}) polling instead of trusting receive, my own retry-with-jitter inside tools because restart: "resume" can't retry a 5xx, a watchdog that pokes detached agents because running might mean "dead", and a sticky note that says never crash-resume without re-deriving every flag the original run had.

Every one of those is fixable at the surface (a flag, a monitor message, a cascaded frontier strip, a loud registry error, a manifest field) — none require touching the engine, which is visibly the strongest thing here. Round 1 ended "ship the polish"; round 2's version is: ship the failure paths. The features work; it's their failures that don't.

(Postscript: shipped — see Status: fixes shipped above. One caveat the fixes surfaced: "fixable at the surface" was wrong for Finding 1 — the crash-resume failure ran deeper than the missing flag, into actor re-materialization at live receives — but it landed all the same, with regression tests.)


Appendix A: hard-evidence log

  • Newsroom run 137674ee — 175 calls, 106,529ms, 15,722/3,947 tokens; replay with CHIDORI_OPENAI_COMPAT_KEY=sk-invalid…: identical output, 0m0.079s.
  • Crash run 3b3c156e — SIGKILL at 18s; 43 parent records (1 prompt, 2 spawn_actor, 19 receive); resume … --model deepseek-v4-flash exited 1 after ~8min: newsroom: researchers timed out at newsroom.ts:52 (the parent's own receive timeout).
  • Flaky lab — server hit-counter file read 1 after a run that reported restarts: 2; journal holds the 500-body http record as a completed effect at seq 1000000000003.
  • Desk wedge — registry running, listen: null, restarts: 0, no records.jsonl, lease expired 22:40:54Z, serve log shows only Re-armed 1 detached agent(s); POST /send{"delivered":true} into the orphaned inbox.
  • Desk (healthy incarnation, run 7f066b4e) — journal 3 records after first triage, 6 after wake-on-send, 9 after post-restart digest; digest text referenced both triaged tips; alarm deadline persisted across server SIGKILL.

Appendix B: the newsroom (abridged)

newsroom.ts — the editor:

import { chidori, run, type AgentJson, type JsonObject } from "chidori:agent";

run(async (input: { topic: string }) => {
  const planRaw = await chidori.prompt(
    `…propose exactly 2 research angles… Reply as a JSON array…`,
    { type: "progress", format: "json", maxTokens: 2000 },
  );
  const angles = Array.isArray(planRaw) ? planRaw.slice(0, 2) : [input.topic];

  const researchers = [];
  for (const [i, angle] of angles.entries()) {
    researchers.push(await chidori.actors.spawn(
      "workers/researcher.ts",
      { topic: input.topic, angle, id: i + 1 },
      { name: `researcher-${i + 1}`, restart: "resume", maxRestarts: 2 },
    ));
  }

  const findings: JsonObject[] = [];
  while (findings.length < researchers.length) {
    const msg = await chidori.receive(["progress", "finding"], { timeoutMs: 480000 });
    if (msg.timedOut) throw new Error("newsroom: researchers timed out");
    if (msg.name === "finding") findings.push(msg.payload as JsonObject);
  }
  for (const r of researchers) await r.join();

  const dossier = findings.map((f, i) => `### Angle ${i + 1}: ${f.angle}\n\n${f.findings}`).join("\n\n");

  const critic = await chidori.actors.spawn("workers/critic.ts", { topic: input.topic, dossier }, { name: "critic" });
  const critique = ((await critic.join()).output as JsonObject)?.critique as string;

  const outcomes = await chidori.branch([
    { label: "exec-brief",    source: "strategies/exec_brief.ts",    input: { topic: input.topic, dossier, critique } },
    { label: "feature-story", source: "strategies/feature_story.ts", input: { topic: input.topic, dossier, critique } },
  ], { concurrency: 2 });

  // …editor-in-chief prompt picks a draft; chidori.input() gates;
  // chidori.workspace.write publishes. Full flow as in round 1's appendix.
});

workers/researcher.ts — the supervised worker (the budget-nudge shape that made round 2's output publication-quality):

run(async (input: { topic: string; angle: string; id: number }) => {
  let ctx = chidori.context()
    .system("You are a research analyst… use hn_search / hn_thread… read ≥2 threads…")
    .tools(["hn_search", "hn_thread"])
    .user(`Topic: ${input.topic}\nYour assigned angle: ${input.angle}`);

  let findings = ""; let toolCalls = 0;
  for (let step = 0; step < 6; step++) {
    const stepsLeft = 6 - step;
    if (stepsLeft <= 2) ctx = ctx.user(stepsLeft === 2
      ? "Budget check: ONE more round of tool calls, then write up."
      : "Budget exhausted. Write your findings NOW. Make NO tool calls.");
    const { response, context } = await ctx.respond({ type: "progress", maxTokens: 3000 });
    ctx = context;
    if (response.toolCalls.length === 0) { findings = response.content; break; }
    for (const call of response.toolCalls) {
      const result = await chidori.tool(call.name, call.input);
      toolCalls++;
      await chidori.actors.send("parent", "progress", { id: input.id, tool: call.name });
      ctx = ctx.toolResult(call.id, JSON.stringify(result));
    }
  }
  if (!findings) {
    const { text } = await ctx.user("Stop. Summarize your findings now. No tool calls.")
      .prompt({ type: "progress", maxTokens: 3000 });
    findings = text;
  }
  await chidori.actors.send("parent", "finding", { angle: input.angle, findings, toolCalls });
  return { id: input.id, angle: input.angle, toolCalls };
});

services/desk.ts — the detached service:

run(async () => {
  const triaged: { headline: string; verdict: string }[] = [];
  let digests = 0;
  for (;;) {
    const msg = await chidori.signal(["story", "digest_now", "close"],
      { timeoutMs: 24 * 60 * 60 * 1000 });          // daily digest alarm
    if (msg.timedOut || msg.name === "digest_now") {
      digests++;
      await chidori.log("digest", { digest: await chidori.prompt(
        `Write a 3-sentence desk digest of: ${JSON.stringify(triaged)}`) });
      continue;
    }
    if (msg.name === "close") return { triaged: triaged.length, digests };
    const tip = msg.payload as JsonObject;
    const verdict = await chidori.prompt(`Triage this tip in one sentence: ${JSON.stringify(tip)}`);
    triaged.push({ headline: String(tip.headline ?? "?"), verdict });
  }
});

Appendix C: reproduction commands

export CHIDORI_OPENAI_COMPAT_URL=https://api.deepseek.com
export CHIDORI_OPENAI_COMPAT_KEY=sk-...

# the pipeline
chidori run newsroom.ts --model deepseek-v4-flash \
  --input topic="AI coding agents" --tools tools --trusted

# zero-cost whole-tree replay (must repeat --model: Finding 5)
chidori resume newsroom.ts <run-id> --model deepseek-v4-flash

# Finding 1 (crash resume): SIGKILL the run mid-fan-out, then the resume
# above — actors' tool calls are policy-denied (no --trusted on resume),
# parent starves for its receive timeout.

# Finding 2 (flaky restart): tool fetches a server that 500s once then
# 200s; spawn with restart:"resume" — the upstream is hit exactly once.

# Finding 4 (desk wedge): chidori run spawn_desk.ts --trusted  (exits
# while the desk is mid-first-execution), then chidori serve …; the desk
# stays "running" forever with no worker.

# detached desk over HTTP
chidori serve spawn_desk.ts --port 8091 --model deepseek-v4-flash
curl -XPOST :8091/agents/detached/news-desk/send \
  -d '{"name":"story","payload":{"headline":"…"}}'

# branch iterate (env var, not a flag: Finding 5)
CHIDORI_MODEL=deepseek-v4-flash chidori branch-rerun <run-id> <branch-id>