@crouton-kit/crouter 0.3.25 → 0.3.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ ---
2
+ kind: reference
3
+ when-and-why-to-read: When you hit a question about how the crtr runtime itself works — how nodes and the canvas behave, where state lives on disk, or how the primitives compose into real systems — this index should be read because it routes you to the runtime's own operational documentation, so you act from the canonical model instead of inferring it from command help.
4
+ short-form: internal/ is the runtime's self-documentation — operational guides to nodes/canvas and the storage tiers, plus worked example compositions. Open it when you need to understand how crtr works.
5
+ system-prompt-visibility: preview
6
+ file-read-visibility: none
7
+ ---
8
+
9
+ # internal/ — how the crtr runtime works
10
+
11
+ This directory is the runtime's **self-documentation**: operational references covering how crtr behaves, so any agent operating under it can route to the canonical model rather than reconstruct it from scattered `-h` output. It ships with crtr.
12
+
13
+ Open this dir whenever a task turns on understanding the runtime itself. Contents:
14
+
15
+ - **nodes-and-canvas** — the agent-runtime model: nodes on the canvas graph, spawn/delegate, the push/feed spine, lifecycle (mode + lifecycle axes), and revive (manual + daemon auto-revive).
16
+ - **storage-tiers** — where every kind of state lives: the three tiers (scope root, per-cwd crouter root, canvas home) and their durability/ownership contracts.
17
+ - **examples/** — worked compositions of the primitives into complete systems (the analogue of pi's `examples/` dir), e.g. the iMessage assistant node.
18
+
19
+ Adjacent, outside this dir: authoring memory documents (frontmatter, rungs, gates, the asked-to-remember workflow) is owned by the builtin **memory-authoring** skill; making a persona (a custom `--kind`) is owned by the builtin **crouter-development/personas** skills.
20
+
21
+ The individual files surface at `name` (their titles route; open the one the situation calls for); this index surfaces at `preview` so the dir announces when to come looking.
@@ -0,0 +1,15 @@
1
+ ---
2
+ kind: reference
3
+ when-and-why-to-read: When you want a worked end-to-end composition of crouter primitives — not what one command does but how several combine into a real standing system — open this dir because it holds complete example builds, each verified against the actual runtime and OS mechanics.
4
+ short-form: Worked examples composing crouter primitives into complete systems (like pi's examples/ dir). Currently — the iMessage assistant node.
5
+ system-prompt-visibility: name
6
+ file-read-visibility: none
7
+ ---
8
+
9
+ # internal/examples/ — worked compositions of crouter primitives
10
+
11
+ The other internal/ docs explain the primitives one at a time; this dir shows them composed into complete, buildable systems — the crtr analogue of pi's `examples/` directory. Each example is grounded: its OS-level mechanics and command contracts were verified before being written down.
12
+
13
+ - **imessage-assistant** — an OpenClaw-style always-on assistant: resident root node with its own dir and persona, woken event-style by a launchd watcher via `node msg`, reading `chat.db` (with the attributedBody gotcha), replying via osascript, remembering people through project-scope memory.
14
+
15
+ Add an example here when a composition was non-obvious enough that the next builder shouldn't have to re-derive it.
@@ -0,0 +1,100 @@
1
+ ---
2
+ kind: reference
3
+ when-and-why-to-read: When you are building a standing assistant node bridged to an external channel — iMessage, email, a chat service — this example should be read because it composes the crouter primitives (resident root node, custom persona, event-driven wake via node msg, project-scope memory) into a working OpenClaw-style design, with the macOS chat.db/osascript mechanics verified.
4
+ short-form: Worked example — an always-on iMessage assistant built from crouter primitives. Resident root node + launchd watcher → node msg wakes + chat.db reads + osascript sends + substrate memory.
5
+ system-prompt-visibility: name
6
+ file-read-visibility: none
7
+ ---
8
+
9
+ # Example: an iMessage assistant node (OpenClaw-style)
10
+
11
+ A standing assistant that lives on the canvas, sleeps for free, wakes when a text arrives, reads the conversation, replies over iMessage, and accumulates memory about the people it talks to. Everything here composes existing primitives — no new runtime machinery.
12
+
13
+ ## The shape
14
+
15
+ | Need | Primitive |
16
+ |---|---|
17
+ | Its own home | Dedicated dir (e.g. `~/assistants/imessage/`), node spawned with `--cwd` pinned there |
18
+ | Always-on, never "finishes" | `--root` spawn → resident lifecycle; dormant between messages costs nothing |
19
+ | Hears incoming texts | launchd watcher on `chat.db-wal` → `crtr node msg <id>` (event-driven; the spine delivers, the node never polls) |
20
+ | Reads messages | sqlite against `~/Library/Messages/chat.db`, cursored by ROWID |
21
+ | Sends replies | `osascript` → Messages.app |
22
+ | Memory | Project-scope substrate (`~/assistants/imessage/.crouter/memory/`) for durable knowledge; context dir for the cursor |
23
+ | Identity/behavior | A custom persona kind (see [[crouter-development/personas]]) |
24
+ | Crash recovery | The daemon auto-revives dead nodes; the watcher's `node msg` also revives a dormant target on its own |
25
+
26
+ ## 1. Persona
27
+
28
+ Define a kind (e.g. `imessage-assistant`) per [[crouter-development/personas]]. The persona body carries: who it speaks for, which senders it may auto-reply to (allowlist — never reply to arbitrary numbers), voice, the wake loop below, and the memory protocol (when to write a contact note vs. keep it in-thread). Behavior lives in the persona, not in the watcher.
29
+
30
+ ## 2. Spawn
31
+
32
+ ```bash
33
+ mkdir -p ~/assistants/imessage
34
+ crtr node new --root --kind imessage-assistant --cwd ~/assistants/imessage --name imessage <<'EOF'
35
+ You are the standing iMessage assistant. Initialize: read your persona's wake loop, record the current chat.db max ROWID as your cursor, then go dormant. From now on a watcher wakes you via inbox message whenever iMessage activity lands.
36
+ EOF
37
+ ```
38
+
39
+ `--root` makes it independent and resident — top-level on the canvas, no parent to report to, never forced to finish. Add `--headless` if it shouldn't occupy a tmux window. Record the returned node id for the watcher.
40
+
41
+ ## 3. Wake: event-driven, not polled
42
+
43
+ A trivial launchd agent watches the Messages write-ahead log and pokes the node's inbox. `node msg` revives a dormant target by itself, so the watcher is the *only* off-canvas piece.
44
+
45
+ ```xml
46
+ <!-- ~/Library/LaunchAgents/com.user.imessage-watch.plist (key parts) -->
47
+ <key>WatchPaths</key><array><string>/Users/YOU/Library/Messages/chat.db-wal</string></array>
48
+ <key>ProgramArguments</key><array>
49
+ <string>/bin/zsh</string><string>-c</string>
50
+ <string>crtr node msg NODE_ID "iMessage activity — check chat.db since your cursor"</string>
51
+ </array>
52
+ ```
53
+
54
+ Multiple bursts while the node is mid-turn just append to its inbox and coalesce — no dedupe logic needed. Trust the runtime: do **not** also arm a recurring `node wake at` as a backstop. A self-scheduled wake is only the fallback if you choose not to run a watcher at all (pure polling — workable, but pays a window per tick and adds latency).
55
+
56
+ ## 4. Reading chat.db (the verified gotchas)
57
+
58
+ - The process querying needs **Full Disk Access** (the node's shell inherits the terminal/launchd grant). Read-only — never write to chat.db.
59
+ - Cursor on `message.ROWID`, persisted in the node's context dir (e.g. `context/cursor`); filter `is_from_me = 0`.
60
+ - **On modern macOS `message.text` is often NULL** — the body lives in the `attributedBody` blob (NSAttributedString archive). Extract with:
61
+
62
+ ```python
63
+ import re
64
+ m = re.search(rb'NSString.{1,10}?\+?(.{1,200}?)\x86', attributed_body, re.S)
65
+ text = m.group(1).decode('utf-8', 'ignore') if m else None
66
+ ```
67
+
68
+ - Join for sender + thread:
69
+
70
+ ```sql
71
+ SELECT m.ROWID, h.id AS sender, c.chat_identifier, m.text, m.attributedBody
72
+ FROM message m
73
+ JOIN handle h ON m.handle_id = h.ROWID
74
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
75
+ JOIN chat c ON c.ROWID = cmj.chat_id
76
+ WHERE m.ROWID > :cursor AND m.is_from_me = 0
77
+ ORDER BY m.ROWID;
78
+ ```
79
+
80
+ ## 5. Sending
81
+
82
+ ```bash
83
+ osascript -e 'tell application "Messages" to send "reply text" to buddy "+15551234567" of (service 1 whose service type is iMessage)'
84
+ ```
85
+
86
+ Group chats target the chat instead: `send "..." to chat id "iMessage;+;chat123..."` (the `chat.guid` column). First send prompts a one-time **Automation** permission grant for Messages.app.
87
+
88
+ ## 6. Memory
89
+
90
+ Two tiers, matching [[internal/storage-tiers]]:
91
+
92
+ - **Durable knowledge** → project-scope memory in the node's pinned dir (`~/assistants/imessage/.crouter/memory/`): one reference per contact/thread (who they are, open loops, tone), loaded by the substrate on the node's own boots. The persona instructs when to write/update these.
93
+ - **Working state** → the node's context dir: the ROWID cursor, drafts, a running log.
94
+
95
+ ## The wake loop (persona-side)
96
+
97
+ 1. Wake on inbox message → read new rows past the cursor.
98
+ 2. Decide per message: reply (allowlisted + warranted), note silently, or ignore.
99
+ 3. Send via osascript; append anything durable to contact memory.
100
+ 4. Advance the cursor, end turn, go dormant. No wake armed — the watcher delivers the next event.
@@ -0,0 +1,49 @@
1
+ ---
2
+ kind: reference
3
+ when-and-why-to-read: When you are operating the canvas — spawning or steering nodes, deciding how work reports up, recovering a dormant or crashed node, or reasoning about the daemon — this reference should be read because it is the operational model of nodes, the spine, lifecycle, and revive, so you drive the runtime correctly instead of inferring it from scattered command help.
4
+ short-form: Operational model of the agent runtime — nodes on the canvas graph, spawn/delegate, the push/feed spine, lifecycle states, and revive (manual + daemon auto-revive).
5
+ system-prompt-visibility: name
6
+ file-read-visibility: none
7
+ ---
8
+
9
+ # How nodes and the canvas work (operational)
10
+
11
+ Every agent is a **node** in one directed graph (the **canvas**). Each node is an independent `pi` process in its own tmux window with its own context dir. The graph's edges are `subscribes_to` — the **spine** — and they decide who-wakes-whom.
12
+
13
+ ## Spawn & delegate
14
+
15
+ `crtr node new "<task>" --kind <kind>` spawns a managed child in a background window and returns its id immediately; you **auto-subscribe** to it, so its finish wakes you. Delegating is the default move, not an optimization: a child's reading and tokens land in a fresh window and only the conclusion comes back, keeping your own context (the scarce resource) free for steering.
16
+
17
+ - Match `--kind` to the work (`explore spec design plan developer review general`, plus any custom persona). See `node new -h`.
18
+ - Fan **independent** units out as concurrent children — a wake with idle workers is wasted. Serialize only true dependencies; never let two live children edit the same files.
19
+ - `--root` spawns an independent node you neither manage nor are woken by (e.g. one a human will drive).
20
+ - Once you delegate a unit, don't also run it yourself — you'll be woken when it finishes.
21
+
22
+ Navigate/steer: `node focus` (jump a node into your pane), `node cycle` (DFS-walk neighbors), `node msg` (direct-message any node at a wake tier, reviving a dormant target), `node subscribe`/`unsubscribe` (wire edges spawn didn't create). Survey with `canvas dashboard` (ASCII tree), `canvas browse` (interactive navigator), `node inspect list/show`, `canvas attention` (who's blocked on a human).
23
+
24
+ ## The push/feed spine
25
+
26
+ Nothing is reported automatically — the feed contains only what a node **pushes**.
27
+
28
+ - `push update` — routine progress; fans a lightweight pointer to subscribers, no forced wake.
29
+ - `push urgent` — force-wakes every subscriber (you're blocked, scope changed, an error derails the plan).
30
+ - `push final` — the ONLY way any node finishes: writes the canonical result, marks the node done, closes its window. Stopping without it is not finishing. (Guarded on a node working directly with a user — needs `--force` after they confirm.)
31
+
32
+ A push fans a ~30-token **pointer** (a ref path), not the content; subscribers dereference lazily. When a subscriber push wakes you, **the wake message already IS the coalesced digest** — don't re-run `feed read` to "open" it (the cursor already advanced); dereference the refs that matter. `feed read` is for proactively polling *before* a wake; `feed peek` shows live state of the nodes below you without draining (use it to confirm workers are alive before you yield). An empty feed while workers run is normal.
33
+
34
+ ## Lifecycle
35
+
36
+ Two orthogonal axes:
37
+
38
+ - **mode**: base (a terminal worker — does one job in one window and ends) ↔ orchestrator (`node promote` — a long-lived, roadmap-holding coordinator that delegates phases and survives context refresh via `node yield`). Don't promote for work that fits one window.
39
+ - **lifecycle**: terminal (owes a final up the spine, reaps when done) ↔ resident (`node lifecycle` / interactable — stays dormant, wakes on inbox/human, never forced to finish). `node demote` is the friendly flip-to-terminal-in-place.
40
+
41
+ A dormant node has two wake triggers: an **inbox** message (push/msg) and the **clock** (`node wake` arms timed/recurring wakeups). To monitor your own children you arm nothing — you auto-subscribe on spawn, so their finish/crash/close wakes you; a deadline to chase a delegate is a belt-and-suspenders the runtime makes redundant.
42
+
43
+ Tear-down: `node close` cascade-cancels a node + its exclusive subtree WITHOUT finishing (revivable, nothing deleted); `node recycle` finishes the agent in your pane and reboots a fresh root in place; `canvas prune` deletes terminal nodes past a TTL.
44
+
45
+ ## Revive & the daemon
46
+
47
+ A dormant node (done/idle/dead/canceled) is reopened with `canvas revive` (resumes the saved conversation, or `--fresh` to restart clean). `reviveNode()` is the **only** sanctioned launcher of `pi --session` — it sets `CRTR_NODE_ID` + canvas extensions and runs `transition('revive')`, keeping the db row and pi session in lockstep. Never spawn `pi --session` raw, and never open a node by spawning pi directly — UIs go through `node focus` / `canvas revive`.
48
+
49
+ The **daemon** (`crtrd`, managed via `canvas daemon start/stop/status`) is a thin supervisor: it polls pane + pi liveness ~every 2s and auto-revives nodes whose window exited. It does NOT host agents and does NOT auto-revive *canceled* nodes (reach for `canvas revive` for those). After rebuilding crouter's `dist/`, restart the daemon — it loads compiled code at start and never reloads it. Restarting is safe: it never signals running nodes.
@@ -0,0 +1,32 @@
1
+ ---
2
+ kind: reference
3
+ when-and-why-to-read: When you need to know where a piece of crtr state lives on disk — or you are adding a new kind of file and must decide where it belongs — this reference should be read because it names the three storage tiers and their durability/ownership contracts, so you put (or find) the file in the right place instead of scattering state.
4
+ short-form: The three crtr storage tiers — scope root (durable user/repo content), per-cwd crouter root (per-project working artifacts), and canvas home (node-graph runtime state).
5
+ system-prompt-visibility: name
6
+ file-read-visibility: none
7
+ ---
8
+
9
+ # Where everything lives (the three storage tiers)
10
+
11
+ crtr state splits into three tiers, each with a distinct durability and ownership contract. This consolidates the contract for routing; for the precise resolver behavior consult the crouter sources (`src/core/scope.ts`, `src/core/artifact.ts`, `src/core/canvas/paths.ts`) and, in the crouter repo, the "Storage locations" section of its `CLAUDE.md`.
12
+
13
+ ## 1. Scope root — durable, user/repo-authored content
14
+
15
+ `~/.crouter/` (user scope) or `<project>/.crouter/` (project scope). Resolved by the scope resolver (`src/core/scope.ts`). Holds: `skills/`, `plugins/`, `marketplaces/`, `memory/`, `config.json`. Persists across project changes; belongs to the user or the repo.
16
+
17
+ ## 2. Per-cwd crouter root — per-project working artifacts
18
+
19
+ `~/.crouter/<mangled-cwd>/`, keyed on the originating cwd via `mangleCwd` (`src/core/artifact.ts`). Holds working artifacts like `interactions/` (humanloop decks for the `crtr human` bridge; `interactions/<id>/` holds `deck.json` / `run.json` / `response.json`).
20
+
21
+ ## 3. Canvas home — node-graph runtime state
22
+
23
+ `~/.crouter/canvas/` (override with `CRTR_HOME`; see `src/core/canvas/paths.ts`). The agent-runtime state for the node graph:
24
+
25
+ - `canvas.db` — sqlite (WAL) topology store: nodes + edges only. On-screen focus is the `focuses` table here (durable, keyed on tmux `%pane_id`).
26
+ - `nodes/<node_id>/` — each node's durable state: `meta.json` (source of truth for the row), `context/` (roadmap.md, prompts, artifacts), `job/` (`log.jsonl`, `telemetry.json`), `reports/` (append-only `<ts>-<kind>.md` push history), `inbox.jsonl` (messages + coalesced feed), `transcript.jsonl`, `session.ptr` (pi session id).
27
+
28
+ ## The contributor rule
29
+
30
+ Durable user content → **scope root**. Per-project working artifacts → **per-cwd crouter root**. Node-graph runtime state (topology, context, reports, inbox, telemetry) → **canvas home**.
31
+
32
+ (The legacy `sessions/` graph and `$XDG_STATE_HOME/crtr/jobs/` tier were removed when the node/canvas runtime replaced the session/job model; node telemetry now lives in each node's `job/telemetry.json` under the canvas home.)
@@ -0,0 +1,100 @@
1
+ ---
2
+ kind: skill
3
+ when-and-why-to-read: When you are about to write or update a memory document — including any time the user asks you to remember something — or need to debug why a document is (not) surfacing, this skill should be read because it tells you how to choose kind and scope, set the disclosure rungs and gate, size the body, and when to first ask the user a clarifying question.
4
+ short-form: Author excellent memories — kind, scope, visibility rungs, gates, body sizing, and the asked-to-remember workflow.
5
+ system-prompt-visibility: preview
6
+ file-read-visibility: none
7
+ ---
8
+
9
+ # Writing memories
10
+
11
+ A memory is a markdown document whose frontmatter decides **when, where, and how much** of it loads into future agents. The body is the easy part; the craft is routing — deciding who sees it, at what moment, at what context cost. Every frontmatter choice below is a routing choice.
12
+
13
+ ## When asked to remember something
14
+
15
+ 1. **Write the routing line first.** Before storing anything, try to complete: *"When <circumstance>, this <kind> should be read because <payoff>."* If you cannot name the concrete situation a future agent will be in when this matters, you do not understand the memory yet — ask the user **one sharp question** instead of improvising. "Remember I like chicken" routes cleanly (food/meal decisions); "remember to be careful with the API" does not (which API? careful how? against what failure?) — that one needs clarifying before it becomes a memory. The routing line is a comprehension test.
16
+ 2. **Find before write.** `crtr memory find <topic>` for an existing doc; update it rather than minting a near-duplicate. Prefer growing `food-preferences` over creating `likes-chicken` — one document per recurring *circumstance*, not one per fact. Group related docs with path names (`area/topic`). Delete memories that turn out wrong.
17
+ 3. **Capture the why, not just the what.** Especially for corrections: record what was rejected and the reasoning. The why is what lets a future agent apply the rule to cases you never saw.
18
+ 4. **Don't store what's already recorded** — code structure, git history, CLAUDE.md content — or what only matters to this conversation. If asked to remember something the repo already records, ask what was non-obvious about it and store that instead.
19
+
20
+ ## Choosing kind
21
+
22
+ - **skill** — how to *do* something (a playbook, a procedure you'd repeat).
23
+ - **reference** — what is *true* or how something *works* (a fact about the user, a system's behavior, code docs).
24
+ - **preference** — how to *behave* (a directive, a standing correction).
25
+
26
+ The reference-vs-preference test: does it direct behavior ("always run lint after authoring") or inform the world-model ("Silas likes chicken", "the daemon never reloads dist/")? A correction usually yields a preference; a learned fact yields a reference; a repeatable procedure yields a skill.
27
+
28
+ Kind sets sensible default rungs — the common case needs no visibility fields at all:
29
+
30
+ | kind | at boot | on file-read |
31
+ |---|---|---|
32
+ | skill | name | none |
33
+ | preference | preview | none |
34
+ | reference | none | preview |
35
+
36
+ ## The two hooks — boot vs file-read
37
+
38
+ There are exactly two moments a doc can surface. `system-prompt-visibility` governs **boot** (the system prompt); `file-read-visibility` governs **on-read** (when a related file is opened).
39
+
40
+ - Behavior and procedure (preferences, skills) are relevant regardless of which file is open → surface at boot, stay out of file-read. That is what the defaults do.
41
+ - Knowledge about code (references) belongs **next to the code**: put the doc in that directory's `.crouter/memory/` and it fires positionally when files under that directory are read — costing nothing at boot.
42
+ - The exception that matters: a reference about a *person or a process* ("Silas's food preferences") has no code directory to anchor to, so positional triggering is meaningless — set `system-prompt-visibility: preview` so its routing line surfaces at boot instead.
43
+ - `applies-to: <glob or list>` extends the on-read trigger beyond position for cross-cutting docs (e.g. a testing reference that should fire for `**/*.test.ts` anywhere).
44
+
45
+ ## Choosing the rung
46
+
47
+ `none → name → preview → content`. Each rung up costs more of **every** future agent's context, paid at every boot or read, forever. Default down, not up.
48
+
49
+ - **content** (full body injected): reserved for guidance that is BOTH always relevant AND ~one bullet's worth of text. Fail either test → preview. Situational guidance is preview *no matter how short*; long guidance is preview no matter how universal it feels.
50
+ - **preview** (the one-sentence routing line): the workhorse. Costs one line; the body is read on demand.
51
+ - **name** (title only): catalog-style docs whose name already routes (`api-reference`).
52
+ - **none**: invisible except to `crtr memory find` — archival or narrowly specialized material.
53
+
54
+ `short-form` is NOT a rung and never enters an agent's context (an agent handed a summary satisfices and skips the real read). It is the gist a human sees in `crtr memory list` — write it for them, and don't make it do routing work.
55
+
56
+ ## The routing line
57
+
58
+ `when-and-why-to-read` is **read-routing, never content**: it answers when to open the doc and why the read pays — never why the content should be obeyed, and never a paraphrase of the content. A preview that gives away the gist defeats the ladder: the agent feels informed and skips the body. Shape: *"When <circumstance the agent is in>, this <kind> should be read because <what the read buys>."* The test: can a stranger mid-task decide from this single line alone whether to spend the read?
59
+
60
+ ## Gates — conditioning on who the agent is
61
+
62
+ An optional `gate:` predicate makes the doc eligible only for nodes whose own config matches. Subject fields: `kind` (node role, free-form), `mode` (base|orchestrator), `lifecycle` (terminal|resident), `hasManager` (bool), `cwd`, `scope` (user|project), `orchestration.depth` (hops to the root orchestrator; root = 0). Matchers: scalar equality, list membership, or operator objects (`{gte: 2}`, `{in: […]}`, `{matches: "…"}`, `exists`, `contains`…), with `all`/`any`/`not` combinators.
63
+
64
+ ```yaml
65
+ gate: { mode: orchestrator } # only delegating managers
66
+ gate: { orchestration.depth: { gte: 2 } } # only substantial, scaled-up efforts
67
+ ```
68
+
69
+ Default is no gate — always eligible; most docs want exactly that. An empty `gate: {}` is inert (never matches) — don't write it. A failing gate hides the doc from both hooks, but it stays findable by search.
70
+
71
+ ## Scope — where the file lives
72
+
73
+ - **user** (`~/.crouter/memory/`): facts about the user, cross-project behavior. The chicken memory goes here.
74
+ - **project** (`<dir>/.crouter/memory/`): anything about a codebase or workspace — and place it in the *specific directory* it describes (any directory can hold a `.crouter/`), so positional on-read fires precisely.
75
+ - **builtin**: ships with crtr itself (contributed via `src/builtin-memory/` in the crouter repo) — docs every crtr agent should have.
76
+
77
+ Resolution is project > user > builtin with leaf-name fallback (`read <leaf>` finds `area/<leaf>` when unambiguous), so a project doc can shadow a same-named user or builtin doc.
78
+
79
+ ## Mechanics worth knowing
80
+
81
+ - **Directory entries**: a directory may carry an `INDEX.md` with the same frontmatter schema as any doc; the dir then renders as one entry at the INDEX's rung, and that rung is a **ceiling for its subtree** (`none` hides the whole dir). When a doc mysteriously isn't surfacing, check its ancestors' INDEX rungs and its gate.
82
+ - **CLI**: `crtr memory list` (human inventory) · `read <name>` (names are path-derived, never file paths) · `find <query>` (search ignores gates and rungs) · `write` (author) · `lint` (validate after authoring). Run `-h` on a leaf before first use.
83
+
84
+ ## Body content
85
+
86
+ Write for a stranger: a future session that shares none of this conversation. No "as discussed", no narration of how you learned it — state current truth, not the history of getting there. Keep the reasoning behind rules; cut everything else. Don't pad: a preference can legitimately be two sentences, and a skill should lead with decisions, not mechanism. A body behind `preview` may be long when it earns it, but every line still costs a reader who is mid-task — dense beats complete.
87
+
88
+ ## Worked example
89
+
90
+ User says: *"remember that I like chicken."*
91
+
92
+ ```bash
93
+ echo "Silas likes chicken." | crtr memory write food-preferences \
94
+ --kind reference --scope user \
95
+ --when-and-why-to-read "When you are choosing or recommending food, meals, or recipes for Silas, this reference should be read because it records his food preferences." \
96
+ --short-form "Silas's food likes/dislikes" \
97
+ --system-prompt-visibility preview
98
+ ```
99
+
100
+ Reference (a fact, not a directive) · user scope (about the person, not a repo) · `preview` at boot (no code directory to anchor on-read) · named for the recurring circumstance, so future food facts append to the same doc. After authoring, validate: `crtr memory lint`.
@@ -20,6 +20,9 @@ import { CustomEditor, getSelectListTheme, } from '@earendil-works/pi-coding-age
20
20
  import { defineBranch, defineLeaf } from '../../core/command.js';
21
21
  import { InputError } from '../../core/io.js';
22
22
  import { getNode } from '../../core/canvas/index.js';
23
+ // tmux driver verbs only through placement (the §5.1 model-over-driver seam) —
24
+ // never `core/runtime/tmux.js` directly.
25
+ import { setPaneOption, currentTmux } from '../../core/runtime/placement.js';
23
26
  import { ChatView } from './chat-view.js';
24
27
  import { InputController } from './input-controller.js';
25
28
  import { applyTheme, createKeybindingsManager } from './config-load.js';
@@ -72,6 +75,28 @@ async function runAttach(nodeId, observer) {
72
75
  next: 'Check the node has a running headless broker.',
73
76
  });
74
77
  });
78
+ // Self-tag this pane with the node it now views so the host-agnostic
79
+ // `nodeInPane()` (node cycle/recycle/close/demote/lifecycle) resolves a
80
+ // broker-hosted node from its VIEWER pane — the broker engine runs detached
81
+ // (window=null), so a window→node lookup can't find it; this pane tag is the
82
+ // handle. Set AFTER the connect proves a broker is here, mirrors view-run's
83
+ // `@crtr_view` self-tag. Cleared on teardown so a stray tag can't outlive the
84
+ // viewer (best-effort; tmux-only).
85
+ const tagPane = process.env['TMUX_PANE'] ?? currentTmux()?.pane;
86
+ if (tagPane !== undefined && tagPane !== '') {
87
+ try {
88
+ setPaneOption(tagPane, '@crtr_node', nodeId);
89
+ }
90
+ catch { /* best-effort */ }
91
+ }
92
+ const clearPaneTag = () => {
93
+ if (tagPane !== undefined && tagPane !== '') {
94
+ try {
95
+ setPaneOption(tagPane, '@crtr_node', '');
96
+ }
97
+ catch { /* best-effort */ }
98
+ }
99
+ };
75
100
  // 1. Theme + keybindings FIRST — pi's components throw "Theme not initialized"
76
101
  // otherwise (T5 contract). One KeybindingsManager feeds BOTH editor + input.
77
102
  applyTheme({ cwd: meta.cwd });
@@ -218,6 +243,7 @@ async function runAttach(nodeId, observer) {
218
243
  if (tornDown)
219
244
  return;
220
245
  tornDown = true;
246
+ clearPaneTag();
221
247
  removeKeyListener();
222
248
  // A clean detach tells the broker to drop this viewer (the engine runs on);
223
249
  // on broker-gone the socket is already dead, so skip `bye`.
@@ -4,11 +4,20 @@ import { type BranchDef } from '../core/command.js';
4
4
  * YIELD_NUDGE_THRESHOLD, steer it to yield now and let its fresh revive handle
5
5
  * the child's result. */
6
6
  export declare function childFollowUp(spawnerId: string | undefined): string;
7
- /** The live node occupying a tmux pane (pane window → node), or undefined.
8
- * Defaults to $TMUX_PANE / the caller's current pane when `pane` is omitted —
9
- * shared by `node recycle` / `node demote` / `node lifecycle` / `node close` /
10
- * `node cycle`, all of which act on "the agent in front of you". Exported for
11
- * the `canvas chord` / `canvas tmux-spread` leaves,
12
- * which resolve the active pane's node the same way. */
7
+ /** The live node "in front of you" in a tmux pane, HOST-AGNOSTIC. Defaults to
8
+ * $TMUX_PANE / the caller's current pane when `pane` is omitted — shared by
9
+ * `node recycle` / `node demote` / `node lifecycle` / `node close` / `node
10
+ * cycle`, and the `canvas chord` / `canvas tmux-spread` leaves.
11
+ *
12
+ * Two resolutions, tried in order:
13
+ * 1. VIEWER pane (broker host): a `crtr attach` viewer self-tags its pane with
14
+ * the node it views (`@crtr_node`). A broker engine runs DETACHED with
15
+ * window=null, so the window→node lookup never finds it — the pane tag is
16
+ * the only handle. Checked FIRST because the tag is pane-precise, whereas a
17
+ * window is shared: a viewer SPLIT beside a tmux engine pane sits in that
18
+ * engine's window, so window→node would mis-resolve to the engine.
19
+ * 2. ENGINE pane (tmux host): the node owns this pane's window.
20
+ * A stale tag (attach SIGKILLed without clearing) is ignored — the tagged node
21
+ * must still be live (active/idle); recycle clears it on the tmux respawn path. */
13
22
  export declare function nodeInPane(pane?: string): string | undefined;
14
23
  export declare function registerNode(): BranchDef;
@@ -15,7 +15,7 @@ import { newNodeId } from '../core/runtime/nodes.js';
15
15
  import { readRoadmap } from '../core/runtime/roadmap.js';
16
16
  import { parseWhen, parseCadence, cadenceDisplay } from '../core/wake.js';
17
17
  import { recycleNode } from '../core/runtime/recycle.js';
18
- import { detachToBackground, focus as placementFocus, windowAlive, windowOfPane, currentTmux } from '../core/runtime/placement.js';
18
+ import { detachToBackground, focus as placementFocus, windowAlive, windowOfPane, currentTmux, getPaneOption } from '../core/runtime/placement.js';
19
19
  import { buildLaunchSpec } from '../core/runtime/launch.js';
20
20
  import { closeNode } from '../core/runtime/close.js';
21
21
  import { appendInbox } from '../core/feed/inbox.js';
@@ -311,15 +311,32 @@ function nodeByWindow(win) {
311
311
  }
312
312
  return undefined;
313
313
  }
314
- /** The live node occupying a tmux pane (pane window → node), or undefined.
315
- * Defaults to $TMUX_PANE / the caller's current pane when `pane` is omitted —
316
- * shared by `node recycle` / `node demote` / `node lifecycle` / `node close` /
317
- * `node cycle`, all of which act on "the agent in front of you". Exported for
318
- * the `canvas chord` / `canvas tmux-spread` leaves,
319
- * which resolve the active pane's node the same way. */
314
+ /** The live node "in front of you" in a tmux pane, HOST-AGNOSTIC. Defaults to
315
+ * $TMUX_PANE / the caller's current pane when `pane` is omitted — shared by
316
+ * `node recycle` / `node demote` / `node lifecycle` / `node close` / `node
317
+ * cycle`, and the `canvas chord` / `canvas tmux-spread` leaves.
318
+ *
319
+ * Two resolutions, tried in order:
320
+ * 1. VIEWER pane (broker host): a `crtr attach` viewer self-tags its pane with
321
+ * the node it views (`@crtr_node`). A broker engine runs DETACHED with
322
+ * window=null, so the window→node lookup never finds it — the pane tag is
323
+ * the only handle. Checked FIRST because the tag is pane-precise, whereas a
324
+ * window is shared: a viewer SPLIT beside a tmux engine pane sits in that
325
+ * engine's window, so window→node would mis-resolve to the engine.
326
+ * 2. ENGINE pane (tmux host): the node owns this pane's window.
327
+ * A stale tag (attach SIGKILLed without clearing) is ignored — the tagged node
328
+ * must still be live (active/idle); recycle clears it on the tmux respawn path. */
320
329
  export function nodeInPane(pane) {
321
330
  const resolvePane = pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
322
- const win = resolvePane !== undefined && resolvePane !== '' ? windowOfPane(resolvePane) : null;
331
+ if (resolvePane === undefined || resolvePane === '')
332
+ return undefined;
333
+ const tagged = getPaneOption(resolvePane, '@crtr_node');
334
+ if (tagged !== undefined && tagged !== '') {
335
+ const m = getNode(tagged);
336
+ if (m !== null && (m.status === 'active' || m.status === 'idle'))
337
+ return tagged;
338
+ }
339
+ const win = windowOfPane(resolvePane);
323
340
  return win !== null ? nodeByWindow(win) : undefined;
324
341
  }
325
342
  const nodeRecycle = defineLeaf({
@@ -360,8 +377,15 @@ const nodeRecycle = defineLeaf({
360
377
  return { recycled: res.recycled, node_id: id, finalized: res.finalized, delivered: res.delivered.length, new_root: res.newRoot ?? undefined };
361
378
  },
362
379
  render: (r) => {
363
- if (r['recycled'] !== true)
380
+ if (r['recycled'] !== true) {
381
+ // A broker recycle that finalized + spawned a fresh root but whose new
382
+ // broker never served its socket: the state DID change (node finished, root
383
+ // born), only the viewer re-attach failed — say so, don't call it a no-op.
384
+ if (r['finalized'] === true && r['new_root']) {
385
+ return `Finished ${r['node_id']} and spawned a fresh broker root (${r['new_root']}), but its viewer did not attach — the new broker did not come up. Re-focus the root to view it.`;
386
+ }
364
387
  return `Recycle failed for ${r['node_id'] ?? '?'} — not in tmux, or no agent in this pane.`;
388
+ }
365
389
  const lines = [`Recycled the pane — finished ${r['node_id']}.`, `- finalized: ${r['finalized']}`];
366
390
  if (r['finalized'] === true)
367
391
  lines.push(`- delivered: ${r['delivered']}`);
@@ -0,0 +1,84 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/full/broker-pane-resolution.test.ts
2
+ //
3
+ // REGRESSION — broker-hosted nodes were second-class in the tmux command
4
+ // surface (audit: findings-headless-default.md, Gaps 1 + 2). Two real bugs,
5
+ // both observable only with a REAL tmux pane, so this is a FULL-tier test.
6
+ //
7
+ // (1) Gap 1 — `nodeInPane()` (src/commands/node.ts) resolved a pane via
8
+ // window→node ONLY. A broker engine runs DETACHED with window=null, and its
9
+ // on-screen presence is a `crtr attach` VIEWER pane, so the window lookup
10
+ // never found it: `node cycle`/`recycle`/`close`/`demote`/`lifecycle` all
11
+ // broke when the current pane was a broker viewer. The fix has attach
12
+ // self-tag its pane `@crtr_node=<id>` and nodeInPane resolve that tag first.
13
+ // LOCKED: an untagged pane over a broker resolves undefined (the bug); the
14
+ // SAME pane, once tagged, resolves the broker (the fix).
15
+ //
16
+ // (2) Gap 2 — `recycleNode()` (src/core/runtime/recycle.ts) always respawned the
17
+ // pane into a fresh TMUX pi root, even for a broker node — silently turning
18
+ // the viewer pane into a tmux engine and dropping out of the broker host
19
+ // model. The fix preserves host_kind: recycling a broker node finalizes it,
20
+ // tears the broker down, and boots a fresh BROKER root the pane re-attaches
21
+ // to. LOCKED: the recycled node is finalized (done) and the fresh root is
22
+ // broker-hosted, not tmux.
23
+ import { test, before, after } from 'node:test';
24
+ import assert from 'node:assert/strict';
25
+ import { spawnSync } from 'node:child_process';
26
+ import { createHarness, hasTmux } from '../helpers/harness.js';
27
+ import { closeDb } from '../../canvas/db.js';
28
+ import { nodeInPane } from '../../../commands/node.js';
29
+ import { recycleNode } from '../../runtime/recycle.js';
30
+ let h;
31
+ let root;
32
+ before(async () => {
33
+ h = await createHarness({ sessionPrefix: 'crtr-brkpane' });
34
+ root = h.spawnRoot('host root');
35
+ });
36
+ after(async () => {
37
+ if (h !== undefined)
38
+ await h.dispose();
39
+ });
40
+ /** Split a throwaway pane in the harness session; return its %id. */
41
+ function makePane() {
42
+ const r = spawnSync('tmux', ['split-window', '-d', '-t', h.session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8' });
43
+ const pane = (r.stdout ?? '').trim();
44
+ assert.ok(pane.startsWith('%'), `expected a %pane_id, got "${pane}" (stderr: ${r.stderr})`);
45
+ return pane;
46
+ }
47
+ function tagPane(pane, value) {
48
+ spawnSync('tmux', ['set-option', '-p', '-t', pane, '@crtr_node', value], { stdio: 'ignore' });
49
+ }
50
+ test('Gap 1 — nodeInPane resolves a broker node from its tagged viewer pane (undefined when untagged)', async (t) => {
51
+ if (!hasTmux())
52
+ return t.skip('tmux unavailable');
53
+ const broker = await h.spawnHeadlessChild(root, 'broker worker');
54
+ closeDb();
55
+ assert.equal(h.node(broker).host_kind, 'broker', 'spawned a broker-hosted node');
56
+ assert.equal(h.node(broker).window ?? null, null, 'broker engine has window=null (paneless)');
57
+ const viewer = makePane();
58
+ // BUG REPRODUCTION: an untagged pane over a broker is invisible — no node owns
59
+ // this pane's window, and a broker has no window to find, so resolution fails.
60
+ closeDb();
61
+ assert.equal(nodeInPane(viewer), undefined, 'untagged viewer pane → broker is invisible (the bug)');
62
+ // FIX: the viewer self-tag (`@crtr_node`) is the broker's pane handle.
63
+ tagPane(viewer, broker);
64
+ closeDb();
65
+ assert.equal(nodeInPane(viewer), broker, 'tagged viewer pane → resolves the broker (the fix)');
66
+ // A stale tag pointing at a DONE node is ignored (must be live: active/idle).
67
+ spawnSync('tmux', ['kill-pane', '-t', viewer], { stdio: 'ignore' });
68
+ });
69
+ test('Gap 2 — recycle preserves the broker host: finalizes the node, boots a fresh BROKER root', async (t) => {
70
+ if (!hasTmux())
71
+ return t.skip('tmux unavailable');
72
+ const broker = await h.spawnHeadlessChild(root, 'broker to recycle');
73
+ const viewer = makePane();
74
+ tagPane(viewer, broker);
75
+ closeDb();
76
+ assert.equal(nodeInPane(viewer), broker, 'precondition: viewer resolves the broker');
77
+ const res = await recycleNode(broker, viewer);
78
+ closeDb();
79
+ assert.equal(h.node(broker).status, 'done', 'recycled broker node is finalized (done)');
80
+ assert.ok(res.newRoot, 'a fresh root was spawned');
81
+ const fresh = h.node(res.newRoot);
82
+ assert.equal(fresh.host_kind, 'broker', 'fresh root PRESERVES the broker host (not a tmux pi root)');
83
+ assert.equal(fresh.parent ?? null, null, 'fresh root is a root (parent=null)');
84
+ });
@@ -746,8 +746,10 @@ export async function runBroker(nodeId) {
746
746
  break;
747
747
  }
748
748
  case 'get_commands': {
749
- if (notController(client, 'list commands'))
750
- break;
749
+ // OBSERVERS may call this: the merged command inventory is static
750
+ // (extensions + templates + skills + builtins) and drives nothing in the
751
+ // engine, so it is not a controller-gated op. The web bridge fetches it on
752
+ // its observer-by-default upstream connection to populate the palette.
751
753
  // The merged command list rides in ack.detail as JSON (the foundation's
752
754
  // AckFrame.detail field) — the viewer (T6) JSON.parses it when for ===
753
755
  // 'get_commands'. Keeps every command op a uniform ack reply.
@@ -266,6 +266,19 @@ export declare function focus(nodeId: string, opts: {
266
266
  callerNode?: string;
267
267
  revive: Reviver;
268
268
  }): FocusResult;
269
+ /** Synchronously wait until a broker's view.sock accepts a connection. `focus()`
270
+ * is sync today (the command layer calls it directly), so the readiness probe
271
+ * lives in a short child Node process that can use async net events while this
272
+ * process blocks in `spawnSync`. Success proves more than file existence: it is
273
+ * robust to a stale leftover socket that the launching broker has not unlinked
274
+ * yet, because only an accepting listener exits 0. */
275
+ export declare function waitForBrokerViewSocket(nodeId: string): boolean;
276
+ /** Env for a `crtr attach` VIEWER pane (the focusBroker split AND recycle's
277
+ * re-attach respawn): propagate CRTR_HOME so the viewer resolves the SAME canvas
278
+ * home — and thus the right view.sock — under a non-default override; otherwise
279
+ * an empty env (the pane inherits the tmux server env, like every other crtr
280
+ * chrome pane). One helper so the two viewer-pane sites can't drift. */
281
+ export declare function viewerSplitEnv(): Record<string, string>;
269
282
  /** Tear a node off its placement (close/reset teardown, §2.3, flow (e)).
270
283
  * Reconcile first (follow a manual move / backfill a legacy pane), close the
271
284
  * focus row it occupies (if any), kill its pane (pane-keyed via the durable
@@ -307,6 +307,18 @@ export function detachToBackground(nodeId, pane) {
307
307
  const row = getRow(nodeId);
308
308
  if (row === null)
309
309
  return false;
310
+ // A broker node has NO engine pane — it is ALREADY headless, so the only thing
311
+ // on screen is a `crtr attach` VIEWER pane. "Detach" (the Alt+C → D let-go)
312
+ // therefore means "stop foregrounding it": close the viewer pane and leave the
313
+ // broker running untouched. The engine-in-pane relocate/release below would
314
+ // mis-anchor the live engine row to the viewer pane and (when idle) flip a
315
+ // LIVE broker to idle-release — corrupting state. Never reach it for a broker.
316
+ if (row.host_kind === 'broker') {
317
+ const viewer = pane ?? null;
318
+ if (viewer !== null && paneExists(viewer))
319
+ return closePane(viewer);
320
+ return false;
321
+ }
310
322
  const target = pane ?? row.pane;
311
323
  if (target === null || !paneExists(target))
312
324
  return false;
@@ -632,7 +644,7 @@ const BROKER_FOCUS_SOCKET_RETRY_MS = 100;
632
644
  * process blocks in `spawnSync`. Success proves more than file existence: it is
633
645
  * robust to a stale leftover socket that the launching broker has not unlinked
634
646
  * yet, because only an accepting listener exits 0. */
635
- function waitForBrokerViewSocket(nodeId) {
647
+ export function waitForBrokerViewSocket(nodeId) {
636
648
  const sockPath = join(nodeDir(nodeId), 'view.sock');
637
649
  const probe = `
638
650
  const net = require('node:net');
@@ -712,17 +724,21 @@ function focusBroker(nodeId, meta, opts) {
712
724
  if (!waitForBrokerViewSocket(nodeId)) {
713
725
  return { focused: false, session: null, inPlace: false, revived };
714
726
  }
715
- // Propagate CRTR_HOME so the viewer resolves the SAME canvas home (and thus
716
- // the right view.sock) under a non-default override; otherwise an empty env
717
- // (the split inherits the tmux server env, like every other crtr chrome pane).
718
- const crtrHome = process.env['CRTR_HOME'];
719
- const env = crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
720
727
  // Node ids are shell-safe identifiers (base36-ts + hex); no quoting needed.
721
- const pane = splitWindow(callerPane, { cwd: meta.cwd, env, command: `crtr attach to ${nodeId}` });
728
+ const pane = splitWindow(callerPane, { cwd: meta.cwd, env: viewerSplitEnv(), command: `crtr attach to ${nodeId}` });
722
729
  if (pane === null)
723
730
  return { focused: false, session: null, inPlace: false, revived };
724
731
  return { focused: true, session: paneLocation(pane)?.session ?? null, inPlace: false, revived };
725
732
  }
733
+ /** Env for a `crtr attach` VIEWER pane (the focusBroker split AND recycle's
734
+ * re-attach respawn): propagate CRTR_HOME so the viewer resolves the SAME canvas
735
+ * home — and thus the right view.sock — under a non-default override; otherwise
736
+ * an empty env (the pane inherits the tmux server env, like every other crtr
737
+ * chrome pane). One helper so the two viewer-pane sites can't drift. */
738
+ export function viewerSplitEnv() {
739
+ const crtrHome = process.env['CRTR_HOME'];
740
+ return crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
741
+ }
726
742
  /** Register the caller's CURRENT pane as a focus so a `node focus`/`cycle` from a
727
743
  * pane that isn't yet a viewport retargets IN PLACE. Occupied by whatever node
728
744
  * sits in the pane now (`callerNode`, else resolved by pane→row), or a HOLDER
@@ -23,7 +23,7 @@ import { pushFinal } from '../feed/feed.js';
23
23
  import { spawnNode, nodeSession, rootOfSpine } from './nodes.js';
24
24
  import { buildLaunchSpec, buildPiArgv } from './launch.js';
25
25
  import { FRONT_DOOR_ENV } from './front-door.js';
26
- import { focusOf, recycleFocusPane, piCommand, paneLocation } from './placement.js';
26
+ import { focusOf, recycleFocusPane, piCommand, paneLocation, respawnPaneSync, setPaneOption, waitForBrokerViewSocket, viewerSplitEnv } from './placement.js';
27
27
  import { hostFor } from './host.js';
28
28
  import { ensureDaemon } from '../../daemon/manage.js';
29
29
  /** The agent's most recent surfaced message: the newest reports/*.md body with
@@ -74,12 +74,14 @@ export async function recycleNode(nodeId, callerPane) {
74
74
  finalized = true;
75
75
  }
76
76
  catch { /* recycle the pane even if the report failed */ }
77
- // A broker node has NO tmux pane, so recycleFocusPane below (respawn-pane -k)
78
- // never kills its engine route its teardown through the Host seam so the
79
- // broker PROCESS exits and releases the sole .jsonl writer (mirrors the T12
77
+ // A broker node's pane is a VIEWER (`crtr attach`), not its engine: the engine
78
+ // is the detached broker process, so respawn-pane -k below would only kill the
79
+ // viewer, never the engine. Route teardown through the Host seam so the broker
80
+ // PROCESS exits and releases the sole .jsonl writer (mirrors the T12
80
81
  // close.ts/reset.ts fix; review reuse MINOR-3). Status is already flipped done
81
82
  // by pushFinal above (crash-safe order: the daemon won't revive a done node).
82
- if (meta.host_kind === 'broker') {
83
+ const isBroker = meta.host_kind === 'broker';
84
+ if (isBroker) {
83
85
  try {
84
86
  hostFor(meta).teardown(nodeId);
85
87
  }
@@ -93,7 +95,12 @@ export async function recycleNode(nodeId, callerPane) {
93
95
  setPresence(nodeId, { pane: null, window: null, tmux_session: null });
94
96
  }
95
97
  catch { /* best-effort */ }
96
- // 2 + 3. Recycle — boot a fresh resident root in the SAME pane.
98
+ // 2 + 3. Recycle — boot a fresh resident root for the SAME pane, PRESERVING
99
+ // the recycled node's host model: a tmux node recycles into a tmux root in the
100
+ // pane; a broker node recycles into a fresh BROKER root the pane re-attaches to
101
+ // (broker-is-the-host — the viewer pane stays a viewer, never becomes an engine
102
+ // pane). Without preserving host_kind, recycling a broker node from its viewer
103
+ // would silently respawn the viewer into a tmux pi root.
97
104
  try {
98
105
  ensureDaemon();
99
106
  }
@@ -108,6 +115,7 @@ export async function recycleNode(nodeId, callerPane) {
108
115
  name: 'general',
109
116
  parent: null,
110
117
  launch,
118
+ hostKind: isBroker ? 'broker' : 'tmux',
111
119
  });
112
120
  // REVIVE-HOME: a recycled root's durable revive target is the session
113
121
  // of the pane it was recycled into (the one place home_session is rewritten
@@ -123,9 +131,56 @@ export async function recycleNode(nodeId, callerPane) {
123
131
  }
124
132
  const fresh = getNode(root.node_id);
125
133
  const inv = buildPiArgv(fresh);
126
- const env = { ...inv.env, CRTR_ROOT_SESSION: nodeSession(), CRTR_SUBTREE: rootOfSpine(root.node_id), [FRONT_DOOR_ENV]: '1' };
127
- const ok = recycleFocusPane(root.node_id, pane, {
128
- command: piCommand(inv.argv), env, cwd: meta.cwd, name: fullName(fresh), resuming: false,
129
- });
134
+ // CRTR_ROOT_SESSION/CRTR_SUBTREE route the fresh root's children to the
135
+ // backstage (both consumers below read this one authoritative env). FRONT_DOOR
136
+ // is added per-consumer: the tmux command const adds it; the broker host sets
137
+ // it itself, so the broker branch omits it.
138
+ inv.env = { ...inv.env, CRTR_ROOT_SESSION: nodeSession(), CRTR_SUBTREE: rootOfSpine(root.node_id) };
139
+ const ok = isBroker
140
+ ? recycleBrokerViewer(fresh, pane, inv)
141
+ : recycleTmuxRoot(root.node_id, pane, {
142
+ command: piCommand(inv.argv),
143
+ env: { ...inv.env, [FRONT_DOOR_ENV]: '1' },
144
+ cwd: meta.cwd,
145
+ name: fullName(fresh),
146
+ });
130
147
  return { recycled: ok, finalized, newRoot: root.node_id, delivered };
131
148
  }
149
+ /** Recycle a TMUX root into `pane`: respawn-pane -k boots the fresh pi engine in
150
+ * place. Clears any stale `@crtr_node` viewer tag first — a prior `crtr attach`
151
+ * in this pane may have left one (tmux pane options survive respawn-pane), and
152
+ * since nodeInPane checks the tag BEFORE the window, a stale tag would shadow
153
+ * the real window→node lookup for the fresh tmux engine now in the pane. */
154
+ function recycleTmuxRoot(nodeId, pane, launch) {
155
+ try {
156
+ setPaneOption(pane, '@crtr_node', '');
157
+ }
158
+ catch { /* best-effort */ }
159
+ return recycleFocusPane(nodeId, pane, { ...launch, resuming: false });
160
+ }
161
+ /** Recycle a BROKER root into `pane`: the fresh root is broker-hosted, so its
162
+ * engine runs in a DETACHED broker, not the pane. Birth-launch that broker via
163
+ * the Host seam (mirrors spawnChild's birth path — the host records its pid),
164
+ * wait for its view.sock to accept, then respawn the pane in place to the VIEWER
165
+ * `crtr attach to <root>`. The pane stays a viewer (attach self-tags it
166
+ * `@crtr_node`); it never hosts the engine. Returns false (recycle reports the
167
+ * pane was not respawned) when the broker never serves — the fresh root row
168
+ * still exists, broker-hosted, for the daemon to revive. */
169
+ function recycleBrokerViewer(fresh, pane, inv) {
170
+ hostFor(fresh).launch(fresh.node_id, inv, { cwd: fresh.cwd, name: fullName(fresh), resuming: false });
171
+ if (!waitForBrokerViewSocket(fresh.node_id))
172
+ return false;
173
+ // Clear the finalized node's stale `@crtr_node` tag before respawn (symmetry
174
+ // with recycleTmuxRoot) so the tag never names a done node during the gap
175
+ // before the new `crtr attach` re-tags on connect. Node ids are shell-safe
176
+ // identifiers; no quoting needed.
177
+ try {
178
+ setPaneOption(pane, '@crtr_node', '');
179
+ }
180
+ catch { /* best-effort */ }
181
+ // SYNC respawn: the recycle command runs as a SEPARATE CLI process (the viewer
182
+ // runs `crtr attach`, a TUI — it never execs recycle), so we are NOT respawning
183
+ // our own pane and can confirm the respawn actually landed (the honest exit
184
+ // status), unlike the tmux path which often recycles the caller's own pane.
185
+ return respawnPaneSync({ pane, cwd: fresh.cwd, env: viewerSplitEnv(), command: `crtr attach to ${fresh.node_id}` });
186
+ }
@@ -237,12 +237,22 @@ function renderChildren(dir, childPrefix, lines) {
237
237
  });
238
238
  }
239
239
  /** Build the file tree for a kind's eligible docs, headed by `rootLabel`.
240
- * Returns '' when there are no docs at all (the empty-tree contract). */
240
+ * Returns '' when there are no docs at all (the empty-tree contract).
241
+ *
242
+ * `docs` arrives in precedence order (project > user > builtin, node-local last)
243
+ * WITHOUT cross-scope dedup — `listAllMemoryDocs` returns every scope's hit for
244
+ * a path-derived name and leaves first-wins dedup to the caller. So a name
245
+ * present in two scopes is deduped here (first occurrence wins); otherwise it
246
+ * would render as two identical sibling lines and double-count into `[+N more]`. */
241
247
  function buildTree(docs, rootLabel) {
242
248
  if (docs.length === 0)
243
249
  return '';
244
250
  const root = newDir('');
251
+ const seen = new Set();
245
252
  for (const d of docs) {
253
+ if (seen.has(d.name))
254
+ continue; // first-wins cross-scope dedup
255
+ seen.add(d.name);
246
256
  if (isIndexName(d.name)) {
247
257
  const dir = ensureDir(root, indexDirOf(d.name));
248
258
  if (dir.index === null)
package/dist/index.d.ts CHANGED
@@ -1 +1,19 @@
1
- export {};
1
+ export { spawnChild } from './core/runtime/spawn.js';
2
+ export type { SpawnChildOpts, SpawnChildResult } from './core/runtime/spawn.js';
3
+ export { reviveNode } from './core/runtime/revive.js';
4
+ export type { ReviveResult } from './core/runtime/revive.js';
5
+ export { closeNode } from './core/runtime/close.js';
6
+ export type { CloseNodeResult } from './core/runtime/close.js';
7
+ export { appendInbox } from './core/feed/inbox.js';
8
+ export type { InboxEntry, InboxTier, InboxKind } from './core/feed/inbox.js';
9
+ export { getNode, listNodes } from './core/canvas/canvas.js';
10
+ export { nodeDir } from './core/canvas/paths.js';
11
+ export type { NodeMeta, NodeRow, NodeStatus, Lifecycle, Mode, } from './core/canvas/types.js';
12
+ export { asksForNodes, asksAcrossCanvas } from './core/canvas/attention.js';
13
+ export type { AskEntry } from './core/canvas/attention.js';
14
+ export { readTelemetry, readContextTokens } from './core/canvas/telemetry.js';
15
+ export type { Telemetry } from './core/canvas/telemetry.js';
16
+ export { ViewSocketClient, BrokerUnavailableError } from './clients/attach/view-socket.js';
17
+ export { encodeFrame, FrameDecoder, FrameOverflowError, CLIENT_READ_CAPS, BROKER_READ_CAPS, } from './core/runtime/broker-protocol.js';
18
+ export type { FrameDecoderCaps, BrokerSnapshot, ClientRole, WelcomeFrame, ControlChangedFrame, AckFrame, ErrorFrame, ExtensionUIRequestFrame, BrokerToClient, HelloFrame, PromptFrame, SteerFrame, FollowUpFrame, AbortFrame, RequestControlFrame, ReleaseControlFrame, ByeFrame, ShutdownFrame, SetModelFrame, CycleModelFrame, SetThinkingLevelFrame, SetAutoRetryFrame, SetAutoCompactionFrame, CompactFrame, NewSessionFrame, SwitchSessionFrame, ForkFrame, SetSessionNameFrame, GetCommandsFrame, NavigateTreeFrame, ReloadFrame, ExportFrame, ClientToBroker, RpcExtensionUIRequest, RpcExtensionUIResponse, ExtensionUIResponseFrame, } from './core/runtime/broker-protocol.js';
19
+ export type { SessionStats } from '@earendil-works/pi-coding-agent';
package/dist/index.js CHANGED
@@ -1 +1,26 @@
1
- export {};
1
+ // @crouton-kit/crouter — the sanctioned library surface (design D1).
2
+ //
3
+ // This barrel is the ONLY supported way to import crouter as a library; the
4
+ // `exports` map gates off deep subpath imports, so consumers (notably
5
+ // @crouton-kit/crouter-web) depend on exactly the symbols re-exported here.
6
+ // Everything below is a stable, server-facing surface: runtime control that
7
+ // keeps the canvas-row ⇄ pi-session lockstep guarantees, read-only canvas/
8
+ // telemetry queries, and the broker socket client + wire protocol. Nothing
9
+ // internal (CLI plumbing, helpers) is exported. This module has no load-time
10
+ // side effects and no circular imports — keep it that way.
11
+ // ── Runtime control ──────────────────────────────────────────────────────
12
+ // Sanctioned launchers/mutators. reviveNode is the ONLY sanctioned launcher of
13
+ // `pi --session`; spawnChild's host is chosen via `hostKind: 'tmux' | 'broker'`.
14
+ export { spawnChild } from './core/runtime/spawn.js';
15
+ export { reviveNode } from './core/runtime/revive.js';
16
+ export { closeNode } from './core/runtime/close.js';
17
+ export { appendInbox } from './core/feed/inbox.js';
18
+ // ── Canvas reads ─────────────────────────────────────────────────────────
19
+ export { getNode, listNodes } from './core/canvas/canvas.js';
20
+ export { nodeDir } from './core/canvas/paths.js';
21
+ export { asksForNodes, asksAcrossCanvas } from './core/canvas/attention.js';
22
+ export { readTelemetry, readContextTokens } from './core/canvas/telemetry.js';
23
+ // ── Broker client ────────────────────────────────────────────────────────
24
+ export { ViewSocketClient, BrokerUnavailableError } from './clients/attach/view-socket.js';
25
+ // ── Broker wire protocol (codec + frame/protocol types) ──────────────────
26
+ export { encodeFrame, FrameDecoder, FrameOverflowError, CLIENT_READ_CAPS, BROKER_READ_CAPS, } from './core/runtime/broker-protocol.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "description": "crtr — fast access to skills, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",