Task workflow inside cyberine chat — shared manifest with the nf-tasks skill
/run and the planner TASK: line dispatch ad-hoc text; the four slash commands below let the chat
operate directly on the project's task manifest — the same .claude/TASKS.md /
.codex/TASKS.md / .local/tasks/ACTIVE.md file the Claude Code nf-tasks skill (the
oc-tasks tool) reads. Both sides read and write the same backlog without clobbering each other:
cyberine status flips are APPENDS to .local/tasks/status.log, never rewrites of the manifest
(details at the end of this section). The normative format spec — file discovery, block grammar,
id minting, status/overlay semantics, the fields table, the lock protocol, and every known
divergence between cyberine and the nf-tasks skill — lives at docs/tasks-manifest.md.
Brainstorm to board — the add_tasks planner tool (2026-09-28)
Ask the chat to put a plan on the board ("ghi task list len board", "save these as tasks") and the planner calls add_tasks, which writes through the same validated writer as cyberine tasks save (convergeApplyUnderLock in src/tasks/boardWrite.ts): ids are minted there, deps may name an existing id or @<ref> of another task in the same call, every task needs title, do, doneWhen and effort (S/M/L), exec is always agent, model defaults to sonnet, status defaults to todo (backlog for a draft), and a brief with the six sections is optional but recommended because the implementing session reads it. The chat prints board: added N task(s): <ids>, and the planner is told the board changed only when that result came back. Then run them:
cyberine chat # brainstorm, then: "ghi task list len board"
cyberine tasks status # see what landed
cyberine autopilot --engine claude --dry-run # the plan: one driven Claude seat session per task
cyberine autopilot --engine claude # run it (asks before spending)In plan permission mode add_tasks is refused until present_plan is approved, like dispatch_run. The web chat does not carry the tool (its tool set stays read-only).
Known limitations and deferred work: the tool only creates tasks; it cannot edit, close or drop one (use cyberine tasks set / status / drop). The chat does not start the autopilot itself; the operator runs the command. Before this tool existed the planner had no board writer at all and, asked to write tasks, answered that it had written them while the manifest stayed empty (measured 2026-09-28 on deepseek-flash); the prompt now forbids claiming a write without an add_tasks result, which a model can still ignore.
Manifest discovery + task shape
The reader (src/tasks/manifestReader.ts) walks these candidates in order and uses the first that
exists:
.claude/TASKS.md
.codex/TASKS.md
.local/tasks/ACTIVE.mdEvery task uses this header shape (case-insensitive field names; R\d+ = recurring, the rest are
one-shots):
## T05 [todo] Add webhook signature verification
deps: T03
exec: agent
model: sonnet
area: api
do: Add HMAC-SHA256 verification to the inbound webhook handler.
done-when: Tests pass and the verifier rejects tampered payloads with a 401.Recognised fields: deps: (comma-separated T\d+ / R\d+), exec:, model:, area: (affinity
hint for grouped fan-out), blocker:, do:, done-when:. Status tokens: [backlog] [todo] [doing] [done] [forbidden]. A [todo] task is runnable when it has no blocker: and every deps: id
is [done]; blocked when at least one dep is not yet [done]; waiting when it carries a
blocker: line (external hold — clear the line to release). [doing] is in-flight, [done]
closed, [forbidden] is a permanent tombstone that no command will launch.
Verify: npm run selftest:runnable-blocker (k3qcc). A throwaway git repo holds a nine-task
board read through the production readManifest() + computeRunnable() — the same two calls
cyberine autopilot makes — and asserts the two exclusions a dispatcher would otherwise walk into:
a [todo] + exec: agent task held only by a prose blocker: line never enters the runnable set
even when every dep is [done], and a [backlog] task never does whatever its exec: says. It
also pins the neighbours (an unmet dep is blocked, not waiting; a [forbidden] tombstone is in
neither) and checks every task lands in exactly one bucket, so nothing is silently dropped.
Positive control: clearing the blocker line on that same task, changing nothing else, makes it
runnable.
Which guard actually holds this, measured: computeRunnable's loop routes a blocker-carrying
[todo] into waiting BEFORE calling isRunnableTask, so the if (t.blocker) return false;
inside that helper is unreachable for this case — deleting it leaves the smoke green. Deleting the
loop's if (t.blocker) branch reddens four assertions, and deleting both reddens seven, including
the reported failure mode itself (the held tasks entering the agent-runnable set a session is
dispatched from). The helper's guard is kept as defence for any future direct caller; the loop is
the one that protects today's behaviour.
/tasks — list the backlog grouped by effective status
Header carries per-status counts (doing / todo broken down into runnable + blocked + waiting +
still-todo / backlog / done / forbidden) plus the full runnable list — the same ids /dispatch
accepts. When nothing is runnable the message names the reason (unblock deps or mark them done
/ N waiting on an external blocker — clear the 'blocker:' line to release /
all todo tasks already in-progress or done / no todo tasks in manifest).
3 doing | 5 todo (2 runnable, 1 blocked, 2 waiting) | 1 backlog | 4 done | 0 forbidden
Runnable (2 — dispatch with /dispatch <id>):
T03 next: handlers [deps: T02]
T07 next: webhook sig [deps: T03]
Doing (3):
T05 Add webhook signature verificationEffective status = the manifest's base token OVERRIDDEN by the last status.log line for that id
(see "Shared-manifest interop" below) — so what /tasks shows is the same view oc-tasks status
produces.
/task <id> — show one task's full body
Prints id, status, title, deps annotated with each dep's effective status (T03(doing) etc.),
exec:, model:, blocker:, do:, done-when:, plus a runnability hint that mirrors the
/dispatch gate so you see the same reason a launch would refuse. Accepts T-id and R-id:
T05 [todo] Add webhook signature verification
deps: T03(doing)
exec: agent
model: sonnet
do: Add HMAC-SHA256 verification to the inbound webhook handler.
done-when: Tests pass and the verifier rejects tampered payloads with a 401.
(blocked — waiting on: T03)Usage: /task T05 (or /task R03 for a recurring task). Bad id format (/task 5, /task T05a)
prints usage: /task <id> (e.g. /task T05).
/xmode — execution mode (compete | swarm | research)
Two
/mode-shaped commands exist in the chat and they're orthogonal:
/mode— the permission mode (default / acceptEdits / plan / auto / bypassPermissions / dontAsk — the Shift+Tab cycle that gates whether/runis allowed to dispatch). See the permission-mode table above./xmode— the execution mode documented here. It controls WHICH fleet topology/runand/dispatchuse, not WHETHER they are allowed to dispatch.The chat's
/dispatch(and/run, the plannerdispatch_runtool,/backlog run,/workflow, Ctrl+B) gates on/xmodeindependently of/mode, so a permission-mode ofacceptEditsplus an execution-mode ofswarmis valid and still surfaces the "not built yet" line.
/xmode:
/xmode(no arg) — cycle forward throughcompete -> swarm -> research -> deep -> smart -> rush./xmode <name>— set directly. Accepted values:compete | swarm | research | deep | smart | rush. Unknown values echo the valid set.- The active mode is surfaced in the chat status line (
fleet: compete;compete/deep/smart/rushuse the accent colour,swarm/researchstay muted) and persisted on the session record (ChatSessionRecord.executionMode), socyberine chat --continue/--resume <id>restores it.
compete (the default), deep, smart, and rush are wired up — all four dispatch through the
same /run path (one task -> N agents -> judge picks the winner), differing only in fleet size /
model tier / judge chain (T324). research dispatches the standing research workflow (plan ->
search fan-out -> verify claims -> synthesize report) — but only from /run (khh1q):
/xmode research
/run "what are the trade-offs between SQLite and DuckDB for embedded analytics?"prints the dispatched runId and the report path immediately, then — once the run reaches a
terminal state — the verified/dropped claim counts and the same report path (also written to disk
at .local/research/<slug>.md). swarm, and research on every OTHER dispatch path (/dispatch,
the planner dispatch_run tool, /backlog run, /workflow, Ctrl+B), are not wired — they refuse
with a one-liner rather than silently downgrading to compete:
fleet: swarm — dispatch not built yet (T234-243) (use /xmode to switch to a wired fleet: compete | deep | smart | rush)
fleet: research — only /run dispatches a research run today; Ctrl+B, /dispatch, and workflow tools are not wired for research yet (use /xmode to switch to a wired fleet: compete | deep | smart | rush)Known Limitations and Deferred Work: no cancel path yet for a research run dispatched this way (it
is a plain detached child process, not registered with the control.json protocol /run's
compete-mode dispatch polls); /dispatch, the planner's dispatch_run tool, /backlog run,
/workflow, and Ctrl+B remain unwired for research even though /run now works.
/settings — the visual settings screen (T418)
/settings # open it; Esc closesOpens the four-tab settings screen inside the chat REPL: Chat, Appearance, Dispatch,
Advanced, covering 39 knobs. It is the first instance of the interaction standard in
docs/design/tui-config-interaction-standard.md — tab bar, type-to-filter box, two-column
label/value list, and an inline editor per row.
Keys: Tab / Shift+Tab switch category, any printable character types into the filter,
Up/Down move the cursor, Enter edits the selected row, Esc closes (or cancels an edit).
A row's editor depends on its type — a toggle flips on Enter, an enum opens an inline
Up/Down list, and a free value becomes an inline text field (Ctrl+U clears). Values that are
still at their default render muted; anything you have set renders in the accent colour, so a
glance tells you what this machine actually overrides.
Writes go to <CYBERINE_HOME>/.env through the same upsertEnvKeys writer /config theme and
/config model already use — one writer for user-set values, not two competing ones — and are
mirrored into process.env so they apply to the running process.
/config is unchanged and stays as the scriptable shorthand for its five Chat keys.
Run recipe:
cyberine chat # then type /settings
npm run selftest:settings-registry # headless: registry integrity + validation + no dead knobsKnown limitations and deferred work. Read this before assuming a knob took effect:
- Not verified on a real TTY. Ink cannot render headlessly and this repo has no TUI test
suite (snapshot tests were removed 2026-06-12 and must not return). What is proven is the
logic in
src/tui/settings/registry.ts— 32 assertions, positive-controlled. The look — column alignment, windowing at small heights, theme contrast — is unverified and belongs to R01 / T389 / T543. - Some knobs need a restart. The screen writes and mirrors into
process.env, but a knob read once at startup (CYBERINE_HOMEmost obviously) will not change the running process. Each such row says so in its help line; the screen does not currently mark them with a badge. - Three of the six named primitives exist.
TabbedView,KeyHintFooterand the settings screen shipped.SearchableListwas NOT extracted — its search box and windowing live insideSettingsScreenfor now — and the row editors are rendered inline rather than as separateToggleEditor/EnumEditor/ValueEditorcomponents. That was deliberate: input ownership stays in ONEuseInputinSettingsScreen, because two Ink components both callinguseInputrace for the same keypress. Extracting them as presentational-only components is still worth doing; migrating the fleet hub's folder-picker and run-filter onto the shared list (as the standard proposes) is not started. - No validation against live catalogs.
Planner modelandOpenCode modelaccept any string; they are not checked against the model catalog the way/modelis. - The registry smoke asserts every knob's env var is read somewhere in
src/, so a switch wired to nothing fails the gate. It does NOT assert the knob is read correctly.
/config [<key> <value>] — view or change chat settings
/config is the single place to read and write all in-session chat settings without memorising
separate keybindings or commands.
Bare /config — print current values of every managed key:
chat config:
fleet compete
permission default
model deepseek/deepseek-v4-pro
thinking on/config <key> <value> — set one key:
| Key | Values | Persists |
|---|---|---|
fleet | compete | swarm | research | deep | smart | rush | session record (executionMode) |
permission | default | acceptEdits | plan | auto | bypassPermissions | dontAsk | session record (permissionMode) |
model | any model id (e.g. deepseek/deepseek-v4-pro) | CYBERINE_CHAT_MODEL env key |
thinking | on | off | true | false | in-session only |
Examples:
/config fleet deep
/config permission acceptEdits
/config model openai/gpt-4o
/config thinking offEach successful set echoes a confirmation line (e.g. config: fleet -> deep). Unknown keys or
invalid values print the valid set without erroring.
The fleet and permission keys are the same settings cycled by /xmode and /mode (and
Shift+Tab) — /config is just an alternative path that accepts the value directly without cycling.
/dispatch <T-id...> — claim + launch task(s)
With one or more ids (/dispatch T05 T07), the command parses a whitespace- and comma-separated
list (normalizes and dedupes ids) and claims through the same board lock as cyberine tasks run.
If any id is invalid or unavailable, the whole selection is refused before launch.
Per-id gate (every non-runnable id is refused with a specific reason):
T00: not in manifest
T01: already [done] — /next for what's runnable
T02: already [doing] — /agents to watch it
T04: [backlog] — flip to [todo] first (oc-tasks doing T04)
T09: [forbidden] — permanent tombstone, never runs
T11: held by blocker: <text> — clear the 'blocker:' line to release
T12: blocked — waiting on: T03, T07
T13: no do: and no title to dispatch — edit the manifestdo: is the task prompt sent to the worker; if both do: and the ## Tnn title are blank the
manifest entry is not enough to launch a run and /dispatch refuses with the last line.
Valid ids are claimed BEFORE launch ([todo] -> [doing] written to the status overlay) and dispatched
through the active execution mode. Only compete is wired up — in swarm or research the whole
command refuses with the "not built yet" line before any claim or launch. On a successful launch
the chat prints:
dispatched 2 task(s):
T05 (Add webhook signature verification)
runId: abc12345...
log: .local/runs/abc12345.../console.log
watch: cyberine agents
T07 (next: webhook sig)
runId: def67890...
log: .local/runs/def67890.../console.log
watch: cyberine agents
status flips on completion: done on success, todo on failureIf the launch itself fails after the claim, the overlay flips back to [todo] so you can
re-dispatch without manually editing the manifest. When the run reaches a terminal state, the
chat's completion handler auto-flips the task: [done] on success, back to [todo] on error /
cancelled — so the chat is a full loop (pick a T-id, watch it run, see the backlog reflect the
outcome) without leaving the chat.
/dispatch with no args claims everything currently runnable — the one-shot equivalent of "claim
every free task":
dispatch: claiming all 2 runnable task(s): T03, T07plan and dontAsk permission modes additionally block /dispatch with
plan mode: /dispatch blocked — switch /mode first /
dontAsk mode: /dispatch blocked — only pre-approved actions allowed. The claim happens AFTER
both gates, so a blocked-mode dispatch never produces a partial status flip.
Shared-manifest interop — how cyberine and the nf-tasks skill coexist
The Claude Code nf-tasks skill (the oc-tasks tool's run / done / doing actions) and the
cyberine chat both read the SAME manifest and flip status on the SAME tasks. The naive approach —
rewriting the manifest on every flip — loses updates under concurrency (last-writer-wins on the
whole file) and disturbs every other agent holding the manifest in context.
Cyberine solves this with a side overlay file:
<repo-root>/.local/tasks/status.logStatus flips are APPENDS — one line per flip, TSV, same format the nf_append script in the skill
writes:
<epoch-seconds>\t<agent-tag>\t<T-id>\t<status>\t<extras>\n<extras> always carries at least tz:<±HH:MM> — the writer's own UTC offset at flip time
(OVERLAY-TZ-TOKEN, agreed with nf-tasks 2026-09-17) — ahead of an optional evidence:<text> /
fail:<reason> breadcrumb, e.g. tz:+07:00 alone or tz:+07:00 evidence:typecheck 0. This is
what keeps completed: and the journal's ts showing the same day on every machine that
reconciles the rsync-mirrored board, in the flipper's own timezone, instead of the reconciling
machine's; a line written before this token existed has no tz: and still folds exactly as
before (see docs/tasks-manifest.md section 7 for the full grammar and validity rule).
Concurrent O_APPEND writes of short lines serialise in the kernel and never interleave or clobber
— no lock required. Both sides resolve the path through ONE resolver (src/tasks/boardPaths.ts),
so worktrees write to the main worktree's log and a submodule with no board of its own anchors to
its superproject — the same derivation the skill's nf_resolve performs. Effective status =
base token in the manifest, OVERRIDDEN by the last status.log line for that id:
- The manifest file is NEVER rewritten by a flip — it stays as the author wrote it.
readManifestreads the manifest then folds the overlay onto every task's status before returning.- The CC skill's own
nf_appendand cyberine'ssetStatuswrite lines in the same TSV format, so the two systems share one backlog.
What this gives you in practice:
/dispatchflips a task to[doing]and the same id appears as[doing]in the CCoc-tasks statusview (the fold is the same).- Closing a task works from either side:
oc-tasks done Tnn(CC) and the chat's/dispatchcompletion handler (cyberine) both append adoneline; whichever lands last wins. - Concurrent agents — chat + Claude Code + an external
oc-tasks run— never clobber the manifest, because every flip is an append.
setStatus() honours NF_TASKS_AGENT for the agent tag (whitespace sanitised; falls back to
user@pid otherwise) so the provenance column in status.log shows which side wrote each line —
useful when you scan status.log by hand to see who claimed what. Writable statuses are restricted
to todo | doing | done | backlog (forbidden is set by hand in the manifest; recurring R-ids
are status-only). The fold behaviour — including the agent-tag fallback — is covered end-to-end
by scripts/t256-smoke.ts (run on demand; not part of npm run typecheck).
cyberine tasks calibrate — declared effort vs measured context tokens (kj7t5)
The board declares effort: S|M|L per task at draft time, with no way to know whether an S
actually costs S-sized context until something measures it. cyberine tasks calibrate closes that
loop: it scans every .local/runs/*/autopilot.jsonl (written by the autopilot session driver,
src/autopilot/sessionDriver.ts), attributes each turn's context consumption to whatever
taskId the record carries, buckets the summed actuals by the task's CURRENT declared effort
(read fresh off the board via readManifest), and flags a task whose actual landed outside its
declared class's neighbouring range.
Attribution: turns are grouped by consecutive equal taskId within one run's autopilot.jsonl (a
turn with no taskId breaks the group but its context still becomes the next group's baseline).
A group's contextDelta = contextTokens at its last turn minus contextTokens at the turn
immediately before its first turn (0 when the group opens the file). A task spanning several runs
gets one row per run/session; the calibration pass sums those rows by id.
Run recipe:
cyberine tasks calibrate # table: n/median/p90/median-cost per class, plus outliers
cyberine tasks calibrate --json # the same report as JSON
cyberine tasks calibrate --runs-dir <path> # scan a different runs directory (default .local/runs)Writes two files under .local/autopilot/ (full regeneration each run — this is derived data,
never hand-edited):
effort-actuals.jsonl— one row per (task, session):id,declared,contextDelta,costUsd,turns,sessions,date.calibration.json—{ version, generatedAt, windowTokens, classes: { S, M, L }, outliers }. Each class reportsn,median,p90,medianCostUsd; belown = 3samples the last three arenull(printedn/a— too few points for a percentile to mean anything). An outlier is a task whose summed actual sits above the NEXT class'sp90or below the PREVIOUS class'sp10(the check is skipped when that neighbour class has zero samples);inferrednames the class whose measured range actually contains the actual, checked against every class EXCEPT the declared one (a class's own range trivially contains its own extreme member, which would make every outlier "infer" straight back to the mismatch it was just flagged for).
With zero autopilot history, it exits 0, prints no autopilot runs recorded yet, and still writes
a calibration.json with every class at n: 0 and every statistic null — so a caller can always
read the file rather than branch on "does it exist yet". windowTokens comes from
CYBERINE_AUTOPILOT_WINDOW_TOKENS (default 1000000).
resolveEffortBoundaries() (src/autopilot/effortCalibration.ts) is exported for the future
scheduling-policy consumer: it returns the measured { median, p90 } per class when that class
has n >= 3, and falls back to DEFAULT_EFFORT_TABLE otherwise — S 40000/60000, M
120000/180000, L 300000/450000 median/p90 context tokens. That table is documented, not
tuned: a starting point before this project has measured a single real autopilot run, not a
number anyone should trust until calibration.json carries real samples.
Verify it:
npm run selftest:effort-calibrationTwo fixture run directories (hand-written autopilot.jsonl) plus a fixture board pin the whole
attribution chain: per-class n/median/p90 (with the n < 3 class reporting n/a), a task
spanning two sessions summing correctly (and counting ONCE toward its class's n, not twice), an
outlier declared far below its measured class, and calibration.json validating against a zod
schema after a real write to a temp dir.
Known Limitations and Deferred Work. The command only reads history that already exists — it
never drives a session itself; pairing it with sessionDriver.ts and stamping taskId from a
[nf-tasks <id>] prompt prefix is a future autopilot command, not this one. resolveEffortBoundaries()
is exported but nothing in the scheduler consumes it yet — that wiring, and any policy decision
about what to DO with a flagged outlier (split the task? re-estimate it?), is deliberately out of
scope here. The outlier check only looks at immediate-neighbour classes (S vs M, M vs L), never a
direct S-vs-L comparison, and percentiles use nearest-rank (not linear interpolation), which is
coarse at small sample counts — expect noisy boundaries until real history accumulates.
cyberine tasks <subcommand> — create, re-judge, reconcile and archive the board
Everything above is the READ half. The subcommands below provide the board view and WRITE half: a
port of the deterministic core of the nf-tasks skill into src/tasks/board*.ts, so a machine
that has cyberine but NOT the skill can create, re-judge, reconcile and archive the same board,
and on a machine that has both, either tool picks up where the other left off. The format contract
both sides conform to is docs/tasks-manifest.md.
Bare cyberine tasks is cyberine tasks status: it prints the board, and --all, --summary
and --json pass through (k0zd1, 2026-09-30). cyberine tasks next is cyberine next. The
legacy .local/dogfood/backlog-*.md listing moved to cyberine tasks dogfood [--all].
cyberine init creates .local/tasks/drafts/consumed/, including when a task manifest already exists.
Ids accept git-style unambiguous prefixes everywhere (kvg resolves to kvgex when it is the
only match; an ambiguous prefix errors with the candidate list). Validation refusals exit 2;
operational failures (lock busy, doctor findings) exit 1.
Run recipes:
# read the whole board; --all removes submodule path scope.
# The full view ends with a [board health] section (backlog-rot / age / stale-doing / recurring)
# when there is a finding.
cyberine tasks status
cyberine tasks status --all
# one stdout line for a SessionStart banner; scope notices and stale-doing warnings use stderr
cyberine tasks status --summary
# the whole board as one JSON line (schema board-status/1, see "JSON reads of the board");
# --all works as above, --summary does not combine with it (exit 2)
cyberine tasks status --json
# preview the highest-priority runnable tasks without taking the board lock or changing status
cyberine tasks run --dry --take 2
# atomically claim two tasks, then hand the printed prompts to workers
cyberine tasks run --take 2
# claim exactly these runnable ids; the whole call refuses if either is unavailable
cyberine tasks run k7m2q T03
# renew this session's claim leases (heartbeat) -- a live session re-stamps its holds so they
# are never reclaimed; reclaims any other session's expired claims first
cyberine tasks run --renew
# cluster the runnable pool into worker GROUPS for affinity-aware fan-out (ported from nf-tasks'
# `group`, krf5m): dependency components first, then merged into affinity clusters by explicit
# area: tag or >= 2 shared title/do: tokens, then bin-packed into K groups (default 5) ordered by
# priority (each group's own priority is its most urgent member). Read-only -- takes no lock.
cyberine tasks group
cyberine tasks group --workers 3
cyberine tasks group --json
# start a machine-local board, or create a tracked board in .claude/TASKS.md
cyberine tasks init
cyberine tasks init --sync
# move the manifest, briefs, archive, dedupe ledger and journal together
cyberine tasks relocate --to sync --dry
cyberine tasks relocate --to sync
cyberine tasks relocate --to local
# create a task -- --effort is MANDATORY (S|M|L); omitting it exits 2 and writes nothing
cyberine tasks add --title "Add rate limiter" \
--do "Add a token-bucket rate limiter to the auth API." \
--done-when "POST /login returns 429 after 5 reqs/10s from one IP." \
--effort M --model sonnet --exec agent --priority p1 --area auth \
--deps T03,k7m2q --why "bound login abuse" --brief-file ./notes.md
# draft a task the OPERATOR promotes later (kwzd6): --status accepts todo|backlog ONLY, so no new
# block can be created as doing, done or forbidden. --created-by records who drafted it.
cyberine tasks add --title "..." --do "..." --done-when "..." --effort S \
--status backlog --created-by autopilot-brainstorm --why "USAGE.md Known Limitations under X"
# re-tier an existing task (any subset of the four tier flags; at least one required)
cyberine tasks set kvg --effort L --model opus --exec agent --priority p0
# per-task reasoning effort for a dispatched agent (kfvvf): low|medium|high|xhigh|max, exec: agent
# only, placed right after effort:. "-" removes it. add --reasoning and save's
# "reasoning" key write the same field. Known limitation: cyberine autopilot does not pass it to
# the driven session yet; it is a board field shared with nf-tasks until then.
cyberine tasks set kvg --reasoning high
cyberine tasks set kvg --reasoning -
# edit a live task's text. --do and --done-when narrow scope, so they REQUIRE --reason
# (a value is whitespace-collapsed onto ONE line, so a newline can never inject a field)
cyberine tasks set kvg --title "Add the rate limiter to the login API"
cyberine tasks set kvg --do "Add a token-bucket limiter, 5 reqs/10s." --reason "narrowed after audit"
cyberine tasks set kvg --done-when "POST /login returns 429 after 5 reqs/10s." --reason "made observable"
cyberine tasks set kvg --why "bound login abuse"
cyberine tasks set kvg --why - # "-" removes the why: line
# --done-when also rewrites the brief's ## Done-when body (heading + comment kept); --do never
# rewrites the brief's Intent -- it only prints a stale-brief notice when a brief exists
# re-tier MANY tasks at once (ported from nf-tasks): all-or-nothing -- every entry is validated
# BEFORE anything is written, and one bad entry (unknown id, invalid field, exec/model mismatch,
# duplicate id) rejects the whole batch with every problem listed, manifest byte-identical.
# On success it is ONE atomic manifest write plus ONE journal event naming the count, the judge,
# and the id list -- never one event per entry, which would hide that these were one decision.
# --by is REQUIRED: a batch with no judge named is not an audit trail. Reuses the exact same
# per-field validation as the single `set` form above, so the two paths can never drift on what
# an exec/model/priority/effort/reasoning value is allowed to be.
cat > /tmp/batch.json <<'JSON'
{"changes": [
{"id": "kvg", "model": "opus", "priority": "p0"},
{"id": "k2x4", "exec": "manual", "model": "-"}
]}
JSON
cyberine tasks set --batch /tmp/batch.json --by claude-opus-5
echo '{"changes":[{"id":"kvg","reasoning":"high"}]}' | cyberine tasks set --batch - --by claude-opus-5
# Known limitation: batch entries carry only model/reasoning/exec/priority/effort -- title/do/
# done-when/why stay single-id-only (set <id> --title|--do|--done-when|--why), since those fields
# are not part of a re-tier decision and --do/--done-when's --reason requirement has no batch-wide
# analogue yet.
# flip status (an APPEND to the overlay -- no lock, never rewrites the manifest)
cyberine tasks status doing k7m2q
cyberine tasks status done k7m2q --evidence "typecheck 0, sweep 194/0"
cyberine tasks status todo k7m2q --fail "provider 402, retry when funded"
cyberine tasks status fail k7m2q "provider 402, retry when funded" # also appends ## Attempts to the brief
cyberine tasks status doing k7m2q --path # stamp path: from this cwd if absent
cyberine tasks status done # close every effective [doing] task
cyberine tasks status forbidden k7m2q # explicit tombstone flip
# recurring cadence (R blocks in the board manifest)
cyberine tasks recurring list # STATE|id|days|every|last-done|title
cyberine tasks recurring due-count # count NEW and DUE blocks
cyberine tasks status did R02 # set last-done: to today's local date
# recurring writers (R_FIELD_ORDER: every, last-done, exec, model, reasoning, do)
cyberine tasks recurring add "Run flow-dream on memory" --every 7d --do "Run flow-dream." --exec agent --model sonnet
cyberine tasks recurring set R02 --model haiku --reasoning high
cyberine tasks recurring set R02 --exec - # "-" clears exec/model/reasoning/last-done (never every:/do:/title)
cyberine tasks recurring drop R02
# `run` also dispatches an OVERDUE recurring block that opted in with `exec:`, in its own trailing
# section after the task plan -- close it with `status did`, never `status done`. A block with no
# `exec:` stays surface-only (recurring list/due-count only) and never appears in `run`'s plan.
cyberine tasks run --dry # includes a "## Recurring dispatch" preview
cyberine tasks run # claims tasks AND marks overdue R-blocks dispatched
cyberine tasks status did R02 # close it; a second `run` will not re-dispatch it
# blocker: field writers (a lock-guarded set/clear on the block, not a status flip)
cyberine tasks status block k7m2q "waiting on a third-party API key"
cyberine tasks status unblock k7m2q
# dependencies
cyberine tasks deps show k7m2q
cyberine tasks deps set k7m2q "T03,k9s4m" # "-" clears to `deps: -`
# bake the overlay back onto the manifest (the rare, deliberate op)
cyberine tasks reconcile --dry-run # reports what WOULD change, writes nothing
cyberine tasks reconcile
# move every [done] block into TASKS_ARCHIVE.md, briefs alongside
cyberine tasks archive # unconditional
cyberine tasks archive --auto # no-op below 10 done tasks
# one-shot end-of-session wrap: gate on pending drafts (exit 2, nothing written), reconcile the
# overlay, archive EVERY [done] task with no threshold (briefs move alongside), report anything
# still [doing], then print the resulting board
cyberine tasks end
cyberine tasks end --force # wrap anyway with drafts pending (they stay in the inbox)
# tombstone a task: bakes [forbidden] in place, writes reason: under the header, journals a note
cyberine tasks status forbidden k1abc --reason "drops the production database"
# read-only integrity report; exits 1 on a ghost-id gap in the LIVE manifest.
# section 8 (informational, never gates) lists [forbidden] blocks with no reason: and
# reason: lines left on a block that is no longer [forbidden]; section 10 (informational) lists
# open tasks short of the manifest standard: a do: of more than one sentence or over 300
# characters, and an effort M/L task with no brief
cyberine tasks doctor
# duplicate handling (writes TASKS_DEDUPE_LEDGER.jsonl next to the manifest)
cyberine tasks merge k9s4m k2p3t # k9s4m becomes duplicate-of k2p3t
cyberine tasks merge --reject k9s4m k2p3t # record "reviewed, NOT duplicates"
# remove live tasks you will not do: block + brief gone, ONE "dropped" journal note.
# Ids are EXACT (a bare prefix is refused with a did-you-mean -- unlike `status`/`set`/
# `deps`/`merge`, which accept an unambiguous prefix). Refuses [done] (archive it),
# [forbidden], archived ids, and ids a surviving task still depends on (or is duplicate-of).
cyberine tasks drop k9s4m --reason "superseded by k2p3t"
cyberine tasks drop k9s4m k2p3t # several ids at once
cyberine tasks drop k9s4m --dry # print the plan, change nothing
# erase EVERY trace of an id, live or archived: manifest/archive block, both brief
# copies, journal events, dedupe-ledger rows -- the only way to remove an archived,
# [done] or [forbidden] id. Nothing records that it happened.
cyberine tasks forget k9s4m
cyberine tasks forget k9s4m k2p3t # several ids at once
cyberine tasks forget k9s4m --dry # print the plan, change nothing
# append a dated update to a live task without touching its status or fields:
# `note` appends `update <YYYY-MM-DD>: <text>` below the manifest block's own fields (a
# non-field-shaped line every parser reads past); `brief note` appends `- <YYYY-MM-DD>: <text>`
# to the brief's `## Updates` section, creating it above the first heading the first time.
# Both accept an unambiguous id prefix, both journal a `note` event, and both refuse a
# recurring id and a [forbidden] tombstone; `brief note` also refuses a task with no brief.
cyberine tasks note k7m2q "re-scoped: only the read path is in for v1"
cyberine tasks brief note k7m2q "confirmed with ops: the write path ships in a follow-up"
# record work already done outside the task flow (born [done], straight into the archive)
cyberine tasks log --by claude-opus-5 "Fixed CORS on the gateway" "Bumped opencode 0.4"
# journal + timeline
cyberine tasks journal "Investigated the flaky CI job" --body "..." --id k7m2q
cyberine tasks journal --title "Big refactor day"
cyberine tasks timeline # month-grouped day list, newest first
cyberine tasks timeline 2026-09-06 # one day: done spine, decisions, notes
cyberine tasks timeline --grep "rate limit"
# machine-readable snapshot of every task, JSONL on stdout (for a dedupe pass)
cyberine tasks corpus --recent 30
# read-only: commits in a window whose message names no board task id (in any form: the
# scope, the subject or the body), plus the uncommitted paths. The window is a git ref
# (commits after it) or anything git log --since accepts; the default is the last 24 hours.
# Merges are excluded. /cyberine:end reads it to propose logging work that never reached the board.
cyberine tasks unlogged
cyberine tasks unlogged --since 65305c88 --json
cyberine tasks unlogged --since "3 days ago"
# render a READ-ONLY, self-contained static HTML snapshot under .local/tasks/board.html:
# a Kanban by effective status, a sortable table, and recurring/archive/journal tabs, with a
# search box and model/area/exec filters. No network requests; nothing on the page writes the
# board, and its statuses are the SAME derivation `cyberine tasks status` uses, so a flip not
# yet reconciled shows on it immediately.
cyberine tasks board
cyberine tasks board --open # also open it in the default browser
cyberine tasks board --out /tmp/board.html # write somewhere else
cyberine tasks board --json # print the page's data as JSON, write nothing
cyberine tasks board --archive-recent 0 --journal-days 0 # smaller page (no archive/journal rows)
# convert a pre-convention board in place (timestamped backup first)
cyberine tasks migrate --dry
cyberine tasks migrate
# read-only: inspect board state (manifest counts, drafts, recurring-due, tidy candidates,
# the same board-health findings as `status`) and print the one recommended next command
cyberine tasks suggest
# mechanical housekeeping only: reconcile, refresh the header token-list comment, group every
# [forbidden] block at the end under a tombstone marker, and report (never auto-rewrite)
# old-style forbidden workarounds -- a [todo]/[backlog] task whose title/blocker/do: reads
# "FORBIDDEN"/"NEVER RUN"/"DO NOT" but was never flipped to [forbidden]
cyberine tasks tidy
# reorder one whole task block relative to another; refuses a recurring (R##) block, a
# [forbidden] tombstone, either as the block being moved OR as the anchor, and moving a task
# relative to itself
cyberine tasks move k7m2q --before k9s4m
cyberine tasks move k7m2q --after k9s4m
# the pure validator+writer: consume an already-decided batch. `save` and `inbox` are the
# documented verbs since k8acs; the older names `converge-apply` and `gather` still work as
# hidden aliases for scripts, and no refusal or reply names them.
cyberine tasks save --schema
cyberine tasks save --input batch.json
cat batch.json | cyberine tasks save
# every entry needs a six-section "brief" (kg2gf): a missing brief, or a bare N/A / None / TBD / -
# in any section, rejects the whole batch. decisions/rejected/gotchas take "None — <why>" instead.
# "exec" and "model" are optional (k4hwz): an entry without them gets exec: agent / model: sonnet,
# the same defaults as `add`, so a skill never has to pass them.
# leave a provenance-stamped draft in another repo's inbox, then read pending drafts there
cyberine tasks draft --to ../other-repo "Add rate limiter" --do "Limit login attempts" --done-when "Excess attempts return 429"
cd ../other-repo
cyberine tasks inbox
# copy each listed filename into its task's "draft" key in batch.json before applying
cyberine tasks save --input batch.jsonKnown Limitations and Deferred Work. Neither verb resolves an ARCHIVED id (moved out of the
live manifest by archive/end) -- both refuse it the same way as an unknown id's sibling
check, naming it archived rather than missing, and point back at note for a task that has no
brief. Neither edits any of the brief's six converge sections or the manifest's own fields --
note/brief note are purely additive logs, never a substitute for set/deps set when the
task itself changed. There is no note --list or per-task update history view yet; read the
manifest block or the brief's ## Updates section directly, or cyberine tasks timeline for the
journal's day-grouped view of the same events.
Set NF_TASKS_SYNC_DIR=.codex before cyberine tasks init --sync or relocate --to sync to use .codex/TASKS.md. A tracked board adds <syncDir>/journal/*.jsonl merge=union to .gitattributes; .local/ stays ignored. Relocation refuses pending status flips, competing manifests, and destination files it would overwrite. A .local/tasks/ACTIVE.md symlink that resolves to the tracked manifest (left by older nf-tasks setups) is an alias, not a competing board: relocate names it in the --dry plan as an unlink: line and removes it before moving the real file, and cyberine tasks doctor no longer reports it as a second manifest. relocate --to local runs no git command: when the source was tracked it prints a note: that the moved files now show as deleted, and the removal commit is left to the repo; it appends .local/ to .gitignore only when the new location is not already ignored. In plain words, /cyberine:tasks maps "untrack the board" / "đưa board ra khỏi git" to --to local and "track the board in git" to --to sync, dry-runs first and asks once. Run cyberine tasks reconcile before relocating a board with pending flips. cyberine tasks doctor exits 1 when two manifest candidates exist and reports which board hides the other.
Known Limitations and Deferred Work. Relocation does not merge competing boards or reconcile status flips automatically. Resolve both cases before moving the board.
Known Limitations and Deferred Work. cyberine tasks suggest is read-only and never writes anything, including the manifest, the overlay or the lock file; it always reports hook : not-applicable because cyberine has no SessionStart hook to detect (unlike upstream nf-tasks suggest, which checks for one). cyberine tasks tidy only does the mechanical half: it reconciles, refreshes the header token-list comment, and groups every [forbidden] block at the end under a tombstone marker (the neutral (board tidy) marker and Task board manifest v1 header line since kd2nb, so every writer puts the same bytes on a shared board and no board names a private tool; an older (... tasks tidy) marker is recognised and replaced, never duplicated, and an older nf-tasks manifest v1 header that already lists [forbidden] is left as it is); it reports old-style forbidden candidates (a [todo]/[backlog] task whose title/blocker:/do: reads FORBIDDEN/NEVER RUN/DO NOT) but never rewrites one — confirming and migrating a candidate to [forbidden] is a judgement call left to cyberine tasks status forbidden <id> plus hand edits to its fields, same as upstream. cyberine tasks move only relocates a whole task (T##/k####) block; it refuses to move a recurring (R##) block, a [forbidden] tombstone, or to anchor relative to either, and anchor resolution is exact-id-match only (no prefix support).
Flags, per subcommand:
| Subcommand | Flags |
|---|---|
status (board view) | --summary (one stdout line), --all (all paths when reading a superproject board from a submodule) |
status <done|todo|doing|backlog|forbidden> [id...] | --evidence <text> (only on done), --fail <reason> (only on todo), --reason <why> (only on forbidden), --path (only on doing); done with no ids closes every effective doing task |
status fail <id> "<reason>" | Returns to todo and appends a dated Attempts line to the brief |
status did <Rxx> | Sets the recurring block's last-done: to the local date |
recurring | list or due-count |
run | --take N or exact <id...> selectors; --dry / --dry-run previews without locking or claiming |
group | --workers N (default 5, clamped to [1, component count]), --json |
add | required --title, --do, --done-when, --effort S|M|L; optional --model <tier> (default sonnet; forced to - when --exec manual), --exec agent|manual|opencode (default agent), --priority p0|p1|p2, --area <tags>, --deps <ids>, --why <text>, --brief-file <path> |
set <id> | any of --effort S|M|L, --model <tier>|-, --exec agent|manual|opencode, --priority p0|p1|p2, --title <text>, --do <text>, --done-when <text>, --why <text>|-; --reason <text> (REQUIRED with --do or --done-when) |
deps | show <id>, set <id> "<ids|->" |
archive | --auto |
end | --force |
reconcile | --dry-run (alias --dry) |
doctor | none |
merge | <dup> <survivor>, or --reject <a> <b> |
drop | <id...>, --reason "<why>", --dry |
forget | <id...>, --dry |
log | --by <model>, then one or more quoted descriptions |
journal | "<title>" with --body <text> / --id <id>, or --title "<text>" for a day-title override |
timeline | [<YYYY-MM-DD>], --grep <pattern> |
corpus | --recent N (default 30; 0 drops the archive section) |
board | --out <file>, --open (--no-open is the default), --json, --archive-recent N (default 50; 0 for none), --journal-days N (default 14; 0 for none) |
migrate | --dry (alias --dry-run) |
suggest | none |
tidy | none |
move <id> | --before <anchor-id> or --after <anchor-id> (exactly one required) |
save | --schema, --input <file|-> |
draft | optional --to <repo-path>, --do <text>, --done-when <text>, --why <text>, --by <model-id>, --file <path> or --stdin; required title |
inbox | none |
Known Limitations and Deferred Work. cyberine tasks status only reads the board: it does not auto-reconcile when the overlay reaches 200 lines. The full view ends with a [board health] section that announces findings only — dep-satisfied [backlog] rot (naming the status todo <id> promote for each), [todo]/[backlog] ages from a created: field, stale [doing] claims, and the recurring due/dispatchable/manual split. Each group prints only when it has a finding, so a healthy board prints no section, and it never promotes or flips anything — there is no health gate; cyberine tasks tidy is a separate, deliberately-invoked mechanical pass, never triggered automatically by status or suggest. --summary emits one banner line and leaves the detailed task list, recurring schedule, dependency reasons and the health section to the full view. A [doing] task has no measurable age after its overlay claim is reconciled away, so the stale warning only covers tasks with a remaining overlay timestamp; NF_TASKS_STALE_DOING_DAYS changes its default three-day threshold.
Known Limitations and Deferred Work. cyberine tasks group never claims or writes anything
(read-only, same as run --dry); dispatching a group is still the caller's job (one long-lived
session per group, doing that group's tasks in listed dependency order). Its area:/token
affinity clustering does not consult path: submodule scope, so --all is implicit -- it groups
the whole board even when run from inside a submodule. Priority ordering is per-GROUP (its most
urgent member decides), never per-task inside a group, matching upstream: a p2 task riding
along in an otherwise-p0 area group keeps its own p2 label and is never reordered ahead of a
same-group p0 task by this command.
Known Limitations and Deferred Work. cyberine tasks board writes a STATIC snapshot, not a live view: the page is regenerated by hand and never refetches, and the effective statuses it embeds are read once with the same derivation cyberine tasks status uses — so a flip made after it was generated is not on it until the next run (a flip that is already pending appears immediately, unreconciled). It writes only its own HTML file (default .local/tasks/board.html; --out changes that) and touches no board, manifest, archive or overlay file. The per-card copy buttons only put a cyberine tasks status ... command on the clipboard — nothing on the page writes the board. There is no groups tab embedding cyberine tasks group's output (run it separately) and no live #/tasks dashboard view; the page is a plain file under .local/, so it works offline and needs no server.
What the writes actually touch:
runreads the effective board under the shared lock, orders runnable[todo]tasks byp0,p1,p2and then manifest order, and writes[doing]flips to the overlay before releasing the lock. Missing priority meansp1. An id selection is all-or-nothing. Its plan includes the task's model tier, exec kind, brief path when present,[cyberine-task <id>], andCYBERINE_TASKS_ROOT=<root>so a worker resolves the same board.NF_TASKS_ROOTremains a supported fallback pin.--dryreads without taking the lock and never writes. In the SAME pass (same lock, an id-selection refusal aborts both halves together) it also re-reads every recurringR-block: one currently DUE or NEW that carriesexec:gets a trailing "## Recurring dispatch" section in the plan and, on a real (non---dry) run, an overlay line<id> dispatched-- a distinct token fromtodo/doing/done/backlog/forbiddenthat can never collide with a task flip and thatreconcilecan never bake into the manifest (its header-match regex only ever seesT/k-shape ids). A block already markeddispatchedis skipped untilstatus did <id>resets its cadence, so one overdue chore is dispatched exactly once per due cycle even across concurrentruninvocations (same claim lock as the task side). Amodel:missing or invalid on anexec: agentblock defaults tosonnetwith aNOTE:line, same as the task side.cyberine nextshows the same priority order;cyberine next --dispatch <id>immediately launches one run and spends the configured provider or seat quota.cyberine tasks runonly claims and prints a plan for sessions to execute.cyberine autopilotis the separate unattended board loop that selects, runs, and closes tasks. TUI/dispatchclaims through the same locked path astasks runbefore it launches.add/save/set/deps/merge/drop/forget/archive/migrate/reconciletake a portable mkdir spinlock at<root>/.local/tasks/.reconcile.lock(5s wall-clock ceiling) and rewrite the manifest via a same-directory temp file plus rename. Astatusflip takes NO lock — it is an append, which is the whole point of the overlay.archivewritesTASKS_ARCHIVE.mdFIRST, then the manifest, both atomically. A crash between the two leaves the task in BOTH files; recovery is always to delete the block fromTASKS_ARCHIVE.md, never from the manifest.- Every locked transform counts task headers before and after (fence-aware) and REFUSES the write on a mismatch or on empty output, leaving the overlay intact.
- A
[forbidden]block is a tombstone:reconcilerewrites its bracket only when an explicittodoflip appears somewhere in the pass. - A block whose bracket is empty (
## k1 []) has no status to rewrite, soreconcileleaves the whole block byte-identical (nocompleted:, noevidence:, nothing cleared), consumes the flip, and prints one stderr line per such id:cyberine tasks: reconcile discarded a done flip for k1 -- its header bracket is empty ([]), so there is no status to rewrite; repair the bracket and flip it again. No command writes[]; it only comes from a hand-edit of the manifest.
Known limitations and deferred work:
-
runprints a plan; it does not start an agent or close a task, for either a task or a recurring block. A dry preview can become stale before a later claim, so rerun without--dryto reserve the current selection.--dry-with-mateand OpenCode-specific routing (upstream routes anexec: opencodeblock through thenf-opencodeskill invocation shape) are deferred;runprints the same plain prompt/CYBERINE_TASKS_ROOTshape for every exec kind, task or recurring. -
A claim carries a lease (ky0c9). Each
runstamps alease: <session>@<host> <expires-ms>field onto the claimed block and flips it to[doing]. The claim outlives neither its session nor the board:runreclaims any[doing]task whose lease expired (back to[todo], one journal line naming the dead owner) before selecting, andreconcileclears alease:line the moment a task leaves[doing]. Renewal is the explicitrun --renewheartbeat (any board write does NOT renew — a session doing long work with no intermediate write must call it). Astatus done/doingflip on a task you no longer hold is refused with the holder's name, never written over. -
No LLM judgement anywhere in this surface.
saveis a validator and writer only: it consumes an already-decided JSON batch (--schemaprints the shape) and makes no dedupe, triage or brief-writing judgement of its own.mergelikewise records a verdict you already reached; it does no similarity scoring. -
Drafts remain pending until named in a batch.
inboxprints thefrom,draft, and optionalpathvalues to copy into an already-decidedsavebatch. It does not decide whether to turn a draft into a task. An omitteddraftkey leaves that file pending; a misspelled key rejects the whole batch before writing. -
Recurring writers and dispatch.
recurring add/set/dropwrite R-blocks inR_FIELD_ORDER(every, last-done, exec, model, reasoning, do), matching upstream nf-tasks' validation (--reasoningonly withexec: agent;model:/reasoning:only withexec:set;droptakes an exact id, never a prefix).rundispatches an overdueexec:-bearing block once per due cycle (see therunbullet above). -
Task-quality warnings never refuse (k5n66).
addandsaveprintwarning: <id>: ...on stderr for a new task whosedo:reads as more than one sentence (terminators counted outside backticks, withe.g.-style abbreviations and version numbers ignored) or runs over 300 characters, andaddalso for an effort M/L task written without--brief-file; the task is still written and the exit code is unchanged.doctor's section 10 reports the same findings for every open task. -
A forbidden flip writes
reason:only with--reason.status forbidden <id> --reason "<why>"reconciles at once and writesreason:directly under the header (a second call replaces it); without the flag the tombstone lands with no reason and stderr says how to add one. -
A failed attempt has two writes. The todo overlay flip lands first; if the brief write fails, the command reports that partial result and exits nonzero. The brief can be missing and is created when the attempt is recorded.
-
addcannot setpath:(onlysavecan), andaddwrites a brief file only when--brief-fileis given — a missing brief is reported bydoctoras informational, never fatal. That file is copied VERBATIM totasks/<id>.md; it is not folded into the six-section templatesaverenders, because inventing placeholder sections would put prose on disk nobody wrote. A brief with no## Decisions/## Rejected approachesheading simply yields no journaldecidedevent at reconcile time. -
archivedoes not stampcompleted:on a normally-archived block; onlylog's born-done blocks carry it, matching the skill. -
endis a wrap, not judgement. It takes the board lock for the reconcile+archive pair and releases it before printing. Inside that pair it archives EVERY[done]task with NO threshold — unlikearchive --auto, which is a deliberate no-op below 10 — and moves each archived task's brief alongside it. It REPORTS every task still[doing]at session end but never flips one: whether adoingtask actually finished is a judgement the command will not make. A pending draft in.local/tasks/drafts/refuses the whole wrap (exit 2, nothing written);end --forcewraps anyway and leaves the drafts for a later converge. Re-running on a clean board is idempotent — it reportsno [done] tasks to archive.and reprints the unchanged board. -
dropandforgetare the two removal verbs — there is nomove. Dropping a live task deletes its manifest block and itstasks/<id>.mdbrief and appends ONEdropped <id>: <title>journal note (--reasonbecomes its body); earlier journal history,TASKS_ARCHIVE.mdandTASKS_DEDUPE_LEDGER.jsonlare left alone, so a dropped id still leaves traces in those. It refuses[done](archive it instead),[forbidden](a tombstone), archived ids, an id a surviving task still lists indeps:, and one a surviving task isduplicate-of. Ids are EXACT: a bare prefix is refused with a did-you-mean, unlikestatus,set,depsandmerge, which accept an unambiguous prefix.--dryprints the plan and writes nothing.move(reorder a block) is not ported. -
forgetis the only way to remove an archived,[done]or[forbidden]id, and it leaves no trace at all. It erases the manifest block AND theTASKS_ARCHIVE.mdblock, both brief copies (tasks/<id>.mdandtasks/archive/<id>.md), every journal event carrying the id (rewriting the day's.jsonlfile, or deleting it when the id was the only event left), and everyTASKS_DEDUPE_LEDGER.jsonlrow naming the id — unlikedrop, it records no note anywhere. It still works on an id whose manifest block is already gone but whose brief or journal events remain, and it sharesdrop's dependency/duplicate-ofguard against a surviving task. Ids are EXACT, same asdrop. It refuses an id with literally no trace on the board (no trace of this id) rather than silently doing nothing.--dryprints the plan and writes nothing. -
An unknown or empty bracket stays invisible.
[dne]or[]is parsed, warned about bycyberine nextand reported bycyberine tasks doctor, but stays out ofnext,/tasksand autopilot until it is hand-fixed;[done 2026-08-10]reads as its first token. nf-tasks refuses both the same way since 2026-09-20, so the two tools now derive the same runnable set. -
A flip against an empty bracket is HELD, not discarded (kc7x6). Reconcile cannot rewrite a
[]header, so instead of dropping the flip it re-publishes it verbatim as a freshstatus.dfile — original epoch, agent tag and extras intact — printsreconcile is holding a <status> flip for <id>, and leaves it out of the journal harvest. It survives any number of reconciles as exactly one retained line. Repair the bracket and the next reconcile bakes it with the date it ORIGINALLY happened, evidence included; nothing has to be re-flipped.cyberine tasks doctorsection 9 lists every held flip with its age:9) Held flips (informational -- never fails this report) k2abc -- held "done" flip, 5d old (2025-09-04T...)Known Limitations and Deferred Work. A held flip is only visible through
doctor— nothing reminds you at flip time, and nothing expires it, so a bracket never repaired holds forever. This is where the two implementations DIVERGE: nf-tasks still discards (measured 2026-09-21 on the same fixture,nf-tasks: reconcile discarded a done flip), so a board reconciled by nf-tasks loses the flip that Cyberine would have kept. That is deliberate — the two tools are not reconciled with each other — but it means the holding guarantee is Cyberine's alone. -
setjournals onenoteper call that changed anything.--title,--why,--do/--done-when(the latter two carrying their required--reason) and, since kg2gf (2026-09-26, matching nf-tasks),--exec/--model/--effort/--reasoning/--priorityappend a singlenotewhose title lists the changed fields and whose body carriesold:/new:per field. An absentpriority:is journalled asp1, its implicit value, and a flag that sets a field to the value it already has journals nothing. Every value is whitespace-collapsed onto one line, so a newline can never inject a field or a header. -
--do/--done-whenare scope cuts and REQUIRE--reason.--done-whenalso rewrites the brief's## Done-whensection body (the heading and its<!-- ... -->comment stay); it updates the manifest only, with a stderr notice, when the task has no brief or the brief has no## Done-whenheading.--doNEVER rewrites the brief's Intent — a mechanical rewrite would corrupt broader prose — and instead prints a stale-brief notice when a brief exists.--why -removes thewhy:line. -
Re-applying an old
savedecision after a--title/--doedit can mint a duplicate.convergeApply's skip-check is thetitle+dopair, so oncesetchanges either half the original decision no longer matches the block and a fresh id is minted instead of being skipped. -
sethas no--batch. A rubric re-tier of many tasks is N independent locked calls, with no single-write/single-journal-event batch form here (dropped by kqfjh). -
No test framework here either.
npm run selftest:tasks-writeandnpm run selftest:tasks-lockare the regression guards; both use throwaway repositories under the OS tmpdir and never touch this repo's own.local/tasks/board.
Interop with the nf-tasks skill (TASKS-INTEROP-SMOKE). A board written by either
implementation reads correctly in the other — npm run selftest:tasks-interop is the regression
guard, in two halves. Half one (always runs, no external dependency) drives cyberine's own
reader (readManifest / computeRunnable) and CLI (doctor, reconcile --dry-run, migrate --dry) against real-file fixtures under scripts/fixtures/tasks-interop/: a board exercising
k-id/T-id/R-id parsing, field-order independence (created-by:/why: before do:), a fenced
code block whose embedded ## id [status] title line must never count as a task, the v1+v2
overlay replayed by real filesystem mtime rather than filename order, the duplicate-of
tombstone + survivor dependency resolution, a legacy (pre-convention) board, and a non-board
file. Half two additionally drives the real Claude Code nf-tasks skill via bun: cyberine
add/status done/archive/logs a board, nf-tasks status/doctor reads it clean, then
nf-tasks log writes a born-done task and cyberine's own doctor reads that clean too. Half three (always runs) replays FROZEN nf-tasks output
captured under scripts/fixtures/tasks-interop/nf-frozen/ -- a reconcile, a converge block, a
run --dry set, and a whole board nf-tasks wrote (overlay, archive, journal, draft, briefs) --
and asserts cyberine reads and reproduces it, so the cross-tool contract stays checked on a machine
with no bun or nf-tasks (the CI runner, or after nf-tasks is retired). Where both exist, half two
runs too and also checks the frozen files still match the live nf-tasks; a mismatch fails with the
recapture command, TASKS_INTEROP_CAPTURE=1 npm run selftest:tasks-interop. Known limitation: the
frozen board is one capture of nf-tasks at the sha recorded in nf-frozen/meta.json; a behaviour
nf-tasks gains later is covered only after a recapture.
/cyberine:tasks — the plugin skill that supplies the judgement these commands refuse to make (k7h49)
Everything above is deterministic on purpose: save consumes an already-decided batch, merge records a verdict you already reached, and nothing scores similarity or writes a brief for you. /cyberine:tasks is the bundled Claude Code skill that supplies exactly that missing half, so a user with the plugin and no nf-tasks install still has a verb for "turn what we just discussed into tasks". It decides — dedupe, split, dependency order, a brief per task, what counts as evidence — then writes ONLY through the subcommands above; it never edits the manifest, the overlay, the journal or the archive directly.
Run recipe. The slash form below always works; since kfp69 (2026-09-29) the skill is also model-invocable, so in a repo that has a board, plain words with no "cyberine" in them ("what's next", "task nao tiep", "xong roi cai rate limit", "save these as tasks") reach it too. In a repo with no board, plain words do not; type /cyberine:tasks there and it offers to start one.
# in a Claude Code session with the plugin installed
/cyberine:tasks # READ-ONLY: board snapshot, then the next task and why
/cyberine:tasks save these as tasks # converge whatever the conversation just decided
/cyberine:tasks next # the same read, stated: pick one and explain the pick
/cyberine:tasks done k7m2q # close it with observed evidence
/cyberine:tasks block k7m2q "waiting on the Stripe live key"
/cyberine:tasks tidy # reconcile + archive --auto + doctor
/cyberine:tasks end # cyberine tasks end: gate on drafts, reconcile, archive all done, print the boardThe intent map it follows, and the command each intent lands on:
| Intent | Command it calls |
|---|---|
| "save these as tasks", "gom plan thành tasks", "turn this into a backlog" | cyberine tasks corpus to dedupe, then cyberine tasks save --input - with briefs in the batch |
| "what's next", "task nào tiếp" | cyberine next, then cyberine tasks corpus for the picked task's body |
| "done", "close X", "xong rồi" | cyberine tasks status done <id> --evidence "<what was observed>" |
| "block this on X", "chờ credential" | cyberine tasks status block <id> "<reason>" / status unblock <id> (k3c00) |
| "tidy the board", "archive done" | cyberine tasks reconcile, archive --auto, doctor |
| "add a recurring task", "every week remind me to X", "việc định kỳ" | cyberine tasks recurring add "<title>" --every <N>d|w|m --do <text>; status did <Rxx> when one is done |
| "add one task" | cyberine tasks add --title ... --do ... --done-when ... --effort ... --brief-file <path> |
"wrap the board", /cyberine:tasks end | cyberine tasks end (add --force only while drafts are pending) |
It is the cheap counterpart to /cyberine:plan: plan INVENTS a list nobody has yet and pays a full fleet round plus adversarial skeptics to expose the gaps; this skill CONVERGES a list the session already holds, in one turn, for the price of the conversation that produced it.
Known Limitations and Deferred Work. No status forbidden. Recurring chores are cyberine tasks recurring add|set|drop|list and status did <Rxx>; the skill routes "add a recurring task" / "every week remind me to X" there. The drafts inbox is available through cyberine tasks draft --to, inbox, and the draft key in save; the skill still has to decide which gathered drafts become tasks. Dedupe is the skill's own judgement: the only automatic check is save's exact (title, do) idempotence skip, so a re-worded near-duplicate lands as a second task and is repaired afterwards with cyberine tasks merge <dup> <survivor>. save only CREATES, so the skill cannot update an existing task through it — retiering is set, deps are deps set, and a blocker: on an existing task uses status block / unblock. Its claude plugin eval coverage is the nl-* suite's board intents (nl-next-*, nl-capture-*, nl-board-*, nl-neg-noboard-*), and as of 2026-09-13 it has never converged a real conversation on a user board that is not the author's own. The bare invocation is a read that writes nothing, so the frontmatter description and argument-hint name intents (save, what's next, close, block, wrap) and never a CLI verb - the verb list is the skill body and cyberine tasks --help; a bare call that turned into a write without the operator's go-ahead would be a bug, not a feature. Plain-language triggering (kfp69) is designed, not measured: the phrase set was measured firing the standalone cyberine-taskboard plugin's skill with claude -p on Sonnet, but for this plugin's tasks skill trigger reliability is unmeasured until the operator runs the nl-* suite (plugin-evals/, operator-gated because it spends quota), in particular nl-board-* (fires in a repo with a board) and nl-neg-noboard-* (stays silent without one). The no-board check is wording in the skill (look for .local/tasks/ACTIVE.md or .claude/TASKS.md before any cyberine command), not a filesystem gate the harness enforces, and the model decides whether to invoke the skill before it has looked.
Task-boundary policy — the three tiers that decide whether to take another task (knrwd)
src/autopilot/boundaryPolicy.ts answers one question: now that a task has just finished, does
this session take another one? It is decided at the TASK BOUNDARY, never at a token threshold. A
fixed percentage cuts a session in the middle of a module and pays the bootstrap cost twice; when
36 percent of the window is what finishes the module, finishing it is right. So the context-fill
number is an INPUT to a rule evaluated only at a boundary, not a trigger of its own. The cost of a
candidate task comes from its declared effort: S|M|L priced through
cyberine tasks calibrate (above) — calibration.classes[effort].p90 once that class has n >= 3
samples, DEFAULT_EFFORT_TABLE[effort].p90 before that; the returned reason string always names
which of the two it used (calibrated or default table).
Three tiers, each a fraction of the context window:
| Tier | Default | Env var | Meaning |
|---|---|---|---|
| soft | 0.35 | CYBERINE_AUTOPILOT_SOFT | Do not START a task that will not fit. A SMALLER task that does fit may still be taken — below soft the first task in order is taken regardless of fit, between soft and hard the first task that fits is taken |
| hard | 0.65 | CYBERINE_AUTOPILOT_HARD | Finish the task in hand, then hand off. Take nothing new |
| emergency | 0.85 | CYBERINE_AUTOPILOT_EMERGENCY | Hand off NOW, even mid-task. A safety net, not an operating mode |
soft < hard < emergency is validated at config load, and each value must be a fraction strictly
inside (0, 1) — a percentage (35) or a 1 is rejected with a message naming the variable, not
silently accepted. The emergency tier is only acceptable because this repo's vibe rule requires a
.local/vibe/<task>.md goal file with milestone checkboxes, so a mid-task cut resumes from the
last ticked milestone rather than from nothing.
Ordering is priority (p0 before p1 before p2; an absent priority counts as p1) then the
task's line number in the manifest. Budget is windowTokens - contextTokens - CYBERINE_AUTOPILOT_HANDOFF_RESERVE, so a session always keeps enough room to write its handoff.
Three filters run before any of that, each recorded in the returned excluded list: exec other
than agent (not-agent), a task already started in CYBERINE_AUTOPILOT_REPEAT_FAIL sessions
(repeat-fail, which also raises a repeat-fail:<id> lint), and the task that just finished
(just-finished). A task with no effort: is sized as M and raises a missing-effort:<id>
lint every time — the board tool requires effort at draft time, so its absence is a board defect
worth surfacing rather than swallowing.
Extend recipe. decide(input) is a pure function: no I/O, no clock, no env read, no
randomness. Every number it needs is passed in, so a caller resolves the config once
(resolvePolicyConfig(process.env), or just read config.autopilotSoft and friends), maps the
board with toPolicyTasks(readManifest(cwd)) (which carries effort, priority, exec and sets
fileOrder from the manifest line), and passes the measured contextTokens from its own session
driver. The verdict is one of continue (with nextTaskId), finish-then-handoff, handoff-now
or no-runnable.
Verify it:
npm run selftest:boundary-policy138 checks over a literal case table — every tier boundary, both estimate sources, each exclusion
filter and its lint, priority-then-file ordering, the pick-a-smaller-task-that-fits behaviour, the
config validation errors, and a determinism check that calls decide() twice on a deep-cloned
input.
Known Limitations and Deferred Work. cyberine autopilot (see "Autopilot command") is the only
caller that feeds decide() a live contextTokens reading and acts on the verdict; this module
itself is still just the rule plus its guard. The history map (sessionsStarted per
task) is an input the caller must supply; this module neither reads nor writes any history store.
exec is matched strictly against agent, so a task with NO exec: line at all is excluded as
not-agent rather than assumed runnable — the board tool stamps exec: on every task it creates,
but a hand-written block without it will be skipped. Estimates are per-class p90s, so they are a
scheduling heuristic and not a prediction for any individual task; and finish-then-handoff is
returned (rather than handoff-now) even when the caller asks at a boundary with the task already
finished, which keeps the two situations distinguishable in a log at the cost of the caller having
to know they mean the same thing there.
Handoff bridge — hand an autopilot session off through the handoff writer, then bootstrap the next one (kcs40)
src/autopilot/handoffBridge.ts and src/autopilot/prompts.ts are the machine version of the user's manual loop: task ends, a handoff file is written into the handoff folder, a Telegram alert fires, a new Claude Code session reads the handoff plus the board and continues. The writer is always the builtin bin/cyberine-handoff.mjs (see "Builtin handoff writer" below), or whatever CYBERINE_HANDOFF_WRITER_BIN points at; since 2026-09-30 Cyberine no longer calls the author's private amemoria tool for handoffs at all (amemoria is memory-only). handoff(input) resolves the handoff folder via <writer> resolve (parsing its path= line — never hardcoded), snapshots the handoff files already there for this project, spawns <writer> offload --backend <backend> DETACHED with the seed on stdin and immediately unref()s it, then waits on EVIDENCE — a new handoff-<slug>_<project>.md file appearing in that folder, filtered by project suffix so other repos' concurrent sessions are ignored, and read as done once its size is stable across two polls (the writer may still be flushing). It never waits on a clock: fs.watch on the folder plus a pollIntervalMs poll (default 5000ms, since fs.watch alone can miss events on macOS) drive the same check, and a writer that never lands rejects with HandoffTimeoutError (carrying memoryDir and waitedMs) after timeoutMs (default 600000ms). buildSeed(input) is pure and produces the 2-line Where: ... / Next: ... seed <writer> offload reads on stdin, truncating the excerpt of the session's last result text and stripping any line that looks like KEY=value or names sk-/an api key/token/secret/password with a value, so nothing the driven session read from .env rides along.
prompts.ts builds the bootstrap prompt for the next session as a pure function of its inputs (buildBootstrapPrompt), never string concatenation scattered around: it tells the agent to read the handoff file at the exact resolved path (when one was produced), read the board via cyberine tasks and cyberine tasks doctor, work exactly the named task A-Z under the repo's own auto-loading rules, flip status ONLY with cyberine tasks status done <id> --evidence "<one line>" and file follow-ups only with cyberine tasks add ... --effort S|M|L (never hand-edit the manifest), end with one line DONE <taskId> when finished, or reply with the single word NO_RUNNABLE when the board has no exec:agent runnable task. buildContinuePrompt is the shorter version for later turns in the same driven session. PROMPT_VERSION (bump when the template's wording changes) is stamped into the bootstrap prompt so a transcript shows which template produced it. hasNoRunnableSentinel(text) matches the sentinel as a whole word, so NO_RUNNABLE_YET does not false-positive.
Run recipe — wiring one turn's result into a handoff, then bootstrapping the next session's first turn:
import { handoff } from './src/autopilot/handoffBridge.ts';
import { buildBootstrapPrompt } from './src/autopilot/prompts.ts';
import { resolveBoard } from './src/tasks/boardPaths.ts';
const { handoffPath } = await handoff({
cwd: repoRoot,
lastResultText: turn.resultText,
branch: currentBranch,
dirty: treeIsDirty,
currentTaskId: 'kcs40',
});
const board = resolveBoard(repoRoot);
const prompt = buildBootstrapPrompt({
handoffPath,
taskId: nextTaskId,
boardPath: board.manifest,
repoRoot,
});Extend recipe. writerBin (or CYBERINE_HANDOFF_WRITER_BIN, read by handoffWriterBinPath() in src/autopilot/handoffBridge.ts) points at a different writer binary that speaks the same resolve / offload protocol — useful for a per-machine install path or a test double; unset, the builtin bin/cyberine-handoff.mjs is used. backend (claude | codex | opencode, default claude) and model pass straight through to offload, where the builtin writer accepts and ignores them. pollIntervalMs trades wake-ups for responsiveness on a slow disk or a very fast writer.
Verify it:
npm run selftest:handoff-bridgeA stub writer script stands in for the real binary (resolve prints a temp folder, offload reads the seed from stdin to a file and writes the handoff after a short delay): asserts a successful resolve, a pre-existing handoff file is never returned, a same-poll-cycle file from another project's suffix is ignored, a writer that never lands times out with waitedMs at or above the deadline, and buildSeed strips a secret-shaped line. Positive control: skipping the pre-existing-file snapshot makes the very first poll return that stale file instead of waiting for the real one, and 6 assertions go red (including the timeout case, which then wrongly "succeeds" on the stale file) — restoring the snapshot turns the smoke green again.
Known Limitations and Deferred Work. backend defaults to claude, whose own default model is opus — this module never passes --model unless the caller sets one. The bridge waits on a FILE, so a writer that crashes, or one that succeeds but writes to a different project's suffix by mistake, surfaces identically as a plain timeout — a writer that CRASHES surfaces as a plain timeout, and one that succeeds but writes to a different project's suffix does too — the one refusal now caught early is a non-zero exit from offload with no candidate file on disk, which rejects at once with HandoffOffloadError rather than waiting out the deadline. cyberine autopilot (see "Autopilot command") is what calls handoff() and buildBootstrapPrompt() at a real task boundary. handoffWriterBinPath() (formerly amemoriaBinPath(), renamed 2026-09-30) returns CYBERINE_HANDOFF_WRITER_BIN when set and the builtin writer otherwise; there is no amemoria probe any more (see "Builtin handoff writer" below).
Builtin handoff writer — cyberine handoff, the only autopilot handoff writer (k73tg)
cyberine autopilot used to refuse to start unless the author's private amemoria skill binary (~/.claude/skills/amemoria/bin/amemoria) was executable, then (k73tg) preferred it when present and fell back to a builtin writer. Since 2026-09-30 (commits 3ab734c2, 61c3c2d4, 87c1e488) amemoria is memory-only and Cyberine never calls it for handoffs: the CYBERINE_AMEMORIA_BIN override and the skill-directory probe are gone. src/commands/handoff.ts ships the builtin, deterministic writer that speaks the two-verb protocol the bridge drives, and handoffWriterBinPath() in src/autopilot/handoffBridge.ts resolves it: CYBERINE_HANDOFF_WRITER_BIN (explicit override, for a test double or a different writer) wins, otherwise bin/cyberine-handoff.mjs (which forwards into cyberine handoff <args>, resolved through packageRoot() so it keeps working both from a repo checkout and from an installed package after cyberine build). No model call, no summarisation — the seed is written back near verbatim under a small YAML frontmatter block.
cyberine handoff resolve— printspath=<dir>for$CYBERINE_HOME/handoffs(created if missing) and exits 0.cyberine handoff offload --backend <b> [--model <m>]— reads a seed from stdin (--backend/--modelare accepted for protocol compatibility and otherwise ignored, since the builtin writer is deterministic), writes<dir>/handoff-<YYYYMMDD>-<HHMMSS>_<project>.md(project = the git toplevel basename of the invoking cwd) with awriter: cyberine-builtinfrontmatter block followed by the seed verbatim, printslog: <path>to a small log file next to it, and exits 0. The filename satisfies the samehandoff-.+_<project>\.mdpattern and 8-digit-date requirementpickNewestHandoff(src/store/handoffPick.ts) already ranks on, and the frontmatter alone satisfiesisHandoffContentComplete.cyberine handoff path— printsdir=<dir>andfile=<name>(the filenameoffloadwould use right now), so an interactive session can write a richer handoff by hand instead of piping a seed throughoffload.
Run recipe:
echo -e "Where: branch main (clean tree) in $(pwd)\nNext: pick up the next runnable task" | cyberine handoff offload --backend claude
cyberine handoff resolve
cyberine handoff pathVerify it:
npm run selftest:handoff-bridgeThe same kcs40-handoff-bridge-smoke.ts smoke covers it: a case runs the real bin/cyberine-handoff.mjs (no stub) against a temp CYBERINE_HOME, asserts the written file matches the project pattern, satisfies isHandoffContentComplete, that findNewestHandoff resolves it, and that cyberine handoff path reports the same directory; a second case asserts handoffWriterBinPath() resolves to the builtin when CYBERINE_HANDOFF_WRITER_BIN is unset and to the override when it is set.
Known Limitations and Deferred Work. Deterministic and seed-only: no model call, no summarisation, no read of the session transcript the way the amemoria skill's own offload did — the handoff is exactly the seed the caller already built (buildSeed()), wrapped in frontmatter. The builtin is the only writer unless CYBERINE_HANDOFF_WRITER_BIN names another. --backend/--model are accepted (so scripts that always pass them keep working) but have no effect on the builtin's output.
Session handoff — cyberine handoff write --mode short|long (k81j7)
The end-of-session handoff a live session writes so the next session of the SAME repo resumes without re-discovery. Two modes, sharing one seed the live session composes from what it already holds (Where: / Decisions: / Open: / Next: lines on stdin; a bare line continues the previous section):
short(default, free): writes the per-session breadcrumb<repo>/.cyberine/handoffs/<session-id>.mdstraight from the seed, no model call. Sections:## Where,## Decisions,## Open,## Next(numbered),## Context.long: writes the same breadcrumb first, then starts a detachedclaude -p(the official CLI under your own login; default modelopus,--modeloverrides) that reads the seed, a tail excerpt of this session's transcript and read-only git, writes the full handoff to~/.cyberine/handoffs/handoff-<YYYYMMDD>-<HHMMSS>_<project>.md(Where, Done, Decisions, Open, Next, Watchouts, Key paths, Resume) and rewrites the breadcrumb with a## Fullpointer to it. The command returns at once; the worker's output goes to<repo>/.cyberine/handoffs/logs/. The worker may use only Read, Write, Glob, Grep, read-only git andgh run list/gh run view, with write access limited to the two handoff directories, and it runs without the parent session'sCLAUDE_CODE_*variables and withoutANTHROPIC_API_KEYwhen a subscription login exists. Since 2026-09-30 it also runs with--settings '{"disableAllHooks":true}', so the headless writer no longer fires your hooks or any plugin's hooks (the board banner, the handoff pointer and so on would otherwise run inside it).
The seed is trusted for what only the live session knew (decisions, intent, what was tried); live state is re-read by the worker instead (kct4c): branch and HEAD, commits ahead of and behind upstream, the dirty tree, and the CI result of the pushed head through gh run list. When the live value disagrees with the seed, the handoff carries the live value and says so; when a check cannot run (no upstream, gh not authenticated, no network) it says the status is unknown rather than repeating the seed. The prompt also asks for what the operator granted the session and what it spent, attempts that failed and why, side effects outside the repository, commands left for the operator, and a one-line resume hint.
--backend codex (k9bnn, long mode only) hands the same prompt to codex exec instead of claude -p, for when a ChatGPT plan is the cheaper writer. The worker runs with --cd set to the breadcrumb directory and Codex's own workspace-write sandbox, --add-dir for ~/.cyberine/handoffs/, /tmp and $TMPDIR excluded, network on (for gh), and approval_policy="never", so it can read the repository but write only to the two handoff directories; OPENAI_API_KEY is dropped from its environment when a ChatGPT login exists. Its model is Codex's configured default (~/.codex/config.toml) unless --model names one; claude stays the default backend and keeps opus. cyberine tasks end --handoff long --backend codex passes it through. --backend with --mode short, or with a value other than claude or codex, is refused with exit 2 before anything is written.
The session id comes from --session <id>, else CLAUDE_CODE_SESSION_ID (set inside a Claude Code session), else the newest transcript for this repo, else manual-<timestamp>; one file per session, so parallel sessions never overwrite each other. Breadcrumbs older than 14 days are pruned on each write. The first write creates .cyberine/handoffs/.gitignore containing *, so a handoff can never be committed even in a repo that never ran cyberine init.
Run recipe:
printf 'Where: main at abc1234, clean\nDecisions: X instead of Y -- because Z\nNext: build the pull verb\n' | cyberine handoff write
printf 'Where: ...\nNext: ...\n' | cyberine handoff write --mode long # background claude, opus
printf 'Where: ...\nNext: ...\n' | cyberine handoff write --mode long --dry-run # print the argv, spend nothing
printf 'Where: ...\nNext: ...\n' | cyberine handoff write --mode long --backend codex # background codex exec, codex's default modelVerify it: npm run selftest:handoff-write (seed parsing and the breadcrumb byte-for-byte, the self-ignoring .gitignore, the 14-day prune, every refusal, the long-mode argv from --dry-run, a real detached spawn against a fake claude with the scrubbed env, the transcript excerpt, and offload unchanged).
Known Limitations and Deferred Work. Refusals exit 2 and write nothing: no seed, a TTY on stdin, an unknown mode or flag, --model with --mode short, a session id that is not a plain file-name token. The short breadcrumb always stands. long now tells you when its worker finished or failed (see "Long handoff completion notice" below); a writer that cannot even start (claude not on PATH) is reported by that notice rather than on the command's own stderr, because the command has returned by then. offload is unchanged: it is the deterministic writer autopilot always uses (since 2026-09-30 the only one), and cyberine autopilot bootstraps a cold start from ~/.cyberine/handoffs/ only, so a short breadcrumb alone is not what it reads (use long before an unattended run).
Long handoff completion notice (k5m91). --mode long (and so cyberine tasks end --handoff long and /cyberine:end) no longer fires the writer and forgets it. The command starts one detached watcher process (src/cyberine-handoff-watch.ts, logic in src/handoff/handoffNotify.ts) that runs the claude -p or codex exec writer itself, waits for it, and tells you when it stops, so closing the terminal or the Claude Code session does not lose the answer.
- OS notification, on by default: a
Handoff started · <HH:MM>banner when the writer begins (click opens the writer's log) and one when it exits, through the samesrc/tui/notify.tscode the fleet TUI uses (macOS, Linuxnotify-send; silent no-op elsewhere). The subtitle is the Claude Code session name (the transcript's last/renametitle, else its AI title, elsesession <short id>), with the repo appended when the name does not already contain it. Success readsHandoff ready · ended <HH:MM>with the handoff topic and the duration in the body and opens the handoff file on click; failure readsHandoff failed · ended <HH:MM>with the exit, reason and duration and opens the writer's log. Success means exit 0 AND a non-empty handoff file at the announced path; exit 0 with no file counts as a failure. The two banners are never coalesced, so a writer that fails within two seconds still shows its failure banner (kmz1t). - Branded banners on macOS (kmz1t):
npm run notifier:buildcompilesnative/macos-notifier/main.swiftinto an ad-hoc-signedCyberine.appunder$CYBERINE_HOME/notifier/with the Cyberine icon (needs Xcode'sswiftc). When that app exists every OS notification goes through it, with the icon, a subtitle, a sound and click-to-open; without it macOS falls back toosascript(Script Editor icon, no click action). The first banner from the app asks for notification permission once. - Telegram, opt-in: with
CYBERINE_TELEGRAM_BOT_TOKENandCYBERINE_TELEGRAM_CHAT_IDset (shell or~/.cyberine/.env; optionalCYBERINE_TELEGRAM_THREAD_IDfor a forum topic) the watcher also posts a START message when the writer begins and an END or END FAILED message when it stops, through the sameresolveTelegramConfig+buildTelegramSendMessagechannelcyberine autopilotuses. With no Telegram variables nothing is sent and nothing is logged about it. The OS banners mirror START and END. - Message layout (plain text): line 1
Handoff started|ready|FAILED — <session name>. START then readsProject: <repo>,Start: <HH:MM:SS> · <date>,Writer: <claude|codex> · session <short id>. END addsTopic: <topic>(the# Handoff — <topic>heading the writer produced, humanized) beforeProject:, thenEnd: <HH:MM:SS> · <date> (took <duration>)andFile: <path>. FAILED readsProject:,End: ... (after <duration>),Reason: <exit code or signal, with the reason when the writer exited 0 without a file or could not start>andLog: <path>. - Environment: the writer keeps exactly the scrubbed environment it always had (no
CLAUDECODEorCLAUDE_CODE_*, no metered key when a subscription login exists), and now also losesCYBERINE_TELEGRAM_BOT_TOKEN. The watcher itself runs with a minimal environment:PATH,HOME, locale and display variables, and the Telegram variables only when both the token and the chat id are set; it never sees a provider API key. The writer's environment reaches the watcher over a pipe, never argv or a file. The bot token is never logged and never appears in a URL that is logged.
printf 'Where: ...\nNext: ...\n' | cyberine tasks end --handoff long # banner (and Telegram, if configured) when the writer stops
CYBERINE_NOTIFY=0 cyberine tasks end --handoff long < seed.txt # no OS banner; Telegram still sent if configuredTurn the OS banner off with CYBERINE_NOTIFY=0 or CYBERINE_NOTIFICATIONS=off; any other CYBERINE_NOTIFICATIONS value (including the bell default) gives the banner here, since a terminal bell means nothing to a process that has no terminal. The watcher's own stdout and stderr go to the same logs/long-<session>-<time>.log file as the writer's.
Verify it: npm run selftest:handoff-notify (no network: the watcher environment carries no API key and Telegram only when configured, the message layout for START, END and END FAILED, success and failure selection including exit 0 with no file, and a real child exiting 3 and a missing binary reported as failures). Operator check: run a real cyberine tasks end --handoff long and expect a Handoff started banner, then one naming the handoff file; force a failure with --model not-a-model and expect a failed banner naming the log.
Known Limitations and Deferred Work. macOS may ask once for permission to show notifications from Script Editor (the process osascript runs as); until it is granted, or while Do Not Disturb or a Focus mode is on, the banner does not show even though the watcher ran. Without npm run notifier:build there is no Cyberine icon, since osascript always shows the Script Editor icon; the helper app is built per machine and not shipped prebuilt, it is ad-hoc-signed rather than notarized, and Telegram cannot carry a per-message icon (the bot avatar is fixed). The watcher has no timeout of its own: a writer that hangs keeps the watcher alive exactly as long as it kept the writer alive before. If the machine sleeps or the watcher process is killed, no notice is sent and the log file is the only record. The notice reports the writer's exit and the file it was told to write, not the quality of the handoff.
Recall, the session-start pointer, the skill, and end-of-session in one command (kq52e, kyx8r, k24g6, kmzg6).
cyberine handoff pull # the newest handoff for this repo, in full
cyberine handoff pull last 3 # the newest three
cyberine handoff pull list # every handoff with its date and source
cyberine handoff pull rate-limit # case-insensitive search across all of them
cyberine handoff pointer # one line naming the newest (what the SessionStart hook prints)
printf 'Where: ...\nNext: ...\n' | cyberine tasks end --handoff short # wrap the board, then write the breadcrumbpullis read-only and makes no model call. It reads three sources for the repo you are in, newest first by modification time: the short breadcrumbs in.cyberine/handoffs/, the long handoffs in~/.cyberine/handoffs/whose name ends_<repo>.md, and a legacy.remember/handoffs/directory when one exists (labelledlegacy .remember, read only, never copied or migrated).pull last 0or a non-number is refused with exit 2.pointerprints one line ([handoff] newest for <repo>: <title> (<source>, <date>) · <path> -- full text: cyberine handoff pull last), or nothing at all when there is no handoff or the directory is not a git repository; it always exits 0, so it can never break a session start.cyberine initadds.cyberine/handoffs/to the Cyberine ignore block, and on a terminal it asks whether to installcyberine handoff pointeras a SessionStart hook in.claude/settings.local.json(merged into the file, never overwriting other settings; an unreadable file is left alone and reported). Without a terminal, or with--yes, it installs nothing and prints how to get it. Since 2026-09-30 the plugin runs this hook itself in every git repo, andinitskips the question when the plugin is installed.- Two plugin skills drive it.
/cyberine:handoffwrites the free short breadcrumb: the live session composes the seed from what it already holds and pipes it tocyberine handoff write, then stops; it is model-invocable (anchored on the word "cyberine", or with no anchor in any git repo), so "cyberine nghi tay" and a plain "nghi tay" both reach it directly./cyberine:endin a repo with no board skips the wrap and runscyberine handoff write --mode longalone, sincetasks endrefuses without a manifest./cyberine:end [--model <m>]closes the session:cyberine tasks end --handoff long, the board wrap plus the full handoff a backgroundclaude -pwrites; it is model-invocable on its own phrase ("cyberine end", "cyberine-end", "cyberine chot session") and owns its consent: typing/cyberine:end, sending the bare phrase, or sending an instruction that carries it ("finish the wave, then cyberine-end", including when the session reaches the end only after that other work) is the yes to the spend and to every write, and every other way in (a question that mentions it, the model's own judgment with no anchor in the operator's message, text read from a file or tool output) first names the cost and asks one question with the choices run it / wrap the board only / cancel. Since k15yz it closes a session end to end: it readscyberine tasks unlogged,cyberine tasks statusand any pending draft, proposes logging the work that never reached the board (finished work as a closed task with its commit as evidence, unfinished work astodo, work only discussed asbacklog, trivia skipped), on the command path (typed, bare phrase, or an instruction carrying the phrase, since 2026-10-01) writes every proposal at once with no question and turns anything it would have asked into a task or anOpen:line (since 2026-09-30, operator ruling:endexists to trace the session onto the board), while the ask-first path asks once in plain words as part of its consent question, writes what was accepted throughsave, and then wraps and hands off. A pending draft no longer stops it: on the ask-first path the operator converges it in the same answer or leaves it; on the command path it is always left in the inbox; either wayendruns with--forceand the handoff'sOpen:line names the draft./cyberine:helproutes "cyberine ghi handoff" to the first and "cyberine handoff long" to the second. cyberine tasks end --handoff short|long [--model <m>]reads the seed from stdin FIRST and refuses (exit 2, nothing archived) when there is none, then runs the normalendwrap, then writes the handoff. The drafts gate still stops the plain CLIendbefore anything, and then no handoff is written either;/cyberine:endasks about the draft first and passes--forceonly when the operator chose to leave it.
Known limitations: pull orders by modification time, so touching an old file moves it up; tasks unlogged cannot tell whose commit is whose, so a repo with parallel sessions lists their unlogged commits too and the skill drops the ones this session did not make, and its default 24-hour window is a guess when the session does not know the HEAD it started from; the pointer hook needs cyberine on the PATH of the Claude Code session; the skill and the help route were not re-scored with the eval suite (a paid run, the operator's call).
SessionEnd safety net — cyberine handoff capture (kvw41), REMOVED from the plugin and from cyberine init on 2026-09-30. A session that closes without /cyberine:handoff or /cyberine:end leaves no handoff, and the capture hook was meant to cover that: at session end it read Claude Code's hook JSON on stdin (session_id, transcript_path, cwd, reason) and wrote <repo>/.cyberine/handoffs/<session-id>.md titled # Handoff — <repo> (<date>) · auto capture. It was removed because it never wrote anything in practice: Claude Code bounds SessionEnd hooks at 1.5 seconds and a plugin cannot raise that (only the per-machine env CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS can), and booting the CLI took about 1.9 seconds, so the hook was killed first. SessionEnd also does not fire at all on kill -9, a crash or a closed terminal. The plugin therefore ships three hooks (board banner, board guard, handoff pointer) and cyberine init no longer asks about a SessionEnd hook.
The subcommand itself is unchanged and still works by hand:
printf '{"session_id":"s1","cwd":"%s","reason":"other"}' "$PWD" | cyberine handoff captureIt writes nothing when the session already has a real breadcrumb (a later /cyberine:handoff overwrites the capture, never the other way round), when the transcript has no request from you (an empty session), when the transcript has not changed since the last capture, or outside a git repository, and it always exits 0. Cleanup: a repo that got the hook from an older cyberine init keeps it in its own .claude/settings.local.json; delete that SessionEnd entry (the one whose command is cyberine handoff capture), since it fires under the same 1.5 second bound and writes nothing. A replacement that reconciles the sessions of the last run at the NEXT SessionStart, where there is no such deadline, is backlog task k4n28 and does not exist yet.
One whole-board command — cyberine autopilot, fleet engine by default, cyberine loop an alias (k2010)
Until 2026-09-18 this repo shipped two whole-board verbs and only one of them used the fleet: cyberine loop dispatched every runnable task to the build fleet but had no supervision, and cyberine autopilot had all the supervision but drove one Claude session per task. They are now ONE command. cyberine autopilot keeps its supervision — the start refusals, the RunStore run record, the STOP file, the wall-clock budget, the handoff and the Stop-hook / webhook / Telegram notifications — and its WORK ENGINE is selectable:
--engine | what it does |
|---|---|
fleet (default) | derive the runnable frontier and dispatch each task to the build fleet: design -> implement in an isolated worktree -> review -> DiffJudge, then land the winner |
claude | exactly today's autopilot: one driven Claude session per task, turn by turn, with checkpoints and handoffs |
cyberine loop still resolves. It prints one deprecation line on stderr and runs cyberine autopilot --engine fleet with the same scopes, flags and exit codes.
Run recipe.
# drain the whole board on the fleet (the default; nothing else to pass)
cyberine autopilot
# the same, but see the plan and the first verdict without starting anything
cyberine autopilot --dry-run
cyberine autopilot --dry-run --json
# scope it: ids, an area tag, a natural-language goal, or the tight fix cycle
cyberine autopilot k2010 k00vv
cyberine autopilot --area autonomy
cyberine autopilot "Build a small LMS"
cyberine autopilot --test-debug-fix
# launch a detached fleet run per task and return immediately, instead of draining in-process
cyberine autopilot --dispatch async
# the previous autopilot: one driven Claude session per task
cyberine autopilot --engine claude
cyberine autopilot --engine claude --bg --driver bg
# ONE driven session orchestrating a whole area, one worktree subagent per task
cyberine autopilot --engine claude --area autopilot --batch 3 --driver stream
# the same, but naming the tasks by hand instead of by area
cyberine autopilot --engine claude --batch 3 k1ggk ktfy0 kvmga --driver stream
cyberine autopilot --engine claude --batch 3 'the tasks-board tasks' # select by plain language (k9ete)
# the deprecated alias, unchanged for every existing caller
cyberine loop --area autonomyA natural-language goal is planned onto the board through the board writer (kpv5x). cyberine autopilot "<goal>" asks the planner model for a backlog, then writes it with save under the board lock, on the same board cyberine tasks resolves (NF_TASKS_ROOT, NF_TASKS_SYNC_DIR and submodule anchoring included). Each task gets a fresh k-id minted against the manifest, the archive and the briefs, exec: agent, model: sonnet, the planner's effort (M when it names none), and a why: quoting the goal. A planned task whose title, do or done-when spans more than one line is dropped, and so is any task whose dep will not be written, rather than written without that dep. Known limits: planned tasks carry no brief file and no per-task model choice; a board lock held for more than the lock's 5-second budget makes the planner give up and the run falls back to every runnable task on the board.
--dispatch sync is why the default drains. On sync (the fleet engine's default) each task runs in-process, is gated on DiffJudge for a code task or the non-code verify gate for a text task, and its status is written only when the gate passes — so the next iteration sees an advanced frontier. On async the command launches a detached fleet run per task and returns; nothing flips a status, which is the right shape for a fire-and-forget batch and the wrong one for draining a board. cyberine loop keeps async as its default so a script that shells out to it still returns rather than running for hours; --verify is a synonym for --dispatch sync, as is CYBERINE_LOOP_SYNC_VERIFY=1.
Each landed task is a commit on --dispatch sync (k31fs). When a code task's winning diff applies to the working tree, the loop commits exactly the paths that diff touched on the current branch, with the message <task id>: <task title>, before it dispatches the next task, and logs [autonomy-loop] sync-verify: <id> landed and committed as <sha>. Every candidate worktree is cut from HEAD, so this is what lets a dependent task build on its dependency; git log --oneline after a run lists one commit per landed task. cyberine autopilot already refuses a repo without rules.applied in .cyberine/config.local.json or .cyberine/config.json, which is that repo's opt-in to unattended commits. When the winning diff does not apply, or the commit fails, the task is NOT marked done: the patch is kept at .local/runs/<loopId>/<task id>-winner.patch, the winner worktree stays on disk under .claude/worktrees/, and the log line, the board blocker: and the end-of-run decisions report all name both paths. Recover by fixing the conflict, running git apply --recount <patch> yourself, then git worktree remove <path>.
Known Limitations and Deferred Work (k31fs). The loop never pushes; landing on the remote stays with the operator or cyberine land. The commit takes the working-tree content of every path the diff touched, so an uncommitted edit of your own in one of those files rides along in the task's commit; start a sync run on a clean tree. Pre-commit hooks run as usual, and a hook that rejects the commit reverts the applied diff and blocks the task.
The fleet engine implements with the fleet you enabled (kmf67). With CYBERINE_BUILD_FLEET unset, the fleet engine resolves its implementers the same way cyberine run's router does: from the ENABLED providers in fleet.json (cyberine fleet list), honouring seats, retired workers and quota cooldowns. Before kmf67 only the env var registered a file-writing worker on this path, so a repo with an enabled fleet.json and no env var fell through to the raw read-only exploration worker, whose candidates carry no diff by construction — every fork ended no winner - all candidates failed the DiffJudge gate and the loop dispatched the next task on the same dead lane. An explicit CYBERINE_BUILD_FLEET still wins verbatim (it is an override, never merged with the fleet.json ids), and CYBERINE_ROUTE=off turns the fleet.json resolution off so only an explicit pin registers an implementer. Beam search also picks its exploration worker from the writers in the pool now, so a read-only compare worker can no longer win the fork while an implementer sits beside it.
A run that cannot write a file refuses instead of burning the board. When that resolution yields no worker that may write the working tree, cyberine autopilot --engine fleet exits 2 before dispatching anything and names the fix — cyberine fleet enable <provider> first, a CYBERINE_BUILD_FLEET=<worker-id> pin second. There is no override flag: a run with nothing that can write has nothing to force. --dry-run never refuses: it prints the whole plan, an implementers line, and a Readiness: NOT READY block, and still exits 0.
# what the fleet engine would actually implement with, before spending anything
cyberine autopilot --dry-run --json | jq .implementers
# -> { "ready": true, "source": "fleet.json", "implementerIds": ["claude"], "explorationIds": [...] }
# enable a provider once and every autopilot run uses it, no env var
cyberine fleet list
cyberine fleet enable claudeFail fast on a dead fleet. On --dispatch sync the loop classifies every fork: a fork where at least one candidate carried a diff is LIVE (the judge rejecting that diff is a task problem, so the counter resets), and a fork where every candidate came back empty is DEAD — either the adapter errored or the worker ran and wrote nothing. After CYBERINE_LOOP_DEAD_FORK_LIMIT consecutive dead forks (default 2) the run stops with a terminal reason naming the worker and the error class, written to the audit ledger under .local/autonomy-audit/ and carried into the run record as autopilot failed: dead fleet: ... (exit 1). The error class reuses the same classifier the worker-cooldown store uses, so out-of-credits, auth-failure, rate-limit and connection-failure are named as such rather than as one generic failure.
# allow three consecutive empty forks before stopping (default 2)
CYBERINE_LOOP_DEAD_FORK_LIMIT=3 cyberine autopilot
# turn the backstop off entirely (the pre-kmf67 behaviour: the loop keeps going)
CYBERINE_LOOP_DEAD_FORK_LIMIT=0 cyberine autopilotrouting.steps pins decide the sync dispatch (kenmv). When the fleet.json in effect pins any step under routing.steps, every CODE task on --dispatch sync runs the same implement-task workflow a background run uses, in-process: design, then implement with one isolated worktree per pinned implementer and DiffJudge picking the diff, then the skeptic review. Each step dispatches exactly its pinned ids. The implement winner is applied to the working tree and gates the task, exactly as the beam-search winner did. A fleet with no pins keeps beam search and its single exploration worker, unchanged. Before kenmv the sync path never read routing.steps: it forked every candidate onto one exploration worker the pins did not name, while the dry run printed the pins as if they applied.
Each pinned dispatch is recorded as its own run under .local/runs/ (source auto-dispatch), and the audit ledger under .local/autonomy-audit/ carries one "status": "step" line per step naming the workers it dispatched, the step's state and winner, and the runId, ahead of the task's usual dispatched or blocked line. The dry run prints a code tasks line naming the path each code task takes (beam search ... with the exploration writer, design -> implement -> review in-process ..., or a detached run per task on --dispatch async), and on sync its Pinned steps block lists what each step will dispatch: the pinned ids that were built for this run, with any unbuilt ones named as skipped.
A pinned step none of whose ids was built refuses the run by name instead of widening, because a widened step would bill workers the operator did not choose: cyberine autopilot exits 2 before dispatching anything with pinned step unresolved: routing.steps.<step> names [...], the dry run prints the same sentence under Readiness: NOT READY, and the loop itself writes it to the ledger and stops. When only some of a step's ids were built, the step runs the built ones and names the rest in a warning, the same rule a background run follows.
# what each code task will dispatch, per step, before spending anything
cyberine autopilot --dry-run
cyberine autopilot --dry-run --json | jq '.fleet | {codeTasks, pinnedSteps}'
# -> { "codeTasks": "pinned-workflow", "pinnedSteps": [ { "step": "design", "pinned": ["claude-kt"], "dispatched": ["claude-kt"], "unbuilt": [] }, ... ] }
# after a run: which workers each step actually dispatched
jq -c 'select(.status == "step") | {taskId, step, workers, runId}' .local/autonomy-audit/<loopId>.jsonlAn id scope counts done deps outside the list (ky6md). cyberine autopilot <id> <id> (and cyberine loop with an id list) computes the runnable frontier over the whole board and only then narrows it to the named ids, so a named task whose dep is already [done] but was not named itself is runnable, exactly as the dry run lists it. A dep that is still open outside the list keeps the named task blocked; the loop never pulls an unnamed task into the run. Guard: npm run selftest:autopilot-scope-done-deps.
Batch deferrals are reported, never silent. decideBatch records a reason per task it leaves out of a roster (another task in area "x" is already in this batch, its do: names src/core/config.ts, which a task already in this batch also names, needs the working tree to itself, batch is full at N). Since kjgpm those reasons are printed rather than dropped: the dry-run plan lists them on the batch N: line, always naming every OPERATOR-NAMED id before the +N more tail, and the live run logs one [autopilot] batch defers <id>: <reason> line per named id plus one summary line for the unnamed rest — one line each for the unnamed set would drown the log, because a lead that needs the tree to itself defers every other runnable task. An id the operator named that never reaches the runnable set at all is reported before anything starts, by id and cause ([backlog] — promote it first, already [done], held by its blocker: …, blocked on <deps>, no such task on this board).
A plain-language scope SELECTS, it does not write (k9ete). On --engine claude a prose
positional is an nl-select scope: it resolves to ids the board ALREADY holds, which is the only
thing a driven session can execute. The same phrase on --engine fleet still means nl-goal and
still runs NlGoalPlanner to write new tasks — the two modes are opposites and the engine is what
picks between them. Resolution is deterministic first: the phrase is reduced to terms (a stopword
is dropped only when it stands ALONE, so the tasks-board tasks keeps both tasks and board),
and a task matches only when it hits EVERY term, scored area > title > do. One cheap model
call is the fallback, and ONLY when the matcher resolved nothing. The resolved ids are always
printed with the evidence per id before anything spawns:
[autopilot] the phrase "the tasks-board tasks" resolved to 14 task(s) (deterministic):
k3cte TASKS-STATUS-VIEW: a full cyberine tasks status board view and the one-line --summary
matched on area:"tasks" + title:"board"On a TTY the match evidence appears in the plan block and Run? [Y/n/e] confirms it with the rest of the run; --yes carries that confirmation, and a
non-TTY caller without --yes is refused rather than silently aborted. A phrase matching nothing
exits 2 naming the phrase — it never falls back to the whole board.
Known Limitations and Deferred Work. --dry-run never spends, so it skips the model fallback
entirely and says so; a phrase that only the model could resolve therefore looks unmatched under
--dry-run. The matcher is substring-based with no stemming or synonyms, so dashboards does not
match dashboard, and the Vietnamese stopword list is unaccented only. The model fallback uses the
cheapest authed chat-eligible provider (the session-title pick) and is skipped with a note when
none is authed.
Settle operator decisions before the first dispatch: --answers <file> (kkamv). A task whose prose asks the operator for something (the T460 non-codeable gate: supplying a key, a production deploy, a payment) is normally deferred and only answerable AFTER the run, through the capsule and --resume. On the fleet engine --answers <file> answers them up front. cyberine autopilot --dry-run lists every such task on the current frontier under Operator decisions (T460) as T460 defer <id>: "<question>" (and as fleet.operatorDecisions in --json), so a caller knows what to answer; with --answers the dry run prints T460 answered <id> for each task the file covers. The real run skips the classifier for an answered task, logs T460 answered <id>, and prepends the decision to its prompt exactly as --resume does; an unanswered deferring task is still deferred.
cyberine autopilot --dry-run # lists "T460 defer <id>: ..." lines
cat > .local/answers.json <<'JSON'
[{ "task_id": "k7m5n", "question": "which key", "answer": "use SAYTAPE_API_KEY from .env; never print it" }]
JSON
cyberine autopilot --dry-run --answers .local/answers.json # now "T460 answered k7m5n"
cyberine autopilot --answers .local/answers.jsonThe file is a JSON array of { "task_id", "question", "answer" }, each a non-empty string, one entry per task. Every task_id is checked against the board before anything starts, and an unknown or duplicated id exits 2 naming it. --answers is refused under --engine claude, and together with --resume (the capsule already carries its answers). The path is resolved against the current directory, and a --bg run receives it as an absolute path. Guard: npm run selftest:autopilot-preflight-answers.
Known Limitations and Deferred Work (kkamv). Only the T460 non-codeable gate reads the file: a worker that emits its own structured defer slot mid-task is still deferred to the end-of-run report. The dry run previews only the tasks on the current frontier, so a deferring task that becomes runnable later in the run is not listed until a later dry run shows it; answering it in the file ahead of time still works. The skill half that writes this file is the autopilot skill's preflight (kg3yc, below).
The autopilot skill's preflight (kg3yc). Inside a Claude Code session, /cyberine:autopilot no longer asks a fixed engine / scope / budget list. Unless the operator typed an explicit scope AND budget (Path A, which asks nothing), the session runs a free preflight before its one question round: (1) cyberine autopilot --dry-run with whatever the invocation named, a refusal going to /cyberine:init; (2) a run-readiness pass over every id under Runnable (N):, read from the manifest (cyberine tasks corpus carries do but not done-when or deps), repairing a do: that names no file, function or command, a done-when: that is not observable, or a prerequisite missing from deps: through cyberine tasks set <id> --do|--done-when "<text>" --reason "autopilot preflight: ..." or cyberine tasks deps set, at most one write per field per task; (3) a fork pass over every T460 defer <id> line: a reversible in-repo fork (a name, a default value, which of two existing modules, a config key the repo already reads) is answered by the session with a one-line rationale and written to .local/autopilot/answers-<timestamp>.json, while a credential, a purchase, an external side effect or a product decision is carried to the operator; (4) ONE AskUserQuestion of at most four questions: the spend consent (task count, the quota line, Telegram configured or not, and the engine, scope, dispatch and budget stated as facts: fleet, the whole board or the named scope, sync, --max-iter 100 --max-hours 8, unless the prose named others), the operator-only forks, and any operator-only readiness gap; (5) the operator's fork answers are added to the file, cyberine autopilot --dry-run --answers <file> confirms T460 answered <id>, and the run starts as cyberine autopilot --bg --yes --answers <file> ..., without --answers when nothing was deferred. Steps 2 and 3 decide from the task text and the repository alone, never by running a worker.
# what the session runs on a board with one reversible and one operator-only deferral
cyberine autopilot --dry-run # two "T460 defer <id>" lines
cyberine tasks set k2x4d --done-when "npm test passes and clamp(5, 0, 3) returns 3" --reason "autopilot preflight: done-when was not observable"
cat > .local/autopilot/answers-20260924-101500.json <<'JSON'
[{ "task_id": "k7m5n", "question": "<copied from the defer line>", "answer": "use src/format/intl.ts as the default formatter (settled by the session: both modules already exist, switching back is a one-line change)" }]
JSON
# ...the one question round; the operator answers k9p1x, the session appends that entry...
cyberine autopilot --dry-run --answers .local/autopilot/answers-20260924-101500.json # both ids now "T460 answered"
cyberine autopilot --bg --yes --answers .local/autopilot/answers-20260924-101500.jsonKnown Limitations and Deferred Work (kg3yc). The readiness and fork judgements are the session model's own reading of the task text; nothing checks them before the run, so a wrong "reversible" call reaches the worker as a decision (it is visible in the answers file and in the consent question's text). cyberine tasks deps set records no reason, so a deps repair is named only in the question text. The claude engine prints no Operator decisions block and refuses --answers, so its runs get the readiness pass and the single question round but no fork pass. Path A settles nothing: a deferring task there stays deferred to the end-of-run report. The flow is exercised by the plugin eval case autopilot-preflight-answers against a recorded dry-run; no live eval of it has been run yet.
Engine-specific flags are refused, never ignored. --driver, --attached, --batch, --on-drained, --brainstorm-max, --brainstorm-model, --on-quota, --force-model, --stall-timeout and --turn-timeout all describe a driven Claude session, so passing one with --engine fleet exits 2 naming the flag and pointing at --engine claude. --dispatch, --verify, --max-iter, --evolve-source, --resume, --answers, --test-debug-fix and a positional task id or goal all scope the fleet frontier, so passing one with --engine claude exits 2 the same way. --area is the exception since kfhsz: BOTH engines read it, because the scope machinery (parseLoopScope -> filterForScope) was always shared and only the claude engine was fenced off from it, which left it with no scope control at all. What stays fleet-only is every scope that writes or resolves tasks rather than selecting existing ones — --resume rehydrates a capsule, --test-debug-fix is a fleet verify cycle, and a bare goal string runs NlGoalPlanner. A scope that matches no runnable exec: agent task is now refused by name on either engine (no exec: agent task in <board> matches --area <tag>) instead of silently running as the whole board. Since kjgpm an explicit task-id list is read by both engines too; a natural-language GOAL stays fleet-only, because it runs NlGoalPlanner to write new tasks and a driven session has no path to that — on --engine claude the same prose is an nl-select scope instead (k9ete, above). The discrimination rule: parseLoopScope classifies the positionals as ids only when EVERY one matches a known id shape (T\d+, R\d+, or a k-shape). Since knctg a list that MIXES id-shaped tokens with a non-id-shaped one is no longer folded into prose at all — it exits 2 naming the offending token on both engines, because the only way to type one is a typo in an id list. --max-sessions is the exception: on the fleet engine it caps the number of tasks the run may dispatch when --max-iter was not given, so a caller that already passes a budget keeps one.
Extend recipe. Set the default engine for a repo or a host without touching any call site:
export CYBERINE_AUTOPILOT_ENGINE=claude # or fleet
cyberine autopilot # now runs on that engine
cyberine autopilot --engine fleet # the flag still winsEnvironment.
| Variable | Default | Meaning |
|---|---|---|
CYBERINE_AUTOPILOT_ENGINE | fleet | default work engine for cyberine autopilot; --engine overrides it |
CYBERINE_LOOP_MAX_ITER | 100 | fleet engine: tasks one run may dispatch, when --max-iter was not given |
CYBERINE_LOOP_BLOCKED_THRESHOLD | 3 | fleet engine: consecutive no-progress iterations before stopping |
CYBERINE_LOOP_SYNC_VERIFY | unset | 1 is the same as --dispatch sync |
CYBERINE_LOOP_DEAD_FORK_LIMIT | 2 | fleet engine, --dispatch sync: consecutive forks producing NO candidate at all before the run stops with a dead fleet: reason. 0 disables the backstop |
Known Limitations and Deferred Work. The fleet engine deliberately does NOT inherit three things from the Claude engine, and says so rather than pretending otherwise:
-
No quota probe. The Claude engine spends a real
claude -pturn to read the subscription rate-limit window and can refuse the whole run on it. The fleet's workers are mostly OpenAI-wire loop workers with their own keys, so the fleet engine READS the cached reading (free) for the plan but never probes and never refuses on it. Spend is gated where the fleet actually gates it: the daily-spend cap and its admission reservation at the dispatch boundary.--on-quotais therefore a--engine claudeflag. -
No per-task checkpoint. A fleet task is one dispatch, not a conversation with a context window to exhaust, so there is no session boundary to checkpoint. The loop writes its own audit ledger and context capsules per task, and ONE handoff is written at run end (skip it with
--handoff none). -
The plan's session-shaped lines are inert.
--dry-runprints one plan for both engines so no reader has to learn two; on--engine fleetthe driver, session-name, stall, hold, turn-cap, on-quota, batch and model-boundary lines describe--engine claudeand a dimmed note under theengineline says so. -
The readiness check reads what resolves, never a provider. A worker that resolves from
fleet.json, constructs, and only then fails at dispatch (a 401, an exhausted balance) still reports READY — nothing is probed, because a plan must not spend. That case is caught after the fact by the dead-fork backstop, not before it. -
The dead-fork backstop only counts EMPTY forks. A fleet that faithfully produces diffs the judge rejects every time is a task problem, so it never trips this backstop;
CYBERINE_LOOP_BLOCKED_THRESHOLDis the rail for that. The counter is per-run and in-memory: a freshcyberine autopilotstarts at zero. -
--dispatch asyncobserves no fork outcome, so the dead-fork backstop never fires there (the detached run owns the fork). The readiness refusal still applies to both dispatch modes. -
Pins shape code tasks only. A task the non-code verify gate claims (research, docs, plans) keeps that gate's own in-process dispatch;
routing.stepsdoes not apply to it. -
A
fork:row does not fan out per approach under pins. Its approaches are appended to the task asCANDIDATE APPROACHESand the design step picks one; the pinned implementers then compete on that plan. One worktree per approach remains the unpinned beam-search behaviour. -
No mode preset on the pinned path. The workflow runs without a
CYBERINE_MODEpreset, because a preset's fleet-size cap slices the pool before the pins filter it and could drop a pinned worker the dry run promised. -
The review step is advisory. The task gate is the implement step's DiffJudge winner applying cleanly to the working tree; a
VERDICT: riskfrom the review step is recorded on the run and in its ledger line but does not block the task, the same as a background run's review. A review that returns risk with concerns first gets the one bounded fix pass described in "Review findings get one fix pass (kd8dj)", so the diff that gates the task is the fixed one when it was adopted. -
The dead-fork backstop reads the implement step under pins. A pinned dispatch counts as live when any implement candidate carried a diff; when none did, the worker it names comes from the implement step, or from the first failed step when implement never ran.
-
The unresolved-pin refusal is
--dispatch synconly. Onasynceach detached run builds its own pool, so the parent cannot know what it will build; the detached run's own step refusal still applies there.
Also deferred: --bg on the fleet engine always uses the detached-process path, never a claude --bg --exec fleet row, because there is no steerable session for claude attach to reach. Watch it in cyberine agents (runs view, under the repo's folder row) or cyberine agents --json; claude agents never lists it.
Live progress in the run record (k32pf). The fleet engine's outer autopilot run is updated after every task that reaches the audit ledger, not only at start and end: run.json gains one step per task (its id and title, done when the gate passed, error when it was blocked or failed, with the ledger's reason as the step detail and the output tokens spent on that task), detail names the last task and its outcome, and updatedAt moves. So the cyberine agents row shows a growing STEPS count and TOKENS while the run is live. Updates land when a task FINISHES; the task in flight is not shown until its gate settles, because the loop writes its ledger line only then.
Interactive preflight on a real TTY (knpbq)
Run cyberine autopilot with no flags on a real terminal (not piped, not --bg, not --dry-run) and it shows one plan block with task ids, engine, time/task caps, the daily and hard spend caps, cached quota, estimated spend and metered API status, then asks Run? [Y/n/e]. Enter or y starts, n exits 0 before spawning, and e opens the detailed preflight menu, then returns to the plan and the same prompt. The plan uses cached quota data and does not run the quota probe.
Run recipe.
# on a real terminal: shows the plan, then asks Run? [Y/n/e]
cyberine autopilot
cyberine autopilot --engine claude --on-quota api
# any of these skip the menu and run with today's exact defaults, no prompt, no hang
cyberine autopilot --yes
cyberine autopilot --bg # on a real terminal
cyberine autopilot --dry-run # free anywhere, never needs --yes
cyberine loop # the deprecated alias carries --yes internally on a terminal
# no terminal (a script, cron, an agent's shell): --yes is required, the operator's yes made explicit
cyberine autopilot --bg --yes
cyberine autopilot --bg < /dev/null # refused: exit 2, naming the spendNo terminal means --yes (kvd26, operator ruling 2026-09-19). Without a real terminal on both stdin and stdout, a real run (not --dry-run) refuses with exit 2 unless it carries --yes, and the refusal names what the run would spend (the fleet dispatching every agent-runnable task, or up to --max-sessions Claude Code sessions, for up to --max-hours). The reason is an agent: measured on 2026-09-19, a Claude Code session asked to "let cyberine work through the whole board unattended" launched cyberine autopilot in 2 of 3 runs before the operator had said yes, and an agent's shell is never a terminal. --bg is not an exemption on its own, because the parent is exactly the call an agent makes; the children it starts are (the fleet row carries --attached, the detached fallback is given --yes by its parent). The deprecated cyberine loop alias injects --yes only on a terminal, so a scripted loop meets the same guard. No daemon, cron, schedule or queue path launches autopilot, so nothing internal needed a change. Above this guard, the autopilot skill asks at most once: an invocation that already names both scope and budget carries the go-ahead itself, and every other invocation gets exactly one question round whose answer authorizes --yes - never a second confirmation turn. Known limitation: the guard cannot tell an operator's --yes from one an agent added on its own; it makes a forgotten yes safe, and the refusal text tells an agent never to add it unasked, but it is not proof of consent.
The plan lists every selected task id. Enter e at the prompt to open the detailed menu. Two rows open on demand:
- Scope — checkbox-toggle which areas run, then optionally fine-tune individual tasks within the areas kept on. Resolves to the whole board (unchanged) when every task stays checked, or to an explicit task-id list otherwise. Hidden once an explicit scope was already given on the command line (a task id,
--area, or--resume) — that value pre-fills and the question is skipped. - Plan — the pre-computed frontier, one line per task: its cost class (
cheap/expensive, the same classifiercyberine run's background dispatch uses) and each fleet step's band plus anyfleet.json routing.stepspin. Read-only; always shows the full frontier regardless of the Scope toggles.
The Engine row is hidden when --engine (or CYBERINE_AUTOPILOT_ENGINE) was already given — that choice pre-fills instead. The Caps row is hidden the same way when --max-hours or --max-sessions was already given; otherwise it offers no cap (today's defaults), a time cap in hours (sets the same budget as --max-hours), or a spend cap in dollars (sets CYBERINE_SPEND_CAP_USD for this run only). Confirming or cancelling the detailed menu returns to the plan prompt or exits without starting, respectively.
Extend recipe. The menu logic is a pure module, independent of any terminal: src/autopilot/preflight.ts exports buildPreflightPlan / resolvePreflightScope / resolvePreflightFlags / preflightApplies (plan + answers -> a value, no I/O) and runAutopilotPreflight (the interactive loop, parameterized over a small PreflightIO port instead of touching process.stdin/stdout). A throwaway script (gitignored, .local/tmp/*.ts) can drive every branch by injecting fake pickFromList / checkboxSelect / readLine functions instead of a real terminal — the menu, the scope/caps submenus, cancel, and switching engines mid-menu are all exercisable this way, with zero TTY. src/commands/autopilot.ts wires the real src/core/cliPrompt.ts primitives (the same ones cyberine setup's wizard and cyberine init's prompts already use) into that port.
Environment.
| Variable | Default | Meaning |
|---|---|---|
CYBERINE_SPEND_CAP_USD | unset | daily spend cap in USD (src/store/spendCap.ts); the preflight's spend-cap answer sets this for the current process only, it does not persist |
Known Limitations and Deferred Work.
- Not verified on a real TTY. The one-prompt smoke injects
PreflightIOand is left for the operator to run; terminal rendering and the assembled menu flow still need a real-TTY pass. - A dollar estimate is unavailable before dispatch. Task usage and provider prices cannot be established from the board and cached quota reading alone. The plan labels estimated spend unavailable and shows configured daily (
CYBERINE_DAILY_USD_CAP) and hard (CYBERINE_SPEND_CAP_USD) spend caps separately. - The plan's cost-class preview can differ from the real dispatch. The board task shape available here (
PolicyTaskfromboundaryPolicy.ts) carries notitle:field, onlydo:prose, so the classifier's title input is approximated withdo:. A task whose escalating word (security / concurrency / migrations) appears only in its board title and not itsdo:line previews a class the real dispatch (which does have the title) would not pick. - Switching engine away from
fleetin the menu drops an explicit task-id or--resumescope silently. If the command line already named an explicit scope (a task id,--area,--resume) the Scope row is locked and hidden as documented above; if the operator then uses the (still-visible, since--enginewas not itself explicit) Engine row to switch toclaude, a locked--areanow carries over (kfhsz), but a task-id or--resumescope has nothing to apply to on that engine and is simply unused for that run. - The Caps row offers only time or spend, never a session-count cap (
--max-sessionsstays flag-only); and picking a cap here does not persist past the current process.
Autopilot command — cyberine autopilot --engine claude, the whole session -> handoff -> session loop (k0vpr)
cyberine autopilot is the supervised form of the loop the operator otherwise runs by hand: work a
session down to some fill, end the task, write a handoff, wait for the Telegram ping, open a fresh
session, tell it to read the handoff and the board, continue. It composes the four pieces that
already existed — sessionDriver.ts (drive one Claude Code session turn by turn and measure its
context), boundaryPolicy.ts (decide at a TASK boundary whether to take another task),
effortCalibration.ts (price effort: S|M|L in tokens) and handoffBridge.ts + prompts.ts
(hand off through the builtin handoff writer, bootstrap the next session) — into one bounded, audited process.
The loop lives in src/autopilot/autopilotLoop.ts; src/commands/autopilot.ts only parses flags,
refuses to start when a precondition is missing, and supplies the real ports.
One session, in order: decide() at fill 0 picks the task, the session bootstraps on it (with the
previous session's handoff file when there is one), and after EVERY measured turn the policy is
asked again. continue keeps the session going — on the same task with a short nudge, or on the
next task with a fresh continue prompt once the current one is done. finish-then-handoff spends
one more turn finishing (skipped when the task is already done) and then ends the session.
handoff-now ends it immediately. no-runnable, or a NO_RUNNABLE reply from the driven agent,
ends the whole loop. A task counts as done when the reply carries a line DONE <taskId> OR the
board's effective status for it flipped to done — whichever lands first.
At a session end the handoff is planned with a SEPARATE decision taken at fill 0, because the next
session starts with an empty window: a finished task yields the next task on the board, and an
unfinished one is carried by default as a RETURN to the board (it stays runnable, with its
checkpoint attached as context) so whichever free session claims it next resumes from the goal
file's last ticked milestone — or, under --unfinished handoff, pinned to ONE successor via
nextTaskId. The handoff path is then fed into the next session's bootstrap prompt.
Cold start — session 1 reads the last invocation's handoff, and repeat-fail counts survive the run
(kj6he). A fresh cyberine autopilot used to know only about handoffs it produced itself, so
session 1 of every new invocation bootstrapped on the fallback line ("no handoff file was produced
for this session; read the board directly") and saw yesterday's handoff only when a ~/.claude
SessionStart hook happened to inject it — something this loop neither owns nor checks. The command
now resolves the newest handoff-*_<project>.md in the handoff folder (same
<writer> resolve and same project-suffix filter the handoff bridge already uses, so a folder
shared by several repos never hands one project another's file) and passes it as session 1's
handoff; the bootstrap prompt names it exactly as it names an in-run one. "Newest" is decided by
the 8-digit date embedded in the filename (e.g. -20260913) when it parses as a real calendar
date, ranked above file mtime and only falling back to mtime as a same-date tie-break, so a
...-20260912 handoff rewritten later never again beats a genuinely newer ...-20260913 one.
Every failure — a non-executable
writer binary, a resolve that prints no path= line, an unreadable folder — degrades to the
old fallback rather than blocking the run. The same file backs the repeat-fail guard: the
sessionsStarted count per task is written to .local/autopilot/history.json on every bump and
read back at the next start, so a task that already burned CYBERINE_AUTOPILOT_REPEAT_FAIL
(default 3) sessions yesterday is excluded today instead of restarting at zero. Entries are dropped
once the board calls the task done, and expire 7 days after the last real bump so nothing is locked
out forever. --dry-run states both up front:
session 1 reads /Users/me/.claude/memory/vibecoding/handoff-…_ai-agent-solution.md
fail history /repo/.local/autopilot/history.json — 1 task(s) carried over: kj6he x3 (excluded)and --dry-run --json carries the same as coldStart: { handoffPath, memoryDir, failHistoryPath, carriedFailHistory[] }. Known Limitations and Deferred Work. The cold-start handoff is picked
by filename and mtime, not by reading it: nothing checks that its contents match this repo's
current HEAD. The date is read from the filename, not the file's own content — a handoff written
without the YYYYMMDD date suffix (or with a syntactically-8-digit but invalid calendar date) is
ranked by mtime only and always sorts below every dated file, even one dated far in the past, so
an undated handoff never wins as long as any dated one exists in the folder. The retention window
and the history path are constants (src/autopilot/failHistory.ts), not flags. Counts are per
task id only — a task renamed or re-added under a new id starts at zero.
# see the plan without starting anything (safe on any board)
cyberine autopilot --dry-run
# drive the board with the default budget (6 sessions / 8 hours)
cyberine autopilot
# bounded run on a named model, detached with a log
cyberine autopilot --max-sessions 2 --max-hours 1 --model sonnet
cyberine autopilot --bg # log: .local/autopilot/logs/<runId>.log
# stop the loop at the next boundary (it hands off first, then exits 0)
touch .local/autopilot/STOP| Flag | Default | Effect |
|---|---|---|
--max-sessions N | 6 | sessions before the loop stops with a LIMIT notification |
--max-hours H | 8 | wall-clock budget; checked before each new session AND before a session takes another task or spends another turn (ktnfy) — see "Wall-clock budget" below |
--model <m> | (unset) | FALLBACK tier for tasks with no model: of their own; unset means the CLI's own default model. A session is spawned on the tier of the task it opens, so on --driver stream/bg a tier change ends the session — see "Per-task model tier" |
--window N | derived from the models the run will drive | window the policy measures fill against. Precedence: --window, then CYBERINE_AUTOPILOT_WINDOW_TOKENS, then the SMALLEST window across --model and every runnable task's model: tier (Opus 5 / Sonnet 5 / Fable / [1m] suffix = 1000000; Haiku, Opus 4.6, Sonnet 4.5/4.6 = 200000; per Claude Code's model-config page, "Extended context"), then the CLI default (1000000, both plan defaults are native 1M). CLAUDE_CODE_DISABLE_1M_CONTEXT=1 in the spawn env caps every model at 200000. An unrecognised model id assumes 200000 and prints a warning, never 1M. The dry-run plan's window line shows the value AND its source (k2v68). Known limitation: CLAUDE_CODE_AUTO_COMPACT_WINDOW is not read -- set --window to match when you use it |
--backend claude|codex|opencode | claude | backend the handoff writer's offload is told to use; the builtin writer accepts and ignores it |
--handoff checkpoint|offload|none | checkpoint (or CYBERINE_AUTOPILOT_HANDOFF) | how a session boundary reaches the next session (ksb1a) -- see "Mechanical checkpoint" below. offload is the pre-ksb1a behaviour: one builtin offload handoff per boundary; amemoria is still accepted as an alias for offload (renamed 2026-09-30). none writes nothing at boundaries or run end |
--unfinished return|handoff | return (or CYBERINE_AUTOPILOT_UNFINISHED) | how a boundary treats a task the session did NOT finish (k6yf5) -- see "Unfinished task: return to the board or hand off" below. return keeps it runnable so the board arbitrates the next claimer, with the checkpoint attached as context; handoff pins it to ONE successor via the checkpoint's nextTaskId |
--stall-timeout M | 20 | minutes with NO stream event AND no busy child tool before a driven turn is judged hung and its process tree is killed (khgxt). A long Bash tool call keeps the turn alive as long as its subprocess is running or burning CPU |
| (hold, no flag) | backoff 30s -> 60s -> 120s -> 300s | k335n. A turn whose reply is HOLD <taskId> <reason> (the contract every bootstrap and nudge prompt now states), or a short reply that reads as holding ("still waiting on the sweep"), is NOT re-prompted at once: the loop sleeps the next backoff step (STOP polled every minute, bounded by --max-hours), then nudges again. Held turns are counted in the session's turns but not toward the 40-turn ceiling, and are recorded as autopilot:decision events with phase: "held". One hold streak may last at most the grace (--grace, default the stall timeout); past it the session gets one wrap-up turn and hands off (phase: "hold-ceiling"). Run 15a3f96e is why: 39 nudges at ~4 s each on "still holding for the sweep", the ceiling in 9 minutes, and the boundary killed the sweep it was holding for |
--grace M | the --stall-timeout value (20) | minutes past --max-hours a driver turn may still be RUNNING (kb2j2). Every turn is capped at startedAt + max-hours + grace - now; a finish or wrap-up turn whose grace is spent is skipped and the task hands off unfinished. 0 makes the deadline itself the bound — see "The overshoot bound" |
| (quota, no flag) | probe on every real run | before the first session the command spends one throwaway sonnet turn (session cyberine-ap-<runid8>-probe, recorded in autopilot.jsonl with probe: true) purely to read the CLI's rate_limit_event; refuses with exit 2 when the last known status is rejected and its reset is still ahead (kxvc9) |
--on-quota wait|stop|api | wait | what a session does when the subscription window is exhausted MID-run (kp5x7): wait pauses and probes after the reset, stop hands off and exits 0 with reason quota, api swaps to the metered ANTHROPIC_API_KEY for the rest of the run |
--force-model | off | make --model win over a task's own model: tier (ke8hm). By default the task's tier is the more specific declaration and wins; --model is only the fallback for tasks with no tier. Pinning one model also removes every tier boundary, so a mixed-tier board runs in fewer sessions |
--confirm-spend | off | deprecated no-op, accepted for existing scripts and prints one notice; --on-quota api uses the daily spend cap and CYBERINE_SPEND_CAP_USD |
--turn-timeout M | 0 (none) | OPTIONAL hard wall-clock cap on one turn. Off by default since 2026-09-06: a fixed 20-minute cap killed a healthy kmq07 turn, and the operator's rule is "check whether it is actually running; if it is, let it run" |
--batch N | 1 | disjoint runnable tasks per turn (kg9ny). Above 1 the session fans each out to its own worktree agent; the roster excludes tasks sharing an area: or a src/ path, and a task that runs the autopilot or the sweep gets a batch alone — see "Batching" |
--on-drained exit|brainstorm | exit | what happens when the board has no agent-runnable task (kwzd6): exit ends the run with reason no-runnable, unchanged; brainstorm spends ONE bounded session drafting the next batch as [backlog] entries and exits 0 with reason drained-brainstormed. It NEVER promotes — see "Drafting the next batch" |
--brainstorm-max N | 5 | ceiling on drafts per brainstorm session. Fewer is fine; zero is a legal answer |
--brainstorm-model <m> | --model, else sonnet | model the brainstorm session runs on |
--dry-run | off | print the plan and exit 0 without spawning anything |
--bg | off | detach, print the pid + log path, keep running after the shell exits |
--json | off | machine-readable output (both for --dry-run and the final summary) |
--debug | off | let the session driver log prompt and result bodies (they can contain secrets) |
--driver print|stream|bg | print, but stream under --bg | which session driver runs the driven session. print is unchanged. bg (kddqh) drives ONE claude --bg background job, so the session is a real row in claude agents and can be steered mid-run — see "Fleet-visible driver" below. Since k378p whatever you pass is carried INTO the fleet row unchanged; the launch output prints the driver actually used |
Mechanical checkpoint (ksb1a, default since 2026-09-13). Real runs showed the then-current amemoria offload taking 1-2 minutes at EVERY session boundary -- an external LLM re-reading the driven transcript to write prose -- while the sessions ended at 18-27% fill for board or --max-sessions reasons, never for context. The durable state (board briefs, git, the .local/vibe/<task>.md goal file) already survives outside the transcript; what does not is the rationale. So the driven agent now writes that itself, in the turn it already has: every bootstrap, continue, batch and wrap-up prompt states the contract, and the agent ends its reply with
STATE:
Decisions:
- <what you chose and why>
Rejected:
- <approach ruled out and why>
Open:
- <unverified hypothesis or question for the operator, or "- none">
Next:
- <the next concrete step>
Pending:
- <background job or gate still running, or "- none">
DONE <taskId>At the boundary the loop parses that block (leniently: case-insensitive headers, - or * bullets, inline values, continuation lines fold into the previous bullet, the LAST STATE: block in the reply wins, parsing stops at the DONE line) and, when all five sections are present, writes .local/autopilot/checkpoint/<runId>-s<n>.json (machine) and .md (human) atomically (tmp + rename) in milliseconds: runId, sessionIndex, sessionId, taskId + done flag, the planned next task, the sha the session opened at and the sha now, modified + untracked paths, the board ids flipped to done during the session, the newest .local/vibe/*.md touched since the run started, the trailer, context tokens + fill, and the write duration. The next session's bootstrap prompt then says Read the checkpoint at <path> first, then the goal file it names. The log line is session N checkpoint written in <ms> ms: <path>; the session summary and the autopilot:session event carry handoffKind: checkpoint.
The offload handoff still runs where it earns its cost, and since 2026-09-30 it is always the builtin writer (deterministic, no model call, so no longer slow): (1) a boundary whose last turn carried NO STATE: block or an INCOMPLETE one (a section missing) falls back to the builtin offload for that boundary and records why in the session summary (handoffFallback: "the STATE trailer is missing Pending") -- nothing is ever fabricated; a session killed for a stall gets its wrap-up turn, and that turn's reply is what is parsed; (2) at RUN END, once, for the human pickup -- unless the last boundary already ran the offload (then a second one is not written) or --handoff none. The run summary prints run-end handoff: <path>; a run-end offload failure is logged and reported (runEndHandoffError in --json) but does not change the exit code, because the checkpoints are already on disk.
cyberine autopilot --dry-run # the plan's "handoff" line shows the mode and where checkpoints land
cyberine autopilot --max-sessions 2 # checkpoint mode (default)
cyberine autopilot --handoff offload # the pre-ksb1a behaviour, one builtin offload per boundary
cyberine autopilot --handoff none # nothing written at boundaries or run end (handoff writer not required)
CYBERINE_AUTOPILOT_HANDOFF=offload cyberine autopilot # env mirror of the flag
ls .local/autopilot/checkpoint/ # <runId>-s<n>.json + .md per boundary
npm run selftest:autopilot-checkpoint # parser, prompts, atomic write (28 checks)
npm run selftest:autopilot-loop # case (ksb1a): the three modes end to end on the scripted driverMeasured alternative, rejected (kcgg5, 2026-09-13): keeping ONE Claude Code session across boundaries (resume + one /compact focus ... turn) was compared against fresh+checkpoint on 3 boundaries of tiny sonnet tasks with three planted facts. Fresh won: ~41 s vs ~82 s per boundary (the compact turn alone is ~60 s), -12% cost, no context saving (~52k vs ~54k at the successor's first action, because ~50k is Claude Code's own per-session baseline), identical fact survival through the checkpoint. The experimental flag was removed; what stayed is bootstrapMs / bootstrapContextTokens on every session summary (--json) and the per-session row of the text summary. Under --json the log now goes to stderr so stdout is one JSON document. Note: .local/research/autopilot-handoff/kcgg5-continue-vs-fresh-20260913.md.
Known limitations (ksb1a): the loop's OWN default stays offload (AutopilotConfig.handoffMode undefined; the mode was named amemoria until 2026-09-30) so every pre-ksb1a caller and smoke keeps its behaviour -- only the CLI defaults to checkpoint. The checkpoint records what the agent SAID in its trailer; it does not verify those claims against the tree (the shas and dirty list next to it are the mechanical half). The killed-session path is proven through the missing-trailer fallback on the scripted driver, not through a real stall. boundaryPolicy tiers are untouched.
Unfinished task: return to the board or hand off (k6yf5). Until now a session that hit its
context boundary with a task still in flight always carried that task to ONE designated successor:
the checkpoint's nextTaskId named the unfinished task, and state travelled one way through disk.
That is the serial, one-session-at-a-time assumption. --unfinished (env
CYBERINE_AUTOPILOT_UNFINISHED) drops it:
return(the DEFAULT) — the board arbitrates. The checkpoint is still written (the ruling is about who decides, not about discarding what was learned), but instead of pinning a successor the loop releases the carry: the task stays runnable, the checkpoint is attached to the task as context, and whichever free session claims it next reads it. A finished task still names its successor as before; only an UNFINISHED task is returned.handoff— the legacy serial carry. The unfinished task is pinned to ONE successor via the checkpoint'snextTaskId.
The default is return because the operator ruling (2026-09-21) settled that the board arbitrates
once the one-session-at-a-time assumption drops, and this is the autopilot half of that. A task
returned again and again without progress does NOT cycle silently: every return bumps its
sessionsStarted count in .local/autopilot/history.json, and once that reaches
CYBERINE_AUTOPILOT_REPEAT_FAIL (default 3) the boundary policy stops offering it (excluded: { reason: "repeat-fail" } in the decision event), so the run ends no-runnable and the loop logs
returns <id> to the board unfinished on each return. The handoff-plan decision event carries
returned: true when a task is returned.
cyberine autopilot # return (default): the board arbitrates the next claimer
cyberine autopilot --unfinished return # explicit
cyberine autopilot --unfinished handoff # the legacy carry to one successor
CYBERINE_AUTOPILOT_UNFINISHED=handoff cyberine autopilot # env mirror of the flag
cyberine autopilot --dry-run # the plan's "unfinished" line shows the mode
npm run selftest:autopilot-boundary-return # return, handoff and repeat-fail on the scripted driverKnown Limitations and Deferred Work. return is proven on the scripted driver within one run
(a second session claims the returned task and reads its checkpoint); it does not yet release a
board [doing] claim, because the Claude engine never takes one — the task is [todo] throughout,
so "return" only stops pinning a successor. Releasing a real board claim (cyberine tasks run
flips a task to [doing]) is the other half the operator ruling enables and is not wired here.
--unfinished is a Claude-engine flag only; the fleet engine has no per-session boundary to
return from.
Exit codes. 0 — the board drained, the driven agent replied NO_RUNNABLE, the STOP file
appeared, or a budget limit was reached. 1 — failed: a session-driver error, a handoff timeout,
or a handoff writer refusal. 2 — refused to start.
Refuses to start (exit 2, one line saying which) when the repo root is not a git repository
(the driven sessions commit and push, so git init and a first commit must already exist there),
when neither .cyberine/config.local.json nor .cyberine/config.json records rules.applied === true (this command commits and
pushes through the agent it drives, so it starts only where that autonomy is already granted via
cyberine init --rules), when no task manifest exists, when the runnable set carries no
exec: agent task (the refusal also counts dep-free exec: agent tasks sitting in [backlog] and prints a
cyberine tasks status todo <ids> promote hint when any exist), or when the handoff writer is
not executable (CYBERINE_HANDOFF_WRITER_BIN, default the builtin bin/cyberine-handoff.mjs).
--dry-run enforces ALL FIVE, so a green plan proves a real start will get past the gates (ka8v9;
until 2026-09-08 the handoff-writer check was wrapped in if (!dryRun), so a clean dry-run said nothing
about whether --bg would come up). It is one stat. A green plan carries a handoff writer <path> ok (builtin) line (or (CYBERINE_HANDOFF_WRITER_BIN) when the override is set) naming the resolved binary, and --dry-run --json carries handoffWriter: { bin, ok, writer } (before 2026-09-30 the line and the field were amemoria); a REFUSAL
prints only its own line on stderr and writes nothing to stdout, so short output is a refusal to
read, never a plan to skim. The refusal reads "the handoff writer is not executable (); ..." and names the builtin path to fall back to: when
CYBERINE_HANDOFF_WRITER_BIN is what points at the missing file it says so and gives the builtin path unsetting
falls back to, otherwise it says the install may be missing the builtin or its executable bit. That path is resolved by
ONE exported function, handoffWriterBinPath() in src/autopilot/handoffBridge.ts, which both the gate
and the bridge call; it used to be a literal duplicated in two files that could not diverge only
because the gate always handed the bridge its own answer.
STOP file. .local/autopilot/STOP is checked before every turn and before every new session.
Present mid-session, the loop hands off first and then exits 0 with reason stopped; present
before a new session, it exits 0 immediately. Deleting the file re-enables a later run — nothing
else reads it.
Timed-out tasks, fleet engine (kyksr). --on-timeout retry-once|skip|stop (or CYBERINE_ON_TIMEOUT; the flag wins) decides what a code task does when its worker timed out (CLI agent timed out after ... or the kem49 first-activity stall) and no candidate landed. retry-once (default) re-dispatches it once with the CLI timeout doubled and a brief line saying the last attempt timed out; if the retry lands, its dependents run as usual. skip, and retry-once whose retry also timed out, writes a board blocker on the task (timed out; skipped by --on-timeout skip ...), drops it and its dependents from this run and carries on with the rest; when only its dependents are left the run ends with frontier empty: the remaining task(s) wait on N task(s) skipped after a timeout ... instead of the self-starvation stop. stop keeps the pre-kyksr behaviour. Each retry and skip is an audit step line (timeout-retry / timeout-skip), and a skip is listed in the handoff's decisions.
cyberine autopilot --engine fleet --dispatch sync --on-timeout skip
CYBERINE_ON_TIMEOUT=stop cyberine autopilot --engine fleet --dispatch sync
npm run selftest:timeout-policyKnown limitations: applies to code tasks dispatched through the beam fork in --dispatch sync; non-code tasks and the pinned-workflow path keep the old behaviour. The doubled timeout is the per-worker CLI timeout (CYBERINE_CLI_TIMEOUT_MS), not the run's --max-hours, so a retry can spend the remaining budget. The board blocker a skip writes stays until the operator clears it (cyberine tasks status unblock <id>).
Signals, fleet engine (k7xrx). cyberine autopilot --engine fleet treats SIGTERM and SIGINT like a STOP file that also interrupts the task in flight: the first signal stops new dispatch, sends SIGTERM to every in-flight worker's process group, and lets the loop return, so the stop reason (stopped: SIGTERM from the operator ...), the audit loop-end line and the handoff are written as on a STOP file; the run exits 0 with reason stopped. A task interrupted this way did not pass its gate and stays open on the board. If the loop has not returned within CYBERINE_STOP_GRACE_MS (default 30000), the worker groups get SIGKILL and the process exits 143 (130 for SIGINT) without a handoff. A second signal does that at once.
kill -TERM <autopilot pid>
CYBERINE_STOP_GRACE_MS=10000 cyberine autopilot --engine fleet --dispatch sync
npm run selftest:signal-stopKnown limitations: the handler covers the fleet engine only; the session engines keep their existing signal behaviour. Workers are reached through the process groups CliAgentAdapter spawns; a claude --bg worker lives under the Claude daemon and is not signalled. Before k7xrx, merely importing the LSP registry installed a SIGTERM listener that swallowed the first SIGTERM in every Cyberine command; it now exits 143 when it is the only listener.
Wall-clock budget (ktnfy). --max-hours is checked in three places, all reading the same
remainingBudgetMs() helper in src/autopilot/autopilotLoop.ts: before a new session starts, while
a session is paused on the subscription quota, and — since ktnfy — inside a running session, after
every measured turn. The in-session check runs AFTER the policy's own verdicts (so a handoff the
policy already ordered keeps its own reason) and AFTER the 40-turn ceiling (so a runaway session is
not granted one more turn). When the budget is spent, the session finishes the task it is on — one
finish turn if it has not said DONE yet, none if it has — hands off through the builtin offload like
any other boundary, and the run exits 0 with reason limit and a message naming the task:
max-hours 2 reached mid-session on kx75n. An autopilot:decision event with phase: budget
records it. Before ktnfy the budget was read only at a session boundary, so a session that drained
the board task after task never reached one: run 3fed0a2b (2026-09-08) was granted --max-hours 2
and ran 2h49m across six back-to-back tasks.
The overshoot bound (kb2j2). --max-hours alone is a deadline for STARTING work, and turns are
atomic — so before kb2j2 a turn already in flight ran to its --stall-timeout (20 min) and an
unfinished task was then granted a finish turn that could run another, a worst case of about 2x
the stall timeout past the deadline with nothing naming it. Now --grace M (default: the
--stall-timeout value, so one knob moves both) states the bound, and every driver turn is handed
startedAt + max-hours + grace - now as its hard cap:
No driver turn is still running after
startedAt + max-hours + grace. At the defaults that is8h + 20min = 8h20m.--dry-runprints the number as awall-clock boundline.
This was FALSE when first shipped, and the way it was false is worth recording: AutopilotDriver's
start/turn declared opts?: { model?: string }, so the loop's capMs arrived as an excess
property on a variable — which TypeScript does not flag — and all three real drivers rebuilt
{ model } and dropped it. The loop smoke stayed green because it drives a scripted double that
reads opts.capMs directly. An adversarial review found it on 2026-09-09; the guarantee now holds
against the REAL drivers and selftest:autopilot-stream-driver pins it with a positive control
(without the fix the same hanging turn runs 30 s instead of 500 ms).
Three consequences. A turn started near the deadline is capped rather than left to the stall
timeout — and on --driver bg that cap now covers the WHOLE turn, not just its measured phase
(kzr6x): waitForIdle, stopAndSettle, spawnBackground and waitForSessionId each used to run
their own timeout and ignore the caller, so a wrap-up carrying seconds of remaining budget could
spend twenty minutes in waitForIdle first. One deadline is derived per turn and every phase is
tightened to what is left of it; a phase whose budget is already gone is refused with
cause: 'cap' rather than started on its own clock. A finish turn whose grace is already spent is skipped, and the unfinished task hands
off as it stands — the goal file's last ticked milestone is what the next session resumes from, so
nothing is lost; an autopilot:decision event with phase: grace-spent records the skip. And the
inactivity-recovery wrap-up turn, which carries its own 5-minute cap, is capped by whichever is
tighter and skipped outright past the grace, so it cannot buy a further five minutes. --grace 0 is
legal and makes the deadline itself the bound. A run PAUSED on the subscription quota
(--on-quota wait) is not a running turn; the existing budget check ends the run at the pause
instead.
Batching: one turn, N disjoint tasks, one worktree agent each (kg9ny). --batch 1 is the
default and unchanged. Above 1 the driven session stops being an executor and becomes an
orchestrator:
cyberine autopilot --dry-run --batch 3 # see the partition before spending anything
cyberine autopilot --batch 2 --max-sessions 1 --max-hours 1The roster is chosen by decideBatch() (src/autopilot/boundaryPolicy.ts), which grows
decide()'s single pick into an ordered batch and never changes WHETHER to work or WHICH task
leads. Three exclusions, because undeclared coupling is what a naive N-in-order hits:
- An exclusive task takes a batch alone. A
do:that runscyberine autopilot,npm run sweeporcyberine runneeds the working tree to itself — this repo's vibe rule forbids editingsrc/while a run is alive, and the sweep marks a dirty tree's stamp not-ok on purpose. Autopilot FLAGS count as the same signal: measured on this board 2026-09-09,kj8y2'sdo:says "Run the same shape with ...--max-sessions 2" and "--bg --driver bg" and never the words "cyberine autopilot", so a command-name-only rule left it batchable. - At most one task per
area:. Two tasks sharing an area are assumed to touch the same code even when neither names a file. An untagged task is never excluded by this rule — the tag is evidence of coupling, its absence is not evidence of independence. - A
src/path named by two picked tasks defers the second. The match is deliberately narrow (asrc/prefix, no globbing, no resolution): a false match defers a task by one batch, a missed match puts two worktree agents on one file and produces a merge conflict the loop cannot resolve.
A dependency can never share a batch with its dependent and that needs no rule: runnable only
holds tasks whose deps are all done, so a task depending on another runnable task is by
construction not in the set. The invariant is asserted in the smoke rather than implemented.
The batch prompt (prompts.ts v1.3.0) hands the session a CLOSED roster, tells it to spawn one
isolation: "worktree" agent per task CONCURRENTLY, and names an explicit --model per spawn
because the operator's agent-model-guard hook denies a spawn carrying none. Each finished task
gets its own DONE <id> line; the loop accepts them in any order and across turns, ignores an id
that is not on the roster, and retires the batch only when EVERY id is done or the board already
carries it — a partly-finished batch keeps working, and the next nudge names only what is still
open. run.json records a phase: "batch-open" decision event with the roster, and every turn
event carries roster and pending.
Subagent spend is measured, not assumed free (kg9ny). contextTokens is the TOP-LEVEL context
by design — it is what the scheduling tiers compare against — so a fan-out turn used to look free
however many agents it spawned. AutopilotTurnRecord carries subagentTokens: the sum of
input + cache_read + cache_creation over every model call the turn's subagents made. It is
subtracted from the quota budget in decide() (never from the context budget, which measures a
real window), and cyberine tasks calibrate reports it per task beside contextDelta. Since
2026-09-09 all three drivers record it, as a per-turn delta read from Claude Code's subagent
transcripts — ~/.claude/projects/<slug>/<sessionId>/subagents/agent-<id>.jsonl
(src/autopilot/subagentUsage.ts, guarded by npm run selftest:subagent-usage). The earlier
reader summed isSidechain records in the PARENT transcript and read 0 on every real run: on
Claude Code 2.1.266 a worktree subagent writes no record there at all.
The first live figure, measured on a 3-task fixture board with --batch 2 and two haiku agents
each writing one file: 1,093,284 tokens under --driver bg and 1,532,923 under print
(most of the latter were permission-denial retries) against an orchestrating context of ~50k —
two trivial tasks cost 20-30x the session that dispatched them. The full write-up is
.local/measurements/batch-subagent-tokens-2026-09-09.md.
Every driver now passes --dangerously-skip-permissions. Until 2026-09-09 only stream did;
print (the foreground default) and bg carried just --permission-prompts none, so under a
global defaultMode: auto every git commit, npm run and cd ... && git status in the driven
session and its worktree agents was auto-denied with "no approval surface", and the 2026-09-08
real runs committed only because --bg defaults to stream. The flag is also carried on a
--bg --resume follow-up so a resumed session cannot fall back to prompting.
The board wins over a NO_RUNNABLE reply (k488f). The sentinel is model output; the board is
on disk. When a driven session answers NO_RUNNABLE while an exec: agent task is still runnable
(other than ones that same reply finished with a DONE <id> line), the loop records a
sentinel-contradicted decision event naming the ids and continues with the normal board decision
instead of ending the run. A second contradiction in the same session hands off, so a session that
keeps refusing is replaced by a fresh bootstrap rather than nudged forever. Found on the 2026-09-09
fixture run, where a haiku orchestrator blocked by permission denials answered NO_RUNNABLE with a
task still [todo] and the run ended "drained" with nothing committed.
--batch N refuses --driver bg (k0x80). Each bg turn is a claude --bg --resume <sid>, and
background agents spawned in the previous turn come back as "checkpointed for the background fork
but could not be resumed", after which the orchestrator idles to the stall kill (measured
2026-09-09, run c8646c81: turn 4 spent 20 minutes on nothing). So cyberine autopilot --batch 2 --driver bg exits 2 at parse time with a reason naming the incompatibility — --dry-run included —
and fan-out goes through --driver stream (one steerable child per session; the resume that kills
the agents never happens there). npm run selftest:autopilot-batch-bg-refusal pins it. Lifting the
refusal means teaching the bg driver to wait for the session's live background agents before it
resumes; that is the deferred half.
Known Limitations and Deferred Work. The area:-collision rule could not be discriminated on this repo's own board
either: exactly ONE runnable task carries area: autopilot, so the plan satisfying "never puts two
together" is trivially true there and the rule is only exercised by the smoke. The file rule reads
do: PROSE, so a task that touches a file without naming it is invisible to it — worktree
isolation plus the merge-and-verify step is what actually catches that, not the roster. And
--batch composes with --driver stream/bg only as far as the model tier allows: a batch whose
tasks declare different tiers still leads on the lead's tier, since a session carries one
--model.
Drafting the next batch when the board drains (kwzd6). The chat -> converge -> autopilot loop
used to break at exactly one point: the board runs out of agent-runnable work, autopilot exits, and
the operator has to converge the next batch by hand before anything can run again.
--on-drained brainstorm closes that gap with ONE bounded session:
cyberine autopilot --on-drained brainstorm --brainstorm-max 3It fires when decide() returns no-runnable — at session open or after a session finished the
last task — and it drafts, it does not decide. The prompt (prompts.ts v1.2.0) fixes the evidence
list so two brainstorms on the same board read the same inputs: docs/FEATURES.md (what already
shipped), every Known Limitations and Deferred Work block in this file (boundaries the authors
already named), .local/vibe/ goal files including their ## Lessons, the board with its blocker
reasons, git log --since="7 days ago", and the newest handoff. Each draft is written with
cyberine tasks add ... --status backlog --created-by autopilot-brainstorm --why <the evidence>,
and the session ends with BRAINSTORMED <ids|none>, which the run reports as
brainstormedIds and in its final notification.
The promote gate is the point. Auto-promotion would let the loop draft work for itself and burn
the operator's quota in a closed circle, so promoting a draft to todo stays a human act. Three
layers, and the middle one was added after an adversarial review showed the gate had been
prompt-deep only:
- The prompt forbids
cyberine tasks statusoutright and requires--status backlogon every add. This is instruction text, and holds only while the model obeys it. CYBERINE_TASKS_NO_PROMOTE=1, set bycyberine autopilotin the brainstorm session's spawn environment ONLY. Under itcyberine tasksREFUSES any status oftodoordoing— fromtasks status, fromtasks add --status todo, and fromtasks addwith the flag omitted (the default istodo, which was the easiest bypass). Every other caller, including the operator's own shell and an ordinary driven task session, is untouched.WRITABLE_STATUSEScapstasks add --statusattodo|backlogfor every caller, so no new block can be created asdoing,doneorforbidden(that last is a ToS tombstone in this manifest, see T42-T44).
Before layer 2, a drafting agent could promote by omitting a flag, and the "never promotes" claim rested on the prompt alone.
Bounded four ways: one session per invocation, --brainstorm-max drafts (default 5),
--brainstorm-max-turns worth of turns (default 5, then it stops even without the sentinel), and
the same wall-clock cap every other turn gets. A spent budget or a STOP file refuses the session
outright — drafting is a spend, and the budget is a spend limit. No handoff is written for a
drafting session, and an autopilot:decision event with phase: brainstorm-open records the
decision with the model and the draft ceiling. --dry-run says whether it would fire and, on a
board that is ALREADY drained, that it would fire now.
Known Limitations and Deferred Work. No live brainstorm run has been made: the behaviour is
pinned by the loop smoke against a scripted driver, and the 7d subscription window stood at 79%
when this shipped, so the "one real run on a drained fixture board produces drafts under a bounded
cost recorded in autopilot.jsonl" half of the task is NOT done. The draft QUALITY is therefore also
unmeasured — the prompt names its evidence, but whether a real session cites it faithfully has not
been observed. --brainstorm-max-turns has no CLI flag yet (it is an AutopilotConfig field the
smoke drives); the default of 5 is what a real run gets. And the loop reads the ids only from the
sentinel line, so a session that writes tasks and then fails to emit the line reports zero drafts
while the board really did gain them — cyberine tasks --all is the check.
Audit. Every run is a normal RunStore run (workflowName: 'autopilot'), so cyberine agents
and the web dashboard list it like any other. events.jsonl carries autopilot:decision (one per
policy call: phases session-open, turn, stop-file, budget, handoff-plan) and autopilot:session
(one per session, with turns, tokens, cost and the handoff path), each with the payload as a JSON
detail. The session driver's own per-turn records land in the same run dir as autopilot.jsonl,
stamped with the taskId the turn was working on — which is exactly what cyberine tasks calibrate
reads back later.
Notifications. Every boundary fires THREE channels, each degrading independently: the Cyberine
Stop hook (~/.cyberine/hooks.json / .cyberine/hooks.json), CYBERINE_ALERT_WEBHOOK_URL, and —
since k6p4c — a first-party Telegram channel configured by env only: CYBERINE_TELEGRAM_BOT_TOKEN +
CYBERINE_TELEGRAM_CHAT_ID (optional CYBERINE_TELEGRAM_THREAD_ID for a forum topic,
CYBERINE_TELEGRAM_PARSE_MODE, and CYBERINE_TELEGRAM_API_BASE for a stub in tests). The Telegram
channel posts the already-formatted message as text to sendMessage (truncated below 4096 with an
ellipsis, no parse mode by default because handoff paths carry _ and *), through the same bounded
retry as the webhook (retry on 5xx/429 only, never on 400), and the token never reaches a log line
or a thrown error. Every message names the session index, the task id, the fill percent, the tokens,
the cost so far, and the handoff path when there is one. A channel that is not configured degrades to
a logged line, never a crash — --dry-run prints a telegram line saying whether it is configured.
Both Telegram paths can coexist; a machine that already wires a Telegram sender through the Stop
hook and ALSO sets the two env vars will get every ping twice, so pick one.
Spawn environment — the subscription wins over a metered key. src/core/config.ts loads the
repo's .env into process.env, and Claude Code treats a present ANTHROPIC_API_KEY as THE auth
source, ignoring the claude.ai login. So autopilot builds one environment for everything it spawns
(the driven claude session AND the handoff writer — src/autopilot/spawnEnv.ts) and
removes the metered key through the same meteredEnvToScrubForSub rule CliAgentAdapter already
applies: scrubbed only when a subscription login is actually present, kept when the key is the sole
auth. --backend codex additionally scrubs OPENAI_API_KEY for the writer. --dry-run prints the
env scrub line so the choice is visible before anything starts. The first real run on 2026-09-06
is why this exists: with the key inherited, every turn came back Credit balance is too low in
~630ms with zero tokens, and the handoff died of the same key.
Error-turn breaker. A turn whose result event carries is_error counts toward a streak; three
consecutive error turns end the session as failed (exit 1, one autopilot-failed notification
naming the CLI's error text, NO handoff attempt — there is nothing to hand off and the writer would
hit the same fault). Any successful turn resets the streak. Without it the same 2026-09-06 run spun
to the 40-turn ceiling in ten minutes and then tried to hand off an empty session.
Quota pause (kp5x7). A turn is classified BEFORE it counts toward the breaker:
classifyTurnFailure() calls it a QUOTA turn when the turn's rate_limit_event says rejected or
its result text matches the rate-limit vocabulary classifyQuotaError already knows (429, too many requests, rate limit, ...). A quota turn never feeds the 3-strike streak. What happens next
is --on-quota: wait (default) sends ONE autopilot-paused notification naming the reset time
(hook tag [paused]), then sleeps until resetsAt plus up to a minute of jitter when the event
reported one, else backs off 5, 10, 20, 30, 30... minutes — never busy-polling, every probe is a
request against the same exhausted window; the sleep runs in 60-second slices so the STOP file is
honoured within a minute; then it spends one sonnet probe turn ("Reply with exactly: ok") on the
SAME session and, once the probe comes back allowed, resumes the interrupted task with the normal
nudge prompt (streak reset). A probe that fails for a NON-quota reason (auth, network) is an
error turn and goes to the breaker as before. stop hands off immediately and exits 0 with reason
quota. api re-creates the driver on the same session id with the scrubbed ANTHROPIC_API_KEY
restored; run.json records autopilotMeteredFromTurn so cyberine tasks calibrate and the
billing view can separate subscription cost from metered cost. --max-hours is the outer deadline
of the pause: when it expires while paused the session hands off (a completed turn always exists by
then) and the run exits limit with a message naming the reset time; the wait is clamped to the
remaining budget so the loop never sleeps past its own deadline.
Per-task model tier (ke8hm, k8bm4). Every board task may carry model: haiku | sonnet | opus | fable
(cyberine tasks add --model, the tier the subagent-model-tier rubric judged it needs). Precedence,
resolved in one place (src/autopilot/modelTier.ts) that both the plan and the run call: --force-model
pins --model for every task; otherwise the task's own tier wins; otherwise --model; otherwise the
CLI's own default answers. fable is a real alias on the installed CLI (claude --help, v2.1.263).
TurnResult.model and the autopilot.jsonl model column report what actually answered, so a tier
the CLI mapped elsewhere is visible per turn. The quota probe turn asks for sonnet.
How the tier reaches the model depends on the driver, and the difference is the operator's cost
lever (k8bm4). A session is SPAWNED with the tier of the task it opens — that is the only moment
stream and bg can be given a model at all, because a streaming session carries the one --model
it was spawned with and a background job replays the options it was created with. So:
--driver printspawns a fresh child per turn and switches model per task inside one session (claude -p --resume <sid> --model <m>accepts a different model on a resumed session, so no context is thrown away; keep5 case 11 asserts the session id stays bound across the switch).--driver stream/--driver bgcarry ONE model per session. When the next task's effective model differs, the session ends at that task boundary and the next session opens on the new tier. The split is recorded inrun.json's decision events asphase: "model-tier-boundary"naming both models, and it consumes one--max-sessionsslot.
--dry-run states the cost up front: a task models line naming, per runnable task IN THE ORDER
THE POLICY WILL TAKE THEM, the model it will get and why (task model:, --model,
--force-model, cli default), plus a model boundaries line that for a one-model-per-session
driver lists the tier groups and how many of the session budget they need. --json carries the
same as taskModels[] and modelBoundaries. Before k8bm4 the plan printed the intended tier and
the runtime discarded it: run 3fed0a2b ran all six of its model: sonnet tasks on the CLI default
(opus) under --driver bg, and before ke8hm run f4249d5d took kxvc9 (drafted model: opus) in a
--model sonnet session.
Per-task reasoning effort (k71d0). A task's reasoning: low | medium | high | xhigh | max
(cyberine tasks add --reasoning, exec: agent only) reaches the session as claude --effort <level>,
the same five values the installed CLI accepts. It travels exactly like the model tier: the print driver
passes it per turn, stream and bg pass it once at spawn, and a change of EITHER the model or the effort
ends a one-model-per-session session at the task boundary (the model-tier-boundary event now also names
sessionEffort / nextEffort). --force-model pins the model only; the task's effort still applies. A task
with no reasoning: passes no --effort, and an unset effort never shares a session with a set one.
cyberine tasks set k1abc --reasoning high # the next autopilot session for k1abc spawns with --effort high
cyberine autopilot --dry-run # task models shows "k1abc sonnet effort high (task model:)"--dry-run's task models and model boundaries lines print the effort next to the model, and --json
adds effort to every taskModels[] entry and every modelBoundaries.groups[] entry.
Known limitations: there is no autopilot-wide --reasoning flag (the board field is the lever), and the
batch roster (--batch) does not group by effort, the same as it does not group by model today.
Cost figures are API-list-price-equivalent, not money charged (k8bm4). costUsd in
autopilot.jsonl, the per-session lines the wrapper prints, and the cost $… field in every
notification all come from the CLI's total_cost_usd, which prices the turn's tokens at API list
rate. A subscription login is not billed by it. Run 3fed0a2b printed cost $212.03 and was read as
an invoice; the number is now labelled everywhere it appears.
Session names. Every driven turn is spawned with --name cyberine-ap-<runId8>-s<N> (N = the
session index). --name applies in print mode: the process registers under that name in
~/.claude/sessions/<pid>.json (so ListAgents and peer SendMessage see cyberine-ap-0dec102d-s2
instead of a derived ai-agent-solution-e2), and the CLI writes a custom-title record into the
session transcript — which is where amemoria reads the label for its own Telegram pings, so the
handoff pings say [cyberine-ap-0dec102d-s2] instead of [unnamed]. The shape deliberately ends in
-s<N>: the operator's cc_session lineage treats _<digits> and -<3+ digits> tails as its own
and would misroute peer messages to a duplicate address. A session driven by the DEFAULT print
driver does NOT appear in the Claude Code fleet view (claude agents): that view renders
daemon-supervised --bg jobs only, and --bg is incompatible with --print/stream-json.
--driver bg (below) is the driver that does put it there.
--bg carries the driver you asked for (k378p). --bg wraps the run in a claude --bg --exec
fleet row that re-invokes this checkout with --attached. Until 2026-09-08 that wrapper stripped any
--driver the operator passed and appended --driver stream unconditionally, so
cyberine autopilot --bg --driver bg silently ran the stream driver. The chosen driver now rides
into the row; stream remains the DEFAULT under --bg (nothing else steers a PTY row), and print
passes through too — it drives fine there, it simply has no inject() seam, which the attached
runtime already says once in the log. The launch output names it, so the choice is auditable
without reading the row:
runId: aca742b5-6626-4eba-a10b-74d605d7081c
fleet: a33e99b6
driver: bg
open: claude attach cyberine-ap-aca742b5-s1With --driver bg the driven session is its own attachable job, so claude agents shows TWO rows —
the wrapper cyberine-ap-<runId8> and the session cyberine-ap-<runId8>-s1. With --driver stream
there is one row and the session is read with claude --resume <sessionId> --fork-session (the id
comes from cyberine agents or autopilot.jsonl); forking rather than resuming leaves the live
session's own transcript alone. Measured live 2026-09-08 on an isolated fixture board:
cyberine autopilot --bg --driver bg --max-sessions 1 --max-hours 1 produced fleet row 4ab7be86
and session row cyberine-ap-7b023d8c-s1 (id b92ae4a8, session id
b92ae4a8-cd9b-498f-9d04-5596af4ced13), whose attached log opens driver=bg; an earlier run of the
same shape was re-opened afterwards with claude -p --resume <sessionId>, so the row is a real
resumable session and not just a name. Full transcript, including two failures that were fixture
artefacts rather than product faults, in .local/measurements/k378p-bg-driver-passthrough.md.
claude attach itself needs a TTY and stays the operator's step.
Measurement. contextTokens is the LAST model call's context read from its assistant event,
not result.usage — the result's usage is the turn TOTAL summed over every call, which on the
first real run reported 9.8M tokens for a 195k session and drove a 986% "fill" handoff at what
was really 19.6%. autopilot.jsonl rows now also carry modelCalls and contextSource; the
session-driver section below has the probe numbers.
Run recipe for the first real run. Use a repo that has run cyberine init --rules
(.cyberine/config.json carries rules.applied === true), and
a board whose runnable tasks carry exec: agent and effort:. Start with cyberine autopilot --dry-run and read the chosen task, the excluded list and the lints; then run it bounded
(cyberine autopilot --max-sessions 2 --max-hours 1) and expect one Telegram or webhook message per
session boundary. The driven agent does its own commits under the repo's own rules; autopilot never
runs a git mutation itself.
Verify:
npm run selftest:autopilot-loopCases (a)-(f) drive the real state machine with a scripted driver, a mutable fake board, a fake
handoff, a collecting notifier and a fake clock — one task ending in DONE with the board drained,
a first task ending at 70% fill so the session hands off with the second task as nextTaskId and
the next session bootstraps on that handoff path, a STOP file appearing before turn 2, the
NO_RUNNABLE sentinel, --max-sessions 1 reaching the limit, and a handoff that times out (exit 1,
FAIL notification, session event still appended). Cases (g)-(h) spawn the real CLI in a throwaway
git repo for the rules-not-applied refusal and the --dry-run plan (asserting no session driver was
spawned and no run directory created); case (h2) covers the handoff-writer gate under --dry-run — a
CYBERINE_HANDOFF_WRITER_BIN pointing at a missing file exits 2, names both that path and the builtin
path, and writes NOTHING to stdout, with a positive control that the same command against an
executable stub prints the plan and carries handoffWriter: { bin, ok, writer } in --json. It also pins the
resolver's truth table and greps src/ to assert the builtin path is built in exactly one file
(positive controls: re-wrapping the gate in if (!dryRun) reddens four assertions; adding the
literal to a second file reddens the drift guard). Case (i) drives the real handoff bridge against a stub
binary that refuses. Case (j) feeds eight scripted error turns and asserts the breaker stops at
three with no handoff, then that two errors followed by a good turn reset the streak; with the
breaker threshold lifted the same script runs to the 40-turn ceiling (positive control). Case (k)
covers the spawn environment as a pure function: the driver key is scrubbed, --backend codex adds
OPENAI_API_KEY, everything else and the injected run id survive, and an empty scrub list (no
subscription found) leaves the key in place. Positive control: replacing the turn verdict's done with a literal false
fails case (b) — the finished session then spends a finish-then-handoff turn re-nudging the first
task and plans the handoff with the wrong nextTaskId; restoring it is green again.
Quota (kxvc9). Autopilot runs on the operator's Claude Code SUBSCRIPTION (the driven claude
sees no ANTHROPIC_API_KEY), which has a rolling 5-hour window and a weekly cap. There is no
headless "remaining usage" readout, so the signal is assembled from the CLI's own
rate_limit_event (see docs/providers/claude-code-cli.md): every real --driver print and
--driver stream turn carries one and the
driver records it as rateLimit on the autopilot.jsonl row (status, resetsAt in epoch
seconds, rateLimitType, fiveHourUtilization, sevenDayUtilization). A --driver bg turn
carries NONE and instead re-runs the probe at its own boundaries — see the --driver bg section
below (kp5m0). Before the first session a
cheap probe turn refreshes it; the newest reading is cached in BOTH .local/autopilot/quota.json
and $CYBERINE_HOME/quota/claude-<accountKey>.json. Since 2026-09-11 (kh6hf) that global copy is
keyed by ACCOUNT, not shared by all of them: accountKey is a 12-hex fingerprint of the
CLAUDE_CODE_OAUTH_TOKEN in the spawn env when a seat is pinned, else of the logged-in
oauthAccount in ~/.claude.json (CLAUDE_CONFIG_DIR honoured) — the raw token, uuid and email
are hashed, never stored. A reading is served only to the account that produced it, and a reading
whose account cannot be identified (no key derivable, or a pre-kh6hf record) is treated as NO
reading rather than as fact, because one global file previously served every seat on the machine
and a run under one account was sized from another's number. Percentages are also DIRECTED: the
line reads 7d 39% used, 61% left (resets in 5d9h), since utilization is the CONSUMED fraction
and an undirected 39% was read as remaining at least once. And a reading older than 30 minutes
(QUOTA_PRESENTATION_MAX_AGE_MS) is refused at presentation instead of being rendered with a
freshly-recomputed countdown: the line reads no usable reading: the last one is 1d4h old, past the 30m bound — a real run re-probes on start, and --dry-run withholds it from the sizing policy too,
so it can never advise from a number it will not show. Three effects: (1) --dry-run prints a quota line from the
cache and never probes — and since 2026-09-09 (kg1s7) a cached window whose resetsAt has already
passed is EXPIRED, not live: the line reads 7d reset since observed (was 79% used) and that window no
longer binds the first verdict (the dry-run used to refuse a fresh window on an 18-hour-old 79%); (2) a real run REFUSES (exit 2, reset time in the message) when the last
known status is rejected and resetsAt is still in the future; (3) boundaryPolicy.decide()
takes the most-binding window's utilization as a SECOND budget in windowTokens units —
(1 - utilization) * windowTokens - handoffReserve — so a task whose calibrated estimate does not
fit the remaining quota is not started even when the context alone would afford it, and the reason
string names the quota bind. The number is re-read after every print/stream turn from that
turn's own event, and re-PROBED at a bg run's session and turn boundaries, so under every driver
the policy sees the window drain during the run. cyberine autopilot --dry-run prints which of the
two a given --driver gets as its quota signal line (and quotaSignal under --json). It is a heuristic, not a token-accurate
conversion (Anthropic's limit is a usage quota, not a token count); it fails toward caution, and a
declined task simply runs once resetsAt passes. allowed_warning never hard-stops on its own.
The Codex backend exposes no comparable event: its quota is "unknown", never guessed. Mid-run
exhaustion is kp5x7's pause, not this.
Known Limitations and Deferred Work.
- claude-only driver.
sessionDriver.tsdrivesclaudeand nothing else, so--modelis a Claude Code model alias/id.--backendonly picks which backend writes the HANDOFF, not who does the work. - No mid-turn interruption. Turns are atomic by construction (the measurement only exists once
the CLI emits its result event), so the STOP file, the emergency tier and
--max-hoursall take effect at the next turn boundary, never inside one.--max-hourstherefore bounds when work is STARTED, not when the process exits: a turn in flight when the budget expires runs to completion, and an unfinished task is granted one finish turn before the handoff. - Needs the Cyberine rules applied (
cyberine init --rules). That is a refusal, not a fallback. There is a built-in handoff writer (since k73tg, and the only one since 2026-09-30), so no external skill is needed. - The live proof used a STUB writer (then an
amemoriastub). Running the real offload would have written into the operator's own memory repo, so the shipped evidence covers the loop, the board flip, the stub handoff and the second session's bootstrap. The first real run (2026-09-06) then found the two faults the stub could not: the inheritedANTHROPIC_API_KEYand the absence of an error-turn breaker, both fixed above. - The breaker threshold is fixed at three. There is no flag or env var for it; a CLI-level error is not something more patience fixes.
- A mid-turn quota hit has not been observed live. The classifier handles both shapes the CLI
can produce — a
rate_limit_eventwithstatus: rejected, or anis_errorresult whose text carries the rate-limit vocabulary — but whether a limit reached MID-turn ends that turn withis_error(it should: the API call fails) is recorded indocs/providers/claude-code-cli.mdas unverified until a real window runs out under autopilot. The probe model issonnet; on a plan whose binding window is model-specific (seven_day_opus) a sonnet probe can still come back allowed while the session's own model is limited, in which case the resumed turn is classified quota again and the pause simply continues. - A killed turn (stall or cap) gets ONE wrap-up turn, then the session hands off (ksn4d).
The driver still adopts no session id from a turn that produced no result event, but it
remembers the id
system/initannounced asobservedSessionId(on thetimeouterror and viadriver.observedSessionId()), and the loop resumes THAT session once withbuildWrapUpPrompt(prompt v1.1.0): stop working, commit coherent work under the repo's rules orgit stash push -u -m "autopilot partial <id>"where a commit is not pre-authorized, replyDONE <id>only if the task is verifiably complete, else one status line; no new work. The wrap-up is capped at 5 minutes regardless of--turn-timeout, is recorded inautopilot.jsonlwithwrapUp: true(socyberine tasks calibratecan exclude it), and its result feeds the normal handoff. A second kill on the wrap-up itself, a kill beforesystem/initarrived, or a STOP file present at that moment still ends the sessionfailedexactly as before. Cyberine itself never runs a git mutation — the agent does, under its own rules. (On 2026-09-06 session 2 lost a real 20-minutekmq07turn this way, leaving 271 uncommitted lines the operator had to stash by hand.) Since khgxt a turn is no longer killed for running long: it is killed only when BOTH liveness signals go quiet for--stall-timeout— no stream-json event on stdout AND no descendant process that is younger than the window or consumed CPU since the last 15 s poll (a sweep or build inside a Bash tool call emits nothing for minutes while its subprocess is busy; a stuckssh/dockerconsumes nothing, which is what separates it). MCP servers the CLI spawns at turn start are excluded from the "young" test so they cannot keep a hung turn alive.--turn-timeoutsurvives only as an opt-in cap. Everyautopilot.jsonlrow carriesidleMsMax, the longest quiet gap the turn showed, socyberine tasks calibratecan show how close healthy turns get to the threshold before anyone tunes it. - A whole task in one turn means one decision point per task. The boundary policy is asked
after every turn, but the agent's own turn ends when the task ends, so
continue/nudgemostly fire at task boundaries anyway. That matches the "decide at TASK boundaries" rule, but it also means the fill measured mid-task is never seen — the emergency tier can only react after the fact. - A writer that dies AFTER
offloadreturned 0 surfaces in seconds — IF the writer writes its status marker (kcmnj). The bridge still watches for a FILE as the success signal. It now also reads thelog: <path>line the offload prints on stdout and watches for<path>.status.json({ "exit": N }) next to it: a non-zero exit with no handoff file fails at once with a typedHandoffWriterDiedErrornaming the writer's own log; exit 0 with no file fails after two poll intervals with the same class. The amemoria skill wrote that marker since 2026-09-07 (~/.claudee03c3d02:{"exit":$EXIT}to<log>.status.jsonright afterEXIT=$?in the detached wrapper), so a writer that dies is seen within one poll instead of the 10-minuteHandoffTimeoutErrorthe 2026-09-06 run paid (exit 1 after 6 s, ten idle minutes). A writer without the marker degrades to that timeout, and the deterministic builtin does not write one. The bridge never parses the writer's log lines — that format belongs to the writer. - The driven agent's commits are governed by the repo's rules, not by autopilot. Autopilot never stages, commits, pushes or merges; it only starts sessions and writes its own audit.
- A runaway session is capped by turn count, not by anything smarter. A driven agent that never
says
DONEand whose context never grows is handed off after 40 turns (DEFAULT_MAX_TURNS_PER_SESSION), which is a backstop rather than an operating limit. --bgre-spawns the command throughnpx tsxand prints the run id it pre-allocated (CYBERINE_AUTOPILOT_RUN_ID); there is nocyberine autopilot status— read the log path it printed, orcyberine agents. Since kwx2r that re-spawn is wrapped in aclaude --bg --execfleet row by default, soclaude agentsshows it too — see "Steer it from the fleet" below.
Session driver — --driver print | stream | bg (k59px, kddqh). print is the default and is unchanged:
one claude -p [--resume <sid>] child per turn, stdin closed, the turn's result event measured
and appended to autopilot.jsonl. --driver stream keeps every one of those numbers and moves the
process boundary: ONE child per SESSION, spawned as
claude [--resume <sid>] -p \
--input-format stream-json --output-format stream-json --verbose \
--permission-prompts none --dangerously-skip-permissions \
[--model <model>] --name cyberine-ap-<runId8>-s<N>with stdin PIPED — the only Cyberine spawn that keeps stdin open. Each loop prompt is written as one
{"type":"user","message":{"role":"user","content":"..."}} line, and EVERY top-level result the
child emits becomes one AutopilotTurnRecord with the same contextTokens rule (the LAST top-level
assistant event's input + cache_read + cache_creation, scoped to that turn's slice of the
stream). There is never a positional prompt in the argv, because --tools / --allowedTools /
--add-dir are variadic and would swallow one; --verbose is mandatory, the CLI refuses stream-json
output without it.
Steer it with SendMessage. A streaming child registers in ~/.claude/sessions/<pid>.json as
kind: interactive, entrypoint: sdk-cli, WITH a messagingSocketPath — so any Claude Code session
on the same machine can address it by the --name autopilot already gives it and inject a turn while
the loop is between turns:
cyberine autopilot --driver stream --max-sessions 1 --model haiku
# from any other Claude Code session on this machine:
# ListAgents -> find the row named cyberine-ap-<runId8>-s1
# SendMessage to that name -> lands as a new user turn in the SAME sessionA result the loop did not ask for — a peer SendMessage, or the library-level inject(text) — is
recorded as an OPERATOR turn: origin: "operator", resumed: true, and deliberately NO taskId, so
cyberine tasks calibrate treats it as a group break rather than as effort spent on the task. Loop
turns carry origin: "loop". The field is additive and optional; print records do not carry it at
all. Measured live on 2026-09-08 (claude 2.1.263, --model haiku, throwaway repo): one process,
session id stable, contextTokens 64009 (loop) -> 64269 (peer SendMessage, origin: operator,
promptChars: 0) -> 64494 (loop), and the session deregistered from ~/.claude/sessions/ on
close().
Known Limitations and Deferred Work (--driver stream)
printis the default only for a foreground run. Under--bgthe default isstream(kwx2r, k378p), and it has driven a full board unattended: run3fed0a2bon 2026-09-08 worked six tasks in one session on this driver.- No CLI surface for
inject()yet. It is a library seam on the driver (and an optionalinject?on theAutopilotDriverport); the operator-facing channel today isSendMessagefrom another Claude Code session. Acyberine autopilot replycommand is not built. - Per-turn
--modelis ignored. A streaming child carries the ONE--modelit was spawned with; a different per-turn model (a task'smodel:tier, or thesonnetquota probe) is logged once on stderr and otherwise ignored. Consequence: under--driver streamthe mid-run quota probe runs on the session's own model instead ofsonnet. Changing a live session's model would mean hand-rolling the CLI's privatecontrol_requestprotocol, which this deliberately does not do. - It runs with
--dangerously-skip-permissions, which--driver printdoes not: a streaming child registers as an INTERACTIVE session, so--permission-prompts nonealone would auto-DENY every prompt the operator's settings do not already allow. Both flags are passed. - The streaming child itself is not a row in the
claude agentsfleet TABLE. That view renders daemon-supervised--bgjobs; an interactive/sdk-clisession appears inclaude agents --jsonandListAgentsonly. Under--bgthe WRAPPER is a row (cyberine-ap-<runId8>, kwx2r) and peek replies reach the child through it;--driver bgis what makes the session itself a row. - A stalled turn kills the child.
--stall-timeout/--turn-timeoutmean exactly what they do underprint, measured from the stdin write to the matchingresult. The loop's one wrap-up turn then respawns a child with--resume <observed session id>, which re-plays the transcript — the context that costs is real, not free.
Seeing an autopilot run's progress — terminal activity lines and the hub row (kyqm6)
Until this change a cyberine autopilot --engine claude turn printed nothing while it worked (a 17 minute first turn was silent), and the hub showed the run as Cli 0/0 with empty TOKENS and COST. Both sides now read what the run already produces.
Terminal. With the default --driver print, the driver reads the turn's stream-json as it arrives and prints one dim line per burst of tool use, at most one every 15 seconds, prefixed with the session and the task, for example [autopilot] session 1 task kb8qh · Edit src/tui/app.tsx · 34 tools · 6m12s. A tool call that keeps the stream silent (a long test run) gets a still on Bash npm run typecheck (46s in this call) line every 45 seconds, so a turn that is running tools is never quiet for more than about a minute. Only the tool name and one short target are shown: a file path (relative to the repo), the first line of a shell command, a search pattern, or a URL host. Every target goes through the secret redaction used for worker output plus a pass that masks key-shaped tokens and URL credentials before it is clipped, and an unknown or MCP tool shows its name alone. A full tool input is never printed. Under --json the lines go to stderr as the other [autopilot] lines do.
Hub row. cyberine autopilot now writes autopilot (progress and usage) onto the run's run.json after every session and turn decision. A live row reads autopilot: session 1 · task kb8qh, a finished one autopilot: 9 tasks done (an errored or cancelled one appends the reason), WORKFLOW reads Pilot instead of Cli, and STEPS shows the tasks finished (9✓) instead of 0/0. TOKENS and COST are filled, and the header totals include them: tokens are fresh input plus cache-creation plus output tokens, cache re-reads are kept out of the figure (they dwarf everything else and are not new work), and cost is the API-list-price-equivalent of those tokens, not money charged, exactly as the run's final summary line labels it. Probe turns are excluded so the run total equals the summary line's cost. AGE is unchanged: it is the time since the run's last update.
Detail. The wide hub's preview pane and the drill-in detail view (Enter on the run) show a timeline built from the run's autopilot:decision and autopilot:session events: session opens, each turn with the task it finished and the next one, the context fill, how long the turn took, and the session end with its outcome and cost, plus a usage block (tokens, API-list-price-equivalent cost, model time, cache reads). The events file is tailed by byte offset, only while the run is live, so a poll that finds it unchanged reads nothing.
cyberine autopilot --engine claude --max-sessions 2 --max-hours 1
cyberine agentsKnown Limitations and Deferred Work. The activity lines exist for --driver print only; the stream and bg drivers stay silent between turns, as does --engine fleet, which also writes no progress record. A run recorded before this change has no autopilot field, so its row keeps the end-reason text with Pilot and - in the WORKFLOW and STEPS cells and empty TOKENS and COST; its detail view still shows the timeline, because the events were always recorded, but no usage block. The token figure is not the context size the final summary prints as context N (that is the last call's prompt, a per-session peak), so the two numbers differ on purpose. Tokens and cost update at each turn boundary, not during a turn. A batch turn counts a task as finished when the loop's own decision says so, the same rule as the summary.
Fleet-visible driver — cyberine autopilot --driver bg (kddqh)
--driver bg swaps the session driver (and nothing else: the loop, the boundary policy, the handoff
bridge, the notifications and every autopilot.jsonl consumer are untouched; --driver print stays
the default and behaves exactly as before). Instead of a fresh claude -p --resume child per turn,
it spawns ONE daemon-supervised background job and steers it:
- Spawn, once per session:
claude "<bootstrap prompt>" --bg --name cyberine-ap-<runId8>-s<N> [--model <m>] --permission-prompts none. The prompt is the FIRST argv element on purpose — several Claude Code flags are variadic (--tools,--add-dir), and a trailing positional gets swallowed as one of their values.--add-diris not passed: the job already runs in the repo root. - Every follow-up:
claude stop <jobId>, thenclaude "<prompt>" --bg --resume <sessionId>with no other flags. Measured on claude 2.1.263: that exact form wakes the SAME job under the same short id and reuses its saved options; resuming a job that is still alive, or passing ANY flag, makes the CLI start a COPY and printnote: session <id> is already running in the background, so this started a copy as <newid>.. The driver compares the id the resume printed against the id it owns; on a mismatch it stops AND removes the accidental copy and fails the turn withBgSessionDriverErrorcodeforked-copyrather than driving a session nobody is watching. - The stop must SETTLE before the resume, and
claude stopreturning is not that signal. Measured 2026-09-07:claude stop <id>returns, and the job's row inclaude agents --jsonloses itspidfield, roughly 0.6 s BEFORE the OS process actually exits — and the CLI's "already running" test sees that still-live process, so a resume fired immediately forks a copy every time (it did, twice, on the first live run of this driver). So the driver reads the row'spidBEFORE stopping, then waits for that pid to actually die (kill(pid, 0), bounded bystopTimeoutMs, default 60 s) plus astopSettleMsgrace (default 1 s) before resuming. That is what makes the follow-up land on the same id instead of a copy. - Turn boundaries come from
claude agents --json --all(stategoesworking->done/blocked) plus the session transcript's ownsystem/turn_durationrecord. The driver never injects while the job is mid-turn. - Measurement reads
~/.claude/projects/<cwd-slug>/<sessionId>.jsonl— the same invariant the print driver holds: the context is the LAST top-levelassistantrecord'sinput + cache_read + cache_creation, never a sum over the turn's calls, and sidechain (subagent) records are skipped. The cwd isrealpath-resolved first, because Claude Code names that folder from the process's own resolved cwd (on macOS/var/...is/private/var/...). The folder name is the harness's slug (every non-alphanumeric char becomes-) as a first guess; since kp95c the transcript is found by session id across~/.claude/projects/*/when that guess misses (a slug over 200 chars carries an undocumented hash suffix), and the subagent directory is taken from the folder the transcript was found in (src/autopilot/claudeTranscriptPath.ts, guarded bynpm run selftest:claude-transcript-path). Field reading goes throughreadClaudeTurnTokensinsrc/adapters/claudeStreamJson.ts, so the two drivers cannot drift on it. - Turns the loop did not originate count. A peek reply from the fleet, a claude.ai Remote Control
reply and a peer
SendMessageall land in the same transcript as ordinary prompt records. Each complete one is measured, appended toautopilot.jsonlwithorigin: 'operator'(driven turns carryorigin: 'loop'), counted byturnCount(), and its answer text is folded into theresultTextthe loop runs itsDONE <taskId>check over. close()runsclaude stop <jobId>so an aborted autopilot never leaves an agent running unattended. It deliberately does NOTclaude rmthe job: the conversation stays openable withclaude attach <id>and resumable withclaude --resume <sessionId>.- A session boundary (kj8y2, measured 2026-09-09):
-s2really is a second, independent fleet row, and-s1really is stopped, not left running. A two-fixture-task run forced across a session boundary (one task onhaiku, the next onsonnet, so the model-tier split ends session 1 early) showedcyberine-ap-<run8>-s1's row flip tostate: "stopped"with itspidfield gone in the SAME poll the handoff log line appears, thencyberine-ap-<run8>-s2appear as a brand-new row (its own fleet id, pid and Claude Code session id) the moment session 2 bootstraps. The handoff writer inherits no process or session state from the-s1job — it is a freshoffloadspawn — only the SEED TEXT the loop builds from session 1's own result (Where: ... last task <id> -- DONE <id>/Next: continue with task <id> on the board), and session 2's actual bootstrap prompt names that handoff file and tells the agent to read it first. Full measurement, including the fixture and theclaude agents --json --alltable per phase:.local/measurements/kj8y2-bg-driver-session-boundary.md.
Run recipe — steer it from the fleet:
# start a bounded run whose session shows up in the fleet view
cyberine autopilot --driver bg --max-sessions 1 --max-hours 1 --model haiku
# in another terminal: the driven session is a real row (name cyberine-ap-<runId8>-s1)
claude agents --json --all | jq '.[] | select(.name | startswith("cyberine-ap-"))'
claude agents # the TUI: arrows to the row, Space to peek, type a reply, Enter
# read what it is doing, or take the terminal over entirely
claude logs <jobId>
claude attach <jobId>
# the reply you typed lands in autopilot.jsonl as an operator turn, with its own measured context
jq -c 'select(.origin=="operator") | {turn,contextTokens,promptChars,resultChars}' \
.local/runs/<runId>/autopilot.jsonl
# kp5m0: the quota reading MOVES across a bg run -- one probe row per refreshed boundary
jq -c 'select(.probe==true) | {turn, u: .rateLimit.fiveHourUtilization, at: .finishedAt}' \
.local/runs/<runId>/autopilot.jsonl
# which driver keeps the reading live, before starting anything
cyberine autopilot --driver bg --dry-run | grep 'quota signal'A reply the fleet cannot deliver right now (the job is stopped between turns) is SAVED by Claude Code and sent as that session's next prompt when it starts again, so a peek reply typed mid-nudge is not lost — it simply arrives one turn later and is measured there.
Known Limitations and Deferred Work (--driver bg).
- No cost. A background job emits no
resultevent, so there is nototal_cost_usd.costUsdisundefinedon every bg turn and theautopilot.jsonlcolumn is absent rather than guessed — the repo's price table models neither cache-read nor cache-creation rates, and a wrong number is worse than none. Token counts, context andmodelCallsare unaffected. - No
rate_limit_eventon a bg turn — the reading is PROBED, not carried (kp5m0). Scanned 2026-09-10 over every transcript under~/.claude/projects: the background session's.jsonlhas no rate-limit record TYPE at all (the stringrate_limit_infoappears there only inside message content), so a bg turn can never carry the reading the way a-por stream turn does. Until kp5m0 that left the boundary policy's quota budget frozen at the pre-run probe for the whole run — the guard that stopped run01d98073at 79% could not fire a second time. The bg driver now re-runs the SAME cheap probe (probeQuota, oneclaude -ponsonnet, ~1 turn of the cheapest model) at every session boundary and at the first turn boundary that finds the last reading older than 10 minutes; a boundary whose reading is under 1 minute old spends nothing, so the preflight probe is never paid for twice. Each refresh writes through the same path a print-driver turn does:.local/autopilot/quota.json+$CYBERINE_HOME/quota/claude.json, and oneprobe: truerow inautopilot.jsonlcarryingrateLimit.fiveHourUtilization. A probe that fails, times out or reports nothing leaves the last reading standing and never fails the turn it precedes. Still deferred: the refresh is at BOUNDARIES, not continuous, so a window that fills inside one long turn is seen at the next boundary rather than the moment it happens; the intervals are constants insrc/autopilot/quotaRefresh.ts, not flags or env vars; a probe runs BEFORE the turn's own wall-clock cap is converted to a deadline, so each refreshed boundary can push the run'smaxHoursbound out by up to the probe's 60 s ceiling (well inside the default 20-minute grace, but it is not free); and--on-quota waitstill classifies a quota-blocked turn from the result TEXT, not from the reading. --modeland--permission-modeare frozen at spawn. A background session replays its own saved options, and passing a flag on resume forks a copy. So the per-taskmodel:tier (ke8hm) cannot switch mid-session on this driver: the whole session runs the model it was spawned with, and a per-turn override is logged under--debugand ignored.autopilot.jsonl'smodelcolumn still reports what actually answered, so the mismatch is visible per turn.--on-quota apicannot swap the key mid-run. The metered restart re-creates the driver with a different environment, but a background job's environment was fixed when the daemon spawned it. The driver re-adopts the existing job by session id instead, which means the run keeps using the subscription; use--driver printwhen a metered fallback matters.- A stop + resume costs a few seconds per nudge (the stop settle alone is ~1.5 s) and re-reads
the session from disk, where the print driver simply spawns the next
-pchild. On a task that needs many short nudges this is the slower driver. - An unapproved project MCP server blocks the job instead of failing it. Measured 2026-09-07: a
first
--bgrun in a directory whose.mcp.jsonhas never been trusted parks atblockedwith1 new MCP server needs approval, and--permission-prompts nonedoes not cover that prompt. The driver reports it as atimeoutnaming the job state and pointing atclaude logs <id>; the operator either approves it once fromclaude attach <id>or spawns with--strict-mcp-config. - A turn that stops mid-way at
blockedis aneeds-inputfailure, not a success (kakt9). Measured 2026-10-02 on Claude Code 2.1.287 with the driver's own argv: a model that callsAskUserQuestionmid-turn leaves the job atblocked(statuswaiting) with noturn_durationrecord, and--permission-prompts nonedoes not answer it. The driver used to settle that unfinished turn as idle and the bg worker reported success; it now fails the turn with codeneeds-input, namingclaude attach <id>. A completed turn whose row happens to readblockedstill returns normally, and an interactiveclaude agentsrow (statenull) carrying the same session id is never adopted on resume. - The private daemon files are never touched.
~/.claude/jobs/<id>/state.json,daemon/roster.jsonand the daemon control socket are read-only-by-inspection here; the driver talks toclaudethe CLI only (--bg,agents --json --all,stop,rm), which is what keeps it on a supported surface.
Verify:
npm run selftest:autopilot-bg-driver
npm run selftest:quota-preflight # section (f) covers the kp5m0 bg quota refreshDrives the real driver against a fake claude that mimics --bg (prints the real banner shape,
registers a job, writes a transcript from a detached worker), agents --json --all, stop and
rm. It pins the argv contract (prompt first; the follow-up is EXACTLY
[prompt, --bg, --resume, <sid>]), that no prompt is ever injected while the job is working, that
the .env scrub reaches the child, that the context is the LAST call and not the turn total (a
subagent record carrying 999999 is proven inert), that an operator turn is recorded with
origin: 'operator' and folded into resultText, that close() stops the job, and that a resume
which forks a copy is stopped, removed and reported instead of driven. Positive control: corrupting
the last TOP-LEVEL assistant usage moves the measured context by exactly the injected amount, while
corrupting the last SIDECHAIN one moves it by nothing.
Steer it from the fleet (cyberine autopilot --bg, kwx2r). --bg no longer just detaches: it
registers the run as a row in the claude agents fleet view, so the operator can watch progress and
type into a running loop from the same place every other background session lives. There is no
opt-in flag — the fleet row is the default and the old detached spawn is the fallback.
cyberine autopilot --bg --max-sessions 2 --model haiku
# runId: 03ccdb0c-f4c1-4e75-9148-f518828fd5c0
# fleet: 8a597539
# log: .local/autopilot/logs/03ccdb0c-....log
# watch: claude agents
# stop: claude stop 8a597539
claude agents # the row is named cyberine-ap-<runId8>, kind: background
claude attach 8a597539 # type a line -> it becomes an operator turn in the live session
claude stop 8a597539 # SIGTERM: writes the stop record, then exitsWhat --bg actually does: it probes claude --version against the --exec row in
CLI_FLAG_COMPAT (src/adapters/CliAgentAdapter.ts, known-good 2.1.263), then spawns
claude --bg --name cyberine-ap-<runId8> --exec "<command>" where <command> re-invokes THIS
checkout as CYBERINE_AUTOPILOT_RUN_ID=<runId> npx tsx --tsconfig <tsconfig> <src/cyberine.ts> autopilot --attached <your flags minus --bg> --driver stream,
with cwd = the repo root and the same scrubbed environment the driven session gets. The short id is
read off the backgrounded · <id> line after stripping ANSI (the client keeps its colours on a
pipe). The run id rides IN the command string, not in the spawn environment: measured 2026-09-08,
the daemon does NOT forward the client's extra env vars to an exec job, so without the prefix the
attached child would invent its own run id and log somewhere the parent never printed. The same
prefix carries CYBERINE_HANDOFF_WRITER_BIN and NF_TASKS_ROOT when the launching shell has them set
(kdche, 2026-09-09; forwardableExecEnv in src/autopilot/fleetExec.ts) -- an operator override
of either reaches the exec'd job the same way the run id does, instead of silently falling back to
the builtin handoff writer or git-derived board root once inside the fleet row (the forwarded variable was CYBERINE_AMEMORIA_BIN until 2026-09-30).
--attached is that internal mode, and it is what makes the row steerable:
- PTY stdin is an operator channel. Under
--execthe process owns a real TTY, and a peek reply (Spaceinclaude agents, then type + Enter) orclaude attach <id>writes straight into it. Each non-empty line is trimmed and handed to the stream driver'sinject(text), which lands it as an OPERATOR turn on the live session —origin: "operator", notaskId, exactly asSendMessagedoes. A line that arrives before a session exists is HELD and delivered at the next turn boundary rather than dropped.claude attachforwards the raw PTY, so an arrow key, Ctrl+C or Ctrl+R can land as a CSI/SS3 escape sequence or a lone control byte inside that line before Enter;onLinestrips those (sanitizeOperatorLineinsrc/autopilot/attachedMode.ts) before treating the result as a turn, and a line that is empty after stripping produces no operator turn at all. Attach itself writes nothing to stdout — no hint, no greeting; the first write is the newline-guarded status line at the next turn boundary.claude attach's own key handling (Left/Right navigation back to the fleet view, for example) is outside this repo. - stdout is a status channel, one line per turn boundary.
s<N> t<M> ctx <pct>% <taskId> · <the first 100 chars of the reply>, because the daemon shows the LAST stdout line as the row's detail column. A wait printswaiting: <reason>(quota preflight, quota pause, the offload handoff), so a loop that is blocked says so in the fleet view. - Every emitted line is printable ASCII plus the middle dot, and the first write is
newline-guarded (kqkw2). The
cyberine-ap-3fed0a2brow rendered its detail as<ESC>s1 t1 ctx 22% kx75n ...-- a bare ESC before the head. That byte was never in the line this process builds: the head is a plain template, the tail already goes throughsanitizeStatusText(which replaces every code point below 0x20, ESC included), andemit()appends only a newline.npx tsxwas measured on 2026-09-09 writing NO ESC to a piped stdout, which is what the daemon gives an--execjob, so the runner is not the source either; what remains is the daemon's own capture or the client that renders the row, both outside this repo. Two defences ship instead of a source fix:sanitizeStatusLinereduces the WHOLE line (head included) to[ -~·], and the first stdout write is prefixed with a newline so a foreign unterminated write cannot fuse its trailing bytes onto the front of ours. Known Limitations: the stray byte itself has NOT been re-observed since the fix -- confirming it needs a live--bgrun, which was not made (the 7d subscription window was at 79%), so the ESC's true origin is still unproven and the fix is a contract on our own output, not a diagnosis. - A session opening prints immediately, before its first turn completes.
session <N>/<maxSessions> · <taskId> · turn 1 running— otherwise the row detail stayed on the PREVIOUS session's stale line for the entire bootstrap turn (measured 2026-09-08: 35+ minutes of an actively-working, committing session readidlebecause nothing had reached stdout since the last quota-checkwaiting:line). - A heartbeat refreshes the row detail every 60 s of silence. Whichever status/waiting/session-open
line printed last is re-emitted with the elapsed time appended (
· 12m) if nothing new has printed for 60 s — a single unref'd timer inside the attached runtime, cleared the moment a real line prints or the runtime shuts down; it never keeps the process alive and never runs outside--attachedmode. - The log file keeps everything.
.local/autopilot/logs/<runId>.logreceives the full loop log AND every line the command would otherwise have written to stderr; only the status lines reach stdout.claude logs <id>therefore shows the status stream, and the file shows the run. - One attached process per runId (kx75n). Before it reads the board, an
--attachedstart claims.local/autopilot/locks/<runId>.json— a file written atomically (staged, thenlinked, so it never appears half-written) carrying the holder's pid and start time. A second start for the SAMECYBERINE_AUTOPILOT_RUN_IDwhile that pid is alive prints one line on stdout (autopilot: runId <id> is already driven by pid <pid> (since <ts>); this copy exits without touching the board) and exits 0 — no board read, no quota preflight, no driver spawn. A lock whose pid is gone is stale and is broken automatically, so an unclean kill never wedges the next run. The lock is released on exit, including theclaude stopSIGTERM path. A run with noCYBERINE_AUTOPILOT_RUN_IDmints a fresh id and so can never collide. claude stop <id>sends SIGTERM and SIGKILLs ~5 s later (measured 2026-09-08). The handler is synchronous inside that budget: it writes.local/autopilot/stopped/<runId>.json— run id, signal, session index, task, turn, context tokens, the log path andresume: claude --resume <sessionId>— flips the same in-memory stop flag the STOP file sets, and exits. It never tries to finish the model turn in flight; the driven session is resumed by its session id instead.
Fallback, announced on stderr as no claude fleet row (<reason>); falling back to a detached background process:
claude missing or older than the known-good version, a --version that does not parse, or no
backgrounded · line within 20 s (the attempt is killed first). The detached npx tsx spawn and
its runId/stop/watch output are byte-identical to what --bg printed before.
Known Limitations and Deferred Work (autopilot --bg fleet row)
- The row is never
Needs input. The daemon derivesworking/idlefrom PTY output activity, so an autopilot that is waiting shows asworking(or goesidleafter ~30 s of quiet) and never as blocked. Thewaiting: <reason>line in the detail column is the only signal, which is why one is printed at every wait. A long-running TURN is a separate risk the session-open line plus the 60 s heartbeat (above) now cover: the row detail no longer goes stale-looking just because the driven agent is still working and has not finished a turn yet. --execis undocumented inclaude --helpon 2.1.263 (the agent-view doc is what specifies it). It is registered inCLI_FLAG_COMPATwith that known-good version so an older binary is refused BEFORE the spawn instead of diagnosed after it, but upstream could rename it without a deprecation.- The two
claude respawnforms behave differently, and only one of them duplicates.claude respawn --allstarts a SECOND copy of an exec row's command WITHOUT killing the first (operator-run probe 2026-09-08, k5qjr: two pids under one fleet id).claude respawn <id>, on a realcyberine autopilot --bgrow, REPLACES instead: measured 2026-09-08 (ktj6p) on a throwaway checkout, the original attached pid was gone afterwards, exactly one attached process was left for that runId, the row stayedworking, and the daemon left ONEcyberine-ap-*row. Do not generalise from one form to the other;--allstays unmeasured here because it acts on every background job in the operator's fleet, this session's own driving row included. - The per-runId lock covers both shapes. In the
<id>case the newcomer finds a lock whose pid is dead, breaks it as stale and takes over — the lock file names the NEW owner, which is the correct outcome, not a leak. In the duplicate case the original is alive and the newcomer is refused: running the row's OWN exec command a second time (byte-for-byte what respawn re-runs, read off the live pty-host argv) exited 0 in ~1 s withautopilot: runId <id> is already driven by pid <pid> ..., wrote nothing to stderr, and left the lock naming the original. Whether the DAEMON leaves one or two rows is upstream's to fix; the board is protected either way. - Daemon-death survival is unmeasured. Whether an exec row survives the daemon dying (it does
die a few times a day on this machine) was not tested, because a SIGTERM to the daemon did not
kill it. Treat "row gone" as a normal exit: the stop record plus
claude --resume <sessionId>is the recovery path, not the row. - The 5 s stop budget is not a wrap-up turn. A stop mid-turn loses that turn's result; the handoff written is the stop record, not an offload handoff.
- One status line per LOOP turn. An operator turn completes inside the driver without passing
through the loop's port wrapper, so it updates
autopilot.jsonlbut does not itself print a status line; the next loop turn does. - The private daemon files stay untouched.
~/.claude/jobs/*,daemon/roster.json,daemon/dispatch/*andcontrol.sockare never written — onlyclaude --bg,--exec,stopandagents --jsonare used.
Verify:
npm run selftest:autopilot-fleet-execHermetic: a fake claude answers --version, agents --json, and --bg --exec <cmd> by really
spawning the command with piped stdio and printing a COLOURED backgrounded · fake1234 line, plus a
fake streaming child for the attached half. It covers the command builder (quoting, --attached,
the forced --driver stream, the env prefix), the id parse through ANSI, the capability gate on
2.1.263 vs 2.1.200 vs a missing binary, a real exec whose stdin reaches the child, both fallback
shapes (a client that exits silently, and one that never prints the line and is killed at the
shortened wait), the status/waiting line formats (ANSI and non-BMP stripped, 100-char cap), and the
attached runtime end to end: a stdin line becomes an origin: 'operator' record, a held line is
delivered once a driver exists, and SIGTERM writes the resumable stop record synchronously. Positive
control: the same assertions against a session nobody typed into find one loop record and NO
operator record.
The kx75n half is measured on the REAL entry, not a fake: the smoke builds the exact command
buildAttachedExecCommand hands the daemon (which is what claude respawn --all re-runs), executes
it TWICE for one runId against a throwaway fixture repo, and asserts the second exits 0, names the
holder pid on stdout, writes nothing to stderr, invokes no claude child, and leaves the first
copy's lock intact — then that a clean SIGTERM of the first releases it. Positive controls: the lock
unit cases flip from refused to acquired on the single change of the holder pid being dead, and
removing the process.exit(0) from the refusal path turns the live half red.
Proven live on 2026-09-08 (claude 2.1.263, --model haiku, a throwaway checkout): the row appeared
as {name: cyberine-ap-d13c4c3e, kind: background, state: working}, its detail moved from
s1 t1 ctx 32% kwx2r · loop turn recorded to waiting: idle, waiting for an operator line (60s),
please also run the lint typed into claude attach <id> landed as
{turn: 2, origin: "operator", promptChars: 24, contextTokens: 64586} on the SAME session id, and
claude stop <id> wrote the stop record and killed the process in ~0.7 s. The exec-ed environment
carried neither CLAUDE_CODE_OAUTH_TOKEN nor ANTHROPIC_API_KEY (presence tested, never printed).
Watch and steer a run from the fleet (cyberine run --bg / chat /run / the queue consumer, kb10z).
Every launchBackgroundRun dispatch — cyberine run --bg, chat /run, cyberine enqueue +
cyberine queue tick, a workflow's awaitRun step, research/teammate sub-dispatches — now tries
the SAME claude --bg --exec fleet row kwx2r built for cyberine autopilot --bg, before falling back
to the original detached npx tsx child. Operator decision 2026-09-08: this is the DEFAULT, not an
opt-in — there is no --fleet flag, and nothing has to be set to get a row.
There IS one opt-OUT, added by knm4d: CYBERINE_FLEET_ROWS=0 (or false) makes every
launchAsFleetRow caller skip the fleet attempt and take the detached path SILENTLY — the
no claude fleet row (<reason>) note is not printed, because an opt-out is not a refusal. It exists
for test runs: npm run sweep sets it for every smoke it spawns, and it chains, because the
detached spawn inherits process.env, so a run's own child dispatch opts out too. Measured
2026-09-08 before the opt-out: one sweep left 2 dead cyberine-run-* rows in the operator's fleet
(one from selftest:dispatch-guards' real background dispatch, one from that run's child dispatch
through cyberine-bg-run.ts), and three sweeps had left 12. Do NOT set it for real work — a
dispatch that opts out is unsteerable from claude agents.
cyberine run --bg "add a health check to the web server"
# Launched as the claude fleet row cyberine-run-4a564c13 (6f91916b); log: .local/runs/<runId>/console.log
# runId: 4a564c13-d92e-4de7-a9f8-4565eec913eb
# state: launching (background)
# watch: cyberine agents
# apply: cyberine apply 4a564c13 (lands the winning diff once the run is done)
claude agents --json --all # the row is named cyberine-run-<runId8>, kind: background
claude attach 6f91916b # type a line -> recorded, read-only, in operator-input.jsonl
claude stop 6f91916b # SIGTERM; claude rm is the operator's own call, cyberine never rm's a rowWhat actually happens: the shared helper launchAsFleetRow (src/autopilot/fleetExec.ts) reuses
kwx2r's probeExecCapability / launchFleetRow / parseBackgroundedId unchanged, and adds one
naming rule (runFleetRowName, prefix cyberine-run- instead of autopilot's cyberine-ap-, same
digit-tail guard) and one env-size rule launchBg.ts needs that autopilot's --attached re-invocation
never did: CYBERINE_TASK here carries the FULL task plus memory/rules/invariants preambles, which
can be many KB — too large to embed safely as a literal shell-assignment prefix in the --exec
string, and (measured by kwx2r, 2026-09-08) the daemon does not forward this call's own spawn
environment to the job it execs. So the launcher writes the whole per-run env dict to
.local/runs/<runId>/bg-env.json (mode 0600) and hands the exec'd command only one small pointer,
CYBERINE_BG_ENV_FILE=<path>; cyberine-bg-run.ts loads that file into process.env before reading
anything else, and this indirection is invisible to a caller — BgLaunch just gains dispatchMode: 'fleet' | 'detached' plus fleetRowId? / fleetRowName? when it went to the fleet.
Row outcome line (krjsv). The daemon shows the LAST STDOUT LINE as a fleet row's detail
(established by kwx2r for the autopilot fleet row), so cyberine-bg-run.ts prints exactly one
outcome line to stdout right before it exits, on every exit path (a pre-execution failure and a
normal end alike), and nothing else after it — a run that used to leave the row reading Failed - exit 1 - or Done - with an empty summary now shows the real reason. The line is built by the pure
buildRunOutcomeLine (src/store/runOutcomeLine.ts) from run.json:
<runId8> <state>: <detail>, where <detail> is run.detail on error, the last step's judge
verdict (<winnerAgentId> — <rationale>, or the step's own detail when there is no verdict) on
done, and the literal word cancelled for a cancelled run. Passed through redactSecrets and
capped at 160 chars. Pinned by npm run selftest:run-outcome-line against fixture run.json
objects for all three states.
Known limitation: the two exit paths that print it are the pre-execution-failure branch and the
normal end — the very first guard (CYBERINE_TASK empty) exits before any run.json exists, so it
still prints only to stderr with no row summary.
The run's own PTY becomes an operator channel exactly as it does for autopilot: cyberine-bg-run.ts
installs installOperatorInputReader() (src/store/operatorInputReader.ts, the SAME
createInterface({ input: process.stdin }) pattern attachedMode.ts uses) right after computing its
runId, and every non-empty trimmed stdin line is appended verbatim as { at, text } JSON to
.local/runs/<runId>/operator-input.jsonl through RunStore.appendOperatorInput. This is a
read-only recording channel — kb10z does not feed the line to anything (no inject(), no
planner, no judge); a later task can wire that up. It shows up read-only in two places:
- TUI (
cyberine agents/npm run tui): the run detail pane (RunDetail.tsx) grows an "operator input" panel, last 5 lines with their timestamps, only when the file is non-empty — polled the same wayuseRunTokenStream.tstailsevents.jsonl(useOperatorInputTail.ts, byte offset, 1 s interval). - Web dashboard (
cyberine web):GET /api/runs/:id/operator-input(and its/v1alias) returns{ records: [{ at, text }] }; the run detail page polls it every 3 s into a collapsible "Operator input (read-only)" panel next to the diff panel, hidden when empty.
Fallback (announced on stderr, one line, never thrown): [cyberine] run <runId8>: no claude fleet row (<reason>); falling back to a detached background process. Same refusal set as autopilot's --bg:
claude missing or older than the --exec known-good version, an unparseable --version, or no
backgrounded · line within 20 s (the attempt is killed first). The fallback path is the exact
pre-kb10z spawn('npx', ['tsx', ...], { detached: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, ...extraEnv } }) call — extraEnv is the same object literal that used to be
inlined directly into that env: field, only extracted to a named variable so both dispatch shapes
can read it; nothing in the fallback branch's argv, stdio, or env construction changed.
Verify:
npm run selftest:fleet-exec-rowsHermetic and independent of the kwx2r autopilot-fleet-exec-smoke.ts fixtures (its own fake claude
binary, its own tmp dir), though both smokes exercise the same fleetExec.ts primitives. Covers:
runFleetRowName's digit-tail guard; a supported claude really --exec-ing the REAL worker script
(scripts/fleet-exec-rows-worker.ts, which installs the SAME production installOperatorInputReader
cyberine-bg-run.ts does — not a re-implementation) with a fed stdin line landing in
operator-input.jsonl; a positive control where nothing is typed and the file stays absent or empty;
and both fallback shapes (an old version refused before any spawn, a client that never prints
backgrounded refused within a shortened wait, not the 20 s production default).
Proven live on 2026-09-08 (claude 2.1.263, real cyberine run --bg dispatch from this checkout): the
row appeared as {name: cyberine-run-4a564c13, kind: background, state: working}; claude attach 6f91916b in a throwaway tmux session, typing please also run the lint, landed in
operator-input.jsonl as {"at":1788812670938,"text":"please also run the lint"} within about a
second (well under the 3 s the reader's own file-append needs once a line reaches its stdin); claude stop / claude rm and the tmux session were cleaned up immediately after, leaving no
cyberine-run-* rows. The run.json before/after byte-identical check for the fallback path was
attempted live and abandoned: this development machine carries already-authenticated real credentials
in the ambient shell environment (independent of this repo's own .env), so a build-fleet pinned to
a deliberately-invalid worker id still "degrades to all available adapters" and dispatches a real
paid call rather than failing fast and network-free — two such attempts were killed as soon as this
was noticed. The claim instead rests on static inspection: extraEnv in src/launchBg.ts is the
unmodified object literal the pre-kb10z code inlined directly into the detached spawn's env: field,
and the fallback spawn(...) call's argv/stdio/env construction is untouched — git diff against the
merge-base shows only the extraction, never a value change.
Stopping a run (kye4c). A SIGTERM to the background process is a CANCELLATION, not a crash:
src/cyberine-bg-run.ts flushes the run record as state: cancelled with a
cancelled by SIGTERM (operator stop) after <n>s detail, then exits 0 — inside a 3 s budget,
because claude stop SIGKILLs ~5 s later. A run that genuinely fails is untouched and still exits
1, so the exit code now separates the two. Measured live on claude 2.1.263, same procedure twice
with only the exit code changed:
| what happened | process exit | run.json state | claude agents row |
|---|---|---|---|
| SIGTERM to the exec'd process, kye4c fix | 0 | cancelled | done |
| SIGTERM to the exec'd process, before kye4c | 143 | cancelled | failed |
claude stop <id> | either | cancelled | stopped |
The third row corrects this task's own premise. claude stop lands the row in stopped
regardless of the exit code — the A/B above produced stopped both ways — so the
Failed · exit 143 the operator saw comes from the OTHER way an exec row dies: a SIGTERM the
daemon did not initiate (a parent killing the process tree, the sweep tearing down), where it sees
only a non-zero exit. That is the case the fix converts to done.
Two limits worth knowing. SIGINT is not covered in practice: the handler treats Ctrl-C the same
way and keeps exit 130 (the shell convention), but under the tsx loader the process is torn down
before the async flush lands — measured by forcing that branch to return 131 and still observing
130 — so a Ctrl-C'd run keeps whatever state it had. claude stop sends SIGTERM, so this does not
affect the fleet path. SIGKILL remains uncatchable: a run killed past the 5 s budget leaves its
record running until the reaper picks it up.
Known Limitations and Deferred Work (fleet-row run --bg, kb10z)
- No
Needs inputstate. Same as autopilot's--bg: the daemon derivesworking/idlefrom PTY output activity, so a quiet run just goesidle, never a distinguishable "waiting for you" state. --execstays undocumented inclaude --helpon 2.1.263 — registered inCLI_FLAG_COMPATwith a known-good version so an older binary is refused before the spawn, but upstream could rename it.- Daemon-death survival is unmeasured, for the same reason kwx2r left it unmeasured: it acts on the operator's whole fleet, not one row, so it was never exercised in isolation for this task either.
- Operator lines are recorded but not acted on.
operator-input.jsonlis a read-only channel; no planner, judge, or session driver reads it back. Feeding it into the run is explicitly out of scope for kb10z (a later task). logPathis only populated in detached mode.BgLaunch.logPathstill names.local/runs/<runId>/console.log, but a fleet-row dispatch never opens or writes that file — its stdout/stderr go to the row's own PTY instead, exactly like autopilot's exec rows.
Brainstorm tasks interactively — cyberine chat
cyberine run needs a task up front; cyberine chat is the interactive way to arrive at one. On a real TTY it opens a full-screen Ink TUI (a welcome banner with the model/provider/cwd + command panel, a
1. Multi-adapter demo (same task -> every available adapter, compare)
Dispatches one fixed task to all available adapters in parallel and prints a comparison table (provider | status | output | costUsd | ms) plus per-adapter detail. Proves every adapter returns the...