diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03956982..c01cab4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,37 +90,11 @@ jobs: shell: pwsh run: ./tests/check-powershell-client.ps1 - # The PTY host is the session backend on Windows and macOS - # (docs/ptyhost.md). These machines are disposable, so the tests that start - # real programs run directly; on Linux they run in the reviewed isolated - # runner above. The smoke test builds lectern, serves it, opens a shell, - # types through the API and the web terminal, restarts the server and finds - # the same shell. + # The PTY host is the session backend on Windows and macOS; these are the + # native tests for it (pty-backend.yml). The release workflow runs the same + # jobs and will not publish unless they pass. pty-backend: - strategy: - fail-fast: false - matrix: - os: [windows-latest, macos-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: "1.25" - cache: true - - name: Session backend, PTY host, web terminal and server smoke tests - shell: bash - env: - LECTERN_PTYHOST_TESTS: "1" - LECTERN_SERVER_SMOKE: "1" - run: >- - go test -count=1 -v - ./internal/sessions/backend/ - ./internal/ptyhost/... - ./internal/terminal/webterm/ - ./internal/gitbash/ - ./internal/smoke/ - ./cmd/lectern/localruntime/ + uses: ./.github/workflows/pty-backend.yml e2e: runs-on: ubuntu-latest diff --git a/.github/workflows/pty-backend.yml b/.github/workflows/pty-backend.yml new file mode 100644 index 00000000..4e1f320a --- /dev/null +++ b/.github/workflows/pty-backend.yml @@ -0,0 +1,48 @@ +name: PTY backend + +# The PTY host is the session backend on Windows and macOS +# (docs/ptyhost.md). These machines are disposable, so the tests that start +# real programs run directly; on Linux they run in the reviewed isolated +# runner (ci.yml). The smoke test builds lectern, serves it, opens a shell, +# types through the API and the web terminal, restarts the server and finds +# the same shell. The attach-client test drives Lectern's own key bar and +# Ctrl+] controls on a real ConPTY / pseudo-terminal, without tmux. +# +# Called by ci.yml on every push and pull request, and by release.yml, which +# does not publish anything unless both platforms pass. +on: + workflow_call: + workflow_dispatch: +permissions: + contents: read +jobs: + pty-backend: + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache: true + - name: Session backend, PTY host, web terminal and server smoke tests + shell: bash + env: + LECTERN_PTYHOST_TESTS: "1" + LECTERN_SERVER_SMOKE: "1" + run: >- + go test -count=1 -v + ./internal/sessions/backend/ + ./internal/ptyhost/... + ./internal/terminal/webterm/ + ./internal/gitbash/ + ./internal/smoke/ + ./cmd/lectern/localruntime/ + - name: Attach client without tmux + shell: bash + env: + LECTERN_PTYHOST_TESTS: "1" + run: go test -count=1 -v -run '^TestBareAttachmentControls' ./cmd/lectern/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f650aa44..592ff187 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,8 +16,13 @@ jobs: first-install: uses: ./.github/workflows/first-install.yml + # Windows and macOS run on the PTY host; nothing ships unless its native + # tests pass on both. + pty-backend: + uses: ./.github/workflows/pty-backend.yml + binaries: - needs: first-install + needs: [first-install, pty-backend] runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -90,7 +95,7 @@ jobs: /tmp/instcheck/lectern version | grep -q "${GITHUB_REF_NAME#v}" image: - needs: first-install + needs: [first-install, pty-backend] runs-on: ubuntu-latest timeout-minutes: 30 steps: diff --git a/.verify.yaml b/.verify.yaml index 5682cc2e..9c529af2 100644 --- a/.verify.yaml +++ b/.verify.yaml @@ -71,9 +71,9 @@ steps: - wait: 2.5 expect: vision: > - the Lectern web app with a desktop sidebar showing Sessions, Approvals, Tasks, Terminals - and Settings directly, with no More flyout, and a list of agent sessions or a clear "Start an agent" - action; no error text, no blank white screen + the Lectern web app with a short desktop sidebar whose main items are Sessions, Approvals and + Settings plus a "More" group (Tasks or Terminals may also appear while in use), and a list of + agent sessions or a clear "Start an agent" action; no error text, no blank white screen - name: tasks board renders live actions: diff --git a/README.md b/README.md index ad2a8d41..c3bbd4dd 100644 --- a/README.md +++ b/README.md @@ -2,33 +2,89 @@ # Lectern -**The self-hosted control plane for coding agents.** +**Lectern runs coding agents such as Claude Code, Codex and Gemini CLI on your +own computer, and lets you follow them, approve what they do and review their +changes from your terminal, browser or phone.** -Run Claude Code, Codex, Gemini CLI and other agents across your workstation, -SSH servers and disposable sandboxes. Dispatch work, choose where it runs, -and supervise it from your terminal or phone. - -![version](https://img.shields.io/github/v/release/JeremiahM37/lectern) +[![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) ![license](https://img.shields.io/badge/license-MIT-blue) ![go](https://img.shields.io/badge/single%20binary-Go-00add8) -```bash +## Quick start + +You need [Git](https://git-scm.com/downloads) and, for real work, an agent CLI +such as Claude Code, Codex or Gemini CLI. Without one you can still try Lectern +with its built-in demo agent. + +**macOS and Linux** + +```sh curl -fsSL https://raw.githubusercontent.com/JeremiahM37/lectern/main/install.sh | sh -lectern up ``` -[Linux](docs/getting-started-linux.md) · [macOS](docs/getting-started-macos.md) · [Windows](docs/getting-started-windows.md) +Or on macOS with Homebrew: `brew install JeremiahM37/tap/lectern` + +**Windows** (PowerShell, with [Git for Windows](https://git-scm.com/download/win) installed) + +```powershell +irm https://raw.githubusercontent.com/JeremiahM37/lectern/main/install.ps1 | iex +``` + +Or with Scoop: `scoop bucket add jeremiahm37 https://github.com/JeremiahM37/scoop-bucket && scoop install lectern` + +**Then, in a project folder:** + +```sh +cd ~/myapp +lectern claude # start your first agent here (or: lectern codex, lectern gemini) +lectern up # open the web dashboard in your browser +lectern phone # show a QR code to pair a phone on the same Wi-Fi +``` + +The agent keeps running when you leave the terminal (**Ctrl+]** then **d**), +and the same session shows up in the browser and on your phone. On a new +install the agent asks before risky actions, such as running a command or +editing a file, and you can answer from any of them. + +**New to Lectern?** [Getting started](docs/getting-started.md) walks through +your first session: install, start an agent, approve from the browser and the +phone, then review and commit the change. + +`lectern doctor` checks your setup and prints a fix for anything missing. +`lectern update` installs a new release. `lectern help` lists every command. ![Choose a machine, dispatch agent work, and review the result in Lectern](docs/media/control-plane/dispatch-review.gif) [Watch the walkthrough](docs/media/control-plane/control-plane.mp4) · [Screenshots and recording details](docs/media/control-plane/README.md) *Recorded from the current app with disposable demo projects and scripted agents. -The recording demonstrates the control workflow, not model performance or a live cluster.* +The recording demonstrates the workflow, not model performance.* + +## What you can do + +- **Watch and talk to agents.** Every session has a terminal and a chat view. + See which sessions are working, idle or waiting for you. +- **Approve or deny.** A pending request shows the command or the diff, with + **Allow once**, **Allow for this session** or **Deny**, on the desktop and + on the phone. +- **Review and commit.** **Review & merge** shows what the agent changed. Leave + 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. + +A pending approval in the phone layout -## Your agents. Your machines. One place to run the work. +[Terminal client](docs/terminal-client.md) · [Phone supervision](docs/mobile-sessions.md) · [Review](docs/review.md) + +## Going further + +Everything above runs on one computer. Lectern can also manage agents across +several machines. + +### Run work where it belongs A coding task needs somewhere to run, a workspace of its own, and a way to bring you back when it needs a decision. Lectern connects those pieces across @@ -59,8 +115,6 @@ runs its queued tasks there. Automatic placement by GPU, RAM or OS requirements is a future direction, not a current feature. Worktrees separate changes; use a sandbox when you also need execution isolation. -## Run work where it belongs - | Execution environment | What Lectern does | |---|---| | **Your workstation** | Runs the installed agent CLIs locally, with persistent terminals on Linux, macOS and Windows. | @@ -68,11 +122,11 @@ use a sandbox when you also need execution isolation. | **Existing Proxmox LXC** | Executes through `pct` from the Proxmox host. | | **Disposable sandbox** | Creates a Proxmox template clone, Docker container, or environment supplied by trusted script hooks for each attempt. Saves results before configured cleanup. | -[SSH machines](docs/ssh.md) · [Sandbox providers and lifecycle](docs/sandboxes.md) · [Isolation options and limits](docs/isolation.md) +[Run a shared server](docs/quickstart.md) · [SSH machines](docs/ssh.md) · [Sandbox providers and lifecycle](docs/sandboxes.md) · [Isolation options and limits](docs/isolation.md) ![Machines and projects in the current desktop UI](docs/media/control-plane/machines.png) -## Keep the agent choice yours +### Keep the agent choice yours Claude Code, Codex and Gemini CLI are built in. Add OpenCode, Aider, Goose, Cursor and other runners from the catalog, or configure a custom CLI. Agent @@ -84,22 +138,19 @@ ended sessions when the agent has resumable history. [Agent catalog and capabilities](docs/agents.md) · [Delegated builds](docs/DELEGATED_BUILDS.md) · [Replay evals](docs/replay-evals.md) -## Stay in control from your desk or phone - -The terminal dashboard, desktop web app and installable phone PWA look at the -same work. See which sessions need you, read tool calls and diffs, and approve -or deny requests. Phone pairing and an optional encrypted relay support access -without requiring Tailscale. +### Reach it from anywhere -A pending approval in the phone layout +Phone pairing on the same Wi-Fi needs nothing extra. Away from home, use +Tailscale, a public tunnel with device pairing, or the optional end-to-end +encrypted relay. Start work from a claude.ai or supported ChatGPT connector, too: send a design or attachment into a session on your machine. Chat connectors cannot approve agent actions. -[Terminal client](docs/terminal-client.md) · [Phone supervision](docs/mobile-sessions.md) · [Remote access](docs/remote-access.md) · [Chat connectors](docs/use-from-chat.md) +[Remote access](docs/remote-access.md) · [Relay](docs/relay.md) · [Chat connectors](docs/use-from-chat.md) -## The everyday details are here, too +### The everyday details - **Files travel with the work.** Open a remote PDF or file path an agent prints; the native client opens it locally, while the web app has a built-in viewer. @@ -115,30 +166,26 @@ agent actions. [More screenshots and file demonstrations](docs/media/control-plane/README.md) · [Full guide](docs/guide.md) -## Install +## Other ways to install | | | |---|---| -| **Linux / macOS** | `curl -fsSL https://raw.githubusercontent.com/JeremiahM37/lectern/main/install.sh \| sh` | -| **Homebrew** | `brew install JeremiahM37/tap/lectern` | -| **Windows** | `irm https://raw.githubusercontent.com/JeremiahM37/lectern/main/install.ps1 \| iex` (needs [Git for Windows](https://git-scm.com/download/win)) | | **Docker** | `docker run -d -p 127.0.0.1:9110:9110 -e LECTERN_INSECURE_LISTEN=1 -v lectern-data:/data ghcr.io/jeremiahm37/lectern:latest` | | **Go** | `go install github.com/JeremiahM37/lectern/v2/cmd/lectern@latest` | +| **deb / rpm** | Packages are attached to each [release](https://github.com/JeremiahM37/lectern/releases/latest). | On the machine that runs Lectern, agents need only `git` and the agent's own CLI: Lectern keeps their terminals alive itself, on Linux, macOS and Windows ([how](docs/ptyhost.md)). Other machines reached over SSH need the `lectern` -binary installed, or `tmux` and `python3`. The web terminal is built in too; -nothing else to install. -`lectern doctor` checks everything and prints a fix next to anything that's -wrong, and `lectern update` installs a new release. You can try it with no -setup at all, using fake agents: `LECTERN_MOCK=1 lectern serve` (it listens on -127.0.0.1 only). +binary installed, or `tmux` and `python3`. The web terminal is built in. +To try it with fake agents and no setup: `LECTERN_MOCK=1 lectern serve` (it +listens on 127.0.0.1 only). ## Documentation | | | |---|---| +| [Getting started](docs/getting-started.md) | Install, first agent, approvals on the web and phone, review and commit | | [Full guide](docs/guide.md) | Everything in depth: sessions, tasks, auth, phone alerts, delegated builds, local models | | [Use from claude.ai / ChatGPT](docs/use-from-chat.md) | The chat connector: setup, what a chat can do, troubleshooting | | [Terminal client](docs/terminal-client.md) | Dashboard keys, multi-window, `lectern claude`, send-file | @@ -146,7 +193,7 @@ setup at all, using fake agents: `LECTERN_MOCK=1 lectern serve` (it listens on | [Mobile sessions](docs/mobile-sessions.md) | Chat cards, approvals, voice mode | | [Browser](docs/browser.md) | Browser pane, Design Mode, agent browser tools, computer use | | [Remote access](docs/remote-access.md) | Phone pairing and tunnels without Tailscale | -| [Local / Docker](docs/local.md) · [Docker](docs/docker.md) | Standalone and container setups | +| [Local runtime](docs/local.md) · [Shared server](docs/quickstart.md) · [Docker](docs/docker.md) | How the private runtime works, building from source, a server for several machines, containers | Lectern was called AgentDeck until v2.3. Old `AGENTDECK_*` settings still work. diff --git a/cmd/lectern/agent_quick.go b/cmd/lectern/agent_quick.go index e99aac07..e16aa78f 100644 --- a/cmd/lectern/agent_quick.go +++ b/cmd/lectern/agent_quick.go @@ -329,6 +329,8 @@ func checkAgentInstalled(c *console.Client, agentName string) error { msg := fmt.Sprintf("lectern %s: %s isn't installed where Lectern runs. To use it, %s, then run lectern up once so Lectern finds it.", agentName, agentName, agentInstallHint(agentName)) if len(installed) > 0 { msg += fmt.Sprintf("\nInstalled now: %s — for example: lectern %s", strings.Join(installed, ", "), installed[0]) + } else { + msg += "\nTo see how Lectern works first, try the demo agent, which needs nothing installed: lectern demo" } return errors.New(msg) } diff --git a/cmd/lectern/client.go b/cmd/lectern/client.go index e954d84f..6b9e0681 100644 --- a/cmd/lectern/client.go +++ b/cmd/lectern/client.go @@ -262,42 +262,18 @@ func clientCommandAt(cfg *config.Config, command string, args []string, base, to return restoreCommand(cfg, args, base, token, local, os.Stdout, interactiveTerminal()) case "phone": return phoneCommand(c, args, os.Stdout) + case "demo": + return demoCommand(cfg, args, base, token, local, os.Stdout) case "controls": return controlsCommand(c, args) case "console", "tui": - attachClient := func(kind, id string) error { - var argv []string - var e error - controls := &nativeControls{Kind: kind, ID: id, Base: base, Token: token, Local: local} - if local { - localCfg := *cfg - localCfg.AuthToken = token - argv, e = attachmentCommandAt(&localCfg, []string{kind, id}, base, "") - } else { - argv, e = attachmentCommand(cfg, []string{kind, id}) - } - if e != nil { - return e - } - // The dashboard callback waits for the attachment instead of - // replacing the process: Bubble Tea must resume afterwards. - return startAttachment(argv, controls, false) - } if len(args) > 0 && (len(args) != 1 || args[0] != "--plain") { return fmt.Errorf("usage: lectern console [--plain]") } if len(args) == 0 && interactiveTerminal() { - return console.RunDashboardWithOptions(c, os.Stdin, os.Stdout, console.DashboardOptions{ - Attach: attachClient, - OpenTerminal: func(kind, id string, batch bool) error { return openTerminalTab(base, token, kind, id, batch) }, - OpenBatch: func(ids []string) error { return openTerminalBatch(base, token, ids) }, - TerminalWorkspace: os.Getenv("TMUX") == "" && !desktopTerminalAvailable(), - BatchOpen: os.Getenv("LECTERN_INITIAL_BATCH") == "true", - InitialSessionID: os.Getenv("LECTERN_INITIAL_SESSION"), - Cwd: dashboardCwd(base, local), - }) - } - return console.NewUI(c, os.Stdin, os.Stdout, attachClient).Run() + return runConsoleDashboard(cfg, base, token, local, console.DashboardOptions{}) + } + return console.NewUI(c, os.Stdin, os.Stdout, consoleAttach(cfg, base, token, local)).Run() case "api": if len(args) < 2 || len(args) > 3 { return fmt.Errorf("usage: lectern api METHOD /path [JSON|@file|-]") @@ -539,3 +515,40 @@ func dashboardCwd(base string, local bool) string { } return dir } + +// consoleAttach is how the dashboard attaches this terminal to a session or +// task: it waits for the attachment instead of replacing the process, since +// Bubble Tea must resume afterwards. +func consoleAttach(cfg *config.Config, base, token string, local bool) func(kind, id string) error { + return func(kind, id string) error { + var argv []string + var e error + controls := &nativeControls{Kind: kind, ID: id, Base: base, Token: token, Local: local} + if local { + localCfg := *cfg + localCfg.AuthToken = token + argv, e = attachmentCommandAt(&localCfg, []string{kind, id}, base, "") + } else { + argv, e = attachmentCommand(cfg, []string{kind, id}) + } + if e != nil { + return e + } + return startAttachment(argv, controls, false) + } +} + +// runConsoleDashboard is `lectern console` on a terminal; opts adds to the +// usual options (restore uses it to open a session's history picker). +func runConsoleDashboard(cfg *config.Config, base, token string, local bool, opts console.DashboardOptions) error { + opts.Attach = consoleAttach(cfg, base, token, local) + opts.OpenTerminal = func(kind, id string, batch bool) error { return openTerminalTab(base, token, kind, id, batch) } + opts.OpenBatch = func(ids []string) error { return openTerminalBatch(base, token, ids) } + opts.TerminalWorkspace = os.Getenv("TMUX") == "" && !desktopTerminalAvailable() + opts.BatchOpen = os.Getenv("LECTERN_INITIAL_BATCH") == "true" + if opts.InitialSessionID == "" { + opts.InitialSessionID = os.Getenv("LECTERN_INITIAL_SESSION") + } + opts.Cwd = dashboardCwd(base, local) + return console.RunDashboardWithOptions(console.New(base, token), os.Stdin, os.Stdout, opts) +} diff --git a/cmd/lectern/demo.go b/cmd/lectern/demo.go new file mode 100644 index 00000000..83d74a2b --- /dev/null +++ b/cmd/lectern/demo.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/JeremiahM37/lectern/v2/internal/config" + "github.com/JeremiahM37/lectern/v2/internal/console" + "github.com/JeremiahM37/lectern/v2/internal/sessions" +) + +// demoCommand is `lectern demo`: the web app's "Try a demo agent", in the +// terminal. It starts the stand-in agent (internal/sessions/demo.go) in a +// throwaway folder — or comes back to the one already running — and attaches, +// so someone with no agent CLI installed can see sessions, approvals and +// review work end to end. +func demoCommand(cfg *config.Config, args []string, base, token string, local bool, out io.Writer) error { + fresh := false + for _, a := range args { + switch a { + case "--new": + fresh = true + default: + return fmt.Errorf("lectern demo: unsupported argument %q (supported: --new)", a) + } + } + c := console.New(base, token) + sess, reused, err := startDemoSession(c, fresh) + if err != nil { + return err + } + if reused { + fmt.Fprintf(out, "Back to the demo agent (session #%d).\n", sess.ID) + } else { + fmt.Fprintf(out, "Started the demo agent (session #%d). It needs nothing installed and uses no AI.\n", sess.ID) + } + fmt.Fprintln(out, "Type a message and press Enter. Before it writes a file it asks you: press Ctrl+] y to allow it.") + fmt.Fprintln(out, "Ctrl+] d leaves it running; lectern demo brings you back. For a real agent, install Claude Code or Codex and run lectern claude or lectern codex.") + if !interactiveTerminal() { + fmt.Fprintf(out, "Attach with: lectern attach session %d\n", sess.ID) + return nil + } + return attachAgentSession(cfg, base, token, local, sess.ID) +} + +// startDemoSession reuses a running demo session unless fresh is set, and +// otherwise starts one exactly as the web app's demo button does. +func startDemoSession(c *console.Client, fresh bool) (*quickSessionView, bool, error) { + if !fresh { + data, err := c.JSON("GET", "/sessions", nil) + if err != nil { + return nil, false, err + } + var rows []quickSessionView + if err := json.Unmarshal(data, &rows); err != nil { + return nil, false, err + } + for i := range rows { + if rows[i].Agent == sessions.DemoAgent { + return &rows[i], true, nil + } + } + } + data, err := c.JSON("POST", "/sessions", map[string]any{"agent": sessions.DemoAgent, "scratch": true, "name": "demo"}) + if err != nil { + return nil, false, fmt.Errorf("start the demo agent: %w", err) + } + var created quickSessionView + if err := json.Unmarshal(data, &created); err != nil { + return nil, false, err + } + return &created, false, nil +} diff --git a/cmd/lectern/demo_test.go b/cmd/lectern/demo_test.go new file mode 100644 index 00000000..58838271 --- /dev/null +++ b/cmd/lectern/demo_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/JeremiahM37/lectern/v2/internal/config" + "github.com/JeremiahM37/lectern/v2/internal/console" +) + +// lectern demo starts the web app's demo the same way (scratch folder, the +// demo agent) and comes back to a running one rather than starting another. +func TestDemoStartsOrReusesTheDemoSession(t *testing.T) { + var running []map[string]any + var created []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/api/sessions": + _ = json.NewEncoder(w).Encode(running) + case r.Method == "POST" && r.URL.Path == "/api/sessions": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + created = append(created, body) + fmt.Fprint(w, `{"id":5,"name":"demo","agent":"demo"}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + var out bytes.Buffer + if err := demoCommand(&config.Config{}, nil, srv.URL, "", false, &out); err != nil { + t.Fatal(err) + } + if len(created) != 1 || created[0]["agent"] != "demo" || created[0]["scratch"] != true { + t.Fatalf("created %v", created) + } + if !strings.Contains(out.String(), "Started the demo agent (session #5)") || !strings.Contains(out.String(), "Ctrl+] y") { + t.Fatalf("output:\n%s", out.String()) + } + running = []map[string]any{{"id": 3, "name": "myapp", "agent": "claude"}, {"id": 4, "name": "demo", "agent": "demo"}} + out.Reset() + if err := demoCommand(&config.Config{}, nil, srv.URL, "", false, &out); err != nil { + t.Fatal(err) + } + if len(created) != 1 || !strings.Contains(out.String(), "Back to the demo agent (session #4)") { + t.Fatalf("did not reuse: created %v\n%s", created, out.String()) + } + if err := demoCommand(&config.Config{}, []string{"--new"}, srv.URL, "", false, &out); err != nil || len(created) != 2 { + t.Fatalf("--new: %v %v", err, created) + } +} + +// With no agent installed at all, `lectern claude` points at the demo. +func TestMissingAgentSuggestsTheDemo(t *testing.T) { + agents := `[{"name":"claude","found":false,"builtin":true},{"name":"codex","found":false,"builtin":true}]` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"agents":%s}`, agents) + })) + t.Cleanup(srv.Close) + err := checkAgentInstalled(console.New(srv.URL, ""), "claude") + if err == nil || !strings.Contains(err.Error(), "lectern demo") { + t.Fatalf("no agent: %v", err) + } + agents = `[{"name":"claude","found":false,"builtin":true},{"name":"codex","found":true,"builtin":true}]` + err = checkAgentInstalled(console.New(srv.URL, ""), "claude") + if err == nil || strings.Contains(err.Error(), "lectern demo") || !strings.Contains(err.Error(), "lectern codex") { + t.Fatalf("another agent installed: %v", err) + } +} diff --git a/cmd/lectern/doctor.go b/cmd/lectern/doctor.go index 0c94ed9f..6b3b1e28 100644 --- a/cmd/lectern/doctor.go +++ b/cmd/lectern/doctor.go @@ -137,18 +137,7 @@ func agentChecks(cfg *config.Config, agents []onboard.AgentCheck, stat func(stri return out } -func agentInstallHint(name string) string { - switch name { - case "claude": - return "install Claude Code (https://docs.claude.com/claude-code) and make sure `claude` is on PATH, or set LECTERN_CLAUDE_BIN" - case "codex": - return "install the Codex CLI (https://github.com/openai/codex) and make sure `codex` is on PATH, or set LECTERN_CODEX_BIN" - case "gemini": - return "install the Gemini CLI and make sure `gemini` is on PATH, or set LECTERN_GEMINI_BIN" - default: - return fmt.Sprintf("install %s and make sure it's on PATH, or configure a custom agent (see docs/agents.md) pointing at wherever it lives", name) - } -} +func agentInstallHint(name string) string { return onboard.InstallHint(name) } func doctorCommand(cfg *config.Config, args []string) error { if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") { diff --git a/cmd/lectern/help.go b/cmd/lectern/help.go index abbea0f4..e056287f 100644 --- a/cmd/lectern/help.go +++ b/cmd/lectern/help.go @@ -30,7 +30,6 @@ const ( var helpGroups = []string{groupStart, groupSessions, groupProjects, groupAdvanced} var commandDocs = []commandDoc{ - {Name: "phone", Group: groupStart, Synopsis: "phone", Summary: "Connect your phone to this Lectern", Usage: []string{"lectern phone"}, About: "Shows a single-use pairing QR code. For a private local runtime, enables authenticated access on this Wi-Fi network using the same sessions and database. Wi-Fi HTTP is unencrypted: use a trusted network. The listener closes when the local runtime stops."}, { Name: "up", Group: groupStart, Synopsis: "up", Summary: "Start Lectern on this computer and open it in your browser", @@ -41,7 +40,10 @@ browser on "Start an agent", already signed in. Run it again any time: it picks up agents you have installed since, and signs in another browser. --no-browser Print the sign-in link instead of opening a browser (over SSH) - --service Also start Lectern when you log in (systemd user unit; launchd on macOS)`, + --service Also start Lectern when you log in: a systemd user unit on + Linux, a launchd agent on macOS, and on Windows an entry in + your account's startup list (the HKCU ...\CurrentVersion\Run + registry key; no administrator rights needed)`, Examples: []string{"cd ~/myapp && lectern up", "lectern up --no-browser"}, }, { @@ -61,6 +63,18 @@ phone too. Leave with Ctrl+] d, come back with the same command. Any agent added under Settings → Agents works the same way: lectern NAME.`, Examples: []string{"cd ~/myapp && lectern claude", "lectern codex --resume", "lectern claude --model opus --new"}, }, + { + Name: "demo", Group: groupStart, Synopsis: "demo", + Summary: "Try Lectern with a demo agent that needs nothing installed", + Usage: []string{"lectern demo [--new]"}, + About: `Starts a stand-in agent in a throwaway folder and connects this terminal to +it — the same demo as "Try a demo agent" on the web page. It uses no AI: +type a message and it writes it to a file, asking you first, so you can try +approvals (Ctrl+] y allows), leaving and coming back, and reviewing a change. + + --new Start another demo instead of going back to the running one`, + Examples: []string{"lectern demo"}, + }, { Name: "doctor", Group: groupStart, Synopsis: "doctor", Summary: "Check this computer and say how to fix anything missing", @@ -74,6 +88,28 @@ Exits 0 when Lectern can run an agent here, and 1 when something required is missing or broken. Optional things never fail it.`, Examples: []string{"lectern doctor"}, }, + { + Name: "phone", Group: groupStart, Synopsis: "phone", + Summary: "Connect your phone to this Lectern, with a QR code to pair it", + Usage: []string{"lectern phone [--no-qr]", "lectern phone --off"}, + About: `Prints a QR code that pairs your phone with this Lectern (the same sessions +you already have). Scan it with the phone's camera: the link pairs the phone +once and works for 5 minutes. + +When Lectern already has an address a phone can reach (your tailnet, or a +server's network address) that one is used. Your private Lectern, which +otherwise only answers this computer, is made reachable on this computer's +Wi-Fi address instead. Only paired devices can use it from the network, but +the connection is not encrypted, so use it on a network you trust. It stops +when the private Lectern stops, or with --off. + +Away from home, use Tailscale (install it on this computer and the phone) or +a relay (lectern relay, see docs/relay.md) instead. + + --off Stop listening on the Wi-Fi address + --no-qr Print only the link`, + Examples: []string{"lectern phone", "lectern phone --off"}, + }, { Name: "update", Group: groupStart, Synopsis: "update", Summary: "Update this lectern to the latest release", @@ -118,8 +154,9 @@ screen readers and pipes.`, Usage: []string{"lectern restore [QUERY|ID] [--last] [--agent NAME] [--model M] [--profile ID] [--all] [--no-attach]"}, About: `With nothing else, lists what can be restored, newest first. A word restores the one closed session that matches it; a number restores that -session. --agent continues it in another agent, primed with its last handoff -or the end of its conversation.`, +session. When Lectern cannot tell which saved conversation to continue, it +shows them so you can pick one. --agent continues it in another agent, +primed with its last handoff or the end of its conversation.`, Examples: []string{"lectern restore", "lectern restore --last", "lectern restore parser", "lectern restore 42 --agent codex"}, }, { @@ -398,12 +435,26 @@ func docFor(words []string) *commandDoc { return d } +// commandSynonyms are words people reach for that are not Lectern's name for +// the thing, mapped to the command that does it. +var commandSynonyms = map[string]string{ + "pair": "phone", "mobile": "phone", "qr": "phone", + "resume": "restore", "reopen": "restore", + "dashboard": "console", "sessions": "console", "list": "console", "ls": "console", + "start": "up", "open": "up", + "upgrade": "update", "check": "doctor", + "try": "demo", "tutorial": "demo", +} + // didYouMean suggests the known command closest to a mistyped one, as // " Did you mean \"lectern restore\"?", or "" when nothing is close. func didYouMean(word string) string { if word == "" { return "" } + if name, ok := commandSynonyms[strings.ToLower(word)]; ok { + return fmt.Sprintf(" Did you mean \"lectern %s\"?", name) + } best, bestDist := "", 3 var names []string for _, d := range commandDocs { diff --git a/cmd/lectern/help_test.go b/cmd/lectern/help_test.go index fc464760..3c4b0e8a 100644 --- a/cmd/lectern/help_test.go +++ b/cmd/lectern/help_test.go @@ -83,4 +83,18 @@ func TestDidYouMean(t *testing.T) { if got := didYouMean("xyzzyplugh"); got != "" { t.Errorf("unrelated word got a suggestion: %q", got) } + // Words that are not a typo of anything but name what a command does. + for word, want := range map[string]string{"pair": "phone", "Resume": "restore", "try": "demo"} { + if got := didYouMean(word); !strings.Contains(got, `"lectern `+want+`"`) { + t.Errorf("didYouMean(%q) = %q, want %s", word, got, want) + } + } + for word, name := range commandSynonyms { + if findDoc(strings.Fields(name)) == nil { + t.Errorf("synonym %q points at %q, which has no help", word, name) + } + if clientVerbs[word] || reservedVerbs[word] || agentQuickVerbs[word] || findDoc([]string{word}) != nil { + t.Errorf("synonym %q is already a command", word) + } + } } diff --git a/cmd/lectern/local_agent_exit_test.go b/cmd/lectern/local_agent_exit_test.go new file mode 100644 index 00000000..b75affaf --- /dev/null +++ b/cmd/lectern/local_agent_exit_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "testing" + "time" +) + +// exitingAgent asks for permission, as a gated tool call does, then dies +// while the request is pending — the audit's agent killed by Ctrl+\\. +const exitingAgent = `#!/bin/sh +[ "$1" = --version ] && { echo 2.1.0; exit 0; } +curl -s -o /dev/null -X POST -H "Authorization: Bearer $LECTERN_HOOK_TOKEN" -H 'Content-Type: application/json' \ + -d '{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"echo hi"}}' \ + "$LECTERN_HOOK_URL/PermissionRequest" & +sleep 3 +kill -QUIT $$ +` + +// TestAgentExitIsStoppedOnEveryBackend: an agent that exits leaves its +// terminal at a shell prompt. The session must read Ended · agent exited +// (Stopped, with Revive), never Idle. +func TestAgentExitIsStoppedOnEveryBackend(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("local runtime test uses a POSIX shell agent") + } + for _, backendName := range []string{"pty", "tmux"} { + t.Run(backendName, func(t *testing.T) { + if backendName == "tmux" { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux is not installed") + } + } + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } + bin := filepath.Join(t.TempDir(), "lectern") + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, out) + } + state, home, fake, repo := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir() + // Exits by itself shortly after starting. + if err := os.WriteFile(filepath.Join(fake, "claude-exit-test"), []byte(exitingAgent), 0o755); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("git", "init", "-q", repo).CombinedOutput(); err != nil { + t.Fatalf("git init: %v %s", err, out) + } + env := append(localTestEnv(t, state), "HOME="+home, "LECTERN_SESSION_BACKEND="+backendName, + "LECTERN_SESSION_POLL=0.5", "LECTERN_CLAUDE_BIN=claude-exit-test", + "PATH="+fake+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Cleanup(func() { + _, _ = runLocalCLI(bin, env, "api", "DELETE", "/sessions/1?kill=true") + _, _ = runLocalCLI(bin, env, "local", "stop") + }) + created, err := runLocalCLI(bin, env, "api", "POST", "/sessions", fmt.Sprintf(`{"agent":"claude","workdir":%q,"permission_mode":"ask"}`, repo)) + if err != nil { + t.Fatalf("create: %v %s", err, created) + } + var s struct { + ID int64 `json:"id"` + } + _ = json.Unmarshal(created, &s) + id := strconv.FormatInt(s.ID, 10) + deadline := time.Now().Add(90 * time.Second) + var view []byte + for time.Now().Before(deadline) { + view, _ = runLocalCLI(bin, env, "api", "GET", "/sessions/"+id) + if bytes.Contains(view, []byte(`"state_reason":"agent_exited"`)) { + break + } + time.Sleep(500 * time.Millisecond) + } + if !bytes.Contains(view, []byte(`"state":"ended"`)) || !bytes.Contains(view, []byte(`"state_reason":"agent_exited"`)) { + t.Fatalf("an exited agent is not shown as stopped: %s", view) + } + // The approval it was blocked on went with it. + deadline = time.Now().Add(10 * time.Second) + var pending []byte + for time.Now().Before(deadline) { + pending, _ = runLocalCLI(bin, env, "api", "GET", "/approvals?status=pending") + if !bytes.Contains(pending, []byte(`"session_id":`+id)) { + break + } + time.Sleep(300 * time.Millisecond) + } + if bytes.Contains(pending, []byte(`"session_id":`+id)) { + t.Fatalf("approval outlived its agent: %s", pending) + } + if expired, _ := runLocalCLI(bin, env, "api", "GET", "/approvals?status=expired"); !bytes.Contains(expired, []byte(`"session_id":`+id)) { + t.Fatalf("the agent's approval was never expired: %s", expired) + } + // Revive with the agent's program gone is refused before the old + // terminal is closed. + if err := os.Remove(filepath.Join(fake, "claude-exit-test")); err != nil { + t.Fatal(err) + } + out, err := runLocalCLI(bin, env, "api", "POST", "/sessions/"+id+"/revive", `{}`) + if err == nil || !bytes.Contains(out, []byte("claude isn't installed")) { + t.Fatalf("revive without the agent: %v %s", err, out) + } + if view, _ := runLocalCLI(bin, env, "api", "GET", "/sessions/"+id); !bytes.Contains(view, []byte(`"ended_at":null`)) { + t.Fatalf("a refused revive closed the session: %s", view) + } + }) + } +} diff --git a/cmd/lectern/local_cli.go b/cmd/lectern/local_cli.go index f7246736..ec27cc96 100644 --- a/cmd/lectern/local_cli.go +++ b/cmd/lectern/local_cli.go @@ -69,7 +69,7 @@ func localCommand(cfg *config.Config, args []string) error { } // People get a sentence; scripts and pipes keep getting the JSON // they always did. - if !asJSON && interactiveTerminal() { + if !asJSON { fmt.Println(describeLocalStatus(status)) return nil } diff --git a/cmd/lectern/local_demo_test.go b/cmd/lectern/local_demo_test.go new file mode 100644 index 00000000..1ec27777 --- /dev/null +++ b/cmd/lectern/local_demo_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "testing" + "time" +) + +// The demo agent, in ask mode, asks for approval before its first write, so a +// new user sees the whole loop with nothing installed. +func TestDemoAgentAsksForApproval(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("local runtime test uses a POSIX shell") + } + for _, tool := range []string{"curl", "git"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s is not installed", tool) + } + } + bin := filepath.Join(t.TempDir(), "lectern") + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, out) + } + state, home, repo := t.TempDir(), t.TempDir(), t.TempDir() + if out, err := exec.Command("git", "init", "-q", repo).CombinedOutput(); err != nil { + t.Fatalf("git init: %v %s", err, out) + } + env := append(localTestEnv(t, state), "HOME="+home, "LECTERN_SESSION_BACKEND=pty") + t.Cleanup(func() { + _, _ = runLocalCLI(bin, env, "api", "DELETE", "/sessions/1?kill=true") + _, _ = runLocalCLI(bin, env, "local", "stop") + }) + created, err := runLocalCLI(bin, env, "api", "POST", "/sessions", fmt.Sprintf(`{"agent":"demo","workdir":%q,"permission_mode":"ask"}`, repo)) + if err != nil { + t.Fatalf("create: %v %s", err, created) + } + var s struct { + ID int64 `json:"id"` + } + _ = json.Unmarshal(created, &s) + id := strconv.FormatInt(s.ID, 10) + time.Sleep(2 * time.Second) + if out, err := runLocalCLI(bin, env, "api", "POST", "/sessions/"+id+"/send", `{"text":"hello demo"}`); err != nil { + t.Fatalf("send: %v %s", err, out) + } + var pending []byte + approval := regexp.MustCompile(`"id":(\d+),"session_id":` + id + `,"tool_name":"Write"`) + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) && !approval.Match(pending) { + time.Sleep(300 * time.Millisecond) + pending, _ = runLocalCLI(bin, env, "api", "GET", "/approvals") + } + m := approval.FindSubmatch(pending) + if m == nil { + t.Fatalf("the demo did not ask for approval: %s", pending) + } + if _, err := os.Stat(filepath.Join(repo, "demo-notes.md")); err == nil { + t.Fatal("the demo wrote before it was approved") + } + if out, err := runLocalCLI(bin, env, "api", "POST", "/approvals/"+string(m[1])+"/decision", `{"decision":"approved"}`); err != nil { + t.Fatalf("approve: %v %s", err, out) + } + deadline = time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + if data, err := os.ReadFile(filepath.Join(repo, "demo-notes.md")); err == nil && bytes.Contains(data, []byte("hello demo")) { + return + } + time.Sleep(300 * time.Millisecond) + } + t.Fatal("the demo did not write after approval") +} + +// Starting an agent that is not on the machine is refused before any session +// exists, naming the agents that are installed and how to install it. +func TestStartingAnUninstalledAgentIsRefused(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("local runtime test uses a POSIX shell") + } + bin := filepath.Join(t.TempDir(), "lectern") + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, out) + } + state, home, fake, dir := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir() + if err := os.WriteFile(filepath.Join(fake, "claude-for-test"), []byte("#!/bin/sh\nexec sleep 60\n"), 0o755); err != nil { + t.Fatal(err) + } + env := append(localTestEnv(t, state), "HOME="+home, "LECTERN_SESSION_BACKEND=pty", + "LECTERN_CLAUDE_BIN=claude-for-test", "LECTERN_CODEX_BIN=codex-not-installed-here", "LECTERN_GEMINI_BIN=gemini-not-installed-here", + "PATH="+fake+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Cleanup(func() { _, _ = runLocalCLI(bin, env, "local", "stop") }) + out, err := runLocalCLI(bin, env, "api", "POST", "/sessions", fmt.Sprintf(`{"agent":"codex","workdir":%q}`, dir)) + if err == nil || !bytes.Contains(out, []byte("codex isn't installed on this computer")) || + !bytes.Contains(out, []byte("Installed there: claude.")) || !bytes.Contains(out, []byte("npm install -g @openai/codex")) { + t.Fatalf("uninstalled agent: %v %s", err, out) + } + // The web app gets a code it can act on. + var endpoint struct{ URL, Token string } + data, _ := os.ReadFile(filepath.Join(state, "lectern", "local", "endpoint.json")) + _ = json.Unmarshal(data, &endpoint) + req, _ := http.NewRequest("POST", endpoint.URL+"/api/sessions", strings.NewReader(fmt.Sprintf(`{"agent":"codex","workdir":%q}`, dir))) + req.Header.Set("Authorization", "Bearer "+endpoint.Token) + req.Header.Set("Content-Type", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != 422 || !bytes.Contains(body, []byte(`"code":"agent_not_installed"`)) || !bytes.Contains(body, []byte(`"installed":["claude"]`)) { + t.Fatalf("API answer: %d %s", res.StatusCode, body) + } + if list, _ := runLocalCLI(bin, env, "api", "GET", "/sessions?all=1"); bytes.Contains(list, []byte(`"agent":"codex"`)) { + t.Fatalf("a session was created anyway: %s", list) + } + // An installed one still starts. + if out, err := runLocalCLI(bin, env, "api", "POST", "/sessions", fmt.Sprintf(`{"agent":"claude","workdir":%q}`, dir)); err != nil { + t.Fatalf("installed agent refused: %v %s", err, out) + } + _, _ = runLocalCLI(bin, env, "api", "DELETE", "/sessions/1?kill=true") +} + +// local status speaks to people by default; --json is for scripts. controls +// without a terminal says what to use instead of failing inside the TUI. +func TestLocalStatusAndControlsWithoutATerminal(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("local runtime test uses POSIX process locks") + } + bin := filepath.Join(t.TempDir(), "lectern") + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, out) + } + env := append(localTestEnv(t, t.TempDir()), "HOME="+t.TempDir()) + out, err := runLocalCLI(bin, env, "local", "status") + if err != nil || !bytes.Contains(out, []byte("Your private Lectern is not running")) { + t.Fatalf("status: %v %s", err, out) + } + out, err = runLocalCLI(bin, env, "local", "status", "--json") + if err != nil || !bytes.Contains(out, []byte(`"state": "stopped"`)) { + t.Fatalf("status --json: %v %s", err, out) + } + t.Cleanup(func() { _, _ = runLocalCLI(bin, env, "local", "stop") }) + out, err = runLocalCLI(bin, env, "controls") + if err == nil || !bytes.Contains(out, []byte("needs a terminal")) || bytes.Contains(out, []byte("epoll")) { + t.Fatalf("controls without a terminal: %v %s", err, out) + } +} diff --git a/cmd/lectern/local_hooks_test.go b/cmd/lectern/local_hooks_test.go index 6de72d10..9b36fdfd 100644 --- a/cmd/lectern/local_hooks_test.go +++ b/cmd/lectern/local_hooks_test.go @@ -56,7 +56,7 @@ exec sleep 120 if out, err := exec.Command("git", "init", "-q", repo).CombinedOutput(); err != nil { t.Fatalf("git init: %v %s", err, out) } - env := append(localTestEnv(state), "HOME="+home, "PATH="+fake+string(os.PathListSeparator)+os.Getenv("PATH")) + env := append(localTestEnv(t, state), "HOME="+home, "PATH="+fake+string(os.PathListSeparator)+os.Getenv("PATH")) t.Cleanup(func() { testutil.CleanupTmuxSocket(t, filepath.Join(state, "lectern", "local", "tmux", fmt.Sprintf("tmux-%d", os.Getuid()), "default")) _, _ = runLocalCLI(bin, env, "local", "stop") @@ -127,7 +127,7 @@ func TestLocalRuntimeRefusesOtherWebsites(t *testing.T) { t.Fatalf("build local CLI: %v\n%s", err, out) } state := t.TempDir() - env := append(localTestEnv(state), "HOME="+t.TempDir()) + env := append(localTestEnv(t, state), "HOME="+t.TempDir()) t.Cleanup(func() { _, _ = runLocalCLI(bin, env, "local", "stop") }) if out, err := runLocalCLI(bin, env, "api", "GET", "/health"); err != nil { t.Fatalf("start runtime: %v %s", err, out) diff --git a/cmd/lectern/local_phone_test.go b/cmd/lectern/local_phone_test.go new file mode 100644 index 00000000..487048c4 --- /dev/null +++ b/cmd/lectern/local_phone_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "bytes" + "net" + "net/http" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +// TestPhoneOnTheSameWiFi: `lectern phone` makes the same runtime reachable on +// the LAN address, and a phone gets in only by redeeming the one-time code. +func TestPhoneOnTheSameWiFi(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("local runtime test uses POSIX process locks") + } + if !hasLANAddress() { + t.Skip("no local network address here (the isolated runner has none)") + } + bin := filepath.Join(t.TempDir(), "lectern") + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, out) + } + env := append(localTestEnv(t, t.TempDir()), "HOME="+t.TempDir()) + t.Cleanup(func() { _, _ = runLocalCLI(bin, env, "phone", "--off"); _, _ = runLocalCLI(bin, env, "local", "stop") }) + out, err := runLocalCLI(bin, env, "phone", "--no-qr") + if err != nil || !bytes.Contains(out, []byte("not encrypted")) || !bytes.Contains(out, []byte("Tailscale")) { + t.Fatalf("lectern phone: %v %s", err, out) + } + m := regexp.MustCompile(`(http://[0-9.]+:\d+)/pair#code=([A-Za-z0-9%-]+)`).FindSubmatch(out) + if m == nil { + t.Fatalf("no pairing link: %s", out) + } + base, code := string(m[1]), strings.ReplaceAll(string(m[2]), "%2D", "-") + if res, err := http.Get(base + "/api/sessions"); err != nil || res.StatusCode != http.StatusUnauthorized { + t.Fatalf("unpaired LAN request: %v %v", err, res) + } + exchange := func() *http.Response { + req, _ := http.NewRequest("POST", base+"/api/pair/exchange", strings.NewReader(`{"code":"`+code+`","name":"test phone"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", base) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + return res + } + res := exchange() + var device *http.Cookie + for _, c := range res.Cookies() { + if c.Name == "lectern_device" { + device = c + } + } + if res.StatusCode != 200 || device == nil || device.Secure { + t.Fatalf("pairing over the LAN: %d cookies=%v (a Secure cookie would be dropped on plain HTTP)", res.StatusCode, res.Cookies()) + } + req, _ := http.NewRequest("GET", base+"/api/sessions", nil) + req.AddCookie(device) + if res, err := http.DefaultClient.Do(req); err != nil || res.StatusCode != 200 { + t.Fatalf("paired phone: %v %v", err, res) + } + if res := exchange(); res.StatusCode == 200 { + t.Fatal("a pairing code worked twice") + } + if out, err := runLocalCLI(bin, env, "phone", "--off"); err != nil { + t.Fatalf("phone --off: %v %s", err, out) + } + if _, err := http.Get(base + "/"); err == nil { + t.Fatal("still reachable on the LAN after --off") + } +} + +func hasLANAddress() bool { + addrs, _ := net.InterfaceAddrs() + for _, a := range addrs { + if n, ok := a.(*net.IPNet); ok && n.IP.To4() != nil && n.IP.IsPrivate() && !n.IP.IsLoopback() { + return true + } + } + return false +} diff --git a/cmd/lectern/local_runtime_test.go b/cmd/lectern/local_runtime_test.go index b12d8edd..eccde35a 100644 --- a/cmd/lectern/local_runtime_test.go +++ b/cmd/lectern/local_runtime_test.go @@ -14,7 +14,9 @@ import ( "testing" "time" + "github.com/JeremiahM37/lectern/v2/internal/ptyhost" "github.com/JeremiahM37/lectern/v2/internal/testutil" + "github.com/JeremiahM37/lectern/v2/internal/testutil/ptytest" ) func TestLocalRuntimeRealProcessPersistenceAndConcurrency(t *testing.T) { @@ -27,7 +29,7 @@ func TestLocalRuntimeRealProcessPersistenceAndConcurrency(t *testing.T) { t.Fatalf("build local CLI: %v\n%s", err, out) } state := t.TempDir() - env := localTestEnv(state) + env := localTestEnv(t, state) t.Cleanup(func() { _, _ = runLocalCLI(bin, env, "local", "stop") }) fixtureDir := t.TempDir() fixtureSocket := filepath.Join(fixtureDir, "fixture") @@ -120,7 +122,7 @@ func TestLocalRuntimeRealProcessPersistenceAndConcurrency(t *testing.T) { if out, err := runLocalCLI(bin, env, "local", "stop"); err != nil { t.Fatalf("local stop: %v (%s)", err, out) } - status, err := runLocalCLI(bin, env, "local", "status") + status, err := runLocalCLI(bin, env, "local", "status", "--json") if err != nil || !bytes.Contains(status, []byte(`"state": "stopped"`)) { t.Fatalf("stopped status: err=%v output=%s", err, status) } @@ -150,7 +152,7 @@ func TestExplicitRemoteFailureDoesNotFallbackToLocal(t *testing.T) { t.Fatalf("build local CLI: %v\n%s", err, out) } state := t.TempDir() - env := append(localTestEnv(state), "LECTERN_API=http://127.0.0.1:1") + env := append(localTestEnv(t, state), "LECTERN_API=http://127.0.0.1:1") if _, err := runLocalCLI(bin, env, "api", "GET", "/health"); err == nil { t.Fatal("explicit remote unexpectedly succeeded") } @@ -169,7 +171,7 @@ func TestHostedAttachMarkerReachesHostedLookup(t *testing.T) { t.Fatalf("build local CLI: %v\n%s", err, out) } state := t.TempDir() - env := localTestEnv(state) + env := localTestEnv(t, state) env = append(env, "LECTERN_PORT=1") out, err := runLocalCLI(bin, env, "--hosted-attach", "attach", "session", "17") if err == nil { @@ -183,9 +185,12 @@ func TestHostedAttachMarkerReachesHostedLookup(t *testing.T) { } } -func localTestEnv(state string) []string { +// localTestEnv is the environment of a local runtime private to one test: its +// own state directory, which holds its tmux directory, and its own PTY host +// socket, stopped when the test ends. +func localTestEnv(t *testing.T, state string) []string { blocked := map[string]bool{} - for _, key := range []string{"LECTERN_API", "LECTERN_ATTACH_HOST", "LECTERN_DB", "LECTERN_HOST", "LECTERN_PORT", "LECTERN_BASE_URL", "LECTERN_AUTH_TOKEN", "LECTERN_MOCK", "LECTERN_GRIMOIRE_URL", "LECTERN_GRIMOIRE_TOKEN", "LECTERN_HOST_CLAUDE_CONFIG", "LECTERN_CREDS", "LECTERN_CODEX_CREDS", "LECTERN_ANTHROPIC_API_KEY", "XDG_STATE_HOME"} { + for _, key := range []string{ptyhost.SocketEnv, "LECTERN_API", "LECTERN_ATTACH_HOST", "LECTERN_DB", "LECTERN_HOST", "LECTERN_PORT", "LECTERN_BASE_URL", "LECTERN_AUTH_TOKEN", "LECTERN_MOCK", "LECTERN_GRIMOIRE_URL", "LECTERN_GRIMOIRE_TOKEN", "LECTERN_HOST_CLAUDE_CONFIG", "LECTERN_CREDS", "LECTERN_CODEX_CREDS", "LECTERN_ANTHROPIC_API_KEY", "XDG_STATE_HOME"} { blocked[key] = true } base := make([]string, 0, len(os.Environ())+2) @@ -197,7 +202,7 @@ func localTestEnv(state string) []string { } // LECTERN_PORT=1: no Lectern service answers there, so plain commands // choose the local runtime even on a host running one on 9110. - return append(base, "XDG_STATE_HOME="+state, "LECTERN_MOCK=1", "LECTERN_PORT=1") + return append(base, "XDG_STATE_HOME="+state, "LECTERN_MOCK=1", "LECTERN_PORT=1", ptyhost.SocketEnv+"="+ptytest.Socket(t)) } func runLocalCLI(bin string, env []string, args ...string) ([]byte, error) { diff --git a/cmd/lectern/localruntime/phone.go b/cmd/lectern/localruntime/phone.go index 69da7ece..d4c25135 100644 --- a/cmd/lectern/localruntime/phone.go +++ b/cmd/lectern/localruntime/phone.go @@ -6,56 +6,83 @@ import ( "net" "net/http" "sort" + "strconv" "strings" "sync" "time" ) +// "Let phones on this Wi-Fi connect": the private runtime normally answers +// only 127.0.0.1, which no phone can reach. Turning this on (POST +// /api/phone/wifi, `lectern phone`, Settings → Connect your phone) makes the +// SAME runtime — same sessions, same database, same handler — also listen on +// one private address of this computer. Nothing else on the network gets in: +// the app runs in token mode, so only a paired device is accepted there, and +// the runtime's own controls and token are never reachable from the network. +// It is plain HTTP on the LAN, which the person is told before it starts. +// It ends with the runtime, or with DELETE /api/phone/wifi (`lectern phone +// --off`). + // wifiListener serves the existing runtime on one private interface. It never // creates another database or changes the loopback endpoint used by agents. type wifiListener struct { - mu sync.Mutex - server *http.Server - url string handler http.Handler + // port is the runtime's loopback port, tried first so the phone's address + // matches the one on this computer. + port int + + mu sync.Mutex + server *http.Server + url string } func (p *wifiListener) address() string { p.mu.Lock(); defer p.mu.Unlock(); return p.url } -func (p *wifiListener) enable() (string, error) { - if address := p.address(); address != "" { - return address, nil - } + +// lanCandidates are this computer's private IPv4 addresses that a phone on +// the same network could reach — not loopback, link-local, or the tailnet's +// CGNAT range — with the default route's address first, ahead of Docker and +// VM bridges. +func lanCandidates() []string { addresses, err := net.InterfaceAddrs() if err != nil { - return "", err + return nil } - // A UDP connect selects a route without sending a packet. Prefer the - // default interface over Docker/VM bridges when several private addresses - // exist. The destination is reserved TEST-NET, not a service dependency. + // A UDP connect selects a route without sending a packet. The destination + // is reserved TEST-NET, not a service dependency. var preferred net.IP if route, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: net.IPv4(192, 0, 2, 1), Port: 9}); err == nil { preferred = route.LocalAddr().(*net.UDPAddr).IP route.Close() } - sort.SliceStable(addresses, func(i, j int) bool { - a, aok := addresses[i].(*net.IPNet) - b, bok := addresses[j].(*net.IPNet) - return aok && a.IP.Equal(preferred) && !(bok && b.IP.Equal(preferred)) - }) + _, cgnat, _ := net.ParseCIDR("100.64.0.0/10") + var out []string for _, a := range addresses { ipnet, ok := a.(*net.IPNet) if !ok { continue } ip := ipnet.IP.To4() - if ip == nil || !ip.IsPrivate() || ip.IsLoopback() { + if ip == nil || !ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || cgnat.Contains(ip) { continue } - if address, err := p.enableAt(ip.String()); err == nil { + out = append(out, ip.String()) + } + sort.SliceStable(out, func(i, j int) bool { + return preferred != nil && out[i] == preferred.String() && out[j] != preferred.String() + }) + return out +} + +func (p *wifiListener) enable() (string, error) { + if address := p.address(); address != "" { + return address, nil + } + for _, ip := range lanCandidates() { + if address, err := p.enableAt(ip); err == nil { return address, nil } } - return "", fmt.Errorf("no private Wi-Fi or Ethernet address is available; connect this computer to your network first") + return "", fmt.Errorf("this computer has no private Wi-Fi or Ethernet address; connect it to your network first, or use Tailscale or a relay") } // enableAt binds one selected interface and reuses the same handler and port @@ -66,9 +93,15 @@ func (p *wifiListener) enableAt(ip string) (string, error) { if p.server != nil { return p.url, nil } - listener, err := net.Listen("tcp", net.JoinHostPort(ip, "0")) + var listener net.Listener + err := fmt.Errorf("no port") + if p.port > 0 { + listener, err = net.Listen("tcp", net.JoinHostPort(ip, strconv.Itoa(p.port))) + } if err != nil { - return "", err + if listener, err = net.Listen("tcp", net.JoinHostPort(ip, "0")); err != nil { + return "", err + } } address := listener.Addr().String() p.server = &http.Server{ReadHeaderTimeout: 15 * time.Second, Handler: wifiHandler(address, p.handler)} @@ -85,6 +118,7 @@ func (p *wifiListener) enableAt(ip string) (string, error) { }() return p.url, nil } + func (p *wifiListener) close() { p.mu.Lock() server := p.server @@ -100,11 +134,13 @@ func (p *wifiListener) close() { } } -// Keep the main API's token/device authentication, plus exact Host and Origin -// checks. This listener exposes no local-runtime sign-in or shutdown routes. +// wifiHandler fronts the app on the Wi-Fi address: the request must name that +// address (no DNS rebinding) and come from its own origin, and the runtime's +// own controls are not reachable from the network at all. Who may use the API +// is then the app's token-mode check: a paired device, nothing else. func wifiHandler(address string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Host != address { + if !strings.EqualFold(r.Host, address) { http.Error(w, "unexpected host", http.StatusMisdirectedRequest) return } @@ -114,10 +150,16 @@ func wifiHandler(address string, next http.Handler) http.Handler { return } } - if strings.HasPrefix(r.URL.Path, "/__lectern_local/") { + if strings.HasPrefix(r.URL.Path, "/__lectern_local/") || strings.HasPrefix(r.URL.Path, "/api/local/") { http.NotFound(w, r) return } + // The runtime token is for this computer only: never accepted from + // the network, even if someone learned it. + if q := r.URL.Query(); q.Has("token") { + q.Del("token") + r.URL.RawQuery = q.Encode() + } next.ServeHTTP(w, r) }) } diff --git a/cmd/lectern/localruntime/phone_test.go b/cmd/lectern/localruntime/phone_test.go index c931b003..d4cbdef1 100644 --- a/cmd/lectern/localruntime/phone_test.go +++ b/cmd/lectern/localruntime/phone_test.go @@ -29,6 +29,7 @@ func TestWiFiHandlerKeepsHostOriginAndLocalRoutesPrivate(t *testing.T) { {"evil.example:32100", "", "/api/health", 421}, {"192.0.2.10:32100", "https://evil.example", "/api/projects", 403}, {"192.0.2.10:32100", "", "/__lectern_local/stop", 404}, + {"192.0.2.10:32100", "", "/api/local/anything", 404}, } { r := httptest.NewRequest("GET", "http://"+tc.host+tc.path, nil) r.Host = tc.host @@ -43,6 +44,25 @@ func TestWiFiHandlerKeepsHostOriginAndLocalRoutesPrivate(t *testing.T) { } } +// The runtime token is for this computer only: a ?token= arriving over the +// network is removed before the app sees the request. +func TestWiFiHandlerNeverPassesTheRuntimeToken(t *testing.T) { + var seen string + handler := wifiHandler("192.0.2.10:32100", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = r.URL.RawQuery })) + r := httptest.NewRequest("GET", "http://192.0.2.10:32100/?token=runtime-token&x=1", nil) + handler.ServeHTTP(httptest.NewRecorder(), r) + if seen != "x=1" { + t.Fatalf("query reaching the app: %q", seen) + } + r = httptest.NewRequest("GET", "http://192.0.2.10:32100/api/health", nil) + r.Header.Set("Referer", "https://evil.example/page") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != 403 { + t.Fatalf("cross-site Referer: %d", w.Code) + } +} + func TestWiFiPairsIntoTheExistingRuntimeAndApprovesItsSession(t *testing.T) { const token = "test-owner-token" cfg := engineConfig(&config.Config{TickInterval: time.Second, ApprovalExpire: time.Hour}, t.TempDir(), 0, token) @@ -68,6 +88,7 @@ func TestWiFiPairsIntoTheExistingRuntimeAndApprovesItsSession(t *testing.T) { defer wifi.close() instance.Server.EnableWiFi = func() (string, error) { return wifi.enableAt("127.0.0.1") } instance.Server.WiFiURL = wifi.address + instance.Server.DisableWiFi = wifi.close local := httptest.NewServer(localHandler(instance.Handler(), newGate(token, "test-browser-key"), "same-runtime", func() error { return nil })) defer local.Close() request := func(client *http.Client, method, address, credential string, body any) (int, []byte) { @@ -155,10 +176,16 @@ func TestWiFiPairsIntoTheExistingRuntimeAndApprovesItsSession(t *testing.T) { if code != 404 { t.Fatalf("phone reached local runtime identity: %d", code) } - wifi.close() + code, data = request(http.DefaultClient, "DELETE", local.URL+"/api/phone/wifi", token, nil) + if code != 200 { + t.Fatalf("turn off: %d %s", code, data) + } if wifi.address() != "" { t.Fatal("stopped listener still advertised") } + if _, err := phone.Get(enabled.URL + "/api/health"); err == nil { + t.Fatal("still reachable on the Wi-Fi address after turning it off") + } // Closing Wi-Fi must leave the local runtime and session in place. code, data = request(http.DefaultClient, "GET", local.URL+"/api/sessions", token, nil) if code != 200 { diff --git a/cmd/lectern/localruntime/runtime.go b/cmd/lectern/localruntime/runtime.go index 2953481a..50411d03 100644 --- a/cmd/lectern/localruntime/runtime.go +++ b/cmd/lectern/localruntime/runtime.go @@ -379,9 +379,12 @@ func Engine(ctx context.Context, base *config.Config, dir, token string, lockFD return err } defer appInstance.Close() - wifi := &wifiListener{handler: appInstance.Handler()} + appHandler := appInstance.Handler() + // Phones on this Wi-Fi reach this same runtime only when asked (phone.go). + wifi := &wifiListener{handler: appHandler, port: port} defer wifi.close() appInstance.Server.EnableWiFi = wifi.enable + appInstance.Server.DisableWiFi = wifi.close appInstance.Server.WiFiURL = wifi.address if !cfg.Mock { targets, err := appInstance.DB.Targets() @@ -411,7 +414,7 @@ func Engine(ctx context.Context, base *config.Config, dir, token string, lockFD }() }) } - server = &http.Server{ReadHeaderTimeout: 15 * time.Second, Handler: localHandler(appInstance.Handler(), newGate(token, browserKey), ep.Instance, func() error { + server = &http.Server{ReadHeaderTimeout: 15 * time.Second, Handler: localHandler(appHandler, newGate(token, browserKey), ep.Instance, func() error { active, err := appInstance.DB.TasksWhere("status IN ('queued','running','review')") if err != nil { return err diff --git a/cmd/lectern/main.go b/cmd/lectern/main.go index e78be6c4..017d08ef 100644 --- a/cmd/lectern/main.go +++ b/cmd/lectern/main.go @@ -22,6 +22,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "strconv" "strings" "syscall" @@ -44,7 +45,7 @@ var clientVerbs = map[string]bool{ "console": true, "tui": true, "shell": true, "api": true, "agent": true, "upload": true, "files": true, "download": true, "post": true, "live": true, "expose": true, "skill": true, "promote": true, "controls": true, "restore": true, "account": true, - "browser": true, "computer": true, "plugin": true, "phone": true, + "browser": true, "computer": true, "plugin": true, "phone": true, "demo": true, "help": true, "--help": true, "-h": true, } @@ -342,6 +343,10 @@ func main() { fmt.Fprintln(os.Stderr, "lectern: "+err.Error()) os.Exit(2) } + if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o700); err != nil { + fmt.Fprintln(os.Stderr, "lectern: "+err.Error()) + os.Exit(1) + } a, err := app.New(cfg, log) if err != nil { log.Error("startup failed", "err", err) @@ -362,7 +367,7 @@ func main() { log.Info("reading settings under their old names; rename them to LECTERN_*", "legacy", aliased) } - log.Info("lectern listening", "addr", addr, "version", version.Version, + log.Info("lectern listening", "addr", addr, "db", cfg.DBPath, "version", version.Version, "mock", cfg.Mock, "auth_mode", a.Server.Auth.Mode) go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { diff --git a/cmd/lectern/main_test.go b/cmd/lectern/main_test.go index a44ea5d6..159dc164 100644 --- a/cmd/lectern/main_test.go +++ b/cmd/lectern/main_test.go @@ -30,7 +30,7 @@ func TestDispatchTable(t *testing.T) { {"agent", true}, {"account", true}, {"upload", true}, {"files", true}, {"download", true}, {"post", true}, {"live", true}, {"expose", true}, {"skill", true}, {"plugin", true}, {"promote", true}, {"controls", true}, {"help", true}, {"--help", true}, {"-h", true}, - {"claude", true}, {"codex", true}, + {"claude", true}, {"codex", true}, {"demo", true}, {"phone", true}, {"local", false}, {"up", false}, {"doctor", false}, {"serve", false}, {"attach", false}, {"mcp", false}, {"version", false}, {"--version", false}, {"-v", false}, {"nonexistent-command", false}, diff --git a/cmd/lectern/native_attach.go b/cmd/lectern/native_attach.go index a9b0b01d..6fca44d5 100644 --- a/cmd/lectern/native_attach.go +++ b/cmd/lectern/native_attach.go @@ -1,10 +1,8 @@ package main import ( - "encoding/base64" "errors" "fmt" - "github.com/JeremiahM37/lectern/v2/internal/filelinks" "io" "os" "os/exec" @@ -16,10 +14,8 @@ import ( "unicode/utf8" "github.com/JeremiahM37/lectern/v2/internal/console" - "github.com/JeremiahM37/lectern/v2/internal/ptyhost" "github.com/JeremiahM37/lectern/v2/internal/shellq" "golang.org/x/term" - "time" ) // nativeControlsEnv disables the client-side wrapper entirely when it is set to @@ -51,7 +47,7 @@ func nativeControlsOff() bool { } func warnNativeControls(reason string) { - fmt.Fprintln(os.Stderr, "lectern: native controls unavailable ("+reason+"); use an interactive terminal for the Lectern menu and detach controls.") + fmt.Fprintln(os.Stderr, "lectern: no key bar ("+reason+"); keys go straight to the session. Leave with Ctrl+] then d (Ctrl-b then d for a tmux session).") } // runAttachment executes a resolved attachment exactly like syscall.Exec did, @@ -73,8 +69,10 @@ func startAttachment(argv []string, controls *nativeControls, replace bool) erro return directAttachment(argv, replace) } tmuxPath, err := exec.LookPath("tmux") - if err != nil { - return runPortableAttachment(argv, *controls) + if err != nil || bareForced() { + // No tmux to wrap the attachment in: Lectern's own client draws the + // same key bar and offers the same Ctrl+] keys (native_bare.go). + return runBareAttachment(argv, *controls, replace) } return runPrivateAttachment(tmuxPath, argv, *controls, replace) } @@ -244,7 +242,7 @@ func newNativeWrapPlan(dir, socket string, controls nativeControls, argv []strin // pane's directory (native_split.go), not a shell here in $HOME. plan.splitBody = execScript([]string{self, terminalSplitFlag, controls.Kind, controls.ID, controls.Base}) if controls.Kind == "session" && controls.Base != "" { - plan.statusBody = execScript([]string{self, attachStatusFlag, controls.Kind, controls.ID, controls.Base}) + plan.statusBody = strings.TrimSuffix(execScript([]string{self, attachStatusFlag, controls.Kind, controls.ID, controls.Base}), "\n") + ` "$@"` + "\n" } plan.conf = plan.tmuxConfig() return plan, nil @@ -297,7 +295,7 @@ func attachHints(tabView bool) string { } wide := "#[bold]Ctrl+]#[default] menu · " + leave + " · #[bold]Ctrl+\\#[default] send file · #[bold]double-click#[default] opens paths " narrow := "#[bold]Ctrl+]#[default] menu · " + leave + " " - prefix := "#[reverse] Ctrl+] then #[default] m actions · u send file · | shell right · - shell below · e open a link · d " + word + " · ? all keys " + prefix := "#[reverse] Ctrl+] then #[default] m actions · d " + word + " · u send file · | shell right · - shell below · e open a link · ? all keys " return "#{?client_prefix," + prefix + ",#{?#{e|<:#{client_width},60}," + narrow + "," + wide + "}}" } @@ -374,6 +372,8 @@ func (p *nativeWrapPlan) tmuxConfig() string { "bind-key -T prefix m display-popup -E -w 90% -h 85% -T 'Lectern controls' " + shellq.Quote(p.controlsScript), "bind-key -T prefix u display-popup -E -w 90% -h 85% -T 'Lectern upload' " + shellq.Quote(p.uploadScript), "bind-key -T prefix ? " + p.attachMenu(), + // Ctrl+] y allows the request the bar says is waiting, once. + "bind-key -T prefix y run-shell -b " + tmuxDQ(strings.ReplaceAll(shellq.Quote(p.statusScript), "#", "##")+" allow"), "bind-key -T prefix Space " + p.attachMenu(), "bind-key -n 'C-\\' display-popup -E -w 90% -h 85% -T 'Lectern upload' " + shellq.Quote(p.uploadScript), "set -g status on", @@ -556,13 +556,14 @@ const ( // terminal's popup. It reuses the dashboard's actions, forms and API client; // only native attach actions are disabled. func controlsCommand(c *console.Client, args []string) error { - if !interactiveTerminal() { - return fmt.Errorf("controls needs an interactive terminal; use lectern console --plain for a text view") - } kind, rid, action, popup, err := parseControlsArgs(args) if err != nil { return err } + if !interactiveTerminal() { + return errors.New("lectern controls is an interactive menu and needs a terminal. " + + "From a script, use lectern api (lectern help api), for example: lectern api GET /sessions") + } opts := console.DashboardOptions{Popup: popup, FocusKind: kind, FocusID: rid, Action: action} if os.Getenv(insertSocketEnv) != "" && os.Getenv(insertTargetEnv) != "" { opts.Insert = insertIntoAttachment @@ -680,73 +681,3 @@ func validateControlsTarget(kind, rid string) error { } return nil } - -// runPortableAttachment keeps controls on the user's machine, even when the -// terminal itself is reached over SSH. Credentials remain in the API client. -func runPortableAttachment(argv []string, controls nativeControls) error { - c := console.New(controls.Base, controls.Token) - statusClient := console.New(controls.Base, controls.Token) - statusClient.HTTP.Timeout = 3 * time.Second - err := ptyhost.AttachProcess(argv, os.Stdin, os.Stdout, ptyhost.TerminalControls{ - History: func(text string) error { return console.RunScrollback(text, os.Stdin, os.Stdout) }, - Action: func(action string, insert func(string) error) error { - if action == "menu" { - action = "" - } - return console.RunControls(c, os.Stdin, os.Stdout, console.DashboardOptions{Popup: true, FocusKind: controls.Kind, FocusID: controls.ID, Action: action, Insert: insert}) - }, - Links: func(text string, width int, insert func(string) error) error { - dir, err := os.MkdirTemp("", "lectern-links-") - if err != nil { - return err - } - defer os.RemoveAll(dir) - e := &linkEnv{kind: controls.Kind, id: controls.ID, base: controls.Base, token: controls.Token, dir: dir} - var actionErr error - e.directAction = func(action string, link filelinks.Link) error { - switch action { - case "send": - actionErr = insert(e.shellWord(link) + " ") - case "copy": - value := link.URL - if link.Kind == "file" { - value = e.absolute(link) - } - _, actionErr = fmt.Fprintf(os.Stdout, "\x1b]52;c;%s\a", base64.StdEncoding.EncodeToString([]byte(value))) - case "open": - if _, ok := localOpener(); !ok && link.Kind == "file" { - actionErr = e.view(link) - } else { - actionErr = e.open(link) - } - default: - actionErr = e.act(action, link) - } - return actionErr - } - lines := strings.Split(strings.TrimSuffix(text, "\n"), "\n") - rows := make([]filelinks.Row, len(lines)) - for i, line := range lines { - rows[i] = filelinks.Row{Text: line} - } - io.WriteString(os.Stdout, "\x1b[?1049h") - defer io.WriteString(os.Stdout, "\x1b[?1049l") - e.hintsFromRows(os.Stdin, os.Stdout, rows, width) - return actionErr - }, - Status: func() string { - if controls.Kind != "session" { - return "" - } - data, err := statusClient.JSON("GET", "/approvals?status=pending", nil) - if err != nil { - return "" - } - return attachStatusLine(data, controls.ID) - }, - }) - if err == nil { - fmt.Fprintln(os.Stderr, "Left the terminal. `lectern` shows your sessions.") - } - return err -} diff --git a/cmd/lectern/native_bare.go b/cmd/lectern/native_bare.go new file mode 100644 index 00000000..7a314fb2 --- /dev/null +++ b/cmd/lectern/native_bare.go @@ -0,0 +1,1261 @@ +package main + +// Lectern's own attach client: the attached-terminal experience without a +// local tmux (docs/terminal-client.md, docs/ptyhost.md). +// +// The tmux path (native_attach.go) wraps an attachment in a private tmux +// server for its key bar, its Ctrl+] menu and its mouse bindings. Where tmux +// is not installed — the default on macOS, Windows and minimal Linux now +// that sessions live on Lectern's PTY host — this client does the same work +// itself: it owns the keyboard and the screen, shows the session in an +// emulator above a key bar it draws on the last row, and keeps every key the +// tmux path has: +// +// Ctrl+] then m actions · y allow a waiting request · u send a file · +// e pick a path or link · | shell right · - shell below · +// o next pane · x close pane · [ scroll back · d leave · +// ? every key · Ctrl+] a literal Ctrl+] +// Ctrl+\ send a file. Always taken here: it is never delivered as +// SIGQUIT, which would end the agent. +// +// Double-click opens a path or link the agent printed, right-click offers +// its menu, a drag copies to the clipboard (OSC 52) and the wheel scrolls +// back — unless the program asked for the mouse itself, which then gets it. + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "strconv" + "strings" + "sync" + "time" + + "github.com/JeremiahM37/lectern/v2/internal/console" + "github.com/JeremiahM37/lectern/v2/internal/filelinks" + "github.com/JeremiahM37/lectern/v2/internal/terminal/webterm" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/cancelreader" + "golang.org/x/term" +) + +// bareEnv forces this client even where tmux is installed (tests, and anyone +// who prefers it). +const bareEnv = "LECTERN_NATIVE_BARE" + +func bareForced() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(bareEnv))) { + case "1", "on", "true", "yes": + return true + } + return false +} + +type bareClient struct { + controls nativeControls + self string + in *os.File + out io.Writer + outFd int + original *term.State + + mu sync.Mutex + panes []*barePane + focus int + dir byte + cols, rows int + prefix bool + overlay *bareOverlay + hints *bareHints + sel *bareSelection + message string + messageEnd time.Time + needsYou string + approvalID string + paused bool + full bool + cache map[*barePane][]string + lastBar string + realModes map[int]bool + realKitty int + realMok int + click struct { + pane *barePane + x, y int + at time.Time + } + + writeMu sync.Mutex + dirty chan struct{} + done chan string + doneOnce sync.Once + reader cancelreader.CancelReader + readerWG sync.WaitGroup + pending []byte + pasting bool + links *linkEnv + linkDir string + pressed bool + // popup runs the Ctrl+] controls in place of the session; nil is the + // real controls dashboard. Tests swap in their own. + popup func(action string) +} + +// runBareAttachment shows the attachment argv with Lectern's own key bar +// and controls until the person leaves or the session ends. +func runBareAttachment(argv []string, controls nativeControls, replace bool) error { + c, err := newBareClient(controls) + if err != nil { + return err + } + return c.run(argv, replace) +} + +func newBareClient(controls nativeControls) (*bareClient, error) { + self, err := os.Executable() + if err != nil { + return nil, err + } + return &bareClient{controls: controls, self: self, in: os.Stdin, out: os.Stdout, outFd: int(os.Stdout.Fd()), + cache: map[*barePane][]string{}, realModes: map[int]bool{}, + dirty: make(chan struct{}, 1), done: make(chan string, 1), dir: '|'}, nil +} + +// run attaches to argv until the person leaves or the session ends. +func (c *bareClient) run(argv []string, replace bool) error { + controls := c.controls + self := c.self + var err error + c.cols, c.rows = 80, 24 + if w, h, err := term.GetSize(c.outFd); err == nil && w > 0 && h > 1 { + c.cols, c.rows = w, h + } + conn, err := webterm.Open(argv, self, c.cols, max(1, c.rows-1)) + if err != nil { + return err + } + if c.linkDir, err = os.MkdirTemp("", "lectern-attach-"); err == nil { + _ = os.Chmod(c.linkDir, 0o700) + defer os.RemoveAll(c.linkDir) + } + c.links = &linkEnv{kind: controls.Kind, id: controls.ID, base: strings.TrimRight(controls.Base, "/"), + token: controls.Token, dir: c.linkDir, ui: c} + agent := newBarePane(c, conn, true, "agent", c.cols, max(1, c.rows-1)) + c.panes = []*barePane{agent} + + if c.original, err = term.MakeRaw(int(c.in.Fd())); err != nil { + conn.Close() + return err + } + c.write("\x1b[?1049h\x1b[H\x1b[2J") + c.full = true + agent.start() + if err := c.startInput(); err != nil { + c.teardown() + conn.Close() + return err + } + stopWatch := make(chan struct{}) + go c.watchSize(stopWatch) + go c.renderLoop(stopWatch) + if controls.Kind == "session" && controls.Base != "" { + go c.pollApprovals(stopWatch) + } + go func() { + <-agent.ended + c.finish("ended") + }() + reason := <-c.done + close(stopWatch) + c.stopInput() + c.mu.Lock() + c.paused = true + panes := c.panes + c.mu.Unlock() + c.teardown() + for _, p := range panes { + p.close() + } + if replace && controls.Kind == "session" { + if reason == "ended" { + fmt.Fprintln(os.Stderr, "The session ended. `lectern` shows your sessions; r there brings an ended one back.") + } else { + fmt.Fprintln(os.Stderr, "Left the session; it keeps running. `lectern` shows all your sessions.") + } + } + return nil +} + +func (c *bareClient) finish(reason string) { + c.doneOnce.Do(func() { c.done <- reason }) +} + +// teardown puts this terminal back the way it was found. +func (c *bareClient) teardown() { + var b strings.Builder + b.WriteString("\x1b[?2026l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1004l\x1b[?2004l\x1b[?1l") + if c.realKitty != 0 { + b.WriteString("\x1b[4;0m") + } + b.WriteString("\x1b[0 q\x1b[0m\x1b[?25h\x1b[?1049l") + c.write(b.String()) + if c.original != nil { + _ = term.Restore(int(c.in.Fd()), c.original) + } +} + +func (c *bareClient) write(s string) { + c.writeMu.Lock() + defer c.writeMu.Unlock() + _, _ = io.WriteString(c.out, s) +} + +// passthrough writes a program's request (a clipboard copy) to this terminal +// unless another program has the screen. +func (c *bareClient) passthrough(s string) { + if !c.paused { + c.write(s) + } +} + +func (c *bareClient) bell() { c.passthrough("\a") } + +func (c *bareClient) markDirty() { + select { + case c.dirty <- struct{}{}: + default: + } +} + +func (c *bareClient) renderLoop(stop chan struct{}) { + tick := time.NewTicker(500 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-stop: + return + case <-c.dirty: + // Gather a burst of output into one frame. + time.Sleep(8 * time.Millisecond) + c.render() + case <-tick.C: + c.mu.Lock() + expired := c.message != "" && time.Now().After(c.messageEnd) + if expired { + c.message = "" + } + c.mu.Unlock() + if expired { + c.render() + } + } + } +} + +// watchSize follows this terminal's size. A poll works the same on every +// platform, and a quarter second is quick enough for a person resizing. +func (c *bareClient) watchSize(stop chan struct{}) { + tick := time.NewTicker(200 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-stop: + return + case <-tick.C: + w, h, err := term.GetSize(c.outFd) + if err != nil || w <= 0 || h <= 1 { + continue + } + c.mu.Lock() + if w != c.cols || h != c.rows { + c.cols, c.rows = w, h + c.layout() + c.mu.Unlock() + c.markDirty() + continue + } + c.mu.Unlock() + } + } +} + +// say shows a message on the bar for a few seconds. +func (c *bareClient) say(message string) { + c.mu.Lock() + c.message = message + c.messageEnd = time.Now().Add(5 * time.Second) + c.mu.Unlock() + c.markDirty() +} + +// copy puts text on this terminal's clipboard with OSC 52, which works over +// SSH as well. +func (c *bareClient) copy(text string) error { + c.write("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a") + return nil +} + +// send types text into the agent's pane without pressing Enter. +func (c *bareClient) send(text string) error { + if r, ok := firstInsertControl(text); ok { + return fmt.Errorf("refusing to type text containing control character %s", strconv.QuoteRune(r)) + } + c.mu.Lock() + agent := c.panes[0] + c.mu.Unlock() + return agent.conn.Write([]byte(text)) +} + +// view shows a file in this terminal for a moment, in place of the session. +func (c *bareClient) view(link filelinks.Link) error { + go c.takeover(func() { + if err := c.links.view(link); err != nil { + fmt.Println(err) + fmt.Print("\nPress Enter to close.") + _, _ = fmt.Scanln() + } + }) + return nil +} + +func (c *bareClient) focused() *barePane { + if c.focus < 0 || c.focus >= len(c.panes) { + return nil + } + return c.panes[c.focus] +} + +// ---- the key bar + +// barText is the last row. Caller holds c.mu. +func (c *bareClient) barText() string { + width := c.cols + text := "" + p := c.focused() + switch { + case c.message != "": + text = " " + c.message + case c.overlay != nil: + text = " ↑↓ choose · Enter or the key runs it · Esc close" + case c.hints != nil: + text = " Type a label to open it · Shift+label for its menu · Esc cancel" + case p != nil && p.scroll > 0: + text = " Scrolled back · ↑↓ PgUp PgDn move · q or Esc back to live" + case c.prefix: + word := "leave" + if c.controls.TabView { + word = "close tab" + } + text = " \x1b[7m Ctrl+] then \x1b[27m m actions · d " + word + " · u send file · | shell right · - shell below · e open a link · ? all keys" + if c.needsYou != "" { + text = " \x1b[7m Ctrl+] then \x1b[27m y allow once · m answer or more · d " + word + " · ? all keys" + } + default: + leave := "\x1b[1mCtrl+] d\x1b[22m leave" + if c.controls.TabView { + leave = "\x1b[1mCtrl+] d\x1b[22m close tab" + } + text = " \x1b[1mCtrl+]\x1b[22m menu · " + leave + if width >= 60 { + text += " · \x1b[1mCtrl+\\\x1b[22m send file · \x1b[1mdouble-click\x1b[22m opens paths" + } + if len(c.panes) > 1 && p != nil { + text = fmt.Sprintf(" [%s %d/%d · Ctrl+] o next]", p.name, c.focus+1, len(c.panes)) + text + } + if c.needsYou != "" { + // The keys come first, so a narrow terminal cuts the request's + // text, never the way to answer it. + what := strings.TrimPrefix(c.needsYou, "⏸ Needs you: ") + text = " \x1b[1;38;5;214m⏸ Needs you · Ctrl+] y allow · Ctrl+] m more\x1b[22;38;5;252m · " + what + } + } + if ansi.StringWidth(text) > width { + text = ansi.Truncate(text, width, "") + } + return text + strings.Repeat(" ", max(0, width-ansi.StringWidth(text))) +} + +// ---- keyboard and mouse + +func (c *bareClient) startInput() error { + r, err := newInputReader(c.in) + if err != nil { + return err + } + c.reader = r + c.readerWG.Add(1) + go func() { + defer c.readerWG.Done() + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + c.input(buf[:n]) + } + if err != nil { + if !errors.Is(err, cancelreader.ErrCanceled) { + c.finish("input") + } + return + } + } + }() + return nil +} + +func (c *bareClient) stopInput() { + if c.reader == nil { + return + } + c.reader.Cancel() + c.readerWG.Wait() + _ = c.reader.Close() + c.reader = nil +} + +// input splits what the terminal sent into keys, mouse reports and pastes. +func (c *bareClient) input(data []byte) { + buf := append(c.pending, data...) + c.pending = nil + var forward []byte + flush := func() { + if len(forward) > 0 { + c.forward(forward) + forward = nil + } + } + for len(buf) > 0 { + if c.pasting { + if i := strings.Index(string(buf), "\x1b[201~"); i >= 0 { + forward = append(forward, buf[:i+6]...) + buf = buf[i+6:] + c.pasting = false + } else { + forward = append(forward, buf...) + buf = nil + } + continue + } + tok, rest, ok := nextInputToken(buf) + if !ok { + c.pending = append([]byte(nil), buf...) + break + } + buf = rest + if string(tok) == "\x1b[200~" { + c.pasting = true + forward = append(forward, tok...) + continue + } + if c.handleToken(tok) { + flush() + continue + } + forward = append(forward, tok...) + } + flush() +} + +// nextInputToken returns one key, escape sequence or run of plain text. +func nextInputToken(b []byte) (tok, rest []byte, complete bool) { + if b[0] != 0x1b { + // One byte at a time: a key typed after Ctrl+] may arrive in the + // same read. The caller still writes a run to the pane at once. + return b[:1], b[1:], true + } + if len(b) == 1 { + return b, nil, true + } + switch b[1] { + case '[': + if len(b) >= 3 && b[2] == 'M' { + // An X10 mouse report: three bytes follow. + if len(b) < 6 { + return nil, b, false + } + return b[:6], b[6:], true + } + for i := 2; i < len(b); i++ { + if b[i] >= 0x40 && b[i] <= 0x7e { + return b[:i+1], b[i+1:], true + } + } + return nil, b, false + case 'O': + if len(b) < 3 { + return nil, b, false + } + return b[:3], b[3:], true + } + return b[:2], b[2:], true +} + +// keyOf names a token for the controls: Ctrl+] and Ctrl+\ in every encoding +// a terminal uses for them (plain, kitty's CSI u, xterm's modifyOtherKeys), +// and a plain character. +func keyOf(tok []byte) string { + s := string(tok) + switch s { + case "\x1d", "\x1b[93;5u", "\x1b[27;5;93~": + return "C-]" + case "\x1c", "\x1b[92;5u", "\x1b[27;5;92~": + return `C-\` + case "\x1b": + return "Esc" + case "\x03": + return "C-c" + case "\r", "\x1b[13u": + return "Enter" + case "\x1b[A", "\x1bOA": + return "Up" + case "\x1b[B", "\x1bOB": + return "Down" + case "\x1b[C", "\x1bOC": + return "Right" + case "\x1b[D", "\x1bOD": + return "Left" + case "\x1b[5~": + return "PgUp" + case "\x1b[6~": + return "PgDn" + case "\x1b[27u": + return "Esc" + } + if strings.HasPrefix(s, "\x1b[") && strings.HasSuffix(s, "u") { + // kitty: CSI code[;mods] u + params := strings.Split(strings.TrimSuffix(strings.TrimPrefix(s, "\x1b["), "u"), ";") + code, err := strconv.Atoi(strings.Split(params[0], ":")[0]) + mods := 1 + if len(params) > 1 { + mods, _ = strconv.Atoi(strings.Split(params[1], ":")[0]) + } + if err == nil && code >= 0x20 && code < 0x7f && (mods == 1 || mods == 2) { + ch := rune(code) + if mods == 2 && ch >= 'a' && ch <= 'z' { + ch -= 'a' - 'A' + } + return string(ch) + } + } + if len(tok) == 1 && tok[0] >= 0x20 && tok[0] < 0x7f { + return s + } + return "" +} + +// handleToken acts on a token that belongs to the client and reports +// whether it did; anything else goes on to the focused pane. +func (c *bareClient) handleToken(tok []byte) bool { + if strings.HasPrefix(string(tok), "\x1b[<") { + c.mouse(string(tok)) + return true + } + if strings.HasPrefix(string(tok), "\x1b[M") && len(tok) == 6 { + return true // an X10 report: this client asked for SGR ones + } + key := keyOf(tok) + c.mu.Lock() + overlay, hints, prefix := c.overlay, c.hints, c.prefix + p := c.focused() + scrolling := p != nil && p.scroll > 0 + c.mu.Unlock() + switch { + case overlay != nil: + c.overlayKey(key) + return true + case hints != nil: + c.hintsKey(key) + return true + case prefix: + c.mu.Lock() + c.prefix = false + c.mu.Unlock() + c.markDirty() + if key == "C-]" { + c.forward([]byte{0x1d}) + return true + } + c.command(key) + return true + case scrolling: + c.scrollKey(p, key) + return true + case key == "C-]": + c.mu.Lock() + c.prefix = true + c.mu.Unlock() + c.markDirty() + return true + case key == `C-\`: + // Never SIGQUIT: in a terminal it ends the agent, and the screen + // a dead agent leaves behind used to show its launch command. + c.controlsPopup("upload") + return true + case string(tok) == "\x1b[I" || string(tok) == "\x1b[O": + if p != nil { + p.mu.Lock() + wants := p.modes[ansi.ModeFocusEvent] + p.mu.Unlock() + if !wants { + return true + } + } + } + return false +} + +// forward types into the focused pane. +func (c *bareClient) forward(b []byte) { + c.mu.Lock() + p := c.focused() + c.sel = nil + c.mu.Unlock() + if p != nil { + _ = p.conn.Write(append([]byte(nil), b...)) + } +} + +// command runs what follows Ctrl+]. +func (c *bareClient) command(key string) { + switch key { + case "d": + c.finish("leave") + case "m": + c.controlsPopup("") + case "u": + c.controlsPopup("upload") + case "y": + c.allowOnce() + case "e": + c.startHints() + case "|", "%": + c.split('|') + case "-", `"`: + c.split('-') + case "c": + c.split(0) + case "o", "Right", "Down": + c.cycleFocus(1) + case "Left", "Up": + c.cycleFocus(-1) + case "x": + c.closeFocused() + case "[": + c.mu.Lock() + if p := c.focused(); p != nil { + p.mu.Lock() + if !p.emu.IsAltScreen() && p.emu.ScrollbackLen() > 0 { + p.scroll = min(p.emu.ScrollbackLen(), max(1, p.h/2)) + } + p.mu.Unlock() + } + c.mu.Unlock() + c.markDirty() + case "?", " ": + c.showKeys() + case "Esc", "C-c", "": + default: + c.say("Ctrl+] " + key + " does nothing here · Ctrl+] ? lists every key") + } +} + +func (c *bareClient) cycleFocus(delta int) { + c.mu.Lock() + if n := len(c.panes); n > 1 { + c.focus = (c.focus + delta + n) % n + c.full = true + } + c.mu.Unlock() + c.markDirty() +} + +// showKeys is Ctrl+] ?: every key, each runnable from the list. +func (c *bareClient) showKeys() { + c.mu.Lock() + leave := "Leave (the session keeps running)" + if c.controls.TabView { + leave = "Close this tab (the session keeps running)" + } + items := []bareItem{{"Lectern actions for this session", "m", func() { c.controlsPopup("") }}} + if c.needsYou != "" { + items = append(items, bareItem{"Allow the waiting request once", "y", c.allowOnce}) + } + items = append(items, + bareItem{`Send a file to the agent (also Ctrl+\)`, "u", func() { c.controlsPopup("upload") }}, + bareItem{"Pick a path or link on screen", "e", c.startHints}, + bareItem{"Shell to the right", "|", func() { c.split('|') }}, + bareItem{"Shell below", "-", func() { c.split('-') }}, + ) + if len(c.panes) > 1 { + items = append(items, bareItem{"Next pane", "o", func() { c.cycleFocus(1) }}) + if p := c.focused(); p != nil && !p.agent { + items = append(items, bareItem{"Close this shell pane", "x", c.closeFocused}) + } + } + items = append(items, + bareItem{"Scroll back (q stops)", "[", func() { c.command("[") }}, + bareItem{leave, "d", func() { c.finish("leave") }}, + bareItem{"Send Ctrl+] to the agent", "C-]", func() { c.forward([]byte{0x1d}) }}, + ) + c.overlay = &bareOverlay{title: " Attach keys ", items: items} + c.mu.Unlock() + c.markDirty() +} + +func (c *bareClient) overlayKey(key string) { + c.mu.Lock() + o := c.overlay + if o == nil { + c.mu.Unlock() + return + } + var run func() + switch key { + case "Esc", "q", "C-c", "C-]": + c.overlay = nil + c.full = true + case "Up", "k": + o.index = (o.index - 1 + len(o.items)) % len(o.items) + case "Down", "j": + o.index = (o.index + 1) % len(o.items) + case "Enter": + run = o.items[o.index].run + default: + for _, it := range o.items { + if it.key == key { + run = it.run + } + } + } + if run != nil { + c.overlay = nil + c.full = true + } + c.mu.Unlock() + c.markDirty() + if run != nil { + run() + } +} + +func (c *bareClient) scrollKey(p *barePane, key string) { + p.mu.Lock() + limit := p.emu.ScrollbackLen() + switch key { + case "Up", "k": + p.scroll++ + case "Down", "j": + p.scroll-- + case "PgUp", "b": + p.scroll += max(1, p.h-1) + case "PgDn", " ", "f": + p.scroll -= max(1, p.h-1) + case "g": + p.scroll = limit + case "G", "q", "Esc", "C-c", "Enter": + p.scroll = 0 + } + p.scroll = max(0, min(limit, p.scroll)) + p.mu.Unlock() + c.markDirty() +} + +// mouse handles an SGR report: CSI < b ; x ; y M (press) or m (release). +func (c *bareClient) mouse(report string) { + body := strings.TrimPrefix(report, "\x1b[<") + release := strings.HasSuffix(body, "m") + parts := strings.Split(body[:len(body)-1], ";") + if len(parts) != 3 { + return + } + b, _ := strconv.Atoi(parts[0]) + col, _ := strconv.Atoi(parts[1]) + row, _ := strconv.Atoi(parts[2]) + col, row = col-1, row-1 + button, motion, wheel := b&3, b&32 != 0, b&64 != 0 + + c.mu.Lock() + if o := c.overlay; o != nil { + c.mu.Unlock() + if !release && !motion && button == 0 && !wheel { + if i := o.itemAt(col, row); i >= 0 { + c.mu.Lock() + run := o.items[i].run + c.overlay, c.full = nil, true + c.mu.Unlock() + c.markDirty() + run() + return + } + c.overlayKey("Esc") + } + return + } + if row == c.rows-1 { + c.mu.Unlock() + // The bar itself: a click there lists every key. + if !release && !motion && button == 0 && !wheel { + c.showKeys() + } + return + } + var p *barePane + for i, q := range c.panes { + if q.contains(col, row) { + p = q + if !release && !motion && !wheel && c.focus != i { + c.focus, c.full = i, true + } + } + } + c.mu.Unlock() + if p == nil { + return + } + x, y := col-p.x, row-p.y + p.mu.Lock() + programMouse := p.wantsMouse() && p.scroll == 0 && b&4 == 0 + sgr := p.modes[ansi.ModeMouseExtSgr] + anyMotion := p.modes[ansi.ModeMouseAnyEvent] + dragMotion := p.modes[ansi.ModeMouseButtonEvent] + alt := p.emu.IsAltScreen() + p.mu.Unlock() + if programMouse { + if motion && !anyMotion && !(dragMotion && c.pressed) { + return + } + if !motion && !wheel { + c.pressed = !release + } + var out string + if sgr { + end := "M" + if release { + end = "m" + } + out = fmt.Sprintf("\x1b[<%d;%d;%d%s", b, x+1, y+1, end) + } else { + code := b + if release { + code = 3 | b&^3 + } + out = "\x1b[M" + string(rune(32+code)) + string(rune(32+min(x+1, 222))) + string(rune(32+min(y+1, 222))) + } + _ = p.conn.Write([]byte(out)) + return + } + switch { + case wheel: + if alt { + return + } + p.mu.Lock() + if b&1 == 0 { + p.scroll = min(p.emu.ScrollbackLen(), p.scroll+3) + } else { + p.scroll = max(0, p.scroll-3) + } + p.mu.Unlock() + c.markDirty() + case button == 0 && !release && !motion: + c.mu.Lock() + c.sel = &bareSelection{pane: p, start: [2]int{x, y}, end: [2]int{x, y}} + c.mu.Unlock() + c.markDirty() + case button == 0 && motion: + c.mu.Lock() + if c.sel != nil && c.sel.pane == p { + c.sel.end = [2]int{max(0, min(p.w-1, x)), max(0, min(p.h-1, y))} + c.sel.moved = true + } + c.mu.Unlock() + c.markDirty() + case release && button == 0: + c.mu.Lock() + sel := c.sel + double := c.click.pane == p && c.click.x == x && c.click.y == y && time.Since(c.click.at) < 450*time.Millisecond + c.click.pane, c.click.x, c.click.y, c.click.at = p, x, y, time.Now() + if sel != nil && !sel.moved { + c.sel = nil + } + c.mu.Unlock() + if sel != nil && sel.moved { + text := sel.text() + _ = c.copy(text) + c.say(fmt.Sprintf("Copied %d characters", len([]rune(text)))) + return + } + c.markDirty() + if double { + go c.openAt(p, x, y) + } + case button == 2 && !release && !motion: + go c.linkMenuAt(p, x, y) + } +} + +// ---- links + +func (c *bareClient) linkAt(p *barePane, x, y int) (filelinks.Link, bool) { + rows := p.rows() + return c.links.detectIn(rows, p.w, x, y, p.hyperlinkAt(x, y)) +} + +func (c *bareClient) openAt(p *barePane, x, y int) { + link, ok := c.linkAt(p, x, y) + if !ok { + return + } + if err := c.links.act("open", link); err != nil { + c.say(failure("open", err)) + } +} + +func (c *bareClient) linkMenuAt(p *barePane, x, y int) { + link, ok := c.linkAt(p, x, y) + if !ok { + c.say("No path or link there · Ctrl+] e labels the ones on screen") + return + } + c.linkMenu(link) +} + +// linkMenu offers what can be done with one link, as the tmux path's +// right-click menu does. +func (c *bareClient) linkMenu(link filelinks.Link) { + act := func(action string) func() { + return func() { + go func() { + if err := c.links.act(action, link); err != nil { + c.say(failure(action, err)) + } + }() + } + } + _, local := localOpener() + var items []bareItem + title := link.URL + if link.Kind == "url" { + if local { + items = append(items, bareItem{"Open in browser", "o", act("open")}) + } else { + items = append(items, bareItem{"Copy link to open in your browser", "o", act("open")}) + } + items = append(items, bareItem{"Copy link", "c", act("copy")}) + } else { + title = link.Path + if local { + items = append(items, bareItem{"Open on this machine", "o", act("open")}, bareItem{"Download to ~/Downloads", "d", act("download")}) + } else { + items = append(items, bareItem{"View here", "v", act("view")}) + } + items = append(items, bareItem{"Copy path", "c", act("copy")}, bareItem{"Send path to the agent", "s", act("send")}, + bareItem{"Open in web viewer", "w", act("web")}) + } + if len([]rune(title)) > 50 { + title = "…" + string([]rune(title)[len([]rune(title))-49:]) + } + c.mu.Lock() + c.overlay = &bareOverlay{title: " " + path.Base(title) + " ", items: items} + if link.Kind == "url" { + c.overlay.title = " " + title + " " + } + c.mu.Unlock() + c.markDirty() +} + +// startHints is Ctrl+] e: label every path and link on the focused pane. +func (c *bareClient) startHints() { + c.mu.Lock() + p := c.focused() + c.mu.Unlock() + if p == nil { + return + } + c.say("Looking for paths and links…") + go func() { + rows := p.rows() + found := c.links.paneHints(rows, p.w) + if len(found) == 0 { + c.say("No paths or links on screen") + return + } + c.mu.Lock() + c.hints = &bareHints{pane: p, rows: rows, hints: found} + c.message = "" + c.full = true + c.mu.Unlock() + c.markDirty() + }() +} + +func (c *bareClient) hintsKey(key string) { + c.mu.Lock() + h := c.hints + if h == nil { + c.mu.Unlock() + return + } + if key == "Esc" || key == "C-c" || key == "C-]" || len(key) != 1 { + if len(key) != 1 { + c.hints, c.full = nil, true + } + c.mu.Unlock() + c.markDirty() + return + } + ch := key[0] + if ch >= 'A' && ch <= 'Z' { + h.actions = true + ch += 'a' - 'A' + } + if ch < 'a' || ch > 'z' { + c.mu.Unlock() + return + } + h.typed += string(ch) + var matched *hint + prefix := false + for i := range h.hints { + if h.hints[i].label == h.typed { + matched = &h.hints[i] + } else if strings.HasPrefix(h.hints[i].label, h.typed) { + prefix = true + } + } + if matched == nil && !prefix { + h.typed, h.actions = "", false + } + if matched != nil { + c.hints, c.full = nil, true + } + actions := h.actions + c.mu.Unlock() + c.markDirty() + if matched != nil { + link := matched.link + if actions { + c.linkMenu(link) + return + } + go func() { + if err := c.links.act("open", link); err != nil { + c.say(failure("open", err)) + } + }() + } +} + +// ---- shells beside the agent + +// split opens a shell on the session's machine, in the agent's directory, +// as a new pane beside (|) or below (-) — a new tracked shell session, as +// with the tmux path's Ctrl+] |. +func (c *bareClient) split(dir byte) { + c.mu.Lock() + if len(c.panes) >= 4 { + c.mu.Unlock() + c.say("Four panes is the most here · Ctrl+] x closes a shell") + return + } + if dir == 0 { + dir = '|' + } + if len(c.panes) == 1 { + c.dir = dir + } else if c.dir != dir { + dir = c.dir + } + w, h := c.cols, max(1, c.rows-1) + c.mu.Unlock() + c.say("Opening a shell where the agent is…") + go func() { + argv, err := splitShellArgv(c.controls.Kind, c.controls.ID, c.controls.Base, c.controls.Token, os.Getenv("LECTERN_ATTACH_HOST"), "agent") + if err != nil { + c.say("Couldn't open a shell: " + err.Error()) + return + } + conn, err := webterm.Open(argv, c.self, max(1, w/2), h) + if err != nil { + c.say("Couldn't open a shell: " + err.Error()) + return + } + pane := newBarePane(c, conn, false, "shell", max(1, w/2), h) + c.mu.Lock() + c.panes = append(c.panes, pane) + c.focus = len(c.panes) - 1 + c.layout() + c.message = "" + c.mu.Unlock() + pane.start() + go func() { + <-pane.ended + c.removePane(pane) + }() + c.markDirty() + }() +} + +func (c *bareClient) removePane(p *barePane) { + c.mu.Lock() + for i, q := range c.panes { + if q == p && !q.agent { + c.panes = append(c.panes[:i], c.panes[i+1:]...) + delete(c.cache, p) + if c.focus >= len(c.panes) || c.focus == i { + c.focus = 0 + } + c.layout() + break + } + } + c.mu.Unlock() + p.close() + c.markDirty() +} + +// closeFocused leaves a shell pane; its shell session keeps running, like a +// closed tmux pane's Lectern shell. +func (c *bareClient) closeFocused() { + c.mu.Lock() + p := c.focused() + c.mu.Unlock() + if p == nil || p.agent { + c.say("The agent's pane stays · Ctrl+] d leaves the session") + return + } + c.removePane(p) +} + +// ---- Lectern actions, approvals and files + +// takeover gives this terminal to fn (the controls dashboard, a pager) and +// takes it back afterwards, drawing the session again. +func (c *bareClient) takeover(fn func()) { + c.mu.Lock() + if c.paused { + c.mu.Unlock() + return + } + c.paused = true + c.prefix = false + c.mu.Unlock() + c.stopInput() + c.write("\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1004l\x1b[?2004l\x1b[?1l\x1b[0 q\x1b[0m\x1b[?25h\x1b[H\x1b[2J") + if c.realKitty != 0 { + c.write("\x1b[