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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ It ships as a **CLI** (`termchart`) plus a **Claude Code plugin** (skills + the

---

## lifeboard

A family board built on the same canvas: your lists, calendar and recipes in the browser, with an
assistant that can read them, change them, and draw new boards. No server, works offline.

[User guide](docs/lifeboard/user-guide.md) · [QA guide](docs/lifeboard/qa-guide.md) ·
[Package](packages/lifeboard/)

## Use it — just ask your agent

You never write diagram syntax. Install once, then **ask in plain language** and your agent picks
Expand Down
12 changes: 11 additions & 1 deletion docs/lifeboard/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
# lifeboard — design deck
# lifeboard

> **A dashboard that reshapes itself around your family — kept by an agent that works while you don't.**

| Guide | For |
|---|---|
| [User guide](./user-guide.md) | Using it: the boards, the assistant, meals, mail, privacy |
| [Bridge setup](./bridge.md) | Running turns through the AI subscriptions you already pay for |
| [QA guide](./qa-guide.md) | Testing it — automated checks and a manual test plan |
| [Persistence audit](./persistence-audit.md) | How state is stored, and what was wrong with it |
| [Package README](../../packages/lifeboard/README.md) | Building on it: architecture and layout |

## The design deck

`deck.html` is a **single self-contained file**. Open it in a browser; navigation is built in.

- **Sidebar contents** — generated from the deck itself, so it cannot drift out of sync.
Expand Down
96 changes: 96 additions & 0 deletions docs/lifeboard/bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# The bridge

The bridge is an optional program you run on your Mac. lifeboard works without it. With it:

- turns run through the **AI subscriptions you already pay for** — Claude Pro, ChatGPT Plus, Google
AI — instead of a metered API key;
- **long research keeps going after you close the tab**, because your Mac is doing it, not the iPad;
- boards can read feeds a browser is not allowed to fetch directly.

It is deliberately dull. It lists what is installed, runs a turn, owns long jobs, and sends a
message you have approved. It never holds your Google credentials.

## Running it

```bash
npx @ivanmkc/termchart bridge
```

It prints something like:

```
termchart bridge listening on http://127.0.0.1:8787
token: 3Qb7… (copy this)
agents ready: Claude Code (Claude Pro)
Codex CLI is installed but signed out — run `codex login`
paste the token into lifeboard → Connect. Loopback only; nothing else on the network can reach it.
```

Then in the app: **Connect → Your Mac → Look for a bridge**, paste the token, and pick an agent.

| Flag | What it does |
|------|--------------|
| `--port <n>` | Listen somewhere other than 8787 |
| `--token <t>` | Use a fixed token instead of a fresh one each start |
| `--allow-origin <url>` | Also trust this origin — needed for a tunnel, or a LAN address |
| `--schedule` | Also run the scheduled boards in `.termchart/boards` |

## What it will use

The bridge finds agents by asking them their version — a binary on the `PATH` that cannot run is not
an installed agent, and it is much better to find that out now than inside your first question.

| Agent | Command | Sign in with |
|-------|---------|--------------|
| Claude Code | `claude` | `claude` (Claude Pro / Max) |
| Codex CLI | `codex` | `codex login` (ChatGPT Plus) |
| Gemini CLI | `gemini` | `gemini` (Google AI) |

"Installed but signed out" is the most common state, and the fix is the command in the last column.

## Security

A program that can run your authenticated AI agents is worth breaking into, so:

- **Loopback only.** It binds `127.0.0.1`, never `0.0.0.0`. Nothing else on the wifi can reach it.
A bridge on a café network would be a remote shell with extra steps.
- **A token**, generated per run, printed once, never written to disk, compared in constant time.
- **Origin checking** on anything that changes something. Any web page you visit can POST to
`127.0.0.1`, so the token alone is not enough — a page in your browser could have been handed one.
Only origins the bridge was started with get through.
- **Health is the only unauthenticated route**, and it says nothing a stranger could use. It exists
so the app can tell "no bridge" apart from "wrong token".

Stop it and the app degrades quietly: no long jobs, and your API key is used again if you have one.

## Long jobs

Ask for something big and the bridge runs it, keeping the state. The Work board mirrors it: a card
appears immediately, shows what it is doing, and swaps in the result. Closing the tab does not stop
it; **Stop** does.

Every job has a step budget. An agent asked to "research trips to Portugal" can go round in circles,
and every circle spends your subscription, so a job that isn't converging is stopped and says so.

> **Not yet wired up:** nothing in the app *starts* a job. The bridge runs them and the board shows
> them, but the button to begin one has not been built.

## Scheduled boards

With `--schedule`, the bridge runs the board definitions in `.termchart/boards` — the same files,
and the same `termchart run`, that a crontab or a GitHub Action would use. The bridge is just
another host.

It is off by default because **every tick costs money** on someone's subscription. Cadence is
reported in words next to the cron expression, since `0 7 * * *` does not tell anyone what it will
cost them.

## When it does not work

| What the app says | What to do |
|---|---|
| "No bridge running" | Start it. Check the port matches (8787 or 8788 are probed). |
| "The bridge did not recognise that token" | It prints a new one each start — paste the current one, or use `--token` |
| "The bridge is not paired with this address" | Restart with `--allow-origin <the address you opened>` |
| "Claude Code is installed but not signed in" | Run its login command in a terminal |
| "…did not finish in time" | The agent hung. Try a smaller question. |
150 changes: 150 additions & 0 deletions docs/lifeboard/persistence-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Audit: the state persistence mechanism

*2026-09-02. Scope: `src/log.ts`, `src/store.ts`, `src/transcript.ts`, `src/prefs.ts`, `src/sw.js` —
everything that decides whether a change a person makes is still there tomorrow.*

## What the design is

One append-only log of entries in a single IndexedDB object store (`entries`, keyed on `seq`). The
collections the UI renders are a fold over that log. Device preferences — the active profile, API
keys, tokens — live in `localStorage`, deliberately outside the log, because the log cannot forget
and a rotated key must actually disappear.

The design is sound. Every finding below is an implementation defect, not an argument with the model.

## Findings

Severity is about what a person loses.

| # | Severity | Finding | Status |
|---|----------|---------|--------|
| S1 | **High** | A second tab's writes were silently lost | **fixed** |
| S2 | **High** | A new build never reached a device that had the old one | **fixed** |
| S3 | Medium-high | The app did not start at all when storage was unavailable | **fixed** |
| S4 | Medium | The cost of a write grew with the age of the household | **fixed** |
| S5 | Medium | Transcript writes could reject with nobody listening | **fixed** |
| S6 | **High** | No export, no backup, no import | **open** |
| S7 | Low-medium | No schema version on entries; database pinned at version 1 | **open** |
| S8 | Low | `onblocked` / `onversionchange` unhandled — a boot that hangs forever | **fixed** |
| S9 | Low | The log never compacts; console chatter is permanent | **open** |
| S10 | Informational | Secrets in `localStorage` | accepted, mitigation recommended |

---

### S1 — A second tab's writes were silently lost *(fixed)*

`fromSeq` came from the in-memory tail, so two connections to the same database minted the same key.
IndexedDB rejected the duplicate and the entry never landed.

Reproduced before the fix:

```
TAB-B-ERROR: Error: IndexedDB transaction failed
ROWS-ON-DISK: ["from A"]
```

What a person saw: in the second tab a tick reverted itself a moment after being tapped, with no
explanation — `postInteract` catches the failure and reverts optimistically. Console lines, written
without anyone awaiting them, became unhandled rejections (S5).

**Fixed** by reading and appending in one transaction: a tab reads whatever landed after its own
tail, folds it in, and only then decides what its change means. An update against a record the tab
had never seen is now applied instead of dropped. Covered by three tests in `test/store.test.ts`.

**Still open within this:** the two tabs do not update each other *live*. A tab notices the other's
writes on its next write, not as they happen. `BroadcastChannel` would close that; it is not urgent,
because no data is lost either way.

### S2 — A new build never reached a device that had the old one *(fixed)*

`app.js` is not content-hashed (`--entry-names=app`), and the service worker was cache-first for
every same-origin GET. A deployed change was therefore invisible until someone edited `CACHE` in
`sw.js` by hand. The code comment asserted that bumping the cache name "is what evicts the old one",
which is true and enforced by nothing.

**Fixed** by splitting the strategy: the shell (`/`, `index.html`, `app.js`, `style.css`) is
network-first with a cache fallback; content-hashed chunks stay cache-first, where that is free and
correct. The offline e2e now serves a changed build mid-run and asserts it arrives.

### S3 — The app did not start when storage was unavailable *(fixed)*

`openStore` rejected, `startApp` awaited it, and `main.ts` had no catch: a private window got a blank
page. The rejection was also literally `undefined`, because `request()` passed `req.error` through
and that is null on several paths — so the console said nothing either.

**Fixed:** a `Backend` seam with an in-memory implementation, `openStoreOrMemory`, a `durable` flag,
and a line in the console saying plainly that nothing is being saved. `prefs.ts` already degraded
this way; the store now matches it.

### S4 — Write cost grew with the age of the household *(fixed)*

`apply` called `materialise(this.entries)` — a full re-fold of the log, `structuredClone`ing every
record — on every append. Measured before the fix:

| Log size | Cost of one write |
|---|---|
| 200 entries | 0.39 ms |
| 1,000 entries | 1.57 ms |
| 3,000 entries | 4.79 ms |

Linear, and every tick writes twice (the change, then the transcript line). A household a year in
would feel it. **Fixed** by folding only the new entries into the existing view.

### S5 — Writes that could reject with nobody listening *(fixed)*

`persistLine`, `updateLine` and `markUndone` used `void store.apply(...)`. On a full disk — or on the
S1 collision — that is an unhandled rejection, which the browser reports as an uncaught error and
nobody acts on. **Fixed:** they catch and warn. Losing a line of narration is survivable; losing it
silently while the page logs an error is not.

### S6 — No export, no backup, no import *(open — the largest remaining risk)*

Everything lives in one browser's IndexedDB. "Clear site data", a lost device, or a browser that
evicts storage under pressure takes the family's calendar, lists and vaccination dates with it.

For a product whose pitch is provenance and trust, this is the biggest single risk in the persistence
design, and it is the cheapest thing on this list to fix: the durable state *is* a JSON array of
entries. Export is `JSON.stringify(store.history())`. Import is replaying it.

Recommended: an Export/Import pair in Connections, and a periodic reminder. Not done here because it
is a feature rather than a defect, and it deserves its own review.

### S7 — No schema version *(open)*

`indexedDB.open(name, 1)` is pinned at 1 and entries carry no version field. A change to a record's
shape would fold old entries into a new view with fields missing and no migration hook. Cheap
insurance: a `v` on each entry, and a documented rule that readers tolerate older shapes.

### S8 — A boot that could hang forever *(fixed)*

`request()` handled `onsuccess` and `onerror` but not `onblocked`. If a future version bump were ever
blocked by an old tab, the promise would neither resolve nor reject and the app would sit on a blank
screen with no error at all. Both `onblocked` and `db.onversionchange` are handled now.

### S9 — The log never compacts *(open)*

Every console line is a permanent entry. Superseded *values* are kept deliberately — that is the
point of the log — but transcript chatter has no such justification. A cap on retained `message`
entries, or moving the transcript to its own store, would bound growth. Not urgent at family scale.

### S10 — Secrets in `localStorage` *(accepted)*

API keys, the Google refresh token and the bridge token are in `localStorage` by design: the log
cannot forget, and a rotated key must actually disappear. The consequence is that any XSS is a full
credential compromise.

There is no known injection path today — markdown is sanitised with DOMPurify, and component boards
render through React, which escapes. This is a defence-in-depth note, not a live vulnerability.
**Recommended:** serve the app with a Content-Security-Policy. Worth doing before anyone hosts this
somewhere public.

## What holds up well

- **Writes are serialised** through a promise queue, so concurrent `apply` calls within a tab cannot
interleave. That was itself a fix for a live bug.
- **Memory is updated only after the transaction commits**, so a failed write cannot leave the
in-memory view claiming something that is not on disk.
- **The failure mode of `prefs` is right** — a probe with a real write, then a silent fall back to
memory. That is the pattern the store now follows.
- **The log's shape earns its keep.** Undo, lineage, and "who changed this" are all reads over data
that had to be recorded anyway.
Loading
Loading