Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ opencode/
settings in its storage, the soundscape)
mapping.ts opencode's events in the heat model's terms, its subagents (child sessions) on the roster (pure)
view.ts the scene as an OpenTUI renderable painting its cells, on the running opencode's own classes
player.ts the soundscape's player: afplay run in the background (macOS)
types.ts opentui.d.ts what Flow uses of opencode's and OpenTUI's API, declared (nothing installed)
node/
player.ts the soundscape's player where the host has none (opencode, pi): afplay run in the background
(macOS). Node-only, shared by those two adapters, never by the Claude Code mod
pi/
index.ts pi adapter (widget above the editor, /flow, ~/.pi/agent/flow.json)
picture.ts a scene's `picture` as a kitty image: whether the terminal draws them, its size, the escape sequence
Expand Down Expand Up @@ -71,7 +73,7 @@ A scene implements `Scene` (`hooks/styles.ts`). Its file exports a `SceneDef` (`
- `tint`: `'smoke'` (a failed command or compaction) or `'blue'` (context nearly full). Must be clearly visible in every scene, at every level, in both layouts, and to colour-blind eyes: move lightness or blue-yellow, not only red-green (white to cyan, say, is a change of red alone, and red-green colour blindness can't see it). `npm run cvd` measures it.
- `night`: for scenes with a night version. Ease a `kNight` toward it with `easeNight` rather than switching in one frame. Use `night.ts` so every night matches, and falls at the same pace.
- `waiting`: Claude waits on the person (a dialog is up for them: a permission, its question, a plan to approve; auto mode only, never in pi). Any loop's dialog counts (a background subagent's too); the main turn's 30 s clock stops for the main loop's own waits and for a dialog up, not for a subagent's ask the mode is still deciding, and the main turn ending forgets only its own. A wait is its session's: the adapter forgets them when the session ends, so a /clear or a resume never carries one over. The level drops to a calm 2 by itself (`WAIT_LEVEL`). Every scene eases a `kWait` toward it (`easeWait`) and ends `grid()` with `waitTone(out, kWait, t)`: the frame turns sepia and breathes, the same in every scene, so it reads at a glance and never like smoke or blue (`PixelScene` does both; `d.wait` says how far). Then settle and hold your own way, never as if working: a place the level stands for holds (the balloon hovers, a rocket keeps its stage and doesn't launch), what moves comes to rest where that's natural (the skier stops, the engine runs down, the stars stop, the train draws up at a signal or a platform). A light can shine through the sepia, only breathing (`waitTone`'s `lamps`; in a `PixelScene`, paint it with `px.lamp`), as the train's signals do. A warm scene can skip the sepia (`waitTone(..., 0)`) but must breathe, as the fire's coals do.
- **Sound** (`hooks/sound.ts`, see *Soundscapes* below): a scene's bed is layers of clips (`LAYERS`) at gains from the level and its `ambience()`, mixed ahead for each of its moods (`MOODS`); its events (`sounds`, an array the scene pushes `{ kind, v }` onto as things happen on screen, through `hear`, which caps it, so an adapter that never takes them stays bounded; pi takes and drops them each frame) play a clip (`EVENTS`) or, small and dense ones, a synthesized burst (`VOICES`). A scene without layers is silent.
- **Sound** (`hooks/sound.ts`, see *Soundscapes* below): a scene's bed is layers of clips (`LAYERS`) at gains from the level and its `ambience()`, mixed ahead for each of its moods (`MOODS`); its events (`sounds`, an array the scene pushes `{ kind, v }` onto as things happen on screen, through `hear`, which caps it, so an adapter that never takes them stays bounded; every adapter's `Soundscape` takes them each frame, heard or not) play a clip (`EVENTS`) or, small and dense ones, a synthesized burst (`VOICES`). A scene without layers is silent.
- **Sizes**: the band is 5 rows × 80–250 columns, above the prompt. The spine is a pane about 13–22 columns × 30–60 rows. Every scene must look right in both. A scene with a view taller than the band's (`Scene.taller`: doom3d, the marine's own eyes) is given more above the prompt: in pi flow.json's `rows` (14 unless it says, at most half the terminal), in Claude Code as many as the prompt leaves, up to 14 (`TALL_ROWS`); `npm run check` times it at 250 × 14. It must still look right at 5 (a short terminal, the picker's thumbnail). Its first-person view, wider than tall, is a panorama (each column its own ray, about 140° across a wide band) rather than the spine's flat projection, so the far edges don't stretch.
- **Drawing**:
- Most scenes paint a pixel layer at 2 × 2 pixels per cell, then fold it into quadrant glyphs with `fitQuad`. A cell holds only two colors; give `fitQuad` a sprite's pixels as its `keep` so a small figure never drops out of its own cell.
Expand All @@ -85,7 +87,7 @@ A scene implements `Scene` (`hooks/styles.ts`). Its file exports a `SceneDef` (`

## Soundscapes

`audio.play` is macOS `afplay`: a clip at a gain fixed when it starts. `hooks/soundscape.ts` runs it a frame at a time over a `Player`, so every host plays it alike: Claude Code's is `audio.play`, opencode's (`opencode/player.ts`) runs `/usr/bin/afplay` itself, one process a clip, killed to stop it, held to the same four at once. Nothing loops or changes volume while it plays, and Claude Code plays at most four at once for a plugin (`MAX_PLAYS`): it refuses a fifth ("4 plays are going at once"), and each holds its place for its length plus almost a second (afplay starting and draining). So:
`audio.play` is macOS `afplay`: a clip at a gain fixed when it starts. `hooks/soundscape.ts` runs it a frame at a time over a `Player`, so every host plays it alike: Claude Code's is `audio.play`, opencode's and pi's (`node/player.ts`) run `/usr/bin/afplay` themselves, one process a clip, killed to stop it, held to the same four at once. pi tells an extension nothing of a wait on the person, so it never chimes. Nothing loops or changes volume while it plays, and Claude Code plays at most four at once for a plugin (`MAX_PLAYS`): it refuses a fifth ("4 plays are going at once"), and each holds its place for its length plus almost a second (afplay starting and draining). So:

- **Beds**: one stream a scene. Its layers (`LAYERS`) are mixed ahead by the build into one bed for each mood the scene reaches (`MOODS`: a band of the level, the balloon's burner, a rocket's phase; the build runs each scene to find them), each at its layers' gains, limited when it would need more than 1.3 to play as loud, and its gain back to the layers' level kept in the manifest (`BED_GAINS`). Takes are 14 s (`BED_MS`) faded at both ends (3 s, `BED_FADE_MS`), the next starting as the last begins to fade, up to half a second sooner at random (`bedGap`). `bedStep` plans it a frame at a time: when the mood changes or the gain moves by more than 3 dB, a fresh take crossfades in and the old one stops once it's in (sooner when the level fell), but only once the take before has left the player (its length plus afplay's drain, `PLAYER_DRAIN_MS`), so a bed never holds more than two plays (a test checks); silent, it stops at once. A mood has three takes (`BED_TAKES`, each a different rotation of its layers' takes, the layers' takes at one loudness), never the one before again. Nothing in a bed may repeat in a rhythm: no tremolo, flanger or phaser. Use `turbulence` (a random smooth swell), `wander` (a resonance drifting at random) and `hum` (narrow resonators on noise, in place of pure tones, which cancel and beat when two takes crossfade). Rhythm comes only from the picture: the engine's chuffs follow its crank, a wave breaks when the scene throws one (at random), a ski swishes on each turn (each turn's pace drawn at random). To check, simulate a scene's mix and look for peaks in the autocorrelation of its loudness, at lags up to half a minute.
- **Events**: big ones play a clip at once, every one, but no nearer than 120 ms to the last of their kind (a closer one waits that long: any nearer, two booms fuse into one), and short enough at the front that a second right behind is heard; a clip finding the player full stops the oldest event clip first (its tail, or afplay's silent drain), and a refused one tries again 50 ms on. Anything scheduled for later (a staggered event, a retry) checks it's still current when it fires: the soundscape not stopped since (`gen`), the scene and the sound setting unchanged; small dense ones (pops, cracks) are drawn in `VOICES` and gathered a second at a time into one clip (shorter, they'd fill the player), each placed at random within its frame (a frame's all at once would buzz at the frame rate) and, past `BURST_MAX`, sampled from across the window (`gather`). afplay takes about a third of a second to start (`PLAYER_LEAD_MS`): a scene that can see a moment coming sends its sound that far ahead (`leadFrames`), as the colony's rocks do, or it lands late on its picture. Seeds come from a counter: scatter them (`unit`) before they pick or seed anything, or neighbouring seeds pick in step.
Expand Down Expand Up @@ -131,7 +133,7 @@ The engine validates the module before it runs (`claude plugin validate .`):
- Only a scene's own name is ever written to `/config` (Claude Code refuses a value outside a row's `options` there). A row still holding one from an older Flow (`colony` before it was `avalon`, the dropped `river` and `lava`) reads as its default to Claude Code, which says so at each load before the module runs: in the debug log, or in the transcript while the session hot-reloads a plugin folder. `readDefaults` reads it as the scene meant (`readConfig`; the default for a scene that's gone), so a renamed scene keeps its old name as an alias. It isn't written back (only `/flow save` writes `/config`); `staleRows` finding one only tells the tips Flow was here before. (Since the rename to `flow-scenes` every row started afresh, so none holds an old name yet.)
- The plugin was `flow` before it was `flow-scenes` (a directory connector has the name). Its `/config` rows, store and state are the plugin's, so they started afresh under the new name; nothing from before is read or moved.
- The balloon's altitude is in `state` (the session's, surviving a reload); the one-time tips stay per user, in `store` under `tips`.
- pi keeps a session's own settings in the session: a `flow` custom entry (`pi.appendEntry`), read back from `getBranch()` at `session_start` and `session_tree`, so a resume or a fork brings them back. `~/.pi/agent/flow.json` holds the defaults, written by `/flow save`, and read afresh before every `/flow` and every 30 s, as in Claude Code (`pi/session.ts`). A pi without session entries keeps one set for every session in flow.json, as before.
- pi keeps a session's own settings in the session: a `flow` custom entry (`pi.appendEntry`), read back from `getBranch()` at `session_start` and `session_tree`, so a resume or a fork brings them back. `~/.pi/agent/flow.json` holds the defaults, written by `/flow save`, and Flow's own keys beside them (`tips`, `warned`; every write goes through `keepStored`, which carries an older `vista.json`'s or `ascii-fire.json`'s settings over on the first), and read afresh before every `/flow` and every 30 s, as in Claude Code (`pi/session.ts`). A pi without session entries keeps one set for every session in flow.json, as before.
- The band and the spine's pane show only while the scene does (`SceneDriver.isShown()`): `/flow off`, or auto's dark idle between turns, gives their rows back. The pane is closed by the plugin then (so the layout stays spine; only you closing it means you'd rather have the band) and reopens with the scene.
- `/flow` is the only command. Don't add aliases.
- One-time tips (`nextTip` in `settings.ts`, kept in `store` under `tips`): at the first chance (a `/flow`, or an interactive session starting, as a toast) while it's still the fire and no other scene has been on, the other scenes; three chances later, if the sound has never been on, the sound (a session starting without a prompt is no chance, but notes what's on: `noteTips`). The record starts at `session.start` (`firstTips`), before that session's `/flow` can store anything: empty for someone new, both told for someone who had Flow before tips were kept (any other key in the store, such as a session's record or old overrides; a stale `/config` row; the balloon's altitude this session; or a `/config` row off the manifest's default).
Expand Down
Loading
Loading