@crouton-kit/crouter 0.3.25 → 0.3.27
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.
- package/dist/builtin-memory/internal/INDEX.md +21 -0
- package/dist/builtin-memory/internal/examples/INDEX.md +15 -0
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +100 -0
- package/dist/builtin-memory/internal/nodes-and-canvas.md +49 -0
- package/dist/builtin-memory/internal/storage-tiers.md +32 -0
- package/dist/clients/attach/attach-cmd.js +26 -0
- package/dist/commands/memory/write.js +8 -0
- package/dist/commands/memory.js +1 -1
- package/dist/commands/node.d.ts +15 -6
- package/dist/commands/node.js +33 -9
- package/dist/core/__tests__/full/broker-pane-resolution.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +84 -0
- package/dist/core/runtime/broker.js +4 -2
- package/dist/core/runtime/naming.d.ts +1 -5
- package/dist/core/runtime/naming.js +33 -49
- package/dist/core/runtime/placement.d.ts +13 -0
- package/dist/core/runtime/placement.js +23 -7
- package/dist/core/runtime/recycle.js +65 -10
- package/dist/core/runtime/spawn.js +7 -10
- package/dist/core/substrate/render.js +11 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.js +26 -1
- package/dist/pi-extensions/canvas-goal-capture.js +38 -22
- package/package.json +1 -1
|
@@ -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 (kind, rungs, gates, routing line, the asked-to-remember workflow) is owned by `crtr memory write -h` — the authoring guide lives on the help-gate so it surfaces exactly when you write; 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.)
|
|
@@ -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`.
|
|
@@ -10,6 +10,14 @@ export const writeLeaf = defineLeaf({
|
|
|
10
10
|
help: {
|
|
11
11
|
name: 'memory write',
|
|
12
12
|
summary: 'create or update memory/<name>.md at the resolved scope from frontmatter flags + a stdin body',
|
|
13
|
+
guide: 'The body is the easy part; the craft is ROUTING — every frontmatter flag decides who sees this doc, when, and at what context cost. Each rung up is paid by every future agent at every boot or read, forever, so default each rung DOWN.\n\n' +
|
|
14
|
+
'Pick the kind. skill = how to DO something (a repeatable procedure/playbook); reference = what is TRUE or how something WORKS (a fact about the user, a system\u2019s behavior, code docs); preference = how to BEHAVE (a directive, a standing correction). The test that splits the close pair: does it DIRECT behavior ("always lint after authoring" \u2192 preference) or INFORM the world-model ("Silas likes chicken", "the daemon never reloads dist/" \u2192 reference)? A correction yields a preference, a learned fact a reference, a repeatable procedure a skill.\n\n' +
|
|
15
|
+
'Set the rungs (none < name < preview < content). Kind sets sensible defaults, so the common case needs no visibility flags at all: skill \u2192 name at boot; preference \u2192 preview at boot; reference \u2192 preview on-read, nothing at boot. Reserve `content` (full body injected) for guidance that is BOTH always-relevant AND ~one bullet long \u2014 fail either test and it is `preview`. Situational guidance is `preview` no matter how short; long guidance is `preview` no matter how universal it feels.\n\n' +
|
|
16
|
+
'Choose the hook \u2014 boot vs file-read. There are exactly two moments a doc can surface. Behavior and procedure (preferences, skills) are relevant whatever file is open \u2192 surface at boot. Knowledge about code (references) belongs NEXT TO the code: put the file in that directory\u2019s .crouter/memory/ and it fires positionally when files there are read, costing nothing at boot. The exception that matters: a reference about a PERSON or PROCESS has no code directory to anchor to, so on-read triggering is meaningless \u2014 set --system-prompt-visibility preview so its routing line surfaces at boot instead.\n\n' +
|
|
17
|
+
'Write the routing line (--when-and-why-to-read) FIRST, before storing anything: "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 that one line alone whether to spend the read? If you cannot name the concrete situation that triggers it, you do not yet understand the memory \u2014 ask the user ONE sharp question instead of improvising. ("Remember I like chicken" routes cleanly \u2192 food/meal decisions; "be careful with the API" does not \u2192 which API, careful how, against what failure?) And NEVER paraphrase the advice in the routing line \u2014 a preview that gives away the gist makes every future agent skip the real read. BAD: "\u2026because it carries the placement policy \u2014 -h first, memory only for dev-mode material." GOOD: "\u2026because it carries the standing placement policy."\n\n' +
|
|
18
|
+
'Find before write. `crtr memory find <topic>` first; grow ONE doc per recurring circumstance rather than minting near-duplicates \u2014 extend `food-preferences`, do not create `likes-chicken`. Group related docs with path names (area/topic). Do not store what is already recorded (code structure, git history, CLAUDE.md) or what only matters to this conversation.\n\n' +
|
|
19
|
+
'Body: write for a STRANGER \u2014 a future session that shares none of this conversation. State current truth, not the history of getting there (no "as discussed"). Keep the reasoning behind a rule and cut everything else; dense beats complete, since every line costs a mid-task reader.\n\n' +
|
|
20
|
+
'Gate (--gate, optional): a predicate making the doc eligible only for nodes whose own config matches (e.g. `{ mode: orchestrator }` or `{ orchestration.depth: { gte: 2 } }`); a failing gate hides it from both hooks but it stays findable by search. Default is no gate \u2014 most docs want exactly that. After authoring, validate with `crtr memory lint`.',
|
|
13
21
|
params: [
|
|
14
22
|
{ kind: 'positional', name: 'name', required: true, constraint: 'Path-derived identity (e.g. `topic` or `area/topic`) → memory/<name>.md at the resolved scope. Updated in place if it already exists, otherwise created.' },
|
|
15
23
|
{ kind: 'flag', name: 'kind', type: 'enum', choices: [...MEMORY_KINDS], required: true, constraint: 'Document kind.' },
|
package/dist/commands/memory.js
CHANGED
|
@@ -20,7 +20,7 @@ export function registerMemory() {
|
|
|
20
20
|
help: {
|
|
21
21
|
name: 'memory',
|
|
22
22
|
summary: 'list, read, search, and write memory documents — skills, references, preferences',
|
|
23
|
-
model: '`list` for a human inventory of what is stored — one line per document, the only surface that shows short-form. `read` (leaf) loads one document body by name, resolved project > user > builtin with leaf-name fallback; --frontmatter keeps the YAML header. `find` when you do not yet know which document applies — it ranks by relevance over name/when/why/short-form, --body to also weigh bodies, --grep for an exact regex over bodies. `write` creates or updates memory/<name>.md at a scope from frontmatter flags + a body piped on stdin. `lint` strict-parses the whole bounded corpus and fails on any invalid frontmatter — run it after authoring. Append `-h` at any leaf for its full schema.',
|
|
23
|
+
model: '`list` for a human inventory of what is stored — one line per document, the only surface that shows short-form. `read` (leaf) loads one document body by name, resolved project > user > builtin with leaf-name fallback; --frontmatter keeps the YAML header. `find` when you do not yet know which document applies — it ranks by relevance over name/when/why/short-form, --body to also weigh bodies, --grep for an exact regex over bodies. `write` creates or updates memory/<name>.md at a scope from frontmatter flags + a body piped on stdin. `lint` strict-parses the whole bounded corpus and fails on any invalid frontmatter — run it after authoring. 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 whole subtree (`none` hides the dir) — when a doc mysteriously is not surfacing, check its ancestors\' INDEX rungs and its gate. Append `-h` at any leaf for its full schema, and `crtr memory write -h` for the authoring guide.',
|
|
24
24
|
},
|
|
25
25
|
children: [listLeaf, readLeaf, findLeaf, writeLeaf, lintLeaf],
|
|
26
26
|
});
|
package/dist/commands/node.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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;
|
package/dist/commands/node.js
CHANGED
|
@@ -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
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
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
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
750
|
-
|
|
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.
|
|
@@ -6,17 +6,13 @@ export declare function sanitizeSessionName(raw: string): string;
|
|
|
6
6
|
/** Local fallback: derive a name straight from the prompt (no pi call). Drops
|
|
7
7
|
* stop-words, takes the first few content words. */
|
|
8
8
|
export declare function slugFromPrompt(prompt: string): string;
|
|
9
|
-
/** Synchronously ask pi for a 3-8 word kebab name for `prompt`. Blocks up to
|
|
10
|
-
* NAME_TIMEOUT_MS; on any failure (non-zero exit, timeout, empty/garbled
|
|
11
|
-
* output) falls back to a local slug. Returns '' only for an empty prompt. */
|
|
12
|
-
export declare function generateSessionName(prompt: string): string;
|
|
13
9
|
/** Asynchronously generate a name for `prompt` and persist it to the node's
|
|
14
10
|
* meta as `description` — only if the node has none yet (so a later message
|
|
15
11
|
* never clobbers it). Non-blocking: safe to call from inside a live pi event
|
|
16
12
|
* loop. Best-effort; swallows all errors.
|
|
17
13
|
*
|
|
18
14
|
* `onNamed` (optional) fires with the freshly-persisted meta the moment the
|
|
19
|
-
* name lands — the
|
|
15
|
+
* name lands — the canvas-goal-capture naming hook passes a callback that calls
|
|
20
16
|
* pi.setSessionName(editorLabel(meta)) so the LIVE editor label updates in the
|
|
21
17
|
* same session, instead of waiting for the next revive/cycle. */
|
|
22
18
|
export declare function generateAndPersistName(nodeId: string, prompt: string, onNamed?: (meta: NodeMeta) => void): void;
|
|
@@ -6,19 +6,16 @@
|
|
|
6
6
|
// the first prompt by asking pi headlessly (`pi -p`), persisted on the node's
|
|
7
7
|
// meta so it survives revives and shows in every cycle.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// case where the prompt only arrives as the first interactive message inside
|
|
16
|
-
// a live pi process; it must never block the event loop. Persists the name
|
|
17
|
-
// to meta so the label picks it up on the next cycle.
|
|
9
|
+
// One entry point: generateAndPersistName — async (execFile, non-blocking).
|
|
10
|
+
// Naming happens INSIDE the named node's own pi process, off the first real
|
|
11
|
+
// message (the kickoff task or a human's first line), never on the spawn path:
|
|
12
|
+
// blocking spawn on an LLM round-trip used to freeze the caller's terminal for
|
|
13
|
+
// 2-3s on every `crtr node new`. The headless namer runs with --no-extensions,
|
|
14
|
+
// so it loads no canvas hooks and can never recurse into another spawn/name.
|
|
18
15
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
import {
|
|
16
|
+
// Best-effort: a failed/slow/garbled pi call falls back to a local slug of the
|
|
17
|
+
// prompt, so a node always gets a sane name.
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
22
19
|
import { getNode, updateNode } from '../canvas/index.js';
|
|
23
20
|
/** Cap on prompt text fed to the namer — a name needs only the gist. */
|
|
24
21
|
const PROMPT_CAP = 2000;
|
|
@@ -97,30 +94,27 @@ function nameArgs(prompt) {
|
|
|
97
94
|
argv.push(nameUserPrompt(prompt));
|
|
98
95
|
return argv;
|
|
99
96
|
}
|
|
100
|
-
/**
|
|
101
|
-
*
|
|
102
|
-
* output)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
timeout: NAME_TIMEOUT_MS,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (name !== '')
|
|
117
|
-
return name;
|
|
97
|
+
/** Ask pi headlessly for a kebab-case name for `body`, async. Resolves to the
|
|
98
|
+
* sanitized name, or '' on any failure (non-zero exit, timeout, empty/garbled
|
|
99
|
+
* output) so the caller can fall back to a local slug. Owns the subprocess
|
|
100
|
+
* mechanics — crucially it hands pi an immediate stdin EOF: `pi -p` reads
|
|
101
|
+
* stdin, and execFile's default stdin is an OPEN pipe that never closes, so
|
|
102
|
+
* without this pi blocks waiting for EOF and the call exits non-zero (the
|
|
103
|
+
* regression that silently lost every LLM name to the slug fallback). */
|
|
104
|
+
function headlessName(body) {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
try {
|
|
107
|
+
const child = execFile('pi', nameArgs(body), { encoding: 'utf8', timeout: NAME_TIMEOUT_MS }, (err, stdout) => {
|
|
108
|
+
if (err || typeof stdout !== 'string')
|
|
109
|
+
return resolve('');
|
|
110
|
+
resolve(sanitizeSessionName(stdout));
|
|
111
|
+
});
|
|
112
|
+
child.stdin?.end(); // immediate EOF — see the doc above
|
|
118
113
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
return slugFromPrompt(body);
|
|
114
|
+
catch {
|
|
115
|
+
resolve('');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
124
118
|
}
|
|
125
119
|
/** Asynchronously generate a name for `prompt` and persist it to the node's
|
|
126
120
|
* meta as `description` — only if the node has none yet (so a later message
|
|
@@ -128,7 +122,7 @@ export function generateSessionName(prompt) {
|
|
|
128
122
|
* loop. Best-effort; swallows all errors.
|
|
129
123
|
*
|
|
130
124
|
* `onNamed` (optional) fires with the freshly-persisted meta the moment the
|
|
131
|
-
* name lands — the
|
|
125
|
+
* name lands — the canvas-goal-capture naming hook passes a callback that calls
|
|
132
126
|
* pi.setSessionName(editorLabel(meta)) so the LIVE editor label updates in the
|
|
133
127
|
* same session, instead of waiting for the next revive/cycle. */
|
|
134
128
|
export function generateAndPersistName(nodeId, prompt, onNamed) {
|
|
@@ -150,17 +144,7 @@ export function generateAndPersistName(nodeId, prompt, onNamed) {
|
|
|
150
144
|
// best-effort
|
|
151
145
|
}
|
|
152
146
|
};
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
persist(slugFromPrompt(body));
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
const name = sanitizeSessionName(stdout);
|
|
160
|
-
persist(name !== '' ? name : slugFromPrompt(body));
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
persist(slugFromPrompt(body));
|
|
165
|
-
}
|
|
147
|
+
void headlessName(body).then((name) => {
|
|
148
|
+
persist(name !== '' ? name : slugFromPrompt(body));
|
|
149
|
+
});
|
|
166
150
|
}
|
|
@@ -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
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
}
|
|
@@ -14,7 +14,6 @@ import { spawnNode, currentNodeContext, resolveBirthSession, nodeSession, rootOf
|
|
|
14
14
|
import { buildLaunchSpec, buildPiArgv } from './launch.js';
|
|
15
15
|
import { writeGoal } from './kickoff.js';
|
|
16
16
|
import { hasRoadmap, seedRoadmap } from './roadmap.js';
|
|
17
|
-
import { generateSessionName } from './naming.js';
|
|
18
17
|
import { buildIdentityAssertion, buildWakeBearings } from './bearings.js';
|
|
19
18
|
import { installMenuBinding, installNavBindings, installViewNavBindings } from './tmux-chrome.js';
|
|
20
19
|
import { setPresence, updateNode, getNode, fullName } from '../canvas/index.js';
|
|
@@ -35,19 +34,16 @@ export function bootRoot(opts) {
|
|
|
35
34
|
// A born-resident root starts in base mode; it earns the orchestrator persona
|
|
36
35
|
// the first time it delegates (or on promotion). Resident lifecycle either way.
|
|
37
36
|
const { launch } = buildLaunchSpec(kind, 'base', { lifecycle: 'resident', hasManager: false });
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
? generateSessionName(opts.prompt)
|
|
43
|
-
: undefined;
|
|
37
|
+
// Born WITHOUT a name. Naming is async + event-driven: the canvas-goal-capture
|
|
38
|
+
// extension names the node from its FIRST real message (the kickoff prompt or
|
|
39
|
+
// a human's first line) inside its own pi process, via a headless `pi -p`.
|
|
40
|
+
// Never block the front door on an LLM round-trip.
|
|
44
41
|
const meta = spawnNode({
|
|
45
42
|
kind,
|
|
46
43
|
mode: 'base',
|
|
47
44
|
lifecycle: 'resident',
|
|
48
45
|
cwd: opts.cwd,
|
|
49
46
|
name: opts.name ?? kind,
|
|
50
|
-
description,
|
|
51
47
|
parent: null,
|
|
52
48
|
launch,
|
|
53
49
|
});
|
|
@@ -162,14 +158,15 @@ export function spawnChild(opts) {
|
|
|
162
158
|
// independent root sits top-of-spine with nobody to push to. Mirrors the
|
|
163
159
|
// `parent` set below (root ? null : spawner), so hasManager === parent!==null.
|
|
164
160
|
const { launch } = buildLaunchSpec(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model });
|
|
165
|
-
//
|
|
161
|
+
// Born WITHOUT a name — the canvas-goal-capture extension names it async from
|
|
162
|
+
// its first message (the kickoff task) inside its own pi process, so spawn
|
|
163
|
+
// never blocks on the LLM naming round-trip (the 2-3s freeze it used to cost).
|
|
166
164
|
const meta = spawnNode({
|
|
167
165
|
kind: opts.kind,
|
|
168
166
|
mode,
|
|
169
167
|
lifecycle,
|
|
170
168
|
cwd: opts.cwd,
|
|
171
169
|
name: opts.name ?? opts.kind,
|
|
172
|
-
description: generateSessionName(opts.prompt),
|
|
173
170
|
// A root has no spine parent (top-level, nobody subscribes); it still
|
|
174
171
|
// records spawned_by=spawner when a node (not a human shell) spawned it.
|
|
175
172
|
// A child's parent IS its manager.
|
|
@@ -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
|
-
|
|
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';
|
|
@@ -3,13 +3,23 @@
|
|
|
3
3
|
// Loaded into every canvas node's pi process via the node's launch.extensions
|
|
4
4
|
// list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
6
|
+
// Two first-message jobs, both keyed off the node's first real `input` event:
|
|
7
|
+
//
|
|
8
|
+
// 1. Goal capture (bare roots). A node spawned with a prompt has its goal
|
|
9
|
+
// persisted at birth (writeGoal in spawn.ts). A bare root (`crtr` with no
|
|
10
|
+
// prompt) starts goal-less — its mandate only arrives when the human types
|
|
11
|
+
// their first message; this persists that as context/initial-prompt.md.
|
|
12
|
+
// Guarded so later messages never clobber it.
|
|
13
|
+
//
|
|
14
|
+
// 2. Naming (every node). Naming is async + event-driven — it does NOT run on
|
|
15
|
+
// the spawn path (that blocking LLM call froze the caller's terminal for
|
|
16
|
+
// 2-3s on every spawn). On the first real message, if the node has no name
|
|
17
|
+
// yet, ask pi headlessly (async, non-blocking) for a kebab-case name and
|
|
18
|
+
// live-update the editor label. The first message may be a human's line OR
|
|
19
|
+
// a delegated child's kickoff task — naming off the agent prompt is fine.
|
|
20
|
+
//
|
|
21
|
+
// Both skip extension-injected messages (inbox wakes, steering) and the
|
|
22
|
+
// fresh-revive kickoff (its sentinel), so neither is mistaken for a first input.
|
|
13
23
|
//
|
|
14
24
|
// Pure observation — it writes the goal file as a side effect and always lets
|
|
15
25
|
// the message through unchanged (returns nothing ⇒ continue). Registered before
|
|
@@ -20,7 +30,7 @@
|
|
|
20
30
|
// crouter's own tsc build without a dep on the pi packages.
|
|
21
31
|
import { captureGoalIfAbsent, REVIVE_KICKOFF_SENTINEL } from '../core/runtime/kickoff.js';
|
|
22
32
|
import { generateAndPersistName } from '../core/runtime/naming.js';
|
|
23
|
-
import { editorLabel } from '../core/canvas/index.js';
|
|
33
|
+
import { editorLabel, getNode } from '../core/canvas/index.js';
|
|
24
34
|
/**
|
|
25
35
|
* Register the goal-capture handler on `pi`.
|
|
26
36
|
*
|
|
@@ -34,26 +44,32 @@ export function registerCanvasGoalCapture(pi) {
|
|
|
34
44
|
const nodeId = process.env['CRTR_NODE_ID'];
|
|
35
45
|
if (nodeId === undefined || nodeId.trim() === '')
|
|
36
46
|
return; // not a canvas node
|
|
37
|
-
// Only a genuine human-typed prompt seeds the mandate — never an RPC or an
|
|
38
|
-
// extension-injected message (inbox wakes, steering nudges, kickoffs).
|
|
39
|
-
if (event.source !== 'interactive')
|
|
40
|
-
return;
|
|
41
47
|
const text = (event.text ?? '').trim();
|
|
42
48
|
if (text === '')
|
|
43
49
|
return;
|
|
44
|
-
//
|
|
45
|
-
//
|
|
50
|
+
// Never seed a mandate or a name from an extension-injected message (inbox
|
|
51
|
+
// wakes, steering nudges) or a fresh-revive kickoff (the node is already
|
|
52
|
+
// named). Both would otherwise masquerade as the node's first real input.
|
|
53
|
+
if (event.source === 'extension')
|
|
54
|
+
return;
|
|
46
55
|
if (text.startsWith(REVIVE_KICKOFF_SENTINEL))
|
|
47
56
|
return;
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
// Goal capture is bare-root only: a delegated child already had its goal
|
|
58
|
+
// persisted at birth (writeGoal), so only a genuine human-typed prompt
|
|
59
|
+
// seeds a mandate here.
|
|
60
|
+
if (event.source === 'interactive')
|
|
61
|
+
captureGoalIfAbsent(nodeId, text);
|
|
62
|
+
// Naming: name the node from its FIRST real message — a human's first line
|
|
63
|
+
// OR a delegated child's kickoff task (naming off the agent prompt is
|
|
64
|
+
// fine) — whenever it has no name yet. Async headless `pi -p` with no
|
|
65
|
+
// canvas extensions, so it never recurses into another spawn/name. The
|
|
66
|
+
// onNamed callback live-updates THIS session's label instead of waiting
|
|
67
|
+
// for the next cycle. The unnamed-guard keeps it to one call per node.
|
|
68
|
+
const meta = getNode(nodeId);
|
|
69
|
+
if (meta !== null && (meta.description ?? '').trim() === '') {
|
|
70
|
+
generateAndPersistName(nodeId, text, (named) => {
|
|
55
71
|
try {
|
|
56
|
-
pi.setSessionName?.(editorLabel(
|
|
72
|
+
pi.setSessionName?.(editorLabel(named));
|
|
57
73
|
}
|
|
58
74
|
catch { /* best-effort */ }
|
|
59
75
|
});
|