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

Filter by extension

Filter by extension


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

---

## lifeboard
## The Termchart client

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) ·
[User guide](docs/client/user-guide.md) · [QA guide](docs/client/qa-guide.md) ·
[Package](packages/termchart-client/)

## Use it — just ask your agent
Expand Down
2 changes: 1 addition & 1 deletion docs/lifeboard/README.md → docs/client/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# lifeboard
# Termchart client

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

Expand Down
4 changes: 2 additions & 2 deletions docs/lifeboard/bridge.md → docs/client/bridge.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# The bridge

The bridge is an optional program you run on your Mac. lifeboard works without it. With it:
The bridge is an optional program you run on your Mac. Termchart 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;
Expand All @@ -23,7 +23,7 @@ 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.
paste the token into Termchart → 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.
Expand Down
242 changes: 121 additions & 121 deletions docs/lifeboard/deck.html → docs/client/deck.html

Large diffs are not rendered by default.

242 changes: 121 additions & 121 deletions docs/lifeboard/index.html → docs/client/index.html

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Plan: third-party integrations

*How lifeboard should reach Gmail, Drive, Dropbox, Maps and everything after them.*
*How Termchart should reach Gmail, Drive, Dropbox, Maps and everything after them.*

## The short version

Expand Down Expand Up @@ -28,7 +28,7 @@ The household configures an MCP server once in their CLI agent. The bridge asks
work. We write no OAuth, hold no tokens, and inherit every integration that ecosystem produces.

```
lifeboard ──HTTP──▶ bridge ──stdio──▶ claude ──MCP──▶ gmail-mcp-server ──▶ Gmail
Termchart ──HTTP──▶ bridge ──stdio──▶ claude ──MCP──▶ gmail-mcp-server ──▶ Gmail
└─MCP──▶ gdrive-mcp-server ──▶ Drive
└─MCP──▶ maps-mcp-server ──▶ Places API
```
Expand Down
File renamed without changes.
4 changes: 2 additions & 2 deletions docs/lifeboard/qa-guide.md → docs/client/qa-guide.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# QA guide

How to check that lifeboard works — what the machines cover, what a person still has to look at, and
How to check that Termchart works — what the machines cover, what a person still has to look at, and
what is already known to be missing so nobody files it twice.

## 1. The automated checks
Expand Down Expand Up @@ -223,7 +223,7 @@ These are the ones that bite in the field.
| J4 | Rebuild while a tab is open, reload | **The new build is served.** A stale app after a deploy is a bug. |
| J5 | Rebuild, go offline, reload | The last good build still loads |
| J6 | Tick ~50 things quickly | No dropped ticks, no lag |
| J7 | Connect → Your data → **Export a backup** | A `lifeboard-<date>.json` file downloads |
| J7 | Connect → Your data → **Export a backup** | A `termchart-<date>.json` file downloads |
| J8 | Clear site data, reload, import that file | Everything is back, including history and undo |
| J9 | Import a file that is not a backup | An explanation, and nothing changed |
| J10 | Import onto a device that already has data | Warns it will REPLACE, with a count, before doing anything |
Expand Down
10 changes: 5 additions & 5 deletions docs/lifeboard/user-guide.md → docs/client/user-guide.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# lifeboard: a guide for the family
# Termchart: a guide for the family

lifeboard is a shared board for the things a household has to keep track of — what's on today, what
Termchart is a shared board for the things a household has to keep track of — what's on today, what
to buy, what's for dinner, what came in the post that somebody has to act on.

It runs in a browser and keeps everything on the device. There is no account and no server. Two
Expand Down Expand Up @@ -135,7 +135,7 @@ each one is allowed to change. You can add your own by describing it: *"track th
lessons"*. You get a draft with a preview, you can keep changing it in plain language, and nothing is
installed until you press save.

A pack is **data, not code**. It can only use things lifeboard already knows how to do, so nothing
A pack is **data, not code**. It can only use things Termchart already knows how to do, so nothing
you or the assistant writes there can make the app do something new and unexpected.

## Calendar and email
Expand All @@ -144,7 +144,7 @@ you or the assistant writes there can make the app do something new and unexpect
does not ship one, which is what keeps a family install in Google's Testing mode and out of a
commercial audit. The screen tells you what to make.

Once connected, opening lifeboard does a catch-up: it pulls the calendar and reads what is new.
Once connected, opening Termchart does a catch-up: it pulls the calendar and reads what is new.

**What happens to a new email:**

Expand Down Expand Up @@ -226,7 +226,7 @@ and your changes are there when you reconnect. The first load must be online; af
| What you see | What it means |
|---|---|
| "No assistant is connected yet" | Connect → add an assistant |
| "This device will not let lifeboard save anything" | Usually a private window. Changes will be lost on close. |
| "This device will not let Termchart save anything" | Usually a private window. Changes will be lost on close. |
| "The bridge did not recognise that token" | The bridge was restarted; it prints a new token each time |
| "Its board would not render, so I showed the answer as a list" | The assistant's drawing was malformed; the answer is intact |
| Something is in the Inbox you did not expect | Decline it. Nothing was applied. |
2 changes: 1 addition & 1 deletion packages/canvas/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @ivanmkc/termchart-canvas — the browser rendering layer shared by the termchart viewer and the
// lifeboard app. Consumed as TypeScript source; consumers bundle it with esbuild.
// termchart client. Consumed as TypeScript source; consumers bundle it with esbuild.
export * from "./flow-patch.js";
export * from "./component-patch.js";
export * from "./panes-patch.js";
Expand Down
6 changes: 3 additions & 3 deletions packages/canvas/src/interact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
// content — the server enforces that the mutation can only touch an existing, editable
// interactive leaf field (see server.ts). The SSE `update` echo then syncs every viewer.

// Guarded: this module is imported by non-browser consumers (and by lifeboard, which never
// uses the HTTP transport at all), and a bare `location` read would throw at import time.
// Guarded: this module is imported by non-browser consumers (and by the termchart client, which
// never uses the HTTP transport at all), and a bare `location` read would throw at import time.
const wsid =
typeof location === "undefined" ? "" : (location.pathname.split("/").filter(Boolean)[1] ?? "");

Expand Down Expand Up @@ -57,7 +57,7 @@ export function wasSelfEcho(project: string, agent: string, ref: string, value:

/**
* How a toggle actually leaves the widget. The viewer POSTs it to its own bounded `interact`
* endpoint; lifeboard has no such endpoint and installs a transport that writes to its local store
* endpoint; the termchart client has no such endpoint and installs a transport that writes to its local store
* instead. The seam exists for correctness, not tidiness: with no transport at all a failed POST
* 404s silently and the box stays ticked, so the user sees a change that was never saved.
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/canvas/test/practice.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function mount(spec: unknown) {
beforeEach(() => {
document.body.replaceChildren();
setWidgetTransport(null);
setActiveScope("lifeboard", "local");
setActiveScope("termchart", "local");
});

describe("registration", () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/bridge.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// `termchart bridge` — the optional local process that lets lifeboard use the AI agents already
// installed and signed in on this machine.
// `termchart bridge` — the optional local process that lets the termchart client use the AI agents
// already installed and signed in on this machine.
//
// It prints its token once, on start, and never writes it anywhere. Pairing is copying that line
// into the app, which is also the moment the person decides this is something they want running.
Expand All @@ -9,7 +9,7 @@ import { detectDrivers } from "./bridge/drivers.js";
import { Scheduler } from "./bridge/schedule.js";

const USAGE = `usage: termchart bridge [--port <n>] [--token <t>] [--allow-origin <url>]
Serve the local agent bridge on 127.0.0.1 so lifeboard can use your installed AI agents.
Serve the local agent bridge on 127.0.0.1 so the termchart client can use your installed AI agents.
--port port to listen on (default 8787)
--token use a specific token instead of generating one
--allow-origin additionally trust this origin (repeatable; for a tunnel or a LAN viewer)
Expand Down Expand Up @@ -59,7 +59,7 @@ export async function bridge(argv: string[], deps: BridgeDeps = {}): Promise<num
: `no signed-in agents found — install and sign in to Claude Code, Codex or Gemini CLI\n`);
for (const a of agents.filter((x) => x.installed && x.auth.status === "signed-out"))
out(`${a.label} is installed but signed out — run \`${a.command} login\`\n`);
out(`paste the token into lifeboard → Connect. Loopback only; nothing else on the network can reach it.\n`);
out(`paste the token into Termchart → Connect. Loopback only; nothing else on the network can reach it.\n`);

if (scheduler) out(`running scheduled boards from ${process.cwd()}/.termchart/boards\n`);

Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/bridge/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
* Origins the bridge answers. A tunnel host is added at pairing time.
*
* The loopback entries carry no port on purpose — see `originAllowed`. `npm run serve` hosts
* lifeboard on 8123, a dev server picks its own, and demanding a port here meant the bridge
* refused every write from the app it ships with.
* the termchart client on 8123, a dev server picks its own, and demanding a port here meant the
* bridge refused every write from the app it ships with.
*/
export const DEFAULT_ORIGINS = [
"http://127.0.0.1",
"http://localhost",
"https://lifeboard.local",
"https://termchart.local",
];

export const newToken = (): string => randomBytes(32).toString("base64url");
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/bridge/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// The bridge: a small HTTP server on loopback that lets lifeboard use the agents already installed
// and signed in on this machine.
// The bridge: a small HTTP server on loopback that lets the termchart client use the agents already
// installed and signed in on this machine.
//
// It is deliberately dull. It exposes what is installed, runs a turn, owns long jobs, and sends a
// message that a person has approved. It holds no Google credentials — mail triage stays in the
Expand Down
14 changes: 7 additions & 7 deletions packages/cli/test/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { approveMessage, notify, smsLink } from "../src/bridge/notify.js";
import { createBridge } from "../src/bridge/server.js";
import { bridge } from "../src/bridge.js";

const POLICY = { allowed: ["http://127.0.0.1", "https://lifeboard.local"] };
const POLICY = { allowed: ["http://127.0.0.1", "https://termchart.local"] };

/** A spawn that answers per command, so nothing in these tests touches a real CLI. */
function fakeSpawn(table: Record<string, { code: number; stdout?: string; stderr?: string }>): CapturingSpawn {
Expand All @@ -31,9 +31,9 @@ describe("security", () => {

it("matches an origin on scheme and host — not by prefix", () => {
expect(originAllowed("http://127.0.0.1", POLICY)).toBe(true);
expect(originAllowed("https://lifeboard.local", POLICY)).toBe(true);
expect(originAllowed("https://termchart.local", POLICY)).toBe(true);
expect(originAllowed("http://127.0.0.1.evil.com", POLICY)).toBe(false);
expect(originAllowed("https://lifeboard.local.evil.com", POLICY)).toBe(false);
expect(originAllowed("https://termchart.local.evil.com", POLICY)).toBe(false);
expect(originAllowed("not a url", POLICY)).toBe(false);
});

Expand All @@ -52,7 +52,7 @@ describe("security", () => {

it("does not extend that leniency past loopback", () => {
// A named host is pinned to the port it was paired on: a tunnel is not the family's own machine.
expect(originAllowed("https://lifeboard.local:8123", POLICY)).toBe(false);
expect(originAllowed("https://termchart.local:8123", POLICY)).toBe(false);
expect(originAllowed("https://evil.example:8123", POLICY)).toBe(false);
// The scheme still has to match, so an http page cannot borrow an https pairing.
expect(originAllowed("https://127.0.0.1:8123", POLICY)).toBe(false);
Expand Down Expand Up @@ -270,7 +270,7 @@ describe("the server", () => {
});

it("runs a turn", async () => {
const r = await call(createBridge(opts), "POST", "/agents/claude/run", { token: "tok", origin: "https://lifeboard.local", body: { prompt: "hello" } });
const r = await call(createBridge(opts), "POST", "/agents/claude/run", { token: "tok", origin: "https://termchart.local", body: { prompt: "hello" } });
expect(r.status).toBe(200);
expect(r.body).toEqual({ output: "hi from claude" });
});
Expand All @@ -288,8 +288,8 @@ describe("the server", () => {
});

it("echoes CORS only for an origin it trusts", async () => {
const good = await call(createBridge(opts), "GET", "/agents", { token: "tok", origin: "https://lifeboard.local" });
expect(good.headers["access-control-allow-origin"]).toBe("https://lifeboard.local");
const good = await call(createBridge(opts), "GET", "/agents", { token: "tok", origin: "https://termchart.local" });
expect(good.headers["access-control-allow-origin"]).toBe("https://termchart.local");
const bad = await call(createBridge(opts), "GET", "/agents", { token: "tok", origin: "https://evil.example" });
expect(bad.headers["access-control-allow-origin"]).toBeUndefined();
});
Expand Down
12 changes: 6 additions & 6 deletions packages/termchart-client/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# lifeboard
# termchart-client

A family board that runs entirely in the browser. Your lists, calendar and recipes live in
IndexedDB on the device; an assistant you connect can read them, change them, and draw new boards
onto the same canvas the [termchart viewer](../viewer) uses.

There is no server. Nothing here talks to a backend we run, because there isn't one.

**Guides:** [user guide](../../docs/lifeboard/user-guide.md) ·
[bridge setup](../../docs/lifeboard/bridge.md) ·
[QA guide](../../docs/lifeboard/qa-guide.md)
**Guides:** [user guide](../../docs/client/user-guide.md) ·
[bridge setup](../../docs/client/bridge.md) ·
[QA guide](../../docs/client/qa-guide.md)

## Quick start

Expand All @@ -19,7 +19,7 @@ npm run serve --workspace @ivanmkc/termchart-client

Then open <http://localhost:8123>. The app seeds a short list on first run so there is something to
look at. It works with no assistant connected — connecting one is a separate step, described in the
[user guide](../../docs/lifeboard/user-guide.md).
[user guide](../../docs/client/user-guide.md).

To build without serving:

Expand Down Expand Up @@ -130,7 +130,7 @@ npm run test:e2e --workspace @ivanmkc/termchart-client # builds, then drives a r
```

The e2e is the one that matters: it switches the network off and proves the app still renders,
accepts a tick, and remembers it across a reload. See the [QA guide](../../docs/lifeboard/qa-guide.md)
accepts a tick, and remembers it across a reload. See the [QA guide](../../docs/client/qa-guide.md)
for what to check by hand.

## Known limits
Expand Down
7 changes: 4 additions & 3 deletions packages/termchart-client/e2e/offline.e2e.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// End-to-end proof of the one guarantee the foundation makes: lifeboard is a static bundle over a
// local log, so it renders and accepts edits with the network switched off, and those edits survive
// a reload. A failure here means the degradation section of the design doc does not hold.
// End-to-end proof of the one guarantee the foundation makes: the termchart client is a static
// bundle over a local log, so it renders and accepts edits with the network switched off, and those
// edits survive a reload. A failure here means the degradation section of the design doc does not
// hold.
//
// node e2e/offline.e2e.mjs (run `npm run build` first, or use `npm run test:e2e`)
//
Expand Down
2 changes: 1 addition & 1 deletion packages/termchart-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@ivanmkc/termchart-client",
"version": "0.1.0",
"private": true,
"description": "lifeboard \u2014 a local-first family dashboard rendered on the termchart canvas.",
"description": "Termchart \u2014 a local-first family dashboard rendered on the termchart canvas.",
"type": "module",
"scripts": {
"build": "rm -rf dist && esbuild src/main.ts --bundle --platform=browser --format=esm --splitting --target=es2022 --outdir=dist --entry-names=app --chunk-names=[name]-[hash] --jsx=automatic --loader:.css=text && cp src/index.html src/style.css src/sw.js dist/",
Expand Down
6 changes: 3 additions & 3 deletions packages/termchart-client/scripts/serve.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// A static server for the built app. No dependencies: lifeboard is a folder of files, and the whole
// point is that anything which can serve a folder can serve it.
// A static server for the built app. No dependencies: the termchart client is a folder of files,
// and the whole point is that anything which can serve a folder can serve it.
//
// npm run serve --workspace @ivanmkc/termchart-client
//
Expand Down Expand Up @@ -52,7 +52,7 @@ server.listen(PORT, () => {
.flat()
.filter((n) => n && n.family === "IPv4" && !n.internal)
.map((n) => `http://${n.address}:${PORT}/`);
process.stdout.write(`lifeboard: http://localhost:${PORT}/\n`);
process.stdout.write(`termchart: http://localhost:${PORT}/\n`);
for (const a of addresses) process.stdout.write(` on this network: ${a}\n`);
process.stdout.write("\nService workers need a secure context: localhost counts, a LAN IP does not.\n");
process.stdout.write("Offline support will be inactive over the network address — everything else works.\n");
Expand Down
4 changes: 2 additions & 2 deletions packages/termchart-client/src/agent/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export interface Connection {
agentId?: string;
}

const LIST_KEY = "lifeboard.connections";
const ACTIVE_KEY = "lifeboard.activeConnection";
const LIST_KEY = "termchart.connections";
const ACTIVE_KEY = "termchart.activeConnection";

export const DEFAULT_MODELS: Record<ProviderId, string> = {
bridge: "",
Expand Down
2 changes: 1 addition & 1 deletion packages/termchart-client/src/agent/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const EXAMPLE_BOARD = `{"reply":"Three grinders that fit a £150 budget — the

export function systemPrompt(ctx: PromptContext): string {
return [
`You are the assistant inside lifeboard, a shared family board. You are talking to ${ctx.profileName}. Today is ${ctx.today}.`,
`You are the assistant inside Termchart, a shared family board. You are talking to ${ctx.profileName}. Today is ${ctx.today}.`,
"",
"## How you reply",
"Reply with ONE JSON object and nothing else — no prose before it, no code fence around it:",
Expand Down
4 changes: 2 additions & 2 deletions packages/termchart-client/src/agent/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export function openrouterProvider(key: string, model = "anthropic/claude-opus-5
headers: {
authorization: `Bearer ${key}`,
// Identifies the app on OpenRouter's dashboards. Not authentication.
"http-referer": typeof location === "undefined" ? "https://lifeboard.local" : location.origin,
"x-title": "lifeboard",
"http-referer": typeof location === "undefined" ? "https://termchart.local" : location.origin,
"x-title": "Termchart",
},
extra: (o) => (o.search ? { plugins: [{ id: "web" }] } : {}),
fetch: f,
Expand Down
4 changes: 2 additions & 2 deletions packages/termchart-client/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@ export async function startApp(deps: AppDeps): Promise<Shell> {
if (!store.durable) {
shell.console.post({
kind: "error",
text: "This device will not let lifeboard save anything — changes will be lost when you close the tab. Private browsing usually causes this.",
text: "This device will not let Termchart save anything — changes will be lost when you close the tab. Private browsing usually causes this.",
});
}

// The catch-up pass, in the background. It must never delay the board coming up: the family's own
// records are already on screen, and Google is the slow, failable part.
const clientId = prefs.get("lifeboard.googleClientId");
const clientId = prefs.get("termchart.googleClientId");
if (clientId) {
void syncGoogle({
store,
Expand Down
Loading
Loading