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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
own computer, and lets you follow them, approve what they do and review their
changes from your terminal, browser or phone.**

[![Latest release: v2.7.0](https://img.shields.io/github/v/release/JeremiahM37/lectern?label=release&color=8b5cf6)](https://github.com/JeremiahM37/lectern/releases/latest)
[![Latest release: v2.8.0](https://img.shields.io/github/v/release/JeremiahM37/lectern?label=release&color=8b5cf6)](https://github.com/JeremiahM37/lectern/releases/latest)
![license](https://img.shields.io/badge/license-MIT-blue)
![go](https://img.shields.io/badge/single%20binary-Go-00add8)

Expand Down Expand Up @@ -72,7 +72,9 @@ phone, then review and commit the change.
comments for the agent, stage what you want and commit. On your main branch
it offers a new branch first.
- **Use your phone.** The phone layout is installable as an app, with
notifications when an agent needs you.
notifications when an agent needs you. On Android there is also a
[native app](docs/android.md) (the APK is on each release) that approves
from the notification and updates itself.

<img src="docs/media/control-plane/phone-approval.png" alt="A pending approval in the phone layout" width="300">

Expand Down
23 changes: 16 additions & 7 deletions cmd/lectern/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const pluginUsage = `usage:
lectern plugin remove ID
lectern plugin secret ID NAME (reads the value from stdin; empty removes it)
lectern plugin source add NAME GIT_URL [--ref REF] | source list | source remove NAME
lectern plugin new ID [DIR]
lectern plugin new ID [DIR] [--mod] (--mod: a plugin with one mod, docs/mods.md)
lectern plugin validate [DIR]`

// pluginOffline handles the subcommands that need no server: scaffolding a
Expand All @@ -38,14 +38,23 @@ func pluginOffline(args []string, out io.Writer) (ok bool, err error) {
}
switch args[0] {
case "new":
if len(args) < 2 || len(args) > 3 {
return true, fmt.Errorf("usage: lectern plugin new ID [DIR]")
scaffold := pluginpkg.Scaffold
var rest []string
for _, a := range args[1:] {
if a == "--mod" {
scaffold = pluginpkg.ScaffoldMod
continue
}
rest = append(rest, a)
}
if len(rest) < 1 || len(rest) > 2 || strings.HasPrefix(rest[0], "-") {
return true, fmt.Errorf("usage: lectern plugin new ID [DIR] [--mod]")
}
dir := args[1]
if len(args) == 3 {
dir = args[2]
dir := rest[0]
if len(rest) == 2 {
dir = rest[1]
}
files, err := pluginpkg.Scaffold(args[1])
files, err := scaffold(rest[0])
if err != nil {
return true, err
}
Expand Down
23 changes: 23 additions & 0 deletions cmd/lectern/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,29 @@ func TestPluginNewThenValidate(t *testing.T) {
}
}

func TestPluginNewModThenValidate(t *testing.T) {
dir := filepath.Join(t.TempDir(), "hello")
var out bytes.Buffer
if ok, err := pluginOffline([]string{"new", "acme.hello", "--mod", dir}, &out); !ok || err != nil {
t.Fatalf("new --mod: %v %v", ok, err)
}
for _, f := range []string{"mods/hello.js", "mods/hello.test.mjs", "README.md"} {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
t.Fatalf("scaffold is missing %s: %v", f, err)
}
}
out.Reset()
if ok, err := pluginOffline([]string{"validate", dir}, &out); !ok || err != nil {
t.Fatalf("validate: %v %v\n%s", ok, err, out.String())
}
if !strings.Contains(out.String(), "acme.hello 0.1.0 — valid") {
t.Fatalf("validate output:\n%s", out.String())
}
if _, err := pluginOffline([]string{"new", "--mod"}, &out); err == nil {
t.Fatal("new --mod without an id was accepted")
}
}

func TestPluginInstallAsksAndConsentsToExactlyWhatWasShown(t *testing.T) {
var installs []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
2 changes: 1 addition & 1 deletion docs/a2a.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ curl -s "$LECTERN_BASE_URL/.well-known/agent-card.json" | jq
{
"name": "Lectern",
"description": "Control plane for AI coding agents. Dispatch a coding task ...",
"version": "2.7.0",
"version": "2.8.0",
"supportedInterfaces": [
{
"url": "https://lectern.example.com/a2a/v1",
Expand Down
53 changes: 47 additions & 6 deletions docs/android.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# Android app

A native Android app for Lectern. It is a prototype. The signed 0.2.1 APK is on the
[v2.6.0 release](https://github.com/JeremiahM37/lectern/releases/download/v2.6.0/lectern-android-0.2.1.apk) (SHA-256 `11f2ee2fa4ea56a38f753c748c1abf0cf82a353c67758eb7fc241ec12a3b7925`).
It is signed with the same key as 0.1.0, so an installed 0.1.0 upgrades in place and keeps its pairing.
0.1.0 opened every in-app terminal as a second copy of the app; 0.2.1 fixes that, and the back key now closes overlays first.
A native Android app for Lectern. From 2.8.0 the app carries Lectern's own
version, and every release has its signed APK attached:
[lectern-android-<version>.apk on the latest release](https://github.com/JeremiahM37/lectern/releases/latest).
It is signed with the same key as every earlier build (0.1.0, 0.2.1), so it
installs over them and keeps their pairing. After that, the app updates itself
(see [Updates](#updates)).
If notifications don't arrive, open ntfy once and turn off battery optimisation for it.
See "Tested" and "Limits" below before using it.

Expand Down Expand Up @@ -149,6 +151,41 @@ only for its own Lectern's pages. Android 12+ shows the lectern mark on the
app's dark background while it starts, and Android 13 themed icons get a
monochrome variant.

## Updates

When a newer Lectern release has an app, the app says so in a bar at the top
of the page (**Update** / **Later**), and **Settings → Phone & devices → App
updates** shows the installed version with **Check for updates**. It checks
when it opens, at most every six hours, and needs no Google service.

- Every release carries `lectern-android.json`:
`{"version", "versionCode", "apk", "sha256", "size"}`. The app reads it from
`https://github.com/JeremiahM37/lectern/releases/latest/download/lectern-android.json`,
which always names the newest release, so there is no API call and no rate
limit.
- **Update** downloads the APK, which must come from this repository's
release downloads, checks it against the manifest's SHA-256, and hands it
to Android's installer. Android shows **Do you want to update this app?**
and refuses any APK not signed with the installed app's key, so a forged
manifest can at most name an update that will not install.
- The first time, Android asks to **Allow from this source** for Lectern;
the update carries on when you come back from that setting.
- Pairings, keys, tokens and push registrations are kept; Android restarts
the app as the new version.

Releasing: after `release.yml` has published a tag, check the tag out and run
`LECTERN_ANDROID_SIGNING=… tools/publish-android.sh vX.Y.Z`. It runs the unit
tests, builds and signs the APK, checks the signing certificate, uploads the
APK, its `.sha256` and `lectern-android.json`, and reads back what installed
apps will see. The app's version comes from `internal/version/version.go`
(`versionCode` = major×10000 + minor×100 + patch).

Tested on an Android 15 emulator: a signed 2.8.0 offered a locally served
stand-in 2.8.1 (built with `LECTERN_ANDROID_VERSION=2.8.1`,
`LECTERN_UPDATE_MANIFEST` and `LECTERN_UPDATE_APK_PREFIX` pointing at it),
went through Allow from this source and Android's confirmation, and came
back as 2.8.1, still paired.

## Push notifications (UnifiedPush and ntfy)

The app receives push through [UnifiedPush](https://unifiedpush.org), so no
Expand Down Expand Up @@ -204,6 +241,10 @@ approvals, and a revoked device gets a 401.
mode the page's origin is your Lectern's address, so API calls, SSE and
terminals are ordinary same-origin requests, but every app file is still
answered from the APK. The service worker is not installed in the app.
- **The screen's edges.** Android 15 draws apps under the status bar, the
camera cutout and the gesture bar. The WebView sits in a frame padded by
those insets (and the keyboard's), whose colour follows the page's theme
(`LecternNative.barColors`), so nothing the page draws is under them.
- **`window.LecternNative`** (`frontend/src/native/bridge.ts`) is how the page
reaches the native side. It refuses calls from any origin but the connected
one, and the WebView opens every other link in the phone's browser.
Expand Down Expand Up @@ -287,8 +328,8 @@ host, physical devices, and Android versions other than 14.
## Limits

- **Web app updates arrive with the APK.** The app runs the web app it was
built with. A host much newer or older than the app can differ in API; build
the APK from the same commit as the host.
built with. A host much newer or older than the app can differ in API; keep
the app updated (it offers each release) along with the host.
- **Media over the relay.** Images, video and downloads that the web app loads
by URL rather than `fetch` (the browser's service worker handles those) do
not load in relay mode yet.
Expand Down
166 changes: 166 additions & 0 deletions docs/mods.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Mods

A mod is a small JavaScript module, shipped inside a [plugin](plugins.md), that
changes how Lectern behaves and looks: it watches what happens, rewrites or
blocks it, draws its own buttons, badges and panes, and adds commands. The same
module runs in the web app and in the terminal console (`lectern console`).

```js
// mods/blast-radius.js
export function register(on) {
on("prompt.submit", async ($, e, next) => {
if (/rm\s+-rf\s+\//.test(e.text)) return { deny: "That would delete the whole disk." };
return next(e);
});
on("ui.render", "session.card", async ($, e, next) => {
const out = await next(e);
const { Badge } = $.ui.resolve(e);
if (e.props.session.agent === "codex") out.append.push(Badge({ text: "codex", tone: "accent" }));
return out;
});
}
```

Code: `internal/pluginpkg` (manifest, validation), `internal/plugins/mods.go`
(serving), `frontend/src/mods/` (web runtime), `internal/console/mods/`
(console runtime).

## The package

```yaml
id: acme.blast-radius
name: Blast radius
version: 0.1.0
capabilities:
mods: [web, cli] # where its code runs: your browser, your terminal console
api: read # optional: $.api may read Lectern's API ("write" may also change things)
contributes:
mods:
- id: blast-radius
path: mods/blast-radius.js
surfaces: [web, cli] # default: both
```

`lectern plugin validate` refuses a mod whose surfaces the `mods` capability
does not list, a file that is missing, larger than 256 KB, not `.js`/`.mjs`, or
that does not parse. A mod is one file: it may not `import` anything (bundle
with esbuild first if you need a library). It defines `register`, either as
`export function register(on, options)`, `export default function (on, options)`,
or a plain `function register(on, options)`.

Install, consent, updates and the content hash work exactly as for every other
plugin: the code that runs is the code a person looked at and allowed, and a
changed file stops the mod until someone trusts it again.

## Hooks

`on(event, [matcher], handler)` adds a handler. Handlers of every mod form one
chain per event, in install order; `next(e)` passes the event on, and the end
of the chain is Lectern itself. A handler can:

- **observe**: `const r = await next(e); …; return r;`
- **rewrite**: `return next({ ...e, text: e.text.trim() });`
- **answer**: return without calling `next`, e.g. `return { deny: "reason" }`.

`matcher` narrows an event: the component name for `ui.render`, the command id
for `command.run`, the stream type for `server.event`. It is a string or a
RegExp.

| Event | `e` | What Lectern does at the end of the chain | A handler may |
| --- | --- | --- | --- |
| `app.start` | `{surface, version}` | nothing | observe |
| `server.event` | `{type, data}` — every live update (`session`, `task`, `approval`, …) | nothing | observe |
| `prompt.submit` | `{session_id, text}` — a message a person sends to a session | sends `text` | rewrite `text`, or `{deny}` |
| `approval.decide` | `{approval, decision}` — `decision` is `allow` or `deny` | records the decision | `{deny}` to stop it (a person decides again) |
| `command.run` | `{id, args}` — a command from the palette | runs it | rewrite, or answer |
| `ui.render` | `{component, props, surface, viewport}` | returns `{hidden:false, append:[]}` | hide the component, append elements |

`{deny}` is a safety net for the person at the keyboard, not a permission
system: agents never go through these hooks. Use permission rules for a hard
block. In the web app `prompt.submit` and `approval.decide` are hooked in the
API client every screen uses; the approval strip inside a terminal tab and
keys typed straight into a terminal do not pass through them.

### Components

| `component` | Where | `props` |
| --- | --- | --- |
| `session.card` | a session in Sessions (web) and the console list (cli) | `{session}` |
| `status` | the top bar (web) and the console footer (cli) | `{}` |
| `pane` | a pane opened with `$.ui.open` | `{id}` |

For `pane` the result's `append` is the pane's content.

## `$`

The module runs in a sandbox of its own: in the browser, an opaque-origin
iframe with no access to Lectern's page, cookies or network; in the console,
an embedded JavaScript engine with no file system or network. Everything goes
through `$`.

| | |
| --- | --- |
| `$.surface` | `"web"` or `"cli"` |
| `$.mod` | `{plugin, id}` |
| `$.ui.resolve(e)` | element constructors: `Box`, `Text`, `Badge`, `Button`, `Link` |
| `$.ui.toast(text)` | a notification |
| `$.ui.status(text)` | this mod's segment of the status component (`null` clears it) |
| `$.ui.open({id, title})` / `$.ui.close(id)` | a pane, drawn by `ui.render` with `component: "pane"` |
| `$.ui.render()` | draw again (`$.state.set` does this for you) |
| `$.state.get(key)` / `$.state.set(key, value)` | JSON values kept for this mod on this device |
| `$.command.register({id, title, run})` | a palette command; `run($, args)` |
| `$.navigate(hash)` | open a Lectern view (`#sessions`, `#session/4`); the console ignores it |
| `$.api.get(path)` | needs `api: read`; `path` starts with `/api/` |
| `$.api.post/put/delete(path, body)` | needs `api: write` |
| `$.sleep(ms)` | wait |
| `$.log(...)` | shown under the mod in Settings → Plugins |

The API is called as the person using the app. Plugin management, approval
decisions, sign-in and secrets are never reachable from a mod.

### Elements

Elements are plain objects, the same on both surfaces; the console draws what
a terminal can and ignores the rest.

| | props |
| --- | --- |
| `Box` | `direction` (`row`/`column`), `gap`, `children` |
| `Text` | `text`, `tone` (`dim`, `accent`, `warn`, `danger`, `ok`), `bold`, `mono` |
| `Badge` | `text`, `tone` |
| `Button` | `label`, `onPress`, `hotkey` (console) |
| `Link` | `label`, `href` (`#view` or `https://`) |

## In the console

`lectern console` runs the same modules on an embedded JavaScript engine, one
per mod, with no file system or network. It differs from the web app where a
terminal has to:

- It polls rather than streams, so a newly enabled mod starts within about 15
seconds, and `server.event` carries only `session` (a status changed) and
`approval` (a new one is waiting).
- A session row draws its mod elements after the title on one line. Its
buttons are in the palette rather than on hotkeys, which would clash with
the dashboard's own; inside a pane, `hotkey` works and Esc closes it.
- `Link` is drawn as its label, and `Text`'s `mono` is ignored.
- `lectern console --plain` does not load mods.

State lives beside the console's saved view, in your config directory.

## Limits

- A handler has 2 seconds; one that throws or runs over is skipped (the event
continues as if it had called `next(e)`) and the error is recorded. A mod
that fails 5 times in a minute is paused until the page or console restarts.
- `ui.render` handlers are synchronous in practice: a render waits at most
100 ms for them and draws without the late ones.
- State is at most 256 KB per mod.

## Writing one

```sh
lectern plugin new my-mod --mod # a plugin with one mod and a test
lectern plugin validate ./my-mod
lectern plugin install ./my-mod # preview, then Allow
```
10 changes: 6 additions & 4 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ contributes:
- {id: docs, title: "Release notes: open docs", href: "https://example.com/release-notes"}
```

Everything is declarative. Code runs only as a declared hook, a declared MCP
server, or a command an agent or person runs from a skill. A plugin has no
in-process code and no UI code of its own.
Everything else is declarative. Code runs only as a declared hook, a declared
MCP server, a command an agent or person runs from a skill, or a
[mod](mods.md): a JavaScript module that hooks the web app and the terminal
console from a sandbox of its own.

### Contributions

Expand All @@ -65,6 +66,7 @@ in-process code and no UI code of its own.
| `quick_commands` | commands in the terminal key row and Snippets sheet | — |
| `themes` | presets in Settings → Appearance | — |
| `palette_commands` | entries in the command palette that open a Lectern view or a link | — |
| `mods` | JavaScript that hooks events and draws in the web app and `lectern console` ([mods.md](mods.md)) | `mods`, and `api` to call Lectern's API |

`lectern plugin validate` refuses a manifest whose contributions need a
capability it does not declare, so the capability list is never a summary
Expand Down Expand Up @@ -192,7 +194,7 @@ files stops the provider.

A plugin is enabled everywhere or for chosen projects. Project scope applies to
the contributions that belong to a project — MCP servers, skills, workflows,
hooks and quick commands. Agents, themes and palette commands are global.
hooks and quick commands. Agents, themes, palette commands and mods are global.

Turning a plugin off stops its MCP servers, hooks, quick commands, themes and
palette commands at once, and turns its skills and workflows off in every
Expand Down
5 changes: 1 addition & 4 deletions e2e/session_sheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,9 @@ def nav_to(page, name):
if tab.count() and tab.is_visible():
tab.click()
return
# More: a menu on a narrow screen, a group that opens in place in the
# desktop sidebar.
# More, on a narrow screen; a desktop sidebar lists every page.
if page.locator("#nav-overflow").count():
page.locator("#nav-overflow > summary").click()
elif not page.locator("#nav-more-group").evaluate("el => el.open"):
page.locator("#nav-more-group > summary").click()
page.locator(f'#tabbar [data-nav-target="{name}"]:visible').click()


Expand Down
Loading
Loading