SLOPSHOPPER

pad

Talk to your project. Pad is local-first project management for developers and AI agents — this plugin gives Claude Code a conversational Pad skill, typed…

panebandguardcommandtoastprompt
★ 195v?Apache-2.0updated 2026-10-26PerpetualSoftware/pad/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pad
│ ┃ Pad ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ pad │ ┃ n: Now x: Next b: Browse f: Find a: Acti ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ─────────── ⏺ Update(src/auth.ts) │ ┃ In progress ⎿ Added 2 lines, removed 1 line │ ┃ Nothing in progress. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ──────────────────────────────────────────── │ ┃ ─────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ r: refresh q: quick add via mcp │ ┃ ctrl+x tab: use the pane · ctrl+x x: close ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /pad-pane │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pad
▣ pad n: Now x: Next b: Browse f: Find a: Activity g: Setting ─────────────────────────────────────────────────────── In progress Nothing in progress. ─────────────────────────────────────────────────────── r: refresh q: quick add via mcp ctrl+x tab: use the pane · ctrl+x x: close
README

<img src="docs/brand/mark/pad-mark.svg" width="72" height="72" alt=""> <h1 align="center">Pad</h1> <a href="https://github.com/PerpetualSoftware/pad/actions/workflows/ci.yml"><img src="https://github.com/PerpetualSoftware/pad/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="https://github.com/PerpetualSoftware/pad/releases"><img src="https://img.shields.io/github/v/release/PerpetualSoftware/pad" alt="Release"></a> <a href="https://goreportcard.com/report/github.com/PerpetualSoftware/pad"><img src="https://goreportcard.com/badge/github.com/PerpetualSoftware/pad" alt="Go Report Card"></a> <a href="https://github.com/PerpetualSoftware/pad/pkgs/container/pad"><img src="https://img.shields.io/badge/ghcr.io-perpetualsoftware%2Fpad-blue?logo=docker&logoColor=white" alt="Container image on GHCR"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue" alt="License"></a> <a href="https://github.com/sponsors/xarmian"><img src="https://img.shields.io/github/sponsors/xarmian?label=sponsors&logo=github" alt="GitHub Sponsors"></a> <a href="https://getpad.dev">Website</a> &nbsp;·&nbsp; <a href="https://getpad.dev/docs">Docs</a> &nbsp;·&nbsp; <a href="https://getpad.dev/blog">Blog</a> &nbsp;·&nbsp; <a href="https://getpad.dev/changelog">Changelog</a> &nbsp;·&nbsp; <a href="https://www.reddit.com/r/getpad/">Reddit</a> &nbsp;·&nbsp; <a href="https://x.com/getpaddev">X</a> &nbsp;·&nbsp; <a href="https://bsky.app/profile/getpaddev.bsky.social">Bluesky</a>


One binary. Local-first. No sign-up. Pad gives you a CLI, a web UI, and an AI agent skill — all backed by SQLite, all running on your machine. Your project data stays on your laptop — unless you take it to Pad Cloud.

<img src="docs/screenshots/dashboard.png" width="900" alt="Pad dashboard for a project called Atlas: active work cards, an active plan at 4 of 7 tasks done, collection summaries, and what needs attention next" />

Quick Start

brew install PerpetualSoftware/tap/pad
cd your-project
pad init                    # configure, auth, workspace, AI skill — all in one
pad server open             # opens the web UI at localhost:7777

pad init is the smart entry point — it auto-detects what's needed, walks you through each step, and is safe to re-run anytime (it skips finished steps and prints a status summary).

Then, in a fresh agent session in your project, say:

/pad onboard

Your new workspace ships with the canonical onboard playbook auto-activated. The agent walks an interview, inspects your codebase if it has shell access, and adapts your workspace's collections, conventions, roles, and playbooks to match the project. It's the fastest way to go from empty workspace to "okay, this is mine."

Why Pad?

Tools like Linear, Jira, and Notion are built for teams on the cloud. Pad is built for developers on their machine — and for the AI agents working alongside them. When you do want your projects on every device or a teammate on the board, Pad Cloud hosts the same product with sync, workspace invites, and role-based access. A self-hosted workspace moves there by export and import; see the move-to-Cloud guide.

PadLinear / JiraNotion
Setuppad initCreate account, invite team, configureCreate account, pick template
AI agentsNative /pad skill for 7+ toolsThird-party integrationsThird-party integrations
DataLocal SQLite you own — or opt-in Pad CloudTheir cloudTheir cloud
OfflineFull functionalityRead-only cache at bestLimited
CLIFirst-classAfterthoughtNone
PriceFree, open sourcePer-seat pricingPer-seat pricing

Features

For Developers

CLI that doesn't get in your way. Create tasks, search items, check status — without leaving the terminal.

pad item create task "Fix OAuth redirect" --priority high
pad item create idea "Real-time collaboration" --category infrastructure
pad item list tasks --status in-progress
pad item search "authentication"
pad project dashboard                   # Project dashboard
pad project next                        # What should I work on?
pad server info                         # How this client is connected to Pad

Web UI that stays out of your way. A clean interface at localhost:7777, in dark and light themes, with:

  • Board, list, and table views — drag-and-drop between status columns
  • Keyboard navigation — j/k to move, Enter to open, Esc to go back, Cmd+K to search
  • Rich text editor — Tiptap-based with markdown and auto-save, and real-time co-editing
  • Wiki-links — type [[Title]] to link between items
  • Real-time updates — an agent creates a task in the terminal, it appears in the browser instantly
  • History — every change is versioned, with who made it: a person, or a named agent
  • Dashboard — active work, plan progress, what needs attention, and what to do next

<img src="docs/screenshots/board.png" width="900" alt="Pad tasks board: Open, In-Progress, Done and Cancelled columns of task cards, each showing priority and the plan it belongs to" />

For AI Agents

Using Claude Code? Install the Pad plugin. It bundles the /pad skill, typed shortcuts (/pad:status, /pad:capture, /pad:onboard), live item notifications and a side panel (/pad-pane), and it updates itself:

/plugin marketplace add PerpetualSoftware/pad
/plugin install pad@pad

In the Claude Desktop app's Code tab: + → Plugins, add the marketplace PerpetualSoftware/pad, then install pad. Open the panel with /pad-pane. If nothing is set up yet, it walks you through connecting to Pad Cloud, installing Pad on this machine, or linking your own server. The panel needs Claude Code 2.1.287 or later; the skills work on any version.

Your agent becomes a project partner. Install the /pad skill once, and your AI coding tool can read, create, and update project items through natural language. Cursor, Codex, Windsurf, and OpenCode receive a compact dispatcher, reuse bootstrapped context within a conversation, and load detailed guidance by topic with pad agent guide, keeping routine turns small.

pad agent install        # Auto-detects your tools and installs the skill

Works with Claude Code, Cursor, Windsurf, Codex, OpenCode, GitHub Copilot, Amazon Q, and JetBrains Junie.

Then just talk to your project:

> /pad what should I work on next?
> /pad I finished the OAuth fix
> /pad create a task to add rate limiting
> /pad let's brainstorm about the API redesign

Conventions and playbooks teach agents how your project works:

  • Conventions — trigger-based rules like "run tests before marking a task done" or "use conventional commits"
  • Playbooks — multi-step workflows like "when implementing a feature: read the spec, create a branch, write tests first, then implement". Playbooks can declare a kebab-case invocation_slug so users can invoke them directly: /pad ship PLAN-42, /pad release 0.5.0. Fresh startup workspaces ship a generic ship playbook out of the box.
pad item create convention "Run tests before completing tasks" \
  --field trigger=on-task-complete \
  --field scope=all \
  --field priority=must

Agents load relevant conventions automatically, and every agent action is attributed in the activity feed — so you can see what the AI changed rather than finding it later in a diff.

<img src="docs/screenshots/item.png" width="900" alt="A task open beside the task list: its status, priority, effort and assignee, the plan it belongs to, and a body written by an agent, with a 2 of 4 checklist progress bar on its list row" />

Name your agents. An agent that identifies itself gets its name on its writes: in the activity feed, the dashboard, item timelines and the admin audit log. Pad takes the first of these it finds:

pad session register --agent rook   # 0. this session's registered name
# 1. agent_name = "reviewer" in .pad.toml (per workspace, committed)
export PAD_AGENT=reviewer           # 2. per process
# 3. otherwise the detected runtime, e.g. "claude-code"

With none of these, the write is recorded as the person whose credentials it used. The name is self-declared, so it labels a trail rather than proving who acted; provenance surfaces show both, e.g. reviewer (via Dana). pad session list shows which registered sessions on this machine are alive, and as which agent.

Onboard agents to a new codebase:

Open an agent session in the workspace directory and run /pad onboard. The agent walks an interview, detects your build/test/CI tooling, and adapts your workspace's collections, conventions, roles, and playbooks to match the project. Works for any agent that speaks Pad — Claude Code, MCP-only agents, etc.

Collections & Custom Fields

Pad organizes work into collections — typed containers with structured fields.

Built-in collections:

CollectionPurpose
TasksWork items with status, priority, assignee, effort, due date
IdeasFeature ideas with impact and category
PlansProject milestones with progress tracking
DocsDocumentation, decisions, reference material
ConventionsProject rules that guide agent behavior
PlaybooksMulti-step workflows for agents to follow

Create your own with typed fields — select, text, date, number, url, relation, checkbox:

pad collection create "Bug Reports" \
  --fields "severity:select:low,medium,high,critical; browser:text; reproducible:checkbox"

Items get reference numbers automatically (TASK-5, BUG-12) and can be moved between collections with field migration.

Installation

Homebrew (macOS and Linux)

brew install PerpetualSoftware/tap/pad

Build from Source

git clone https://github.com/PerpetualSoftware/pad
cd pad
make build
cp pad ~/.local/bin/   # or /usr/local/bin/

Requires Go 1.26+ and Node.js 22+. Alternatively, nix develop provides a shell with the exact Go and Node versions pinned — see the Nix section below.

The go install github.com/PerpetualSoftware/pad/cmd/pad@latest path is not supported for the full Pad binary, because the web UI must be built and embedded during the source build.

Docker

docker run -p 127.0.0.1:7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad

This publishes Pad to localhost:7777 on the host machine, which is the recommended default for local use.

Pin a version for anything you keep. The untagged name is latest, which moves to each new stable release (never a release candidate) the next time the image is pulled. Name the release instead, as a bare version with no leading v, for example ghcr.io/perpetualsoftware/pad:0.17.2, and change the tag when you choose to upgrade. Every published image is multi-arch and cosign-signed.

First run — create the first admin. Open http://localhost:7777 and you'll hit a setup page asking for a bootstrap token. On first start with no users, Pad logs a one-time setup URL to stderr (captured by docker logs) — grep it and open the printed link:

docker logs <container> 2>&1 | grep -A6 'Pad first-run setup'
# → http://<your-host>:7777/setup#token=<one-time-token>

Open that URL, create your admin account, and the token is consumed (the banner stops appearing). If you'd rather stay on the CLI, docker exec -it <container> pad auth setup works too — running inside the container counts as loopback, which the bootstrap gate allows. On a network you already trust, set PAD_BYPASS_SETUP_TOKEN=true to skip the token and create the admin straight from http://<your-host>:7777/setup (only safe when the port isn't reachable from the open internet).

Single user, more than one device? Publish to all interfaces so you can reach Pad from your phone, tablet, or another machine on the same LAN, Tailscale network, or home VPN:

docker run -p 7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad

Set PUID / PGID to match your host's file ownership: the entrypoint runs Pad as that user. The defaults, 99/100, are Unraid's nobody:users.

For multi-instance deployments, Pad supports Postgres + Redis via docker-compose.yml — see docs/deployment.md for the full setup. The compose file runs the released image; set PAD_VERSION in .env to pin it.

Nix

Run without installing:

nix run github:PerpetualSoftware/pad

Or install into your profile:

nix profile install github:PerpetualSoftware/pad

A flake devShell (Go, Node, and friends, pinned to the same versions CI uses) is also available for contributors:

nix develop

A nixpkgs package (nix-shell -p pad / environment.systemPackages) is planned but not yet merged upstream. Until then, use the github:PerpetualSoftware/pad flake reference above.

Binary Download

Pre-built binaries for macOS, Linux, and Windows are available on the releases page.

Pad Cloud (hosted)

Don't want to run anything? Pad Cloud is the managed option — same product, same CLI, same /pad skill, free during beta. Sign up on the web, then connect a project directory:

pad init --url https://app.getpad.dev --workspace my-workspace

Self-hosting stays first-class: the binary is unchanged and no features are Cloud-only.

Upgrading Pad

Pad ships a new binary on a roughly weekly cadence. Upgrades are designed to be boring: install the new binary and restart. Database migrations run automatically at startup, only the ones your database is missing are applied, and each migration commits atomically (a failed migration rolls back cleanly and is retried next boot).

The one rule: only ever move forward. Newer binaries know how to migrate an older database; older binaries do not understand a newer schema. Since Pad added its schema-ahead guard, a downgraded binary that finds a database newer than itself refuses to start rather than silently running old code against a newer schema (which can corrupt data):

database schema is newer than this pad binary: ... This almost always means the
binary was DOWNGRADED (e.g. brew/docker rollback) ... Upgrade pad back to a build
that includes those migrations, or re-run with `pad start --force`.

To recover, reinstall the newer binary (brew upgrade pad, pull the newer Docker tag, etc.). If you have intentionally downgraded and accept the risk, start with pad start --force (or set PAD_ALLOW_SCHEMA_AHEAD=1) to override the guard.

Automatic pre-migration snapshot (SQLite). Whenever a SQLite-backed instance has pending migrations to apply, Pad first copies the database file to pad.db.pre-<version> next to it. If an upgrade ever goes wrong, stop the server and copy that snapshot back over pad.db. This is a convenience net, not a backup strategy — keep your own backups (see docs/backup.md). PostgreSQL instances are skipped here; use pg_dump or a provider snapshot before upgrading.

Recommended upgrade flow:

# 1. Back up first (SQLite shown; see docs/backup.md for Postgres)
pad db backup -o pad-backup-$(date +%Y%m%d).db

# 2. Stop the server, install the new binary, restart
#    (migrations + the pre-migration snapshot run automatically on start)
brew upgrade pad        # or: a new image tag (see Docker above), binary download, make install

# 3. Confirm it's healthy
pad --version
curl -s localhost:7777/api/v1/health

Getting Started

1. Set up Pad

cd ~/projects/myapp
pad init "My App"

pad init is the smart entry point that handles everything in one command:

  • Configures this client's connection (local server, remote, or Docker)
  • Auto-starts the local server
  • Creates the first admin account on a fresh local install (Docker / remote hosts run pad auth setup on the server instead)
  • Logs you in if needed
  • Creates or links a workspace for the current directory (writes .pad.toml)
  • Installs the /pad skill for any AI tools detected in the project

Run from your project root. Safe to re-run anytime — it skips finished steps and prints a status summary if nothing's needed.

Choose a template with --template, or omit it for an interactive picker grouped by category (Software / People / …):

pad workspace init --list-templates                   # See the full catalog grouped by category
pad init "My App" --template scrum                    # Scrum-style with sprints
pad init "My App" --template product                  # Product management focused
pad init "My Hiring" --template hiring                # Company-side: requisitions, candidates, interview loops, feedback
pad init "Job Search" --template interviewing         # Candidate-side: applications, interviews, companies, contacts
pad init "My App" --template blank                    # Custom: system collections only — let /pad onboard build the rest

Pad ships templates for software (startup / scrum / product), people workflows (hiring, interviewing), and a custom blank template — system collections (Conventions, Playbooks) only, with the /pad onboard playbook as its sole seeded content. blank is the entry point for the agent-driven /pad onboard flow: it walks you through shaping collections, conventions, and roles to match your actual project. Reserved categories for research, content, operations, and personal use await their first templates, so the same project-management primitives fit well beyond code projects. There's also a hidden demo template — the startup layout pre-loaded with realistic sample data — that's kept out of the picker but can be built explicitly with --template demo.

2. Start working

# From the CLI
pad item create task "Set up CI pipeline" --priority high
pad item create idea "Add WebSocket support" --category infrastructure
pad project dashboard

# From the web UI
pad server open              # Opens localhost:7777 in your browser

# From your AI agent
# Just use /pad in Claude Code, Cursor, etc.

3. Teach your agents the rules

In an agent session inside the workspace:

/pad onboard

The agent walks an interview, detects your tooling, and adapts the workspace's collections, conventions, roles, and playbooks. To browse the library directly:

pad library list --type conventions  # Pre-built conventions you can adopt
pad library list --type playbooks    # Pre-built multi-step workflows

4. Optional — connect a desktop AI app via MCP

Pad ships an MCP (Model Context Protocol) server so Claude Desktop, Cursor, Windsurf, Claude Code, or Codex can manage items, plans, ideas, and dependencies as native tools, read workspace state by URL, and load multi-step workflows as prompts.

pad mcp install claude-desktop   # or: cursor, windsurf, claude-code, codex, --all
# Restart the client; pad shows up as the "pad" MCP server.

Cursor and Codex installations can opt into compact tool results:

pad mcp install cursor --compact-results
pad mcp install codex --compact-results

Pad keeps the result channel each client currently exposes to its model: JSON text for Cursor and structuredContent for Codex. It removes the duplicate channel only from successful structured results; errors and one-channel results remain unchanged. Leave the flag off for other clients or compatibility testing.

pad mcp install writes each client's native config: JSON mcpServers for Claude Desktop / Cursor / Windsurf, a project-local .mcp.json in the current directory for claude-code, and an [mcp_servers.pad] table in ~/.codex/config.toml (TOML) for codex. Because Claude Code's config is project-scoped, it's install-on-request only — --all and pad mcp status cover the per-user clients (including Codex) and skip it.

Tool catalog (v0.106) — eleven resource × action tools plus pad_set_workspace. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:

ToolActions
pad_itemcreate, update, delete, get, list, move, restore, link, unlink, deps, context, star, unstar, starred, comment, list-comments, edit-comment, delete-comment, backlinks, bulk-update, note, decide, export, import, history, remind, ack-reminder, reminders, unremind, link-pr, unlink-pr, claim, release, patch
pad_workspacelist, members, invite, storage, audit-log, create, claim, deleted, restore
pad_collectionlist, create, update, delete, list-archived, restore
pad_projectdashboard, next, ready, stale, standup, changelog, report, activity, catch-up
pad_rolelist, create, update, delete
pad_searchquery
pad_playbooklist, get, run, match
pad_librarylist, get, activate
pad_attachmentlist, show
pad_chatlist, inbox, show, send, delete, read (global agent chat; excluded from ChatGPT)
pad_metaserver-info, version, tool-surface, bootstrap
pad_set_workspacesession-default workspace pinning (response embeds the bootstrap blob)

Plus resources at pad://workspaces, pad://workspace/{ws}/dashboard, pad://workspace/{ws}/items, pad://workspace/{ws}/items/{ref}, pad://workspace/{ws}/collections, pad://workspace/{ws}/attachments/{id} (bounded image bytes), pad://workspace/{ws}/bootstrap, and pad://_meta/version.

Stability contract. The catalog is versioned (tool_surface_version: "0.106") and advertised in the initialize handshake and at pad://_meta/version, so external agents can pin against it; the per-version changelog lives in internal/mcp/version.go. Errors come back as structured envelopes ({error: {code, message, hint, ...}}) from a closed code list in internal/mcp/errors.go. Branch on code, not message text, and treat stored_state_unreadable as STOP rather than retry.

Full guide at getpad.dev/mcp/local — install paths, action enums per tool, error taxonomy, troubleshooting.

On Pad Cloud? Skip the install: add https://mcp.getpad.dev as a remote MCP server in Claude Desktop, Claude.ai, Cursor, or Windsurf and sign in with OAuth — same tool surface, no local binary. Setup guide at getpad.dev/mcp/remote. ChatGPT connects through its own OAuth-only endpoint; see getpad.dev/docs/mcp/chatgpt.

Self-hosting? The same remote MCP endpoint is built in and off by default: an admin turns it on, with OAuth when its issuer URL is https and personal API tokens either way. See "MCP for agents (self-hosted)" in [docs/deployment.md](docs/d

Source 1 files
hooks/register.js 1568 lines
1// The Pad pane inside Claude Code (IDEA-3477; folded into the pad plugin by TASK-3486).
2//
3// One command, /pad-pane, opens a pane on the linked workspace. Nothing else
4// changes what Claude sees, and nothing blocks. The one band is the chat-seen
5// band (TASK-4071): chat delivered to the agent, shown to the person too. Session start runs
6// nothing (TASK-3970); after a plugin reload it only picks back up a pane that
7// was already open. Otherwise the mod listens for Claude touching Pad: the
8// first touch wakes the session (presence, live events; see wake), and an
9// open pane stays fresh.
10//
11// Views: Now (n), Next (x), Browse (b), Find (f), Activity (a), Settings (g).
12// Item view: status (s), comment (c), work on it (w), copy ref (y), open in
13// link (o), back (h). Quick add: q. Refresh: r.
14
15// ---------------------------------------------------------------- Pad client
16
17// One Pad client, two transports. The pane only calls these methods, so it
18// works the same with the `pad` CLI installed or with only an MCP connection.
19//
20// CLI: `pad … --format json`, which finds the workspace from .pad.toml and
21//      carries its own credentials.
22// MCP: $.mcp.call on a connected Pad server (a Pad plugin's server, the
23//      remote mcp.getpad.dev, or a local `pad mcp`), using the workspace slug
24//      from .pad.toml.
25
26const MCP_SERVER_GUESSES = ['pad', 'claude.ai pad', 'plugin:pad:pad', 'getpad']
27const PAD_CLOUD_APP = 'https://app.getpad.dev'
28
29// An MCP endpoint's web app: Pad Cloud serves MCP from its own host, and a
30// self-hosted Pad serves it at <base>/mcp.
31export function webBaseFromMcp(url) {
32  let u
33  try { u = new URL(url) } catch { return PAD_CLOUD_APP }
34  if (u.hostname === 'mcp.getpad.dev') return PAD_CLOUD_APP
35  return (u.origin + u.pathname.replace(/\/mcp\/?$/, '')).replace(/\/+$/, '')
36}
37
38// .pad.toml from the session directory upwards: { workspace, url }.
39async function readPadToml($) {
40  let dir = String(await $.session.cwd())
41  for (let i = 0; i < 40 && dir; i++) {
42    const path = (dir === '/' ? '' : dir) + '/.pad.toml'
43    try {
44      if (await $.fs.exists(path)) {
45        const text = String(await $.fs.read(path))
46        const get = (k) => (text.match(new RegExp('^\\s*' + k + '\\s*=\\s*"([^"]*)"', 'm')) || [])[1]
47        return { workspace: get('workspace'), url: get('url'), dir }
48      }
49    } catch {}
50    if (dir === '/') break
51    dir = dir.replace(/\/[^/]*$/, '') || '/'
52  }
53  return null
54}
55
56function parseMcp(result) {
57  if (!result) throw new Error('no result')
58  const text = (result.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('')
59  if (result.isError) throw new Error(text.split('\n')[0] || 'MCP error')
60  try {
61    return JSON.parse(text)
62  } catch {
63    return text
64  }
65}
66
67// Some endpoints return `fields` as a JSON string (item show), others as an
68// object (lists). Always hand the pane an object.
69function fieldsOf(f) {
70  if (f && typeof f === 'object') return f
71  if (typeof f === 'string') {
72    try {
73      const v = JSON.parse(f)
74      return v && typeof v === 'object' ? v : {}
75    } catch {}
76  }
77  return {}
78}
79
80const arr = (x, ...keys) => {
81  if (Array.isArray(x)) return x
82  for (const k of keys) if (x && Array.isArray(x[k])) return x[k]
83  return []
84}
85
86function makeClient($, settings, toml) {
87  let transport = null // 'cli' | 'mcp'
88  let mcpServer = null
89  let ws = toml?.workspace || null
90
91  async function cli(args) {
92    const r = await $.process.run(['pad', ...args, '--format', 'json'], { timeoutMs: 20000 })
93    if (r.exitCode !== 0) throw new Error((r.stderr || r.stdout || 'pad failed').trim().split('\n')[0])
94    return JSON.parse(r.stdout)
95  }
96
97  async function mcp(tool, args) {
98    return parseMcp(await $.mcp.call(mcpServer, tool, { workspace: ws, ...args }))
99  }
100
101  async function connect() {
102    const want = settings.transport // 'auto' | 'cli' | 'mcp'
103    if (want !== 'mcp') {
104      try {
105        const boot = await cli(['bootstrap'])
106        if (boot?.workspace?.slug) {
107          ws = boot.workspace.slug
108          transport = 'cli'
109          return { transport, ws }
110        }
111      } catch (err) {
112        if (want === 'cli') throw err
113      }
114    }
115    const names = settings.mcpServer ? [settings.mcpServer] : MCP_SERVER_GUESSES
116    for (const name of names) {
117      try {
118        mcpServer = name
119        await mcp('pad_collection', { action: 'list' })
120        transport = 'mcp'
121        return { transport, ws, server: name }
122      } catch {}
123    }
124    mcpServer = null
125    throw new Error(
126      ws
127        ? 'No way to reach Pad: the pad CLI is not installed or not linked here, and no Pad MCP server answered.'
128        : 'This folder is not linked to a Pad workspace (no .pad.toml).'
129    )
130  }
131
132  let webBase = null
133  async function resolveWebBase() {
134    const clean = (u) => String(u).trim().replace(/\/+$/, '')
135    if (toml?.url) return clean(toml.url)
136    try {
137      if (transport === 'cli') {
138        const info = await cli(['server', 'info'])
139        if (info?.config?.base_url) return clean(info.config.base_url)
140      } else if (transport === 'mcp' && mcpServer && !mcpServer.startsWith('claude.ai ')) {
141        const r = await $.process.run(['claude', 'mcp', 'get', mcpServer], { timeoutMs: 10000 })
142        const url = (String(r.stdout || '').match(/URL:\s*(\S+)/) || [])[1]
143        if (url) return webBaseFromMcp(url)
144      }
145    } catch {}
146    return PAD_CLOUD_APP
147  }
148
149  const via = (cliFn, mcpFn) => (...a) => (transport === 'cli' ? cliFn(...a) : mcpFn(...a))
150
151  return {
152    connect,
153    get transport() { return transport },
154    get workspace() { return ws },
155    // The web address of an item on the server this pane talks to (BUG-3484):
156    // .pad.toml's url, else the CLI's configured server, else the MCP server's
157    // own URL (Pad Cloud's MCP host maps to the app), else Pad Cloud.
158    async webUrl(ref) {
159      if (!webBase) webBase = await resolveWebBase()
160      return `${webBase}/-/r/${ws}/${ref}`
161    },
162    dashboard: via(() => cli(['project', 'dashboard']), () => mcp('pad_project', { action: 'dashboard' })),
163    ready: via(() => cli(['project', 'ready']), () => mcp('pad_project', { action: 'ready' })),
164    activity: via(
165      (limit) => cli(['project', 'activity', '--limit', String(limit)]),
166      (limit) => mcp('pad_project', { action: 'activity', limit })
167    ),
168    collections: via(() => cli(['collection', 'list']), () => mcp('pad_collection', { action: 'list' })),
169    list: via(
170      (collection) => cli(['item', 'list', collection, '--limit', '100']),
171      (collection) => mcp('pad_item', { action: 'list', collection, limit: 100 })
172    ),
173    get: via((ref) => cli(['item', 'show', ref]), (ref) => mcp('pad_item', { action: 'get', ref })),
174    comments: via(
175      (ref) => cli(['item', 'comments', ref]),
176      (ref) => mcp('pad_item', { action: 'list-comments', ref })
177    ),
178    search: via(
179      (q) => cli(['item', 'search', q, '--limit', '30']),
180      (q) => mcp('pad_search', { action: 'query', query: q, limit: 30 })
181    ),
182    setStatus: via(
183      (ref, status) => cli(['item', 'update', ref, '--status', status]),
184      (ref, status) => mcp('pad_item', { action: 'update', ref, status })
185    ),
186    comment: via(
187      (ref, text) => cli(['item', 'comment', ref, text]),
188      (ref, text) => mcp('pad_item', { action: 'comment', ref, message: text })
189    ),
190    create: via(
191      (collection, title) => cli(['item', 'create', collection, title]),
192      (collection, title) => mcp('pad_item', { action: 'create', collection, title })
193    ),
194  }
195}
196
197// Shapes differ a little between endpoints and transports; these flatten them.
198const norm = {
199  row(x) {
200    return {
201      ref: x.ref || x.item_ref || x.item?.ref,
202      title: x.title || x.item_title || x.item?.title || '',
203      status: x.status || fieldsOf(x.fields).status || fieldsOf(x.item?.fields).status || '',
204      // Bugs rank by severity; show it where a priority would go.
205      priority: x.priority || fieldsOf(x.fields).priority || fieldsOf(x.item?.fields).priority || x.severity || fieldsOf(x.fields).severity || '',
206      collection: x.collection_slug || x.collection || x.item?.collection_slug || '',
207      icon: x.collection_icon || x.item?.collection_icon || '',
208      updated: x.updated_at || x.item?.updated_at || '',
209      reason: x.reason || '',
210    }
211  },
212  activeItems: (d) => arr(d?.active_items).map(norm.row),
213  ready: (r) => arr(r, 'results').map(norm.row),
214  list: (r) => arr(r, 'items', 'results').map(norm.row),
215  search: (r) => arr(r, 'results').map((x) => norm.row(x.item ? { ...x.item, ...x } : x)),
216  comments: (r) => arr(r, 'comments'),
217  activity: (r) => arr(r, 'activities', 'results'),
218  collections(r) {
219    return arr(r, 'collections').map((c) => {
220      let schema = c.schema
221      if (typeof schema === 'string') {
222        try { schema = JSON.parse(schema) } catch { schema = {} }
223      }
224      const status = (schema?.fields || []).find((f) => f.key === 'status')
225      return {
226        slug: c.slug, name: c.name, icon: c.icon || '·', prefix: c.prefix,
227        system: !!c.is_system, open: c.active_item_count ?? null, total: c.item_count ?? 0,
228        statuses: status?.options || [], terminal: status?.terminal_options || [],
229      }
230    })
231  },
232}
233
234function ago(iso) {
235  if (!iso) return ''
236  const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000)
237  if (s < 60) return Math.floor(s) + 's'
238  if (s < 3600) return Math.floor(s / 60) + 'm'
239  if (s < 86400) return Math.floor(s / 3600) + 'h'
240  return Math.floor(s / 86400) + 'd'
241}
242
243// ---------------------------------------------------------------- pane state
244
245const PANE = 'pad'
246const PLUGIN = 'pad' // as plugin.json names it: ui.focus redirects carry it
247const DEFAULTS = { startView: 'now', refreshSec: 60, transport: 'auto', mcpServer: '', background: 'theme' }
248// The engine fills a pane with the terminal's "bright black", whose shade is the
249// terminal palette's; these paint the pane's body over it. 'theme' leaves it be.
250const BACKGROUNDS = { theme: undefined, dark: '#262626', darker: '#1c1c1c', black: '#121212' }
251
252let settings = { ...DEFAULTS }
253let client = null
254let conn = null // { transport, ws } once connected
255let connError = null
256// Get started: what this machine has, read when the pane can't connect.
257const PAD_CLOUD_MCP = 'https://mcp.getpad.dev'
258let setup = null // { hasCli, linked } once checked
259let cloudStep = null // null | 'confirm' | 'added' | 'failed'
260let cloudError = null
261let open = false
262
263let view = 'now'
264const stack = []
265const data = { now: [], next: [], activity: [], collections: [], list: [], results: [], query: '' }
266let browseCollection = null
267let item = null // { ref, raw, comments, statusOptions }
268let mode = null // null | 'status' | 'comment' | 'add'
269let addCollection = null
270let loadedAt = null
271let busy = false
272let notice = null
273
274// Keyboard model. Tab and Shift+Tab walk the menu: the view tabs, the list as ONE
275// stop, then the view's other controls. Up and Down walk the list's rows.
276// The engine reports Tab only as a focus move and an arrow only as a one-row
277// scroll whenever the drawing is taller than the pane, so the drawing is always
278// kept one row taller, and the two hooks below give each key its own meaning.
279const ring = { order: [], cur: null, lastRow: {}, terminal: true }
280const LIST_KEY = /^(row-|coll-|act-|empty-back$|gs-)/
281const NOT_A_STOP = /^(refresh|add)$/ // footer actions: their hotkeys reach them
282const stopOf = (k) => (LIST_KEY.test(k) ? 'LIST' : k)
283const listKeys = () => ring.order.filter((k) => LIST_KEY.test(k))
284
285// Where a person's focus move should land, or null to let it land where the
286// engine aimed it. `order` is the drawn controls; `to` is the engine's target
287// (undefined for one of its own stops, such as the close mark). A move of one
288// step either way is Tab or Shift+Tab; anything else is a click.
289// Returns { element, engineStop } (engineStop: the engine aimed at its own stop).
290export function tabStep(order, cur, to, listEntry) {
291  const n = order.length
292  const dT = order.indexOf(to), dC = order.indexOf(cur)
293  if (n < 2 || (dT < 0 && dC < 0)) return null
294  const stops = []
295  for (const k of order) {
296    if (NOT_A_STOP.test(k)) continue
297    const s = stopOf(k)
298    if (stops[stops.length - 1] !== s) stops.push(s)
299  }
300  const land = (target, engineStop = false) => ({ element: target === 'LIST' ? listEntry : target, engineStop })
301  const at = stops.indexOf(stopOf(cur))
302  if (dT >= 0 && dC >= 0) {
303    const dir = (dT - dC + n) % n === 1 ? 1 : (dC - dT + n) % n === 1 ? -1 : 0
304    if (!dir) return null
305    return land(stops[(at + dir + stops.length) % stops.length])
306  }
307  if (dC >= 0) {
308    if (at === 0) return land(stops[stops.length - 1], true)
309    if (at === stops.length - 1) return land(stops[0], true)
310    return null
311  }
312  // coming back from one of the engine's stops: enter at whichever end is nearer
313  return land(dT > n / 2 ? stops[stops.length - 1] : stops[0])
314}
315
316// The row an arrow (a one-row scroll) moves to, or null to let it scroll.
317export function arrowStep(order, cur, by, listEntry) {
318  if (Math.abs(by) !== 1) return null
319  const rows = order.filter((k) => LIST_KEY.test(k))
320  if (!rows.length) return null
321  const i = rows.indexOf(cur)
322  if (i < 0) return by > 0 ? listEntry : null
323  return rows[i + by] || null
324}
325const firstRowFor = () => {
326  const rows = listKeys()
327  const last = ring.lastRow[view + ':' + (browseCollection || '')]
328  return rows.includes(last) ? last : rows[0]
329}
330
331const redraw = ($) => $.ui.invalidate('ui.render')
332const msg = (err) => String(err?.message || err).slice(0, 120)
333
334async function ensureClient($) {
335  if (client && conn) return true
336  const toml = await readPadToml($)
337  client = makeClient($, settings, toml)
338  try {
339    conn = await client.connect()
340    connError = null
341    return true
342  } catch (err) {
343    conn = null
344    connError = msg(err)
345    await checkSetup($)
346    return false
347  }
348}
349
350async function checkSetup($) {
351  let hasCli = false
352  try {
353    hasCli = (await $.process.run(['pad', '--version'], { timeoutMs: 5000 })).exitCode === 0
354  } catch {}
355  setup = { hasCli, linked: !!(await readPadToml($)) }
356}
357
358// Pad Cloud needs no install: add its MCP server to Claude Code (user scope),
359// then the person sends /reload-plugins and signs in from /mcp. Listing it in
360// the plugin manifest instead would nag every session, self-hosters included
361// (PLAN-3490).
362async function addPadCloud($) {
363  try {
364    const r = await $.process.run(['claude', 'mcp', 'add', '--scope', 'user', '--transport', 'http', 'pad', PAD_CLOUD_MCP], { timeoutMs: 20000 })
365    if (r.exitCode !== 0) throw new Error((r.stderr || r.stdout || 'claude mcp add failed').trim().split('\n')[0])
366    cloudStep = 'added'
367    cloudError = null
368    settings = { ...settings, transport: 'auto' }
369    client = null
370    await $.prompt.fill({ text: '/reload-plugins' }).catch(() => {})
371  } catch (err) {
372    cloudStep = 'failed'
373    cloudError = msg(err)
374  }
375}
376
377async function ensureCollections() {
378  if (!data.collections.length) data.collections = norm.collections(await client.collections())
379}
380
381async function load($, what = view) {
382  if (!(await ensureClient($))) return redraw($)
383  busy = true
384  redraw($)
385  try {
386    if (what === 'now') data.now = norm.activeItems(await client.dashboard())
387    else if (what === 'next') data.next = norm.ready(await client.ready())
388    else if (what === 'activity') data.activity = norm.activity(await client.activity(30))
389    else if (what === 'browse') {
390      data.collections = norm.collections(await client.collections())
391      if (browseCollection) data.list = norm.list(await client.list(browseCollection))
392    } else if (what === 'item' && item) await fetchItem($, item.ref)
393    loadedAt = new Date().toISOString()
394    notice = null
395  } catch (err) {
396    notice = 'Pad: ' + msg(err)
397  }
398  busy = false
399  redraw($)
400}
401
402async function fetchItem($, ref) {
403  const got = await client.get(ref)
404  const raw = { ...got, fields: fieldsOf(got?.fields) }
405  const comments = norm.comments(await client.comments(ref))
406  await ensureCollections()
407  const coll = data.collections.find((c) => c.slug === raw.collection_slug)
408  item = { ref, raw, comments, statusOptions: coll?.statuses || [] }
409  loadedAt = new Date().toISOString()
410}
411
412async function openItem($, ref) {
413  if (!(await ensureClient($))) return redraw($)
414  if (view !== 'item') stack.push(view)
415  view = 'item'
416  mode = null
417  busy = true
418  redraw($)
419  try {
420    await fetchItem($, ref)
421    notice = null
422  } catch (err) {
423    notice = 'Pad: could not open ' + ref + ': ' + msg(err)
424  }
425  busy = false
426  redraw($)
427}
428
429function back($) {
430  mode = null
431  view = stack.pop() || settings.startView
432  redraw($)
433}
434
435// One level up from where the pane is, or false at the top level.
436function stepBack($) {
437  if (mode) {
438    mode = null
439  } else if (view === 'item') {
440    view = stack.pop() || settings.startView
441  } else if (view === 'browse' && browseCollection) {
442    browseCollection = null
443  } else if (view !== settings.startView) {
444    view = settings.startView
445    if (view !== 'find' && view !== 'settings') load($, view)
446  } else {
447    return false
448  }
449  redraw($)
450  return true
451}
452
453const OPEN_ARGS = { id: PANE, title: 'Pad', focus: true, closeOnEscape: true }
454
455function go($, v) {
456  mode = null
457  stack.length = 0
458  view = v
459  if (v === 'browse') browseCollection = null
460  if (v === 'find' || v === 'settings') return redraw($)
461  load($, v)
462}
463
464// ---------------------------------------------------------------- drawing
465
466// ---------------------------------------------------------------- look
467
468// Colours are theme keys, so the pane follows the user's light or dark theme.
469const C = { brand: 'merged', open: 'suggestion', active: 'warning', done: 'success', blocked: 'error', muted: 'inactive', accent: 'claude' }
470const DONE = /^(done|fixed|completed?|implemented|published|merged|resolved|shipped|archived|consumed)$/
471const ACTIVE = /^(in[-_ ]?progress|doing|fixing|exploring|implementing|in[-_ ]?review|review|testing|active|drafting|writing|running)$/
472const BLOCKED = /^(blocked|rejected|wont[-_ ]?fix|cancell?ed|failed|deprecated)$/
473
474function statusColor(s) {
475  s = String(s || '').toLowerCase()
476  if (DONE.test(s)) return C.done
477  if (ACTIVE.test(s)) return C.active
478  if (BLOCKED.test(s)) return C.blocked
479  return s ? C.open : C.muted
480}
481const statusGlyph = (s) => {
482  s = String(s || '').toLowerCase()
483  if (DONE.test(s)) return '✔'
484  if (ACTIVE.test(s)) return '◐'
485  if (BLOCKED.test(s)) return '✖'
486  return s ? '○' : '·'
487}
488function priorityMark(p) {
489  p = String(p || '').toLowerCase()
490  if (p === 'critical' || p === 'urgent') return { t: '‼', c: C.blocked }
491  if (p === 'high' || p === 'must') return { t: '!', c: C.blocked }
492  if (p === 'low' || p === 'may') return { t: '↓', c: C.muted }
493  return { t: ' ', c: C.muted }
494}
495
496const HINTS = {
497  now: '↑↓ move · ⏎ open · q add · r refresh · esc close',
498  next: '↑↓ move · ⏎ open · q add · r refresh · esc back',
499  browse: '↑↓ move · ⏎ open · esc back · q add · r refresh',
500  find: 'type, then ⏎ to search · ↑↓ results · esc back',
501  activity: '↑↓ move · ⏎ open the item · r refresh · esc back',
502  settings: '↑↓ move · ⏎ change · esc back',
503  item: 's status · c comment · w work on it · y copy ref · o copy link · esc back',
504}
505
506function draw($, e) {
507  const ui = $.ui.resolve(e)
508  const { Text, Markdown } = ui
509  ring.order = []
510  let autoKey = null
511  const stop = (make) => (props) => {
512    if (props?.key) { ring.order.push(props.key); if (props.autoFocus && !autoKey) autoKey = props.key }
513    return make(props)
514  }
515  const Button = stop(ui.Button), Input = stop(ui.Input), Select = stop(ui.Select)
516  // Keep every box at its natural height: when the drawing is taller than the pane
517  // it scrolls as a whole, but flex would first squeeze bordered boxes to nothing.
518  const Box = (props) => ui.Box({ flexShrink: 0, ...props })
519  const W = Math.max(28, (e.props.bodyColumns || 60) - 1)
520  // The Desktop app draws these elements natively, in a proportional font: no
521  // character-drawn rules or space-padded columns there, and its keycap badges
522  // already show each hotkey.
523  const desktop = e.surface === 'desktop'
524  // the keyboard model is the terminal's: Desktop's native focus needs none of it,
525  // and there a click one control away read as Tab and was redirected
526  ring.terminal = !desktop
527  const clip = (t, n = W) => {
528    t = String(t ?? '')
529    return t.length > n ? t.slice(0, Math.max(1, n - 1)) + '…' : t
530  }
531  const T = (children, props = {}) => Text({ ...props, children: Array.isArray(children) ? children : [children] })
532  const dim = (t) => T(t, { dimColor: true })
533  const gap = () => T(' ')
534  // Desktop draws a run of '─' as two lines; its own vector leaf draws exactly one.
535  // This markup is the one that showed (2026-10-08): solid #888, 2px. A 1px,
536  // translucent, 2400-wide version drew nothing.
537  // The line sits at the top of an 8px leaf, so 6px of space follows it.
538  const RULE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="600" height="8" viewBox="0 0 600 8" preserveAspectRatio="none"><rect x="0" y="0" width="600" height="2" fill="#888888"/></svg>'
539  // Desktop only: a few pixels of air, as an empty vector leaf (it lays out
540  // exactly; a blank Text would cost a whole row).
541  const spacer = (px) => (desktop && ui.Svg ? ui.Svg({ source: `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="${px}"></svg>`, alt: '', height: px }) : null)
542  const rule = () =>
543    desktop
544      ? (ui.Svg ? ui.Svg({ source: RULE_SVG, alt: 'divider', height: 8 }) : null)
545      : T('─'.repeat(W), { color: C.muted })
546  const section = (label, extra) =>
547    Box({ flexDirection: 'row', columnGap: 1, children: [T(label, { bold: true, color: C.brand }), ...(extra ? [dim(extra)] : [])] })
548
549  // ---- header: brand, workspace, tabs
550  const tab = (v, label, hotkey) =>
551    Button({ key: 'tab-' + v, label, hotkey, plain: true, dimColor: view !== v && !(view === 'item' && stack[0] === v), onPress: () => go($, v) })
552  const header = Box({
553    flexDirection: 'column',
554    children: [
555      Box({
556        flexDirection: 'row',
557        justifyContent: 'space-between',
558        children: [
559          Box({ flexDirection: 'row', columnGap: 1, children: [T('▣ pad', { bold: true, color: C.brand }), T(conn ? conn.ws : '…', { bold: true })] }),
560          Box({
561            flexDirection: 'row',
562            columnGap: 2,
563            children: [
564              dim(busy ? 'loading…' : loadedAt ? 'updated ' + ago(loadedAt) + ' ago' : ''),
565              ...(desktop ? [tab('settings', 'Settings', 'g')] : []),
566            ],
567          }),
568        ],
569      }),
570      spacer(6),
571      Box({
572        flexDirection: 'row',
573        columnGap: desktop ? 1 : 2,
574        flexWrap: 'wrap',
575        children: [
576          tab('now', 'Now', 'n'),
577          tab('next', 'Next', 'x'),
578          tab('browse', 'Browse', 'b'),
579          tab('find', 'Find', 'f'),
580          tab('activity', 'Activity', 'a'),
581          ...(desktop ? [] : [tab('settings', 'Settings', 'g')]),
582        ],
583      }),
584      rule(),
585    ].filter(Boolean),
586  })
587
588  // ---- one item row: glyph, priority, ref (fixed width), title, status, age
589  const REFW = 10
590  const row = (r, i, opts = {}) => {
591    const sc = statusColor(r.status)
592    const pm = priorityMark(r.priority)
593    const tail = [opts.hideStatus ? '' : r.status, r.updated ? ago(r.updated) : ''].filter(Boolean).join(' · ')
594    const titleW = Math.max(8, W - REFW - tail.length - 7)
595    return Box({
596      key: 'r-' + view + '-' + (r.ref || i),
597      flexDirection: 'row',
598      columnGap: 1,
599      children: [
600        T(statusGlyph(r.status), { color: sc }),
601        T(pm.t, { color: pm.c }),
602        // Desktop's font is proportional, so spaces can't hold the ref column:
603        // give the ref a fixed-width box and let the Button carry the title
604        ...(desktop ? [Box({ width: REFW, flexShrink: 0, children: [T(r.ref || '', { dimColor: true })] })] : []),
605        Button({
606          key: 'row-' + view + '-' + (r.ref || i),
607          plain: true,
608          ...(i === 0 && !opts.noFocus ? { autoFocus: true } : {}),
609          label: desktop ? clip(r.title, titleW) : (r.ref || '').padEnd(REFW) + ' ' + clip(r.title, titleW),
610          onPress: () => openItem($, r.ref),
611        }),
612        Box({ flexGrow: 1 }),
613        T(tail, { color: opts.hideStatus ? C.muted : sc, dimColor: opts.hideStatus }),
614      ],
615    })
616  }
617  const empty = (t) => [dim(busy ? '  loading…' : '  ' + t)]
618
619  let body = []
620  if (!conn && !setup) {
621    body = [dim(busy || !connError ? '  connecting…' : '  ' + clip(connError, W * 3))]
622  } else if (!conn) {
623    const have = (ok, label) => T((ok ? '✔ ' : '○ ') + label, { color: ok ? C.done : C.muted })
624    const fill = (text) => $.prompt.fill({ text }).catch(() => {})
625    const path = (key, hotkey, label, note, onPress) =>
626      Box({
627        flexDirection: 'column',
628        children: [
629          Button({ key: 'gs-' + key, hotkey, plain: true, label, onPress, ...(key === 'cloud' ? { autoFocus: true } : {}) }),
630          dim('   ' + clip(note, W * 2)),
631        ],
632      })
633    body = [
634      section('Get started with Pad'),
635      dim(clip('Pad is shared memory for you and your agents. Pick how to connect; you can change it later.', W * 2)),
636      gap(),
637      have(setup.hasCli, 'pad CLI installed'),
638      have(setup.linked, 'this folder is linked to a workspace'),
639      gap(),
640      path('cloud', '1', 'Use Pad Cloud — nothing to install',
641        cloudStep === 'confirm'
642          ? 'Adds Pad Cloud to Claude Code: claude mcp add --transport http pad ' + PAD_CLOUD_MCP + '. Press 1 again to go ahead.'
643          : 'Sign in or sign up in your browser; Claude Code talks to Pad Cloud over MCP.',
644        async () => {
645          if (cloudStep !== 'confirm') { cloudStep = 'confirm'; return redraw($) }
646          await addPadCloud($)
647          redraw($)
648        }),
649      ...(cloudStep === 'added'
650        ? [T(clip('Added. Send /reload-plugins (it is in your prompt), then open /mcp → pad → Authenticate. Press r once you are signed in.', W * 3), { color: C.done })]
651        : cloudStep === 'failed'
652          ? [T(clip('Could not add it: ' + (cloudError || '') + '. Run it yourself: claude mcp add --transport http pad ' + PAD_CLOUD_MCP, W * 3), { color: C.blocked })]
653          : []),
654      gap(),
655      path('local', '2', setup.hasCli ? 'Set up this folder with the pad CLI' : 'Run Pad on this machine',
656        setup.hasCli ? 'Claude runs /pad:onboard: a workspace for this folder and a first item.' : 'Claude installs the pad CLI (Homebrew or a release binary), then sets up this folder.',
657        () => fill(setup.hasCli
658          ? '/pad:onboard'
659          : 'Install the Pad CLI for me (brew install PerpetualSoftware/tap/pad, or the binary for this OS from https://github.com/PerpetualSoftware/pad/releases), then run /pad:onboard to set up this folder.')),
660      gap(),
661      path('server', '3', 'Connect to your own Pad server',
662        'Claude links this folder to a self-hosted Pad (pad init --url …, then pad auth login).',
663        () => fill('Connect this folder to my self-hosted Pad server: ask me for its URL, then run pad init --url <url> and pad auth login. If I have no server yet, point me to https://getpad.dev/docs/self-hosting')),
664      gap(),
665      dim(clip('r: check again', W)),
666    ]
667  } else if (mode === 'add') {
668    const colls = data.collections.filter((c) => !c.system)
669    body = [
670      section('New item'),
671      gap(),
672      Select({
673        key: 'add-collection',
674        label: 'Collection',
675        value: addCollection || colls[0]?.slug,
676        options: colls.map((c) => ({ value: c.slug, label: `${c.icon} ${c.name}` })),
677        onSelect: (v) => {
678          addCollection = v
679          redraw($)
680        },
681      }),
682      Input({
683        key: 'add-title',
684        label: 'Title',
685        placeholder: 'What needs doing? ⏎ to create',
686        autoFocus: true,
687        submitLabel: 'Create',
688        onSubmit: async (title) => {
689          if (!String(title).trim()) return
690          try {
691            const created = await client.create(addCollection || colls[0]?.slug, String(title).trim())
692            const ref = created?.ref || created?.item?.ref
693            mode = null
694            $.ui.toast('Created ' + (ref || 'item'))
695            if (ref) await openItem($, ref)
696            else redraw($)
697          } catch (err) {
698            notice = 'Create failed: ' + msg(err)
699            redraw($)
700          }
701        },
702      }),
703      gap(),
704      Button({ key: 'add-cancel', label: 'cancel', hotkey: 'h', plain: true, dimColor: true, onPress: () => { mode = null; redraw($) } }),
705    ]
706  } else if (view === 'now') {
707    body = [section('In progress', data.now.length ? String(data.now.length) : ''), ...(data.now.length ? data.now.map((r, i) => row(r, i)) : empty('Nothing in progress.'))]
708  } else if (view === 'next') {
709    body = [section('Ready to work on', data.next.length ? String(data.next.length) : '')]
710    data.next.forEach((r, i) => {
711      body.push(row(r, i))
712      if (r.reason) body.push(T('     ' + clip(r.reason, W - 5), { color: C.muted, italic: true }))
713    })
714    if (!data.next.length) body.push(...empty('Nothing ready.'))
715  } else if (view === 'browse') {
716    if (!browseCollection) {
717      body = [section('Collections')]
718      const user = data.collections.filter((c) => !c.system)
719      const lib = data.collections.filter((c) => c.system)
720      for (const c of [...user, ...(lib.length ? [{ heading: 'Library' }] : []), ...lib]) {
721        if (c.heading) { body.push(T(c.heading, { key: 'ch-lib', bold: true, color: C.muted })); continue }
722        body.push(
723          Box({
724            key: 'cb-' + c.slug,
725            flexDirection: 'row',
726            columnGap: 1,
727            children: [
728              Button({ key: 'coll-' + c.slug, plain: true, label: `${c.icon} ${clip(c.name, W - 12)}`, onPress: async () => {
729                browseCollection = c.slug; data.list = []
730                await load($, 'browse')
731                // An empty collection's only other stop is the header's back Button;
732                // put the ring on the vertical back row instead (the engine keeps a
733                // ring the person moved, so autoFocus alone doesn't move it).
734                if (!data.list.length) $.ui.focus({ requestId: PANE, key: 'empty-back' }).catch(() => {})
735              } }),
736              Box({ flexGrow: 1 }),
737              dim(c.open == null || c.open === c.total ? String(c.total) : `${c.open} open · ${c.total}`),
738            ],
739          })
740        )
741      }
742      if (body.length === 1) body.push(...empty('No collections.'))
743    } else {
744      const coll = data.collections.find((c) => c.slug === browseCollection)
745      body = [
746        Box({
747          flexDirection: 'row',
748          columnGap: 1,
749          children: [Button({ key: 'browse-up', label: '‹ Collections', hotkey: 'h', plain: true, dimColor: true, onPress: () => { browseCollection = null; redraw($) } }), T('/', { color: C.muted }), T(`${coll?.icon || ''} ${coll?.name || browseCollection}`, { bold: true, color: C.brand })],
750        }),
751      ]
752      const groups = new Map()
753      for (const r of data.list) {
754        if (!groups.has(r.status)) groups.set(r.status, [])
755        groups.get(r.status).push(r)
756      }
757      const order = coll?.statuses || []
758      const keys = [...groups.keys()].sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99))
759      for (const status of keys) {
760        const rows = groups.get(status)
761        body.push(gap(), Box({ flexDirection: 'row', columnGap: 1, children: [T(statusGlyph(status), { color: statusColor(status) }), T(status || 'no status', { bold: true, color: statusColor(status) }), dim(String(rows.length))] }))
762        rows.forEach((r, i) => body.push(row(r, i, { hideStatus: true, noFocus: status !== keys[0] })))
763      }
764      if (!data.list.length) {
765        // An empty collection still gives the ring a vertical stop, or it falls
766        // back onto the header's back Button, where ← looks like it should work.
767        body.push(...empty('No open items.'), Button({ key: 'empty-back', label: '‹ Back to collections', plain: true, autoFocus: true, onPress: () => { browseCollection = null; redraw($) } }))
768      }
769    }
770  } else if (view === 'find') {
771    body = [
772      Input({
773        key: 'find',
774        label: 'Search',
775        placeholder: 'Words, then ⏎',
776        value: data.query,
777        autoFocus: true,
778        onSubmit: async (q) => {
779          data.query = String(q)
780          if (!data.query.trim() || !(await ensureClient($))) return
781          busy = true
782          redraw($)
783          try {
784            data.results = norm.search(await client.search(data.query))
785            notice = null
786          } catch (err) {
787            notice = 'Search failed: ' + msg(err)
788          }
789          busy = false
790          redraw($)
791        },
792      }),
793    ]
794    if (data.results.length) body.push(gap(), section('Results for “' + clip(data.query, W - 24) + '”', String(data.results.length)), ...data.results.map((r, i) => row(r, i)))
795    else if (data.query && !busy) body.push(gap(), dim('  No matches for “' + data.query + '”.'))
796  } else if (view === 'activity') {
797    body = [section('Recent activity')]
798    data.activity.forEach((a, i) => {
799      let who = a.actor_name || a.user_name || 'someone'
800      let isAgent = a.actor === 'agent'
801      try {
802        const m = typeof a.metadata === 'string' ? JSON.parse(a.metadata) : a.metadata
803        if (m?.agent) { who = m.agent; isAgent = true }
804      } catch {}
805      const what = String(a.action || '').replace(/_/g, ' ')
806      body.push(
807        Box({
808          key: 'ab-' + (a.id || i),
809          flexDirection: 'row',
810          columnGap: 1,
811          children: [
812            T(ago(a.created_at).padStart(3), { color: C.muted }),
813            T(clip(who, 12).padEnd(12), { color: isAgent ? C.accent : C.open, bold: true }),
814            T(clip(what, 12).padEnd(12), { dimColor: true }),
815            Button({ key: 'act-' + (a.id || i), plain: true, label: clip(`${a.item_ref || ''}  ${a.item_title || ''}`, Math.max(10, W - 32)), onPress: () => a.item_ref && openItem($, a.item_ref) }),
816          ],
817        })
818      )
819    })
820    if (!data.activity.length) body.push(...empty('No recent activity.'))
821  } else if (view === 'settings') {
822    const set = async (k, v) => {
823      settings = { ...settings, [k]: v }
824      await $.store.set('settings', settings)
825      if (k === 'transport') {
826        client = null
827        conn = null
828        await ensureClient($)
829      }
830      redraw($)
831    }
832    body = [
833      section('Settings'),
834      gap(),
835      Select({ key: 'set-start', label: 'Opens on', value: settings.startView, options: ['now', 'next', 'browse', 'activity'].map((v) => ({ value: v, label: v })), onSelect: (v) => set('startView', v) }),
836      Select({ key: 'set-refresh', label: 'Refresh while open', value: String(settings.refreshSec), options: [['30', 'every 30s'], ['60', 'every minute'], ['300', 'every 5 min'], ['0', 'only when I press r']].map(([value, label]) => ({ value, label })), onSelect: (v) => set('refreshSec', Number(v)) }),
837      Select({ key: 'set-background', label: 'Background', value: settings.background, options: [['theme', 'the terminal\'s pane colour'], ['dark', 'dark grey'], ['darker', 'darker grey'], ['black', 'near black']].map(([value, label]) => ({ value, label })), onSelect: (v) => set('background', v) }),
838      Select({ key: 'set-transport', label: 'Talk to Pad through', value: settings.transport, options: [['auto', 'CLI if installed, else MCP'], ['cli', 'the pad CLI'], ['mcp', 'the Pad MCP server']].map(([value, label]) => ({ value, label })), onSelect: (v) => set('transport', v) }),
839      gap(),
840      T(conn ? `● connected to ${conn.ws} via ${conn.transport}${conn.server ? ' (' + conn.server + ')' : ''}` : '○ not connected', { color: conn ? C.done : C.blocked }),
841    ]
842  } else if (view === 'item' && item) {
843    const f = item.raw.fields || {}
844    const sc = statusColor(f.status)
845    const pm = priorityMark(f.priority || f.severity)
846    const meta = Object.entries(f)
847      .filter(([k, v]) => !['status', 'priority', 'severity'].includes(k) && v !== null && v !== '' && typeof v !== 'object')
848      .map(([k, v]) => `${k}: ${v}`)
849    const act = (key, label, hotkey, onPress) => Button({ key: 'do-' + key, label, hotkey, plain: true, onPress })
850    const card = Box({
851      flexDirection: 'column',
852      // 'quote', not 'round': in a pane, a full border drew empty (CC 2.1.293).
853      borderStyle: 'quote',
854      borderColor: sc,
855      paddingLeft: 1,
856      paddingRight: 1,
857      children: [
858        Box({
859          flexDirection: 'row',
860          justifyContent: 'space-between',
861          children: [T(item.ref, { bold: true, color: C.brand }), T(item.raw.updated_at ? 'updated ' + ago(item.raw.updated_at) + ' ago' : '', { dimColor: true })],
862        }),
863        T(item.raw.title || '', { bold: true, wrap: 'wrap' }),
864        Box({
865          flexDirection: 'row',
866          columnGap: 2,
867          flexWrap: 'wrap',
868          children: [
869            T(`${statusGlyph(f.status)} ${f.status || 'no status'}`, { color: sc, bold: true }),
870            ...(f.priority || f.severity ? [T(`${pm.t.trim() || '·'} ${f.priority || f.severity}`, { color: pm.c })] : []),
871            ...(item.raw.parent_ref ? [dim('parent ' + item.raw.parent_ref)] : []),
872          ],
873        }),
874        ...(meta.length ? [dim(clip(meta.join('  ·  '), W * 2))] : []),
875        ...(item.raw.decisions || [])
876          .filter((d) => d.question_set === 'conventions' && d.current !== false && d.answer?.noul >= 0.9)
877          .map((d) => T('⚑ Possibly breaks ' + String(d.question_key).replace(/^conv:/, ''), { color: C.active })),
878      ],
879    })
880    body = [
881      card,
882      Box({
883        flexDirection: 'row',
884        columnGap: 2,
885        flexWrap: 'wrap',
886        children: [
887          act('status', 'status', 's', () => { mode = mode === 'status' ? null : 'status'; redraw($) }),
888          act('comment', 'comment', 'c', () => { mode = mode === 'comment' ? null : 'comment'; redraw($) }),
889          act('work', 'work on it', 'w', async () => {
890            await $.prompt.fill({ text: `/pad let's work on ${item.ref}` })
891            $.ui.toast('In your prompt: edit it or press Enter')
892          }),
893          act('copy', 'copy ref', 'y', (press) => { $.ui.copy({ text: item.ref, surface: press?.surface }); $.ui.toast('Copied ' + item.ref) }),
894          act('open', 'copy link', 'o', async (press) => { const url = await client.webUrl(item.ref); $.ui.copy({ text: url, surface: press?.surface }); $.ui.toast('Link copied') }),
895          act('back', 'back', 'h', () => back($)),
896        ],
897      }),
898    ]
899    if (mode === 'status') {
900      body.push(
901        Select({
902          key: 'status-select',
903          label: 'Status',
904          value: f.status,
905          autoFocus: true,
906          options: (item.statusOptions.length ? item.statusOptions : [f.status || 'open']).map((s) => ({ value: s, label: `${statusGlyph(s)} ${s}` })),
907          onSelect: async (s) => {
908            if (s === f.status) { mode = null; return redraw($) }
909            try {
910              await client.setStatus(item.ref, s)
911              $.ui.toast(`${item.ref} → ${s}`)
912              mode = null
913              await fetchItem($, item.ref)
914            } catch (err) {
915              notice = 'Status change failed: ' + msg(err)
916            }
917            redraw($)
918          },
919        })
920      )
921    }
922    if (mode === 'comment') {
923      body.push(
924        Input({
925          key: 'comment-input',
926          label: 'Comment',
927          placeholder: '⏎ to post',
928          autoFocus: true,
929          submitLabel: 'Post',
930          onSubmit: async (text) => {
931            if (!String(text).trim()) return
932            try {
933              await client.comment(item.ref, String(text).trim())
934              $.ui.toast('Commented on ' + item.ref)
935              mode = null
936              await fetchItem($, item.ref)
937            } catch (err) {
938              notice = 'Comment failed: ' + msg(err)
939            }
940            redraw($)
941          },
942        })
943      )
944    }
945    const content = String(item.raw.content || '').trim()
946    body.push(gap())
947    if (content) body.push(Markdown({ key: 'body-' + item.ref, text: content.length > 6000 ? content.slice(0, 6000) + '\n\n…' : content }))
948    else body.push(dim('No description.'))
949    if (item.comments.length) {
950      body.push(gap(), section('Comments', String(item.comments.length) + (item.comments.length > 5 ? ' · latest 5' : '')))
951      for (const c of item.comments.slice(-5)) {
952        const who = c.agent_name || c.author || '?'
953        body.push(
954          Box({
955            flexDirection: 'column',
956            // 'quote', not 'round': in a pane, a full border drew empty (CC 2.1.293).
957      borderStyle: 'quote',
958            borderColor: c.agent_name ? C.accent : C.open,
959            paddingLeft: 1,
960            marginTop: 1,
961            children: [
962              Box({ flexDirection: 'row', columnGap: 1, children: [T(who, { bold: true, color: c.agent_name ? C.accent : C.open }), dim(ago(c.created_at) + ' ago')] }),
963              T(clip(String(c.body || '').replace(/\s+/g, ' ').trim(), W * 4), { wrap: 'wrap' }),
964            ],
965          })
966        )
967      }
968    }
969  }
970
971  const footer = Box({
972    flexDirection: 'column',
973    children: [
974      rule(),
975      Box({
976        flexDirection: 'row',
977        columnGap: 2,
978        children: [
979          Button({ key: 'refresh', label: 'refresh', hotkey: 'r', plain: true, dimColor: true, onPress: () => load($) }),
980          Button({
981            key: 'add',
982            label: 'quick add',
983            hotkey: 'q',
984            plain: true,
985            dimColor: true,
986            onPress: async () => {
987              if (!(await ensureClient($))) return redraw($)
988              await ensureCollections()
989              mode = 'add'
990              redraw($)
991            },
992          }),
993          Box({ flexGrow: 1 }),
994          ...(conn ? [dim('via ' + conn.transport)] : []),
995        ],
996      }),
997      ...(desktop
998        ? []
999        : [
1000            e.props.isFocused
1001              ? dim(clip(HINTS[mode === 'add' ? 'find' : view] || '', W))
1002              : T(clip('ctrl+x tab: use the pane · ctrl+x x: close', W), { color: C.accent }),
1003          ]),
1004    ].filter(Boolean),
1005  })
1006
1007  // A new view drops the element the ring was on; the engine then shows it on
1008  // the view's autoFocus control without raising ui.focus, so follow it here.
1009  if (!ring.order.includes(ring.cur)) ring.cur = autoKey || ring.order.find((k) => LIST_KEY.test(k)) || null
1010  const bg = BACKGROUNDS[settings.background]
1011  return Box({
1012    key: 'pane-root',
1013    flexDirection: 'column',
1014    // one row taller than the pane, always: see the keyboard model at the top
1015    ...(desktop ? {} : { minHeight: (e.props.scroll?.bodyRows || 0) + 1 }),
1016    ...(bg ? { backgroundColor: bg } : {}),
1017    children: [header, ...(notice ? [T('⚠ ' + clip(notice, W * 2), { color: C.blocked })] : []), ...body, gap(), footer],
1018  })
1019}
1020
1021// ---------------------------------------------------------------- live item events
1022
1023// Pushes and watched-item changes reach Claude only in a session that has
1024// consented (PLAN-2613): .pad.toml push.auto_arm, or /pad:connect. The decision
1025// is the CLI's (`pad session should-arm`); this mod only acts on it.
1026//
1027// This replaces an "always" monitor (TASK-3513). A monitor that exits is
1028// reported at startup ("ended without producing output"), and an unarmed
1029// session's monitor always exited, so every session opened with a notice.
1030// Here an unarmed session runs nothing and shows nothing: no process, no line,
1031// no notice. /pad:connect keeps its own on-skill-invoke monitor, which is also
1032// the path where mods do not load; pad-monitor.sh's lockfile keeps the two from
1033// streaming twice in one session.
1034//
1035// Nothing of this runs at session start (TASK-3970): a session that never
1036// touches Pad never runs the CLI, never opens a socket and never starts a
1037// timer. Presence and the arm check run on the session's first use of Pad
1038// instead (wake): the first `pad` command or Pad MCP tool call, or opening the
1039// pane. A .pad.toml push.auto_arm opt-in is therefore read then, not at start.
1040
1041const WATCH_BURST_MS = 1500 // a burst ends after this much quiet, and goes to Claude as one message
1042const WATCH_BURST_MAX_MS = 10000 // a stream that never goes quiet still flushes this often
1043let burstTimer = null
1044let burstStart = 0
1045let watch = null // the running pad-monitor.sh, as $.process.spawn's iterator
1046let watchEpoch = 0 // bumped by stopWatch: a start still asking the CLI then spawns nothing
1047let pending = [] // lines not yet handed to Claude
1048let submitting = false
1049
1050// The harness session's pid. Claude Code exports CLAUDE_PID and CLAUDECODE to
1051// Bash and to monitors but not to a mod's processes, whose parent is that same
1052// process (measured, TASK-3513). The session registry and the headless
1053// arm-state owner key on the pid; the registry names the runtime from
1054// CLAUDECODE. CLAUDE_CODE_MESSAGING_SOCKET, which consent is keyed on, does
1055// reach a mod's processes.
1056async function sessionPid($) {
1057  try {
1058    const r = await $.process.run(['sh', '-c', 'echo $PPID'], { timeoutMs: 5000 })
1059    const pid = String(r.stdout || '').trim()
1060    return /^\d+$/.test(pid) ? pid : ''
1061  } catch {
1062    return ''
1063  }
1064}
1065
1066// Hands the lines gathered so far to Claude, then any that arrived while that
1067// message waited for the session to go idle: one message per idle window.
1068async function flushWatch($) {
1069  if (submitting || !pending.length) return
1070  const epoch = watchEpoch
1071  submitting = true
1072  try {
1073    // A flush the session's end overtook relays nothing more (codex r2): a
1074    // submit already waiting cannot be withdrawn, but nothing follows it.
1075    while (pending.length && epoch === watchEpoch) {
1076      const text = pending.join('\n')
1077      pending = []
1078      await $.prompt.submit({ text })
1079    }
1080  } catch {
1081    // a refused submit (the session is ending) drops the lines; the stream
1082    // goes on, and the next line tries again
1083  } finally {
1084    if (epoch === watchEpoch) submitting = false
1085  }
1086}
1087
1088let woken = null // wake's promise for this session (reset by an end that stops the stream)
1089
1090// The session's first use of Pad: presence, then the arm check (startWatch).
1091// Once per session, so later pad commands run nothing extra. Resolves once
1092// presence is recorded, not when the arm check answers: `pad session register`
1093// mints the worker id the CLI sends on every call (leases key on it), so the
1094// tool.call hook waits for it before the first pad command runs. A register
1095// that fails is not retried in this session: it stays unregistered, as it
1096// did when start-time registration failed.
1097function wake($) {
1098  if (woken) return woken
1099  let registered = () => {}
1100  woken = new Promise((r) => { registered = r })
1101  startWatch($, registered).catch(() => {}).finally(registered)
1102  // TASK-4071: the chat-seen reader starts with the session's first use of
1103  // Pad too, armed or not: /pad:connect may arm the session later, and its
1104  // monitor writes the same file.
1105  startSeen($).catch(() => {})
1106  return woken
1107}
1108
1109// A Bash command that runs the pad CLI, or a call of a Pad MCP tool. `pad` as
1110// a whole word: not inside a longer name (`padd`, `.padrc`, `pad-monitor.sh`),
1111// whatever stands around it otherwise (a quote, `\`, `$(`, `;`, a path). Wide
1112// on purpose: a false positive (`cd ~/pad`) only wakes early, while a miss
1113// runs that first pad call unregistered. Rounds r1 and r2 each found a missed
1114// shape; the shapes are tabled in watch.test.ts rather than listed here.
1115function usesPad(e) {
1116  if (e.tool === 'Bash') return /(^|[^\w.-])pad($|[^\w.-])/.test(String(e.command || ''))
1117  // Any server name: Pad's tools are pad_* whatever the person called the server
1118  // (Codex r3: `.*pad.*` in the server part missed a server named otherwise).
1119  return /^mcp__.+__pad_[a-z0-9_]+$/i.test(String(e.tool || ''))
1120}
1121
1122// Silent by construction: every failure here leaves the session as it was.
1123async function startWatch($, registered = () => {}) {
1124  if (watch) return
1125  const epoch = watchEpoch
1126  const pid = await sessionPid($)
1127  const env = pid ? { CLAUDECODE: '1', PAD_SESSION_PID: pid } : { CLAUDECODE: '1' }
1128  // Presence is a fact about the session, kept whether or not it consents
1129  // (TASK-2767): `pad session list` reads it back.
1130  try { await $.process.run(['pad', 'session', 'register'], { env, timeoutMs: 10000 }) } catch {}
1131  registered()
1132  let armed = false
1133  try {
1134    armed = (await $.process.run(['pad', 'session', 'should-arm'], { env, timeoutMs: 10000 })).exitCode === 0
1135  } catch {}
1136  // The session may have ended while the CLI answered (codex r1). Consent
1137  // withdrawn in that window needs no check here: pad-monitor.sh asks
1138  // should-arm again before it streams, and that answer is the one that counts.
1139  if (!armed || watch || epoch !== watchEpoch) return
1140  let it
1141  try {
1142    it = $.process.spawn({ argv: [$.plugin.root + '/scripts/pad-monitor.sh'], env })
1143  } catch {
1144    return
1145  }
1146  watch = it
1147  ;(async () => {
1148    let carry = ''
1149    try {
1150      for await (const chunk of it) {
1151        // A stream the session ended may still deliver a chunk that was
1152        // already on its way: return() takes effect after the pending read.
1153        if (watch !== it) break
1154        if (chunk.stream !== 'stdout') continue
1155        const lines = (carry + chunk.text).split('\n')
1156        carry = lines.pop()
1157        for (const line of lines) if (line.trim()) pending.push(line)
1158        if (!pending.length) continue
1159        // Wait for the burst to go quiet (codex r4), but never longer than
1160        // WATCH_BURST_MAX_MS from its first line.
1161        const now = await $.clock.now()
1162        if (!burstStart) burstStart = now
1163        if (burstTimer) burstTimer.cancel()
1164        const wait = Math.max(0, Math.min(WATCH_BURST_MS, burstStart + WATCH_BURST_MAX_MS - now))
1165        burstTimer = $.clock.after(wait, () => {
1166          burstTimer = null
1167          burstStart = 0
1168          flushWatch($)
1169        })
1170      }
1171    } catch {}
1172    if (watch === it) watch = null
1173  })()
1174}
1175
1176function stopWatch() {
1177  woken = null
1178  watchEpoch++
1179  const it = watch
1180  watch = null
1181  pending = []
1182  submitting = false
1183  if (burstTimer) burstTimer.cancel()
1184  burstTimer = null
1185  burstStart = 0
1186  if (it) {
1187    try { it.return() } catch {}
1188  }
1189}
1190
1191// The pane's auto-refresh. It runs only while the pane is open (TASK-3970):
1192// a closed pane leaves no timer behind.
1193let refresh = null
1194
1195function startRefresh($) {
1196  if (refresh) return
1197  refresh = $.clock.every(5000, () => {
1198    if (!open || !settings.refreshSec || busy || mode || view === 'find' || view === 'settings') return
1199    if (!loadedAt || Date.now() - Date.parse(loadedAt) >= settings.refreshSec * 1000) load($)
1200  })