diff --git a/docs/architecture.md b/docs/architecture.md
index ea006d50b..83e05a6fa 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -98,9 +98,9 @@ A Bun workspace (`packages/*`, shared `tsconfig.base.json`). Layering between OL
| `index` | `format` | the SEARCH INDEX and nothing that decides: a trigram table (`bun:sqlite`'s FTS5, so the substring matching olai's grammar promises survives — every word-based library would change what search finds, which [brainstorming/search-index.md](https://github.com/juspay/oss.olai/blob/main/olai/brainstorming/search-index.md) surveys) over the very fold the matcher reads, kept level with the store's revisions by identity rather than by a push, and answering with CANDIDATES: the records and documents a query might select, or "ask the corpus" for a needle under three characters, a query with no word to look up, and a word so common that narrowing costs more than walking. It holds no grammar — which words may narrow a query is `format`'s `narrowableBy`, and what matches is `format`'s one matcher, run over the shortlist exactly as it is run over the corpus. It sits above `format` rather than inside it because the engine is Bun's and `format` is bundled into a browser; the property that makes it safe is a superset, and it is pinned by a differential test over a soak of random writes rather than argued |
| `ops` | `format`, `git`, `index`, `store` (`log` for tests only) | the only writer: the semantic edits, the codec that joins the two below it, the parsed-node queries (search — WHAT matches is `format`'s one matcher, because a second implementation of it is how `is:done` comes to mean two things; what is here is the situating of a hit, the cap it applies and the uncapped total it reports, plus the one table a directory search takes its candidates from, opened per served directory beside the store and spent at that one door (`@olai/index`), plus the OTHER half the same matcher answers, `Query.narrowing`: which nodes of ONE PAGE a query selects, ids and why, for the page filter in a browser that no longer holds the set — a reading on the revision pulse rather than a call, since a filter is a standing view and asking it as one meant a whole-vault walk per page frame — the field weights, the done penalty and the order are `format`'s, one layer down, because the browser ranks a shortlist of its own), what is waiting to be committed and the one verb that commits it, and the closed table of what an agent may do. NOT how that table is spoken: no MCP, no SDK, no wire |
| `surface` | `acp` (its `./wire` subpath only), `format` | the surface spec: an `outlines` collection keyed by file path, a `documents` collection keyed the same way (bodies, read one key at a time), a `heads` collection on those same keys carrying one revision each and no body (batched `deltas`, so a reader watching a file it does not draw from — a previewed `.html` — costs a number rather than the file), a `manifest` cell, an `errors` cell, a `git` cell and a `pending` cell with its `git.commit` and `git.push` procedures, chat's transcript collection, the `saying` collection beside it — the pieces of the row an agent is still saying, so a streaming answer costs the wire the answer once rather than the whole row per token (`transcript-stream-quadratic`) — its state cell and its procedures, and the KEYBOARD's one `edit.apply` procedure over a closed union of intents — plus the contracts both ends spell and neither can import from the other: the `/media` URL, `who.get` and `GET /olai/who` (who is looking — per connection, not a cell), `/_olai/assets/` (the hashed bundle, so a vault file under `assets/` is a page), and what an attached file may BE (the two allowlists, the size cap, the sentence both ends refuse with). How it is cut into frames is deliberately NOT here: that is wire physics, and it lives beside the cap it is derived from in `@kolu/surface/frame-chunking`, which both ends import directly. The serving-side seam that used to sit here beside the declaration is gone: knowing when the last reader of a file lets go was `holding`, a wrap over a per-key `get`'s own subscription behind a subpath of its own, and it is `holders` on a collection's handler deps upstream now (juspay/kolu#2188), handed in where `@olai/server` builds them. So this package is one door and one kind of thing, the DECLARATION, and nothing in it reaches for a server |
-| `kolu-client` | `surface` | HOW OLAI REACHES KOLU, and the only package that names padi: the dial (`@kolu/padi-client`'s `connectPadi`), the ONE standing mirror of padi's `terminals` collection and its two attention feeds (`urgency`, `activity`) that feeds every reader, the projection of a terminal record into `surface`'s own `FleetTerminal` — which is kolu's OWN Dock-row prop bag plus an owner, every field folded by kolu's own function, since olai draws kolu's row rather than inventing a second visual language for one fleet — the rendezvous (`$PADI_SOCKET` first, the path algebra second), and the one `screen.text` read the snapshot pane makes. `koluHalf` is what `server` composes, and it returns a HANDLERS SLICE — the four members in the shape `defineSurface` takes — plus one revision hook, so `runtime.ts` names no kolu verb. Four more doors beside the root: `./wire` (the vocabulary and those member declarations, which `surface` spreads into its spec and re-exports, so no consumer rewrote an import), `./detect`, `./testlib` and `./drivers`. A PACKAGE rather than a directory under `server` because the wall makes the direction physics — this cannot depend back on the composition root, where a directory could reach `../runtime.ts` one convenient import at a time. It names `surface` and NOTHING else of olai: the walk over the vault that decides who OWNS a terminal is `server`'s (`claimants.ts`), and what crosses is four strings per claim, so "how olai reaches kolu" never learns what an outline node is |
+| `kolu-client` | `surface` | HOW OLAI REACHES KOLU, and the only package that names padi: the dial (`@kolu/padi-client`'s `connectPadi`), the ONE standing mirror of padi's `terminals` collection and its two attention feeds (`urgency`, `activity`) that feeds every reader, the projection of a terminal record into `surface`'s own `FleetTerminal` — which is kolu's OWN Dock-row prop bag plus an owner, every field folded by kolu's own function, since olai draws kolu's row rather than inventing a second visual language for one fleet — the rendezvous (`$PADI_SOCKET` first, the path algebra second), and the one `screen.text` read the snapshot pane makes. `koluHalf` is what `server` composes, and it returns a HANDLERS SLICE — the five members (`kolu` cell, `fleet`, `events`, the live pane's attach stream, `screen.text`) in the shape `defineSurface` takes — plus one revision hook holding the two walks (the claims and the watcher config), so `runtime.ts` names no kolu verb. Four more doors beside the root: `./wire` (the vocabulary and those member declarations, which `surface` spreads into its spec and re-exports, so no consumer rewrote an import), `./detect`, `./testlib` and `./drivers`. A PACKAGE rather than a directory under `server` because the wall makes the direction physics — this cannot depend back on the composition root, where a directory could reach `../runtime.ts` one convenient import at a time. It names `surface` and NOTHING else of olai: the walk over the vault that decides who OWNS a terminal is `server`'s (`claimants.ts`), and what crosses is four strings per claim, so "how olai reaches kolu" never learns what an outline node is |
| `chat` | `acp`, `child`, `format`, `log`, `state`, `surface` | one conversation with one ACP agent: the subprocess, the session, the transcript, the conversation's tmp directory for pasted pictures, the note in the state directory saying which conversation this directory's panel was in, the six verbs, and the domestic reading of what a call CHANGED through the ops layer (olai's own write reply — the protocol's diff blocks are `acp`'s to read). It consumes `acp`'s projections and translates that package's `Refused` into the domain's `UsageFailure` at the seam. The subprocess socket is `@olai/child`'s; the ACP stream and the handshake stay here. NOT `ops` — the agent reaches the ops layer as an MCP client, over a URL and a token, which is why a whole-file write is not expressible |
-| `kolu-ui` | `surface` | EVERYTHING BROWSER ABOUT KOLU: the Dock row a `terminal` property draws (kolu's own component, filled from olai's wire), the live pane over one terminal, the re-attach policy that pane runs, the fleet a tab holds once however many rows draw one, and the words the header readout says. Its whole contact with the app is `KoluUi` — the app hands over its composed surface client and a clock, and which members exist, what they are called and how a pane's subscription is bound stay inside. `TerminalBlock` is a renderer that registers nothing: `web` owns its own block table and calls `registerBlock(TERMINAL_KEY, TerminalBlock)` itself. Three doors: the socket, `./testids` (names only, so a scenario asserts without pulling a component into the suite), and `./all.css`, which carries the row's sheet, xterm's and its own `@source`. A PACKAGE rather than a directory under `web` for ruling 2's reason — a directory wall is broken by one convenient import, a package wall is not |
+| `kolu-ui` | `surface` | EVERYTHING BROWSER ABOUT KOLU: the Dock row a `terminal` property draws (kolu's own component, filled from olai's wire), the live pane over one terminal, the re-attach policy that pane runs, the fleet a tab holds once however many rows draw one, the EVENTS the padi readout's press fans (`props/EventsFeed.tsx` over `padi/events.ts` — the watcher's log, the frozen draw, the words), and the words the header readout says. Its whole contact with the app is `KoluUi` — the app hands over its composed surface client and a clock, and which members exist, what they are called and how a pane's subscription is bound stay inside. `TerminalBlock` is a renderer that registers nothing: `web` owns its own block table and calls `registerBlock(TERMINAL_KEY, TerminalBlock)` itself. Three doors: the socket, `./testids` (names only, so a scenario asserts without pulling a component into the suite), and `./all.css`, which carries the row's sheet, xterm's and its own `@source`. A PACKAGE rather than a directory under `web` for ruling 2's reason — a directory wall is broken by one convenient import, a package wall is not |
| `server` | `chat`, `format`, `identity`, `kolu-client`, `log`, `ops`, `state`, `store`, `surface` (`child` for tests only) | the composition root: the HTTP + WebSocket server with the MCP route on it, the HTTP `/mcp` a terminal agent posts at, the ONE surface-mcp server behind both faces, the order the layers go in, and the binary that one command is. Product spawn is `chat`'s and `git`'s; `@olai/child` is what the process-boundary tests ride |
| `fonts` | — | the typefaces, and TWO entry points because there are two readers. `@olai/fonts` is what a PAGE reads: the picks (name, label, group, hint and the three stacks) and nothing else. `@olai/fonts/build` is what a client BUILD takes: the `@font-face` / `:root[data-font="…"]` sheet generated from those picks, and the list of woff2 files its `src: url(…)` needs — one entry, because a build that took the sheet and not the files would ship a page whose every face 404s. Beside them, in the same package, the `default.nix` that converts those files in the Nix store — off `src/hosted.json`, the ONE list of faces that nix file and the TypeScript both read, because where a face's bytes come from and what CSS identity they carry is one fact and was two. No dependencies at all: two tables and the pure functions over them. What is NOT here is the pick, which is client state and stays in `web` |
| `web` | `child`, `fonts`, `format`, `surface` | the SolidJS client — outline, journal, agenda and chat panel — its Tailwind stylesheet, and the `Bun.build` that produces both. `@olai/child` is the Tailwind CLI the build script starts, and the browser bundle must never import it |
diff --git a/docs/kolu.md b/docs/kolu.md
index 2aa97f1f6..680f436a0 100644
--- a/docs/kolu.md
+++ b/docs/kolu.md
@@ -47,6 +47,30 @@ Attaching is a write on a shared pty, and kolu's semantic is that every client s
Closing drops the attach. Twelve lanes on a page are twelve rows and **zero** attached terminals until somebody presses one.
+## The events feed
+
+Press the `● kolu` readout and a panel opens: **what recently wanted attention**, as a log. olai watches the fleet it already holds and says when a terminal has been sitting in a state only a person can carry — `awaiting` your answer, or `waiting` on input — past the moment it is worth saying so. While a pressed Terminal waits on a machine's other half somewhere, the watch itself stays the same economy as the fleet drawer it sits on: the one daemon, the one subscriber, the map the server already held.
+
+Every row is a **frozen draw of the moment the event fired** — which pip it wore, which label it had, how long it had already held. The row is a fact about the past, not a current affairs teller: a terminal that found its answer ten minutes ago still shows the ask it was, and the headline age runs off the same clock as the pips.
+
+The watch is the SERVER's — one watcher, one ring of the last roughly two hundred events, and every browser is a reader of it. The ring itself is **attention only** — heartbeats are nowhere on it; a feed of migrated rows would be the same distraction the usernames they fold back to would be.
+
+The liveness lives on the PILL instead, which is where the feed's own bar already was: `watcher pulse 2m ago` in the hover while it is healthy (the fold's first register, `fresh`), and — crossed past **2 × the vault's `heartbeat` knob** — the pill's loud amber face: the chip's dot hollow, its border and ring warming, and its words naming it as `watcher quiet 47m` until the next beat. That double-cadence is the pill's own margin: the fold counts the beat's stamp against `everyMs` right beside the pulse's record, so a tab answering on its own clock need never guess the vault's cadence. A padi link flapping under olai fires nothing: a fleet emptied because the socket went is a fleet PAUSED, not a closing one — the holds keep their own clocks through it, and a `since` survives a reconnect the way padi's own daemon runs it.
+
+Where a terminal asks is the only thing a held state cannot tell: you WANT it to ask somewhere. **Mute the fleet one at a time** in `_olai/Kolu.olai`, the same file the watch reads its cadence from:
+
+```
+{"id":"watch","ord":"a0","title":"watch","custom":{"held-for":"60s"}}
+{"id":"mutes","ord":"a1","title":"mutes"}
+{"id":"one","parent":"mutes","ord":"a0","title":"a side shell nobody watches","custom":{"terminal":"5c5824d5"}}
+```
+
+The file is normal outline records. The watch node carries the knobs — `held-for` (the pause that holds a terminal's state out of the feed), `nag` (how often an unanswered one is said again) and `heartbeat` — written as `s`, `m` or `h`; an absent value defaults, and a malformed one defaults **and is said on the server's console at warning level**, so a typing mistake is never silent. The grammar is padi's own: `held-for` may be `0` — the report the INSTANT a state lands, which padi's watch flags also allow — but the other two never are, because a nag every 0 ms is the spin padi's own schema refuses, and both cap at the ~24.8-day timer bound. The mutes are children of a `mutes` node holding one `terminal` property each — full id or a unique prefix of one. Edits land live: the watch reads its knobs on every vault revision, so the file a person is *editing* is the file a person is **already being watched by**.
+
+An ambiguous prefix — one that names two open terminals — silences neither, and says so: write more of the id.
+
+The file is yours, not installed: without it the watch runs its defaults (sixty seconds, ten minutes, half an hour).
+
## When there is nothing to see
A machine not running kolu is the ordinary case, not a fault. There is no row, and in its place a **sentence** — never a grey row, which would claim the terminal is sitting there doing nothing, and that is a different and wrong fact:
diff --git a/packages/kolu-client/README.md b/packages/kolu-client/README.md
index 86655557b..5ccc494f8 100644
--- a/packages/kolu-client/README.md
+++ b/packages/kolu-client/README.md
@@ -1,6 +1,6 @@
# @olai/kolu-client — how olai reaches kolu
-One package holds the dial, the standing mirror, the projection into olai's own vocabulary, and the one screen read. What leaves is [`@olai/surface`](../surface/README.md)'s shapes — a `KoluLink`, a `FleetTerminal`, a `Snapshot` — so a change to padi's contract is a change **here** and stops.
+One package holds the dial, the standing mirror, the projection into olai's own vocabulary, the one screen read, and the **watch** that turns a held fleet state into an event the board can read. What leaves is [`@olai/surface`](../surface/README.md)'s shapes — a `KoluLink`, a `FleetTerminal`, a `Snapshot`, a `KoluEvent` — so a change to padi's contract is a change **here** and stops.
**Olai works on top of kolu and never launches agents itself.** Every process with a model in it is a kolu terminal (`https://github.com/juspay/oss.olai/blob/main/olai/brainstorming/orchestrator.md`); what olai does is *read* the fleet those terminals make and put it where the fact already is. This package is the whole of the reading.
@@ -11,9 +11,10 @@ The terminal door's two rungs (roadmap: `terminal-door`). A lane step carries `t
- **`link.ts`** — the dial and the **one standing mirror**. `connectPadi` over the unix socket, `mirrorRemoteSurface` of padi's `terminals` collection plus the two attention feeds (the `urgency` cell and the `activity` stream), and a five-second re-dial that never gives up. Ten tabs are ten subscriptions to olai's own `fleet` collection and *one* connection to padi, because the link is forked by the `kolu` cell's connector when the surface **binds** — the git sweep's arrangement applied to a socket. The invariant is structural at bind; `mirror.test.ts` counts it as well.
- **`mirror.ts`** — the three clocks joined. padi's records move on one, its attention partition on two more (an agent transition; kaval's byte edge), and olai's ownership overlay on a fourth — the vault's revision stream. Each publishes only the rows it actually moved.
- **`fleet.ts`** — one padi record projected to one row of kolu's own prop bag, joined with olai's ownership overlay. Every field is kolu's own fold: `bindStatePip`, `paintDockRow`, `rowSubline`, `activePr`, `annotationLine`, `identityColor`. Nothing here decides what a state means or what colour it paints — that was `face.ts`, and the fifth Löwy sitting deleted it by ratification.
+- **`watch.ts`** — the ATTENTION WATCH: a local reading of the mirror the server already holds, not a second ask of padi. A fleet row folded into a held bucket starts a hold; held past `held-for` the watcher emits a `transition`; still held after `nag`, the `nag`s; and the **pulse** lives off that ring — it lands on the `pulse` cell instead (the wire's `pulse` member, which the pill folds), so a quiet feed and a dead one read differently and the drawer eats attention rows alone. The knobs are the SERVER's (`@olai/server`'s `koluConfig.ts` reads `_olai/Kolu.olai`), and what the watch owns is one ring of `WATCH_RING` events — the same economy as the mirror's own rows.
- **`socket.ts`** — where padi is: `$PADI_SOCKET` first, the rendezvous path algebra second. kolu's own README asks a client to be *given* the socket, because the correcting read-back stayed with the daemon.
- **`screen.ts`** — one `screen.text` read, tailed here.
-- **`index.ts`** — `koluHalf`, which is what a server composes: three surface members and one revision hook.
+- **`index.ts`** — `koluHalf`, which is what a server composes: five surface members and one revision hook carrying the two walks (the claims and the knobs of the watch).
## Absent is a state, not a failure
diff --git a/packages/kolu-client/src/index.ts b/packages/kolu-client/src/index.ts
index 4d688fdb0..284687e6c 100644
--- a/packages/kolu-client/src/index.ts
+++ b/packages/kolu-client/src/index.ts
@@ -68,8 +68,9 @@
* - **`@olai/kolu-client`** — THE DIAL and the wire. The only package that
* speaks padi: one socket per server, the standing mirror, the projection
* into olai's own shapes. Four doors beside the root — `./wire` (the
- * vocabulary and the four surface members, which `@olai/surface` spreads
- * into its spec and re-exports), `./detect` (the spawn-time probe's
+ * vocabulary and the members — the events ring and the `pulse` cell are
+ * the newest — which `@olai/surface` spreads into its spec and re-exports),
+ * `./detect` (the spawn-time probe's
* surface), `./testlib` (the fake padi and its lifecycle) and `./drivers`
* (the two padi-dialing evidence scripts).
* - **`@olai/kolu-ui`** — EVERYTHING BROWSER. The Dock row on a `terminal`
@@ -95,11 +96,14 @@ import { makeMirror, type MirrorOptions } from "./mirror.ts"
import {
type FleetTerminal,
KOLU_UNDIALED,
+ type KoluEvent,
type KoluLink,
type Snapshot,
type TerminalFrame,
SnapshotRefused,
+ type WatchPulse,
} from "./wire/index.ts"
+import { makeWatch, type Watch, type WatchConfig } from "./watch.ts"
import { Effect, Stream } from "effect"
/**
@@ -118,15 +122,41 @@ export interface KoluDeps {
readonly upsert: (key: string, value: FleetTerminal) => void
readonly remove: (key: string) => void
} | undefined
- /** THE VAULT WALK, injected. Who claims which terminal is read off outline
- * records, and an outline record is a thing this package must not know —
+ /** The events ring's writer verbs, as a FUNCTION for `fleet`'s reason: the
+ * surface may not exist yet when the first event fires. */
+ readonly events: () => {
+ readonly upsert: (key: string, value: KoluEvent) => void
+ readonly remove: (key: string) => void
+ } | undefined
+ /** The pulse cell's setter, as a FUNCTION for `events`' reason: the
+ * surface may not exist yet when the first beat lands. */
+ readonly pulse: () => {
+ readonly set: (value: WatchPulse) => void
+ } | undefined
+ /** THE FIRST VAULT WALK, injected. Who claims which terminal is read off
+ * outline records, and an outline record is a thing this package must not
+ * know —
* so the server passes its own walk in (`@olai/server`'s `claimants.ts`,
* which stays there whole) and what comes back is four strings per claim.
* The ruling's words: "the server passes the vault-walk in". */
readonly claimants: (nodes: ReadonlyArray) => Iterable
+ /** THE SECOND VAULT WALK, injected, and the same boundary again. What
+ * `_olai/Kolu.olai`'s watch knobs and mutes say is read off the same
+ * nodes by `@olai/server`'s `koluConfig.ts`; what crosses is the derived
+ * intervals and the MUTE VALUES, plus the malformed lines this package
+ * then says. See `koluConfig.ts` for what a malformed value means. */
+ readonly config: (nodes: ReadonlyArray) => {
+ readonly config: WatchConfig
+ readonly malformed: ReadonlyArray
+ }
/** Routine narration, at debug: on a machine with no kolu this is a line
* every few seconds and it is not news. */
readonly say: (line: string) => void
+ /** The sentences the OWNER must read — the vault's malformed knob values
+ * (`@olai/server`'s `koluConfig.ts`) and the watcher's ambiguous-mute
+ * (`./watch.ts`) — wired to a level the default console turns on: a
+ * broken spell would stay behind `OLAI_LOG_LEVEL=debug` otherwise. */
+ readonly warn: (line: string) => void
}
/** The three bindings, plus the one hook a revision pulls. */
@@ -160,7 +190,7 @@ export interface KoluHalf {
* (`@olai/server`'s `claimants.ts`), and what arrives here is four strings
* per claim. See {@link Claimant}. */
/**
- * THE FOUR MEMBER HANDLERS, as `@olai/server` spreads them.
+ * THE FIVE MEMBER HANDLERS, as `@olai/server` spreads them.
*
* They used to be four clumps written out in `runtime.ts` — a store and a
* connector for the cell, a `readAll` and two no-op writers for the
@@ -180,19 +210,20 @@ export interface KoluHalf {
readonly handlers: KoluHandlers
/**
* A VAULT REVISION LANDED. The server drives it; what it hands over is the
- * nodes, and the WALK is this package's to run through the one it was given.
+ * nodes, and the WALKS are this package's to run through the TWO it was
+ * given — one for the claims, one for the watcher's config.
*
- * It used to be `kolu.reclaim(claimantsIn(nodes))` at the call site — the
- * server holding both the trigger and the walk, and naming a kolu verb to do
- * it. The walk still belongs to whoever holds the vault (it reads outline
- * records and this package has no business knowing what one is), so it
- * arrives as {@link KoluDeps.claimants} and this hook closes over it. The
- * server's line is now `join.revision(nodes)`, which names nothing of kolu's.
+ * The claims are re-derived and the mirror told, as before; the second
+ * half is the watcher's CONFIG, re-derived on the same revision — the way
+ * `held-for`, `nag`, `heartbeat` and the mutes move under a live watcher's
+ * hands. Both walks are the SERVER's (`@olai/server`'s `claimants.ts` and
+ * `koluConfig.ts`); what crosses is four strings per claim and one
+ * `WatchConfig` per revision — the boundary the header draws, grown one
+ * sibling rather than relaxed one jot.
*/
readonly revision: (nodes: ReadonlyArray) => void
}
-
/**
* ONE VAULT NODE, as this package needs to see it — which is not at all.
*
@@ -207,13 +238,19 @@ export type VaultNode = unknown
// in the node type, which is the same claim the compiler can check: a package
// generic in N cannot read an N.)
-/** The four member handlers, in the shape `defineSurface`'s sections take. */
+/** The five member handlers, in the shape `defineSurface`'s sections take. */
export interface KoluHandlers {
readonly cells: {
readonly kolu: {
readonly store: CellStore
readonly connect: (cell: { set: (value: KoluLink) => void }) => Effect.Effect
}
+ /** The watcher's pulse — the whole of what the header's pill reads
+ * beyond the link's own `since`. Read-only on the wire: a beat
+ * never asks for a browser's opinion. */
+ readonly pulse: {
+ readonly store: CellStore
+ }
}
readonly collections: {
readonly fleet: {
@@ -221,6 +258,13 @@ export interface KoluHandlers {
readonly upsert: () => void
readonly remove: () => void
}
+ /** The events ring — `readAll` is the watcher's own map for `fleet`'s
+ * own reason, and the writers are no-ops for `fleet`'s too. */
+ readonly events: {
+ readonly readAll: () => Map
+ readonly upsert: () => void
+ readonly remove: () => void
+ }
}
readonly streams: {
readonly terminal: {
@@ -255,6 +299,47 @@ const NO_LINK = new SnapshotRefused({
const NO_ROWS = new Map()
export const koluHalf = (deps: KoluDeps): KoluHalf => {
+ /** THE WATCHER, built for every face — linked or not. On a machine with
+ * no kolu the collection is not dead, it is heartbeating, and a UI
+ * stating recently in that case is a healthy fresh-install preview. The
+ * clock is the wall: the tests that need a vocabulary of their own get
+ * it through `./watch.ts`'s `options.now`, not through here. */
+ /** The beat's LAST value, so the cell's snapshot answer is the one the
+ * live broadcast ate: the setter publishes to open subscribers and the
+ * store answers a fresh one — the events collection's two paths, one
+ * member over. */
+ let pulse: WatchPulse | null = null
+ const watch: Watch = makeWatch(
+ {
+ emit: (event) => deps.events()?.upsert(event.id, event),
+ evict: (id) => deps.events()?.remove(id),
+ beat: (at, everyMs) => {
+ pulse = { at, everyMs }
+ deps.pulse()?.set(pulse)
+ },
+ say: deps.warn,
+ },
+ { now: () => Date.now() },
+ )
+ /** The malformed-set last said, joined for a one-line compare: the vault
+ * re-derives on every keystroke, and saying the same malformed value on
+ * each one is the noise this exists against. */
+ let saidMalformed = ""
+ /** A VAULT REVISION, as both walks. `mirror` may not exist (a linkless
+ * face), which is why the claims walk sits behind the optional call and
+ * the vault walk's `ReadonlyArray` is satisfied by the surface-driven
+ * walk on the server's side. */
+ let mirror: ReturnType | undefined
+ const revision = (nodes: ReadonlyArray): void => {
+ mirror?.reclaim(deps.claimants(nodes))
+ const next = deps.config(nodes)
+ watch.reconfigure(next.config)
+ const lines = next.malformed.join("\n")
+ if (lines !== saidMalformed) {
+ saidMalformed = lines
+ for (const line of next.malformed) deps.warn(line)
+ }
+ }
if (deps.options === null) {
return {
// A connector that PARKS rather than returns. A connector that returns
@@ -269,8 +354,8 @@ export const koluHalf = (deps: KoluDeps): KoluHalf => {
// `./mirror.ts` on why a refusal here fails rather than ends.
attach: () =>
Stream.make({ kind: "refused", says: NO_LINK.says } as TerminalFrame),
- revision: () => {},
- handlers: linklessHandlers(),
+ revision,
+ handlers: linklessHandlers(watch, () => pulse),
}
}
const { now } = deps.options
@@ -278,11 +363,28 @@ export const koluHalf = (deps: KoluDeps): KoluHalf => {
* the rest of the sink — there is exactly one connector and it runs for the
* life of the runtime, so a closure is the whole of the plumbing. */
let cell: { set: (value: KoluLink) => void } | undefined
- const mirror = makeMirror(
+ mirror = makeMirror(
{
link: (state) => cell?.set(state),
- upsert: (id, row) => deps.fleet()?.upsert(id, row),
- remove: (id) => deps.fleet()?.remove(id),
+ // Every row the mirror moves is an observation, in the same breath
+ // — that is the whole of the watcher's economy, and it is why the
+ // watcher is sure its view is what the fleet tabs see. The two
+ // leave-shapes are kept apart on the same breath: a row CLOSING is
+ // `remove`, a fleet emptied by the link dying is `suspend` — the
+ // difference a restart's `since` would re-date, which `./watch.ts`'s
+ // header argues.
+ upsert: (id, row) => {
+ deps.fleet()?.upsert(id, row)
+ watch.observe(id, row)
+ },
+ remove: (id) => {
+ deps.fleet()?.remove(id)
+ watch.remove(id)
+ },
+ clearedRow: (id) => {
+ deps.fleet()?.remove(id)
+ watch.suspend(id)
+ },
say: deps.say,
},
deps.options,
@@ -290,7 +392,10 @@ export const koluHalf = (deps: KoluDeps): KoluHalf => {
const connect = (handle: { set: (value: KoluLink) => void }): Effect.Effect =>
Effect.suspend(() => {
cell = handle
- return mirror.run
+ // The watcher's death is ordinary closure machinery: the connector
+ // runs for the runtime's life (see `@anyforge/surface`'s
+ // `driver.conn`), and an interruption of it is its stop.
+ return Effect.ensuring(mirror.run, Effect.sync(() => watch.stop()))
})
const screen = (terminal: string, lines: number | undefined) =>
mirror.screen(terminal, lines, now)
@@ -299,8 +404,15 @@ export const koluHalf = (deps: KoluDeps): KoluHalf => {
rows: mirror.rows,
screen,
attach: mirror.attach,
- revision: (nodes) => mirror.reclaim(deps.claimants(nodes)),
- handlers: handlersOf({ connect, rows: mirror.rows, screen, attach: mirror.attach }),
+ revision,
+ handlers: handlersOf({
+ connect,
+ rows: mirror.rows,
+ events: watch.events,
+ pulse: () => pulse,
+ screen,
+ attach: mirror.attach,
+ }),
}
}
@@ -309,19 +421,26 @@ export { DEFAULT_LINES } from "./screen.ts"
export { PADI_SOCKET, type Rendezvous, rendezvousIn } from "./socket.ts"
export { type Claimant } from "./fleet.ts"
export { type MirrorOptions } from "./mirror.ts"
+export { DEFAULT_WATCH, makeWatch, WATCH_RING, type Watch, type WatchConfig } from "./watch.ts"
/**
- * THE FOUR HANDLERS, built from the four verbs.
+ * THE FIVE HANDLERS, built from the verbs.
*
- * One function so the SHAPE lives once. `runtime.ts` used to spell it four
- * times and this package used to spell the verbs; now the package spells both
- * and the server spreads the result. The doc sentences travelled with the
- * handlers they are about, which is why they read as answers to questions
- * nobody asks in this file — they are answers a reader of the SURFACE asks.
+ * One function so the SHAPE lives once. `runtime.ts` used to spell it one
+ * clump a member and this package used to spell the verbs; now the package
+ * spells both and the server spreads the result. The doc sentences travelled
+ * with the handlers they are about, which is why they read as answers to
+ * questions nobody asks in this file — they are answers a reader of the
+ * SURFACE asks.
*/
const handlersOf = (verbs: {
readonly connect: (cell: { set: (value: KoluLink) => void }) => Effect.Effect
readonly rows: () => Map
+ readonly events: () => Map
+ /** The pulse's LAST beat, for the cell's snapshot — the column the
+ * dep fold reads as the standing value, beside the broadcast the
+ * setter walks. */
+ readonly pulse: () => WatchPulse | null
readonly screen: (
terminal: string,
lines: number | undefined,
@@ -338,6 +457,12 @@ const handlersOf = (verbs: {
store: inMemoryStore(SEED),
connect: verbs.connect,
},
+ pulse: {
+ // Wire-read-only, like `kolu`: a beat is something the server
+ // records, never a value a tab could set. The store's getter is the
+ // LAST stamped beat; the setter walks a hollow arm on purpose.
+ store: { get: verbs.pulse, set: () => {} },
+ },
},
collections: {
fleet: {
@@ -350,6 +475,13 @@ const handlersOf = (verbs: {
upsert: () => {},
remove: () => {},
},
+ events: {
+ // The ring's own map — snapshot-then-deltas against a LIVE source,
+ // for `fleet`'s same reason. No writers: the watcher alone writes it.
+ readAll: verbs.events,
+ upsert: () => {},
+ remove: () => {},
+ },
},
streams: {
terminal: {
@@ -363,12 +495,24 @@ const handlersOf = (verbs: {
},
})
-/** What a face with no link answers on all four members — the same refusal the
- * verbs above give, in the shape the surface takes. */
-const linklessHandlers = (): KoluHandlers =>
+/** What a face with no link answers on the whole surface — the same refusal
+ * the verbs above give, in the shape the surface takes. The events
+ * collection and the pulse cell are the one arm that is ALIVE here: no
+ * fleet, no screen, no pane — but the watcher pulses, which is the
+ * fresh-install preview its header argues for. */
+const linklessHandlers = (
+ watch: Watch,
+ beat: () => WatchPulse | null,
+): KoluHandlers =>
handlersOf({
- connect: () => Effect.never,
+ // The connector beholds forever, and the RUNTIME's interrupt of it is
+ // the same death the linked half plans: `ensuring`'s second arm is not
+ // irrelevance — a connector that merely never-ends is where the
+ // watcher's heartbeat has to be allowed to stop with the runtime.
+ connect: () => Effect.ensuring(Effect.never, Effect.sync(() => watch.stop())),
rows: () => NO_ROWS,
+ events: watch.events,
+ pulse: beat,
screen: () => Effect.fail(NO_LINK),
attach: () => Stream.make({ kind: "refused", says: NO_LINK.says } as TerminalFrame),
})
diff --git a/packages/kolu-client/src/mirror.test.ts b/packages/kolu-client/src/mirror.test.ts
index a22d17f0a..e124c37b5 100644
--- a/packages/kolu-client/src/mirror.test.ts
+++ b/packages/kolu-client/src/mirror.test.ts
@@ -35,10 +35,12 @@ const recorder = () => {
const rows = new Map()
const removed: string[] = []
const lines: string[] = []
+ const cleared: string[] = []
return {
links,
rows,
removed,
+ cleared,
lines,
sink: {
link: (state: KoluLink) => links.push(state),
@@ -49,6 +51,12 @@ const recorder = () => {
rows.delete(id)
removed.push(id)
},
+ // The flap's own door — kept apart the way `./mirror.ts`'s sink
+ // declares it: a reader of ITS list only ever means “the LINK went.”
+ clearedRow: (id: string) => {
+ rows.delete(id)
+ cleared.push(id)
+ },
say: (line: string) => lines.push(line),
},
}
@@ -371,6 +379,36 @@ describe("the padi mirror", () => {
expect(refused.reason).toBe("no-padi")
})
+ it("a LINK closing empties the rows on `clearedRow` — never on `remove`", async () => {
+ // The separation the watcher braces on: a flap must not look like a
+ // fleet closing, or every standing hold gets re-dated (`./watch.ts`'s
+ // "A link drop is not a closing fleet").
+ const seen = recorder()
+ let close: (() => void) | undefined
+ const dropping: Dial = () =>
+ Effect.succeed({
+ client: faceWith([FULL_ID]),
+ identity: { stateRoot: "/run/padi", surfaceVersion: SPEAKS },
+ startedAt: 0,
+ dispose: () => {},
+ onClose: (cb: () => void) => {
+ close = cb
+ },
+ } as never)
+ const mirror = makeMirror(seen.sink, { env: {}, now: () => AT, dial: dropping })
+ const fiber = Effect.runFork(Effect.scoped(mirror.run))
+ await Effect.runPromise(Effect.sleep("80 millis"))
+ expect(seen.rows.has(FULL_ID)).toBe(true)
+
+ close?.()
+ await Effect.runPromise(Effect.sleep("50 millis"))
+ await Effect.runPromise(Fiber.interrupt(fiber))
+
+ expect(seen.cleared).toContain(FULL_ID)
+ expect(seen.removed).not.toContain(FULL_ID)
+ expect(mirror.rows().size).toBe(0)
+ })
+
it("reads a screen named by an EIGHT-CHARACTER PREFIX — the board's own spelling", async () => {
// THE SECOND PRODUCTION DEFECT. The chip sent what the property holds; the
diff --git a/packages/kolu-client/src/mirror.ts b/packages/kolu-client/src/mirror.ts
index b6f0383c8..df324cbbe 100644
--- a/packages/kolu-client/src/mirror.ts
+++ b/packages/kolu-client/src/mirror.ts
@@ -62,6 +62,12 @@ export interface MirrorSink {
readonly upsert: (id: string, row: FleetTerminal) => void
/** A row left — wired to the same collection's `remove`. */
readonly remove: (id: string) => void
+ /** THE FLAP's row leave: the fleet went whole because the LINK did, which is
+ * a different event from any one terminal closing. The collection's rows
+ * read the same emptiness as `remove`; what this door is FOR is whoever is
+ * hanging semantic money on the difference — the watcher, whose holds
+ * pause through a flap rather than re-firing after one (`./watch.ts`). */
+ readonly clearedRow: (id: string) => void
/** Routine narration, wired to the server's log. */
readonly say: (line: string) => void
}
@@ -380,14 +386,15 @@ export const makeMirror = (sink: MirrorSink, options: MirrorOptions): Mirror =>
refeed({ byClass: frame.byClass, liveIds: [...ids] as never })
},
cleared: () => {
- // EVERY row goes, one remove each, because that is what the collection's
- // wire can say. It is not the same event as thirty terminals closing and
- // a reader should not have to tell them apart from the frames — which is
- // why the `kolu` cell moves to `absent` in the same breath, and why a
- // chip reads THAT for its hollow rather than reading an empty fleet.
+ // EVERY row goes, one call each — but on `clearedRow`, not `remove`:
+ // the LINK went, so each row's leaving is a suspension, not a
+ // shutting down, and a reader hanging semantics on the difference (the
+ // watcher, `./watch.ts`) needs the door to tell them apart. The one the
+ // chip WEARS is the `kolu` cell's own `absent`, which `linkSink` moves
+ // the same breath.
for (const id of [...rows.keys()]) {
rows.delete(id)
- sink.remove(id)
+ sink.clearedRow(id)
}
records.clear()
reader = null
diff --git a/packages/kolu-client/src/watch.test.ts b/packages/kolu-client/src/watch.test.ts
new file mode 100644
index 000000000..0cdcfa32d
--- /dev/null
+++ b/packages/kolu-client/src/watch.test.ts
@@ -0,0 +1,635 @@
+/**
+ * THE ATTENTION WATCHER — the brief's five cases, and the timers.
+ *
+ * What is driven HERE are the semantics `./watch.ts` owns; what is proved
+ * over `./mirror.test.ts`'s ground is the CHAIN: padi's attention words,
+ * folded by the mirror, seen by the watcher the way the mirror publishes —
+ * which is the defeat the brief plans the daemon's own watcher being named
+ * for: nothing in `./watch.ts` can make a bell out of bytes.
+ *
+ * ## The clocks
+ *
+ * The hold clock is REAL but SMALL — the tests spell forty to ninety
+ * milliseconds for sixty seconds — because this module takes a clock for
+ * the WORDS only (`options.now`) and real timers for the pacing; small real
+ * timers is the honest trade — an injected scheduler would prove the watch
+ * obeys the clock it was given, rather than that the holds arm and die in
+ * the order they were armed, which is the whole question. Bun's per-test
+ * five seconds are plenty for each.
+ */
+
+import { describe, expect, it } from "bun:test"
+import { Effect, Fiber, Stream } from "effect"
+
+import { type Dial, SPEAKS } from "./link.ts"
+import { makeMirror } from "./mirror.ts"
+import { DEFAULT_WATCH, makeWatch, type WatchConfig } from "./watch.ts"
+import type { FleetTerminal, KoluEvent } from "./wire/index.ts"
+import { UNOWNED } from "./wire/index.ts"
+
+/** One timed wait, small and honest — see the header. */
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
+
+/** A KNOB SET shortened to test scale: a minute is 40 ms, ten is 110, half an
+ * hour is a breath's width of 400 — the HOLD windows only; the heartbeat
+ * stays parked at "far away" unless a case is about its cadence. */
+const tiny = (extra?: Partial): WatchConfig => ({
+ heldForMs: 40,
+ nagMs: 110,
+ heartbeatMs: 60_000,
+ muted: [],
+ ...extra,
+})
+
+/** A FLEET ROW, wire-shaped: what the mirror's `rows().get(id)` holds and
+ * what `./watch.ts` reads. The four fields the watcher computes off, and a
+ * quiet working face for the rest. */
+const row = (id: string, agentState: string | null, label = ""): FleetTerminal => {
+ const word = agentState ?? "idle"
+ const held = agentState === "awaiting_user" || agentState === "waiting"
+ const bucket = agentState === null
+ ? "idle"
+ : held
+ ? agentState === "waiting" ? "waiting" : "awaiting"
+ : "working"
+ return {
+ id,
+ pip: {
+ variant: held ? "waiting" : "busy",
+ glyph: "claude-code",
+ active: agentState !== null,
+ asking: held,
+ bytesLive: true,
+ hasAgent: agentState !== null,
+ sleeping: false,
+ alert: false,
+ alertLabel: "",
+ },
+ bucket,
+ agentState,
+ label,
+ labelColor: "",
+ subline: { text: word, fromAgent: true },
+ pr: null,
+ recencyAt: null,
+ repo: null,
+ themeName: null,
+ owner: UNOWNED,
+ }
+}
+
+/**
+ * The events and the ring, collected. `sets` is every event as it went;
+ * `ring()` is the live view, eviction included — the two halves of "the
+ * watcher's arrivals", as `./watch.ts`'s sink given a writer.
+ */
+const collected = () => {
+ const sets: Array = []
+ const said: Array = []
+ const sets_full: Array = []
+ const ring = new Map()
+ /** Every beat as it landed on the sink: the pill's recency, which LIVES
+ * here since the beat came out of the ring (see `./watch.ts`'s header). */
+ const beats: Array<{ at: string; everyMs: number }> = []
+ return {
+ sets,
+ said,
+ events: sets_full,
+ beats,
+ ring: () => new Map(ring),
+ sink: {
+ emit: (event: KoluEvent) => {
+ sets.push(event.id)
+ sets_full.push(event)
+ ring.set(event.id, event)
+ },
+ say: (line: string) =>
+ said.push(line),
+ evict: (id: string) => {
+ ring.delete(id)
+ },
+ beat: (at: string, everyMs: number) => {
+ beats.push({ at, everyMs })
+ },
+ },
+ }
+}
+
+describe("the attention watcher", () => {
+ it("boots with a heartbeat and tells no other lies", () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => 1_700_000_000_000 })
+ // THE BEAT IS IMMEDIATE — the pill stamps once at boot, and the RING
+ // holds attention only: a feed opened one breath in answers "quiet"
+ // with a pulse, never with a ring row.
+ expect(seen.beats.length).toBe(1)
+ expect(seen.beats[0]?.at).toBe(new Date(1_700_000_000_000).toISOString())
+ expect(seen.beats[0]?.everyMs).toBe(DEFAULT_WATCH.heartbeatMs)
+ expect(seen.events.filter((e) => e.kind === "heartbeat").length).toBe(0)
+ watch.stop()
+ })
+
+ it("fires a `transition` only once the state has HELD past the window", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny())
+
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(18)
+ // WITHIN THE WINDOW: the terminal is waiting, but the rule is that it
+ // has been so for long enough to say so — forty ms on a test clock.
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(0)
+
+ await sleep(60)
+ const fired = seen.events.filter((e) => e.kind === "transition")
+ expect(fired.length).toBe(1)
+ // The event says what it saw — `state` is the BUCKET, carried whole.
+ expect(fired[0]?.row?.state).toBe("awaiting")
+ expect(fired[0]?.row?.terminal).toBe("t1")
+ // The FROZEN draw: live flags stamped out. See `./watch.ts`'s stamp —
+ // a two-hour-old event must not flash LIVE.
+ expect(fired[0]?.row?.pip?.active).toBe(false)
+ expect(fired[0]?.row?.pip?.bytesLive).toBe(false)
+ watch.stop()
+ })
+
+ it("does not fire at all if the state eases inside the window", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny())
+
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(18)
+ // The agent moved on — the window closed silently.
+ watch.observe("t1", row("t1", "thinking"))
+ await sleep(60)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(0)
+ watch.stop()
+ })
+
+ it("answers a long hold with one transition, then `nag`s on the cadence", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny())
+
+ watch.observe("t1", row("t1", "waiting"))
+ await sleep(40 + 30)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(1)
+
+ await sleep(110 + 50)
+ // FIRST NAG: still waiting, one interval later.
+ expect(seen.events.filter((e) => e.kind === "nag").length).toBe(1)
+ expect(seen.events.at(-1)?.row?.state).toBe("waiting")
+
+ await sleep(110 + 50)
+ // THE NEXT ONE. The cadence is measured from the LAST thing said, never
+ // from the first — see `fireNag`'s doc. Small real timers are
+ // calibration, not arithmetic, so the count is a floor rather than an
+ // equality: two full intervals beyond the transition, at least.
+ expect(seen.events.filter((e) => e.kind === "nag").length).toBeGreaterThanOrEqual(2)
+ watch.stop()
+ })
+
+ it("fires for a SECOND held bucket too — waiting → awaiting is a new hold", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny())
+
+ watch.observe("t1", row("t1", "waiting"))
+ await sleep(40 + 30)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(1)
+
+ // The state CHANGED held buckets, mid-nag: the nag's craft is over and
+ // a new debounce is on. Two buckets are spelled two ways for a reason.
+ watch.observe("t1", { ...row("t1", "awaiting_user"), bucket: "awaiting" })
+ await sleep(18)
+ // The second window has not battened yet — one nag's worth of noise is
+ // feared, not hoped: nothing has earned a second event YET.
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(1)
+ await sleep(60)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(2)
+ expect(seen.events.at(-1)?.row?.state).toBe("awaiting")
+ watch.stop()
+ })
+
+ it("one mute value silences its terminal, and only that one", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny({ muted: ["t1"] }))
+
+ watch.observe("t1", row("t1", "awaiting_user"))
+ watch.observe("t2", row("t2", "awaiting_user"))
+ await sleep(80)
+ const fired = seen.events.filter((e) => e.kind === "transition")
+ expect(fired.length).toBe(1)
+ expect(fired[0]?.row?.terminal).toBe("t2")
+
+ // UNMUTE, mid-life: `reconfigure` is the file's own watcher's door. The
+ // same t1 enters again under a lightened list, and it is told.
+ watch.observe("t1", row("t1", "thinking"))
+ watch.reconfigure(tiny())
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(80)
+ expect(seen.events.filter((e) => e.kind === "transition").map((e) => e.row?.terminal))
+ .toEqual(["t2", "t1"])
+ watch.stop()
+ })
+
+ it("kills a hold the moment an edit mutes it", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny())
+
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(20)
+ watch.reconfigure(tiny({ muted: ["t1"] }))
+ await sleep(60)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(0)
+ watch.stop()
+ })
+
+ it("an AMBIGUOUS mute silences nobody under BOTH candidates it names, and says why once", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ // THE ORDER IS THE POINT: both terminals are ALREADY live when the edit
+ // lands, so `t` names two ids at the moment of the ask — padi's refusal,
+ // made a sentence. (Had the file been read when only t1 was around, the
+ // prefix would have meant t1, and t1 would be the one muted — the values
+ // are resolved against the LIVE roster, per observation.)
+ watch.observe("t1", row("t1", "awaiting_user"))
+ watch.observe("t2", row("t2", "awaiting_user"))
+ watch.reconfigure(tiny({ muted: ["t"] }))
+ await sleep(80)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(2)
+ expect(
+ seen.said.filter((line) => line.includes("names 2 terminals")).length,
+ ).toBe(1)
+ watch.stop()
+ })
+
+ it("pulses on the heartbeat's cadence, not a keystroke's", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny({ heartbeatMs: 200 }))
+
+ // The test's arrival at t≈0 includes the DETAIL of the eat: the
+ // reconfigure MOVES the heartbeat (200 ≠ default) — `rearmHeartbeat`'s
+ // the-forward-fond eat is the answer it gives ON the eat. Then 120 ms
+ // into the new 200-cycle: the two of them, and nothing else. An echoed
+ // knob (what every vault keystroke hands the watch) leaves the
+ // in-flight interval alone, so the next beat lands 80 ms hence — a
+ // clear-then-re-arm would charge a full 200, and the count is where
+ // that shows.
+ await sleep(120)
+ expect(seen.beats.length).toBe(2)
+ watch.reconfigure(tiny({ heartbeatMs: 200 }))
+ await sleep(90)
+ expect(seen.beats.length).toBe(3)
+
+ // Raising the knob answers with ONE beat — `reconfigure`'s echo
+ // guard eats the keystroke (beats stay 3), but the moved knob
+ // `rearmHeartbeat` restsamp at once AND lands a 4th one: the eat's
+ // stamp says the new cadence NOW, so the door never has to read the
+ // shorter one's margin off the previous `everyMs` for two whole
+ // windows)
+ watch.reconfigure(tiny({ heartbeatMs: 10_000 }))
+ expect(seen.beats.length).toBe(4)
+ expect(seen.beats[3]?.everyMs).toBe(10_000)
+ await sleep(100)
+ expect(seen.beats.length).toBe(4)
+ // The ring holds the ATTENTION rows, and nothing else — the beat is
+ // not a row, and never was one (see the header).
+ expect(seen.events.filter((e) => e.kind === "heartbeat").length).toBe(0)
+ watch.stop()
+ })
+
+ it("a LOWERED `held-for` re-arms the debounce without re-asking TIME", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ // Long window first, then the LOWERED edit while the hold is settled.
+ // (A raised one sits out the difference — see `reconfigure`.)
+ watch.reconfigure(tiny({ heldForMs: 400 }))
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(30)
+ watch.reconfigure(tiny({ heldForMs: 50 }))
+ // The hold has 20 ms left under the new knob — a re-DATED hold (the
+ // easy bug) would have answered at 480 ms, and nothing would have seen.
+ await sleep(80)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(1)
+ watch.stop()
+ })
+
+ it("removes a terminal's hold with the terminal", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny())
+
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(20)
+ watch.remove("t1")
+ await sleep(80)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(0)
+ watch.stop()
+ })
+
+ it("editing `held-for` mid-lodge does not touch a fired hold's nag pace", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny({ heldForMs: 40, nagMs: 80 }))
+
+ watch.observe("t1", row("t1", "waiting"))
+ await sleep(190)
+ // FIRED, the debounce spent; the first nag lands at emission+80.
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(1)
+ expect(seen.events.filter((e) => e.kind === "nag").length).toBe(1)
+
+ // A knob the nag does not care about must not push it out. A re-ARM by
+ // now+nag would put the next one 80 ms from the edit; the cadence
+ // keeps — measured from the last EMISSION through `armNag`.
+ watch.reconfigure(tiny({ heldForMs: 400, nagMs: 80 }))
+ await sleep(60)
+ expect(seen.events.filter((e) => e.kind === "nag").length).toBeGreaterThanOrEqual(2)
+ watch.stop()
+ })
+
+ it("a moved nag knob re-arms from the LAST EMISSION, not from the edit", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny({ heldForMs: 40, nagMs: 200 }))
+
+ watch.observe("t1", row("t1", "waiting"))
+ await sleep(60)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(1)
+
+ // The transition fired; the editor lowers `nag` 60 ms later, so the
+ // next one lands at emission+100 — not EDIT+100, which is opus's
+ // minute-long typing of one file, shelved at a hundredth the clock.
+ await sleep(60)
+ watch.reconfigure(tiny({ heldForMs: 40, nagMs: 100 }))
+ await sleep(50)
+ expect(seen.events.filter((e) => e.kind === "nag").length).toBeGreaterThanOrEqual(1)
+ watch.stop()
+ })
+
+ it("the ring caps and evicts, in both directions", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny({ heldForMs: 5, nagMs: 60_000 }))
+ // 208 arrivals into a ring of 200, for the evict-on-eight-threshold the
+ // assertions below count on. ( beats never REACH the ring now — the
+ // fill is attention rows or nothing. )
+ for (let i = 1; i <= 208; i += 1) {
+ watch.observe(`t${i}`, row(`t${i}`, "waiting"))
+ }
+ await sleep(70)
+ // The two halves of "the cap fired": the oldest ids are GONE from the
+ // live ring (the deltas saw them drop) and the newest arrived.
+ const live = watch.events()
+ expect(live.size).toBe(200)
+ const ghosted = seen.events.slice(0, 8).filter((e) => !live.has(e.id))
+ expect(ghosted.length).toBe(8)
+ expect(live.has(seen.events.at(-1)!.id)).toBe(true)
+ // And the view a subscriber rebuilt from deltas alone agrees —
+ // `readAll` cannot name what the wire evicted.
+ expect(seen.ring().size).toBe(200)
+ expect(seen.ring().has(seen.events.at(-1)!.id)).toBe(true)
+ watch.stop()
+ })
+
+ it("stops cleanly — no timer outlives it", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny({ heartbeatMs: 45 }))
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(18)
+ watch.stop()
+ // What the ring SAYS at stop time, before any timer could count down.
+ const atStop = seen.events.length
+ await sleep(120)
+ expect(seen.events.length).toBe(atStop)
+ })
+
+ it("a close an unsettled prefix re-asks the mute fold", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny())
+ // BOTH ids live, the prefix `t` names two: the mute silences nobody
+ // (padi's own verdict once a mutes' answer is two) — both fire.
+ watch.observe("t1", row("t1", "awaiting_user"))
+ watch.observe("t2", row("t2", "awaiting_user"))
+ watch.reconfigure(tiny({ muted: ["t"] }))
+ await sleep(70)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(2)
+
+ // THE fleet moves one more time — t2 closes — and the ride out should
+ // not leave the answer at ambient: `t` names one now, so the hold dies
+ // without a first nag packing the ring past its target.
+ watch.remove("t2")
+ await sleep(180)
+ expect(seen.events.filter((e) => e.kind === "nag").length).toBe(0)
+ watch.stop()
+ })
+})
+
+describe("a link drop is not a closing fleet", () => {
+ it("the flap fires nothing — no transition the wire already said", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny({ heldForMs: 40, nagMs: 110 }))
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(70)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(1)
+ const saidSince = seen.events.find((e) => e.kind === "transition")!.row!.since
+
+ // THE FLAP: rows fall, the link says nothing, time alone talks. Through
+ // the blind span, the nag arm would have fired — save no.
+ watch.suspend("t1")
+ await sleep(140)
+ expect(seen.events.filter((e) => e.kind === "nag").length).toBe(0)
+
+ // AND RESUME. A re-dated hold would answer at once; the hold's own
+ // clock keeps ticking — the DEBT of the nag the blind span swallowed
+ // folds especially: the row was waiting through the flap, so the
+ // next said is fired on the row's return, by the same arithmetic the
+ // soak's own `kolu watch` runs on a reconnect of its own daemon.
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(40)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(1)
+ const nags = seen.events.filter((e) => e.kind === "nag")
+ expect(nags.length).toBe(1)
+ // And the said hands the ORIGINAL `since` — the flap's lie is exactly
+ // what it doesn't say.
+ expect(nags[0]!.row!.since).toBe(saidSince)
+ await sleep(120)
+ // And the cadence resumes: the next one rides emission+110 from THAT
+ // emission — its own.
+ expect(seen.events.filter((e) => e.kind === "nag").length).toBeGreaterThanOrEqual(2)
+ watch.stop()
+ })
+
+ it("a hold that crossed its window while the fleet was blind fires on its return", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny({ heldForMs: 40, nagMs: 110 }))
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(20)
+ // Suspended INSIDE the debounce at 20 of 40; the blind span swallows
+ // five times the window.
+ watch.suspend("t1")
+ await sleep(200)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(0)
+ // On the resume the math reads left from `since + heldFor`: the debt
+ // lands at once, once — not re-deferred the flap's length.
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(30)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(1)
+ watch.stop()
+ })
+
+ it("a preserved hold answers only to its own bucket — a different one is a renewed hold", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny({ heldForMs: 40, nagMs: 110 }))
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(70)
+ const flappedAt = seen.events.filter((e) => e.kind === "transition").length
+ expect(flappedAt).toBe(1)
+
+ // The flap, and the id RETURNS in the OTHER held bucket: it is the rule
+ // `observe` always holds — one hold closes, another opens with its own
+ // since — and the flap changes nothing about it.
+ watch.suspend("t1")
+ await sleep(100)
+ watch.observe("t1", row("t1", "waiting"))
+ await sleep(70)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(2)
+ watch.stop()
+ })
+
+ it("an edit saying a suspended hold is to die is obeyed at the flap", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny())
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(70)
+ expect(seen.events.filter((e) => e.kind === "transition").length).toBe(1)
+
+ watch.suspend("t1")
+ watch.reconfigure(tiny({ muted: ["t1"] }))
+ // The return of the muted id is another plain observation — the gate
+ // folds it and no arm is set: through a nag window and some, the ring
+ // gets no nag.
+ watch.observe("t1", row("t1", "awaiting_user"))
+ await sleep(180)
+ expect(seen.events.filter((e) => e.kind === "nag").length).toBe(0)
+ watch.stop()
+ })
+})
+
+// ── Over the mirror's own records ─────────────────────────────────────────
+
+/**
+ * THE CHAIN: padi's record → the mirror's row → the watch's event.
+ *
+ * The unit cases above drive rows by hand; this one proves the wiring that
+ * `koluHalf`'s four lines (`./index.ts`'s `upsert`) describe — the watch is
+ * fed by the mirror's publications, same tick, same row — by standing both
+ * on the same fake far end: a real `makeMirror` drive against `face = the
+ * near side of padi`, exactly `./mirror.test.ts`'s idiom, and a flip from
+ * the idle agent record to `awaiting_user` the watch then reports.
+ */
+
+const faceFlipping = (record1: object, record2: object) => ({
+ padi: {
+ surface: {
+ terminalAttach: { get: () => Stream.never },
+ urgency: {
+ get: () =>
+ Stream.concat(
+ Stream.make({
+ awaitingIds: [],
+ finishedIds: [],
+ workingIds: [],
+ lingerIds: [],
+ }),
+ Stream.never,
+ ),
+ },
+ activity: {
+ get: () => Stream.concat(Stream.make([] as ReadonlyArray), Stream.never),
+ },
+ terminals: {
+ keys: () => Stream.concat(Stream.make(["t1"]), Stream.never),
+ get: (_input: { key: string }) =>
+ // TWO RECORDS — padi's `get` is a WATCH: the first frame is what
+ // it remembers, every later frame is a move, and the mirror folds
+ // each one as it lands.
+ Stream.concat(
+ Stream.concat(Stream.make(record1 as never), Stream.make(record2 as never)),
+ Stream.never,
+ ),
+ },
+ screen: { text: () => Effect.succeed("") },
+ },
+ },
+})
+
+const dialTo = (record1: object, record2: object): Dial =>
+() =>
+ Effect.succeed({
+ client: faceFlipping(record1, record2),
+ identity: { stateRoot: "/run/padi", surfaceVersion: SPEAKS },
+ startedAt: 0,
+ onClose: () => {},
+ dispose: () => {},
+ } as never)
+
+describe("the watcher's chain through the mirror", () => {
+ it("a flip to `awaiting_user` over the wire is one `transition`", async () => {
+ const seen = collected()
+ const watch = makeWatch(seen.sink, { now: () => Date.now() })
+ watch.reconfigure(tiny({ heartbeatMs: 10_000 }))
+ const mirror = makeMirror(
+ {
+ link: () => {},
+ upsert: (id: string, row: FleetTerminal) => watch.observe(id, row),
+ remove: (id: string) => watch.remove(id),
+ clearedRow: (id: string) => watch.suspend(id),
+ say: seen.sink.say,
+ },
+ {
+ env: {},
+ now: () => new Date().toISOString(),
+ dial: dialTo(
+ {
+ state: "active",
+ agent: null,
+ pr: { kind: "absent" },
+ cwd: "/tmp/a",
+ git: null,
+ lastActivityAt: null,
+ },
+ {
+ state: "active",
+ agent: { kind: "claude-code", state: "awaiting_user", summary: null },
+ pr: { kind: "absent" },
+ cwd: "/tmp/b",
+ git: null,
+ lastActivityAt: null,
+ },
+ ),
+ },
+ )
+
+ const fiber = Effect.runFork(Effect.scoped(mirror.run))
+ await sleep(80)
+ // The FIRST frame is the idle agent record — the debounce has not armed
+ // (nothing has been said); the flip arrives INSIDE the first window.
+ const fired = () => seen.events.filter((e) => e.kind === "transition")
+ await sleep(80)
+ expect(fired().length).toBe(1)
+ expect(fired()[0]?.row?.state).toBe("awaiting")
+ await Effect.runPromise(Fiber.interrupt(fiber))
+ watch.stop()
+ })
+})
diff --git a/packages/kolu-client/src/watch.ts b/packages/kolu-client/src/watch.ts
new file mode 100644
index 000000000..217052a59
--- /dev/null
+++ b/packages/kolu-client/src/watch.ts
@@ -0,0 +1,533 @@
+/**
+ * THE WATCHER — olai's own computation of the attention events the
+ * orchestrator today gets from a hand-armed `kolu watch`.
+ *
+ * ## What it reads, and why that is the whole economy
+ *
+ * It reads the MIRROR — the rows `./mirror.ts` already publishes to the
+ * `fleet` collection — and nothing else. No second subscription and no
+ * second dial: `koluHalf`'s sink hands every row the mirror moves to this
+ * beside the collection, so the events are computed over exactly what ten
+ * tabs on a lanes page already see, in the same breath.
+ *
+ * That is a deliberate difference from padi's own `watchStates` member,
+ * which computes the same events DAEMON-side from its own records. What
+ * olai computes here is deliberately a READING of the mirror rather than a
+ * subscription to that member: the mirror's rows are the mirror's problem,
+ * and the day padi's watch member changes shape this module does not move.
+ * What is lost is the daemon's `since` clock — the daemon has watched the
+ * state since IT saw it enter, and olai restarting re-dates every standing
+ * hold. `KoluEvent.row.since` says so on its own doc.
+ *
+ * ## The semantics, in one breath
+ *
+ * For every un-muted terminal whose row enters a held bucket — `awaiting`
+ * or `waiting`, the two that need a person
+ * (`@kolu/terminal-vocab`'s WATCH_DEFAULT_STATES, spelled out so that the
+ * fold's home need not be imported for one set) — start a hold clocked from
+ * the first observation of it. Held past `heldForMs`: emit a `transition`.
+ * Still held another `nagMs`: emit a `nag`, and keep re-emitting until the
+ * row says otherwise. A terminal that leaves the bucket inside the window
+ * is never said at all — that is the debounce padi's `heldForMs` documents
+ * and the soak runs beside.
+ *
+ * A beat is said every `heartbeatMs` (immediately once, at boot) — into
+ * the SINK's `beat`, not the ring. Liveness lives on the pill (the wire's
+ * `pulse` member); the ring holds ATTENTION events only, so a feed drawn
+ * over a dead fleet is a page that had nothing to say, not a beat failing
+ * to reach it.
+ *
+ * ## A link drop is not a closing fleet
+ *
+ * There are TWO ways a row leaves the fleet, and the watcher reads them
+ * apart (`./mirror.ts`'s `remove` vs `clearedRow`). A row leave against a
+ * standing fleet is a terminal that SHUT: its hold goes with it. A fleet
+ * emptied whole — the padi socket reboot under olai's hands — leaves the
+ * waiting exactly where it was: the daemon re-dates nothing, and neither
+ * does this module. The hold's clock is the watcher's own, not the
+ * record's: `suspend` freezes it timerless, a returning `observe` in the
+ * same bucket resumes it from its own `since`, and nothing in the flap
+ * interval ever fires. The soak (`kolu watch`'s `PadiStateEvent` snapshot
+ * semantics) behaves the same, which is the comparison the events are
+ * soaked against.
+ *
+ * ## What is deliberately not computed here
+ *
+ * The KNOB reading. `_olai/Kolu.olai` is outline records, and this package
+ * has no business knowing what one is (`./index.ts`'s header): the server
+ * walks the vault (`@olai/server`'s `koluConfig.ts`, beside `claimants.ts`)
+ * and hands over the derived config — malformed values already defaulted
+ * and named. This module takes intervals and mute values, compares them,
+ * reconfigures, and owns the timers.
+ *
+ * PERSISTENCE. The ring is a standing thing per server — `WATCH_RING`
+ * events, newest kept, snapshot-then-deltas on the wire. A server restart
+ * re-sees the fleet cold: no memory of last night's holds, and a terminal
+ * still `waiting` at dawn fires as a fresh hold under the same rules as a
+ * fresh transition.
+ *
+ * ## The timers, and how they die
+ *
+ * Every timer a terminal owns lives on its hold; every release path —
+ * bucket left, muted, row removed, watcher stopped — runs through
+ * `releaseHold`, so a nag cannot fire into a state that already ended. The
+ * one timer that is not a hold's is the heartbeat's interval, and
+ * `stop` clears it.
+ */
+
+import { narrowAgentState } from "@kolu/solid-dockrow/rowValues"
+import { agentBucket, WATCH_DEFAULT_STATES } from "@kolu/terminal-vocab/agentProjection"
+
+import { type FleetTerminal, type KoluEvent, resolveTerminal } from "./wire/index.ts"
+
+// ── The config, as the vault walk hands it over ──────────────────────────
+
+/**
+ * The watcher's knob set, DERIVED ELSEWHERE — see the header for why this
+ * shape never names a vault node.
+ */
+export interface WatchConfig {
+ readonly heldForMs: number
+ readonly nagMs: number
+ readonly heartbeatMs: number
+ /** Mute values, VERBATIM from the `mutes` node's children — full ids or
+ * the prefix spelling the board actually writes. Resolution is this
+ * module's to do per observation, against the fleet's CURRENT id set,
+ * because the roster only exists here. */
+ readonly muted: ReadonlyArray
+}
+
+/** The knobs when `_olai/Kolu.olai` is absent, torn or quiet — the brief's
+ * own numbers, one constant, not three defaults remembered in two places. */
+export const DEFAULT_WATCH: WatchConfig = {
+ heldForMs: 60_000,
+ nagMs: 600_000,
+ heartbeatMs: 1_800_000,
+ muted: [],
+}
+
+/** The ring's cap — the brief's `~200`. */
+export const WATCH_RING = 200
+
+/** The buckets a hold may be about: `WATCH_DEFAULT_STATES`, THE set the
+ * fold's home pins for every face (its own doc: read this one constant,
+ * or advertise a default nothing applies). The one exclusion in it —
+ * `working` — is the flood every watch feature was written to replace.
+ * Held as a Set so `heldBucketOf`'s membership ask is one hash, not a
+ * scan. */
+const HELD_BUCKETS: ReadonlySet = new Set(WATCH_DEFAULT_STATES)
+
+/** What the watcher emits. `emit`/`evict` are the events collection's two
+ * verbs — a fresh row, and the row the ring dropped — so `./index.ts`
+ * closes over this and nothing else. */
+export interface WatchSink {
+ readonly emit: (event: KoluEvent) => void
+ /** Fired ONLY on a ring eviction: events are never edited, only dropped. */
+ readonly evict: (id: string) => void
+ /** The beat: the watcher is alive. Attentive value (`at` + the cadence it
+ * was stamped under) rides beside it, which is the pill's whole read. */
+ readonly beat: (at: string, everyMs: number) => void
+ readonly say: (line: string) => void
+}
+
+export interface Watch {
+ /** A fleet row MOVED — start, refresh or kill a hold, according to its
+ * bucket. An id that was SUSPENDED returns through this same door: a
+ * resume, rather than a re-dating. */
+ readonly observe: (id: string, row: FleetTerminal) => void
+ /** A row left the fleet underneath a STANDING link — a terminal that
+ * shut. Its hold goes with it, silently: a terminal that left is not
+ * waiting for anything. */
+ readonly remove: (id: string) => void
+ /** The LINK fell, carrying every row with it. The waiting underneath did
+ * not move: the holds PAUSE — no timer fires while there is no fleet to
+ * watch — and a returning `observe` in the same bucket resumes from the
+ * hold's own clock. Holds whose id never returns keep no timer and hold
+ * only their memory; `stop` clears them with the rest. */
+ readonly suspend: (id: string) => void
+ /** The config the vault NOW says, freshly derived on every revision. */
+ readonly reconfigure: (config: WatchConfig) => void
+ /** The ring, oldest first — the collection's `readAll` reads it
+ * verbatim, which is the same typed-mutable life the fleet's rows lead. */
+ readonly events: () => Map
+ /** Stops every timer. Called when the runtime that owns this watcher
+ * closes. */
+ readonly stop: () => void
+}
+
+/**
+ * One terminal's in-flight hold.
+ *
+ * `row` is the LAST observed row, refreshed on every observe: the event
+ * freezes facts at FIRE time, so an intent the terminal was handed after
+ * the state began is on the event that names it.
+ *
+ * `holdTimer` and `nagTimer` are disjoint by construction — the debounce
+ * arms until the transition fires, the nag from then on — and `fired` is
+ * the line between them. Keeping the two arms separate is what lets a
+ * config edit re-pace the nag without re-debouncing a state already said.
+ */
+interface Hold {
+ readonly id: string
+ /** The held bucket — `awaiting` | `waiting`. */
+ readonly state: string
+ /** The verbatim agent state the row spelled when the hold began — the
+ * typed literal {@link heldStateOf} narrowed out of the row's word, which
+ * IS the verbatism for a known state. */
+ agentState: string
+ /** Epoch ms of the FIRST observation in this hold. */
+ readonly since: number
+ /** Epoch ms of the LAST emission. Seeded with `since` so an unfired hold's
+ * arithmetic needs no third field; set by every `emitHold`, which is the
+ * sole producer of events — and the seam by which both a `reconfigure`
+ * re-arm and a flap RESUME measure from the emission that already
+ * happened rather than the moment somebody edited a knob. */
+ lastEmittedAt: number
+ /** The draw facts, refreshed per observation, frozen into the event at
+ * fire time. */
+ row: FleetTerminal
+ /** Whether the `transition` has been said. */
+ fired: boolean
+ holdTimer: ReturnType | undefined
+ nagTimer: ReturnType | undefined
+}
+
+/**
+ * The HOLDABLE state a row folds to, or `null` when there is none to
+ * hold: no agent in the row, a state this build's vocabulary does not know
+ * (a newer padi — `narrowAgentState` keeps the word verbatim and marks it
+ * unknown), or a bucket that is not `HELD_BUCKETS` — `other`, and
+ * `working`, the flood this whole feature exists to replace.
+ *
+ * The ONE fold in this file, and there is exactly one of it: `observe`'s
+ * gate asks this and nothing else.
+ */
+interface HeldState {
+ /** The bucket the hold is about — one of `HELD_BUCKETS`. */
+ readonly bucket: string
+ /** The state the row spelled, as the narrowed LITERAL — the same word the
+ * wire's verbatim contract already promises. */
+ readonly spelled: string
+}
+const heldStateOf = (row: FleetTerminal): HeldState | null => {
+ const narrowed = narrowAgentState(row.agentState)
+ if (narrowed.state === undefined) return null
+ const bucket = agentBucket(narrowed.state)
+ return HELD_BUCKETS.has(bucket) ? { bucket, spelled: narrowed.state } : null
+}
+
+export const makeWatch = (
+ sink: WatchSink,
+ options: { readonly now: () => number },
+): Watch => {
+ /** The knob set in force. Defaults until the vault's walk reconfigures —
+ * which is also what an absent `_olai/Kolu.olai` reconfigures TO. */
+ let config: WatchConfig = DEFAULT_WATCH
+ /** The holds, keyed by fleet id. */
+ const holds = new Map()
+ /** The suspended-half of the same book: holds whose fleet emptied under a
+ * flap (see the `suspend` doc). No timer lives here — one is armed at
+ * resume or at the hold's death, and `stop()` clears them. */
+ const suspended = new Map()
+ /** The fleet's id SET as the mirror knows it, kept for one job: prefix
+ * resolution of the mute values. */
+ const seen = new Set()
+ /** The ring. Insertion-ordered Map, capped at `WATCH_RING` — a Map key
+ * iteration order is insertion order, and the eviction asks the first
+ * key. */
+ const ring = new Map()
+ /** One counter for the whole watcher, so the ring's keys read in fire
+ * order and are unique per shot. */
+ let seq = 0
+ let heartbeatTimer: ReturnType | undefined
+
+ /** The mute fold, resolved ONCE per use: one `mutes × fleet` walk,
+ * which on a running machine is four times thirty — against the row
+ * walk it rode in on, nothing. Its two consumers (the hold gate and the
+ * ambiguity says) read THE one resolution, so a value can never be
+ * silenced-to and said about in the same breath. The ids it can name
+ * are only those the mirror has ever handed over this observation
+ * life; a muted prefix naming nothing stays inert — the fail-open rule. */
+ interface MuteFold {
+ /** The live ids the list silences. */
+ readonly silenced: Set
+ /** The values that name MORE than one live terminal — inert, and said. */
+ readonly ambiguous: ReadonlyArray<{ readonly value: string; readonly count: number }>
+ }
+ const foldMutes = (): MuteFold => {
+ const silenced = new Set()
+ const ambiguous: Array<{ readonly value: string; readonly count: number }> = []
+ for (const value of config.muted) {
+ const resolved = resolveTerminal(value, seen)
+ if (resolved.kind === "one") silenced.add(resolved.id)
+ else if (resolved.kind === "many") ambiguous.push({ value, count: resolved.count })
+ }
+ return { silenced, ambiguous }
+ }
+
+ /** The ambiguous values of a mute fold, said ONCE per value rather than
+ * per fold — an ambiguous mute silences nobody (the events keep
+ * coming), and the only door the owner has to know is this line — the
+ * CLI's refusal, made a sentence. */
+ const saidAmbiguous = new Set()
+ const sayAmbiguousMutes = (fold: MuteFold): void => {
+ for (const { value, count } of fold.ambiguous) {
+ if (!saidAmbiguous.has(value)) {
+ saidAmbiguous.add(value)
+ sink.say(
+ `kolu: the mute \`${value}\` names ${count} terminals — write more of the id.`,
+ )
+ }
+ }
+ }
+
+ /** One event onto the ring, evicting the oldest while the cap is full. */
+ const push = (event: KoluEvent): void => {
+ ring.set(event.id, event)
+ sink.emit(event)
+ while (ring.size > WATCH_RING) {
+ const oldest = ring.keys().next().value
+ if (oldest === undefined) break
+ ring.delete(oldest)
+ sink.evict(oldest)
+ }
+ }
+
+ /** One hold's event row, stamped at fire time — see `Hold.row` for why
+ * the facts are read NOW rather than held from the first observation. */
+ const emitHold = (hold: Hold, kind: "transition" | "nag"): void => {
+ const at = options.now()
+ hold.lastEmittedAt = at
+ seq += 1
+ push({
+ id: `ev-${seq}`,
+ kind,
+ at: new Date(at).toISOString(),
+ row: {
+ terminal: hold.id,
+ state: hold.state,
+ agentState: hold.agentState,
+ // The LIVE flags stamped out: an event that went on for hours
+ // would otherwise flash motion for a moment that passed long ago.
+ // Variant, glyph, asking, ink and the label are the whole of what
+ // the past is allowed to say.
+ pip: { ...hold.row.pip, active: false, bytesLive: false },
+ bucket: hold.row.bucket,
+ label: hold.row.label,
+ labelColor: hold.row.labelColor,
+ repo: hold.row.repo,
+ since: new Date(hold.since).toISOString(),
+ },
+ })
+ }
+
+ /** The heart: attention events are not its to say — one stamp per
+ * beat, onto the sink's `beat`, with the cadence eaten beside it. No
+ * row, no terminal, no nag — the watcher's whole answer to the
+ * door's quiet face. */
+ const pulse = (): void => {
+ sink.beat(new Date(options.now()).toISOString(), config.heartbeatMs)
+ }
+
+ /** Re-arm the heartbeat under the config in force — called only when
+ * `heartbeat` MOVED (`reconfigure`'s echo-guard's job), so the eat is
+ * a real knob move and never a vault keystroke: the interval is
+ * re-called cleanly rather than inheriting a staggered one, and one
+ * beat says the eat at once: the stamp carries `everyMs` with it, so a
+ * LENGTHENED cadence (the edit to 120s in the evidence's own run)
+ * would leave the door remembering the shorter one's margin in the
+ * meantime without this.
+ */
+ const rearmHeartbeat = (): void => {
+ if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer)
+ pulse()
+ heartbeatTimer = setInterval(pulse, config.heartbeatMs)
+ }
+
+ /** Arm one hold's nag timer under the config in force, anchored at the
+ * LAST EMISSION: the interval is measured from the line that already
+ * ran, so a re-arm in a knob edit (or on resume, through a flap) cannot
+ * push a nag out by another full window. */
+ const armNag = (hold: Hold): void => {
+ const remaining = hold.lastEmittedAt + config.nagMs - options.now()
+ hold.nagTimer = setTimeout(() => fireNag(hold), Math.max(0, remaining))
+ }
+
+ /** Re-arm ONE hold's timers under the knob set now in force — the one fold
+ * of the pacing that `reconfigure` (an interval moved) and resume (a
+ * fleet came back) both walk. */
+ const rearmHold = (hold: Hold): void => {
+ if (hold.holdTimer !== undefined) clearTimeout(hold.holdTimer)
+ if (hold.nagTimer !== undefined) clearTimeout(hold.nagTimer)
+ hold.holdTimer = undefined
+ hold.nagTimer = undefined
+ if (hold.fired) {
+ armNag(hold)
+ } else {
+ const remaining = hold.since + config.heldForMs - options.now()
+ hold.holdTimer = setTimeout(() => fireTransition(hold), Math.max(0, remaining))
+ }
+ }
+
+ // ARM IT AT CONSTRUCTION: `rearmHeartbeat` pulses once when it eats
+ // the arm, so the boot's answer needs no second call of its own.
+ rearmHeartbeat()
+
+ /** Cancel ONE hold's timers and forget it. Idempotent, and the ONLY
+ * door out of both maps the indices will say it lives in. */
+ const releaseHold = (hold: Hold): void => {
+ if (hold.holdTimer !== undefined) clearTimeout(hold.holdTimer)
+ if (hold.nagTimer !== undefined) clearTimeout(hold.nagTimer)
+ hold.holdTimer = undefined
+ hold.nagTimer = undefined
+ if (holds.get(hold.id) === hold) holds.delete(hold.id)
+ if (suspended.get(hold.id) === hold) suspended.delete(hold.id)
+ }
+
+ /** What a fired timer must first ask: is this hold still live? A nag
+ * the microsecond after a `releaseHold` is the bug this single line
+ * exists against. */
+ const liveHold = (hold: Hold): boolean => holds.get(hold.id) === hold
+
+ const fireTransition = (hold: Hold): void => {
+ if (!liveHold(hold)) return
+ hold.fired = true
+ hold.holdTimer = undefined
+ emitHold(hold, "transition")
+ armNag(hold)
+ }
+
+ const fireNag = (hold: Hold): void => {
+ if (!liveHold(hold)) return
+ hold.nagTimer = undefined
+ emitHold(hold, "nag")
+ armNag(hold)
+ }
+
+ return {
+ observe: (id, row) => {
+ seen.add(id)
+ // The prefix table moves when the FLEET moves, not only when the file
+ // does — a terminal ARRIVING can be what makes one prefix ambiguous —
+ // so the mute fold is taken once per observation, and both readers
+ // read the one answer. `sayAmbiguousMutes` holds the once-per-VALUE
+ // half itself.
+ const fold = foldMutes()
+ sayAmbiguousMutes(fold)
+ const state = heldStateOf(row)
+ // FIRST: an id whose fleet fell out from under it is a resume, not a
+ // reopen — the daemon's own `since` does not move on a reconnect, and
+ // neither does ours. Same bucket, still un-muted: the hold returns
+ // with the timer re-armed off its own clock. A different bucket is
+ // what `observe` always takes it for: one hold closes and another
+ // opens, herein falling through to it as usual.
+ const suspendedHold = suspended.get(id)
+ if (suspendedHold !== undefined) {
+ suspended.delete(id)
+ if (state !== null && state.bucket === suspendedHold.state && !fold.silenced.has(id)) {
+ suspendedHold.row = row
+ holds.set(id, suspendedHold)
+ rearmHold(suspendedHold)
+ return
+ }
+ releaseHold(suspendedHold)
+ }
+ const previous = holds.get(id)
+ if (state === null || fold.silenced.has(id)) {
+ if (previous !== undefined) releaseHold(previous)
+ return
+ }
+ if (previous !== undefined && previous.state === state.bucket) {
+ // Still the same hold — refresh the facts and let the timers run.
+ previous.row = row
+ return
+ }
+ // A DIFFERENT held bucket is a new hold, not a continuation:
+ // `waiting` → `awaiting` is the terminal asking either way, but the
+ // states are spelled two ways for a reason, and the event says
+ // which one.
+ if (previous !== undefined) releaseHold(previous)
+ const hold: Hold = {
+ id,
+ state: state.bucket,
+ agentState: state.spelled,
+ since: options.now(),
+ lastEmittedAt: options.now(),
+ row,
+ fired: false,
+ holdTimer: undefined,
+ nagTimer: undefined,
+ }
+ holds.set(id, hold)
+ hold.holdTimer = setTimeout(() => fireTransition(hold), config.heldForMs)
+ },
+ remove: (id) => {
+ seen.delete(id)
+ const hold = holds.get(id)
+ if (hold !== undefined) releaseHold(hold)
+ const gone = suspended.get(id)
+ if (gone !== undefined) {
+ suspended.delete(id)
+ releaseHold(gone)
+ }
+ // A fleet move is a mute-fold move, on LEAVING as much as on
+ // arriving: a prefix that was ambiguous two rows back might be an
+ // address now, and the newly-silenced hold should not nag its lone
+ // remaining row before the next upsert.
+ const fold = foldMutes()
+ for (const singing of [...holds.values()]) {
+ if (fold.silenced.has(singing.id)) releaseHold(singing)
+ }
+ sayAmbiguousMutes(fold)
+ },
+ suspend: (id) => {
+ const hold = holds.get(id)
+ if (hold === undefined) return
+ holds.delete(id)
+ if (hold.holdTimer !== undefined) clearTimeout(hold.holdTimer)
+ if (hold.nagTimer !== undefined) clearTimeout(hold.nagTimer)
+ hold.holdTimer = undefined
+ hold.nagTimer = undefined
+ suspended.set(id, hold)
+ },
+ reconfigure: (next) => {
+ // WHICH KNOBS MOVED, asked BEFORE the swap — load-bearing in exactly
+ // this shape: `revision` calls this on every keystroke that lands in
+ // the vault, and a pacing reset per keystroke under a busy vault is
+ // a nag (and a heartbeat) that never fires. A moved KNOB re-paces;
+ // a keystroke does not. The TWO interval guards stay apart: `held-for`
+ // is an arm altogether beside `nag` — each moves its OWN holds.
+ const heldForMoved = next.heldForMs !== config.heldForMs
+ const nagMoved = next.nagMs !== config.nagMs
+ const heartbeatMoved = next.heartbeatMs !== config.heartbeatMs
+ config = next
+ // A terminal muted under the NEW list loses its hold NOW — the
+ // event it was about to fire is exactly the event the vault just
+ // said nobody wants. Suspended holds are released through the same
+ // door: the flap is not an excuse from the list.
+ const fold = foldMutes()
+ for (const hold of [...holds.values(), ...suspended.values()]) {
+ if (fold.silenced.has(hold.id)) releaseHold(hold)
+ }
+ sayAmbiguousMutes(fold)
+ if (heartbeatMoved) rearmHeartbeat()
+ if (!heldForMoved && !nagMoved) return
+ // Re-pace, ONE pass, through the one re-arm fold: each hold asks the
+ // interval its own timers run on, and only a moved knob wakes it.
+ // Two inherited semantics worth naming: a debounce measures from
+ // `since` — the hold did not move — so a LOWERED `held-for` fires at
+ // once and a RAISED one sits out the difference; a nag measures from
+ // `lastEmittedAt` — so a knob edit can never push the next one out
+ // another full window, no matter how the file is typed.
+ for (const hold of holds.values()) {
+ if (hold.fired ? nagMoved : heldForMoved) rearmHold(hold)
+ }
+ },
+ events: () => ring,
+ stop: () => {
+ if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer)
+ heartbeatTimer = undefined
+ for (const hold of [...holds.values(), ...suspended.values()]) releaseHold(hold)
+ },
+ }
+}
diff --git a/packages/kolu-client/src/wire/index.ts b/packages/kolu-client/src/wire/index.ts
index 7a3b36fb5..af2b59d82 100644
--- a/packages/kolu-client/src/wire/index.ts
+++ b/packages/kolu-client/src/wire/index.ts
@@ -1,5 +1,5 @@
/**
- * KOLU'S SLICE OF OLAI'S WIRE — the vocabulary and the four members, here
+ * KOLU'S SLICE OF OLAI'S WIRE — the vocabulary and the members, here
* rather than in `@olai/surface`.
*
* ## Why the spec moved
@@ -47,6 +47,8 @@ import { Schema } from "effect"
import {
FleetTerminal,
KOLU_UNDIALED,
+ KOLU_UNPULSED,
+ KoluEvent,
KoluLink,
sameKolu,
Snapshot,
@@ -54,6 +56,7 @@ import {
SnapshotRequest,
TerminalAttach,
TerminalFrame,
+ WatchPulse,
} from "./kolu.ts"
/**
@@ -88,6 +91,22 @@ export const koluMembers = {
verbs: ["get"],
equals: sameKolu,
},
+ /**
+ * THE PULSE — the watcher's liveness, as a timestamp (see
+ * { ./kolu.ts}'s `WatchPulse`): WHEN the heart last beat, and HOW LONG
+ * the cadence is allowed to run once it is overdue. The door reads
+ * `at` off this one stamp; the pill's answer to "has it gone quiet" is
+ * arithmetic it can do itself (`everyMs` is carried beside the stamp,
+ * so the browser never guesses the vault's cadence name). Two values,
+ * one stamp per beat.
+ *
+ * Wire-read-only: what is beating is not something a browser could set.
+ */
+ pulse: {
+ schema: Schema.NullOr(WatchPulse),
+ default: KOLU_UNPULSED,
+ verbs: ["get"],
+ },
},
collections: {
/**
@@ -120,6 +139,35 @@ export const koluMembers = {
schema: FleetTerminal,
verbs: ["keys", "get", "deltas"],
},
+ /**
+ * THE RECENT EVENTS — what the server-side watcher computed, as a ring of
+ * the last ~200.
+ *
+ * The knob set these events came from is `_olai/Kolu.olai` in the served
+ * directory (the vault owner's, read live); what olai owns is the reading
+ * and the math — the mirror's rows folded into transition/hold/nag. The
+ * ring is ATTENTION ONLY — liveness is the `pulse` cell above, not a row
+ * here. See `@olai/kolu-client`'s `watch.ts` for the semantics and
+ * {@link ./kolu.ts}'s `KoluEvent` for the shape.
+ *
+ * A COLLECTION rather than a stream: the ring is a standing thing every
+ * subscriber wants at once — a snapshot of however much of it survives,
+ * then deltas — where a stream would ask each tab to assemble its own
+ * copy. `deltas`, for `fleet`'s reason one name over: an entry is a dozen
+ * short fields, the set is a couple of hundred on a busy day, and a page
+ * draws the whole recent ring at once.
+ *
+ * Read-only on the wire, twice over: a browser neither mints an event nor
+ * takes one back. Muting a terminal is an EDIT to the vault's config
+ * outline, which reaches this collection through the watcher and no other
+ * way.
+ */
+ events: {
+ /** `ev-` — see {@link KoluEvent}. */
+ keySchema: Schema.String,
+ schema: KoluEvent,
+ verbs: ["keys", "get", "deltas"],
+ },
},
streams: {
/**
diff --git a/packages/kolu-client/src/wire/kolu.ts b/packages/kolu-client/src/wire/kolu.ts
index e2c3a43d9..cf9604915 100644
--- a/packages/kolu-client/src/wire/kolu.ts
+++ b/packages/kolu-client/src/wire/kolu.ts
@@ -2,8 +2,10 @@
* @olai/surface's KOLU HALF — what a reader is told about the padi link and
* the fleet behind it.
*
- * Three members, declared here because they are one subject and `./index.ts`
- * is already long: the LINK (a cell), the FLEET (a collection), and the
+ * Four members held here, in one file, because they are ONE subject and
+ * `./index.ts` is already long: the LINK (a cell), the FLEET (a collection),
+ * the EVENTS (a second collection — the watcher's ring: not a snap of any
+ * fleet row, a line-keeping of the MOMENTS that demanded one), and the
* SNAPSHOT (a procedure). They are the wire half of
* `https://github.com/juspay/oss.olai/blob/main/olai/brainstorming/orchestrator.md`'s first row — padi mirrored into olai —
* and phase 1a's whole visible payoff is one property chip drawing them.
@@ -139,6 +141,33 @@ export const KOLU_UNDIALED: KoluLink = {
since: "",
}
+/**
+ * WHERE THE WATCHER'S HEART IS, at last count — the pill's only liveness
+ * fact.
+ *
+ * The AT is the beat's own timestamp — ISO, like every `KoluEvent` row's.
+ * The `everyMs` is the CADENCE: the vault's `heartbeat:` knob, in
+ * milliseconds, as the watcher is currently configured. The pill doesn't
+ * know the knob, so the cadence rides beside the stamp: the fold's answer
+ * to "has the pulse gone quiet" is arithmetic the header can do itself
+ * without a second reader (the fold — `padiSaid`'s — eats
+ * `age > everyMs * 2`).
+ */
+export const WatchPulse = Schema.Struct({
+ /** The beat — and the entry's only wall-clock truth about liveness. */
+ at: Schema.String,
+ /** The vault's `heartbeat:` knob at this beat's reading, in
+ * milliseconds — the cadence the stamp answers to. */
+ everyMs: Schema.Number,
+})
+
+export type WatchPulse = typeof WatchPulse.Type
+
+/** No beat has been read yet — the pill before the watcher exposed its
+ * cadence. The door answers `kolu` on its own, the quiet face of the
+ * fold: no recency value answers, so the header can't compute an age. */
+export const KOLU_UNPULSED: WatchPulse | null = null
+
/** Two readings that say the same thing about the link — the member's `equals`,
* so a re-dial that found exactly what it found last time publishes nothing
* and `since` does not creep. Everything but `since` is compared; `since` is
@@ -244,6 +273,29 @@ export const UNOWNED: FleetOwner = { kind: "unowned" }
* would be a second spelling of a shape designed to travel, which is the drift
* the paragraph above exists to prevent, reached from the other side.
*/
+/**
+ * THE BOUND PIP, as the wire carries it — `bindStatePip`'s answer, whole.
+ *
+ * Factored out of {@link FleetTerminal} because a SECOND carrier wants the same
+ * bag: a watcher's frozen event row ({@link KoluEvent}) holds the pip exactly
+ * as it was the moment the event fired, spelled the same way it is spelled when
+ * live. Two spellings of the bag would be two answers to what a pip is, free to
+ * drift — the rule the bag's own block below argues is why the audit did not
+ * tolerate two `FleetTerminal`s.
+ */
+export const FleetPip = Schema.Struct({
+ variant: Schema.String,
+ glyph: Schema.String,
+ active: Schema.Boolean,
+ asking: Schema.Boolean,
+ bytesLive: Schema.Boolean,
+ hasAgent: Schema.Boolean,
+ sleeping: Schema.Boolean,
+ alert: Schema.Boolean,
+ alertLabel: Schema.String,
+})
+export type FleetPip = typeof FleetPip.Type
+
export const FleetTerminal = Schema.Struct({
/** padi's terminal id — the same string a `terminal` property holds, which is
* what makes the chip a resolution rather than a search. */
@@ -286,17 +338,7 @@ export const FleetTerminal = Schema.Struct({
* olai had been transporting a field nothing read, which `fleet.ts`'s own law
* already forbade.
*/
- pip: Schema.Struct({
- variant: Schema.String,
- glyph: Schema.String,
- active: Schema.Boolean,
- asking: Schema.Boolean,
- bytesLive: Schema.Boolean,
- hasAgent: Schema.Boolean,
- sleeping: Schema.Boolean,
- alert: Schema.Boolean,
- alertLabel: Schema.String,
- }),
+ pip: FleetPip,
/** The row's ORDER/paint bucket, verbatim — narrowed browser-side by
* `isDockRowBucket`. */
bucket: Schema.String,
@@ -403,6 +445,100 @@ export class SnapshotRefused extends Schema.TaggedError(
}
}
+// ── The events feed ───────────────────────────────────────────────────────
+
+/**
+ * THE THREE THINGS THE WATCHER CAN SAY — the kinds a `KoluEvent` carries.
+ *
+ * The first two spell the same word padi's own state watch does
+ * (`@kolu/padi-client`'s `PadiStateEvent`), and that is deliberate rather than
+ * theft: olai's watcher computes over the mirror it already holds the events
+ * the orchestrator today gets from a hand-armed `kolu watch`, and the soak
+ * that proves the ladder runs the two in parallel — one jargon, two channels,
+ * and nothing for a reader to translate.
+ *
+ * - `transition` — a terminal entered `awaiting` or `waiting` and HELD it
+ * for `held-for`. The debounce says this: a turn that ends and is handed
+ * more work inside the window was never said at all.
+ * - `nag` — it is STILL holding, one `nag` interval after it was last said.
+ * The level-trigger: an ignored terminal comes back instead of vanishing
+ * after one line.
+ * - `heartbeat` — the watcher is alive and watching. It names NO terminal:
+ * a reader who has seen nothing for half an hour needs to be able to tell
+ * "nothing matched" from "nothing is running". The kind survives as the
+ * spelling; the RING eats attention events only — boot and liveness sit
+ * on the `pulse` cell instead ({ ./index.ts}'s `pulse` member).
+ */
+export const KOLU_EVENT_KINDS = ["transition", "nag", "heartbeat"] as const
+
+/**
+ * ONE WATCHER EVENT — what the recent-events feed is made of.
+ *
+ * The kinds are padi's spellings (see {@link KOLU_EVENT_KINDS}); the shape is
+ * olai's own, and deliberately NOT padi's event: where padi hands the watcher
+ * a terminal id and lets the recipient read the screen for itself, THIS event
+ * freezes enough of the FleetTerminal row at the instant it fired to be drawn
+ * on its own. A log row is a fact at a time: the event that said a terminal
+ * held `awaiting` for three hours still says it after the terminal is back at
+ * work, and its row does not repaint to the way the fleet now sees it. That is
+ * also what frees the UI from the fleet a dead terminal has left.
+ *
+ * `row` is `null` on `heartbeat` and ONLY then: a heartbeat names no terminal
+ * because nobody is blocked and nothing is held.
+ */
+export const KoluEvent = Schema.Struct({
+ /** ONE EMISSION's own id. `ev-` on a per-server monotonic counter, which
+ * is also the collection's key: a fresh subscriber is snapshotted from the
+ * ring with it, and nags and transitions on one terminal are _rows_ not
+ * patches. */
+ id: Schema.String,
+ kind: Schema.Literals(KOLU_EVENT_KINDS),
+ /** WHEN it fired, ISO. */
+ at: Schema.String,
+ /** WHOSE event it is — the row frozen as it was the moment it fired, so that
+ * a feed drawn on a fleet the terminal has since left (or after a link flap
+ * that emptied the fleet) still shows what the watcher saw. `null` on
+ * `heartbeat`. */
+ row: Schema.NullOr(Schema.Struct({
+ terminal: Schema.String,
+ /** The held bucket, as kolu spells it — `awaiting` or `waiting`
+ * (`@kolu/terminal-vocab`'s `agentBucket`). What the watcher matched on and
+ * what the feed's wording keys off. */
+ state: Schema.String,
+ /** The agent state VERBATIM as the row spelled it at the time
+ * (`awaiting_user`, `waiting`). The subline's words come from this. */
+ agentState: Schema.String,
+ /** The bound pip, frozen, with one edit: `active` and `bytesLive` are
+ * stamped `false`, because they are LIVE facts and a log row must not
+ * flash motion for something that might have been quiet for hours. The
+ * rest is verbatim from the same `FleetPip` bag the fleet carries — one
+ * spelling, two carriers. */
+ pip: FleetPip,
+ /** The order/paint bucket, frozen. */
+ bucket: Schema.String,
+ /** The annotation line (intent, else branch) and its ink, frozen. */
+ label: Schema.String,
+ labelColor: Schema.String,
+ /** THE REPOSITORY, frozen — the row's only DISAMBIGUATOR: three
+ * terminals' labels can all read `master` (the human's drawer
+ * screenshot, on the first real free-for-all watch). The fold that
+ * names it is `repo·label` — kolu's own `repo·branch` spelling the
+ * Dock's grouping answers — and a terminal with no repo carries a
+ * `null`, so the row shows what the Dock's own group shows in that
+ * case: the label alone. */
+ repo: Schema.NullOr(Schema.String),
+ /** WHEN this server first saw the terminal holding this state, ISO. It is
+ * an OBSERVATION-lifetime clock: olai's restart re-dates every standing
+ * hold — the difference is an ordinary restart, not a lie. A LINK flap
+ * does not: the hold's clock is the watcher's own, not the record's —
+ * padi's daemon keeps `since` through a client reconnect, and this
+ * clock follows that (`@olai/kolu-client`'s `A link drop is not a
+ * closing fleet`). */
+ since: Schema.String,
+ })),
+})
+export type KoluEvent = typeof KoluEvent.Type
+
// ── The live pane ─────────────────────────────────────────────────────────
/**
diff --git a/packages/kolu-ui/README.md b/packages/kolu-ui/README.md
new file mode 100644
index 000000000..fb2c9e4f5
--- /dev/null
+++ b/packages/kolu-ui/README.md
@@ -0,0 +1,18 @@
+# @olai/kolu-ui — the browser's kolu half
+
+Everything a page draws about kolu, in one package: the row on a `terminal` property, the live pane the row opens, the fleet a tab holds **once**, the events a header press shows — and the words the padi readout says. What the app reaches it through is `KoluUi`, one mount taking the composed client and a `now`: which surface members exist, what two named verbs the pane calls — the whole of what would otherwise be spelled in olai's composition root. `Block` registration, the pill's chrome and olai's preference cadence are web's.
+
+- **`src/props/KoluUi.tsx`** — the socket: `KoluClient`, the structural pin, and the five members of the composition (the cell, the two collections — fleet and the watcher's events — the screen read, the live pane).
+- **`src/props/fleet.tsx` / `src/props/held.ts`** — the fleet the tab holds once: one subscription per tab however many chips are down the page, one map, one counter; and the accumulator the events ring reuses (a frame the events ring sees is the same move as one the fleet sees, since the server's ring is one Store).
+- **`src/props/EventsFeed.tsx` / `src/padi/events.ts`** — the drawer the Padi pill opens: what recently wanted attention, the folds over kolu's own, the words spelled **once, in `padi/events.ts`, and nowhere else** — past tense means the past, even for a still-held state. ATTENTION only: heartbeats never reach this drawer — liveness lives on the pill (the pulse cell folded in `padi/said.ts`).
+- **`src/props/TerminalDoor.tsx` / `src/props/LivePane.tsx`** — the block and the window, wearing web's own chrome.
+- **`src/padi/said.ts`** — every word a status reads has one home; the header and the chat's probe speak this three-state sentence.
+- **`src/testids.ts`** — this half's `data-testid`s. Web keeps its own side of the bar.
+
+## Why it is a package
+
+The sixth Löwy sitting's ruling in one manifest: the human asked that the NON-kolu packages of Olai not hold kolu implementation, and a directory is a wall one import pore can cross. Two more packages know about kolu the way `git` knows about `@olai/git` — `server` composes, `web` chrome — and the rest never does. `scripts/check-kolu-deps.sh`'s fourth assertion is the wall's *fourth* leg: the hydrated `@kolu/*` imports also only compile inside this package and its sibling, `kolu-client`.
+
+## The events
+
+The feed shows **the past tense of the fleet** — it is a LOG, not a live reading: the server's watch draws a frozen pip, label and hold at fire time, and this package draws exactly that. Two folds own the words (`padi/events.ts`, `fleet.tsx`), THE ONE VOCAB — WHAT an event IS — is [`@olai/kolu-client`](../kolu-client/README.md)'s `KoluEvent`, and the pill's own register — the beat's — is `padi/said.ts`'s `beatOf` (a `fresh | quiet | none` fold off the `pulse` cell). The watch reads `_olai/Kolu.olai` through the server's walk ([server's README](../server/README.md)'s `koluConfig.ts`).
diff --git a/packages/kolu-ui/package.json b/packages/kolu-ui/package.json
index deb8dae79..084c4b095 100644
--- a/packages/kolu-ui/package.json
+++ b/packages/kolu-ui/package.json
@@ -15,7 +15,7 @@
},
"//": "THE BROWSER'S KOLU HALF — see src/index.ts, which argues the socket. The sixth sitting's ruling in one manifest: the human asked that the non-kolu packages not contain kolu implementation, and a package wall is what makes that physics rather than discipline — a directory wall is broken by an import, this one cannot be.",
"//exports": "Three doors and no more. The root is the socket (KoluUi + TerminalBlock + padiSaid); ./testids is NAMES ONLY, so a scenario can assert without pulling a component into the suite (packages/tests' import sweep pins that); ./all.css carries the row's sheet, xterm's, and this directory's own @source, so a consumer imports one line and knows nothing about where any of it landed.",
- "//dependencies": "solid-js because these are components; @olai/surface for the composed wire vocabulary the row is filled from. The @kolu/* this package imports — solid-dockrow (the row and its folds), terminal-vocab (grid equality, DASH), terminal-themes (the pane's palette and font), and solid-statepip in the geometry harness only — are hydrated as raw TypeScript from the Nix store rather than installed from bun.lock, so they are declared once for the whole tree in the root package.json; scripts/check-kolu-deps.sh keeps those versions honest and its fourth assertion is what confines them to this package and @olai/kolu-client. @xterm/* are ordinary npm and are listed here because they moved OFF @olai/web's manifest: the emulator is this appliance's, and web has no business declaring a terminal renderer.",
+ "//dependencies": "solid-js because these are components; @olai/surface for the composed wire vocabulary the row is filled from. The @kolu/* this package imports — solid-dockrow (the row and its folds), terminal-vocab (grid equality, DASH), terminal-themes (the pane's palette and font), and solid-statepip in BOTH places: the geometry harness, and the events feed, whose frozen pip is the row the watcher made a memory of — are hydrated as raw TypeScript from the Nix store rather than installed from bun.lock, so they are declared once for the whole tree in the root package.json; scripts/check-kolu-deps.sh keeps those versions honest and its fourth assertion is what confines them to this package and @olai/kolu-client. @xterm/* are ordinary npm and are listed here because they moved OFF @olai/web's manifest: the emulator is this appliance's, and web has no business declaring a terminal renderer.",
"dependencies": {
"@olai/surface": "workspace:*",
"@xterm/addon-fit": "^0.11.0",
diff --git a/packages/kolu-ui/src/index.ts b/packages/kolu-ui/src/index.ts
index c46f60c17..a6f77c24c 100644
--- a/packages/kolu-ui/src/index.ts
+++ b/packages/kolu-ui/src/index.ts
@@ -45,6 +45,12 @@ export { TerminalBlock } from "./props/TerminalDoor.tsx"
export { padiSaid } from "./padi/said.ts"
export type { Said } from "./padi/said.ts"
+/** THE FEED — what recently wanted attention, as a log. The Padi pill's
+ * press opens it: the panel chrome is the app's, and this is the content. */
+export { EventsFeed, EventRow } from "./props/EventsFeed.tsx"
+export { eventLine } from "./padi/events.ts"
+export type { EventLine } from "./padi/events.ts"
+
/** The fleet context, for the chrome readout that reads the link beside the
* pill — the one thing outside this package that needs the fleet itself. */
export { useFleet } from "./props/fleet.tsx"
diff --git a/packages/kolu-ui/src/padi/events.test.ts b/packages/kolu-ui/src/padi/events.test.ts
new file mode 100644
index 000000000..74aa8c9b4
--- /dev/null
+++ b/packages/kolu-ui/src/padi/events.test.ts
@@ -0,0 +1,125 @@
+/**
+ * WHAT ONE EVENT LINE SAYS — the fold as the words.
+ *
+ * The tests name the one module's three shapes and one vocabulary discipline:
+ *
+ * - the three sentences of `transition`, `nag` and `heartbeat`, each
+ * spelled in words that CANNOT drift from kolu's own; and
+ * - the durations, which are `recencyText`'s phrases rather than a second
+ * calendar: the AGE is the `ago` arm's "4m ago", and the HELD FOR is the
+ * wait-chip's "38m".
+ */
+
+import { expect, test } from "bun:test"
+
+import type { KoluEvent } from "@olai/surface"
+
+import { eventLine } from "./events.ts"
+
+/** One frozen draw, structurally — what `./watch.ts` freezes a row into.
+ * `holdMs` is the span the hold had at the fire time, and the two are
+ * distance-paraphrased onto the one clock the tests stand on. */
+const T0 = 1_700_000_000_000
+
+const heldEvent = (
+ kind: "transition" | "nag",
+ opts: {
+ state?: string
+ agentState?: string | null
+ heldMs?: number
+ ageMs?: number
+ label?: string
+ repo?: string | null
+ } = {},
+): KoluEvent => {
+ const ageMs = opts.ageMs ?? 4 * 60_000
+ const heldMs = opts.heldMs ?? 38 * 60_000
+ return {
+ id: "ev-1",
+ kind,
+ at: new Date(T0 - ageMs).toISOString(),
+ row: {
+ terminal: "dffa1c90",
+ state: opts.state ?? "waiting",
+ agentState: opts.agentState ?? (opts.state === "awaiting" ? "awaiting_user" : "waiting"),
+ pip: {
+ variant: "waiting",
+ glyph: "claude-code",
+ active: false,
+ asking: true,
+ bytesLive: false,
+ hasAgent: true,
+ sleeping: false,
+ alert: false,
+ alertLabel: "",
+ },
+ bucket: "awaiting",
+ label: opts.label ?? "panel-step",
+ labelColor: "#a2c",
+ repo: opts.repo === undefined ? "olai" : opts.repo,
+ since: new Date(T0 - ageMs - heldMs).toISOString(),
+ },
+ }
+}
+
+test("a `nag` is a hold made louder, not one made new", () => {
+ const line = eventLine(heldEvent("nag"), T0)
+ expect(line.words).toBe("still waiting for input for 38m")
+ expect(line.asking).toBe(true)
+})
+
+test("a `transition` says has been", () => {
+ const line = eventLine(heldEvent("transition"), T0)
+ expect(line.label).toBe("panel-step")
+ expect(line.who).toBe("olai·panel-step")
+ expect(line.words).toBe("has been waiting for input for 38m")
+ // The AGE is kolu's own ago-phrase.
+ expect(line.age).toBe("4m ago")
+})
+
+test("`awaiting_user` lands differently than plain `waiting`", () => {
+ const line = eventLine(heldEvent("transition", {
+ state: "awaiting",
+ agentState: "awaiting_user",
+ }), T0)
+ expect(line.words).toBe("has been awaiting input for 38m")
+})
+
+test("the `who` is the Dock's own spelling — `repo·label`, or the label alone", () => {
+ expect(eventLine(heldEvent("transition", { repo: "olai", label: "kolu-events-feed" }), T0).who)
+ .toBe("olai·kolu-events-feed")
+ expect(eventLine(heldEvent("transition", { repo: "nixos-config", label: "master" }), T0).who)
+ .toBe("nixos-config·master")
+ // And the no-repo case answers what the Dock's group answers: nothing
+ // extra, the plain label.
+ expect(eventLine(heldEvent("transition", { repo: null, label: "the lane the evidence rides" }), T0).who)
+ .toBe("the lane the evidence rides")
+})
+
+test("an event younger than one tick is `just now`, never a dash", () => {
+ // The tab's clock ticks per minute and an event lands ahead of it,
+ // briefly, every minute — the fold clamps at the seam rather than letting
+ // a dash reach the one reader the age phrase was made for.
+ const fresh: KoluEvent = {
+ id: "ev-9",
+ kind: "heartbeat",
+ at: new Date(T0).toISOString(),
+ row: null,
+ }
+ expect(eventLine(fresh, T0 - 50_000).age).toBe("just now")
+})
+
+test("a heartbeat is not a sentence — the drawer folds it out before this door", () => {
+ const heartbeat: KoluEvent = {
+ id: "ev-2",
+ kind: "heartbeat",
+ at: new Date(T0 - 45_000).toISOString(),
+ row: null,
+ }
+ const line = eventLine(heartbeat, T0)
+ expect(line.about).toBeNull()
+ expect(line.label).toBe("")
+ // And the fold's answer is SILENCE — never "the watcher is alive", which
+ // is the pill's register's to say.
+ expect(line.words).toBe("")
+})
diff --git a/packages/kolu-ui/src/padi/events.ts b/packages/kolu-ui/src/padi/events.ts
new file mode 100644
index 000000000..ba4095323
--- /dev/null
+++ b/packages/kolu-ui/src/padi/events.ts
@@ -0,0 +1,124 @@
+/**
+ * WHAT ONE EVENT LINE SAYS — the words, as a pure function.
+ *
+ * The feed (`../props/EventsFeed.tsx`) is a log reader: each row is one
+ * server-authored event, FROZEN — it shows what the terminal looked like the
+ * moment it fired, not what it looks like now. This module is the whole of
+ * the wording, out here rather than inside the component for
+ * `../props/terminal.ts`'s reason: three kinds with an order between their
+ * sentences is the shape that goes quietly wrong in a render function, and
+ * the suite reads the words without standing up a DOM.
+ *
+ * ## The words, in one place
+ *
+ * - `transition` — "has been waiting for input for 38m". The state is the
+ * BUCKET's word (`awaiting` / `waiting`); the duration is the hold's
+ * own clock.
+ * - `nag` — "still waiting for input for 38m". A hold that fired last
+ * interval and has not resolved; the words say nothing else has changed.
+ * - `heartbeat` — THE ARM IS GONE: the beat folds onto the pill
+ * (`./said.ts`'s `beatOf`), and a row with no terminal is skipped by
+ * the drawer's one hinge, `EventsFeed`'s filter. The kind survives
+ * on the wire's spelling for the ring before it learned the rule.
+ *
+ * ## Where the folds live, which is NOT here
+ *
+ * Two thirds of every line are kolu's own phrases and neither is re-spelled:
+ * the AGE column is `recencyText`'s `ago` arm ("4m ago", "just now"), and
+ * the HELD FOR duration is its `wait-chip` arm ("38m", "20h") — the capsule
+ * format, stolen for a log line, which is two clocks and one vocabulary all
+ * the way down (`@kolu/solid-dockrow/rowValues`). The BUCKET WORD is
+ * `stateLabels`'s, lowercased; when the wire names a state this build does
+ * not know, that fold keeps it too, and the sentence names it rather than
+ * a neighbour.
+ *
+ * The ONLY thing this module computes itself is the three sentences' shape.
+ */
+
+import { narrowAgentState, recencyText } from "@kolu/solid-dockrow/rowValues"
+
+import type { KoluEvent } from "@olai/surface"
+
+/** One line of the feed, folded. The rendering takes it whole. */
+export interface EventLine {
+ /** Whether the frozen pip was blocked on somebody when this fired — the one
+ * test every kolu surface reads for the violet emphasis. */
+ readonly asking: boolean
+ /** THE WHO line, as the drawer writes it: `repo · label` when the frozen
+ * row carries a repo (kolu's own `repo·branch` spelling, the Dock's
+ * grouping answer in one breath), the plain label where nobody ever
+ * named one — the drawer never reads `label` raw. Blank on a
+ * heartbeat. */
+ readonly who: string
+ /** The frozen label — the intent line, else the branch, blank on a
+ * heartbeat. */
+ readonly label: string
+ /** Its ink. */
+ readonly labelColor: string
+ /** The sentence half — see the header. */
+ readonly words: string
+ /** The age column — kolu's own ago-phrase, or "" under a minute's
+ * truncation, as the fold gives it. */
+ readonly age: string
+ /** Which terminal this row is ABOUT, or `null` for the heartbeat. The
+ * full id, so a row's title can say what it could not fit. */
+ readonly about: string | null
+}
+
+/** The WHO fold, separately named: `repo · label` in the Dock's own
+ * spelling, or the label alone. A lab R following another row whose word
+ * is exactly the same (`master`, twice) was never disambiguated without
+ * this. */
+export const repoPrefix = (repo: string | null, label: string): string =>
+ repo === null ? label : `${repo}·${label}`
+
+/** The state's own word, narrowed by kolu rather than spelled: the label
+ * a known state carries (`awaiting_user` reads "awaiting input"); an
+ * agent state this build's narrowing does not know falls to the BUCKET,
+ * which is already a word in the right vocabulary. Lowercased to sit
+ * inside a sentence. */
+const stateWord = (agentState: string | null, state: string): string =>
+ (narrowAgentState(agentState).label ?? state).toLowerCase()
+
+export const eventLine = (event: KoluEvent, now: number): EventLine => {
+ const row = event.row
+ const atMs = Date.parse(event.at)
+ // The tab's tick lags a fresh frame by up to its cadence — an event
+ // younger than one tick would otherwise fold to a NEGATIVE age, which
+ // `agoPhrase` reads as a dash. The fix at the SEAM rather than the fold:
+ // the fold's `now` argument clamps to the event itself, so the youngest
+ // row reads "just now" the way kolu's own phrase reads it.
+ const age = recencyText("ago", atMs, Math.max(now, atMs))
+ // A row WITH NO TERMINAL is the heartbeat the pill no longer reads —
+ // the drawer folds it out before it gets here; treat a leak as nothing
+ // to fold rather than as a lie to spell.
+ if (row === null) {
+ return {
+ asking: false,
+ who: "",
+ label: "",
+ labelColor: "",
+ words: "",
+ age,
+ about: null,
+ }
+ }
+ // THE HELD-FOR, frozen at FIRE time: a log line is a fact at a time,
+ // and the `…for 38m` is the hold AS the event said it, not as the
+ // reader's now stretches it. The wire GUARANTEES a `since` — the
+ // watcher's own observation clock — so no row folds here without one.
+ const held = recencyText("wait-chip", Date.parse(row.since), atMs)
+ const word = stateWord(row.agentState, row.state)
+ const words = event.kind === "nag"
+ ? `still ${word} for ${held}`
+ : `has been ${word} for ${held}`
+ return {
+ asking: row.pip.asking,
+ who: repoPrefix(row.repo, row.label),
+ label: row.label,
+ labelColor: row.labelColor,
+ words,
+ age,
+ about: row.terminal,
+ }
+}
diff --git a/packages/kolu-ui/src/padi/said.test.ts b/packages/kolu-ui/src/padi/said.test.ts
new file mode 100644
index 000000000..db82f6529
--- /dev/null
+++ b/packages/kolu-ui/src/padi/said.test.ts
@@ -0,0 +1,94 @@
+/**
+ * THE PILL'S REGISTER, as arithmetic rather than paint.
+ *
+ * The fold's barrel words: the link is one axis (`connected` / `absent` /
+ * `skew`, answered by `padiSaid`), and the beat is a second one — on a
+ * HEALTHY link the pulse's age pulls the register from `fresh` to `quiet`
+ * the moment the timestamp is more than two cadences old. The counting is
+ * deliberate, and this file is its house:
+ *
+ * - the ring-pulse (the `fresh` register), so the pill's inspection face
+ * says `watcher pulse 2m ago` and the coat takes NOTHING; and
+ * - the quiet-fold's threshold, `everyMs * 2` — the pill's arithmetic,
+ * spelled as `watcher quiet 47m` when it crosses; and
+ * - the pre-beat window (no pulse IN yet), so the door's first drawing
+ * is the same quiet face it wears while a fresh link simply hasn't
+ * stamped you recency yet.
+ */
+
+import { expect, test } from "bun:test"
+
+import type { KoluLink, WatchPulse } from "@olai/surface"
+
+import { beatOf, padiSaid } from "./said.ts"
+
+const T0 = 1_700_000_000_000
+
+const connectedLink: KoluLink = {
+ status: "connected",
+ socket: "/tmp/padi.sock",
+ told: true,
+ stateRoot: "",
+ surfaceVersion: "v10",
+ speaks: "v10",
+ since: new Date(T0 - 86_400).toISOString(),
+}
+
+const pulse = (ageMs: number, everyMs: number): WatchPulse => ({
+ at: new Date(T0 - ageMs).toISOString(),
+ everyMs,
+})
+
+test("the beat speaks plainly while it is fresh — the inspection sentence", () => {
+ const said = padiSaid(connectedLink, pulse(120_000, 60_000), T0)
+ expect(said.beat?.kind).toBe("fresh")
+ expect(said.beat?.said).toBe("watcher pulse 2m ago")
+ expect(said.detail).toBe("mirror connected · watcher pulse 2m ago")
+ // And the register paint is quiet: no hollow, no amber, the dot says
+ // as `connected` always did.
+ expect(said.label).toBe("kolu")
+ expect(said.dot).toBe("bg-done")
+})
+
+test("the register crosses at `everyMs * 2`, the pill's house margin", () => {
+ // The fold's line: 2 × the cadence — beyond it the watcher owes a beat.
+ // `age ≤ twice` is fresh; `age > twice` is quiet.
+ const half = pulse(60_000, 30_000) // 2× — on the line
+ const over = pulse(120_001, 30_000) // one ms past
+ expect(beatOf(half, T0).kind).toBe("fresh")
+ expect(beatOf(over, T0).kind).toBe("quiet")
+ // And the register's words move with it: the sentence the drawer spells
+ // gets the same `ago`, but the VERB folds from pulse to quiet the beat
+ // IT folded.
+ expect(beatOf(half, T0).said).toBe("watcher pulse 1m ago")
+ expect(beatOf(over, T0).said).toBe("watcher quiet 2m")
+})
+
+test("the quiet register is loud enough to name the piece that went down", () => {
+ const said = padiSaid(connectedLink, pulse(47 * 60_000, 60_000), T0)
+ expect(said.beat?.kind).toBe("quiet")
+ expect(said.beat?.said).toBe("watcher quiet 47m")
+ // The coat is the prototype's amber — the one draw outside the link's
+ // three faces this fold owns — and the words the hid-hover eats carry
+ // the same phrase.
+ expect(said.detail).toBe("mirror connected · watcher quiet 47m")
+})
+
+test("a fresh link that never stamped is not a quiet one — the pre-beat face", () => {
+ // No pulse yet — never a beat read: the register's answer is `none`,
+ // never `fresh`'s good conscience and NEVER `quiet`'s warning. The chip
+ // answers `kolu` the way it did before the vault had a watcher to weigh.
+ const said = padiSaid(connectedLink, null, T0)
+ expect(said.beat).toBeNull()
+ expect(said.detail).toContain("connected to padi")
+})
+
+test("the beat says nothing once the link has failed — the fault is the link's", () => {
+ // The link is absent: whatever age the pulse owes the door is the
+ // dead-horse read, and the fold's register answers `none` so the chip
+ // folds onto the link's own answer — `absent`'s hollow face.
+ const absent: KoluLink = { ...connectedLink, status: "absent" }
+ const said = padiSaid(absent, pulse(5_000, 30_000), T0)
+ expect(said.beat).toBeNull()
+ expect(said.dot).toBe("bg-muted")
+})
diff --git a/packages/kolu-ui/src/padi/said.ts b/packages/kolu-ui/src/padi/said.ts
index c7afb00c5..f791519dd 100644
--- a/packages/kolu-ui/src/padi/said.ts
+++ b/packages/kolu-ui/src/padi/said.ts
@@ -35,7 +35,52 @@
* ever say it.
*/
-import type { KoluLink } from "@olai/surface"
+import { recencyText } from "@kolu/solid-dockrow/rowValues"
+import type { KoluLink, WatchPulse } from "@olai/surface"
+
+/**
+ * THE BEAT'S REGISTER, in one word — the fold the pill paints itself with,
+ * so the palette below (`../readout`'s own constants) is chosen ship-side,
+ * not arbitrarily at the door.
+ *
+ * `none` is the face before the first stamp: not healthy and not loud, it
+ * is merely EMPTY, and it must wear neither green's good conscience nor
+ * amber's warning. Violet = an agent needs you; amber = something of this
+ * machine's OWN is broken (the watcher's one register, alongside
+ * `skulk`'s — NO other member's). The diamond answer on the door fold
+ * is `kind`, not a second link-state: the link is the link.
+ */
+export interface Beat {
+ readonly kind: "none" | "fresh" | "quiet"
+ /** What the chip's inspection face says: `watcher pulse 2m ago` — or its
+ * long form, `watcher quiet 47m`, when the pulse HAS gone quiet. */
+ readonly said: string | null
+}
+
+/**
+ * The fold from a pulse stamp to the beat's register.
+ *
+ * The threshold is ARITHMETIC, and one line of it: the pulse is "quiet"
+ * once the last beat is older than twice its cadence. The multiple is the
+ * pill's margin — one window for the timer's own drift and one for the
+ * wire between them, so a beat one window late does not paint amber on a
+ * normal burn — and "from the config" is the `everyMs` the stamp rides
+ * beside (`@olai/kolu-client`'s `KoluConfig`), so the pill need never guess
+ * the vault's knobs.
+ */
+export const beatOf = (pulse: WatchPulse | null | undefined, now: number): Beat => {
+ if (pulse === undefined || pulse === null) return { kind: "none", said: null }
+ const at = new Date(pulse.at).getTime()
+ const quiet = now - at > pulse.everyMs * 2
+ // `age` folds differently the two ways: a fresh beat is a recency
+ // ("watcher pulse 2m ago"); a quiet beat is A DEBT — the capsule, with
+ // no `ago`, because what the register owes is an answer ("watcher quiet
+ // 47m", the pill's loud words) and the longer phrase would blur it.
+ const said = quiet
+ ? `watcher quiet ${recencyText("wait-chip", at, now)}`
+ : `watcher pulse ${recencyText("ago", at, now)}`
+ return { kind: quiet ? "quiet" : "fresh", said }
+}
/**
* WHAT THE READOUT SAYS, as its own shape.
@@ -63,6 +108,10 @@ export interface Said {
* tip or the `title`, and the `aria-label` that keeps it from being
* hover-only. */
readonly detail: string
+ /** The pulse's register, or `null` on the `skew`/`absent` faces: the
+ * link's own fault is what those fold-safe talk about, and the beat
+ * behind it is a dead horse nobody should beat. */
+ readonly beat: Beat | null
}
/** One sentence about where olai looked, shared by the two arms that have
@@ -75,30 +124,39 @@ const lookedAt = (link: KoluLink): string =>
? `${link.socket}, which is where $PADI_SOCKET points.`
: `${link.socket}, the default rendezvous path.`
-export const padiSaid = (link: KoluLink): Said => {
+export const padiSaid = (link: KoluLink, pulse?: WatchPulse | null, now?: number): Said => {
switch (link.status) {
- case "connected":
+ case "connected": {
+ // Either side absent — the wire unfed or the clock unpassed — is the
+ // pre-beat face rather than a quiet beat read: never a lie by
+ // arithmetic on a clock nobody asked for.
+ const beat = pulse === undefined || now === undefined ? { kind: "none" as const, said: null } : beatOf(pulse, now)
return {
// The DONE green, the same one a finished task wears: this is the
// quiet face, and the outline's own vocabulary is what keeps a second
// green from meaning a second thing.
dot: "bg-done",
label: "kolu",
- detail:
- `connected to padi at ${link.socket} — the terminal rows on this page are live.`,
+ detail: beat.said === null
+ ? `connected to padi at ${link.socket} — the terminal rows on this page are live.`
+ : `mirror connected · ${beat.said}`,
+ beat: beat.kind === "none" ? null : beat,
}
+ }
case "skew":
return {
dot: "bg-alarm",
label: "kolu skew",
detail:
`padi at ${link.socket} speaks ${link.surfaceVersion ?? "?"} and this olai speaks ${link.speaks} — one of the two needs an upgrade, and until then no terminal can be read.`,
+ beat: null,
}
case "absent":
return {
dot: "bg-muted",
label: "no kolu",
detail: `no padi is answering at ${lookedAt(link)}`,
+ beat: null,
}
}
}
diff --git a/packages/kolu-ui/src/props/EventsFeed.tsx b/packages/kolu-ui/src/props/EventsFeed.tsx
new file mode 100644
index 000000000..00bc19090
--- /dev/null
+++ b/packages/kolu-ui/src/props/EventsFeed.tsx
@@ -0,0 +1,164 @@
+/**
+ * THE EVENTS FEED — what recently wanted attention, as a log.
+ *
+ * The panel half of the Padi pill's press: the SERVER watches the fleet and
+ * keeps a ring of what it saw (`@olai/kolu-client`'s `watch.ts`); this is
+ * what a reader reads. It is a LOG, not a list of current affairs, and that
+ * is the one rule every row obeys: a row is the frozen draw — its pip, its
+ * label, its hold — at the moment the event fired. A terminal that found
+ * its answer ten minutes ago still shows the ask it was; the draw moves on.
+ *
+ * What is read off the live fleet HERE is nothing. No second per-property
+ * lookup, no re-folding of agent states: the event carries its own words
+ * (`../padi/events.ts`), backed by the same narrowing the live door rows
+ * use over the wire's frozen BAG, which is vocabulary rather than state —
+ * the wire's `label` travels verbatim, the same line the live row draws.
+ *
+ * The subscription is `../fleet.tsx`'s own: the events fold is held by the
+ * one provider, with every feed a reader off the context — the one log the
+ * server keeps is the one the tab is subscribed to, which is also why an
+ * empty FEED on a live wire is a healthy false: the ring is the server's,
+ * and a fresh olai watching a watched vault answers from the one it runs.
+ *
+ * ATTENTION ONLY: the beat left this drawer. Liveness and the boot pulse
+ * live on the pill (the wire's `pulse` cell — `@olai/kolu-client`'s
+ * `watch.ts`); the rows here name a TERMINAL or they are nothing. The
+ * single hinge that keeps it so is the one fold below the `useFleet()`
+ * — and a reader served from an older ring eats heartbeats as silently
+ * skipped rows, never as odd paint.
+ */
+
+import { For, type JSX, Show } from "solid-js"
+
+import { StatePip } from "@kolu/solid-statepip"
+import { RowLabel } from "@kolu/solid-dockrow"
+import { narrowRowVocab } from "@kolu/solid-dockrow/rowValues"
+
+import type { KoluEvent } from "@olai/surface"
+
+import { eventLine } from "../padi/events.ts"
+import { padiSaid } from "../padi/said.ts"
+import { TESTID } from "../testids.ts"
+import { useFleet } from "./fleet.tsx"
+
+/**
+ * THE ONE EVENT — the frozen draw.
+ *
+ * A row's pip is ASKED rather than recomputed: `narrowRowVocab` over the
+ * frozen bag gives the StatePip everything typed, and the live flags are
+ * what they were at fire time — the event's own wire record
+ * (`@olai/kolu-client`'s `watch.ts`) has stamped `active` and `bytesLive`
+ * false already, because a two-hour-old event flashing LIVE is a lie the
+ * wire carries, not one the browser must see.
+ */
+export function EventRow(props: {
+ readonly event: KoluEvent
+ readonly now: () => number
+}): JSX.Element {
+ const line = () => eventLine(props.event, props.now())
+ const row = () => props.event.row
+ // THE NARROWING over the FROZEN bag — one fold, the same as the live
+ // door's: the vocabulary on the wire is not typed until kolu's row package
+ // says so, and the vocab the door reads is the vocab the feed reads.
+ const vocab = () =>
+ row() === null ? undefined : narrowRowVocab({ pip: row()!.pip, bucket: row()!.bucket })
+ return (
+
+ {row() === null
+ ? (
+ // THE PULSE, in one line — see the fold for the sentence of a
+ // watcher that has nothing else to say.
+
+
+ ⌁
+
+ {line().words}
+ {line().age}
+
+ )
+ : (
+
+
+
+ {(v) => (
+ // The FROZEN bag, passed through whole — the wire's own
+ // active/bytesLive are already false, so motion folds to
+ // none and the pip is a memory rather than a flare.
+
+ )}
+
+
+
+
+ {/* THE WHO — `repo·label` in the Dock's own spelling, fed
+ from the fold's `who` (the frozen `label` alone is
+ what made three repos read `master` alike). */}
+ markdown}
+ class="min-w-0 text-[0.8125rem] leading-4"
+ color={line().labelColor === ""
+ ? undefined
+ : line().labelColor}
+ />
+
+ {line().age}
+
+
+
+ {line().words}
+
+
+
+ )}
+
+ )
+}
+
+/**
+ * What the drawer says when there is nothing to say.
+ *
+ * DELIBERATELY a sentence and never a blank panel: the events the server
+ * keeps are short-lived memory, and a fresh server over a machine whose
+ * terminals are all happy answers the same whether the watch is a minute
+ * or a day old. One thing is always true and worth saying: that the
+ * absence is an ABSENCE of memory rather than of machines (so the link's
+ * own words say which, when a machine is there). The third case — the
+ * quiet-and-broken one — stopped being a thing this drawer diagnoses: the
+ * watcher itself answers on the pill's register.
+ */
+export function EventsFeed(): JSX.Element {
+ const fleet = useFleet()
+ // ATTENTION ONLY — the one knockout this drawer keeps (see the header).
+ const events = () => [...fleet.events().values()].reverse().filter((e) => e.kind !== "heartbeat")
+ return (
+
+ {padiSaid(fleet.link()).detail}
+
+ }
+ >
+
+
+ {(event) => fleet.now()} />}
+
+
+
+ )
+}
diff --git a/packages/kolu-ui/src/props/KoluUi.tsx b/packages/kolu-ui/src/props/KoluUi.tsx
index a2422f803..9a35b6a96 100644
--- a/packages/kolu-ui/src/props/KoluUi.tsx
+++ b/packages/kolu-ui/src/props/KoluUi.tsx
@@ -54,12 +54,21 @@ import type { Accessor, JSX } from "solid-js"
import { unenrolledStreamCall } from "@kolu/surface/client"
import type { Effect, Stream } from "effect"
-import type { FleetTerminal, KoluLink, Snapshot, TerminalFrame } from "@olai/surface"
+import type {
+ FleetTerminal,
+ KoluEvent,
+ KoluLink,
+ Snapshot,
+ TerminalFrame,
+ WatchPulse,
+} from "@olai/surface"
import { FleetProvider, readingScreen, watchingTerminal } from "./fleet.tsx"
/**
- * THE FOUR MEMBERS this appliance reads, structurally.
+ * THE FIVE MEMBERS this appliance reads, structurally — the cell, the two
+ * collections (`fleet` and the watcher's `events`), the screen read and
+ * the live pane.
*
* Written as the shape rather than imported as the client's type for the reason
* the header gives: a pin a suite can satisfy, and a name-change that stops
@@ -69,9 +78,16 @@ import { FleetProvider, readingScreen, watchingTerminal } from "./fleet.tsx"
export interface KoluClient {
readonly cells: {
readonly kolu: { use: () => { readonly value: Accessor } }
+ /** The pill's liveness cell — the beat the watcher last stamped, or
+ * `null` before the boot pulse is ever read. */
+ readonly pulse: { use: () => { readonly value: Accessor } }
}
readonly collections: {
readonly fleet: { use: () => { readonly fold: unknown } }
+ /** The watcher's ring — added with events, so a hand-built mock from
+ * before them must say so at the type level rather than draw a feed
+ * off nothing. */
+ readonly events: { use: () => { readonly fold: unknown } }
}
readonly procedures: {
readonly screen: {
@@ -106,7 +122,9 @@ export function KoluUi(props: {
now={props.now}
sources={{
link: props.client.cells.kolu.use().value,
+ pulse: props.client.cells.pulse.use().value,
fold: props.client.collections.fleet.use().fold as never,
+ events: props.client.collections.events.use().fold as never,
read: readingScreen(props.client.procedures.screen.text),
watch: watchingTerminal((input) =>
unenrolledStreamCall(
diff --git a/packages/kolu-ui/src/props/fleet.tsx b/packages/kolu-ui/src/props/fleet.tsx
index 2ebf4b35f..e594b78bd 100644
--- a/packages/kolu-ui/src/props/fleet.tsx
+++ b/packages/kolu-ui/src/props/fleet.tsx
@@ -71,7 +71,14 @@ import type { CollectionDelta } from "@kolu/surface/define"
import type { CollectionFold } from "@kolu/surface/solid"
import { Effect, Result, type Stream } from "effect"
-import type { FleetTerminal, KoluLink, Snapshot, TerminalFrame } from "@olai/surface"
+import type {
+ FleetTerminal,
+ KoluEvent,
+ KoluLink,
+ Snapshot,
+ TerminalFrame,
+ WatchPulse,
+} from "@olai/surface"
import { after, type Held, seeded } from "./held.ts"
import { KOLU_UNDIALED, SnapshotRefused } from "@olai/surface"
@@ -92,12 +99,21 @@ export type WatchTerminal = (
/** What a chip asks: the link, the rows it resolves against, and the one verb. */
export interface Fleet {
readonly link: Accessor
+ /** THE PILL'S LIVENESS READ — the beat, or `undefined` while the wire
+ * has not arrived, or `null` until the watcher has stamped once (see
+ * `@olai/surface`'s `pulse` cell). */
+ readonly pulse: Accessor
/** THE ROWS, as a map keyed by padi's full id — handed over whole rather
* than as a lookup, because a chip does not look its value UP: it RESOLVES
* it (`@olai/surface`'s `resolveTerminal`), and a prefix needs the key set
* to resolve against. A lookup was what drew a working terminal as retired
* for every one of the board's eight-character values. */
readonly terminals: () => ReadonlyMap
+ /** THE EVENT LOG, as the server keeps it — the watcher's ring, in fire
+ * order. Same handing rule as `terminals`: a reader reads it whole,
+ * because the feed's ordering is the ring's and a second walk is a
+ * second answer. */
+ readonly events: () => ReadonlyMap
/**
* See the header. `undefined` where there is no wire — a test that mounts a
* run, a document page drawn statically — and then the dot is a status glyph
@@ -126,14 +142,22 @@ export interface Fleet {
const FleetContext = createContext()
/**
- * The three things this provider is handed — narrowed at the parameter for the
+ * The things this provider is handed — narrowed at the parameter for the
* reason every seam in this client is (`../directory.ts`'s note): what a module
* is handed should be what it reads, and a suite can then stand one up out of a
* hand-driven frame source instead of a wire socket.
*/
export interface FleetSources {
readonly link: Accessor
+ /** The wire's `pulse` cell's value — the watcher's last stamp, so the
+ * pill can read liveness on its own cadence. */
+ readonly pulse: Accessor
readonly fold: CollectionFold
+ /** THE LOG the server is keeping — the events collection's fold, fed by the
+ * watcher (`@olai/kolu-client`'s `watch.ts`). It reads THROUGH the same
+ * machinery as the fleet's own: one subscription per tab, a map the readers
+ * re-ask rather than a store per reader. */
+ readonly events: CollectionFold
/**
* WATCH one terminal — the live pane's subscription, and `undefined` in the
* same places `read` is: a run drawn with no wire behind it.
@@ -163,6 +187,14 @@ export function FleetProvider(props: {
step: (previous, delta: CollectionDelta) =>
after(previous, delta.upserts, delta.removes),
})
+ const ring = props.sources.events>({
+ // THE RING'S OWN FOLD, same machinery as the fleet's: a snapshot replaces,
+ // an upsert appends and an eviction drops — and insertion order is the
+ // fire order the Map gives it for free.
+ init: seeded,
+ step: (previous, delta: CollectionDelta) =>
+ after(previous, delta.upserts, delta.removes),
+ })
const fleet: Fleet = {
// The SEED is `absent` and not `undefined`, which is `@olai/surface`'s own
// decision one wire back: a server that has not finished dialing has not
@@ -170,7 +202,9 @@ export function FleetProvider(props: {
// hollow chip it would draw a moment later anyway, and a fourth state would
// reach every renderer for the sake of it.
link: createMemo(() => props.sources.link() ?? KOLU_UNDIALED),
+ pulse: props.sources.pulse,
terminals: () => held()?.rows ?? NO_ROWS,
+ events: () => ring()?.rows ?? NO_EVENTS,
read: props.sources.read,
watch: props.sources.watch,
now: props.now,
@@ -192,7 +226,9 @@ export function FleetProvider(props: {
export const useFleet = (): Fleet =>
useContext(FleetContext) ?? {
link: () => KOLU_UNDIALED,
+ pulse: () => null,
terminals: () => NO_ROWS,
+ events: () => NO_EVENTS,
// A HOLLOW STILL TICKS, because a row drawn outside a provider still draws
// its recency cell and the phrase is a pure function of an instant. A
// frozen clock is the honest answer for a host that never mounted the
@@ -204,6 +240,11 @@ export const useFleet = (): Fleet =>
* provider reads before its first frame. */
const NO_ROWS: ReadonlyMap = new Map()
+/** The empty ring, minted once, for the same two readers — a providerless
+ * host's feed, and a provider's first draw. An EMPTY ring is the honest
+ * answer in both places, the one the watcher's boot beat is not. */
+const NO_EVENTS: ReadonlyMap = new Map()
+
/** A clock that does not move, for the hollow above. Minted once for the same
* reason `NO_ROWS` is. */
const NEVER_TICKS: Accessor = () => 0
diff --git a/packages/kolu-ui/src/props/held.ts b/packages/kolu-ui/src/props/held.ts
index e08057a52..f50ad4652 100644
--- a/packages/kolu-ui/src/props/held.ts
+++ b/packages/kolu-ui/src/props/held.ts
@@ -24,14 +24,17 @@ import type { FleetTerminal } from "@olai/surface"
* handing out the same map with no counter would be a value that never appears
* to change, so nothing would re-run at all.
*/
-export interface Held {
- readonly rows: Map
+export interface Held {
+ readonly rows: Map
readonly at: number
}
/** Nothing held yet — a fresh map each time, because it is about to be written
- * into. */
-export const holdingNothing = (): Held => ({ rows: new Map(), at: 0 })
+ * into. This went GENERIC the day the events ring (`./EventsFeed.tsx`'s
+ * fold) started running the same arithmetic over the watcher's log: a
+ * second accumulator's file would be the two things that can go wrong,
+ * fenced twice. */
+export const holdingNothing = (): Held => ({ rows: new Map(), at: 0 })
/**
* One frame, applied.
@@ -47,11 +50,11 @@ export const holdingNothing = (): Held => ({ rows: new Map(), at: 0 })
* and closed between two frames reaches a tab as a remove with no upsert
* before it. `Map.delete` of an absent key is the whole of the handling.
*/
-export const after = (
- held: Held,
- upserts: ReadonlyArray,
+export const after = (
+ held: Held,
+ upserts: ReadonlyArray,
removes: ReadonlyArray,
-): Held => {
+): Held => {
for (const [id, row] of upserts) held.rows.set(id, row)
for (const id of removes) held.rows.delete(id)
// The counter moves on EVERY frame, including one that changed nothing. That
@@ -71,7 +74,8 @@ export const after = (
* that was killed during a link flap would sit on the page wearing its last
* face, forever, because nothing will ever send a remove for it.
*/
-export const seeded = (entries: ReadonlyArray): Held =>
- after(holdingNothing(), entries, NO_REMOVES)
+export const seeded = (
+ entries: ReadonlyArray,
+): Held => after(holdingNothing(), entries, NO_REMOVES)
const NO_REMOVES: ReadonlyArray = []
diff --git a/packages/kolu-ui/src/testids.ts b/packages/kolu-ui/src/testids.ts
index dbd7178f9..315340a5a 100644
--- a/packages/kolu-ui/src/testids.ts
+++ b/packages/kolu-ui/src/testids.ts
@@ -41,4 +41,18 @@ export const TESTID = {
terminalScreen: "terminal-screen",
/** Read it again — kept for the snapshot face; nothing wears it today. */
terminalRefetch: "terminal-refetch",
+ /** THE EVENTS FEED — the list a Padi press opens (`./props/EventsFeed.tsx`).
+ * `data-kind` per row is the event's own word, and `data-asking` is the
+ * wire's frozen draw; the scenario asserts on THOSE rather than re-asking
+ * what a violet means. */
+ eventsFeed: "events-feed",
+ /** ONE ROW of it — the frozen draw. */
+ eventsRow: "events-row",
+ /** The sentence half under a frozen row — "has been waiting for input for
+ * 38m". The words are the server-fold's words; this door is how a test
+ * stops spelling them twice. */
+ eventsWords: "events-words",
+ /** What the feed says where the list is empty — the link's own sentence in
+ * place of a blank. */
+ eventsEmpty: "events-empty",
} as const
diff --git a/packages/server/README.md b/packages/server/README.md
index adfedcfd0..7285d9280 100644
--- a/packages/server/README.md
+++ b/packages/server/README.md
@@ -46,6 +46,7 @@ Talking to the agent is not here. It was, and it was four modules of domain insi
| `mcpClient.ts` | `/mcp`, dialled: the MCP handshake and the three POSTs a CLI actually makes, without the SDK's client (which wants an SSE stream this endpoint refuses and a session id it never issues). Half-duplex by construction, which is why the projection declares `streaming: false`. It knows the two failures the exit matrix cares about apart: a tool that ANSWERED with a refusal (exit 1, its structured detail on stderr) and a door that did not answer at all (exit 3, naming the URL as the caller spelled it) |
| `resync.ts` | `POST /olai/resync`: look at the disk now, ignoring mtime+size stamps. Loopback only. The store is entitled not to notice a same-length rewrite; this is the override the e2e harness (and an operator after a `git checkout`) needs, not a test-only door |
| `claimants.ts` | which nodes name a terminal — the vault half of a fleet row's owner. It is here rather than in [`@olai/kolu-client`](../kolu-client/README.md) because it reads outline RECORDS and would be exactly this function if the fleet came from somewhere else; keeping it this side is what lets that package name `@olai/surface` and nothing else. What crosses is four strings per claim |
+| `koluConfig.ts` | what `_olai/Kolu.olai` says — the watch's knobs (`held-for`/`nag`/`heartbeat`) and the mutes' VALUES, verbatim. The sibling of `claimants.ts`, one shelf down: outline RECORDS are a thing the package that dials padi must not learn, and what crosses is one `WatchConfig` per revision plus the malformed lines the server says — the walk HERE, the semantics' HOME in `watch.ts` |
| `manifest.ts` | what an installed olai is: name, description, colours, and the mark |
| `directory.ts` | the served directory, opened: resolved, annotated onto the log, CLAIMED, and a store over it — in the order the composition root needs and should not have to remember |
| `lock.ts` | one brain per vault: the kernel-held claim on the served directory, keyed on its realpath, the sentence a second olai gets instead of a store of its own, unlink of the lock file on a graceful stop, and the boot sweep of leftover `.lock` / `.sock` files |
diff --git a/packages/server/src/faces.ts b/packages/server/src/faces.ts
index 00c9f85b7..ac9fc119b 100644
--- a/packages/server/src/faces.ts
+++ b/packages/server/src/faces.ts
@@ -273,7 +273,14 @@ export const BROWSER: ExposeMap = {
// gesture somebody made in a tab they are looking at, and it is not a verb
// an agent gets for asking.
kolu: "resource",
+ // The watcher pulse — the pill's only liveness read. Same standing as
+ // `kolu`: a browser wants the answer, not a handle.
+ pulse: "resource",
fleet: "resource",
+ // THE EVENTS FEED, same door and the same reasoning: an agent asking what
+ // recently wanted attention has kolu's own MCP face, and olai's log rows
+ // are a reading of it it has no business re-publishing.
+ events: "resource",
// THE LIVE PANE, and the browser's alone: an agent reading a terminal has
// `screen.text` and its own kolu MCP besides, so a byte stream that only
// exists while somebody is LOOKING has no reader on that face.
diff --git a/packages/server/src/koluConfig.test.ts b/packages/server/src/koluConfig.test.ts
new file mode 100644
index 000000000..cb90c707f
--- /dev/null
+++ b/packages/server/src/koluConfig.test.ts
@@ -0,0 +1,215 @@
+/**
+ * THE `_olai/Kolu.olai` WALK — its semantics, without a revision around it.
+ *
+ * What is covered is what `./koluConfig.ts` promises:
+ *
+ * - an absent file, and a present one shaped every way a vault can shape it;
+ * - the grammar of a duration, and the malformed line saying which;
+ * - the mutes, verbatim, and what the word FIRST pins (one watch, one mutes,
+ * and no precedence that is not spelled).
+ *
+ * The fixtures are REAL outline records — JSONL, the format's own — parsed by
+ * the format package's own testlib (`@olai/format`'s `fixtures.testlib.ts`).
+ * A hand-typed record would be a note about the schema rather than the format;
+ * a broken reader would be passing anyway.
+ */
+
+import { expect, test } from "bun:test"
+
+import { DEFAULT_WATCH } from "@olai/kolu-client"
+import { nodesOf, nodesOfFiles } from "@olai/format/testlib"
+
+import { watchConfigIn } from "./koluConfig.ts"
+
+/** One JSONL line's worth of a watch, so the reader's eye is not spent on
+ * escape quotients — the ord is stable per record, since only ONE of these
+ * exists per fixture file today. */
+const rec = (title: string, fields: Record, id = `${title}-w`): string =>
+ `{"id":${JSON.stringify(id)},"ord":"a0","title":${JSON.stringify(title)}${
+ Object.keys(fields).length === 0
+ ? ""
+ : `,"custom":${JSON.stringify(fields)}`
+ }}`
+
+/** The documents. Each test hands its whole vault as JSONL the parser itself
+ * accepts. */
+const setOf = (files: Record) => nodesOfFiles(files)
+
+// ── The convention's door ─────────────────────────────────────────────────
+
+test("a set with no `kolu.olai` says the defaults, and has nothing to say", () => {
+ const reading = watchConfigIn(
+ setOf({
+ "_olai/Pins.olai":
+ `{"id":"p","ord":"a0","title":"the shelf everyone's reading"}`,
+ }),
+ )
+ expect(reading.config).toEqual(DEFAULT_WATCH)
+ expect(reading.malformed).toEqual([])
+})
+
+test("an empty set says the defaults", () => {
+ expect(watchConfigIn([]).malformed).toEqual([])
+})
+
+test("the file is by basename and case-folded, as the other conventions are", () => {
+ const reading = watchConfigIn(
+ setOf({
+ "notes/kolu.olai": rec("watch", { "held-for": "20s" }),
+ }),
+ )
+ expect(reading.config.heldForMs).toBe(20_000)
+})
+
+test("the FIRST file capable of deciding decides — shallowest, then lowest path", () => {
+ const reading = watchConfigIn(
+ setOf({
+ "_olai/a/kolu.olai": rec("watch", { "held-for": "9s" }),
+ "_olai/kolu.olai": rec("watch", { "held-for": "5s" }),
+ }),
+ )
+ // `_olai/kolu.olai` is the shallower file; the 9s in the nested one is the
+ // noise the walk is asked not to listen to.
+ expect(reading.config.heldForMs).toBe(5_000)
+})
+
+test("the FIRST `watch` node of that file decides — a second is a mistake, not precedence", () => {
+ const reading = watchConfigIn(
+ setOf({
+ "_olai/kolu.olai":
+ rec("watch", { "held-for": "17s" }, "w1") + "\n" +
+ rec("watch", { "held-for": "3s" }, "w2"),
+ }),
+ )
+ expect(reading.config.heldForMs).toBe(17_000)
+})
+
+// ── The grammar ───────────────────────────────────────────────────────────
+
+test("`held-for`, `nag` and `heartbeat` are durations, in the three units", () => {
+ const reading = watchConfigIn(
+ setOf({
+ "_olai/kolu.olai": rec("watch", {
+ "held-for": "90s",
+ "nag": "15m",
+ "heartbeat": "2h",
+ }),
+ }),
+ )
+ expect(reading.config).toEqual({
+ heldForMs: 90_000,
+ nagMs: 900_000,
+ heartbeatMs: 7_200_000,
+ muted: [],
+ })
+})
+
+test("one malformed value earns one line — the default stands, and it is SAID", () => {
+ const reading = watchConfigIn(
+ setOf({
+ "_olai/kolu.olai": rec("watch", { "held-for": "a minute or two" }),
+ }),
+ )
+ expect(reading.config.heldForMs).toBe(DEFAULT_WATCH.heldForMs)
+ // The other two knobs DEFAULT untroubled — the malformed one is the only
+ // one being said about.
+ expect(reading.config.nagMs).toBe(DEFAULT_WATCH.nagMs)
+ expect(reading.config.heartbeatMs).toBe(DEFAULT_WATCH.heartbeatMs)
+ // The sentence the server says — naming the file, the prop and the value,
+ // so the owner finds the mistake in three words of reading.
+ expect(reading.malformed.length).toBe(1)
+ expect(reading.malformed[0]).toContain("_olai/kolu.olai")
+ expect(reading.malformed[0]).toContain("held-for")
+})
+
+// ── The mutes ─────────────────────────────────────────────────────────────
+
+test("the `mutes` node's children carry the values — verbatim, prefix and id", () => {
+ const reading = watchConfigIn(
+ nodesOf(
+ `{"id":"m","ord":"a0","title":"mutes"}\n` +
+ `{"id":"c1","parent":"m","ord":"a0","title":"the side shell nobody watches","custom":{"terminal":"5c5824d5"}}\n` +
+ `{"id":"c2","parent":"m","ord":"a1","title":"just a note, never a value"}\n`,
+ "_olai/Kolu.olai",
+ ),
+ )
+ expect(reading.config.muted).toEqual(["5c5824d5"])
+})
+
+test("children of the mutes node do not trespass on elsewhere", () => {
+ const reading = watchConfigIn(
+ nodesOf(
+ `{"id":"x","ord":"a0","title":"something else","custom":{"terminal":"not-a-child"}}\n` +
+ `{"id":"m","ord":"a1","title":"mutes"}\n` +
+ `{"id":"c1","parent":"m","ord":"a2","title":"the one flagged","custom":{"terminal":"t1"}}\n`,
+ "_olai/Kolu.olai",
+ ),
+ )
+ expect(reading.config.muted).toEqual(["t1"])
+})
+
+test("an empty terminal value earns no mute", () => {
+ const reading = watchConfigIn(
+ nodesOf(
+ `{"id":"m","ord":"a0","title":"mutes"}\n` +
+ `{"id":"c1","parent":"m","ord":"a0","title":"a placeholder","custom":{"terminal":""}}\n`,
+ "_olai/Kolu.olai",
+ ),
+ )
+ expect(reading.config.muted).toEqual([])
+})
+
+test("ONE FILE decides the whole: a `watch` here and a `mutes` there do not compose", () => {
+ const reading = watchConfigIn(
+ setOf({
+ "_olai/kolu.olai": rec("watch", { "held-for": "20s" }),
+ // The mutes this one holds are the mistake the first file's occupants
+ // already answered: the convention judges one file.
+ "_olai/torn/kolu.olai":
+ `{"id":"m2","ord":"a0","title":"mutes"}\n` +
+ `{"id":"c9","parent":"m2","ord":"a1","title":"elsewhere's wilful","custom":{"terminal":"d3adbeef"}}`,
+ }),
+ )
+ expect(reading.config.heldForMs).toBe(20_000)
+ expect(reading.config.muted).toEqual([])
+})
+
+test("`0s` is a legal `held-for` — the instant report — but never a `nag` or a `heartbeat`", () => {
+ const legal = watchConfigIn(
+ setOf({ "_olai/kolu.olai": rec("watch", { "held-for": "0s" }) }),
+ )
+ expect(legal.config.heldForMs).toBe(0)
+ expect(legal.malformed).toEqual([])
+
+ const spin = watchConfigIn(
+ setOf({
+ "_olai/kolu.olai": rec("watch", { nag: "0s", heartbeat: "0s" }),
+ }),
+ )
+ expect(spin.config.nagMs).toBe(DEFAULT_WATCH.nagMs)
+ expect(spin.config.heartbeatMs).toBe(DEFAULT_WATCH.heartbeatMs)
+ expect(spin.malformed.length).toBe(2)
+ expect(spin.malformed[0]).toContain("nag")
+ expect(spin.malformed[1]).toContain("heartbeat")
+})
+
+test("past the ~24.8-day ceiling is past a timer's grammar", () => {
+ const reading = watchConfigIn(
+ setOf({
+ // 597 hours is past 2_147_483_647 ms; 596 is not, which is exactly the
+ // arithmetic the test cares about: the word must be lined and defaulted.
+ "_olai/kolu.olai": rec("watch", { heartbeat: "597h" }),
+ }),
+ )
+ expect(reading.config.heartbeatMs).toBe(DEFAULT_WATCH.heartbeatMs)
+ expect(reading.malformed.length).toBe(1)
+ expect(reading.malformed[0]).toContain("heartbeat")
+})
+
+test("`held-for` just inside the ceiling parses and is silent", () => {
+ const reading = watchConfigIn(
+ setOf({ "_olai/kolu.olai": rec("watch", { "held-for": "596h" }) }),
+ )
+ expect(reading.config.heldForMs).toBe(596 * 3_600_000)
+ expect(reading.malformed).toEqual([])
+})
diff --git a/packages/server/src/koluConfig.ts b/packages/server/src/koluConfig.ts
new file mode 100644
index 000000000..dea0454d2
--- /dev/null
+++ b/packages/server/src/koluConfig.ts
@@ -0,0 +1,193 @@
+/**
+ * THE VAULT HALF of the attention watcher — what `_olai/Kolu.olai` says.
+ *
+ * HERE rather than in `@olai/kolu-client` for `claimants.ts`'s reason,
+ * one shelf below: the config file is reading of the SET and nothing to
+ * do with kolu. The package that dials padi gets the derived intervals
+ * and the mute VALUES, four words and a number twice, and never learns
+ * that an outline record exists.
+ *
+ * This is `@olai`'s own judgement ABOUT kolu, and the structure is
+ * borrowed outright: one file by convention (`kolu.olai`, which is what
+ * `_olai/Kolu.olai` reads as, found the way every convention file is
+ * found — shallowest first, ties by path), two titled nodes under it,
+ * and everything else left alone.
+ *
+ * # Kolu
+ *
+ * - watch ← the knobs, properties:
+ * - held-for: "60s" ← debounce before a held state fires
+ * - nag: "10m" ← re-fire while a fired state holds
+ * - heartbeat: "30m" ← proof-of-life when nothing holds
+ * - mutes ← terminals that never fire, by
+ * - some note or nothing ← child, with `terminal: `
+ *
+ * The knobs are DURATIONS — `s`, `m` or `h`, the same grammar
+ * padi's `heldForMs` documents its own watch flags in — and a value that
+ * is not one is a malformed value: the default stands and the line it
+ * earns is returned, for the caller to SAY (`koluHalf`'s `revision`,
+ * which is where the "log line" the brief promises lives). The vault
+ * text is authoritative-as-written rather than repaired: olai does not
+ * edit the person's file.
+ *
+ * One file decides THE WHOLE reading, the way the convention decides a
+ * file: the shallowest file holding EITHER node, ties by path, and both
+ * halves are then read inside it — a `watch` in one file and a `mutes` in
+ * another is not composed, any more than two `watch` nodes would be: the
+ * SECOND of any of them is the owner's mistake, not a precedence
+ * question.
+ *
+ * The VALUES also answer padi's grammar, besides the vault's: `held-for`
+ * accepts `0` the way padi's own `heldForMs` does — the instant report —
+ * and `nag` and `heartbeat` do not, because a nag every 0 ms is the spin
+ * padi itself refuses. Every duration is capped at `MAX_TIMER_MS`: past
+ * that, the timer wrap fires near-instantly forever and it is the
+ * malformed half rather than a knob.
+ *
+ * WHAT IS NOT READ here is the mutes' resolution: a `terminal` value may
+ * be a full id or a prefix of one, and which fleet ids it names is a
+ * question only the watcher can answer (the roster lives in the mirror).
+ * Values pass through verbatim.
+ */
+
+import { customText, isRegular, type Located } from "@olai/format"
+import { DEFAULT_WATCH, type WatchConfig } from "@olai/kolu-client"
+import { TERMINAL_KEY } from "@olai/surface"
+
+/** The basename the convention answers to, case-folded at the caller's end. */
+const FILE_BASENAME = "kolu.olai"
+
+/** The two node titles, exact and case-sensitive, `outlineCalled`'s rule. */
+const WATCH_TITLE = "watch"
+const MUTES_TITLE = "mutes"
+
+/** One duration written as the vault writes it, `s|m|h`, in ms. */
+const DURATION = /^(\d+)(s|m|h)$/
+const UNIT_MS: Readonly> = { s: 1_000, m: 60_000, h: 3_600_000 }
+
+/** The timer ceiling padi's own schema documents: past it a `setTimeout`
+ * wraps to a near-instant fire-forever. Spelled locally: the `@kolu`
+ * product tier is confined by the repo's fence to the two kolu packages.
+ */
+const MAX_TIMER_MS = 2_147_483_647
+
+/** Each prop's floor, in padi's own reading: `held-for` is a debounce and
+ * `0` is its legal "say it the instant it holds"; `nag` and `heartbeat`
+ * are INTERVALS, whose zero cannot be spelled into a loop the timers are
+ * then asked to hold. */
+const FLOORS: Readonly> = {
+ "held-for": 0,
+ nag: 1,
+ heartbeat: 1,
+}
+
+/** The three props `watch` carries, in the order a reader sets them. */
+type WatchProp = "held-for" | "nag" | "heartbeat"
+
+/** What {@link watchConfigIn} returns — the config itself plus the malformed
+ * VALUE LINES, said by the caller so a vault typo is a sentence on the
+ * server's console exactly once per new shape, and not a silent default. */
+export interface WatchReading {
+ readonly config: WatchConfig
+ readonly malformed: ReadonlyArray
+}
+
+/**
+ * Whether a node lives in a file the convention could be — basename
+ * case-folded, like every convention file's check (`@olai/format`'s
+ * `inOlaiDir` does that one fold for `_olai/`).
+ */
+const inKoluFile = (located: Located): boolean => {
+ const parts = located.file.split("/")
+ const base = parts[parts.length - 1]
+ return base !== undefined && base.toLowerCase() === FILE_BASENAME
+}
+
+/** Convention rank: shallowest first, ties by path — `@olai/format`'s
+ * argument for why depth is a convention's read. */
+const byConvention = (a: Located, b: Located): number => {
+ const da = a.file.split("/").length
+ const db = b.file.split("/").length
+ if (da !== db) return da - db
+ return a.file.localeCompare(b.file)
+}
+
+/** One node's children, as lines' terminal values, verbatim. */
+const mutesOf = (nodes: ReadonlyArray, parent: string): ReadonlyArray => {
+ const values: Array = []
+ for (const located of nodes) {
+ if (!isRegular(located)) continue
+ if (located.node.parent !== parent) continue
+ const value = customText(located.node, TERMINAL_KEY)
+ if (value === undefined || value.trim() === "") continue
+ values.push(value)
+ }
+ return values
+}
+
+/**
+ * What the vault says the watcher's knobs and mutes are, read off one
+ * revision's nodes.
+ *
+ * ABSENT means the defaults. `DEFAULT_WATCH` returns as itself, not a copy:
+ * there is exactly one "the vault said nothing" answer, so there is exactly
+ * one object for it, and the check that catches it is a `===`.
+ *
+ * TWO FILES are the convention's tie, and it is decided the way every
+ * convention decides: the shallowest, then the lowest path, then within a
+ * file the FIRST `watch` node. A file with neither node is not a candidate.
+ */
+export const watchConfigIn = (nodes: ReadonlyArray): WatchReading => {
+ const regulars = nodes.filter(isRegular).filter(inKoluFile).sort(byConvention)
+ // THE ONE FILE that decides: the first by convention holding EITHER node;
+ // both halves are read inside it — a `watch` in one file and a `mutes`
+ // in another would be two minds the way two `watch` nodes would.
+ const theFile = regulars.find(({ node }) =>
+ node.title === WATCH_TITLE || node.title === MUTES_TITLE
+ )?.file
+ if (theFile === undefined) return { config: DEFAULT_WATCH, malformed: [] }
+ const inside = regulars.filter((located) => located.file === theFile)
+ const watch = inside.find(({ node }) => node.title === WATCH_TITLE)
+ const mutes = inside.find(({ node }) => node.title === MUTES_TITLE)
+ const malformed: Array = []
+ /** One prop, defensively: the default stands, and a line names the file,
+ * the node, the value and the grammar it violated. The vault is left
+ * with its word — a repair is the editor's, not this reader's. */
+ const readDuration = (key: WatchProp, fallback: number): number => {
+ if (watch === undefined) return fallback
+ const value = customText(watch.node, key)
+ if (value === undefined) return fallback
+ const match = DURATION.exec(value.trim())
+ if (match === null) {
+ malformed.push(
+ `kolu: \`${key}: ${value}\` in ${watch.file} is not a duration — write s, m or h.`,
+ )
+ return fallback
+ }
+ const [, amount, unit] = match
+ if (amount === undefined || unit === undefined) return fallback
+ const ms = Number(amount) * (UNIT_MS[unit] ?? 1_000)
+ if (ms < FLOORS[key]) {
+ malformed.push(
+ `kolu: \`${key}: ${value}\` in ${watch.file} is not an interval its timer allows — padi refuses a ${key} of 0 as the spin it is.`,
+ )
+ return fallback
+ }
+ if (ms > MAX_TIMER_MS) {
+ malformed.push(
+ `kolu: \`${key}: ${value}\` in ${watch.file} is past the ~24.8-day timer ceiling — it over-writes a setTimeout into a steady fire.`,
+ )
+ return fallback
+ }
+ return ms
+ }
+ return {
+ config: {
+ heldForMs: readDuration("held-for", DEFAULT_WATCH.heldForMs),
+ nagMs: readDuration("nag", DEFAULT_WATCH.nagMs),
+ heartbeatMs: readDuration("heartbeat", DEFAULT_WATCH.heartbeatMs),
+ muted: mutes === undefined ? [] : mutesOf(nodes, mutes.node.id),
+ },
+ malformed,
+ }
+}
diff --git a/packages/server/src/runtime.ts b/packages/server/src/runtime.ts
index b4e671df4..8c66c06e2 100644
--- a/packages/server/src/runtime.ts
+++ b/packages/server/src/runtime.ts
@@ -133,6 +133,7 @@ import { cadence } from "@olai/chat"
import { type Dial, koluHalf, type KoluHalf, SEED } from "@olai/kolu-client"
import { claimantsIn } from "./claimants.ts"
+import { watchConfigIn } from "./koluConfig.ts"
import type { Cadence, Change, Chat } from "@olai/chat"
import { type Emit, emitter } from "@olai/log"
@@ -715,16 +716,26 @@ export const bind = (
const kolu = koluHalf({
options: wiring.kolu,
fleet: () => published?.collections.fleet,
- // THE VAULT WALK, passed in — the ruling's own words. `claimants.ts`
+ events: () => published?.collections.events,
+ pulse: () => published?.cells.pulse,
+ // THE VAULT WALKS, passed in — the ruling's own words. `claimants.ts`
// stays here whole because it reads outline records, which is a thing
// the package that dials padi must not learn; what crosses is four
- // strings per claim.
+ // strings per claim. `koluConfig.ts` is the second of the kind, for
+ // the watcher's knobs: what crosses is the derived intervals, the
+ // mute VALUES verbatim, and the malformed lines to say.
claimants: claimantsIn,
+ config: watchConfigIn,
// Chatter, at debug: on a machine with no kolu this is a line every few
// seconds and it is not news. What IS news — a connect, a skew, a link
// that dropped — is the same channel, because the alternative is this
// module deciding which of padi's sentences matter.
say: (line) => say(Effect.logDebug(line)),
+ // What the OWNER must read: a malformed `_olai/Kolu.olai` value, an
+ // ambiguous mute — the sentences whose promise lives in `docs/kolu.md`.
+ // Rare by latch (one line per new shape or value), and the default
+ // console level is `info`, so the channel is `warning`, not `debug`.
+ warn: (line) => say(Effect.logWarning(line)),
})
/**
diff --git a/packages/surface/src/index.ts b/packages/surface/src/index.ts
index c8c6a2170..d5a6778e7 100644
--- a/packages/surface/src/index.ts
+++ b/packages/surface/src/index.ts
@@ -1662,7 +1662,9 @@ export {
export {
FleetOwner,
FleetTerminal,
+ KoluEvent,
KOLU_UNDIALED,
+ KOLU_UNPULSED,
KoluLink,
KoluStatus,
type Resolved,
@@ -1675,4 +1677,5 @@ export {
TerminalFrame,
TERMINAL_KEY,
UNOWNED,
+ type WatchPulse,
} from "@olai/kolu-client/wire"
diff --git a/packages/web/src/client/AppHeader.tsx b/packages/web/src/client/AppHeader.tsx
index 6483169f4..70d3deb50 100644
--- a/packages/web/src/client/AppHeader.tsx
+++ b/packages/web/src/client/AppHeader.tsx
@@ -218,7 +218,7 @@ export function AppHeader(props: {
is no fleet", and a page with no `terminal` property says nothing
at all). A second reader of the same `cells.kolu` the dots
consume; nothing new on the wire. */}
-
+
diff --git a/packages/web/src/client/padi/Feed.tsx b/packages/web/src/client/padi/Feed.tsx
new file mode 100644
index 000000000..fca7295d0
--- /dev/null
+++ b/packages/web/src/client/padi/Feed.tsx
@@ -0,0 +1,50 @@
+/**
+ * WHAT RECENTLY WANTED ATTENTION — the drawer off the Padi pill.
+ *
+ * THE CHROME ONLY. What is inside it is the appliance's (`@olai/kolu-ui`'s
+ * `EventsFeed`): this file is where the section sits, how wide it is and
+ * how the portalled half of `../popover.ts`'s focus cycle finds it — the
+ * same contract `../commit/Panel.tsx` and `../settings/Panel.tsx` wear,
+ * because a popover that its trigger stops reaching is a bug the chrome
+ * half writes, not the log.
+ *
+ * THE PILL stays beside the pile of badges a page might draw in a place
+ * where the header's word is "no". It does NOT carry a summary of its own
+ * — the count a drawer reads off a header is the one the panel itself is
+ * for, which is `one-git-indicator`'s own door.
+ */
+
+import { EventsFeed } from "@olai/kolu-ui"
+
+import { type Anchor, styleOf } from "../anchor.ts"
+import { LAYER } from "../layer.ts"
+import { TESTID } from "../testids.ts"
+
+export function Feed(props: {
+ /** Where to sit, in viewport pixels — see `../anchor.ts`. */
+ readonly at: Anchor
+ /** The portalled-half handshake (`../popover.ts`'s). */
+ readonly inside: (el: HTMLElement | undefined) => void
+}) {
+ return (
+
+
+ recently wanted attention
+
+
+
+ )
+}
diff --git a/packages/web/src/client/padi/Padi.tsx b/packages/web/src/client/padi/Padi.tsx
index a666dda99..15e946c4b 100644
--- a/packages/web/src/client/padi/Padi.tsx
+++ b/packages/web/src/client/padi/Padi.tsx
@@ -13,35 +13,85 @@
* wordmark, the burger and search, and everything else is news under the bar
* or a row in the drawer. A padi that is absent is not news of that kind —
* nothing is broken, and the terminal chips say so where they are.
+ *
+ * ## Press it
+ *
+ * It is a DOOR as well as a readout: pressing it opens the events feed,
+ * what recently wanted attention (`./Feed.tsx` — the log the watcher keeps,
+ * `@olai/kolu-ui`'s to answer and this file's to hang). The popover is
+ * `../popover.ts`'s, as the Commit panel's and the preferences' are: one
+ * focus cycle for the bar, rather than four.
+ *
+ * What the live announce SEEDS: `aria-live` was on the readout's pill, and
+ * this file's own edit DROPS it deliberately — an interactive element
+ * double-roars every twin announce keypress, and a door that says two
+ * lines (its press AND its changed status) is worse than a door saying
+ * none. Status still reads in its `data-padi` attribute, asserted where
+ * a scenario needs it.
*/
import { Show } from "solid-js"
+import { Portal } from "solid-js/web"
import type { KoluLink } from "@olai/surface"
import { desktop } from "../layout/media.ts"
-import { DOT, PILL } from "../readout.ts"
+import { DOT, DOT_HOLLOW_WARN, PILL, PILL_WARN_COAT, TEXT_WARN } from "../readout.ts"
import { TESTID } from "../testids.ts"
+import { createPopover } from "../popover.ts"
+import { Feed } from "./Feed.tsx"
import { padiSaid } from "@olai/kolu-ui"
+import type { WatchPulse } from "@olai/surface"
+import type { Accessor } from "solid-js"
-export function Padi(props: { readonly link: KoluLink }) {
- const said = () => padiSaid(props.link)
+export function Padi(props: {
+ readonly link: KoluLink
+ /** The watcher's pulse stamp (`@olai/surface`'s `pulse` cell) — the
+ * `watcher pulse … ago` the pill's inspection face can spell, or the
+ * quiet the register on it is warning about. */
+ readonly pulse: WatchPulse | null | undefined
+ /** The clock the pill's folds run on — `useFleet`'s now, which ticks
+ * `age → quiet` over without waiting for a beat to tell it so. */
+ readonly now: Accessor
+}) {
+ const said = () => padiSaid(props.link, props.pulse, props.now())
+ const quiet = () => said().beat?.kind === "quiet"
+ const popover = createPopover()
return (
- popover.toggle()}
>
-
+
{said().label}
-
+
+ {(beat) => · {beat()}}
+
+
+
+ {(at) => (
+
+
+
+ )}
+
)
}
diff --git a/packages/web/src/client/readout.ts b/packages/web/src/client/readout.ts
index ebff5d013..cf949c6bb 100644
--- a/packages/web/src/client/readout.ts
+++ b/packages/web/src/client/readout.ts
@@ -55,6 +55,25 @@ export const PILL =
/** The dot itself, which the state's own `dot` utility colours. */
export const DOT = "inline-block size-2 shrink-0 rounded-full"
+/**
+ * THE INFRASTRUCTURE-WARNING REGISTER — the pill's one non-status face.
+ *
+ * The bar's loud colours already have a ruling: violet (`styles.css`'s
+ * `--color-alarm` and `--color-doing`'s siblings) is what an AGENT's ask
+ * for a human wears — the board's `blocked` column, the skew chip's
+ * "upgrade me". The PILL's new face (the watcher gone silent) is the
+ * other kind of wrong: something of this machine's OWN is broken, rather
+ * than a human is owed, and it gets AMBER as a third, smaller family so
+ * the two are never one glance's confusion. The inks are the prototype's
+ * own (`projects/olai/prototypes/pill-mock.png`): a hollow dot, a warm
+ * coat on the chip, and a warm word beside it.
+ */
+export const PILL_WARN_COAT = "!border-[#e0a83c] shadow-[0_0_0_1px_#e0a83c66]"
+/** The dot's HOLLOW face — the same round, emptied. */
+export const DOT_HOLLOW_WARN = "!bg-transparent border-2 !border-[#e0a83c]"
+/** The quiet sentence's ink, beside the dot's. */
+export const TEXT_WARN = "text-[#f0c46a]"
+
/**
* The other shape in the bar: a BUTTON with a glyph on it — the agent toggle
* and the preferences trigger.
diff --git a/packages/web/src/client/testids.ts b/packages/web/src/client/testids.ts
index d7943bb41..225a80f36 100644
--- a/packages/web/src/client/testids.ts
+++ b/packages/web/src/client/testids.ts
@@ -209,6 +209,10 @@ export const TESTID = {
* desktop: an indicator that appears only when something is wrong cannot be
* trusted when it is absent. */
padi: "padi",
+ /** THE FEED the pill's press opens — the box of `@olai/kolu-ui`'s
+ * `EventsFeed`. THE PANEL'S OWN HANDLE only: the rows are the appliance's
+ * and are asserted through `./kolu-ui`'s `./testids`. */
+ padiFeed: "padi-feed",
/** THE TERMINAL DOOR's row — kolu's own Dock row, drawn where the `terminal`
* property is (`client/props/TerminalDoor.tsx`). The row's own attribute