Ctrl K
ComunidadGuías…

AGENTS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Open Walnut — Personal AI

> **ACP reference implementation:** https://github.com/agentclientprotocol/claude-agent-acp (public).
> **References**: [ARCHITECTURE.md](./ARCHITECTURE.md) | per-directory `AGENTS.md` files are
> concise quick-references; the **deep implementation details live in skills** (auto-discovered,
> load on demand): `walnut-core-internals` (src/core/),
> `walnut-web-frontend` (web/src/), `walnut-testing` (tests/), `walnut-ops` (incidents + src/logging/).
> Load the matching skill before non-trivial work in that area.
> **Important docs:** browse [`docs/`](./docs/README.md) first; model work starts with
> [Claude model configuration](./docs/reference/claude-model-configuration.md).

## Ownership: You Are the CTO

**Act as the CTO of this repo — proactive, decisive, and accountable for the outcome.**
Don't wait to be told the obvious next step or stop to ask which way to go when the
intent is clear. Make good, thoughtful decisions: weigh all the trade-offs (UX,
maintainability, performance, blast radius, root-cause vs. band-aid), pick the option
you'd defend in a design review, and state the call + the reasoning as you go. When a
choice is genuinely the user's to make (irreversible, a real product fork, or it
contradicts a stated preference) surface it with a recommendation — otherwise pick the
obvious option and proceed. Fix root causes, not symptoms. Verify your own work
(build + real-UI E2E) before claiming it's done. Default to finishing the whole job.

**Done means live on prod, and you saw it there.** A change is finished only when it is
committed, deployed to the live server on :3456, and you opened live prod yourself and
watched it work (the real UI for a UI change, a read-only check for the rest). After the
review, commit and deploy on your own; don't stop and wait to be asked. The shared tree
almost always holds other agents' uncommitted work, so deploy the commit, not the tree:
`bash scripts/deploy-committed.sh` builds the committed HEAD in a clean clone and serves
it as this checkout: the compiled walnut file for this Mac (plus the Linux files host servers
take, from the same build), run directly with no node. Once it serves, the dist of that build is
copied into this checkout's `dist/`, because the local `walnut` CLI runs the op registry
(descriptions, schemas, outcomes) from there (`scripts/publish-serve-root-dist.mjs`; 2026-10-05:
sessions read op text two deploys old). It skips the deploy (exit 0, no restart) when production
already serves that commit or a later one containing it from a clean build, because every restart
costs open windows seconds of timeouts (2026-10-28: up to 76 a day); `WALNUT_DEVPROD_FORCE=1`
deploys anyway. Deploys are queued (`scripts/lib/deploy-queue.sh`, 2026-11-03: 31 restarts in a
day): production restarts at most once per 10 minutes (`WALNUT_DEPLOY_MIN_GAP_SECS`), and on a Mac whose load is over 4 per CPU it waits for the load to ease, up to 20 minutes after the last restart (`WALNUT_DEPLOY_BUSY_GAP_SECS`, 2026-11-09: four deploys an hour with an iOS build took the load to 400), a call
inside the gap waits and goes out with the newest commit of then, and it exits 0 only once
production serves a build containing its commit (it says which); a host's daemon is replaced at
most once per 20 minutes (`src/providers/daemon-update-gap.ts`). Never wait for the tree to be clean, and never stash, revert or commit
their files to get there. If a deploy guard refuses (type-check, smoke boot, cooldown), fix
what it names or report it as the blocker. Never end with "committed, not deployed, say
deploy". Scope the commit to your own changes only (never sweep up other agents'
uncommitted files), and run the usual pre-commit sensitive-content scan. Push still only
happens on request.

## CRITICAL: Open Source Repository

**PUBLIC repo. Every commit is visible to the internet.**

No company-internal names, personal info, internal URLs, credentials, or internal processes. Generic descriptions only. Internal plugins go in `~/.open-walnut/plugins/` (never committed). **When in doubt, leave it out.**

## Every feature works for anyone who installs it

Walnut and its plugins ship to the public, so nothing may work only on the maintainer's machine
(2026-11-01: a Focus block's macOS Do Not Disturb, the network relay). Set it up for the user in
one click where the platform allows it; where it needs a step only they can take (a permission,
an OS setting, a port on their own server), walk them through it in place, showing the exact
screen, and check that it worked. Where the maintainer picked one way for himself (his own relay,
say), the other reasonable ways stay options for everyone else. Never depend on a file, shortcut or
setting that exists on one machine only.

**Walnut never sets an engine's own defaults.** The default model and settings of an underlying
agent (Claude Code, Codex and the rest) belong to that agent's own settings; Walnut only passes a
model someone chose and lists each engine's models, so it stays generic and works the same on every
platform.

## Existing users keep working and keep upgrading

Every change reaches people who already run an older Walnut. Before it lands, make sure:

- **It still works for them.** Data an older version wrote opens and reads the same, and a
  host, daemon, phone app or companion still on the older version keeps working with it.
- **They can upgrade.** The updater their OLD version already has (npm, the install script,
  Homebrew, the Mac app) takes them to the new one with no manual step.
- **They can go back.** The previous release still starts on data the new one wrote, or
  refuses with a clear message; it never corrupts it.

Prove it against the real published release, not only the new code
(`scripts/release-rehearsal/`). Keep the way across simple: one obvious path per install
kind, not a pile of special cases.

## Multi-Agent Safety

- **NEVER** delete/revert other agents' changes or switch branches unless asked
- No `git stash`, no `git worktree` ops unless explicitly requested
- On "push": `git pull --rebase` OK. On "commit": scope to your changes only
- If build fails, retry — another agent may be mid-commit
- Bug investigations: read npm dep source + all related code before concluding
- Code style: brief comments for tricky logic; files under ~500 LOC

## Production Server Safety

**Port 3456 = PRODUCTION. NEVER kill, restart, or interfere with it by hand: the deploy
scripts below are the only way to replace it.**

### Never block the web server (each rule = a shipped outage)

- **No sync blocking on the event loop** (`execSync`, sync native-addon calls, multi-MB parse): one call freezes EVERY route. Child process + timeout + cached value instead (`setImmediate` doesn't help). Ratchet: `tests/core/event-loop-blocking-ratchet.test.ts`.
- **Every route touching daemon/SSH/network/whale files needs a deadline** — answer degraded (204/stale), never hang: one pinned response starves the browser's 6-connection pool → app-wide fake 15s timeouts.

```bash
bash scripts/deploy-committed.sh  # Deploy the committed HEAD (clean clone) → restart 3456
bun run dev:prod        # Build the working tree as it is → restart 3456
bun run dev:ephemeral   # Ephemeral server (random port, temp data, auto-cleans)
bun run dev:ephemeral -- --file [<walnut file>]   # the same, running a compiled file

# Verify the deploy script itself WITHOUT deploying: runs every guard, then stops
# before build / server kill / launch. WALNUT_DEVPROD_PORT is honoured only here
# and in a rehearsal.
WALNUT_DEVPROD_DRY_RUN=1 WALNUT_DEVPROD_PORT=35999 TMPDIR=/tmp/dry bash scripts/dev-prod.sh

# Rehearse the WHOLE real deploy (build, smoke, kill and launch, LKG, rollback) away
# from production: its own port, data dir, daemon dir and launchd label, and a state
# dir it makes, <dir>/rehearsal-state (lock, stamps, stages, LKG, log, the CLI publish
# stand-in), with the build clone beside it in <dir>. It writes only into those dirs
# (and the daemon's <daemon dir>-streams), except that its servers, like every Walnut
# server, append their exit line to /tmp/open-walnut-exit.log, and on a Mac with
# Walnut.app it adds its server's command to the session host's approvals (the others
# kept) and runs it under the app, as the real deploy does (WALNUT_SESSION_HOST=0: not). Refuses :3456, :3457,
# a port served by anything it did not start, ~/.open-walnut, /tmp/open-walnut,
# ~/.cache/open-walnut, the production log and any WALNUT_DEVPROD_LKG_DIR.
# Clean up after: bootout com.open-walnut.dev-prod-rehearsal.<port>, then remove the dirs.
WALNUT_DEVPROD_REHEARSAL=1 WALNUT_DEVPROD_PORT=35871 WALNUT_DEVPROD_REHEARSAL_DIR=/tmp/rh \
  OPEN_WALNUT_HOME=/tmp/rh/home WALNUT_DAEMON_DIR=/tmp/rh/daemon bash scripts/deploy-committed.sh
```

### Live verification runs on an ephemeral server, never on :3456

Anything that creates tasks, sessions, notes or cron jobs to prove a change works (a real-CLI
turn, a Playwright click path, a `curl` or `walnut tools call` against the API) runs on
`bun run dev:ephemeral`. Reading :3456 is fine; writing to it for a test is not: every probe
task an agent made there stayed on the user's board (a 2026-09-23 audit found dozens).
`bun run dev:ephemeral -- --file <path>` runs a compiled walnut file instead of the source build
(`--file` alone builds this tree's file first, about a minute): the same launcher code, so the
same isolation below, and the file's runtime tree goes to `$TMPDIR/walnut-ephemeral-runtime`,
never into the real data home (`scripts/dev-ephemeral.sh`).

**When a check must write on :3456, it runs as a test run** (`src/core/test-run.ts`; 2026-10-27:
an Org chart acceptance run made 9 tasks and 6 projects there, which showed on the user's board
and Org page, and its cleanup left 3 session records with no task). A request carrying
`x-walnut-test-run: <label>` makes test objects: every task it creates (and every subtask of
one, whoever files it later) is kept off the board, task lists, Org and its counts, search, the
Brief and the phone the way a plugin's own task is (`plugin_only` = `@test-run`, local, unpinned);
every project it creates carries `metadata.test_run` and is in no project list while only test
work lives in it. The test run's own requests still see them, unmarked, so a UI check finds what
it made. Who sends the header: the web app whenever a browser is under automation
(`navigator.webdriver`, so every Playwright run, MCP included, without asking) or was opened with
`?walnut-test-run=<label>` (for the tab; `=0` ends it), the `walnut` CLI with
`WALNUT_TEST_RUN=<label>` in its env, and `curl -H 'x-walnut-test-run: <label>'`. A plain `curl`
or `walnut` call without it is a real write. Main and the lead of a real project are never test
objects. Deleting a test task removes its session records too, and the primary's sweep
(`src/core/test-run-sweep.ts`, every 30 min) deletes test tasks idle for
`WALNUT_TEST_RUN_TTL_HOURS` (6) with their sessions, then their empty projects, with no tombstone.
Still delete what you made when you are done, and never touch the user's own objects from a test
run: the marker covers what you create, not what you edit. Its preference writes (the synced
layout in ui-prefs, favorites, project order, the mail Grouped switch, a push choice) are kept
apart (`src/core/test-run-prefs.ts`, 2026-10-31: a hidden task panel in the user's synced prefs
left every fresh browser on an empty Home): only its own changes, in memory under its label, laid
over the user's live values in its reads, gone 30 minutes after its last write; an automated
browser's label names its browser context (`webdriver-<id>`), so a fresh browser opens on the
user's layout. `tests/core/test-run-prefs-gate.test.ts` lists every preference write route and
fails on one that does not go through it. What a plugin does outside Walnut (the person's Mac,
an outside account, its own timers) no copy holds, so a plugin op reads `walnut.testRun?.()`, the
request's label, carried through the companion's op relay too, and refuses such a change (2026-11-02:
a test browser's Rhythm focus block quieted the user's Walnut and phone and turned the Mac's Do Not
Disturb on; Rhythm now refuses every write op in a test run); the same gate test fails on a plugin
that takes the person's quiet hold or runs Shortcuts, AppleScript or `open` without reading it.
Throwaway servers (ephemeral, the test
runner, a data home in a temp or task dir) ignore the header; `WALNUT_TEST_RUN_MARK=1` turns it on
there, as `tests/e2e/test-run-objects.test.ts` does.

The launcher prints `{pid, port, tmpDir, daemonDir, pausedCronJobs, removedPushTokens}`. The
server is isolated by construction, so nothing it does reaches the real Walnut:

- its own data copy and its own local daemon in `daemonDir` (its logs are there too); the copy is
  copy-on-write where the volume clones (APFS: notes and plugin data cost nothing until changed),
  else a real copy that leaves the largest folders a test does not need empty to stay under
  `WALNUT_EPHEMERAL_COPY_CAP_MB` (2048, `0` = all) and says which (`snapshot` in the printed
  JSON; `src/commands/ephemeral-snapshot-copy.ts`; 2026-11-04: a 6.15 GB full copy filled the
  Mac's disk). The server ends with the process it works for (`owner`: the first ancestor that
  is not a shell or script runner, the agent CLI as a rule; `WALNUT_EPHEMERAL_OWNER_PID`, `0` =
  none) as well as on its idle timer, and the reaper takes a snapshot whose server stopped
  heartbeating (`src/commands/ephemeral-owner.ts`);
- its own op executor and every local session it launches talk to it, never the :3456 default
  (`OPEN_WALNUT_API_URL`, see `src/lib/self-api-root.ts`);
- the copied cron jobs are paused (jobs created there still run) and Inbox Triage is off in the
  copied config (its bootstrap would re-enable its paused routine), the copied Board reminders
  never fire (ones set there do), the copied phone push tokens
  are removed, no plugin syncs tasks (installed ones included), and there is no cloud bridge
  push and no heartbeat;
- remote hosts stay off unless it is started with `WALNUT_EPHEMERAL_REMOTE_HOSTS=1`, and then
  each ssh host gets an isolated daemon of the test server's own, never the host's production
  daemon (which hands relayed work to whichever server it sees first)
  (`src/providers/remote-isolation.ts`): everything it writes there is under
  `/tmp/open-walnut-test-<key>` (else `~/.cache/open-walnut-test/<key>`): its daemon dir,
  streams, task dirs, installs (bun, host fixes, dtach run with HOME in its `home/`, bun's transpiler cache too) and terminal
  sockets. Both daemon twins say their dir and the test server's key in their hello; a test
  server refuses any other daemon, a real server refuses a test server's, and the daemon refuses
  to start in a production dir. The test server stops it and removes the root when it exits, and
  a daemon left with no server for 30 minutes ends itself the same way. Exceptions that stay in
  the real home on purpose: what a session's CLI writes (`~/.claude`) and the shared spawn
  journal and CLI owner records. Ratchet: `tests/providers/remote-write-paths-ratchet.test.ts`
  lists every remote-exec site in `src/` and why it stays in the root;
- search is keyword only and voice is not set up: no isolated server (an ephemeral child, the
  test runner, a data home in a temp dir or a task dir, a HOME that is not the account's own)
  loads the embedding model, about 2.2 GB per worker lane plus a full re-embed of the copied
  data, or downloads the Qwen3-ASR voice model (2.3 to 3.2 GB) into its HOME
  (`src/core/search/semantic-default.ts`). Nor does it start, adopt or call a speech process, load
  a speech model or record the microphone or system audio, even on a Mac that has the model
  (`src/core/stt/voice-here.ts`, ratchet `tests/core/stt-voice-here-ratchet.test.ts`). Opt in with
  `WALNUT_SEARCH_V2_SEMANTIC=1` and `WALNUT_STT_AUTO_SETUP=1`.

Plugins still load with the copied settings, so a test that sends mail or a chat message
through one sends it for real.

**Testing a task sync plugin.** No plugin syncs tasks on a test server, so tasks made there stay
local, which is what almost every test wants. To test a sync plugin against its real service
(Microsoft To Do, or one the user installed), start the server with
`WALNUT_ALLOW_REMOTE_SYNC_IN_TEST=1 bun run dev:ephemeral`. It then writes to the user's real
account, and the real Walnut pulls those items back onto the user's board. Change only tasks you
created there: the copied tasks are linked to real items, so editing, completing or deleting one
changes the real item. Before you finish, clean up: delete each item you created from the test
server while sync is still on (that removes it from the real service too), then check the real
board and delete any copy it already pulled in. Removing your own leftovers is the one write to
:3456 a test may make. Report what you removed.

**dev-prod.sh must stay portable.** Issue #11: the server log was pinned to `/private/tmp`,
a macOS-only path, so a Linux deploy killed the live server and then failed to start its
replacement. Two rules it now encodes: prove every external prerequisite (a writable log)
BEFORE the first destructive step, and never assume a macOS-only tool (listener detection
probes `lsof` → `ss` → `fuser`, and refuses to run when none exists rather than failing
open and starting a second server against the same data dir; a BusyBox applet under one of
those names, or a BusyBox `ps`, does not count: they take none of the options used). Ratchets:
`tests/scripts/dev-prod-portability.test.ts` (the dry run executes the whole script, on
Linux too, in CI's quick tier) and `tests/scripts/cross-platform-ratchet.test.ts`.

**Deploys smoke-test before killing prod, and roll back on failure.** dev-prod.sh boots the
freshly built dist ONCE in full isolation (temp data dir + temp daemon dir + probe port) and
requires it to serve `/api/config` BEFORE touching the running server — a dist that hangs in
module init (2026-08-22/23 outages) now fails the deploy while prod keeps serving. After a
successful readiness check the dist is snapshotted as last-known-good; a post-kill readiness
failure re-launches that snapshot instead of leaving :3456 dark. Knobs:
`WALNUT_DEVPROD_SMOKE_SECS` (default 120), `WALNUT_DEVPROD_SKIP_SMOKE=1`,
`WALNUT_DEVPROD_LKG_DIR`. Ratchets: `tests/scripts/dev-prod-smoke-rollback.test.ts`.

**Deploys also load the installed plugins before the kill.** The smoke boot's data home is
empty, so it never loads an external plugin (2026-10-25: every one failed to bundle for almost
two hours after a deploy). After it, `<new file> __plugin-check` (`src/core/plugins/plugin-load-check.ts`)
loads each plugin in the server's `plugins/` dir that `:$PORT/api/plugin-runtime` reports
`active`, with a temp data home, and never activates it, so nothing syncs, sends or posts. One
that loads there and fails here aborts the deploy; one broken there too, no answer from the
running server, or a check that runs out of time only warns. Knobs:
`WALNUT_DEVPROD_SKIP_PLUGIN_CHECK=1`, `WALNUT_DEVPROD_PLUGIN_CHECK_SECS` (120),
`WALNUT_DEVPROD_PLUGIN_STRICT=1`. Ratchet: `tests/scripts/dev-prod-plugin-check.test.ts`.

**What a deploy runs: the compiled file, and nothing else.** After `bun run web:build` (the dist: the
CLI publish above needs it, and the file reuses its web assets, so the asset mirror sees one
build either way), `bun scripts/build-binary.mjs --reuse-web` builds this machine's file and the
Linux ones (`linux-x64`, `linux-arm64`, their musl twins) in ONE run, so every file names the same
commit; a failed sibling only warns, a failed file for this machine aborts before the kill. The
files are staged as `<stage>/dist-bin/walnut-<target>`, where host servers pick the siblings up
(`path.dirname(process.execPath)`). `walnut __selftest` runs first (it also unpacks the runtime
tree into `<stage>/runtime/<build>`, on the temp volume, never the data home), then the smoke
boot runs `<file> web` with its own data, daemon and runtime dirs. The launchd job runs the file
directly, under Walnut.app's session host on a Mac that has the app (`session_host_prefix`; the
file approves its own argv with `__session-host-approve`), because macOS keys an unbundled
program's Local Network choice on its path and Mach-O UUID, and each stage path asked again
(2026-11-04); a rollback runs it without the host, and every darwin file carries a fixed UUID and
identifier, checked before the kill (`scripts/lib/macho-identity.mjs`,
`docs/reference/macos-session-identity.md`); the LKG holds `dist-bin/` and `runtime/`, and a rollback starts the last good build's
file, from a fresh stage cloned from the LKG (`open-walnut-stage.rollback.*`), never from
the LKG itself, and a snapshot never replaces an LKG a process still runs from: the next good
deploy replaces the LKG whole, and would delete a rollback server's file and tree (or those of
the daemon it respawned from its own path) from under it. A stage kept only for a daemon or a
session's child loses the Linux files (most of its size; only a serving server reads them), and a
build clone an interrupted `deploy-committed.sh` left in TMPDIR is removed once nothing runs from
it. With TMPDIR unset (over ssh) the deploy uses the per-user temp dir on macOS, `/tmp` elsewhere.
`WALNUT_RUNTIME_DIR` names the parent of the per-build trees, so a child of the server that runs
another build unpacks beside it, never over it (`src/binary/runtime-tree.ts`); sessions and
terminals never inherit it, and `dev:ephemeral --file` keeps the newest 3 trees (and any from the
last day) in `$TMPDIR/walnut-ephemeral-runtime` (`WALNUT_EPHEMERAL_KEEP_TREES`). A file runs
nested copies of a package from their own nested path in the tree, so a nested copy of another
version reads its own files (`scripts/binary-build-checks.mjs`).
The way back from a bad file is that rollback to the last good one; there is no other runtime
to deploy (`WALNUT_DEVPROD_RUNTIME` other than `file` is refused). An LKG an older deploy left as a
Node build is never started; a deploy with no file to roll back to says so before the kill. Knobs: `WALNUT_DEVPROD_FILE_TARGETS` (default the four Linux targets, `""` for none),
`WALNUT_DEVPROD_SKIP_SELFTEST=1`, `WALNUT_BUN` (the build needs bun at least the
`packageManager` pin; an older one is refused before any step). `WALNUT_DEVPROD_REHEARSAL=1` runs
the whole deploy on a private port and data dir (block above); its
`WALNUT_DEVPROD_REHEARSAL_FAIL_LAUNCH=1` / `_FAIL_SMOKE=1` drive the rollback and the pre-kill abort.

**A deploy must not pull the floor out from under an open window.** Every deploy re-hashes and
WIPES `dist/web/static/assets`, so a window that was open before it still runs the old entry
bundle and 404s the first time it reaches for a chunk it had not loaded yet. That fetch happens
inside a click (opening a `.go` file asks for its CodeMirror grammar), `vite:preloadError` fires,
and `stale-assets.ts` reloads the page ON TOP of the click — the 2026-09-03 Mac app report,
"I click a path, the page flashes, nothing opens; the second click works". The Mac app is the
only surface that hits this, because its window is the one that lives across every deploy (its own
page-process recycle deliberately waits for the user to go idle). Two answers, in
`src/web/static-mirror.ts` + `web/src/utils/stale-assets.ts`: the durable asset mirror now keeps
ONE DIRECTORY PER BUILD (`<mirror>/gens/<entryHash>/`, newest served first, all of them mounted as
fallthrough static roots), so a replaced build's chunks stay fetchable; and a window whose bundle no
longer matches the server's reloads itself only while HIDDEN for 20s, never with unsaved text, never
on an unknown. A name no build ever had still 404s — the client's reload recovery needs that to stay
loud. Two traps the mirror's shape encodes: eviction counts BUILDS (keep the newest 6, floor 2, then
72h / 512MB), never file mtimes, because `cpSync` stamps copy time and an mtime clock deletes a
build's chunks in the very refresh that makes them previous; and eviction NEVER consults the primary,
because the failure this mirror exists for (a deploy stage swept from under a live server) removes
FILES while the directory still lists, and a pruner diffing against that would delete almost
everything including the mirror's own `index.html`. Ratchets:
`tests/web/static-mirror-{retention,previous-build}.test.ts`,
`tests/web/stale-build-upgrade.test.ts`, `tests/e2e/browser/stale-build-no-flash.spec.ts`.
Note when testing: the Playwright fixture serves the SPA through Vite in DEV mode, so it has NO
hashed chunks — the hashed half can only be pinned against a real `startServer({dev:false})`.

**⚠️ Launch dev:prod from a non-niced shell.** A server started from a niced parent (e.g. a
background agent session) inherits the positive nice and gets scheduler-starved under machine
load — HTTP latency spikes that look like app bugs. The server logs an error at startup and
exposes `processNice` in `GET /api/config` when this happens; fix = restart from a normal shell.

**The deploy runs the server at app priority, the agents stay below it.** dev-prod.sh loads the
server as a launchd job from a plist with `ProcessType` Interactive (base priority 31), because a
bare `launchctl submit` job is launchd's Standard type, which macOS schedules in the utility band
(priority 20) together with every agent build and test on the Mac. The same plist sets
`WALNUT_DAEMON_QOS_CLAMP=1`, and only then does the server start its background children under
`taskpolicy -c utility` (`src/lib/background-qos.ts`): the local daemon and so every agent
session, the embedding model workers, the git backups and the history compaction keep the band
they had, and so do the model adapter's `claude -p` turns for helpers nobody waits on (titles,
summaries, placement: `purpose: 'background'` on the call). Those turns hold at most two of the
adapter's three `claude` slots, and a turn a person waits on goes ahead of every queued one,
except that one queued 10 s or longer may take a freed slot while no other background turn runs
(never with a single slot: `withSlot` in `src/model/providers/adapter-claude-cli.ts`): with one queue for both, a turn
queued behind three utility-band ones took 5.7 s instead of 2.9 s (load ~375). Every `claude -p`
Walnut starts outside a session is the local daemon's child (capability `oneshot-v1`,
`src/providers/oneshot-core.ts` + `oneshot-client.ts`; user rule 2026-10-26): micro-Claude and its
warm pool (AI search, the direct route of `ai.run`), inline subagents, and those adapter turns.
So with the clamp on they all run in the daemon's band, the turns a person waits on included (a
clamped daemon cannot start a child above it); the slot gate above still lets those go first, and
a daemon older than `oneshot-v1` runs them in the server as before. Still in the server's band:
one-shot setup at boot. The server reads
the request once at startup and removes it from `process.env`, so the in-app terminal and every
other child built from that env never carry it. A server started any other way (a terminal, the Mac app, the submit fallback) clamps
nothing, so its children inherit its band. `WALNUT_DEVPROD_PROCESS_TYPE=Standard` restores the
plain submit. The job lives in one launchd domain (gui/<uid> from a GUI login, user/<uid> over
ssh), and every look at it goes through `launchctl print <domain>/<label>`. A removal boots the
label out of both per-user domains and waits (bounded) until launchd lets go; when it will not,
the deploy starts the new server with nohup instead of failing. Check with
`ps -o pri= -p <pid>` (31 server, 20 daemon; the compiled file's daemon is the same file run as
`walnut-<target> __daemon --start`).

**⚠️ NEVER wrap `bun run dev:prod` in a bare `launchctl submit`.** `launchctl submit` jobs are
KeepAlive — the script exits, launchd re-runs it, forever. dev-prod.sh is a one-shot deploy, so
this becomes a kill-server-every-10s loop (2026-07-25 incident: 7 back-to-back restarts, one of
which killed a healthy mid-compaction CLI session). If you must deploy from a niced shell, use a
one-shot wrapper with a done-marker (`[ -f /tmp/<marker> ] && exit 0; …; touch /tmp/<marker>`)
so re-runs are no-ops. dev-prod.sh also has its own storm breaker now: a <120s cooldown after a
successful deploy (exit 0 no-op) and a refusal to kill a listener younger than 120s
(`WALNUT_DEVPROD_FORCE=1` overrides both for intentional rapid redeploys).

### Isolated sandbox for onboarding / provider testing / demos

`scripts/walnut-sandbox.sh` spins up a fully isolated Walnut on **:3457** (`env -i` + throwaway
HOME + isolated data/daemon dir) to test any credential or record onboarding — **never touches
prod 3456** (the script refuses to act on 3456). Docker-free (Docker may be locked down in some managed environments).

```bash
scripts/walnut-sandbox.sh clean                  # no creds → first-run onboarding banner
scripts/walnut-sandbox.sh token   [region]       # use host AWS_BEARER_TOKEN_BEDROCK
scripts/walnut-sandbox.sh keys    [region]       # use host AWS access keys
scripts/walnut-sandbox.sh profile <name> [region]# use a ~/.aws profile (incl. credential_process)
scripts/walnut-sandbox.sh test  '{...}'          # POST /api/config/test-connection (real round-trip)
scripts/walnut-sandbox.sh chat  "msg"            # one message to the Personal AI → prints its reply
scripts/walnut-sandbox.sh record out.mp4         # record the onboarding chain (needs a bearer token)
scripts/walnut-sandbox.sh status | stop          # health | stop+wipe
```

- **HOME differs by mode (subtle):** `clean`/`token`/`keys` use a **fake HOME** (hides `~/.aws`,
  `~/.claude`); `profile` uses the **real HOME** + `~/.toolbox/bin` on PATH so `credential_process`
  (e.g. `ada`) can resolve. Token would otherwise win over a profile, so token isn't injected in profile mode.
- `chat`/`record` use `scripts/walnut-sandbox-chat.mjs` / `onboarding-chain.mjs` (WS RPC + CDP recorder).
- **Rebuild gotcha:** the sandbox runs `dist/cli.js`; `web:build`/`build` DO recompile the server
  via `tsup`, but if you edit server code and forget to rebuild, the sandbox runs STALE server logic.
  A `400 invalid beta flag` on `chat` while `test` passes is the classic symptom of a stale `dist`
  (an old build that still sent the removed `extended-cache-ttl` beta). Rebuild (`bun run build`) and re-run.
- **Verified-good provider behavior (do NOT "fix"):** Walnut does NOT send `extended-cache-ttl`
  to Bedrock — sending it answers `400 invalid beta flag`, and the 1h cache is GA anyway
  (`cache_control.ttl:'1h'` rides through verbatim; see `EXTENDED_CACHE_TTL_BETA` in
  `src/model/providers/defaults.ts`). opus-4-8 uses `thinking:{type:'adaptive'}` + the
  `interleaved-thinking` beta. This combo + a `~/.aws` profile is confirmed working end-to-end.

## What Is Walnut

Personal AI: tasks + knowledge + AI sessions. **Tasks are the atom.** `Project → Folder → Task → Subtask`. A folder (`task.group_id`) is optional and belongs to exactly one project; a task with no project lives in the **Inbox** (`project = ''`). Event Bus connects everything. See [ARCHITECTURE.md](./ARCHITECTURE.md).

### Key Rules for Implementation

- **Parse harness-owned data the harness's official way — never invent Walnut-side bookkeeping on top of a file Walnut doesn't own.** If the CLI/agent already defines the format's semantics (e.g. transcript JSONL is a `parentUuid` tree and the CLI chain-walks to the active leaf), port that exact logic. Offsets/fingerprints/side records reinterpreting someone else's file go stale the moment the owner writes in a way Walnut didn't initiate. Walnut-invented logic belongs only in Walnut-owned formats (stream capture, ACP journal).
- `task_create` takes an optional `project`; an unknown name auto-creates the registry row (`task_projects`, source `'local'`). Inbox (`''`) has no registry row and can never be claimed by a sync provider
- **Work created from inside a task lands beside it** (`src/core/sessions/caller-placement.ts`, enforced in `POST /api/v1/tasks` from the `x-walnut-caller-sid` header): the caller's project and its folder (a new one holding both when it has none; a SUBFOLDER of it holding both when the folder's own OPEN tasks include work that is not the caller or its subtasks (finished tasks, hidden on the board, and work already in a subfolder don't count: 2026-10-07, a leader under a folder of finished tickets nested once per subtask), so the folder tree follows the subtask tree, and the board draws it nested; a folder made for the caller, or found holding only its family, stays that family's home (`family_group_id`), so a newcomer landing in it, a fork of a subtask say, never pushes the family a level deeper: 2026-10-06, five levels), the caller's board tier (a Focus task's subtask is born in Focus; `pinned`/`focus_tier` in the body win), plus the caller's cwd when the caller runs on the project's default host. Whatever a session files is that session's task's SUBTASK (`parent_task_id`; a Worker pill on the board that leads back to the parent, and a `Leader · N` pill on the parent that counts and lists only the OPEN subtasks wherever they live, gone when none is open), in its project, in another one, and from a Personal AI ask too; a subtask in another project takes that project's source and renders as a top-level row there (nesting stays inside one project). Explicit `project` / `group_id` win, but a worker naming a project other than its own task's must say why in `project_reason` (10+ characters), else `409 project_elsewhere` and nothing is made (2026-10-14: a leader guessed a project from its repo's name and filed its subtask outside the team's project); a folder never follows work into another project; only WORKER callers are placed from (the Personal AI's asks, born `walnut_agent` or filed under an `Ask …` project, keep the old defaults for project, folder, tier and cwd and get the parent link alone; humans and replicas keep the old defaults entirely). `GET /api/v1/me` says who the caller is and where it stands.
  A start from a worker takes the caller's host + cwd as ONE pair when the task has no place of its own, and `task_list` from a worker defaults to the folder ring (project when no folder).
  Server brakes, never prompt-only (`src/core/sessions/subtask-limits.ts`): a session's subtask sits at most 3 levels below a top-level task (`409 subtask_too_deep`), and no lead caps how many of its subtasks run: each host has its own slots for automatic starts (`src/core/sessions/host-admission.ts`, one per two CPUs and per 3 GB of memory or `session_limits`), and a worker's start that no host has room for fails (`409 hosts_full`), never queued: a start answers once its session is up or with the reason it did not start (2026-11-06, the user: "if it did not start, it must be an error"). A task asked to start or sent work that shows nothing for 10 minutes tells its asker (`session-request-notify.ts` remindSilent). The human and an ask are never limited.
  **A session's task title is a few words** (`src/core/sessions/task-title-brake.ts`, in `POST` and `PATCH /api/v1/tasks`): over 60 characters from any session caller, the title is cut to its head at once (the long form becomes the description when the create had none) and refined in the background by the fast model with the session-title recipe, written only while the task still wears the cut. Humans and the phone (no caller header) are never touched. The prompts also pin two words: a "subagent" is Claude Code's Agent tool, a "subtask" is a Walnut task (2026-09-30: "use a subagent" was answered with a Walnut subtask carrying a 140-char title).
  **A subtask is a teammate, not a step**: it owns one area with a clear goal; one per ask (the session asks the user before splitting), and more work in its area goes to it with `task_send`. Prompt, `task_create` description and skill say so; the nudge is server-side: every create that makes a subtask returns the caller's other open subtasks in `placement.open_subtasks`, and the op outcome names them (2026-10-01: one "use a subtask" ask became one task per sentence, then a fourth for the third one's follow-up).
  **A leader adopts, and keeps a Board.** An existing task becomes a worker through `PATCH /api/v1/tasks/:id { parent_task_id }` (`task_update`; `""` releases; on the board, the Leader flyout's "Adopt…" and the task kebab's Team row, collapsed by default (2026-10-05: rare, so one row that expands on click), holding "Adopt a worker…", "Release a worker…" (a leader lets one go; it keeps running) and "Leave leader"): the server checks the parent exists, is not the task or one of its descendants, and, for a session caller, that the adopted SUBTREE still fits `MAX_SUBTASK_DEPTH`; humans are never limited; project and folder stay put; the adopted task's session is told through `walnut-notify` only when it is MID-TURN, where the notice rides along for free; an idle one is never woken for bookkeeping (2026-10-02: five idle workers each woke to say "this is only a notice"), it reads the link from its task when it next runs (its prompt line about its leader is built at spawn). Every task may carry a **Board** (`src/core/boards/`, `src/web/routes/board-v1.ts`, ops `board_get/set/edit/post/post_delete/project_set/remind`, skill `walnut-board`, the `Board` tab beside Files and Changed): one HTML document the leader writes and any task in its tree may edit (exact-string `edits`, each `old` once, all or none; a version for optimistic writes), stored at `WALNUT_HOME/boards/<taskId>.json` with the chat threads and the user's marks beside it, rendered in a sandboxed `allow-scripts` iframe (no same-origin, CSP) with Walnut's runtime upgrading `<walnut-task>` (live chip), `<walnut-thread>` (a human's message is stored and delivered to the task's session like a human `task_send`; the session answers with `board_post`), `<walnut-mark>` (the user's note), `<walnut-strip>` and `<walnut-unread>`; beside the html Walnut also keeps the board's projects (one area of the board each, not a Walnut project; their status, set with `board_project_set` or picked by the user on the `<walnut-project>` pill, recolors every `data-project` element; a session's write keeps the user's pick unless it passes `override_user`, 409 `status_set_by_user` otherwise), the user's read ticks on `<walnut-check>` points (the server hashes each point's html, so an edited point comes back unread), the user's answers to `<walnut-choice>` (an option, their own words from the same docked composer as a thread's, or both, delivered to the session as one message like a thread message), reminders on a choice or a thread (a server clock, `board-reminders.ts`, modelled on `task-wait-until.ts`), and the sections the user has seen; ticks, answers and seen sections record who made them, the user or an agent (`by`), and an agent's shows as such. A team shares ONE board (`resolveTeamBoardTask`: the nearest ancestor with a board, else the root; `?team=1`, `/board/owner`, and every `board_*` op's default); `board:changed` carries a top-level `taskId`. The leader hears about the Board at spawn when it has open subtasks and in every `task_create` outcome that lists its team (2026-10-01: a user could not follow 40 workers through the leader's chat and had the leader hand-write exactly this page as a local file, whose chat never reached the AI and whose marks it could not read).
- **One status the user reads, the same everywhere** (`src/core/user-status.ts`, 2026-10-25, the user: "I don't even know what needs me"; one team read "0 needs you", "1 needs you" and "11 handed back" on three surfaces, because every turn end set NEED_ACTION). The state IS the task's own phase (2026-10-26, the user: "this status need to be self updated by task, can also be updated by leader ... task has status, we already have good task system"; one surface had counted letters, Board choices and reports on their own and said 5 need you where the phases said 1): **Needs you** = NEED_ACTION (or a live prompt), except a NEED_ACTION that only says its turn ended: while that turn's report is still to come (`sessions/report-pending.ts`: for a native Claude Code session the status side question 5 s after the turn stays quiet, else the 4-minute quiet timer or the ask) it reads Waiting, and a report of done reads Waiting on its lead to check and close it (2026-11-01: a wait on the user is Needs you, never Waiting, except Main's, a lead's and a team the user stopped; `tests/core/wait-on-user-is-needs.test.ts`), a wait on its lead or another task reads Waiting on it whatever its words say of the user. **Only a real ask reaches Needs you** (2026-11-02, the user through Main: two finished triage runs, a finished task and a Q&A answer sat there on Walnut's own sentences): its own ask, a wait on the user, a letter, a choice, a prompt, a failure, or a person's NEED_ACTION on a task with no session, or one a person set themself on any task (a request with no `x-walnut-caller-sid` stamps `needs_marked_at`, which holds until the phase moves again: `src/core/sessions/person-needs.ts`, every phase route marks it, `tests/e2e/person-needs.test.ts`). A turn with nothing asked reads **To read** (stored state `read`, 2026-11-02, the user: under Waiting they "looks kike they are waiting for my input"; then "I dont wnat serpeate to read": every surface draws it under Needs you through `shownState`, while the stored state keeps it out of every re-check, push, wake and `user_kind` gate; an older client reads the phase, and Org counts keep `needsYou` without it and `toRead` beside it). Every Needs you row carries one line saying what to do with it, `user_status.category` derived from facts, never a model's guess (`needsCategoryOf`, `needsLineOf`): **Review** for an open review letter, **Can close** for a finished result (with a Close button on Home and the Org rail), **Take a look** for an answer, a hand-back or an error, else **Decide** with what there is to read (`toRead`: `result` for a done report with no lead or an ask with no kind of the user's own, `answer` for a turn that answered his message, `turn` for a hand-back with no report, with the words its last turn ended with once the reconcile tick has read them: `sessions/last-words.ts`, `Task.last_words`), while its NEED_ACTION phase keeps it on his list; a routine run's `result` (and a triage run's `turn`, whose asks are its letters) closes itself (`src/core/routines/run-close.ts`, from the status sync, so a turn end no `session:result` reported closes too; a triage run through its own bookkeeping). Ratchet: `tests/core/only-real-asks-need-you.test.ts`, **Working** = a turn runs, or the task's own work goes on without one (2026-11-02, the user: "wait is just acutally waitng, if it is runnig it shoud be i progress"): its current report says working, or waits `own:` (its build, test run, CI run, deploy or land), and its live process, an armed watch or its WAITING clock brings it back (`ownWork`, `carriesOn`; it may stay parked on that trigger), **Waiting** = really blocked outside its own work (a person's review or reply, another task, its lead, a set time, the machine), TODO, an IN_PROGRESS with nothing running (on the user for Main, on its workers, else `stuck`), or snoozed, each row wearing its wait or snooze mark, never the needs ring, **Done** = COMPLETE; a wait reported before that rule (`rule` absent on the report) is asked once again by the reconcile loop. Ratchet: `tests/core/working-means-own-work.test.ts`. Letters, Board choices, errors, triggers and the report only give the words (the ask, on whom or what it waits: another task, a time, a watch, the machine, its lead, not started). The task moves its phase itself: its turn-end self-report (`FOR_USER_*` lines, `readStatusReport` in `session-hooks/builtins.ts`; 2026-10-31, the user: "one light status-only side question" at the turn end, never a four-minute wait: a native Claude Code session is asked only those lines by `src/core/sessions/status-question.ts` once its turn stays quiet 5 s, and the debounced summary no longer asks them; an ACP engine still answers them in the summary; a reconcile loop, `status-reconcile.ts`, every 5 minutes asks again an idle native session whose stored status is older than its last turn end, at most 4 a pass and once per 30 minutes per session, never waking a stopped one) is applied by `src/core/sessions/report-phase.ts` (stopped on the user or waiting on the user = NEED_ACTION, waiting on others = WAITING with the clock it had, carrying on with a live process = IN_PROGRESS, done = WAITING for its leader; Main moves only to NEED_ACTION), never over a move made after that turn ended, never while a turn runs, and a parked task only for a real stop on the user; the leader starts by applying each stored report the same way. A turn a standing routine's fire started (an armed trigger that fired before, none stopped) ends back in WAITING with its clock (`phase.ts`, `task-wait-resume.ts`). A report older than the latest phase move lends its words, never its ask. Walnut's idle reap is not the task's turn (2026-10-27: a worker's queued test runs were detached commands, and the reap killed them after 20 quiet minutes and put the task on the user): it never ends a session whose own work runs on after its turn (a detached background command or an armed wakeup, `workOutlivesTurn`; extended to 24 h, not disabled), and a worker whose session stopped with no report of its own asking the user reads Waiting on its lead, never Needs you (the hand-back still happens, so a reply its lead waits for is settled and an org lead is woken); a task with no lead reads its turn to read (`toRead: 'turn'`). The server derives the status from the phase plus those facts and stores it on the task as `user_status` (`src/core/user-status-sync.ts`, leader only: event-driven, 1.5 s debounce, a minute sweep, writes only on change, never on a COMPLETE row because `updateTaskRaw` would announce the completion again). Every surface reads that one value: the web task rows (`taskNeedsAction`), the Board's Status view (the default view, `BoardStatusView.tsx`, the user's pick of three designs) and its chips, the kanban card line, the Org counts (`org-tree.ts` `userStateOf`), the v1 projection and iOS (`TeamStatusModel.swift`). A task not derived yet falls back to its phase as an older Walnut meant it (`fallbackUserState`, NEED_ACTION = needs). Agent-written words are plain (the `walnut-board` skill forbids codenames and abbreviations), timestamped, and flagged when a day old. A hand-back with nothing asked, of a task with a session that read Working or Waiting, is stored only `HANDBACK_SETTLE_MS` (20 s) after its phase moved (`user-status-sync.ts`; 2026-10-28: 134 of 519 hand-backs were undone by a new turn within 30 s and flashed the Org page red), and a worker whose own trigger is armed reads Waiting on that watch, never "stuck" (`wakeSource`). A snooze is part of that one status (`src/core/snooze.ts`, 2026-10-30, the user: "if something you look at is already snoozed, don't remind me again, unless you have a particular reason"): a start date still to come, the task's own or an ancestor's, turns a status that asks the user into Waiting until that date (`user_status.snoozed`, its held-back ask beside it; a running turn, a live prompt and Main are as they are), and a session's report cannot undo it; nothing reminds about it before then (the re-checks, the status push to its leader, VP and Main, the lead-waits wake, the wait clock's red dot, the Brief's lines), a letter from or about it needs `snooze_reason` (409 `task_snoozed`), and at the date the re-check clocks start from the date. Every reader reads that one value (web `taskUserState` / `taskNeedsAction` in `web/src/utils/session-status.ts`, agents `user_state` and `user_status` in `task_list` rows, `user_status` on every `task_get_bulk` row whatever fields it names, and a summary always with its `summary_at`, because a summary is the note's history and may describe an ask the user answered days ago: `agentStatusView`, `SUMMARY_IS_HISTORY`; 2026-11-02, Main read two answered asks to the user from summaries); `tests/core/task-state-ratchet.test.ts` fails on a new reminder path that ignores the snooze or a new file deciding needs or waiting from the phase, `tests/e2e/task-status-scenarios.test.ts` walks the flows and checks every surface agrees, and `src/core/status-consistency.ts` (op `status_check`, hourly on the primary) finds a stored status the facts no longer give, a snoozed task stored as Needs you, or a park on nothing outside, re-derives the first two and tells the parked task.
- **A status change is pushed, and a "needs you" says when it was checked** (2026-10-28, the user: "I've already resolved some of these. You should at least verify ... how can we programmatically let everyone know a task's latest status as early as possible"; Main had read him asks he had done in a terminal as fact). `src/core/sessions/status-push.ts`: when a task's settled user status moves (its state, its ask, on whom it waits; held 90 s, so a turn that ends where it began is no news), one line of Walnut's own record (who, from what to what, when, what it asked the user) goes to every session holding an unanswered request to it (mid-turn, else queued for its next message, never a wake), and to the ONE node right above it (`statusFollowerOf`: a worker's leader, a top-level task's VP, a VP's Main; 2026-10-31, the user: updates go up one level at a time and each node passes on what is news in its own words), running or idle: the line rides that session's next message, never a turn of its own (`status-riding.ts`, one item per task, kept on disk across a restart). A VP and Main follow the tasks that report to them the way a leader follows its workers (`src/core/sessions/org-follow.ts`, 2026-10-31, the user: "a VP must always have its up-to-date status"): at the same edges a worker's leader hears (its turn ended with the task open, it completed, it erred, it waits on the user, it parked), the subtask-notice envelope with its last message (a stop's quote cut to 1,500 characters) rides the node's next message in the same item as the task's status line, so no node hears one change twice (a status line or a lead's team note after a notice already told skips that move, `noticeToldSince` in `status-riding.ts`; a node holding a request to the task hears the request's own notice instead; the store is `cache/status-riding.json`); a top-level task's prompt names the VP or Main it reports to. Anyone may put a watch on a task (`task_watch`, `PUT /api/v1/tasks/:id/watch`, `task.watch`, `src/core/sessions/task-watch.ts`, 10 minutes to 7 days): every status the task gives restarts it, a completion ends it, and when it runs out the reconcile tick asks a task that runs or has a turn its status misses (never one with no live session, an ACP engine or a chat lane) and rides the answer to the watcher. A lead keeps its own tracking per task in its MEMORY.md (`LEAD_TRACKING_LINE`). A leader's own status carries its open subtasks' asks as a list (`user_status.asks`, `rollUpAsks` in `user-status.ts`, at most 12, each with the task it came through), so a VP or Main reads every ask below it from its direct reports, and the Org page's Task view lists a subtask's ask under its top-level task (`topLevelOf` in `org-channels-model.ts`). A session's own ask (`needs`, `source: 'session'`) carries `checkedAt` (its turn-end report or phase move) and `unverified` past `UNVERIFIED_AFTER_MS` (4.5 h); a trigger of the task only adds `watchedAt` (its last run, kept to 10 minutes), never a check, because it watches the task and maybe not this ask (`user-status.ts` `stampChecked`, words in `checkedLine`, mirrored on iOS); `src/core/sessions/needs-recheck.ts` wakes the owning session after `NEEDS_RECHECK_MS` (4 h) to check it itself and arm a trigger when a script can tell (6 in a row while it keeps asking, reworded or not, then once a day, never stopping (2026-11-04, Main's reflection: asks sat NOT VERIFIED 18 hours to 5 days behind a capped count or a watch whose runs counted for nothing; ratchet `tests/core/needs-never-stale-guard.test.ts`), `task.needs_recheck`; a re-check turn's Working is held from the push). A trigger the session armed with `trigger_create watches:"ask"` checks that ask each run (its last check without an error is the ask's `checkedAt`, `checkedBy: check`), and one watching a letter id keeps that letter's re-check quiet while it is armed (`src/core/sessions/recheck-watch.ts`, `task.recheck_watch`), and a re-check with nothing new is answered with the agent-ack line, so the user's chat does not repeat the same status (2026-10-29: one lead wrote the same letter status four times, a 15-minute trigger on it notwithstanding). A request its target handed on (`hands_on`) is never settled by a plain turn end: only a real reply (the target's, or the task that took it over, which may answer it directly) or its deadline (then overdue, still open) (`request-handoff.ts`, `session-send-core.ts` performReply). The self-report's FOR_USER_ASK line asks for that check, the Board, the Org "Needs you" cards, and the phone print the checked line or Not verified, and Main's and a lead's Brief prompt lists the team's live asks with their time (`briefSectionLines`). Ratchets: `tests/core/needs-checked.test.ts`, `tests/core/status-push.test.ts`.
- **One decision shows once: tasks of one team asking the same are one ask** (`src/core/same-ask.ts`, `same-ask-tree.ts`; 2026-11-04, a VP's reflection: a lead and its subtask both sat in Needs you asking whether to create three alpha test accounts; then siblings under one lead asked one design decision with opposite picks, and an ask Main had decided still waited on the user). The stored status is made in one place, `statusOf` in `user-status-sync.ts` (the consistency check compares against it too): after `rollUpAsks`, an ancestor whose own ask (a session's question or a Board choice; never a live prompt, a failure, a person's mark or a decision letter) is the same as one its subtree carries up reads Waiting on the deepest task that asks it (`kind: 'task'`, `user_status.sameAsk`), so Home's Needs you, the Org Task view and counts, a VP's or Main's rolled asks, the Brief and the phone show it once. The fold stays while the deeper task still holds the ask (its turn runs on it, it is snoozed with it), and once it asks no more only on evidence the user answered it there (a person's message to it after the fold, `SessionRecord.humanInputAt`, `readFoldEvidence`) and only while the ancestor holds the same report (`sameAsk.answered`; its next status question tells it where the answer went); anything else brings the ancestor's own ask back, so an ask is never hidden on a guess. Teammates beside each other fold the same way (`teamKeysOf`: under one top-level task, or in a project one open lead or VP leads, by `Task.project` or `org_leads`; Main's whole org is no team, nor the Inbox): the older task keeps it (a fixed order, so the row never trades places as their turns come and go; a holder whose turn runs on the ask still holds it), and the other reads Waiting on it (`sameAsk.team`); its status question says to send that task its own pick when it differs, and once the ask is answered there it is told so once (`tellFoldAnswered`). **An ask a lead, VP or Main decided for the user** is sent with `task_send answers_ask:true` (`src/core/sessions/ask-answer.ts`; only from a task it is under, its lead or VP, Main or the person: `409 not_above`; `409 no_open_ask` when it has no ask of its own to answer, naming the task a folded ask shows on, and for a Board choice; an ask of a kind only the user approves (outward, spending, credentials, permissions, cloud_delete, irreversible) needs `user_words` quoting him: `409 user_words`): Walnut leads the message with one line telling the asker (the one time it is told), records `Task.ask_answered`, delivers it only to an open task whose session's CLI is alive, judged on the session the message would really reach (a reply's own routing: past the asker for a carried request, or the asker's live fork) and kept to the write itself (only that session, still live when its row is stored, its stop fence from the check: `liveSendGuard`) (`answerReach`; 2026-11-06, the org reflection: an answer resumed a stopped task, which pushed two commits and armed a trigger: a COMPLETE task, or one with no session running, gets the answer recorded with nothing delivered, `delivery: 'recorded'` with `notWoken`, a request it asked settled by it, and acts on it only through task_start or `force` with a reason; a folded teammate is told only while it runs; the daemon's host never delivers one itself, leaving it to the server, or refusing it while the server is away; ratchet `tests/core/sessions/ask-answer-no-wake-guard.test.ts`, `tests/e2e/ask-answer-no-wake.test.ts`), never against what the user wrote in that task unseen (`src/core/sessions/user-spoke.ts`; 2026-11-07, Main's reflection: Main answered "local only" after his "submit the cr"; the answering task, and a task whose `task_report_set` takes the ask off Needs you, gets `userSaid`: his messages there since the task began asking, `StatusReport.needs_since` kept across consecutive needs reports by both report writers, else his last 3, read from the task's chat (`user-asks.ts`) or an Org node's Threads; one he wrote after the ask appeared refuses the step with `409 user_spoke_since` quoting them until `user_words` quotes each (one check, `settleCheck`, also run by another task's `task_update phase` off NEED_ACTION in both PATCH routes, and counting the words of a teammate whose same ask is folded under it); a close by another task is held (`owed_to_user`) naming his words, and held again when he wrote since; a plain message to a task that still asks shows them beside `openAsk`; the person's own answer is never checked; ratchet `tests/core/sessions/user-spoke-guard.test.ts` fails on a new file that records an answer for him or writes a task's report, `tests/e2e/ask-answer-user-spoke.test.ts`), and `answeredAbove` keeps that ask off Needs you (Waiting, `user_status.answeredFor`) for 7 days while the task asks the same, its next reports repeating it included; a teammate folded under it, or asking it later, reads it answered. Main's and every lead's default instructions say so, and the status question tells every session never to ask what its lead, a VP or Main already decided. Sameness is deterministic and strict, no model call: the same content words in the same order, at least three (`askWords`; a review showed similarity folding "staging" under "production"); the sessions' own word comes first: the turn-end status question (and the summary path) lists the tree's open asks and reads `FOR_USER_ASK_SAME: <task id> | different` (`StatusReport.same_as` / `ask_distinct`; a "different" from either side wins). A report line set from outside (`task_report_set`), a session's `action_required` letter and a Thread an agent hands the user are refused with `409 same_ask` naming the task that already asks it (an open decision letter anywhere in the tree, or a status ask below the writer or in an org node's team; an ancestor's status ask is never a refusal, it folds), unless `same_ask_reason` says why it is another decision; a person's own writes and a host's journal ops are never checked. **One task's ask reworded is the same ask, never news** (`src/core/ask-decision.ts`; 2026-11-09, the CEO's gathering of VP reflections: one ask reworded four times a day, six asks reading as new at every refresh for days, and all five "changes" of one Brief refresh new words with no new fact; the user, 11-07: "can we not ask me everyfucking day"): every ask a task puts on the user carries a decision id (`ask_id` on its report, `user_status.decision`), minted by Walnut and kept on every later report that asks again in any words; the status question shows the session its id (`FOR_USER_ASK_ID: <id> | new`, `FOR_USER_ASK_NEW: <what is really new>`, which counts `ask_rev` up once), `task_report_set` takes `ask_id`/`ask_new`, and only a session's "new", or words that no longer ask it (`sameDecisionWords`: another target swapped in, a "not", another direction or number, almost no shared word), mint another; other options or another way to answer count the revision up by themselves. The id only keeps a rewording from being news: the user's words since an ask are still checked on any reworded line (`user-spoke.ts`). News is the decision key (id, revision, option count, the task's due date, the links it points to), never the words, on every path: the status line to the node above (`status-push.ts askNews`), a Brief's moved lines and its refresh wake and hint (`brief-fresh.ts` `n2:` marks), the Needs you changes a refresh tells (`toldDecisions`), the needs re-check count, a stop notice to a VP or Main that was told the decision (`status-riding.ts decisionToldTo`), a thread write that only rewords its ask (no new `updatedAt`), and a watch armed on the ask. Ratchet: `tests/core/ask-decision-news-guard.test.ts` (a rewording corpus through every path; every caller of a "moved" primitive and every file comparing ask words listed from the source), `tests/e2e/ask-decision.test.ts`. Ratchets: `tests/core/same-ask.test.ts`, `tests/core/same-ask-paths-guard.test.ts` (fails on a new caller of `deriveUserStatus`, a Needs you source not listed, a new report writer, letter sender or thread writer that skips the check, a new writer of `ask_answered`), `tests/core/same-ask-team-guard.test.ts` (one org with every relation, the real `statusOf` pass after pass: no two related tasks both show the ask, a new kind of team key fails), `tests/e2e/same-ask.test.ts`, `tests/e2e/browser/same-ask.spec.ts`.
- **Walnut text that rides a message is a message hint, defined in ONE registry** (`src/core/hints/`, [docs](./docs/reference/message-hints.md); 2026-10-27, the user: rich and reply-language lines rode every message, "a generic feature to define these hidden messages ... per round, per x round, per compaction"). Question number, rich output, voice reply, the org notes and custom hints are registry entries (built-in, or config `session.hints`), each with its triggers (`every_turn`, `every_n_turns`, `session_start`, `after_compaction`, `on_change`, `on_request`), audience and placement; every send path calls `prepareHintedSend`, none knows which hints exist. Each hint is a Claude Code style `<system-reminder>` block opening `Walnut (<kind>):`; the message's own `<system-reminder` tags are escaped first, so display takes hints off by structure and never matches the person's words. History rows carry `hints` and `rawText`; the session menu's "Raw input & output" shows what the model received. People and agents change hints the same way (`/api/v1/hints`, ops `hint_list/hint_set/hint_reset`). Standing rules are not hints: they live in the system prompt and the instruction notice. The user's Reply language setting is one of those rules: a line of every session's prompt (`src/core/sessions/reply-language-context.ts`), never a hint (2026-11-06, the user: "just remove reply language").
- **Text Walnut adds to every message, or every few, costs every session on every turn** (a hint, a hook's `additionalContext`, a notice; 2026-11-06, the user: "especially one every message or every a few message very expensive"; a day of the VP's messages carried 820 KB of hints, 7.3 KB on each of the user's own messages, 63% of it rules the prompt already had). Add one only when nothing else can carry it, never repeat a rule the system prompt has, keep it to a line or two, prefer session start, after compaction and on change, and measure its bytes per message before landing.
- **Main and every lead keep a Brief for the user** (`src/core/sessions/brief.ts`, `web/src/components/org/OrgBrief.tsx`, 2026-10-26, the user: a "persistent letter ... managed by AI ... must be up to date", and "All the leader or main agent, or VP need to utilize this"). Above an Org node's chat a switch offers **Status** (Latest and the Needs you cards) and **Brief**: a short Markdown letter, stored as `task.brief {text, at, marks, asks}`, at most `BRIEF_MAX_CHARS`. **It is a document the node edits itself, any time** (2026-10-31, the user: "不能说是静下来四分钟再重写 ... 这就是一个 file ... 每三轮提示他一下"): ops `brief_read` / `brief_write` (`GET`/`PUT /api/v1/tasks/:id/brief`, `src/core/sessions/brief-write.ts`; whole `content` or exact-string `edits`), never a turn-end field; Walnut reminds it through the `brief` message hint (every 3 messages, at start and after a compaction, and when its team moved since it was written: `sessions/brief-hint.ts`). It has a fixed shape (2026-10-30, the user: "what is being done, what does not need me, what needs me ... brief 就是 AI 写的一个 brief. That's it"): three sections, always all three, in this order, `### Needs you`, `### Working`, `### Waiting / snoozed` (`BRIEF_SECTIONS` in `src/core/user-status.ts`), each task's line under the section of its own live status (`user_status`). `brief_read` hands the session the team's open work as those three lists (`briefSectionLines`: a lead's projects and every task below it, Main's leads and every task below Main, Main's Needs you org-wide, each ask with when it was last checked) and the rules (`briefRules`); every write and every render is shaped live (`structureBrief` with `live` in `src/core/sessions/brief-structure.ts`: a line under the wrong section moves to its task's, a finished task's line goes, every task asking the user that the letter leaves out is added in its own words, an empty section gets a "nothing" line). **Between writes the page says how current it is** (`src/core/sessions/brief-fresh.ts`; 2026-10-31, the user on a VP's Brief 3.5 hours old: "明显的感觉现在已经不是 up to date了"): each write stores every named task's status mark (`statusMark`, the same key `status-push.ts` tells a move by), the head reads "Written … · N lines moved since · N finished · N asks added by Walnut", and a moved line shows its task's words now. Opening a stale Brief (the web's `useBriefRefresh`, the phone's session stream, an attention heartbeat) asks the node for one refresh turn (`src/core/sessions/brief-refresh.ts`, `POST /api/v1/org/nodes/:id/brief-refresh`): only while the user looks, at most once per 15 minutes per node, never while it runs, and the page says "Updating…" until it writes. The Brief view shows the letter alone, its task pills and section headings wearing their live status dot; the Needs you cards live in Status, which a node always opens on; the Brief tab is dotted when it was rewritten since the user read it here. **Main, a VP or a lead is never Needs you for its own question**: that stays in its chat and it reads Waiting on its teams (`orgNodeNeverNeeds` in `user-status.ts`, and `report-phase.ts` never moves it to NEED_ACTION; ratchet `tests/core/org-node-never-needs.test.ts`). The user's own requests to it are its Threads, and a thread waiting on him is shown as threads, never as the node's Needs you (2026-11-03, the user, after a thread had made every VP read Needs you on each surface: "All replaced to Need Action which is the same as the task status, is this right?"; before: "thread is better than need u"): `user_status.threads` (`threadCounts` in `org/thread-view.ts`, read by the status sync) carries the counts the Org list, the node header (no "N open"), the CEO line, the org chart and the phone show. On the CEO's and a VP's own row and header the red is its threads alone (2026-11-03, the user: "the little red should be thread instead of need u, also, even if no thread, show 0 thread"): the badge counts the threads his view shows, "0" in grey when none, the header leads with "N threads" and draws no Needs you pill, the avatar dot and the row's red follow them, and its tasks' asks are in its Tasks (`threadsBadge` in `org-channels-model.ts`, `ChatOrgModel.swift`; `tests/web/org-threads-first.test.ts`). The node's own state, ask and wait stay as they were, so a thread coming or going is no news for the node above. Ratchets: `tests/core/sessions/brief-structure.test.ts`, `brief-fresh.test.ts`, `brief-fresh-surfaces.test.ts`, `brief-refresh-run.test.ts`, `tests/e2e/brief-refresh.test.ts`. The user answers in the chat. The rule rides Main's and every lead's prompt (`BRIEF_LINE` in `session-context.ts`; ratchet `tests/core/sessions/brief-rule.test.ts`).
- **Main and every lead keep Threads: the user's own requests, one thread per subject** (`src/core/org/threads-model.ts`, `thread-capture.ts`, `threads-store.ts`, routes `src/web/routes/org-threads-v1.ts`, ops `thread_list/thread_update/thread_create/thread_split/thread_file`, `web/src/components/org/OrgThreads.tsx`; 2026-11-01, the user: "every user request becomes a thread ... I can acknowledge, or do follow up actions ... this brief is focused on task, but this thread is my own request", then "if my multiple ask is about the same thread, add it below that thread"). The first view beside an Org node's chat, before Tasks and Brief, and the one a node opens on until the user picks another (2026-11-04, the user: "this thread shoudl also be the first ... and default ooen this"; each node then opens on the view it was left on). The third view above an Org node's chat, beside Task and Brief. Walnut files every message a person sends the node (typed into its chat on any surface, or the note answering one of its letters; never a peer's, a notice, a slash command, a side question or a choice alone) on the send path itself (`prepareHintedSend`, whether or not the hint is on), at `WALNUT_HOME/org/threads/<taskId>.json`, and the same message carries the `threads` hint, one line: which thread it went to, and for a new one the open threads it may belong to (`merge_into`). The node keeps each thread current (title, ask, state `new|working|needs|answered|pending`, the summary `reply`, latest first with a sentence or two of background, its tasks) with `thread_update`, and news about a thread updates its summary, never adds to it; a wrong merge is split back out (Split out on a message, `thread_split`). The card's Follow up sends a message filed under that thread (`threadId` on `session:send`); Acknowledge closes it (the node hears once on his next message, never woken for it); a new ask about a closed subject is a new thread that `follows` it. A retry of the same words within 2 minutes is one message. Only the primary keeps them (the companion reads none, refuses writes). A thread reads as its conversation (2026-11-01, the user: "do it like slack thread"): Walnut reads the node's chat reply to each of his messages from its transcript when its turn ends (`thread-answers.ts`, kept on the message as `answer`), and a card holds only its title, the summary and the answers he has not reacted to, each folded with Expand / Collapse on the answer; what he already knew is gone from it, and the whole conversation (numbered, the answer under each message) opens from a toggle in the card body (`thread-view.ts` `cardView`; 2026-11-01, the user: "I replied from the first one to the ninth, so why still show the first to the eighth?"). He knows an answer only once he reacted after it (wrote again, or the node marked `known` after he reacted elsewhere, or Acknowledge): Walnut refuses a node's `known` or close before that (`not_reacted`, 2026-11-01, the user: "I had not replied ... you cannot remove it"); the rule rides the prompt and the ops (`thread-rules.ts`). His view shows a thread only when it has something for him (`thread-view.ts` `shownToHim`): `needs` or `answered`; `new`, `working` and `pending` are out of sight behind one "N in progress" line, a working thread with the node's chat answer on it included (2026-11-07, the user: the real reply comes about 20 s later, "before it replies, what is the use?"). A summary replaces the node's earlier answers on the card (`replyAt`, `supersededBySummary`: an answer to a message the summary was written after, unless it came over 10 minutes later), and every card leads with a summary line: until the node writes one, Walnut's own from its latest answer (`cardView().summary`, `plainLine`), and the `threads-tidy` hint asks the node for a missing or outdated one (`summaryOwed`; 2026-11-08, the user: "no summary at all ... I should not need to open it"). A message of his newer than the summary outdates it (`summaryBehindHim`; a summary from before `replyAt` gets its time at the first write after, `stampLegacyReplyAt` in `mutateThreads`, so a state-only write never makes it current; 2026-11-08, the user: "I already replied, but this thread is not updated at all", then "walnut need to make sure it doesn't happen"): the card says so under the summary, `threads-tidy` lists such threads first, and the node's Stop hook (`/me/open?hook=stop`, `thread-summary-stop.ts`) holds its turn end with them named until it rewrites the summary or sets pending (twice at most for one message of his). New answers fold behind one "N new answers" line under the summary (2026-11-08, the user: "the history should all be collapsed unless I click to expand"). A thread left `new`/`working` 30 minutes, or `needs` a day, with nothing new is overdue; needs checks keep at least a day between quiet checks and never ask again or rewrite a summary just for a checked time, with one additional check in the day before an open linked task's due date (`threadOverdue`): the primary wakes the node once per stretch with the threads named (`thread-recheck.ts`, source `thread-recheck`, `thread.recheck`; not while the org is off, `WALNUT_THREAD_RECHECK=0` turns it off), and 30 minutes later the card says so (`overdueLine`) and a hidden one with something for him shows (`tests/core/org/thread-overdue.test.ts`). His message in a thread makes it working, so it leaves at once (2026-11-01, the user: "if i reply to a thread, it shoud go out of the thread immediately instead of just show working"); a new summary written without a state turns it `answered` and back; `pending` (a handoff the node judged no news) stays hidden even with its answer on it ("if it is just assigned no need to tell me ... thread pending, which is auto hide like closed"). Reopen shows it again; `thread_create` opens only `needs` or `answered`. The Threads tab carries a dot (like Brief's) while a thread his view shows had news after he last looked at Threads (`newsAt`, seen marks in `org-brief-model.ts`); the whole Threads / Tasks / Brief side bar closes with its × and comes back from the node header's switch (`node.railHidden`, per node, 2026-11-02: "it is a panel too, you can at least let me close it"), the switch dotted when Brief or Threads has news. **The node page is one header and the chat, with the side bar beside it** (2026-11-02, the user picked design 1 of the side-bar round; `OrgNodeDetail.tsx`, `OrgTaskRail.tsx`): the name once, its counts and leads on one row, and on its right the chat's state pills (the wait, Cron, Trigger, Worker, Leader) and its link tags folded into one control (2026-11-03, the user: "only tag go there, other should still outside"; the run status is in the task detail, never a header: 2026-11-02), the side bar's switch and the chat's kebab, which holds Changed, Files, Terminal and Board ("hide all of them in the drop down button"); the chat draws no header of its own there (SessionPanel `headerSlot`, except in fullscreen, where it needs its own). The chat is always on screen; Team and Board are no tabs of their own but ways the side bar's Tasks draws the work (Status | Team | Board, `node.taskMode`), and the side bar opens into full screen as the same pop-up sheet a session panel does, with a margin on every side, never the whole window (2026-11-02, the user: "all full screens are that pop-up window ... not really the whole screen"; it was a widen beside the chat before), the tab's one full-screen state, kept across a reload (`useFullscreen` with the place `org-rail`; `tests/web/fullscreen-one-state.test.ts` lists every sheet and fails on a stylesheet that makes one edge to edge). Ratchet: `tests/e2e/browser/org-side-bar.spec.ts`. One reply that covered his messages in two threads (one came in mid-turn) stays off both cards and shows only in the conversation (`sharedAnswers`); a task the summary already shows as a pill gets no second chip. A node may open a thread itself (`thread_create`, an `opener` post with its source) for what he must act on or asked to follow; his reply files under it. He (or the node, `thread_update snooze`) can put a thread aside until a time (2026-11-02, the user: "in thread has a snoozed section, also a snoozed button"; `snoozed` on the thread, `snoozedNow` in `thread-view.ts`): it leaves his view, the count and the tab's dot for a collapsed Snoozed section saying when it comes back, and returns on its date (the view re-reads then) or earlier on news (his message in it, a node answer after the snooze, a move to needs or answered); the node is not woken and the `threads-tidy` reminder skips it meanwhile (`tests/core/org/thread-snooze.test.ts`). Only open threads show; a closed one is kept 7 days, then the primary's sweep deletes it. Ratchets: `tests/core/org/threads-model.test.ts`, `thread-capture.test.ts`, `thread-view.test.ts`, `thread-answers.test.ts`, `thread-rules-guard.test.ts`, `tests/e2e/org-threads.test.ts`, `tests/e2e/browser/org-threads.spec.ts`.
- **A thread goes with its work** (`src/core/org/thread-follow.ts`; 2026-11-08, the user, after his thread stayed with the VP that had passed the work to another: "should go to walnut vp", then: once the task is moved under the other VP, the first no longer needs to monitor it). A lead's open thread whose listed tasks all do their work in ONE other open lead's team (`ownerNodeOf`: the nearest node in the parent chain, except a task whose parent lead does not answer for its project and another lead does) moves to that lead's Threads with everything on it (`movedFrom` there), and closes on the old one as `dropped` with `movedTo` (`movedAway`; no close kind of its own, so an older release reads it closed); a lead another VP took over hands all its open threads on. The rule runs on task and thread changes and every 10 minutes, so any path that moves a task is covered; Main's threads never move by it, nothing moves to Main or a closed lead by it, and a thread handed somewhere on purpose (`movedFrom.kind: 'hand'`) is never pulled back. A node hands one on with the work by naming `thread` on task_send, org_send, task_start or task_create (`handOffThread` in each route of `HANDOFF_ROUTES`; `hands_on` to another lead's task takes the threads that list it), or moves it with `thread_move`. Both nodes hear one line riding their next message; his Follow up on the old card lands where the thread lives now. Moves run one at a time, copy first then close, and what he wrote into the source meanwhile joins the copy (`addMovedMessages`). Ratchets: `tests/core/org/thread-handoff-paths-guard.test.ts` (a send, start or create route or op that does not hand the thread on), `tests/core/org/thread-follow.test.ts`, `tests/e2e/thread-follow.test.ts`, `tests/e2e/browser/org-thread-moved.spec.ts`.
- **Main and every VP never do the work themselves, and Walnut reminds them at the moment they would forget** (`src/core/org/core-instructions.ts` `orgSelfWorkRule`, `src/providers/org-work-guard-hook.ts`, the `hand-on` message hint; 2026-11-03, the user: "U never never nver do it urslef unless it is under 3 command run", then "I think walnut probally shoudo't stoop it, it shudo use prompt and in correct time to avoid forget, some instruction eaach compation or at start some ever xround"). The rule is the first line of both default core instructions: at most `ORG_SELF_COMMAND_LIMIT` (3) commands of its own in a turn, only to look, then hand the rest on (`task_send`, `task_create`, Main's `org_send`). The `hand-on` hint (Main and the leads only, by its default audience) says it at session start, after a compaction (the next message and the post-compaction hook) and every 3 messages. Inside a turn, every Main or VP session (its role read from its own prompt, `org-prompt-role.ts`) has a PreToolUse hook that counts its own work (a command, host_exec, a file outside its task directory, a subagent, another MCP server's tool; never Walnut's own ops) and adds one line of context to the 3rd call of a turn (`additionalContext`, which the model reads with that call's result), once per turn. It never refuses a call, and there is no setting that would (2026-11-04, the user, of a "Refuse their 4th command" switch: "remove this shodln;'t do this at all"; a `block_own_commands` an older Walnut wrote into config.yaml is ignored and dropped at the next org settings write). With the org off it still applies to what a person types to Main or a VP (a rule, not an org message). `WALNUT_ORG_WORK_GUARD=0` turns the reminder off. Ratchets: `tests/providers/org-work-guard-hook.test.ts` (the hook prints no permission decision for any call, and no source path holds one for Main or a VP), `tests/core/hints/hand-on-hint.test.ts`, `tests/core/org/org-self-work-rule.test.ts`.
- **Main and every VP start by themselves, and one switch turns the whole org off** (`src/core/org/org-autostart.ts`, `org-enabled.ts`, `web/src/components/org/OrgNodeStart.tsx`, Settings → Org `OrgSection.tsx`; 2026-11-03, the user: "they should start from the behind instead of people have to click start and jump to another window", and "people can have setting to disable that ... will disable all the communications or the org, but default enable"). A node with no conversation is started in the background, one at a time, with a Walnut note asking it to read its teams (`org_get`) and write its Brief: when it is made (a new VP or project on the Org page, a lead a VP move made or reopened; making Main starts nothing, since every maker sends it the user's message next), when the Org page shows one with none (`POST /api/v1/org/nodes/:id/start {auto}`; a test run's request starts nothing), a minute after boot (never on an ephemeral server), and when the org is turned on (the settings write or a hand edit of the file). A failed start is tried again by the boot or a turn-on at most 3 times in a row, then only by Retry. A lead starts through `startSessionForTask` (its project's default host), Main through `quickStartSession` as an ask. `task.org_start` (`starting`, or `failed` with the error) is what the page shows; only a failure (Retry), a Main not made yet or a node Walnut skips (a test run's, a start that failed before) has a button, and nothing navigates. The same switch sits on the Org page too (2026-11-03, the user: "the setting should also be in the org view"): a gear beside Org chart opens a panel with it (`OrgSettingsMenu.tsx`, sharing `useOrgSwitch.ts` with Settings → Org), and the gear reads Off while the org is off; the Org page's own overlays are solid in both themes, never the overlays' glass (2026-11-04: the panel showed the VP list through it; `tests/web/org-overlays-opaque.test.ts`). `config.yaml org.enabled: false` (absent = on; only off is written; not in a replica's copy) stops every start of Main or a VP that is not a person's own (`refuseOrgStartWhileOff` in `task-start.ts`, also on the quick-start path), an agent's `org_send` and `task_send` to one (`409 org_off`; an answer to its own request still goes in; a test run cannot move the switch), and every org message Walnut makes: `followContext` drops Main and the VPs (so status lines, org-follow notices, task watches and trigger trouble skip them), the team note and Main's digest, team asks, subtask notices to an org parent, Brief refreshes, the away, Brief and threads-tidy hints, away threads, scheduled reports, the lead-waits wake, the needs re-check of a VP's Board choice, status-reconcile asks, the private-task digest, and a routine aimed at one (`ok` with a Skipped summary); as a backstop, `sendMessageToSession` refuses Walnut's background sources into an org node (`ORG_OFF_BLOCKED_SOURCES`: the wait clock, routines and watchers, re-checks, Brief refreshes, status questions, task watches). `WALNUT_ORG_AUTOSTART=0` (the test setup's default) leaves every start to a person. Ratchets: `tests/e2e/org-autostart.test.ts`, `tests/web/org-start-view.test.ts`, `tests/e2e/browser/org-main-and-chart.spec.ts`, `tests/e2e/browser/org-settings-panel.spec.ts`.
- **The inbox is for what needs the user** (2026-10-05, the user: notifications only when asked, "only the most important information"). A completed task's own `review`/`completion` letters (not pinned, no actions or items, never un-archived) are archived with `archivedReason: 'task_completed'` (`src/core/human-inbox/complete-archive.ts`; a session's completion keeps the unread ones, a reopen restores nothing): a closed task is verified. Walnut's own letters are decisions (a mail send approval, an unsubscribe), reports the user set up (the mail digest), or a fix only the user can make: SSH refusing this machine's sign-in to remote hosts (`src/core/hosts/host-auth-alert.ts`: one letter per expired login across every host and detection point (an outside reminder owning sign-in in `local/sign-in-state.json` holds back only the warning before a certificate's expiry; once sign-in is broken, only its own open letter holds Walnut's: 2026-11-10, a plugin owned it with no letter and the inbox stayed empty; ratchet `tests/core/hosts/expired-login-letter-guard.test.ts`), and one per host for a plain key refusal, read from the `host:status` frames, none for network trouble or a host that is down, archived by itself once a host of it signs in again; the user's own next step comes from config `ssh_login_hint`), or macOS blocking Walnut from this Mac's local network (`src/core/hosts/local-network-alert.ts`: the gateway answers nothing but EHOSTUNREACH twice in a row while its ARP entry is complete, the 2026-11-03 re-bundled app copy; archived by itself once it is reachable again); a park writes none. Every agent-facing text says the same rule (session prompt `session-context.ts`, `human_inbox_send` description, the `walnut` and `walnut-trigger` skills): a letter only when the session is blocked on the user's decision, something needs their review, or the user asked for it (a digest or report they set up, "tell me when X"). Never progress, a finished step, a park or an FYI: the task's state and its session show those, and every letter buzzes the phone. A session's letter repeating its own subject within 10 minutes is refused (409 `duplicate_letter`, checked again under the store lock so concurrent sends make one letter; `resend_reason` for a deliberate one, `src/core/human-inbox/duplicate-letter.ts`), one asking what its open decision letter asks goes out with that letter named (`openAsk`), and a thread reply posted twice is refused (`duplicate_reply`; 2026-11-02: a lead sent one decision three times in a minute). Walnut's own letters, and every word Walnut writes onto a letter (the line naming the user's kind that leads a session's decision letter, a snooze line, a withdrawal's thread turn, a settled reason, a mail item's receipt with any returned error kept after it as the original, a picture-only thread's title), follow the user's reply language, else the display language (`src/core/human-inbox/walnut-letters/`, phrase tables in `thread-words.ts` and `receipt-words.ts`; ratchet `tests/core/walnut-letters-ratchet.test.ts` renders every kind and table in every language and scans for Walnut words written onto a letter from elsewhere), and an open letter of a kind Walnut sends no more settles itself (`retired.ts`). A task answering another task's request reports to that task alone (2026-10-29: finished work reached the user twice, a worker's letter and Main's chat): a report letter from it is `409` unless `user_asked` (`src/core/human-inbox/reply-instead.ts`). A decision letter nobody needs any more is settled (`archivedReason: 'settled'` with a one-line reason): at its task's completion, by its sender (`human_inbox_mark settled`), and through the 4-hour re-check (`src/core/sessions/letter-recheck.ts`). A task answering for the user quotes their words (`user_words`), and an answer approving a step that is hard to undo or leaves Walnut needs words naming it (`src/core/human-inbox/answer-approval.ts`; "clear everything" was read as a public history rewrite). Main's and a lead's chat is written in the user's reply language whoever started the turn (`ORG_REPLY_LANGUAGE_LINE`, with the setting itself a line of every session's prompt and no message hint: 2026-11-05, the user: "I said don't do this every round remove thies, this alsoshoudln't be in any official walnut product", then 2026-11-06: "just remove reply language"). News reaches the user once: a lead that passed a result up to Main ends its own turn with one line (what it passed, to whom), Main tells the user, and the lead tells the user directly only what only it knows and they must act on (`LEAD_NEWS_ONCE_LINE` / `MAIN_NEWS_ONCE_LINE`; 2026-10-30: the same news showed in a lead's chat and in Main's a minute apart).
- **Every letter says who it is from, first** (`src/core/human-inbox/letter-origin.ts`; 2026-11-04: a task's decision letter named an outside company in its subject and the user took it for that company's mail). Nothing in the inbox is outside mail: the store derives `origin` (`task` | `triage` | `session` | `walnut` | `plugin` | `script`, its name, the task) from the stamped sender on every read, and every surface leads with it in Walnut's tint before the subject: the web row and reader (`LetterOriginBadge`), iOS (`LetterOriginBadge.swift`), the bell's card and desktop notification, and the phone push (title = the origin, the subject leads the body). The letter route stamps `via: 'api'` on a caller it cannot tie to a session; any other sender-less letter is Walnut's own. Ratchet: `tests/core/human-inbox/letter-origin-surfaces.test.ts` (every letter type and sender shape, every Walnut letter kind, every web and iOS view that renders a letter's subject, the push).
- **Inbox Triage keeps standing routing rules** (`src/core/triage/routing-rules.ts`, `WALNUT_HOME/triage/routing-rules.json`, routes `src/web/routes/triage-rules-v1.ts`, ops `triage_rule_list/set/delete`, `triage_route`, Settings > Inbox Triage > Routing rules; 2026-11-05: a run is a new task that completes, so "mail about X goes to task Y" said in one run was gone by the next). A rule is a case-insensitive substring of the sender and/or subject (a Slack message's text counts as its subject) and the task its items go to; the first enabled rule wins. Every batch lists the rules and the items they already match (`routingSection` in `batch.ts`); the run hands a matched item over with `triage_route`, which sends it to the rule's task under a line naming the rule (a task that never ran is started with it as its first message, on the task's own place) and records it on the rule (Settings shows what each rule routed); a rule whose task is complete or gone sits out of every batch. Writers: a person, Main or a VP (`org_role` main or lead); any other session gets `403 routing_rule_writer` (a triage run is told to pass the user's words to Main), a test run on a real server `409 test_run` (for `triage_route` too), a replica `501`. The same match to the same task twice is `409 duplicate_rule`; a pasted `Name <address>` sender is kept as the address; a rule keeps who added it (`createdBy`) beside its last writer, and Settings warns on a rule an earlier one fully covers. Ratchet: `tests/e2e/triage-routing-rules.test.ts`.
- **One place per update: a thread's news stays in its thread, never also a letter** (2026-11-04, the user, after a VP sent each thread result as a letter too while he was away: "Only choose one, put it in thread or inbox, don't cluster and duplicate everything everywhere, inbox leave for something else ... if it is just a thread just put it there without sending it to inbox"). A result, answer or update about one of Main's or a lead's open Threads goes to that thread only (`thread_update`; Walnut files the chat reply under his message), away or not; a decision on it is the thread's `needs` state. The rule is `THREAD_ONE_PLACE_RULE` (`src/core/org/thread-rules.ts`), carried by `AWAY_INBOX_LINE`, the thread ops and `human_inbox_send`. It is prompt text only, no server refusal (2026-11-04, the user: "there's no need for programmatic ... thread itself is a text ... just a prompt"): a letter about a thread is sent like any other. A thread moving into `needs` buzzes his phone (`src/core/push/thread-push.ts`; Walnut VP, 2026-11-04: "Evan wants no duplicates, not no reminders"): `threads-store.ts mutateThreads`, the one write of a threads file, hands every move into needs by a node (`threads-model.ts enteredNeeds`) to it; it pushes through the letters' delivery and device modes with no letter (`data.type: org_thread`, the node and the thread; the iOS app has no Threads screen yet, so a tap opens the app), never while he is in the node's chat, once per entry, and one buzz per absence shared with the away letters (`needsBuzzedAt` on the thread, counted by `away-thread.ts` too). Ratchets: `tests/core/org/thread-needs-push-guard.test.ts` (fails when a write goes around `mutateThreads`), `tests/e2e/thread-needs-push.test.ts`, `tests/core/org/thread-one-place-guard.test.ts` (fails when a prompt, hint, op or skill text tells a node to mail a thread's result, or says Walnut refuses such a letter).
- **While the user is away from Main's or a lead's chat, what needs him goes to his inbox, in one thread** (2026-10-31, the user before going to sleep, in his words translated: "when I am not chatting with you here, send it to the inbox ... make good use of the inbox"). `src/core/org/user-away.ts` decides from what Walnut sees (the last message a person typed into the node's chat, `SessionRecord.chatInputAt`, never a letter's answer; the web's and the phone's attention heartbeats on it, an open session stream on the phone): away after 30 minutes (`WALNUT_AWAY_AFTER_MINUTES`). The node hears it through the `user-away` message hint (and once that he is back), and the rule rides Main's and every lead's prompt (`AWAY_INBOX_LINE` in `session-context.ts`): a decision only he can make or something to review that is on none of his Threads goes to the inbox, a decision as `action_required` with its options; anything else stays in the chat. `src/core/human-inbox/away-thread.ts` keeps one thread per absence: the first letter is marked `awayThread`, later updates join its thread as turns, a decision stays its own letter (its buttons are the one tap) and is never named in the thread (a letter's thread holds only its own news, 2026-11-03: three such lines sat under an unrelated letter; ratchet `tests/core/letter-thread-own-news.test.ts`), and nothing of the absence buzzes the phone while one of its letters is unread (`quiet` on the letter event, skipped in `push/letter-push.ts`), except a decision when the unread letter that buzzed is over 3 hours old. The absence is everything after the user was last in the chat, so a restart keeps its thread; only the session that wrote a thread adds to it, and one node's sends go one at a time. `org_report` (`src/core/org/morning-report.ts`, `POST /api/v1/org/report`) is the report he asks for ("give me a report tomorrow morning"): what needs him with the one-tap way to answer each, what finished and what moved since a time, and the node's Brief; shown now, sent now, or scheduled with `at` and sent by the primary's minute clock (`org/report-schedule.json`) as the node's letter, which opens the absence's thread. Ratchets: `tests/core/org/away-inbox.test.ts`, `tests/e2e/away-inbox.test.ts`, `tests/core/away-thread-push.test.ts`.
- **The team charter: the task doing the work decides it** (2026-10-24, the user: "we are a startup, everyone has great power and we move really fast, no bureaucracy"; every step had been going to the lead and then Main for approval, one plan reached its seventh revision without landing, and messages were code names and progress notes). One text, `TEAM_CHARTER` in `src/core/team-charter.ts`, the same for Main, a lead, a worker and the Personal AI: the user and Main run the org as co-CEOs; every agent is the same model, so a higher layer holds different context, not better judgment; the task doing the work plans, builds, tests, verifies, gets an independent review, commits and deploys where done means deployed, without approval rounds, and a new coding task starts in its normal mode, never plan mode except when the user asks for plan (`task_create`/`task_start` `mode` says so too); escalate only for the user's own decisions or authority, information you cannot see or a collision with another team, or a hard-to-reverse choice no rule covers; a lead assigns, coordinates and unblocks, Main sets direction and never reviews implementation; a message only when the receiver must act, conclusion first; the user gets what changes what they do. It grants no outward step it did not already have. It rides the task session prompt (`session-context.ts`, where `orgRoleLine` words Main and lead to match), the operating contract (`self-knowledge-contract.ts`), the `walnut-session-messaging` skill, and in one clause (`MESSAGE_TEXT_HINT`) the text parameter of `task_send`, `org_send`, `board_post` and `human_inbox_send`, so a session spawned before a change still reads it in the live op help. Side-thread forks and resumes keep the prompt they were spawned with; a task session on an ACP engine other than Codex (no instructions channel) reads the same session context on the first prompt of each new provider thread, as one `session-context` hint block (`AcpSession.sessionContextPrefix`, 2026-10-28: a Main or lead on OpenCode or Goose did not know its role), and an ask there carries the persona, contract included, on that message too. Ratchet: `tests/core/team-charter.test.ts`. **The line to the user is checked, not only written** (2026-11-01, the user: "why ask me, just go, make your decision ... this is definitely not an important decision that should go to the user, make it clear in Walnut"; a test had overwritten the app on his own simulator, the task asked whether to reinstall the main build, and its lead handed that to him as a thread waiting on him, though the charter and USER.md both already said to decide): the escalation bullet lists the user's own kinds and names the never-his ones (undoing what an agent broke or changed, choosing between reasonable options, anything reversible, a question whose answer can be read or tried), Main's and a lead's default core instructions say a question a team brings that is not his is decided and told to the team, never passed up, and Walnut refuses at the line itself (`src/core/org/user-kind.ts`): an `action_required` letter from a task session, Main, a lead or a worker alike (`human-inbox/user-kind-letter.ts`; a plugin's, a triage run's and a report letter are not judged, nor is a host's journal op; `user_asked` does not skip it, `user_own` covers what he asked to decide) and a Thread an agent opens waiting on him, moves to `needs`, or reopens into `needs` (`threads-model.ts`; the user's own writes and a thread already waiting are not asked) need `user_kind` (`outward`, `spending`, `credentials`, `permissions`, `cloud_delete`, `irreversible`, `user_info`, `user_own`) and `user_kind_why` (a line, 10+ characters) or get `409 user_kind`; the kind leads the letter so he reads why it is his. The same day a task refused a relayed go-ahead because only the user typing in its own session counted: **the user's word counts wherever Walnut's record shows it** (a message of his in any node's chat, `task_history` rows with `sentBy: "person"`, or a thread message he wrote), and a task checks it there instead of asking him to repeat it; permission prompts and approval rules that name a step still apply. The turn-end path is judged too (2026-11-02, the user's lead: the Usage & Costs task kept asking him to "look at the page" after it had checked prod itself): the self-report's `FOR_USER_ASK` needs a `FOR_USER_ASK_OWN: <kind>: <why>` line (`readStatusReport`; the prompt tells the task to check things itself and report the result, and to write no kind rather than a weak one), and an ask with no kind of his own is dropped: the report reads as waiting on its lead with its words kept as what it waits on (`kindless_ask`), and a task with no lead above it reads as a finished result to read (`toRead: 'result'`), never the ask; a turn that answered his message stays on his list (`answered_person`) as an answer to read. A line set from outside the turn (`PUT /tasks/:id/report`, `task_report_set`, `task_update`'s `report`) by a task, a lead, a VP or Main names `user_kind` and `user_kind_why` for a `needs` or gets `409 user_kind`; a person's own line is never asked. Ratchets: `tests/core/org/user-kind.test.ts`, `tests/e2e/org-threads.test.ts` (last block), `tests/core/team-charter.test.ts`.
- **`sentBy: "person"` is the user's own word, so only text a person typed carries it** (`src/core/sessions/send-frame.ts`, 2026-11-02, the user's own review: `task_history` marked an agent's first prompt, test prompts, "Your task moved to this host" and every Inbox Triage batch as `person`, while every task is told a `person` row is his go-ahead). The transcript reads `sentBy` from the stored text (`transcript-sent-by.ts`), and text with no envelope reads as a person's, so the send holds the line: `sendMessageToSession` (`frameForSource`) wraps whatever a non-person source sends and no envelope frames yet in a `peer-note` naming the sender, a task started by a session gets that session's peer note as its first line and any other non-person start is framed (`frameStartText` in `task-start.ts`, `frameForSource` in `quick-start.ts`), a raw `enqueueMessage` outside the queue frames its text, and a request a session makes to a person's route (`x-walnut-caller-sid` on the phone's send, the launcher, quick-start, the launch route) is sent as `api-agent` (`routeSource`). The daemon's two corrective lines open with an automated marker the transcript reads as an agent's. A source is a person's only if `send-sent-by.ts` lists it; an unlisted one is framed. Rows from before the framing still read `person`. **Another session's mail is not a person's either** (2026-11-02, the org reflection: five reviews from a background agent, delivered as `<agent-message from=…>`, were recorded as the user's): the transcript reads the sender from the line's own `origin` (`nonHumanOrigin` in `lib/history-session-envelope.ts`: only `human`, or no origin, is a person; `agentOrigin` on the history row, `session-history.ts`) and from the tag (`NATIVE_PEER_TAGS`: `cross-session-message`, `agent-message`, `teammate-message`, framed or bare, so an enqueue with no user line behind it reads right too), and a row that mixes envelopes with words a person typed reads `person` and carries `personText`, the typed words alone (`personTextOf`). Ratchets: `tests/core/sessions/sent-by-peer-origin.test.ts`, `tests/core/sessions/send-sender-guard.test.ts` (pins the person sources, lists every call that puts a line into a session per file, sends each source found in the code through the real queue), `send-frame.test.ts`.
- **Every human action is an agent action too** (2026-10-27, the user: "all human action can be done by agent also"; the CEO agent was refused, with `403 human_only`, the instructions of a VP it had just made). Whatever a person can do in Walnut, an agent can do through a named op or the `api` op: the org chart (make a VP or a project, drag a project, name a node, write the CEO's or a VP's instructions: `org_node_create`, `org_node_edit`, `org_vp_set`), the Board (read ticks, choice answers, seen marks, cards from a lane, suggestions, another team's board, another author's post, deleting a board), a task's agent memory, the inbox (list it, read one letter, mark it read, pinned or archived, answer a session's letter in the user's place, recorded as that task's answer: `human_inbox_list`, `human_inbox_get`, `human_inbox_mark`, `human_inbox_answer`), stopping or restarting Main's or one's own lead's session, the retry buttons of settings. No route or op refuses a session caller for being one. The write records who made it (`by`: `"human"` or `"task:<id>"`, with a name the user reads), the UI shows an agent's change as the agent's, and the user can change it back. The user's own words stay theirs until an agent says the user asked for the change: an agent replacing or clearing instructions the user wrote (directly, or by bringing a closed VP back with a clean slate), the user's Board choice answer, mark, reminder or post passes `override_user` (409 `user_wrote` otherwise), as a project status the user picked does; an agent's tick, untick or seen mark never undoes the user's (its seen marks are kept apart), and a deleted board or a deleted post of the user's leaves a copy under `boards/.deleted/`. Main and every VP read Walnut's default core instructions for their role (`src/core/org/core-instructions.ts`; 2026-10-27, the user: "main, u don't do task yourself, u delegate to other VP or task directly"), shown and editable on the Org page and in the session menu: the user's own words replace the default, an agent's are added after it and never override it, and rank below Walnut's rules, the charter and the user, unless the agent wrote them at the user's request (`override_user`, stored `for_user`), which makes them the user's. What stays: the approval rules of the team charter (outward sends, spending, credentials, permissions, deleting cloud resources or data: turning a tunnel on, `config.expose`/`config.hosts`, setting up a host server, signing a browser in, a session's own permission mode, a replica's mass removal, a protected calendar write, a merge from another host outside its own work, the answer to a plugin's letter, which approves a step outside Walnut such as a mail send), and brakes that only cap how much an agent does (subtask depth and count, the title brake) or keep a session from cutting off its own turn (`own_session`). Ratchet: `tests/core/agent-parity-ratchet.test.ts` (every known refusal marker of a session caller is listed there with its reason, and the retired codes stay gone).
- **A parent always hears when its child stops** (`session-request-watch` in `src/core/session-hooks/builtins.ts`): a pending reply request is settled with a Walnut notice when the child's task lands NEED_ACTION or COMPLETE (a COMPLETE set mid-turn waits for that turn's end, so a reply later in the turn still wins), and the notice quotes the child's last message (≤4000 chars, framed as data) plus the tool calls it made after that message (a child that closes its task often ends the turn without a word), so a child that forgot to reply still hands its result over. **A session that completes its own task is stopped when that turn ends, not at once** (`src/core/sessions/self-complete-stop.ts`, keyed on the complete's `actorSid`): stopping it at once cut the very Bash call running `task_complete`, which the CLI records as "The user doesn't want to proceed with this tool use", along with its closing words (2026-10-05). That turn's summary skips the debounce and the stop waits for it (`awaitFinalSummary` in `src/core/session-hooks/builtins.ts`, at most 3 min), because a stopped CLI cannot answer the self-report and a completed task has no next turn; a completed task's summary only records, it notifies no one. A turn end that another turn follows within 3 s is not the end (a background command that finishes later leaves the session idle for about 0.1 s before the CLI's follow-up turn), so the stop waits for that turn. Its other sessions, and a completion from the board or another task, still stop at once. **Never to the session whose own API call made the edge**: the phase event carries `actorSid` (the `x-walnut-caller-sid` of the PATCH or complete), a request that session holds is settled without a notice, and a parent whose session acted gets no subtask notice (2026-10-02: a leader completed 16 workers and was woken 16 times to read the summaries it had just read).
  **A parent hears about its subtasks by their STATE, not by who talked to them** (`src/core/sessions/subtask-notices.ts`, same edges, same hook): whether or not it holds a request, a parent's session gets a `kind="notification"` envelope (no `request` attribute, `outcome` = `stopped` | `completed` | `error` | `blocked` | `waiting`) when a direct subtask's turn ends with the task still open (the body names who started that turn: the user, the parent's message, a trigger, another task), completes, errors, waits on a human prompt, or parks itself. 2026-09-30: a child's one request was answered, so its parent heard nothing more and lost it. One voice per edge (the request fallback speaks for a parent that is an asker; a child that answered its parent within 10 min is not reported "stopped", and its completion carries no quote); one message per parent per 3s burst, several envelopes inside; a newer `stopped`/`blocked`/`waiting` for the same child replaces the one still queued (stable id `sn-<child>-<kind>`); a COMPLETE parent or one with no session hears nothing; direct parent only; the child hears nothing back, so there is no loop. Delivery is the reply's path (`deliverToSession`, source `walnut-notify`, never reopening): a busy parent gets it mid-turn; a parent whose turn is over (idle, its task in NEED_ACTION) is woken only for `completed` and `error`, and a STOPPED parent session is never woken (2026-10-01: a task that had finished its own work was woken, and billed, every time the daily digest it once filed ended a trigger turn; a blocked worker needs the user, not its leader). It reads the rest when it next runs (`open_items`, `task_get`, its Board's live chips), and a reply it waits for (the request fallback) still wakes it. Every `completed` notice says how many of the parent's subtasks are still open and names them (`src/core/sessions/subtask-batch.ts`), and an idle parent is woken ONCE for a batch: a completion while another subtask still works is held (on disk, `subtask-notices-held.json`) and goes out with the edge that leaves none working, an error, or the parent's next turn (2026-10-28, the multi-agent tracking research). A reply or a completion may carry `result` / `evidence` / `not_done` (`src/core/peers/structured-reply.ts`, stored as `Task.result_report`); a non-empty `not_done` reads as Partial on the Board, the Brief, the phone and the parent's `open_items` until it is settled (`task_not_done_settle`). A request past its deadline stays OVERDUE, STILL OPEN (status `expired`, listed in `open_items` and the Brief ask's Needs you list) until a reply settles it, with one reminder to the asker at twice the deadline. A session completing ANOTHER task that is still in use (`source-in-use.ts`: a person wrote in it, its session was open in a terminal, an org role, an armed trigger) or unfinished (`complete-in-use.ts`: a Partial nobody settled, someone else's unanswered request, a turn running now) gets `409 task_in_use`, through `POST /complete` and a PATCH to COMPLETE alike, unless it passes `force` with a `reason`; open subtasks never block it. **One step stops a whole team** (`src/core/sessions/team-stop.ts`, ops `team_stop`/`team_resume`, the Board's Status view and the task kebab's Team row, the phone's Team menu): every open task under it has its live sessions stopped (never the caller's), its armed triggers paused and its wake clocks taken off, recorded on the root as `task.team_stop` so a resume turns exactly those back on; `when:"turn_end"` lets a running turn finish, and the parent hears no "stopped" notice for it. `sessions:"keep"` pauses only those wake sources (every routine aimed at the team, trigger or plain cron, and the clocks) and stops no session; `trigger_list` with `task` (and `team:true`) lists them first (`GET /api/v1/tasks/:id/wake-sources`; 2026-10-29: Main grepped the routine list to find what would wake a team during the user's meeting). **A session ending a turn while it holds a request it has not answered is told before the turn closes** (`src/core/sessions/unanswered-at-stop.ts`, the CLI's own Stop hook in every session's `--settings`, `src/providers/stop-unanswered-hook.ts`; 2026-10-29: workers ended a requested turn with "No response requested"): the hook's `block` answer names the request and the reply command and the CLI goes on in the same turn; once per turn (`stop_hook_active`), at most `MAX_NUDGES` turns per request, never for a request handed on, one not delivered yet, an asker whose task is complete, or a turn whose own work runs on (`workOutlivesTurn`); `WALNUT_STOP_UNANSWERED=0` turns it off. An overdue notice of a request handed on quotes the task it went to (its latest message and state), never the target's own unrelated message. **A parent completes with its subtasks still open** (2026-10-04 user call: a leader whose worker was a recurring daily job could not be closed at all; the old `409 active_children` guard is gone, for humans and sessions alike). The subtasks are not touched: they keep running and keep their parent link, and `POST /api/v1/tasks/:id/complete` lists them in `open_subtasks`. A COMPLETE parent hears nothing more from them until it is reopened, and a reopen restores it all: no state notices (also not one in the 3s window), and the ones already queued for it are dropped at completion; no request fallback (a Walnut notice never goes into a COMPLETE asker's session); its pending asks to them become `withdrawn` (`quietCompletedParent`; `restoreWithdrawnAsks` puts them back on reopen); a reminder a session set on its Board waits; and a subtask's own `task_send` or reply to it is refused with `409 parent_complete` (`session-send-core.ts`, mirrored by the daemon's offline host), because a peer message would reopen it. Anyone else's message still reopens a completed task.
- **After a restart, Walnut resumes the work a restart interrupted, and only that** (`src/core/session-auto-recover.ts`, rules in `src/core/sessions/restart-loss.ts`). 2026-10-26: the Mac rebooted with 42 sessions on it and none came back; twelve tasks with work in flight were handed back as NEED_ACTION, as if they waited on the user (the old per-host budget of 10 went to tasks already handed back, and the daemon's snapshot then rewrote each record's `status_reason`, so the few armed recoveries read as "not infra"). Now every session the startup reconciler (or a runtime site: host reconnect, health monitor) finds killed gets a durable `restartLoss` on its record, a field no status write touches, decided from recorded facts, never a time threshold: the daemon's fold of its stream file at death (an open turn, background commands, an armed ScheduleWakeup or /loop cron, an open permission prompt), else its last projected status, plus the task's phase. Resumed: IN_PROGRESS or WAITING with work in flight (a WAITING task whose wait rode on its own background command or timer waits for nothing). Not resumed: an open permission prompt or a settled turn (handed back, truthfully), WAITING on a clock/trigger/peer (left parked), already NEED_ACTION/TODO/COMPLETE, a working directory the reboot removed (handed back with a note). While a loss is undecided or queued, every hand-back path (`handBackTaskOnSessionEnd`, the snapshot's settled hand-back, the health monitor's drift rule) asks `holdsHandBack` first and leaves the phase alone. The queue: open turns first, then the most recent work; per host `WALNUT_AUTO_RECOVER_CONCURRENCY` (2) resumes in flight, a stagger, and a load gate on this machine (`WALNUT_AUTO_RECOVER_MAX_LOAD` 3 per CPU, then one at a time after 5 min); the attempt budget (3 per 6 h) and the loss are persisted, so a reboot loop stops and a restart mid-recovery resumes the queue; an offline host keeps its sessions queued for up to a day. Each resume is ONE `sendMessageToSession` (the daemon's one-CLI-per-session guard covers it), and the restart note (`sessions/restart-notice.ts`: the cause, what was lost, /tmp cleared on a reboot, whether the cwd survived, check state first) rides in front of whichever message reaches the session first, a human's or a peer's included, once. A death a daemon reports is taken on BEFORE it is written (`sessions/restart-death.ts`; 2026-11-04: a remote host rebooted under 8 running sessions, and the reconnect pull wrote each death and handed the task back before recovery was asked, so it read NEED_ACTION and resumed none): a snapshot apply and the legacy reconnect relabel call `noteRestartDeath` first when the death has a witness (found across a reconnect, a daemon that started after the session was last seen, or a record already marked unreachable), and `applySessionPhase` holds every NEED_ACTION death edge of a held session, a record that reads live included when the edge reports the death itself (`processAlive: false`: a runner the server had attached reports its dead process as a turn end and rewrites the record live, so `fire()` asks the host's fold before it takes a live-reading record for resumed). Opt out: `WALNUT_AUTO_RECOVER_ENABLED=0` or `hooks.overrides['session-auto-recover'].enabled: false`.
- **A session never stays stuck on "Prompt is too long"** (`src/core/sessions/context-full-recovery.ts`, `context-full.ts`; 2026-11-05: a host with Claude Code's auto-compact off reached the CLI's hard stop, 200K window less 23K, at 88% on the badge, and every message after it, the user's "hi" included, was refused before the model; the banner offered only Reconnect). A turn that ends with that error gets its session compacted by Walnut (a quiet `/compact`, `context-compact`), then one note (`context-recovery`) hands the session the messages that got no answer, word for word. At most 2 such compactions an hour start by themselves; past that, or when a compaction fails, `contextRecovery.state: 'failed'` and the banner's Compact and resend (the Retry route, which compacts for this error, or for any error while that state is failed, from any surface: `needsContextRecovery`) runs it again. One recovery holds a session from the overflow to the resend's send, both sends are bound to the stop fence of the overflow, and a Stop or a completed task ends it with nothing sent. While it holds a session, its turn is not over to any reader: `turnHold` (so every TurnEndWait user) reads it mid-turn, a triage run is settled only by the turn that answers (or as the error, once the recovery fails), and a notice never quotes "Prompt is too long" as the session's answer (`noticeText`; 2026-11-06, an asker read it as the reply). Claude Code's own error reply (its synthetic message: `isApiErrorMessage` in the transcript, `error` on the stream line) is an error row, `Claude Code: Prompt is too long` (`cliError`, live and in history), never the assistant's answer, and stands on its own row (2026-11-06). A compaction a restart caught keeps the session held and its turn quiet: the owed messages live on the record (`contextRecovery.owed`), the host's transcript is read for a compaction after the overflow (by order, not by clock), the resend waits for the turn to end and is bound to the stop fence of boot, and a Stop meanwhile ends it with nothing sent. With auto-compact off (the CLI's own `isAutoCompactEnabled` in its get_context_usage answer: settings, the legacy ~/.claude.json and the environment alike) the context percentage is measured against that hard stop (`stopsAt`, `contextStopsAt`), so 100% is where the session stops. The window itself is one rule (`resolveContextWindow` / `modelStringWindow` in `src/providers/context-window.ts`, its web twin `sessionContextWindow`; 2026-11-06, the user's Mac app read "Context 324.4K / 200K (162%)" on Opus 5.5): the CLI's own reading wins; a model the model catalog marks `native_1m` reads 1M with no `[1m]` in its id; every other answer is lifted to what the session shows it holds (its tokens, the CLI's effective window), a Claude model to 1M; a stored window counts as the CLI's own only when the record says so (`modelMaxWindowSource`) for the model it runs (`modelMaxWindowModel`). The model list shows one row per model (`oneRowPerModel`, iOS `ModelCatalogRowLabel.oneRowPerModel`). Ratchets: `tests/core/sessions/context-full.test.ts`, `tests/core/sessions/context-full-turn-end-guard.test.ts` (fails on a new turn-end listener that completes a task without asking the recovery), `tests/e2e/context-full-recovery-e2e.test.ts`, `tests/e2e/browser/session-context-full-banner.spec.ts`, `tests/core/context-window-sites-ratchet.test.ts` (fails on a new file naming a window by number).
- **Right after a compaction, a session reads back what is still open** (`src/core/sessions/open-items.ts`): every Claude Code session is spawned with the CLI's own SessionStart hook (matcher `compact`, `src/providers/compact-open-items-hook.ts`, in the same `--settings` arg as the env), which runs `walnut tools call open_items '{"hook":"compact"}'` and puts the task's unfinished direct subtasks and its pending reply requests (both ways) into context, with no extra turn. A summary is the model's own writing: on 2026-09-30 one dropped a subtask still waiting on a review, and the parent then treated that work as nobody's. Nothing open = nothing injected; the command never fails (no error row); `WALNUT_COMPACT_OPEN_ITEMS=0` turns it off.
- **A task keeps the user's unanswered messages, not its summary** (`src/core/sessions/user-asks.ts`, model `user-asks-model.ts`; 2026-11-03, the user, translated: "why do you miss things?"; a worker told his question "I'll check first, then get back to you", two compactions dropped it, and its lead's team note had counted it answered because the worker's turn ended after it). Every message a person types into a task's chat (and a letter answer's note) is filed on the send path at `WALNUT_HOME/user-asks/<taskId>.json`; Main and the VPs keep Threads instead. The turn-end status question lists the open ones and asks `FOR_USER_OWED` (the ids still owed: "on it" is owed, an answer or a delivered result is not), and Walnut closes the rest; a completion closes all, a week expires one. They lead the post-compaction readback, ahead of everything else, followed by his words of the last two days that were answered (`recentWordsReadback`; 2026-11-03: three compactions thinned a night's conversation to one line, and every message of it had been answered), and `task_history` pages back with `before` (its `older.before`) to read the rest; owed 30 minutes, the Stop hook names them (twice at most, never while the work runs on or the task parked) and the lead's team note lists them in his words; owed an hour with no turn running, the session is woken once. `WALNUT_USER_ASKS=0` turns the loop off. Ratchets: `tests/core/sessions/user-asks-ratchet.test.ts` (every path that files an Org node's thread files the ask for other tasks), `tests/e2e/user-asks.test.ts`.
- **An op call whose answer is lost says what is known, and a resend runs it once** (`src/lib/op-call-id.ts`, `src/ops/op-calls.ts`; 2026-11-04: `task_send` came back `hub_timeout` while its message was delivered and answered, so the caller could resend it, two copies, or take it for lost). Every client picks one call id (`oc-…`) per op call and sends it beside the op's name (`callId`, outside `args`, so an older server ignores it): the `walnut` CLI on every host, its MCP server, the source daemon's CLI, the Mac's CLI and MCP server, plugin jobs, and the offline handover's replays of a host journal (`journalCallId`). The server runs a write op once per id (a copy waits for the first, or gets its answer; another op or other arguments under the id is refused), and each write the call makes to its own API carries `x-walnut-op-id` derived from it (`writeOpId`: call id, route, order), which the applied-ops ledger dedups across a restart. A call whose write lost its answer, or was refused with a 5xx, 408 or 429, is not kept: its copy runs again and the ledger answers what already applied. A lost answer prints the call id, the read (`op_result`, `GET /api/v1/op-calls/:id`: running, done with its answer, lost with its writes, applied, unknown) and the safe re-run: the same arguments with `"_call_id"`, which clients and `executeOp` take out before the op's arguments are checked. A poll that is a write (`approval_wait`, `password_wait`) gets a new id each time. An answer holding a secret, or over 256 KB, is never kept (its copy runs again and meets the route's own checks), a copy from another session under the id is refused and `op_result` shows the answer only to the calling session; a self-call whose body was cut short counts as lost, and so does one a handler caught and answered ok over (the bulk ops); a host daemon derives a same-host `task_send`'s message id from the call id (`messageIdOfCall`), so a re-run is one delivery; a handler that fails on its re-run after an earlier try already wrote (a `note_edit` whose edit applied) says so, naming those writes. The server's own lost self-call answers the gateway `hub_timeout`, so every client prints the lines above. Ratchet: `tests/mcp/op-call-id-ratchet.test.ts` (every `tools.call` client and `executeOp` caller, every write op's write path).
- **A message to a host nothing reaches waits for that host, and says so** (`src/providers/host-wait-words.ts`, `claude-code-session.ts` `linkDownHost` / `armSlowHostRetry('link')`; 2026-11-06: the Mac's sign-in to a dev host expired, and a person's messages to tasks there read "Send failed: Not sent yet: waiting for the connection to the session host" with no host and no next try, a bare "Queued" after a reload, and nothing in the panel). Every runner path that ends a delivery asks whether the session's host link is down (the error says nothing reached the host, `isHostLinkFailure`, or the pool says the link that was up dropped, `hostLinkDown` in `daemon-connection.ts`): its rows stay pending under "Not sent yet: Walnut cannot reach <host's name> right now ..." with the next try (the host's own re-dial, `hostReconnectAt`, or a clock that never dials a host still down) and Send now, never "Send failed"; the reconnect delivers them (`redeliverPendingForHost`). A reloaded panel reads the same words and next try (`session:get-queue` `heldReason`/`heldRetryAt`, `core/sessions/held-wait.ts`, which after a server restart reads the wait from the link itself). The session panel says the host cannot be reached the moment the link drops (`SessionHostDownBanner`), a column reads its history again when its host comes back (a reply written meanwhile), and the task reads Waiting on its machine, naming the host, on every surface (`user_status` `hostDown`, re-derived on each `host:status` link change). Ratchets: `tests/providers/delivery-link-down-ratchet.test.ts` (every runner function that puts rows back or reports them failed is classified, every transport `SendHeldError` is classified), `tests/providers/host-link-down-delivery.test.ts`; E2E with the link cut on purpose: `tests/e2e/browser/cloud-host-link-down.spec.ts`.
- **A host whose link keeps dropping reads unstable, never "Connected"** (`src/providers/daemon-link-drops.ts`, `hostFlapOf` / `hostConnectedWords` in `src/core/hosts/host-problem.ts`; 2026-11-08: a laptop on the host network dropped every 30 s for hours and read Connected between drops). Each drop of a link that was up (not a planned daemon swap or a teardown) is kept for an hour and rides the `host:status` frame as `drops`; 3 in 30 minutes reads "Unstable: its link dropped N times in the last 30 min" on the Settings row, the host dot, the bell's host row and card, the fleet card (`connection.unstable`, a Link unstable problem) and `host_list` (and no place for a long-running job). Ratchet: `tests/core/hosts/host-connected-words-guard.test.ts` (every file that says "Connected" reads the rule or is listed as no host link); E2E: `tests/e2e/browser/cloud-host-flapping.spec.ts`.
- **task_history reads short when asked** (`src/ops/task-history-shape.ts`, applied in `src/ops/work.ts`; 2026-11-02, Main's daily reflection read one task's history as about 90,000 characters, over its tool output ceiling): `compact:true` keeps every message's text and folds each tool call, thinking block and Walnut status notice to one line (the person's own words, `personText` included, are never clipped; an assistant reply clips at 2,000 and another session's note at 1,200), `limit:N` keeps the newest N text messages (the route's `visible`, so the page reaches back for them; the cut never splits rows sharing a timestamp, so `older.before` reads the rest), `since:<ISO>` keeps rows after a time (the route's `since`; a cut page that starts after it asks the page before whether `since` was reached). The route honors `visible` and `since` only on a page it builds, so either one sends `fresh=1`. A shaped answer carries `left_out` (counts and characters dropped, one `note` line); with none of the three the answer is the route's own, unchanged. `tests/mcp/task-history-compact.test.ts` pins a 100-row heavy page under 50,000 characters compact.
- **A session hears where its task stands now when that moved** (`src/core/sessions/place-notice.ts`, called from `sendMessageToSession`): the prompt names the task's project, folder and leader at spawn, but every cold resume replays the spawn-time prompt byte for byte and a live CLI never rereads one, so a task the user moved kept a session that believed the old place (2026-10-14: a leader moved a week earlier still read its old project in its prompt and filed its subtask there). The told place is background state (`placeNoted` on the session record, valid while it runs on the prompt it was written against, else the place read back from the stored prompt, else the record's start project); when the live place differs, the next message into the session carries a `kind="notification"` `outcome="moved"` note in the same queue row, so it costs no turn and never interrupts one. A compaction keeps the system prompt and summarizes the rest, so a note may be gone afterwards while the prompt's old place stays: the stream's compaction boundary (`lastCompactUuid`) voids every earlier note, and the post-compaction `open_items` hook states the live place whenever it is not the prompt's, which counts as told (the way Claude Code re-attaches its own state after a compaction instead of trusting the summary). Personal AI asks are never told.
- **A running session hears its default instructions that changed** (`src/core/sessions/instruction-notice.ts`, 2026-10-27; the user: a rule change reached Main only at its next fresh start, and a session older than the charter brought the old ways back after every compaction). A resume replays the spawn-time prompt byte for byte, so it never picks up a change, except a cold resume whose prompt cache has expired, which runs on the current prompt and stores it, since the replay would save nothing (`src/core/sessions/prompt-refresh.ts`; 2026-11-06: a VP on a 3 KB prompt from 2026-10-24 was told 28 KB after every compaction). `defaultInstructions(task)` in `session-context.ts` is the prompt without its state lines (title, place, leader, workers, task directory); every line of it the stored prompt lacks is a changed rule. The first line frozen into the session after that carries a `kind="notification"` note with only those lines (under their heading), once per prompt and compaction boundary; a later change sends only its own lines (told lines kept as hashes in `taskRoutingNoted.lines`), the post-compaction `open_items` hook restates them all, and a current prompt hears nothing. Few and small (2026-11-03, the org reflection: 64 notices, about 287K characters, in 12 hours across 100 sessions): once told on a prompt and boundary, the next notice waits `NOTICE_GAP_MS` (3 h), so changes close together go out as one; it rides only a line with a person's, a task's or a trigger's message in it, never one of Walnut's own notes alone (`isWalnutNotesOnly`); a fork's compaction (a side question) runs the hook too, and its statement keeps what it replaced as `prior`, which still counts when the statement is void (`heldStatement` in `hook-statement.ts`; it had wiped the record, and the next message restated everything, 10.8 KB twice in 20 minutes), for the place note as well; a hook statement takes the boundary the record holds under its lock; and the hook adds at most `HOOK_CONTEXT_MAX` (9,500) characters, because Claude Code shows the model only a 2 KB preview of a hook's text past 10,000 (Main's 39 KB statement was recorded as told): changes that do not fit come whole in front of the next message instead. A versioned block (the user's profile, a Main's or lead's core instructions) is compared by its words against the version the session holds (`instruction-diff.ts`; 2026-10-30, a one-line USER.md save re-sent the whole 4.9 KB profile to about 25 sessions): only the new or reworded lines, each under its sub-heading, and the removed ones; a session that never had the block, or whose block changed form, gets it whole; a change is never sent whole (2026-11-03: a save that reworded 10 of 27 lines went whole to every live session), a removed line is named by its first 80 characters and a reworded one says its new wording is above. A line USER.md's 4000-character budget leaves out is still the user's and is never told as removed (2026-10-30: two rules still in the file were told "no longer in force"); a change to a block that does not fit says so, and a save past the budget answers with a `warning` naming what is left out. One notice is on its way to a session at a time (`instruction-notice-flight.ts`), a delivery records what that notice said onto the record as it is then, and the daemon writes one notice into one CLI process once (`noticeOnce` in `daemon-core.ts`), because the server's queue and the host's own same-host delivery each learn of the other's only later (2026-10-30: Main got one twice, 4 s apart). Every Claude Code task session gets it, Main, leads, workers and Personal AI asks alike (an ask's persona text is not compared, only its Walnut session context); a prompt without the Walnut identity line (a slim profile) and ACP engines never do: Codex rebuilds its developer instructions at each process start, and other ACP engines read it once per new provider thread. The wire names stay `outcome="task-routing-policy"` and `taskRoutingPolicy`/`taskRoutingDue` in the host slice, from when it carried only the routing rules: the web and iOS timelines and older daemons read them. The user's profile and standing rules ride those default instructions too (`src/core/sessions/user-profile-context.ts`, 2026-10-28; Main recorded the user's rules in memory/USER.md and only the Personal AI's persona read it, so the user repeated one): every task session reads USER.md's sections last in its prompt, under `## Your user`, after the memory safety screen (an entry it holds back becomes a line that asks no session to act) and with the words capped at 4000 characters. Each line carries a version of the words and of when they were saved, so any save, an undo included, reaches a running session as what changed from the version it holds, and an emptied file says none are recorded now; the companion reads the Mac's copy (`companionMemoryDoc`), never its disk. A persona leaves its own copy out when the session's Walnut context carries it (`userInSessionContext`: a native or ACP ask, a lane of an ask task, the drift repairs only when the stored prompt is Walnut's), and the companion's chat lane keeps the persona copy the Mac rendered.
- Phase: `TODO` → … → `NEED_ACTION` → … → `COMPLETE` (an agent may set any phase, COMPLETE included: a parent that checked its subtasks' work may close them). A new message from a human or a peer task into a completed task's session reopens it to `IN_PROGRESS`; a session's message or answer to a COMPLETE task is first refused with `409 target_complete` (the task is done, read its result) and goes through only with `force` and a `reason`, which leads the delivered text (`checkCompleteTarget` in `src/core/peers/envelope-kit.ts`, shared by the server and the daemon's offline host; 2026-10-14, the user: "not a hard no, there must be a valid reason") (`session:input` with an allowlisted send source is the one path past the terminal guard, see `sendSourceReopensTerminal`); late results, hints and automated sends (auto-continue, routines, hooks) never do. `WAITING` (`src/core/phase.ts` `HELD_PHASES`, timer in `src/core/task-wait-until.ts`) parks a task until something happens: it stays in its board tier (the `wait` tier, labelled Parked, is a shelf, not a state) but leaves the default task list and tier cards (`showWaiting` in `TodoPanel`, revealed by "Show waiting" or a WAITING phase filter; search always finds it); nothing of the turn that set it moves it, whoever set it (its output, its end, a session error, the reconciler, a background agent coming back inside it: that `{running}` keeps the turn's `turnStartedAt` and is skipped as `held-this-turn`; a turn-start with no generation, the 30s snapshot pull, is never a new turn: `held-reconcile`, both in `applySessionPhase`), except a prompt that blocks on the human (NEED_ACTION); ANY new turn (a trigger fire, a human, a peer, the `wait_until` clock, which wakes the session with a `kind="trigger"` note or hands a sessionless task back as NEED_ACTION) moves it to `IN_PROGRESS`; a turn that only interrupted the wait (a peer's message, a Walnut status notice, the CLI's own follow-up for background work that came back) ends back in WAITING with the same `wait_until` while the task still has an armed trigger, none stopped and none fired since, unless something it waited for or a person came on the way (any send that is not an interruption: a person's, a fire, the clock, a reply to its request, a subtask's completion or error (`endsWait` on the send), a Retry), a prompt for the human, an error, or a phase write; only a clean turn end restores it, never a process that died in the turn, and the interrupting message carries one note saying so, so the session can set NEED_ACTION itself (`src/core/task-wait-resume.ts`, in memory; 2026-10-25: a worker parked on its trigger flipped to NEED_ACTION seven times in 3.5 h); a wait with only a clock is not brought back, since that turn may be the reply it waited for; a move into WAITING that names no `wait_until` gets `defaultWaitUntil()` = 4 hours out (`DEFAULT_WAIT_HOURS`, `src/core/types.ts`; 2026-10-05: 3 days left a wrong trigger unnoticed for days, 2026-10-30: a day hid a task that needed the user), `""` asks for no clock; a task parks only on a wait outside Walnut and the user (`src/core/sessions/outside-wait.ts`, 2026-10-30, the user: "try to avoid until u are actually wait something like pipeline flowing, slack, or cr approval"): a turn-end report that waits on nothing it names as outside (`outside`, `task`, `lead`, `machine`) and nothing the task has armed (a trigger of its own that does not watch the user's ask, an unanswered request, a subtask at work) lands NEED_ACTION, and so does any waiting report of a turn that answered a message the user sent (`answered_person` on the report, `turnAnsweredPerson` in `sessions/report-phase.ts`; it reads Needs you, "It answered your message", until the user writes again or the 4-hour re-check wakes the task; 2026-10-31: an answer the user had asked for went to WAITING 10 s after it ended because the task also waited on a CR approval), a session's write of WAITING with none of those and no `waiting_on` (PATCH, the batch phase routes, a queued write's replay) is `409 wait_on_user`, a trigger with `watches` never parks and never holds a wait, and `tests/core/park-gate-ratchet.test.ts` fails on a new code path that writes WAITING; the prompts tell a session to name a short `wait_until` itself, and the clock's wake note (`src/core/task-wait-wake.ts`) lists the task's triggers with their state and last check and asks it to check the thing and the trigger before parking again; `trigger_create` parks the task only with `wait:true` (2026-10-31, the user: "a trigger does not always mean it goes into wait ... don't overuse wait"; it parked the caller's own task by default before; `wait_until` takes ISO or `"6h"`/`"3d"`, parsed in `src/core/task-wait-clock.ts`; 2026-10-04: a session asked the user to "make a trigger" for a review it could watch itself, and the session prompt now says waiting is the session's job), and a park sends NO letter, whoever makes it (the 2026-10-04 receipt, one inbox letter per park, became one per re-park after every fire and was removed 2026-10-05; a stray `wait_report` is ignored); a background writer (sync pull, `internal`) may only COMPLETE it (`heldPhaseBlocks`), and a sync plugin whose manifest lacks `phases: ["WAITING"]` sees it as TODO (`src/core/plugins/legacy-phase-sync.ts`), so an older plugin never ends a wait by mapping a remote step back
- **Every task has a working directory that survives reboots** (`src/lib/task-dir.ts`; 2026-10-25: a restart emptied /tmp and took several tasks' clones, one task's only copy of an unmerged commit, and kept evidence). The host's daemon makes `${XDG_STATE_HOME:-~/.local/state}/open-walnut/tasks/<taskId>` when it starts a session for the task (both twins, the ACP start too; capability `task-dir-v1`, sidecar `task-dir-core.cjs`) and sets `WALNUT_TASK_DIR`; the prompt names it, and the rule (`WORK_PLACE_RULE` in `src/core/work-place.ts`) rides the session prompt, the operating contract, the `walnut` skill and the `task_dir` op's help (the op makes one for a session spawned before). A PreToolUse hook on Bash in every Claude Code session's `--settings` (`src/providers/git-tmp-guard-hook.ts`) refuses the session's own `git clone`/`git worktree add`/`git commit` in a temp dir, reading only the command it typed (a deploy's or a test's own scratch clone is untouched); `WALNUT_TMP_GIT_GUARD=0` turns it off. A PostToolUse hook on Write (`src/providers/tmp-write-note-hook.ts`; 2026-10-26: a Slack draft and a plan file in /tmp were swept the same day) never refuses: after a Write of a draft, plan, note or evidence file into a temp dir (prose formats such as `.md` always, any other name only when it or a directory carries a word like draft, plan, notes, evidence, report; never images, video or `.log`) it adds one line of context naming the task directory to move it to; `WALNUT_TMP_WRITE_NOTE=0` turns it off. Both share one temp-path test (`tmp-path-awk.ts`). **A clone keeps its source's commit identity** (`src/providers/clone-identity-hook.ts`, one Bash hook on PreToolUse and PostToolUse; 2026-10-28: agents' landing clones of a checkout with its own identity committed and rebased under the machine's global one, and their pushes carried it): a session's `git clone` of a LOCAL repository is noted before the command runs, read from the directory it starts in (PostToolUse's cwd is where the command ended), and once the clone is checked out (at that command's end, or before the session's next command when it failed or ran in the background), a clone with no identity of its own whose remote points at that source gets the source's `user.name`/`user.email` in its local config, and one line of context says so; URLs, a clone with its own identity, a directory that is not that source's clone and a clone still running are left alone. It reads the command with the same parser as the temp-dir guard (`git-command-awk.ts`, its JSON read linear in time); `WALNUT_CLONE_IDENTITY=0` turns it off. A task transfer carries the identity the source repository sets for itself, never the machine's, in its pack (`identity` in the meta), and the new session's setup steps set it on a fresh clone, or on a checkout of the target that commits as the machine does. Cleanup (`src/core/task-dir-sweep.ts`, primary only, every 6 h): the server picks directories whose task completed 7+ days ago (`WALNUT_TASK_DIR_KEEP_DAYS`) and that nobody used since, and the host removes each only when no repo in it has uncommitted or untracked files, a stash, or commits no remote has. A day after completion (`WALNUT_TASK_DIR_PRUNE_DAYS`, 0 = off; capability `task-dir-prune-v1`, `pruneTaskDirs`; 2026-10-31: task directories held 27 GB, mostly clean clones and node_modules of finished tasks) the host first prunes it, once per use: each repo in it that holds nothing unique and no ignored file but build output goes, unless a repo anywhere under the root borrows its objects (`clone --shared`), and so does build output git does not track (`node_modules`, `DerivedData`, `.build` anywhere; `dist` and test reports only when the repo ignores them); the directory's mtime is put back. Tests and ephemeral servers point `WALNUT_TASK_DIR_ROOT` into their own temp dirs. Scripts keep evidence via `scripts/lib/evidence-dir.{mjs,sh}`. Ratchet: `tests/core/work-place-rule.test.ts`.
- **A session whose working folder is gone keeps working** (`src/core/sessions/folder-gone.ts`; 2026-10-30, the user: "as long as Claude Code has the chat history ... don't recreate the original folder ... let it continue working"; a peer's message to a session in a reorganized notes folder was parked as "Working directory no longer exists" and Retry failed the same way). Every refusal on a gone folder (the spawn pre-flight `cwd-check.ts`, a daemon's own start check in both twins, the ACP drain, Retry, Restart, restart recovery) records `record.cwdGone` (the folder, whether the history is still on the host, the task directory it would move to); the session panel shows one banner and the undelivered row one button, "Continue in a new folder" (`POST /api/sessions/:id/continue-in-new-folder`). It never recreates the old folder: it moves the Claude Code transcript and its `<sid>/` sidecar into the project dir of the task's own directory (the CLI resumes a copy in its own folder's project dir first, and names that dir by the folder's real path), points the record (and `task.cwd` when it was the gone folder) there, and sends what waited, the first message carrying a one-time `outcome="folder-moved"` notice (or the next message, `pendingFolderMovedNotice`). With the history gone too it starts a new conversation for the task there, carrying the waiting messages and the task's summary, and the old session is detached only once that start spawned. ACP engines (Codex resumes a thread by id from any folder) only change folder. Ratchet: `tests/core/sessions/folder-gone-ratchet.test.ts` (every place a gone-folder refusal is made or a delivery failure is classified, counted from the source); E2E: `tests/e2e/session-folder-gone-e2e.test.ts`.
- **NEVER force-kill Claude Code processes** — bypasses on-stop hook
- **A CLI's command line carries paths, never content; nobody kills by pattern** (2026-10-31: `pkill -f "clone.*playwright"` matched the inline `--settings` hook text of every CLI and SIGTERMed every session on the Mac, then an unquoted heredoc ran the same pkill in backticks). Every claude spawn (both daemon twins' session start, every one-shot, the in-process fallback) runs its argv through `src/providers/cli-argv-files-core.ts`: `--settings`, `--mcp-config`, `--append-system-prompt`, `--system-prompt` become content-addressed files in `<streams>/argv` (0600); the server and the daemon's registry keep the inline form. A PreToolUse Bash hook (`src/providers/pattern-kill-guard-hook.ts`, off: `WALNUT_PATTERN_KILL_GUARD=0`) refuses `pkill`, `killall` and `pgrep` with `kill` wherever the shell would run them. A second one (`src/providers/codex-provider-guard-hook.ts`, off: `WALNUT_CODEX_PROVIDER_GUARD=0`) refuses a Codex model run (`codex exec`, `codex review`, a bare `codex`) that has no `-c model_provider=<name>` after its subcommand. Ratchet: `tests/providers/cli-argv-content-ratchet.test.ts`.
- **No agent makes sound on the machine it works on** (2026-11-07, at night: a voice test on someone else's Mac unmuted it at 70% with `osascript ... without output muted`, then spoke and played music through its speakers; a rule in the user's profile did not stop it). One rule set (`src/providers/sound-rules.ts`), two twins over one case table (`tests/providers/sound-guard-cases.ts`): a PreToolUse Bash hook in every Claude Code session (`src/providers/sound-guard-hook.ts`, POSIX awk, read wherever the shell would run the command: wrappers, `$(...)`, `bash -c`, heredocs, pipes into a shell, `ssh host cmd`, osascript's AppleScript, an interpreter's inline code) and the same judge in host_exec on the server for every host (`src/providers/sound-guard.ts`, `409 makes_sound`, no override). Refused: `say` without `-o`, espeak without `-w`, afplay and the other players, a media app told to play, and any volume change or unmute (osascript `set volume`, amixer, pactl, pamixer, wpctl: any level, 0 and "with output muted" included); a mute alone, reading the volume, a null output and choosing an output device stay allowed, so voice tests feed a file or a virtual device. A script file it cannot see is not read. The hook's awk program rides the settings as gzip + base64 (they must stay under 100 KB). The same guard refuses real foreground input on the machine (2026-11-09: a session drove `cliclick` on a Mac while its person watched a video there): `cliclick`, `xdotool`, `ydotool`, `wtype`, `xte`, `dotool`, Peekaboo's click, type, press, hotkey, scroll, swipe, drag, move and paste, System Events clicks and keys from osascript, and input event APIs in an interpreter's code (CGEventPost, pyautogui, pynput, robotjs); host_exec answers `409 foreground_input`. A test drives its own window from inside it (Playwright, XCUITest, `xcrun simctl io`), and a command that truly must drive a window it owns this way names why on its own line (`WALNUT_INPUT_REASON='<why, 10+ characters>'`). Case table: `INPUT_CASES` in `tests/providers/sound-guard-cases.ts`. On for everyone; off with `WALNUT_SOUND_GUARD=0` on the server. A test browser or Simulator makes no sound and opens no audio output either (2026-11-08: Playwright WebKit runs on a Mac with a person near it switched its speaker on at every page's first click, through the reminder chime's audio unlock, 278 times one night; a muted output was not enough, the person heard it): under automation (`navigator.webdriver`) the page tries no audio (`src/lib/automation-silence.ts`: no chime, media never plays, play() rejects as an autoplay block does and an autoplay element is paused, an AudioContext is made with no output device where the engine can (Chromium's `sinkId: { type: 'none' }`) and refused anywhere else (WebKit), speech never spoken; `window.__walnutAudio` counts what a page tried; Walnut's Board and rich-island frames run it first), every Playwright context runs that silencer at document start in every frame, a plugin's page and a dev server in the Web view included (`tests/e2e/browser/pw-silence.ts`, imported by every Playwright config), the Macs' WebKit runners load the same rules into every run from the Mac's main whatever branch it builds (`tests/e2e/browser/pw-silence-preload.cjs`, `NODE_OPTIONS=--require`), and so does every Playwright run under a Walnut session: both daemon twins write that preload into the host's state dir and start every session (Claude Code and ACP) with it first in `NODE_OPTIONS` and `BUN_OPTIONS` (`src/providers/pw-silence-core.ts`, generated into the committed copy by `scripts/pw-silence-preload.ts`; it acts on any load of playwright-core, a runner, a worker or a script of the session's own, and under Bun on a main script that names Playwright; `WALNUT_PW_SILENCE=0` on the daemon turns it off; ratchet `tests/providers/pw-silence-twins-e2e.test.ts`), Chromium projects launch with `--mute-audio`, and the iOS app is silent in the Simulator, under XCTest and with `-walnut.silentAudio` (`AudioSilence`, read by every player and every WKWebView configuration, `-walnut.allowSound YES` for a person; every UI test launch passes it). On a Mac no WebKit test browser ever becomes the frontmost app (2026-11-08: two tasks' headless ones took the front from the person 8 times in 8 seconds, and a Cmd+Q meant for another app quit one): the preload starts each through a runner that loads a library making it background-only (`src/providers/pw-noactivate-core.ts`, `WALNUT_PW_ACTIVATE=1` for a person; ratchet `tests/providers/pw-noactivate.test.ts`). Nor does a test open the Simulator app: a running one is an ordinary app another program can bring to the front, launched with `open -g` or not (checked by the system log), so scripts boot devices headless with `xcrun simctl boot` and never run `open … Simulator` or `maestro start-device` (ratchet `tests/scripts/no-simulator-window.test.ts`). No window of the Mac app or its native tests beeps for a key nobody handles (`QuietKeyWindow` in `desktop/main.swift`; 2026-11-08: a native test driver's Escape, Enter and arrow keys into a web view sounded the alert all night), and a Swift file that makes key events says why none beeps (ratchet `tests/desktop/quiet-window.test.ts`). Nothing touches the machine's volume. Ratchet: `tests/web/automation-silence.test.ts` (every sound API, sandboxed frame and iOS sound site listed).
- **Sessions render in TWO surfaces, both on the Homepage (`/`): the session columns
  (`SessionPanel`) and the chat slot.** The dedicated `/sessions` page was removed (2026-07-25);
  the route is now a redirect shim that reroutes `/sessions?id=…` deep links to the home session
  columns. The chat spot is the Ask Walnut slot (`AskWalnutSlot`): it hosts an embedded
  `SessionPanel` for the selected ask (every window control a column has, incl. the × which
  hides the slot; only lock is absent), and a `DraftSessionPanel` in its New state. The slot
  adds ONE control to the regular panel: a ≡ button leading the header's title row
  (`headerLeading`) that opens a drawer with a search box filtering the asks (tasks born
  `walnut_agent` OR filed under the agent's `Ask …` project), `New chat`, and two links
  (`Context`, `Fix Walnut`); tests reach those through `openAskWalnutDrawer(page)` in
  `tests/e2e/browser/draft-helpers.ts`. The drawer's TITLE is the agent switcher: every
  console agent (Walnut, Mentor, Note Assistant, config-defined) has its own list of asks and
  its own composer; a launch under another agent sends `agentId`, the server files the task
  under `Ask <name>` and stamps `task.agent_id` (rule in `src/core/sessions/ask-agent.ts`,
  client twin `askProjectFor` in `ask-walnut-slot-model.ts`; the persona comes from
  `buildLaneProfile(config, agentId)`). Deliberately NO `+ Task`/`+ Session`/finder/hide rows:
  the draft column is the one task-creation surface (a task with no session is a plain todo),
  ⌘⇧O is the session finder, the panel's × hides the slot. Consequence
  for tests: a Playwright locator for `.session-panel` or `.draft-session-panel` on `/` must be
  scoped to `.main-page-session-column` (see `REAL_PANEL` / `DRAFT_PANEL` in
  `tests/e2e/browser/draft-helpers.ts`) or pinned by `data-session-id`. The slot's panel sits
  earlier in the DOM, so an unscoped `.first()` grabs the wrong one. Tasks also have two
  surfaces: the Homepage `TodoPanel` (primary) and `/tasks` →
  `DashboardPage`/`TasksPageTable` (secondary). Default to the Homepage panels for any
  Task/Session work, demos, and recordings.
- Concurrency: `tasks.json`/`sessions.json` use in-process + cross-process file locks
- **Skill discovery has TWO scopes — don't collapse them** (`src/core/skill-loader.ts`). The
  Personal AI's injected index (`buildSkillsPrompt` → `getPromptSearchDirs()`) covers workspace
  `skills/` + `~/.open-walnut/skills/` + shipped `dist/data/skills/` — deliberately **NOT**
  `~/.claude/skills/`, which is the Claude Code CLI's own store. The CLI discovers those
  deploy/close-session/plan skills natively when needed, so injecting them again would duplicate
  its own context. Management/`skill_view` scope
  (`getSearchDirs()`) still covers all four, so a claude skill stays listable and readable.
  Opt back in with `WALNUT_PERSONAL_AI_CLAUDE_SKILLS=1`. Measured: excluding them cut the Skills
  prompt section from 10.2K → 3.9K tokens (77 → 34 entries).

### Design Principle: host-local work belongs to the DAEMON, not the server

**Every host does its own work through its daemon; the walnut server stays a lightweight
coordinator.** If a computation only touches files/processes on ONE host (parsing that host's
session JSONLs, reading file contents, running git), it runs IN THE DAEMON on that host, and
only the (small) result crosses the tunnel — never the raw bytes, and never "N RPCs per file".
Ship data to the server on demand, at the granularity the UI actually consumes (a list, ONE
file's diff), not wholesale.

**The same split applies to RUNNING things, not just reading them: the server is the API, storage,
delivery and UI plane; a clock, a poll loop, a check script, anything that executes on a host, runs
in that host's daemon (precedents: walnut-trigger, `docs/plan/walnut-trigger.md`; a plugin's jobs, which
the daemon of the machine the user picked runs as processes, `plugin-jobs-v1`, `src/providers/plugin-jobs-core.ts`). Offload to the
daemon whenever the work can live there.**

**If a feature can be self-managed, self-manage it in the daemon; when it cannot, go to the
leader.** A host keeps a read copy of what its sessions need, answers reads from it, writes its
own things first and journals them for the leader. The leader is the Mac (the primary); while
the Mac is away, the cloud companion leads instead when the user allows it, fenced by an epoch
the daemons hold, and the Mac takes the lead back when it returns (`docs/plan/walnut-control-plane.md`,
`src/providers/leader-core.ts`, `src/core/leader/`). Only what a host cannot answer alone (a
session on another host, a task outside its copy) goes to the leader.

Why (each learned the hard way): raw-bytes-over-tunnel hits the WS frame kills and the 32MB
read ceiling (whale JSONLs); per-file RPC fan-out floods the daemon socket and starves its
command timeout; and parse work on the server burns the ONE event loop every route shares.
The daemon has local fs + git + CPU right next to the data — a whale parse that took 40-80s
of chunked tunnel reads is seconds host-local.

Precedents: `git.diff` (daemon runs the whole diff host-side), `changes.compute`/`changes.file`
(daemon parses session JSONLs + reads contents host-side, returns a light list / one file),
snapshot-v1 (daemon folds its own stream files), `fs.resolvePath` (path-resolve-v1: the daemon
runs the whole layered path search on its own tree). When adding a feature that reads
session/host data, default to a daemon command + a thin server relay; only fall back to
DaemonFileReader byte-shuttling when the daemon genuinely can't own the work (e.g. cross-host
aggregation). Capability-gate new commands (`daemon-capabilities.ts`) so old daemons degrade to
the fallback path instead of erroring.

**Corollary — a search over host data is host work too, and a per-item RPC loop is the
anti-pattern.** The old path resolver lived on the SERVER and probed the daemon with ~2
`fs.stat` calls per ancestor level (~18 round trips on a deep path). It routinely spent its
whole 10s budget on latency, then returned a path that didn't exist, which surfaced as a raw
`ENOENT: scandir` in the Files tree. `fs.resolvePath` (`src/providers/path-resolve-core.ts`) is
the same logic moved next to the data: one RPC, and it can use signals the server never had
(the session transcript, `git ls-files --recurse-submodules`). Two rules it encodes: bound the
CANDIDATE SET, not just the clock (an unbounded suffix list burns the budget on tails that
can't match), and batch — every needle rides ONE `ls-files` call per repo root.

**Order search scopes by cost, not by completeness.** `git ls-files --recurse-submodules` is
the complete answer, and on a monorepo of 2,606 initialized submodules it costs **1.24s
regardless of outcome** — while the same pathspec against the superproject index alone costs
**45ms**, and a parallel fan-out over just the submodules under cwd costs **20ms**. Since a
path the model mentioned is almost always inside the subtree the session is working in, the
resolver tries superproject → submodules-under-cwd → submodules-under-cwd's-parent →
everything, returning at the first hit: 2.3s became **~85ms** on the real repo. Two things make
this safe to generalize: read `.gitmodules` as TEXT (~2ms; `git submodule status` stats every
submodule and takes seconds at that scale), and skip the whole submodule question outright when
`.gitmodules` is absent — an ordinary repo spawns exactly ONE `git ls-files` and never touches
the submodule path at all.

**A confident wrong answer is worse than an error.** Trimming leading segments is what lets
`repo/src/x.ts` find `src/x.ts`; taken to its limit it also let `no/such/thing.ts` "find" an
unrelated `other/thing.ts` and report `resolved: true`, so the user opened the wrong file
believing it was right. `MIN_NEEDLE_SEGMENTS` keeps at least one directory of context for any
multi-segment reference (a bare `Makefile` is exempt — there is no context to preserve).

**Parse a path reference before guarding or searching it** (`src/providers/path-ref-parse.ts`,
shared by the resolver and both HTTP edges). A path in prose arrives decorated: `` `a.ts` ``,
`a.ts:42`, `a.ts#L10-L20`, `a.ts(42,7)`, a trailing sentence period, Windows separators. Each
one used to become part of the name being searched for, so the file was missed; the position is
now parsed out and returned (`line`/`column`/`endLine`) instead. Two traps this file encodes:
check traversal SEGMENT-wise, not by substring (`mod..old/thing.ts` is an ordinary filename that
a substring check rejects outright), and note that any rule which can DELETE a `..` must run
after every rule that can CREATE one — trailing-noise trimming turned `a/b/..` into a clean-
looking `a/b` and laundered an escape past the safety check. A generated matrix pins that
invariant.

**Never answer a path question with an errno.** `/api/files/list` accepts optional
`cwd`/`sessionId` and, when the requested path can't be listed, resolves and retries, flagging
a stand-in via `requestedPath`. A dead-end error message in a file tree is a bug, not a
diagnosis: degrade to the nearest existing directory plus "couldn't find X".

### Remote Session Daemon (resilience model)

**Topology:** walnut (Mac) ←ssh tunnel→ daemon (remote bun binary) ←spawn→ `claude -p` CLI. Goal: tunnel/daemon crashes don't lose sessions.

**Remote files:** `/tmp/open-walnut/sessions.json` (registry), `/tmp/open-walnut-streams/<sid>.{pipe,jsonl,pgid}`. JSONL is source of truth.

**CLI lifecycle (READ THIS — easy to get wrong):**
- `claude -p --input-format stream-json` is **LONG-RUNNING**, NOT per-turn. One CLI process stays alive across many messages, reading new input from its FIFO stdin between turns. (Evidence: a session with 39 messages had only 4 spawns.)
- The daemon holds the FIFO open with `O_RDWR` (`daemon-standalone.ts`) so the pipe survives between turns. Process is reaped only by the **idle timer** (`SESSION_IDLE_KILL_MS = 2h`, 5-min warning) or a real death (ENXIO / pid gone / crash) — never "turn ended".
- `isTurnCompleteExit()` does NOT mean turns exit. It only runs *inside* `reapSession()` to normalize the exit code *when a death already happened*, deciding if the last JSONL `result` line was a clean turn-end vs a crash.
- `--resume <sid>` is the **fallback** path (FIFO write failed / process really died), not the normal send path. Normal send = write the live FIFO.

**Delivery paths (where mid-turn injection breaks):** A send to a session walnut thinks is "processing" (`activeProcessing`) goes through `injectMidTurn` (gated on `targetSession.hasPipe`); otherwise `processNext` (writes the FIFO directly, no hasPipe gate). Pitfall: `RemoteSessionManager._hasPipe` is set `true` only in `start()` — `attach()` (used when reconnecting to an already-alive CLI after a daemon restart) returns `alive:true` but historically left `_hasPipe=false`, so `injectMidTurn` falsely reported "no FIFO pipe" and queued the message until the turn ended (25–55s grey stall). Keep `_hasPipe` in sync with daemon-authoritative liveness, not with spawn-vs-attach.

**The server never signals a CLI pid; orphans end through the owning daemon.** A pid in a session
record proves nothing about which daemon spawned the process: on 2026-09-26 an ephemeral test server
over a hand-copied production `sessions.sqlite` SIGTERM'd the user's live CLIs from its orphan sweep.
Every server path that ends a CLI now sends the owning daemon's `stop` (`src/core/sessions/owner-stop.ts`),
and every such stop names the asking Walnut (`home`, capability `owner-home-v1`, `stop-provenance.ts`): the
daemon refuses one for a session its spawn journal records for another Walnut. Orphans use reason
`'orphan'` (capability `orphan-stop-v1`, fail-closed on old daemons), which the daemon honours only for a
process in its own registry (the pid it holds, a matching `.pgid` file in its own streams dir written at
spawn by it or by the daemon it took the session over from, a first spawn-journal line naming the asking
data dir) that nothing keeps alive. Ephemeral servers clear every
inherited pid at boot, refuse to boot on the production data dir, send `strict` stops (only sessions the
journal names them for), and send no stop at all to a daemon without `owner-home-v1`. Ratchet:
`tests/core/signal-call-ratchet.test.ts`.

**One CLI per session id on a host, whichever daemon asks** (`src/providers/cli-owner-core.ts`, both
twins). A daemon only knows its own registry, so a second daemon on the same host (an ephemeral test
server's, a restarted one that could not adopt) used to `--resume` a live session as a second process
on the same transcript: 2026-10-25, an ephemeral server's copied `wait_until` clock did, and two CLIs of
one session edited one tree. Every spawn that names its transcript (`--resume`/`--session-id`, not a
fork) first claims `<spawn-journal dir>/cli-owners/<config dir key>/<sid>.json` with O_EXCL; a record
there is judged against the process table (CLI pid alive with its start time, or a live daemon's claim
under 120 s), never trusted, and a stale one is broken. After the claim a `ps` scan refuses a live
process of the same sid in the same `~/.claude` that no record names (an older daemon's CLI). A refusal
is `errorKind: 'session_live'`, spawns nothing and touches no one's FIFO or process. The server reads
it as `SessionHeldElsewhereError` (`delivery-failure.ts`): the message is parked with that cause (a
Retry sends it once the other process is gone), the session goes back to stopped, the task to
NEED_ACTION with the cause as its status, and an asker waiting on a reply is told at once that its
message was NOT delivered (2026-10-28: a `claude -r` left open in a terminal refused every send for
hours, the server retried on each reconnect, and the asker read "its turn ended" with a quote from
hours before). The words say plainly that the user (or someone) has the session open in a terminal,
the task is fine and the message waits, so nobody takes the task for broken; a lead is not told its
worker failed, and the record keeps `heldElsewhere` (2026-10-28: a lead read the old words as "broken",
moved the work and closed the task the user was working in). `task_transfer` from a session never
completes a source still in use (`src/core/transfer/source-in-use.ts`: a person wrote in it, or its
session was open elsewhere, within 7 days; open subtasks, an org role, an armed trigger); it says why
in `sourceKeptOpen`. Every park answers the askers of the rows it parks
(`src/core/sessions/undelivered-requests.ts`), a request whose message still waits in the target's
queue is never settled by a turn end of the target, and a cold resume's first line stays queued until
the new process's init (a CLI that dies loading the session parks it instead of losing it). Ratchet:
`tests/e2e/session-undelivered-e2e.test.ts`. A daemon replacing its own CLI waits for its whole process group to exit
first. An ephemeral server's starts carry `strict`, and the daemon refuses one for a transcript the
spawn journal records for another Walnut (`session_other_walnut`), live or not. Ratchets:
`tests/providers/cli-owner-core.test.ts`, `tests/providers/daemon-cli-owner-twins-e2e.test.ts`.

**A task whose session host is gone is never stuck** (`src/core/sessions/host-lost.ts`; 2026-11-07:
a task on a laptop that went offline answered 502 to task_move, "already has its active session" to
a start elsewhere, and "its host has not confirmed it yet" to task_stop, for good). A stop on a host
with no live link and no answer to a fresh connect (8 s) stops the session here at once: the record
carries `hostLost` (when, which host, why, who), its stop request stays pending, so nothing is sent
to it from here, and the host's reconnect sends that stop before any redelivery (`sessionStops.flush`),
which ends the old CLI and stamps `hostLost.endedAt`. Carrying the work on elsewhere is stricter
(`hostGone`): the link down 2 minutes or more of this machine's AWAKE time (a down link samples
its own clock, `AWAKE_TICK_MS`, so a Mac's sleep never counts), not for a sign-in failure, and this
machine online (the move's new host answers, or another host does, or with none it has an address
and the failure is not its own network); short of that the move or start answers 409
`host_unreachable` with why and when to try again (it was a 502). Then `task_move` copies nothing
from the old host and starts on the new one with Walnut's own copy of the conversation (the
history cache from the last compaction on) or, with none, the task's summary; a start on another
host (`task_start`, quick start) becomes that move. ACP engines keep the refusal (their thread lives
on the host). What the lost session wrote on its host after the stop is refused at the journal
replay (`lostSessionWindow` in `offline-handover.ts`). A send to a task, or to its old session,
while any move runs is held and goes with the task, never `target_archived` (2026-11-08).
Ratchet: `tests/core/sessions/host-lost-paths.test.ts` (every refusal on an unreachable host, per
file, with its way out); live: `tests/fleet/host-lost.fleet.ts`.

**Daemon restart:** old `cleanup()` leaves CLI alive. New daemon reconciles sessions.json then scans `.pgid` files — scan MUST skip sids already adopted (`if (sessions.has(sid)) continue`). All death paths funnel into `reapSession()` in `daemon-core.ts`; it calls `isTurnCompleteExit()` to normalize code to 0 when JSONL tail shows clean turn completion (otherwise every turn-end shows "exit -1" in UI).

**A session's stdin FIFO is write-only (mode 0200), and nothing but its CLI may read it.** A FIFO
has one byte stream and any reader takes from it. On 2026-10-05 an agent ran
`grep … $(ls -t)` in the streams dir; Claude Code's `grep` is its embedded ugrep, which reads a
FIFO named on its command line, so for 12 minutes every line written to every live session on
that host went to grep. The daemon locks each FIFO right after it opens it for the CLI, on every
adopt path, and in its startup permission repair (`LIVE_FIFO_MODE`, `daemon-core.ts`); the CLI
keeps its fd 0 and the daemon writes O_WRONLY, so neither notices. Behind it,
`send-lost-line-v1`: a line the CLI has not named after 30 s is challenged with a `get_settings`
written after it. Claude Code reads stdin in order, so an ANSWER with still no word on the line
proves the process read past it, and only then is the line written again under its uuid
(`lostPid`, after a lone newline that ends any fragment the CLI holds). No answer proves nothing;
a marker out of the daemon's scan window proves nothing either. Ratchets:
`tests/providers/daemon-send-dedupe-twins-e2e.test.ts`, `tests/providers/line-read-past-rewrite.test.ts`.

**Keep in sync:** `daemon-standalone.ts` (bun binary) + `daemon-source.ts` (JS fallback). Build: `bash scripts/build-daemon.sh`.

**Auto-deploy (use this):** `DaemonConnection` compares local `.version` vs remote `binary --version`; if differs, gzips + chunks binary into 1MB pieces, each via separate SSH connection (bypasses proxies that kill >5MB transfers), retries 2x per chunk, falls back to 44KB source deploy if chunked binary fails. Just `bun run build && bash scripts/build-daemon.sh && bun run dev:prod`: the next UI send to that host auto-upgrades (old CLI processes survive via Phase C).

**Every hold on that update has an end** (`src/providers/daemon-update-behind.ts`; 2026-11-05: a host
whose sessions always run stayed on an old daemon for about 17.6 hours, the reasons taking turns).
One clock per host and running build, kept on disk. Once a host has run another build for 3 hours
the waiting stops: every hold gives way (host_exec runs, open ACP turns, the update gap; this
server's writes in flight get 30 s), and the swap passes `walnut daemon update --over-busy`
(`daemon-over-busy.ts`): plain asks for 30 s, then `force` (a daemon with `service-update-force-v1`
goes over its requests, relays, ACP workers and host_exec runs, each run's log saying why), and at
a minute, or when the daemon does not answer (its hello or the update), a restart by its service.
That restart's SIGTERM shutdown drains for at most 45 s, then ends by SIGKILL to itself, and the cron
supervisor SIGKILLs the daemon pid alone after 60 s (systemd and launchd have their own stop
timeouts). The CLI sessions are their own process groups and keep running; an ACP turn or a
host_exec run open at that moment ends. An older daemon's shutdown does not wait for file writes, so
from the swap's start (after the upload, on the SSH path, the host network path and this Mac's own
managed daemon alike: `daemon-file-fence.ts`) this server holds its file changes back, at most 3 minutes in all (`SWAP_FILE_HOLD_MAX_MS`); writes from
another client of that daemon are not covered. A host that still runs another build after 3 such
swaps (a broken version stamp) is not forced again for that build (`MAX_FORCED_TRIES`). 3 hours is when the waiting stops, not a bound on the whole update, and on a
host that is never quiet a forced swap can come about every 3 hours while builds keep landing. An
update not done with no clock to ask again is listed in `DAEMON_UPDATE_STOPS` with why. `DAEMON_UPDATE_HOLDS` lists every place an update is put off;
`tests/providers/daemon-update-holds-ratchet.test.ts` fails on one not listed.

**Never scp manually** — some corporate SSH proxies kills large transfers. That's exactly what the chunked auto-deploy solves.

### CLI scheduled tasks (crons) are DIRECTORY-scoped, not session-scoped

Upstream docs say "session-scoped". They are wrong, and a 2026-08-09 incident here proved it: session A's recurring cron fired *inside session B* (same cwd) as a bare user message, running a multi-hour unattended job under `bypassPermissions` with zero provenance. Behavior model, established by controlled experiment (CLI 2.1.224):

| `durable` | Where it lives | Creator killed | Creator `--resume`d |
|---|---|---|---|
| `false` (CLI default) | in-memory | job dies, **no** other session can adopt it | **REVIVES** from history replay and immediately fires anything overdue |
| `true` | `{cwd}/.claude/scheduled_tasks.json` | the current **directory** lock holder (`scheduled_tasks.lock`) ADOPTS and executes it | creator reclaims it |

Consequences to keep in mind: killing a process is *never* a reliable way to stop a cron (only `CronDelete` is); recurring tasks auto-expire after 7 days; a creator schedules its own tasks without holding the lock (the lock only gates adopting *foreign* tasks); `CLAUDE_CODE_DISABLE_CRON=1` stops a bystander from ever adopting.

**Walnut can enforce `durable:false` — OPT-IN, delivered as daemon hooks (hooks-v1)**. Default posture is ZERO hooks: a generic install denies nothing, injects nothing, and never rewrites the user's `scheduled_tasks.json`. Two ways to opt in: (a) config sugar `session.cron_policy: 'session-only'` compiles the built-in rule set, or (b) install the self-contained template `src/data/hook-templates/session-only-cron.yaml` into `~/.open-walnut/hooks/` and edit freely (your file wins over the sugar by id). The server compiles `~/.open-walnut/hooks/*.yaml` with `runtime: daemon` into ONE rules JSON (`src/core/hooks/daemon-hooks.ts`) and pushes it via the `hooks.configure` RPC (NOT bridge-reachable; hash-skipped no-ops) at connect + hot on config change — no daemon restart needed on `hooks-v1` daemons; older daemons fall back to `WALNUT_ENFORCE_SESSION_CRON=1` set at spawn. The daemon interprets rules (never executes pushed code) at four points (`src/providers/daemon-core.ts` `evalDaemonHookRules`), in order of how much they can be argued with: (1) `cron.create` → `deny` the durable `CronCreate` at the `can_use_tool` intercept; (2) `cron.created` → `inject` a fixed "CronDelete + recreate non-durable" correction for bypass sessions which never ask — **advisory, and a live CLI verifiably refused it** on 2026-08-11, reasoning that an automated message is not user authorization (correct reasoning, which is why it can't be the guarantee); (3) `cron.fire` (foreign) → `evict` the orphaned row from disk — a foreign fire proves nobody in this process will ever CronDelete it, and eviction is the only thing that ends the hourly hijack loop (2026-08-13: 22 fires); (4) `session.reap` → `strip-own-rows` from `scheduled_tasks.json` — the model has no say, and death is exactly when a durable row becomes adoptable. Foreign fires always get a `scheduled_task_fire` stream marker for the HUMAN (observation), but deliberately NO model-visible message (the old injected warning burned a turn + context per fire and stopped nothing). Idle reapers treat a cron-armed session as long-lived (`hasDiskCronInterest`, 7d), and terminating one needs `force:true` (409 `cron_owner`). `WALNUT_ALLOW_DURABLE_CRON=1` on the daemon is the emergency kill-all override — better yet use crontab/launchd starting its own dedicated session for cross-session jobs.

**Debugging send/delivery latency (quick refs):**
- Both local (`__local__`) AND remote sessions go through the daemon / `RemoteSessionManager`. There is no separate "local" transport — don't assume a stall is SSH-specific.
- Logs: structured JSON at `/tmp/open-walnut/open-walnut-<date>.log` — but **timestamps are UTC** while the **filename is local date**, so a UTC-morning event lands in the *previous* local-day file. Filter by the UTC prefix, not the filename date.
- Daemon's own logs: `/tmp/open-walnut/daemon-d-*.log` (JSON). `state_transition` + `reconcile-adopt` show the long-running process being re-adopted across daemon restarts (proof of long-running CLI).
- Measure end-to-end honestly: `browser [send] dispatching` and `web session message via RPC` share the **same server-logger clock**, so pair them by `sessionId` (not by external `date`/bash time). Stages: `dispatching`→`session message via RPC`→`message enqueued`→`messages batched`→`message delivered`. The `deliveryMs` field only covers enqueue→delivered, so it *misses* any pre-enqueue event-loop lag.
- `scripts/walnut-logs.sh diagnose [sid] | busstorm [sid] | trace <sid> | pipe <sid> | session <sid> | delivery [sid] | slow [ms] | daemon <sid>` — see the log-toolkit section below. **For "send is slow", start with `diagnose <sid>` — it auto-labels the cause (Bug D mid-turn stall / event-loop starvation / slow resume).**

### Subsystem Map

The product is FOUR surfaces sharing one core: the **web console** (Mac,
:3456), the **iOS app** (`ios-native/`, SwiftUI, talks the frozen `/api/v1`),
the **cloud companion** (an EC2 instance you deploy, `WALNUT_CLOUD_MODE=1`
REPLICA — same codebase, proxies to daemons over the `/bridge` WS), and the
**session daemon** (bun/node twins deployed to every exec host — Mac local +
remote dev boxes — owning `claude` CLI processes so they survive tunnel/Mac
death).

| Subsystem | Entry point | Details |
|---|---|---|
| Model calls (providers, catalog) | `src/model/` | `sendMessage` + provider adapters |
| Core (tasks/sessions data) | `src/core/` | skill `walnut-core-internals` + [src/core/AGENTS.md](./src/core/AGENTS.md) |
| Sessions (local + SSH) | `src/providers/` | [ARCHITECTURE.md](./ARCHITECTURE.md) |
| Session daemon (twins) | `src/providers/daemon-standalone.ts` + `daemon-source.ts` | "Remote Session Daemon" section above |
| Web GUI | `src/web/`, `web/src/` | skill `walnut-web-frontend` + [web/src/AGENTS.md](./web/src/AGENTS.md) |
| iOS app | `ios-native/` (xcodegen; `project.yml`) | frozen contract [API v1](./docs/reference/api-v1.md) |
| Cloud companion | `src/web/ws/bridge-registry.ts`, `scripts/cloud/setup.sh` | infra: `infra/` (CDK); deploy = bundle→S3→SSM |
| Voice input (STT) | `src/core/stt/`, `src/web/routes/stt-v1.ts` | routes primary/bridge/openai by reachability |
| Memory & search | `src/core/memory-*.ts`, `src/core/search/`, `src/lib/hybrid-search/` | [hybrid-search README](./src/lib/hybrid-search/README.md) |
| Event bus | `src/core/event-bus.ts` | [ARCHITECTURE.md](./ARCHITECTURE.md) |
| Subagents | `src/providers/` | [ARCHITECTURE.md](./ARCHITECTURE.md) |
| Cron | `src/core/cron/` | [ARCHITECTURE.md](./ARCHITECTURE.md) |
| Plugins | `src/core/integration-*.ts` | [ARCHITECTURE.md](./ARCHITECTURE.md) |
| Chat history | `src/core/chat-history.ts` | skill `walnut-core-internals` |
| Usage tracking | `src/core/usage/` | [ARCHITECTURE.md](./ARCHITECTURE.md) |
| Git sync (data hub) | `src/integrations/git-sync.ts` | Mac ⇄ EC2 data plane; secrets NEVER ride it: ignore rules are only the first layer, every commit path stages through `stageAllForCommit`, whose content scan (`git-sync-secrets.ts`) holds any file with a credential out of the commit and tells the user (2026-07-26: an unlisted atomic-write temp of a sign-in cache was committed); every git spawn takes `gitChildEnv()` (`src/lib/git-env.ts`), because an inherited `GIT_DIR` outranks `cwd` and git exports one while running hooks |
| Logging & ops | `src/logging/` | skill `walnut-ops` + [src/logging/AGENTS.md](./src/logging/AGENTS.md) |
| Testing | `tests/` | skill `walnut-testing` + [tests/AGENTS.md](./tests/AGENTS.md) |

## Development

```bash
bun install                   # Dependencies; git and bun 1.4.4+ are all a checkout needs
bun run build                 # Build server → dist/
cd web && bun x vite build    # Build React SPA
cd web && bun x vite          # Frontend hot reload (:5173, proxies to :3456)
bun run test                  # ⭐ DEFAULT (= test:local): the tests of your change, about a minute
bun run test:land             # what the land check runs: every test the change needs, a few minutes
bun run test:quick            # the whole quick tier, ~3,200 files
bun run test:ci               # Everything CI runs: quick, slow, e2e, frontend, one after another
```

### Test pipeline: run the cheap layer, not the whole suite

Full details: [Testing pipeline](./docs/reference/testing-pipeline.md).

| Layer | Command | Time | When |
|---|---|---|---|
| L0 local | `bun run test` (`test:local`) | about 1 min | every code change: the change's own tests, fast importers, scan tests |
| Land | `bun run test:land` | a few min | before a land (the land check runs it): every test the change needs, to the end |
| L1 quick | `bun run test:quick` | ~10 min at 8 workers | a change that reaches far; the push gate runs it |
| L2 focus | `bun run test:focus <path>` | 0.3 to 30s | one module |
| L3 pre-commit | `bun run test:pre-commit` | 1 to 6 min | before a larger commit: maps your diff → affected tiers |
| L4 CI | GitHub Actions, automatic | free | every push/PR |
| L5 live | `bun run test:live:cloud` / `test:live:daemon` | ~25s / ~2min | cross-machine feature sign-off: zero mocks, real cloud→bridge→CLI; asserts the CLI's actual reply. Mock-green ≠ working (2026-08-07: live layer's first run caught a spawn race no mock can reproduce) |

**The baseline is ONE known failure, not 118** (`d0ae758b` drove it from 121 to 1 by fixing root causes; `tests/setup/known-failures.json` is the record). Judge your change with `bun run test:baseline`: it fails ONLY on failures absent from that file. Never judge from the raw aggregate count, and never wave a pile of red away as "the baseline": at a baseline of 1, extra failures are your regression, another agent's uncommitted `src/` work in this shared worktree, or concurrency noise (fails in a 3-way parallel run, passes alone). Attribute before acting: re-run the file alone, and diff `git show HEAD:<file>` against the worktree to see whose change moved it. When you legitimately fix or add known failures, `bun run test:baseline:record`.

**CI failed?** `scripts/ci-status.sh brief` distils the run into the few real error lines; fix locally (free) rather than running an AI inside CI (paid).

⚠️ **Never raise the local worker budget** (`tests/setup/worker-budget.ts`, **1 worker**). Test fan-out hard-crashed this Mac twice in July 2026 AND again 2026-08-05 at 2 workers (concurrent agent sessions + real spawned servers/daemons live outside the V8 heap cap). Want faster? Use L1 or L2.

## E2E-First Development

**开发开始时必须实际调用 `/plan-develop-test-verify-review-commit`,不能只复述流程。** 若当前环境没有该 skill,明确说明并执行本节的等价验收要求,不假称已经调用。仅修改文档或配置时按实际影响验证,不为无 UI 改动启动浏览器。

**写代码前先列验收场景,主 agent 对最终结果负责。** 测试必须覆盖目标界面的真实使用路径与数据密度,以及相关的边界、并发、失败和恢复路径;不适用项说明原因。Mac app 的网页问题必须验证 WebKit,不能只测 Chromium。

**主 agent 必须亲自完成关键操作与证据检查。** 子 agent 可以协助,但其报告、简单文本样例、类型检查、测试进程已启动或部署成功,都不能代替用户流程验收。修复后重跑原始复现与相关回归,并确认验收的是最终构建;还有失败、未测关键场景或后台测试未结束时,只报告进度或阻塞,不能宣布完成。工具无法显示截图时明确说明,不把 DOM 检查说成肉眼审图。

**Testing and review scale with the change, not with the process** (2026-11-02, the user: "the review should be scale based on the actually scale of the code"; a one-minute `git revert` of a full-screen change then ran Chromium and Linux WebKit specs and a review agent for 30 minutes). Pick the tier before testing and name it in one line; any escalation trigger moves a change up, whatever its size:

| Tier | What counts | Run | Skip |
|---|---|---|---|
| Small | a revert to a state production already ran; CSS, copy, docs or config text only; a few dozen lines with no new branch or state | the one or two tests or specs that touch it, on the engine the user runs (WebKit for the Mac app); the owner reads its own diff; deploy; a short video when it is visible | the full quick tier, `test:pre-commit`, other browser engines, a review agent, an HTML explainer |
| Medium | new logic in one area, a few files, no trigger | the affected test groups (`test:focus`, or `test:pre-commit` for its tiers) and the touched flow in Playwright on the user's engine; one review agent on a mid-size model | the whole suite, a second engine unless the change is engine-specific |
| Large | any trigger, or a change across subsystems | the full flow below: scenario matrix, `test:pre-commit`, the engines the change reaches, an independent review on the strongest model | nothing |

Triggers: user data or storage formats, a migration, concurrency (locks, queues, ordering), security, permissions or credentials, an outward send, the session daemon or process lifecycle, the deploy or release scripts, the frozen `/api/v1` contract or older clients. A failing test in a lower tier is fixed and rerun alone; it moves the change up only when the failure shows the change reaches further than its tier said. A bug fix still adds its guard test in every tier.

- Bug fix: Playwright repro → fix → verify same flow → commit
- **A bug fix covers the whole class, with a guard test** (2026-10-29: three bugs reported fixed came back the same day; each first fix covered only the known case, a fixed list of note types, one of 29 components with the same render mistake). Name the class the bug belongs to, fix every member, and add a test that fails when a NEW member of the class appears (enumerate the members from the source, as the ratchets here do), not only one for the case that was reported.
- **A bug is fixed when it works in the user's own build, on the data that showed it.** The Mac app's WKWebView or the phone build, against the real data of the report; a green test on another build or a fixture is not that proof.
- Feature: define E2E scenarios → implement → build → Playwright verify → commit
- Test UI changes as a real user with Playwright; for load bugs, test `/` and the reported URL 5× and report worst full-load time/errors.
- **NEVER** commit UI changes without Playwright verification
- **NEVER** use `page.goto()` — use real UI clicks (SPA navigation)
- Use `/verify` after implementation

### Playwright runs are machine-wide serialized (don't fight the gate)

Every browser worker is a whole Chromium (~385 MB measured). With several agent
sessions each running `bun x playwright test`, this used to sum to dozens of browsers
and wedge the Mac (2026-07-25: load avg **225** on 14 cores, 1210 processes) — which
then surfaced as "Walnut is slow" and as runs dying with `Timed out waiting 30000ms
from config.webServer`. Concurrent runs were never safe anyway: specs hardcode
`localhost:3457`, and `reuseExistingServer` makes run #2 attach to run #1's fixture
server, so they share one dataset and the first to finish kills the other's server.

The Playwright runner is the one tool here that still runs on Node (`bun x playwright test`
starts it; CI installs Node for it). The fixture servers it tests run on bun like everything else.

`playwright.config.ts` now engages a gate at config-load time (`tests/e2e/browser/pw-gate.ts`):

- **Exclusive lease on :3457** — a second run *queues* instead of interleaving. Seeing
  `[pw-concurrency] another Playwright run holds :3457 … Queuing` is correct behavior,
  not a hang. It self-heals (dead holder / 45-min TTL), and it never runs beside a live
  holder: after `PW_LEASE_WAIT_MIN` (120) it stops with an error and nothing runs.
- **`workers` capped at 4** (was `undefined` = half the cores = 7). Override with `PW_WORKERS`.
- **Orphan sweep** — reaps a fixture server left by a SIGKILLed run before
  `reuseExistingServer` can silently attach to it.
- **Overload wait** — if something else already saturates the box (concurrent vitest,
  Xcode, simulators), it waits rather than piling on, up to 10 minutes, then stops with an
  error and nothing runs. `PW_IGNORE_LOAD=1` skips it.
- **Machine hold** — a machine with `~/.config/walnut/hold` starts no test run, and one with
  `~/.config/walnut/night-quiet` none from 22:00 to 08:00 by its own clock (`src/lib/machine-hold.ts`;
  2026-11-09: review scripts took the lease and started fixture servers on a held Mac at night).
  Every entry point reads it: every vitest config (`maxWorkers()`, the global setup), every
  Playwright config and hand-written script (`engageGate`, `perRunWorkers()`, `acquireSync`), the
  ephemeral test server, and every Playwright browser a Walnut session launches (the silencer
  preload). The file is the only switch. Ratchet: `tests/setup/machine-hold.test.ts`.

```bash
scripts/pw-cleanup.sh status   # browsers / fixture server / isolated daemons / leases
scripts/pw-cleanup.sh clean    # reap orphans + stale leases (skips live runs, never :3456)
```

**When a Playwright run fails on timeouts, check the load first** (`scripts/pw-cleanup.sh
status`). At load 486 every spec failed on `page.waitForLoadState` — those are starvation
artifacts, not product bugs. Fixture cold boot is ~20 s idle but ~70 s at load 133, so
`webServer.timeout` is 120 s.

**Run a TypeScript script with `bun <file>`.** Bun runs TypeScript itself, so no test path
needs tsx any more (a bare `npx tsx --version` once measured **88 s** on this machine, which
alone exceeded the old 30 s webServer budget).

## Testing

Every feature needs 1+ real E2E test through `startServer({ port: 0, dev: true })`. Only mock the Claude CLI. See [tests/AGENTS.md](./tests/AGENTS.md).

## Conventions

Plans: architecture diagrams first → UX scenarios → pseudocode. No detailed implementation code in plans.

### Menus & overlays (web UI) — hard rules

**Before touching any dropdown/menu/flyout in `web/src/`, read ["Menus & overlays — hard rules"](./web/src/AGENTS.md#menus--overlays--hard-rules).** Every rule there is a shipped incident. The one-line version: menus never overflow the viewport (always place via `useMenuPlacement`); unbounded content becomes its own portalled flyout, never inline growth; no native `<select>` inside styled menus; menu portals need `onPointerDown` stopPropagation or dnd-kit drags the row; outside-click closers must exempt child portals.

### A page's view choice is kept: `usePageView` (web/src/utils/view-memory.ts)

Every route but Home unmounts on navigation, so a tab, filter, sort or show-this toggle held in `useState` comes back reset (2026-11-01, the user: "Walnut needs to remember what was open last time, permanently, on every page"). Keep it with `usePageView(page, name, initial, accept)`, or `usePageViewOf(..., id, ...)` for one per node or board; it is mirrored to ui-prefs like the layout. A dialog, a menu, a typed search or one search's results start empty. `tests/web/view-memory-guard.test.ts` fails on a new view-named `useState` started from a literal until it is kept or listed as passing, with why.

Walnut keeps it by itself wherever a view is hosted, plugin pages included (2026-11-02, the user: "make it default feature in walnut no matter where include plugin, remember the state, like full store, file tab, tab location"). A session panel keeps what it shows beside its chat per **place** (`components/sessions/panel-view.ts`: the split view, the file a click opened, the Web service, the Inbox letter, a chat the user hid), the place being its host (the required `viewPlace` prop: `home-column`, `ask-slot`, `org-node`, `plugin:<id>`, ...) and its session, plus the page for a host off Home (`useSheetPlace`). Full screen stays one state of the tab, kept on disk as the place that holds it (`hooks/fullscreen-owner.ts`): a panel that mounts again on that place (a plugin reload, a page reload, a visit elsewhere, a restart) is full screen again; leaving it, entering it elsewhere or opening a column on Home ends it. Each tab shows its own kept view (2026-11-02: in two tabs of one plugin page, a file picked in one switched the other and remounted its editor under the user's typing, because the snapshot read localStorage on every render): a tab reads a view from storage when it first mounts it (for a view kept per id, the first mount of that id) and then only its own writes change it, so another tab's choice shows at the next mount (`tests/web/view-memory-tabs.test.ts`, `tests/e2e/browser/view-state-tabs.spec.ts`). Typed text is no view: a plugin keeps it with `walnut.ui.useDraft(name, { id })`, Walnut's draft store (`plugins/use-draft.ts`; one key per draft, never in ui-prefs, another window's change taken only at mount). A plugin keeps its own view choices with `walnut.ui.useView(name, initial, { id })`, and `walnut-plugin validate` warns on one kept in `useState` and on a draft kept anywhere but `useDraft`, with the matcher the guard uses (`packages/plugin-cli/src/view-state-scan.ts`; the guard also scans the plugins this repository ships). Ratchet: `tests/e2e/browser/view-state-kept.spec.ts`.

### Frontend logging: `import { log } from '@/utils/log'`

Use the structured logger (`log.info('subsystem', 'message', { sessionId, taskId })`) — never raw `console.log`. IDs must be **full, never truncated** so `grep <sessionId>` traces across browser + server. The logger routes through `console.log`/`warn`/`error` which the browser-logger monkey-patch forwards to `/tmp/open-walnut/`. Never use `console.debug` (invisible to forwarder).

### Where logs land (browser crashes included)

| What | Where |
|---|---|
| Server structured JSON (+ forwarded browser console) | `/tmp/open-walnut/open-walnut-<date>.log` — filter browser lines with `jq 'select(.subsystem=="browser")'` or `open-walnut logs -s browser` |
| Every HTTP request (method/path/status/ms/reqId) | same file, `subsystem=web` (request-logger middleware) |
| Uncaught JS exceptions / unhandled rejections / React render crashes | forwarded as `subsystem=browser` `[uncaught]` / `[unhandledrejection]` / `[react]` / `[error-boundary]` entries. Delivery: WS RPC when connected; REST `POST /api/browser-logs` fallback when WS is down (e.g. crash before mount) |
| Daemon logs | `/tmp/open-walnut/daemon-d-*.log` |
| Server exit trace | `/tmp/open-walnut-exit.log` |

`/tmp/open-walnut` belongs to the first Unix user who starts Walnut on a machine. Anyone else's
Walnut uses `~/.cache/open-walnut` for all of these (the exit trace is `exit.log` inside it), the
dir remote hosts already fall back to; a daemon running in either keeps its dir, and so does a
login service that pins the home one (`src/lib/daemon-dir.ts`; `scripts/lib/daemon-dir.sh`, which
`walnut-logs.sh` sources, is its sh twin). The server always names the dir to its daemon
(`WALNUT_DAEMON_DIR`); both daemon builds treat either dir as production. A second user's remote terminal
sockets go to `/tmp/open-walnut/term-<local uid>` on the remote host. 2026-10-30: a second user's server exited at
boot with `EACCES: permission denied, mkdir '/tmp/open-walnut/streams'`.

**"Blank page" triage:** grep the local-date AND previous-day files (UTC timestamps vs local filename!) for `error-boundary`, `\[react\]`, `\[uncaught\]`, and `JSON parse failed`. A repeated crash self-heals via `web/src/utils/crash-recovery.ts` (clears sessionStorage → then walnut localStorage keys + skips one prefs merge); the heals also log `[crash-recovery]`.

## Log investigation toolkit: `scripts/walnut-logs.sh`

One entry point for digging through Walnut logs (structured JSON at `/tmp/open-walnut/open-walnut-<date>.log`). Needs `jq`.

```bash
scripts/walnut-logs.sh diagnose [sid] [mins]  # ⭐⭐ START HERE for "message is slow": auto-labels each send's cause
scripts/walnut-logs.sh busstorm [sid] [mins]  # ⭐ streaming fan-out per subscriber (verify interest-set / spot a storm)
scripts/walnut-logs.sh trace <sid>       # per-message timeline dispatch→RPC→enqueue→route→delivered + Δms/hasPipe/path
scripts/walnut-logs.sh pipe <sid>        # hasPipe / lifecycle transitions — why a send was queued
scripts/walnut-logs.sh session <sid>     # full timeline for a session
scripts/walnut-logs.sh delivery [sid]    # message enqueue→delivered latency (deliveryMs)
scripts/walnut-logs.sh slow [ms]         # deliveries slower than ms (default 3000) — find lag
scripts/walnut-logs.sh daemon <sid>      # which daemon-d-*.log serves a sid
scripts/walnut-logs.sh jsonl <sid>       # tail the session's CLI .jsonl stream
scripts/walnut-logs.sh file <substr>     # ⭐ ONE timeline for ONE file: reads + writes + refusals + the editor's decisions
scripts/walnut-logs.sh stalls [mins]     # ⭐ "the server froze": stall flight records (verdict, loop CPU vs hold, GC, paging) + kept CPU profiles
scripts/walnut-logs.sh req <id> | task <id> | errors [n] | tail [n]
```

**When the whole server froze, run `stalls` before anything else.** The stall flight recorder
(`src/core/stall-recorder.ts`) keeps a CPU profile of every hold of 2 s or more and tags it with a
verdict; `bun scripts/stall-profile-summary.mjs` (newest profile, or a path) prints the functions
the loop was in during the hold, with file:line. `cpu` means Walnut code held the loop, also on a
busy machine (`loadStretched: true` then says the load made it longer). Fix that code first. The
hold itself (`holdCpuMs`, `holdFromMs` to `holdToMs` around the deadline) runs from the last CPU
checkpoint before the deadline to the first one after it; checkpoints are the previous probe tick and a
100 ms timer, so a stop is a gap between two of them. The rules (`holdVerdict` in `src/core/stall-recorder-hold.ts`): the thread ran a
stall's worth of CPU (250 ms) in the hold and the kept profile, when it sampled the hold, mostly
shows code there (`profileHold`, with its top frames; time the sampler missed counts as nothing and
`coverage` says how much it saw); or it ran 250 ms up to the probe (`afterHoldCpuMs` is the part
after the hold, `profileAfter` the profile's view of it), counting only the part after the span when
it ran under half of a span of 250 ms or more, and only past the largest turn the loop ran normally
(`baseTurnMs`) when the span was shorter than that; or it ran 60% of the window. The first two count
only the CPU past the backlog of time the loop did not run its work: a pause right before the hold
(`afterWaitMs`) or between the hold and the probe (`afterHoldPauseMs`), each a second or more the
thread ran under a tenth of and under 250 ms in, in poll or not (more is a block load stretched, not
a pause), a wait in poll inside the hold of a second or more or most of it (`loopIdleMs`), a span
the thread barely ran in. Each owes twice the loop's own rate in its normal turns over the 10 s
before (`baseCpuShare`; read further back, up to a minute, when blocks and pauses filled those 10 s)
times its length, and all of its CPU when that rate is unknown. Trust the profile's functions only
for `cpu` or `gc`: profiles sample on a wall clock, so a `starved` thread (runnable, not run, and
with little of its own to run: the loop thread ran a few ms of the hold) is sampled wherever it
stopped. `starved`, or `paging`/`off-cpu` with a large `decompressions` or `swapins` count, is the
machine (CPU load, memory) rather than a Walnut code path. The recorder costs the loop thread 1.7 to
4.8 ms of CPU a second when idle, 3 to 10.6 ms in a loop of 1 ms timers, 7.7 to 19.6 ms in a loop
reading a socket (measured 2026-10-07 against no recorder).

**"Who overwrote my file?" starts with `file <path-substring>`.** Every read logs `file read`
(status 200/304, the hash the client now holds, the token it quoted, `track`, size, ms), every
write logs `file write` (writer user/live/merge, origin, hash before/after, the token presented,
size before/after, `shrankBy`), and every refusal logs `file write refused` with a `reason` that
matters: `stale-lock` is the guard working (two writers raced, the editor will merge), while
`unlocked-machine-write` means a client bug reached the server and should be ZERO. The browser side
logs `[file-editor] buffer installed` (which path replaced the buffer, the generation, the lock
before and after) and `[file-editor] live write sending` (`armedMs`, `armedGen` vs `currentGen`), so
the causal chain reads in order: a read teaches the editor a hash, the lock advances, a write goes
out under it. That chain is exactly what the 2026-09-05 stale-write-back incident had to be
reconstructed by hand from three message shapes across two files. A `live`/`merge` write with a
large `shrankBy` is that bug's signature.

**When a user reports "message send is slow", run `diagnose <sid>` first.** It pairs each message's enqueue→route→delivered by `messageId` and prints a labelled cause per message + p50/p90, so you don't hand-grep. Labels it distinguishes (these are the known distinct root causes — don't conflate them):
- **BUG D: mid-turn stall** — `injectMidTurn` on a stale `hasPipe=False` (remote sessions). The felt 30–50s QUEUED. Fixed by delegating to processNext; if this label reappears, the fix regressed.
- **EVENT-LOOP STARVATION** — dispatch→enqueue blocked. Was caused by streaming fan-out to global subscribers; fixed by the event-bus `interest` set. Cross-check with `busstorm`.
- **SLOW RESUME** — CLI dead, cold `--resume` path (inherently slower, not a bug).
- **SLOW DELIVER / STUCK** — catch-alls; fall back to `trace`/`pipe` for the timeline.

Both `diagnose` and `busstorm` default to a **30-min window** (so old historical stalls don't masquerade as "happening now"); pass a 3rd arg `mins` (e.g. `120`, or `0` for all-time) to widen it. Timestamps are UTC.

Message-send latency is logged as `message delivered {deliveryMs, path, messageId}` at every delivery point (`path` = stdin / mid-turn / resume). `messageId` (`qm-…`) is the cross-layer request id — grep it to trace one message end-to-end.

## Debugging the Claude Code CLI (stuck / silent sessions)

When a session goes `idle` with no output, gets stuck mid-turn, or the CLI appears hung, check **Claude Code's own trace log**. Walnut passes `--debug` to every `claude -p` spawn by default, so this log is always available.

```bash
WALNUT_CLAUDE_DEBUG=0 bun run dev:prod    # opt out if you need to
```

The flag is added in `src/providers/claude-code-session.ts`. Works for both local and remote (daemon) sessions; args are forwarded through the daemon unchanged.

**Where logs land:**

| Session type | Path |
|---|---|
| Local | `~/.claude/debug/<claude-session-id>.txt` |
| Remote (daemon on clouddev etc.) | `~/.claude/debug/<claude-session-id>.txt` **on the remote host** |

A `latest` symlink in the same dir always points at the most recent file.

```bash
tail -F ~/.claude/debug/latest                         # follow local
ssh clouddev tail -F '~/.claude/debug/latest'          # follow remote
```

**Verbosity knobs** (also env vars: export them before `bun run dev:prod`; for remote, set them where the daemon was started):

- `CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose` — include high-volume diagnostics (statusLine, shell, cwd, stdout/stderr). Default is `debug`, which filters those out.
- `CLAUDE_CODE_DEBUG_LOGS_DIR=/some/path` — override the `~/.claude/debug/` directory.
- `OTEL_LOG_TOOL_DETAILS=1` — capture full tool input/output in OTEL spans (separate from the `--debug` file).

**CLI flags** the fork supports (in case you want to invoke `claude` manually to repro):

- `--debug` / `-d` — enable debug mode (what Walnut injects)
- `--debug-file <path>` — write to a specific file (implicitly enables debug)
- `--debug-to-stderr` / `-d2e` — write debug to stderr instead of a file

The implementation lives in the fork at `~/workplace/myCode/claude-code-fork/claude-code-source-code/src/utils/debug.ts` — `logForDebugging()` is called throughout the CLI. All flags are already compiled into `fork-2.1.88`; no rebuild required.

### The "malware reminder" on every file read

If you're seeing `<system-reminder>Whenever you read a file, you should consider whether it would be considered malware…</system-reminder>` appended to every `Read` tool result, that's **not Walnut** — it's `@anthropic-ai/claude-agent-sdk`'s `CYBER_RISK_MITIGATION_REMINDER`. The SDK injects it unless the active main-loop model is in a hardcoded exempt set. Upstream only lists `claude-opus-4-6`; newer models (4.7, Sonnet, …) get the reminder on every read, eating context.

We keep a bun patch at `patches/@anthropic-ai%2Fclaude-agent-sdk@<version>.patch` (listed under `patchedDependencies` in `package.json`) that **disables the reminder for all models**: it rewrites the ternary `X4z()?j4z:""` in the minified bundle to just `""`, so no file read ever appends the reminder regardless of main-loop model. `bun install` applies it. When bumping the SDK version: run `bun patch @anthropic-ai/claude-agent-sdk`, re-apply the edit to `node_modules/@anthropic-ai/claude-agent-sdk/cli.js` (grep for `considered malware` to locate the template literal, then find and rewrite the ternary that conditionally appends it), and save it with `bun patch --commit @anthropic-ai/claude-agent-sdk`.