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…

<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> · <a href="https://getpad.dev/docs">Docs</a> · <a href="https://getpad.dev/blog">Blog</a> · <a href="https://getpad.dev/changelog">Changelog</a> · <a href="https://www.reddit.com/r/getpad/">Reddit</a> · <a href="https://x.com/getpaddev">X</a> · <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" />
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."
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.
| Pad | Linear / Jira | Notion | |
|---|---|---|---|
| Setup | pad init | Create account, invite team, configure | Create account, pick template |
| AI agents | Native /pad skill for 7+ tools | Third-party integrations | Third-party integrations |
| Data | Local SQLite you own — or opt-in Pad Cloud | Their cloud | Their cloud |
| Offline | Full functionality | Read-only cache at best | Limited |
| CLI | First-class | Afterthought | None |
| Price | Free, open source | Per-seat pricing | Per-seat pricing |
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:
j/k to move, Enter to open, Esc to go back, Cmd+K to search[[Title]] to link between items<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" />
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:
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.
Pad organizes work into collections — typed containers with structured fields.
Built-in collections:
| Collection | Purpose |
|---|---|
| Tasks | Work items with status, priority, assignee, effort, due date |
| Ideas | Feature ideas with impact and category |
| Plans | Project milestones with progress tracking |
| Docs | Documentation, decisions, reference material |
| Conventions | Project rules that guide agent behavior |
| Playbooks | Multi-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.
brew install PerpetualSoftware/tap/pad
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 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.
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
nixpkgspackage (nix-shell -p pad/environment.systemPackages) is planned but not yet merged upstream. Until then, use thegithub:PerpetualSoftware/padflake reference above.
Pre-built binaries for macOS, Linux, and Windows are available on the releases page.
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.
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
cd ~/projects/myapp
pad init "My App"
pad init is the smart entry point that handles everything in one command:
pad auth setup on the server instead).pad.toml)/pad skill for any AI tools detected in the projectRun 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.
# 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.
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
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:
| Tool | Actions |
|---|---|
pad_item | create, 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_workspace | list, members, invite, storage, audit-log, create, claim, deleted, restore |
pad_collection | list, create, update, delete, list-archived, restore |
pad_project | dashboard, next, ready, stale, standup, changelog, report, activity, catch-up |
pad_role | list, create, update, delete |
pad_search | query |
pad_playbook | list, get, run, match |
pad_library | list, get, activate |
pad_attachment | list, show |
pad_chat | list, inbox, show, send, delete, read (global agent chat; excluded from ChatGPT) |
pad_meta | server-info, version, tool-surface, bootstrap |
pad_set_workspace | session-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
hooks/register.js 1568 lines1// 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 })