Files
pad/plugin/hooks/register.js
T
xarmian ffecfc744b feat(chat): show chat delivered to an agent to the human at its terminal (TASK-4071) (#2312)
* feat(chat): tee monitor chat lines to a per-session chat-seen log (TASK-4071)

pad watch --stream --for-session appends every chat line it prints to
~/.pad/chat-seen/<harness-pid>.log (0600 in a 0700 dir, O_NOFOLLOW),
capped at 256 KiB with one .1 generation, so the human at the agent's
terminal can see it. No log when the pid is pad watch's own; pruning a
session record removes its log.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* test(chat): pin the chat-seen tee, perms, rotation, prune and C2 (TASK-4071)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* test(chat): refuse a FIFO that has a reader (TASK-4071 M13)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* feat(plugin): show chat delivered to the agent in a band and a toast (TASK-4071)

The pad mod tails ~/.pad/chat-seen/<harness pid>.log from the session's
first use of Pad, starting at the end of the file, and shows new lines in
a band above the prompt (the last three, until the person's next prompt)
and one toast per burst. Lines rotated away between polls are not shown.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* refactor(plugin): one rotation guard in the chat-seen reader (TASK-4071 P5)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* fix(chat): report a log that is off on its first line, and say what prune and the monitor do with it (TASK-4071)

The monitor prints nothing before it has something to deliver (DOC-2479),
so the no-harness-pid line waits for the first chat line. The pad watch
--stream --for-session and pad session prune help now describe the log.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* fix(chat): r1 findings on the chat-seen log and reader (TASK-4071)

- Refuse a symlinked chat-seen directory instead of chmodding its target.
- Reopen a log that was removed under the writer.
- Remove .log.1 before rotating: Rename does not replace on Windows.
- Reader: drop control characters and direction overrides before they
  reach the terminal; clip by code points; one toast per burst on the
  relay's clock; a stop during start leaves nothing running; a failed
  first read no longer replays the log.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* fix(plugin): r2 findings on the chat-seen reader (TASK-4071)

- The first look skips what the log held at its stat, so a line written
  between that stat and the read still shows.
- A stop during a poll no longer leaves the reader stuck after the next wake.
- An unreadable moment (failed stat, a symlink) changes nothing, so it
  cannot cause a replay.
- U+061C joins the stripped bidi controls.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* fix(plugin): r3 findings on the chat-seen reader (TASK-4071)

- Start a log at zero only when the listing shows it absent; a first look
  that merely failed leaves the start to the first good read (R3-1).
- Restore the byte-size rotation reset beside the character one: a log of
  multi-byte text can shrink in one while it grows in the other (R3-2).

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-25 07:17:51 -04:00

1568 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The Pad pane inside Claude Code (IDEA-3477; folded into the pad plugin by TASK-3486).
//
// One command, /pad-pane, opens a pane on the linked workspace. Nothing else
// changes what Claude sees, and nothing blocks. The one band is the chat-seen
// band (TASK-4071): chat delivered to the agent, shown to the person too. Session start runs
// nothing (TASK-3970); after a plugin reload it only picks back up a pane that
// was already open. Otherwise the mod listens for Claude touching Pad: the
// first touch wakes the session (presence, live events; see wake), and an
// open pane stays fresh.
//
// Views: Now (n), Next (x), Browse (b), Find (f), Activity (a), Settings (g).
// Item view: status (s), comment (c), work on it (w), copy ref (y), open in
// link (o), back (h). Quick add: q. Refresh: r.
// ---------------------------------------------------------------- Pad client
// One Pad client, two transports. The pane only calls these methods, so it
// works the same with the `pad` CLI installed or with only an MCP connection.
//
// CLI: `pad … --format json`, which finds the workspace from .pad.toml and
// carries its own credentials.
// MCP: $.mcp.call on a connected Pad server (a Pad plugin's server, the
// remote mcp.getpad.dev, or a local `pad mcp`), using the workspace slug
// from .pad.toml.
const MCP_SERVER_GUESSES = ['pad', 'claude.ai pad', 'plugin:pad:pad', 'getpad']
const PAD_CLOUD_APP = 'https://app.getpad.dev'
// An MCP endpoint's web app: Pad Cloud serves MCP from its own host, and a
// self-hosted Pad serves it at <base>/mcp.
export function webBaseFromMcp(url) {
let u
try { u = new URL(url) } catch { return PAD_CLOUD_APP }
if (u.hostname === 'mcp.getpad.dev') return PAD_CLOUD_APP
return (u.origin + u.pathname.replace(/\/mcp\/?$/, '')).replace(/\/+$/, '')
}
// .pad.toml from the session directory upwards: { workspace, url }.
async function readPadToml($) {
let dir = String(await $.session.cwd())
for (let i = 0; i < 40 && dir; i++) {
const path = (dir === '/' ? '' : dir) + '/.pad.toml'
try {
if (await $.fs.exists(path)) {
const text = String(await $.fs.read(path))
const get = (k) => (text.match(new RegExp('^\\s*' + k + '\\s*=\\s*"([^"]*)"', 'm')) || [])[1]
return { workspace: get('workspace'), url: get('url'), dir }
}
} catch {}
if (dir === '/') break
dir = dir.replace(/\/[^/]*$/, '') || '/'
}
return null
}
function parseMcp(result) {
if (!result) throw new Error('no result')
const text = (result.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('')
if (result.isError) throw new Error(text.split('\n')[0] || 'MCP error')
try {
return JSON.parse(text)
} catch {
return text
}
}
// Some endpoints return `fields` as a JSON string (item show), others as an
// object (lists). Always hand the pane an object.
function fieldsOf(f) {
if (f && typeof f === 'object') return f
if (typeof f === 'string') {
try {
const v = JSON.parse(f)
return v && typeof v === 'object' ? v : {}
} catch {}
}
return {}
}
const arr = (x, ...keys) => {
if (Array.isArray(x)) return x
for (const k of keys) if (x && Array.isArray(x[k])) return x[k]
return []
}
function makeClient($, settings, toml) {
let transport = null // 'cli' | 'mcp'
let mcpServer = null
let ws = toml?.workspace || null
async function cli(args) {
const r = await $.process.run(['pad', ...args, '--format', 'json'], { timeoutMs: 20000 })
if (r.exitCode !== 0) throw new Error((r.stderr || r.stdout || 'pad failed').trim().split('\n')[0])
return JSON.parse(r.stdout)
}
async function mcp(tool, args) {
return parseMcp(await $.mcp.call(mcpServer, tool, { workspace: ws, ...args }))
}
async function connect() {
const want = settings.transport // 'auto' | 'cli' | 'mcp'
if (want !== 'mcp') {
try {
const boot = await cli(['bootstrap'])
if (boot?.workspace?.slug) {
ws = boot.workspace.slug
transport = 'cli'
return { transport, ws }
}
} catch (err) {
if (want === 'cli') throw err
}
}
const names = settings.mcpServer ? [settings.mcpServer] : MCP_SERVER_GUESSES
for (const name of names) {
try {
mcpServer = name
await mcp('pad_collection', { action: 'list' })
transport = 'mcp'
return { transport, ws, server: name }
} catch {}
}
mcpServer = null
throw new Error(
ws
? 'No way to reach Pad: the pad CLI is not installed or not linked here, and no Pad MCP server answered.'
: 'This folder is not linked to a Pad workspace (no .pad.toml).'
)
}
let webBase = null
async function resolveWebBase() {
const clean = (u) => String(u).trim().replace(/\/+$/, '')
if (toml?.url) return clean(toml.url)
try {
if (transport === 'cli') {
const info = await cli(['server', 'info'])
if (info?.config?.base_url) return clean(info.config.base_url)
} else if (transport === 'mcp' && mcpServer && !mcpServer.startsWith('claude.ai ')) {
const r = await $.process.run(['claude', 'mcp', 'get', mcpServer], { timeoutMs: 10000 })
const url = (String(r.stdout || '').match(/URL:\s*(\S+)/) || [])[1]
if (url) return webBaseFromMcp(url)
}
} catch {}
return PAD_CLOUD_APP
}
const via = (cliFn, mcpFn) => (...a) => (transport === 'cli' ? cliFn(...a) : mcpFn(...a))
return {
connect,
get transport() { return transport },
get workspace() { return ws },
// The web address of an item on the server this pane talks to (BUG-3484):
// .pad.toml's url, else the CLI's configured server, else the MCP server's
// own URL (Pad Cloud's MCP host maps to the app), else Pad Cloud.
async webUrl(ref) {
if (!webBase) webBase = await resolveWebBase()
return `${webBase}/-/r/${ws}/${ref}`
},
dashboard: via(() => cli(['project', 'dashboard']), () => mcp('pad_project', { action: 'dashboard' })),
ready: via(() => cli(['project', 'ready']), () => mcp('pad_project', { action: 'ready' })),
activity: via(
(limit) => cli(['project', 'activity', '--limit', String(limit)]),
(limit) => mcp('pad_project', { action: 'activity', limit })
),
collections: via(() => cli(['collection', 'list']), () => mcp('pad_collection', { action: 'list' })),
list: via(
(collection) => cli(['item', 'list', collection, '--limit', '100']),
(collection) => mcp('pad_item', { action: 'list', collection, limit: 100 })
),
get: via((ref) => cli(['item', 'show', ref]), (ref) => mcp('pad_item', { action: 'get', ref })),
comments: via(
(ref) => cli(['item', 'comments', ref]),
(ref) => mcp('pad_item', { action: 'list-comments', ref })
),
search: via(
(q) => cli(['item', 'search', q, '--limit', '30']),
(q) => mcp('pad_search', { action: 'query', query: q, limit: 30 })
),
setStatus: via(
(ref, status) => cli(['item', 'update', ref, '--status', status]),
(ref, status) => mcp('pad_item', { action: 'update', ref, status })
),
comment: via(
(ref, text) => cli(['item', 'comment', ref, text]),
(ref, text) => mcp('pad_item', { action: 'comment', ref, message: text })
),
create: via(
(collection, title) => cli(['item', 'create', collection, title]),
(collection, title) => mcp('pad_item', { action: 'create', collection, title })
),
}
}
// Shapes differ a little between endpoints and transports; these flatten them.
const norm = {
row(x) {
return {
ref: x.ref || x.item_ref || x.item?.ref,
title: x.title || x.item_title || x.item?.title || '',
status: x.status || fieldsOf(x.fields).status || fieldsOf(x.item?.fields).status || '',
// Bugs rank by severity; show it where a priority would go.
priority: x.priority || fieldsOf(x.fields).priority || fieldsOf(x.item?.fields).priority || x.severity || fieldsOf(x.fields).severity || '',
collection: x.collection_slug || x.collection || x.item?.collection_slug || '',
icon: x.collection_icon || x.item?.collection_icon || '',
updated: x.updated_at || x.item?.updated_at || '',
reason: x.reason || '',
}
},
activeItems: (d) => arr(d?.active_items).map(norm.row),
ready: (r) => arr(r, 'results').map(norm.row),
list: (r) => arr(r, 'items', 'results').map(norm.row),
search: (r) => arr(r, 'results').map((x) => norm.row(x.item ? { ...x.item, ...x } : x)),
comments: (r) => arr(r, 'comments'),
activity: (r) => arr(r, 'activities', 'results'),
collections(r) {
return arr(r, 'collections').map((c) => {
let schema = c.schema
if (typeof schema === 'string') {
try { schema = JSON.parse(schema) } catch { schema = {} }
}
const status = (schema?.fields || []).find((f) => f.key === 'status')
return {
slug: c.slug, name: c.name, icon: c.icon || '·', prefix: c.prefix,
system: !!c.is_system, open: c.active_item_count ?? null, total: c.item_count ?? 0,
statuses: status?.options || [], terminal: status?.terminal_options || [],
}
})
},
}
function ago(iso) {
if (!iso) return ''
const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000)
if (s < 60) return Math.floor(s) + 's'
if (s < 3600) return Math.floor(s / 60) + 'm'
if (s < 86400) return Math.floor(s / 3600) + 'h'
return Math.floor(s / 86400) + 'd'
}
// ---------------------------------------------------------------- pane state
const PANE = 'pad'
const PLUGIN = 'pad' // as plugin.json names it: ui.focus redirects carry it
const DEFAULTS = { startView: 'now', refreshSec: 60, transport: 'auto', mcpServer: '', background: 'theme' }
// The engine fills a pane with the terminal's "bright black", whose shade is the
// terminal palette's; these paint the pane's body over it. 'theme' leaves it be.
const BACKGROUNDS = { theme: undefined, dark: '#262626', darker: '#1c1c1c', black: '#121212' }
let settings = { ...DEFAULTS }
let client = null
let conn = null // { transport, ws } once connected
let connError = null
// Get started: what this machine has, read when the pane can't connect.
const PAD_CLOUD_MCP = 'https://mcp.getpad.dev'
let setup = null // { hasCli, linked } once checked
let cloudStep = null // null | 'confirm' | 'added' | 'failed'
let cloudError = null
let open = false
let view = 'now'
const stack = []
const data = { now: [], next: [], activity: [], collections: [], list: [], results: [], query: '' }
let browseCollection = null
let item = null // { ref, raw, comments, statusOptions }
let mode = null // null | 'status' | 'comment' | 'add'
let addCollection = null
let loadedAt = null
let busy = false
let notice = null
// Keyboard model. Tab and Shift+Tab walk the menu: the view tabs, the list as ONE
// stop, then the view's other controls. Up and Down walk the list's rows.
// The engine reports Tab only as a focus move and an arrow only as a one-row
// scroll whenever the drawing is taller than the pane, so the drawing is always
// kept one row taller, and the two hooks below give each key its own meaning.
const ring = { order: [], cur: null, lastRow: {}, terminal: true }
const LIST_KEY = /^(row-|coll-|act-|empty-back$|gs-)/
const NOT_A_STOP = /^(refresh|add)$/ // footer actions: their hotkeys reach them
const stopOf = (k) => (LIST_KEY.test(k) ? 'LIST' : k)
const listKeys = () => ring.order.filter((k) => LIST_KEY.test(k))
// Where a person's focus move should land, or null to let it land where the
// engine aimed it. `order` is the drawn controls; `to` is the engine's target
// (undefined for one of its own stops, such as the close mark). A move of one
// step either way is Tab or Shift+Tab; anything else is a click.
// Returns { element, engineStop } (engineStop: the engine aimed at its own stop).
export function tabStep(order, cur, to, listEntry) {
const n = order.length
const dT = order.indexOf(to), dC = order.indexOf(cur)
if (n < 2 || (dT < 0 && dC < 0)) return null
const stops = []
for (const k of order) {
if (NOT_A_STOP.test(k)) continue
const s = stopOf(k)
if (stops[stops.length - 1] !== s) stops.push(s)
}
const land = (target, engineStop = false) => ({ element: target === 'LIST' ? listEntry : target, engineStop })
const at = stops.indexOf(stopOf(cur))
if (dT >= 0 && dC >= 0) {
const dir = (dT - dC + n) % n === 1 ? 1 : (dC - dT + n) % n === 1 ? -1 : 0
if (!dir) return null
return land(stops[(at + dir + stops.length) % stops.length])
}
if (dC >= 0) {
if (at === 0) return land(stops[stops.length - 1], true)
if (at === stops.length - 1) return land(stops[0], true)
return null
}
// coming back from one of the engine's stops: enter at whichever end is nearer
return land(dT > n / 2 ? stops[stops.length - 1] : stops[0])
}
// The row an arrow (a one-row scroll) moves to, or null to let it scroll.
export function arrowStep(order, cur, by, listEntry) {
if (Math.abs(by) !== 1) return null
const rows = order.filter((k) => LIST_KEY.test(k))
if (!rows.length) return null
const i = rows.indexOf(cur)
if (i < 0) return by > 0 ? listEntry : null
return rows[i + by] || null
}
const firstRowFor = () => {
const rows = listKeys()
const last = ring.lastRow[view + ':' + (browseCollection || '')]
return rows.includes(last) ? last : rows[0]
}
const redraw = ($) => $.ui.invalidate('ui.render')
const msg = (err) => String(err?.message || err).slice(0, 120)
async function ensureClient($) {
if (client && conn) return true
const toml = await readPadToml($)
client = makeClient($, settings, toml)
try {
conn = await client.connect()
connError = null
return true
} catch (err) {
conn = null
connError = msg(err)
await checkSetup($)
return false
}
}
async function checkSetup($) {
let hasCli = false
try {
hasCli = (await $.process.run(['pad', '--version'], { timeoutMs: 5000 })).exitCode === 0
} catch {}
setup = { hasCli, linked: !!(await readPadToml($)) }
}
// Pad Cloud needs no install: add its MCP server to Claude Code (user scope),
// then the person sends /reload-plugins and signs in from /mcp. Listing it in
// the plugin manifest instead would nag every session, self-hosters included
// (PLAN-3490).
async function addPadCloud($) {
try {
const r = await $.process.run(['claude', 'mcp', 'add', '--scope', 'user', '--transport', 'http', 'pad', PAD_CLOUD_MCP], { timeoutMs: 20000 })
if (r.exitCode !== 0) throw new Error((r.stderr || r.stdout || 'claude mcp add failed').trim().split('\n')[0])
cloudStep = 'added'
cloudError = null
settings = { ...settings, transport: 'auto' }
client = null
await $.prompt.fill({ text: '/reload-plugins' }).catch(() => {})
} catch (err) {
cloudStep = 'failed'
cloudError = msg(err)
}
}
async function ensureCollections() {
if (!data.collections.length) data.collections = norm.collections(await client.collections())
}
async function load($, what = view) {
if (!(await ensureClient($))) return redraw($)
busy = true
redraw($)
try {
if (what === 'now') data.now = norm.activeItems(await client.dashboard())
else if (what === 'next') data.next = norm.ready(await client.ready())
else if (what === 'activity') data.activity = norm.activity(await client.activity(30))
else if (what === 'browse') {
data.collections = norm.collections(await client.collections())
if (browseCollection) data.list = norm.list(await client.list(browseCollection))
} else if (what === 'item' && item) await fetchItem($, item.ref)
loadedAt = new Date().toISOString()
notice = null
} catch (err) {
notice = 'Pad: ' + msg(err)
}
busy = false
redraw($)
}
async function fetchItem($, ref) {
const got = await client.get(ref)
const raw = { ...got, fields: fieldsOf(got?.fields) }
const comments = norm.comments(await client.comments(ref))
await ensureCollections()
const coll = data.collections.find((c) => c.slug === raw.collection_slug)
item = { ref, raw, comments, statusOptions: coll?.statuses || [] }
loadedAt = new Date().toISOString()
}
async function openItem($, ref) {
if (!(await ensureClient($))) return redraw($)
if (view !== 'item') stack.push(view)
view = 'item'
mode = null
busy = true
redraw($)
try {
await fetchItem($, ref)
notice = null
} catch (err) {
notice = 'Pad: could not open ' + ref + ': ' + msg(err)
}
busy = false
redraw($)
}
function back($) {
mode = null
view = stack.pop() || settings.startView
redraw($)
}
// One level up from where the pane is, or false at the top level.
function stepBack($) {
if (mode) {
mode = null
} else if (view === 'item') {
view = stack.pop() || settings.startView
} else if (view === 'browse' && browseCollection) {
browseCollection = null
} else if (view !== settings.startView) {
view = settings.startView
if (view !== 'find' && view !== 'settings') load($, view)
} else {
return false
}
redraw($)
return true
}
const OPEN_ARGS = { id: PANE, title: 'Pad', focus: true, closeOnEscape: true }
function go($, v) {
mode = null
stack.length = 0
view = v
if (v === 'browse') browseCollection = null
if (v === 'find' || v === 'settings') return redraw($)
load($, v)
}
// ---------------------------------------------------------------- drawing
// ---------------------------------------------------------------- look
// Colours are theme keys, so the pane follows the user's light or dark theme.
const C = { brand: 'merged', open: 'suggestion', active: 'warning', done: 'success', blocked: 'error', muted: 'inactive', accent: 'claude' }
const DONE = /^(done|fixed|completed?|implemented|published|merged|resolved|shipped|archived|consumed)$/
const ACTIVE = /^(in[-_ ]?progress|doing|fixing|exploring|implementing|in[-_ ]?review|review|testing|active|drafting|writing|running)$/
const BLOCKED = /^(blocked|rejected|wont[-_ ]?fix|cancell?ed|failed|deprecated)$/
function statusColor(s) {
s = String(s || '').toLowerCase()
if (DONE.test(s)) return C.done
if (ACTIVE.test(s)) return C.active
if (BLOCKED.test(s)) return C.blocked
return s ? C.open : C.muted
}
const statusGlyph = (s) => {
s = String(s || '').toLowerCase()
if (DONE.test(s)) return '✔'
if (ACTIVE.test(s)) return '◐'
if (BLOCKED.test(s)) return '✖'
return s ? '○' : '·'
}
function priorityMark(p) {
p = String(p || '').toLowerCase()
if (p === 'critical' || p === 'urgent') return { t: '‼', c: C.blocked }
if (p === 'high' || p === 'must') return { t: '!', c: C.blocked }
if (p === 'low' || p === 'may') return { t: '↓', c: C.muted }
return { t: ' ', c: C.muted }
}
const HINTS = {
now: '↑↓ move · ⏎ open · q add · r refresh · esc close',
next: '↑↓ move · ⏎ open · q add · r refresh · esc back',
browse: '↑↓ move · ⏎ open · esc back · q add · r refresh',
find: 'type, then ⏎ to search · ↑↓ results · esc back',
activity: '↑↓ move · ⏎ open the item · r refresh · esc back',
settings: '↑↓ move · ⏎ change · esc back',
item: 's status · c comment · w work on it · y copy ref · o copy link · esc back',
}
function draw($, e) {
const ui = $.ui.resolve(e)
const { Text, Markdown } = ui
ring.order = []
let autoKey = null
const stop = (make) => (props) => {
if (props?.key) { ring.order.push(props.key); if (props.autoFocus && !autoKey) autoKey = props.key }
return make(props)
}
const Button = stop(ui.Button), Input = stop(ui.Input), Select = stop(ui.Select)
// Keep every box at its natural height: when the drawing is taller than the pane
// it scrolls as a whole, but flex would first squeeze bordered boxes to nothing.
const Box = (props) => ui.Box({ flexShrink: 0, ...props })
const W = Math.max(28, (e.props.bodyColumns || 60) - 1)
// The Desktop app draws these elements natively, in a proportional font: no
// character-drawn rules or space-padded columns there, and its keycap badges
// already show each hotkey.
const desktop = e.surface === 'desktop'
// the keyboard model is the terminal's: Desktop's native focus needs none of it,
// and there a click one control away read as Tab and was redirected
ring.terminal = !desktop
const clip = (t, n = W) => {
t = String(t ?? '')
return t.length > n ? t.slice(0, Math.max(1, n - 1)) + '…' : t
}
const T = (children, props = {}) => Text({ ...props, children: Array.isArray(children) ? children : [children] })
const dim = (t) => T(t, { dimColor: true })
const gap = () => T(' ')
// Desktop draws a run of '─' as two lines; its own vector leaf draws exactly one.
// This markup is the one that showed (2026-10-08): solid #888, 2px. A 1px,
// translucent, 2400-wide version drew nothing.
// The line sits at the top of an 8px leaf, so 6px of space follows it.
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>'
// Desktop only: a few pixels of air, as an empty vector leaf (it lays out
// exactly; a blank Text would cost a whole row).
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)
const rule = () =>
desktop
? (ui.Svg ? ui.Svg({ source: RULE_SVG, alt: 'divider', height: 8 }) : null)
: T('─'.repeat(W), { color: C.muted })
const section = (label, extra) =>
Box({ flexDirection: 'row', columnGap: 1, children: [T(label, { bold: true, color: C.brand }), ...(extra ? [dim(extra)] : [])] })
// ---- header: brand, workspace, tabs
const tab = (v, label, hotkey) =>
Button({ key: 'tab-' + v, label, hotkey, plain: true, dimColor: view !== v && !(view === 'item' && stack[0] === v), onPress: () => go($, v) })
const header = Box({
flexDirection: 'column',
children: [
Box({
flexDirection: 'row',
justifyContent: 'space-between',
children: [
Box({ flexDirection: 'row', columnGap: 1, children: [T('▣ pad', { bold: true, color: C.brand }), T(conn ? conn.ws : '…', { bold: true })] }),
Box({
flexDirection: 'row',
columnGap: 2,
children: [
dim(busy ? 'loading…' : loadedAt ? 'updated ' + ago(loadedAt) + ' ago' : ''),
...(desktop ? [tab('settings', 'Settings', 'g')] : []),
],
}),
],
}),
spacer(6),
Box({
flexDirection: 'row',
columnGap: desktop ? 1 : 2,
flexWrap: 'wrap',
children: [
tab('now', 'Now', 'n'),
tab('next', 'Next', 'x'),
tab('browse', 'Browse', 'b'),
tab('find', 'Find', 'f'),
tab('activity', 'Activity', 'a'),
...(desktop ? [] : [tab('settings', 'Settings', 'g')]),
],
}),
rule(),
].filter(Boolean),
})
// ---- one item row: glyph, priority, ref (fixed width), title, status, age
const REFW = 10
const row = (r, i, opts = {}) => {
const sc = statusColor(r.status)
const pm = priorityMark(r.priority)
const tail = [opts.hideStatus ? '' : r.status, r.updated ? ago(r.updated) : ''].filter(Boolean).join(' · ')
const titleW = Math.max(8, W - REFW - tail.length - 7)
return Box({
key: 'r-' + view + '-' + (r.ref || i),
flexDirection: 'row',
columnGap: 1,
children: [
T(statusGlyph(r.status), { color: sc }),
T(pm.t, { color: pm.c }),
// Desktop's font is proportional, so spaces can't hold the ref column:
// give the ref a fixed-width box and let the Button carry the title
...(desktop ? [Box({ width: REFW, flexShrink: 0, children: [T(r.ref || '', { dimColor: true })] })] : []),
Button({
key: 'row-' + view + '-' + (r.ref || i),
plain: true,
...(i === 0 && !opts.noFocus ? { autoFocus: true } : {}),
label: desktop ? clip(r.title, titleW) : (r.ref || '').padEnd(REFW) + ' ' + clip(r.title, titleW),
onPress: () => openItem($, r.ref),
}),
Box({ flexGrow: 1 }),
T(tail, { color: opts.hideStatus ? C.muted : sc, dimColor: opts.hideStatus }),
],
})
}
const empty = (t) => [dim(busy ? ' loading…' : ' ' + t)]
let body = []
if (!conn && !setup) {
body = [dim(busy || !connError ? ' connecting…' : ' ' + clip(connError, W * 3))]
} else if (!conn) {
const have = (ok, label) => T((ok ? '✔ ' : '○ ') + label, { color: ok ? C.done : C.muted })
const fill = (text) => $.prompt.fill({ text }).catch(() => {})
const path = (key, hotkey, label, note, onPress) =>
Box({
flexDirection: 'column',
children: [
Button({ key: 'gs-' + key, hotkey, plain: true, label, onPress, ...(key === 'cloud' ? { autoFocus: true } : {}) }),
dim(' ' + clip(note, W * 2)),
],
})
body = [
section('Get started with Pad'),
dim(clip('Pad is shared memory for you and your agents. Pick how to connect; you can change it later.', W * 2)),
gap(),
have(setup.hasCli, 'pad CLI installed'),
have(setup.linked, 'this folder is linked to a workspace'),
gap(),
path('cloud', '1', 'Use Pad Cloud — nothing to install',
cloudStep === 'confirm'
? 'Adds Pad Cloud to Claude Code: claude mcp add --transport http pad ' + PAD_CLOUD_MCP + '. Press 1 again to go ahead.'
: 'Sign in or sign up in your browser; Claude Code talks to Pad Cloud over MCP.',
async () => {
if (cloudStep !== 'confirm') { cloudStep = 'confirm'; return redraw($) }
await addPadCloud($)
redraw($)
}),
...(cloudStep === 'added'
? [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 })]
: cloudStep === 'failed'
? [T(clip('Could not add it: ' + (cloudError || '') + '. Run it yourself: claude mcp add --transport http pad ' + PAD_CLOUD_MCP, W * 3), { color: C.blocked })]
: []),
gap(),
path('local', '2', setup.hasCli ? 'Set up this folder with the pad CLI' : 'Run Pad on this machine',
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.',
() => fill(setup.hasCli
? '/pad:onboard'
: '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.')),
gap(),
path('server', '3', 'Connect to your own Pad server',
'Claude links this folder to a self-hosted Pad (pad init --url …, then pad auth login).',
() => 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')),
gap(),
dim(clip('r: check again', W)),
]
} else if (mode === 'add') {
const colls = data.collections.filter((c) => !c.system)
body = [
section('New item'),
gap(),
Select({
key: 'add-collection',
label: 'Collection',
value: addCollection || colls[0]?.slug,
options: colls.map((c) => ({ value: c.slug, label: `${c.icon} ${c.name}` })),
onSelect: (v) => {
addCollection = v
redraw($)
},
}),
Input({
key: 'add-title',
label: 'Title',
placeholder: 'What needs doing? ⏎ to create',
autoFocus: true,
submitLabel: 'Create',
onSubmit: async (title) => {
if (!String(title).trim()) return
try {
const created = await client.create(addCollection || colls[0]?.slug, String(title).trim())
const ref = created?.ref || created?.item?.ref
mode = null
$.ui.toast('Created ' + (ref || 'item'))
if (ref) await openItem($, ref)
else redraw($)
} catch (err) {
notice = 'Create failed: ' + msg(err)
redraw($)
}
},
}),
gap(),
Button({ key: 'add-cancel', label: 'cancel', hotkey: 'h', plain: true, dimColor: true, onPress: () => { mode = null; redraw($) } }),
]
} else if (view === 'now') {
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.'))]
} else if (view === 'next') {
body = [section('Ready to work on', data.next.length ? String(data.next.length) : '')]
data.next.forEach((r, i) => {
body.push(row(r, i))
if (r.reason) body.push(T(' ' + clip(r.reason, W - 5), { color: C.muted, italic: true }))
})
if (!data.next.length) body.push(...empty('Nothing ready.'))
} else if (view === 'browse') {
if (!browseCollection) {
body = [section('Collections')]
const user = data.collections.filter((c) => !c.system)
const lib = data.collections.filter((c) => c.system)
for (const c of [...user, ...(lib.length ? [{ heading: 'Library' }] : []), ...lib]) {
if (c.heading) { body.push(T(c.heading, { key: 'ch-lib', bold: true, color: C.muted })); continue }
body.push(
Box({
key: 'cb-' + c.slug,
flexDirection: 'row',
columnGap: 1,
children: [
Button({ key: 'coll-' + c.slug, plain: true, label: `${c.icon} ${clip(c.name, W - 12)}`, onPress: async () => {
browseCollection = c.slug; data.list = []
await load($, 'browse')
// An empty collection's only other stop is the header's back Button;
// put the ring on the vertical back row instead (the engine keeps a
// ring the person moved, so autoFocus alone doesn't move it).
if (!data.list.length) $.ui.focus({ requestId: PANE, key: 'empty-back' }).catch(() => {})
} }),
Box({ flexGrow: 1 }),
dim(c.open == null || c.open === c.total ? String(c.total) : `${c.open} open · ${c.total}`),
],
})
)
}
if (body.length === 1) body.push(...empty('No collections.'))
} else {
const coll = data.collections.find((c) => c.slug === browseCollection)
body = [
Box({
flexDirection: 'row',
columnGap: 1,
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 })],
}),
]
const groups = new Map()
for (const r of data.list) {
if (!groups.has(r.status)) groups.set(r.status, [])
groups.get(r.status).push(r)
}
const order = coll?.statuses || []
const keys = [...groups.keys()].sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99))
for (const status of keys) {
const rows = groups.get(status)
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))] }))
rows.forEach((r, i) => body.push(row(r, i, { hideStatus: true, noFocus: status !== keys[0] })))
}
if (!data.list.length) {
// An empty collection still gives the ring a vertical stop, or it falls
// back onto the header's back Button, where ← looks like it should work.
body.push(...empty('No open items.'), Button({ key: 'empty-back', label: '‹ Back to collections', plain: true, autoFocus: true, onPress: () => { browseCollection = null; redraw($) } }))
}
}
} else if (view === 'find') {
body = [
Input({
key: 'find',
label: 'Search',
placeholder: 'Words, then ⏎',
value: data.query,
autoFocus: true,
onSubmit: async (q) => {
data.query = String(q)
if (!data.query.trim() || !(await ensureClient($))) return
busy = true
redraw($)
try {
data.results = norm.search(await client.search(data.query))
notice = null
} catch (err) {
notice = 'Search failed: ' + msg(err)
}
busy = false
redraw($)
},
}),
]
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)))
else if (data.query && !busy) body.push(gap(), dim(' No matches for “' + data.query + '”.'))
} else if (view === 'activity') {
body = [section('Recent activity')]
data.activity.forEach((a, i) => {
let who = a.actor_name || a.user_name || 'someone'
let isAgent = a.actor === 'agent'
try {
const m = typeof a.metadata === 'string' ? JSON.parse(a.metadata) : a.metadata
if (m?.agent) { who = m.agent; isAgent = true }
} catch {}
const what = String(a.action || '').replace(/_/g, ' ')
body.push(
Box({
key: 'ab-' + (a.id || i),
flexDirection: 'row',
columnGap: 1,
children: [
T(ago(a.created_at).padStart(3), { color: C.muted }),
T(clip(who, 12).padEnd(12), { color: isAgent ? C.accent : C.open, bold: true }),
T(clip(what, 12).padEnd(12), { dimColor: true }),
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) }),
],
})
)
})
if (!data.activity.length) body.push(...empty('No recent activity.'))
} else if (view === 'settings') {
const set = async (k, v) => {
settings = { ...settings, [k]: v }
await $.store.set('settings', settings)
if (k === 'transport') {
client = null
conn = null
await ensureClient($)
}
redraw($)
}
body = [
section('Settings'),
gap(),
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) }),
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)) }),
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) }),
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) }),
gap(),
T(conn ? `● connected to ${conn.ws} via ${conn.transport}${conn.server ? ' (' + conn.server + ')' : ''}` : '○ not connected', { color: conn ? C.done : C.blocked }),
]
} else if (view === 'item' && item) {
const f = item.raw.fields || {}
const sc = statusColor(f.status)
const pm = priorityMark(f.priority || f.severity)
const meta = Object.entries(f)
.filter(([k, v]) => !['status', 'priority', 'severity'].includes(k) && v !== null && v !== '' && typeof v !== 'object')
.map(([k, v]) => `${k}: ${v}`)
const act = (key, label, hotkey, onPress) => Button({ key: 'do-' + key, label, hotkey, plain: true, onPress })
const card = Box({
flexDirection: 'column',
// 'quote', not 'round': in a pane, a full border drew empty (CC 2.1.293).
borderStyle: 'quote',
borderColor: sc,
paddingLeft: 1,
paddingRight: 1,
children: [
Box({
flexDirection: 'row',
justifyContent: 'space-between',
children: [T(item.ref, { bold: true, color: C.brand }), T(item.raw.updated_at ? 'updated ' + ago(item.raw.updated_at) + ' ago' : '', { dimColor: true })],
}),
T(item.raw.title || '', { bold: true, wrap: 'wrap' }),
Box({
flexDirection: 'row',
columnGap: 2,
flexWrap: 'wrap',
children: [
T(`${statusGlyph(f.status)} ${f.status || 'no status'}`, { color: sc, bold: true }),
...(f.priority || f.severity ? [T(`${pm.t.trim() || '·'} ${f.priority || f.severity}`, { color: pm.c })] : []),
...(item.raw.parent_ref ? [dim('parent ' + item.raw.parent_ref)] : []),
],
}),
...(meta.length ? [dim(clip(meta.join(' · '), W * 2))] : []),
...(item.raw.decisions || [])
.filter((d) => d.question_set === 'conventions' && d.current !== false && d.answer?.noul >= 0.9)
.map((d) => T('⚑ Possibly breaks ' + String(d.question_key).replace(/^conv:/, ''), { color: C.active })),
],
})
body = [
card,
Box({
flexDirection: 'row',
columnGap: 2,
flexWrap: 'wrap',
children: [
act('status', 'status', 's', () => { mode = mode === 'status' ? null : 'status'; redraw($) }),
act('comment', 'comment', 'c', () => { mode = mode === 'comment' ? null : 'comment'; redraw($) }),
act('work', 'work on it', 'w', async () => {
await $.prompt.fill({ text: `/pad let's work on ${item.ref}` })
$.ui.toast('In your prompt: edit it or press Enter')
}),
act('copy', 'copy ref', 'y', (press) => { $.ui.copy({ text: item.ref, surface: press?.surface }); $.ui.toast('Copied ' + item.ref) }),
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') }),
act('back', 'back', 'h', () => back($)),
],
}),
]
if (mode === 'status') {
body.push(
Select({
key: 'status-select',
label: 'Status',
value: f.status,
autoFocus: true,
options: (item.statusOptions.length ? item.statusOptions : [f.status || 'open']).map((s) => ({ value: s, label: `${statusGlyph(s)} ${s}` })),
onSelect: async (s) => {
if (s === f.status) { mode = null; return redraw($) }
try {
await client.setStatus(item.ref, s)
$.ui.toast(`${item.ref} → ${s}`)
mode = null
await fetchItem($, item.ref)
} catch (err) {
notice = 'Status change failed: ' + msg(err)
}
redraw($)
},
})
)
}
if (mode === 'comment') {
body.push(
Input({
key: 'comment-input',
label: 'Comment',
placeholder: '⏎ to post',
autoFocus: true,
submitLabel: 'Post',
onSubmit: async (text) => {
if (!String(text).trim()) return
try {
await client.comment(item.ref, String(text).trim())
$.ui.toast('Commented on ' + item.ref)
mode = null
await fetchItem($, item.ref)
} catch (err) {
notice = 'Comment failed: ' + msg(err)
}
redraw($)
},
})
)
}
const content = String(item.raw.content || '').trim()
body.push(gap())
if (content) body.push(Markdown({ key: 'body-' + item.ref, text: content.length > 6000 ? content.slice(0, 6000) + '\n\n…' : content }))
else body.push(dim('No description.'))
if (item.comments.length) {
body.push(gap(), section('Comments', String(item.comments.length) + (item.comments.length > 5 ? ' · latest 5' : '')))
for (const c of item.comments.slice(-5)) {
const who = c.agent_name || c.author || '?'
body.push(
Box({
flexDirection: 'column',
// 'quote', not 'round': in a pane, a full border drew empty (CC 2.1.293).
borderStyle: 'quote',
borderColor: c.agent_name ? C.accent : C.open,
paddingLeft: 1,
marginTop: 1,
children: [
Box({ flexDirection: 'row', columnGap: 1, children: [T(who, { bold: true, color: c.agent_name ? C.accent : C.open }), dim(ago(c.created_at) + ' ago')] }),
T(clip(String(c.body || '').replace(/\s+/g, ' ').trim(), W * 4), { wrap: 'wrap' }),
],
})
)
}
}
}
const footer = Box({
flexDirection: 'column',
children: [
rule(),
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({ key: 'refresh', label: 'refresh', hotkey: 'r', plain: true, dimColor: true, onPress: () => load($) }),
Button({
key: 'add',
label: 'quick add',
hotkey: 'q',
plain: true,
dimColor: true,
onPress: async () => {
if (!(await ensureClient($))) return redraw($)
await ensureCollections()
mode = 'add'
redraw($)
},
}),
Box({ flexGrow: 1 }),
...(conn ? [dim('via ' + conn.transport)] : []),
],
}),
...(desktop
? []
: [
e.props.isFocused
? dim(clip(HINTS[mode === 'add' ? 'find' : view] || '', W))
: T(clip('ctrl+x tab: use the pane · ctrl+x x: close', W), { color: C.accent }),
]),
].filter(Boolean),
})
// A new view drops the element the ring was on; the engine then shows it on
// the view's autoFocus control without raising ui.focus, so follow it here.
if (!ring.order.includes(ring.cur)) ring.cur = autoKey || ring.order.find((k) => LIST_KEY.test(k)) || null
const bg = BACKGROUNDS[settings.background]
return Box({
key: 'pane-root',
flexDirection: 'column',
// one row taller than the pane, always: see the keyboard model at the top
...(desktop ? {} : { minHeight: (e.props.scroll?.bodyRows || 0) + 1 }),
...(bg ? { backgroundColor: bg } : {}),
children: [header, ...(notice ? [T('⚠ ' + clip(notice, W * 2), { color: C.blocked })] : []), ...body, gap(), footer],
})
}
// ---------------------------------------------------------------- live item events
// Pushes and watched-item changes reach Claude only in a session that has
// consented (PLAN-2613): .pad.toml push.auto_arm, or /pad:connect. The decision
// is the CLI's (`pad session should-arm`); this mod only acts on it.
//
// This replaces an "always" monitor (TASK-3513). A monitor that exits is
// reported at startup ("ended without producing output"), and an unarmed
// session's monitor always exited, so every session opened with a notice.
// Here an unarmed session runs nothing and shows nothing: no process, no line,
// no notice. /pad:connect keeps its own on-skill-invoke monitor, which is also
// the path where mods do not load; pad-monitor.sh's lockfile keeps the two from
// streaming twice in one session.
//
// Nothing of this runs at session start (TASK-3970): a session that never
// touches Pad never runs the CLI, never opens a socket and never starts a
// timer. Presence and the arm check run on the session's first use of Pad
// instead (wake): the first `pad` command or Pad MCP tool call, or opening the
// pane. A .pad.toml push.auto_arm opt-in is therefore read then, not at start.
const WATCH_BURST_MS = 1500 // a burst ends after this much quiet, and goes to Claude as one message
const WATCH_BURST_MAX_MS = 10000 // a stream that never goes quiet still flushes this often
let burstTimer = null
let burstStart = 0
let watch = null // the running pad-monitor.sh, as $.process.spawn's iterator
let watchEpoch = 0 // bumped by stopWatch: a start still asking the CLI then spawns nothing
let pending = [] // lines not yet handed to Claude
let submitting = false
// The harness session's pid. Claude Code exports CLAUDE_PID and CLAUDECODE to
// Bash and to monitors but not to a mod's processes, whose parent is that same
// process (measured, TASK-3513). The session registry and the headless
// arm-state owner key on the pid; the registry names the runtime from
// CLAUDECODE. CLAUDE_CODE_MESSAGING_SOCKET, which consent is keyed on, does
// reach a mod's processes.
async function sessionPid($) {
try {
const r = await $.process.run(['sh', '-c', 'echo $PPID'], { timeoutMs: 5000 })
const pid = String(r.stdout || '').trim()
return /^\d+$/.test(pid) ? pid : ''
} catch {
return ''
}
}
// Hands the lines gathered so far to Claude, then any that arrived while that
// message waited for the session to go idle: one message per idle window.
async function flushWatch($) {
if (submitting || !pending.length) return
const epoch = watchEpoch
submitting = true
try {
// A flush the session's end overtook relays nothing more (codex r2): a
// submit already waiting cannot be withdrawn, but nothing follows it.
while (pending.length && epoch === watchEpoch) {
const text = pending.join('\n')
pending = []
await $.prompt.submit({ text })
}
} catch {
// a refused submit (the session is ending) drops the lines; the stream
// goes on, and the next line tries again
} finally {
if (epoch === watchEpoch) submitting = false
}
}
let woken = null // wake's promise for this session (reset by an end that stops the stream)
// The session's first use of Pad: presence, then the arm check (startWatch).
// Once per session, so later pad commands run nothing extra. Resolves once
// presence is recorded, not when the arm check answers: `pad session register`
// mints the worker id the CLI sends on every call (leases key on it), so the
// tool.call hook waits for it before the first pad command runs. A register
// that fails is not retried in this session: it stays unregistered, as it
// did when start-time registration failed.
function wake($) {
if (woken) return woken
let registered = () => {}
woken = new Promise((r) => { registered = r })
startWatch($, registered).catch(() => {}).finally(registered)
// TASK-4071: the chat-seen reader starts with the session's first use of
// Pad too, armed or not: /pad:connect may arm the session later, and its
// monitor writes the same file.
startSeen($).catch(() => {})
return woken
}
// A Bash command that runs the pad CLI, or a call of a Pad MCP tool. `pad` as
// a whole word: not inside a longer name (`padd`, `.padrc`, `pad-monitor.sh`),
// whatever stands around it otherwise (a quote, `\`, `$(`, `;`, a path). Wide
// on purpose: a false positive (`cd ~/pad`) only wakes early, while a miss
// runs that first pad call unregistered. Rounds r1 and r2 each found a missed
// shape; the shapes are tabled in watch.test.ts rather than listed here.
function usesPad(e) {
if (e.tool === 'Bash') return /(^|[^\w.-])pad($|[^\w.-])/.test(String(e.command || ''))
// Any server name: Pad's tools are pad_* whatever the person called the server
// (Codex r3: `.*pad.*` in the server part missed a server named otherwise).
return /^mcp__.+__pad_[a-z0-9_]+$/i.test(String(e.tool || ''))
}
// Silent by construction: every failure here leaves the session as it was.
async function startWatch($, registered = () => {}) {
if (watch) return
const epoch = watchEpoch
const pid = await sessionPid($)
const env = pid ? { CLAUDECODE: '1', PAD_SESSION_PID: pid } : { CLAUDECODE: '1' }
// Presence is a fact about the session, kept whether or not it consents
// (TASK-2767): `pad session list` reads it back.
try { await $.process.run(['pad', 'session', 'register'], { env, timeoutMs: 10000 }) } catch {}
registered()
let armed = false
try {
armed = (await $.process.run(['pad', 'session', 'should-arm'], { env, timeoutMs: 10000 })).exitCode === 0
} catch {}
// The session may have ended while the CLI answered (codex r1). Consent
// withdrawn in that window needs no check here: pad-monitor.sh asks
// should-arm again before it streams, and that answer is the one that counts.
if (!armed || watch || epoch !== watchEpoch) return
let it
try {
it = $.process.spawn({ argv: [$.plugin.root + '/scripts/pad-monitor.sh'], env })
} catch {
return
}
watch = it
;(async () => {
let carry = ''
try {
for await (const chunk of it) {
// A stream the session ended may still deliver a chunk that was
// already on its way: return() takes effect after the pending read.
if (watch !== it) break
if (chunk.stream !== 'stdout') continue
const lines = (carry + chunk.text).split('\n')
carry = lines.pop()
for (const line of lines) if (line.trim()) pending.push(line)
if (!pending.length) continue
// Wait for the burst to go quiet (codex r4), but never longer than
// WATCH_BURST_MAX_MS from its first line.
const now = await $.clock.now()
if (!burstStart) burstStart = now
if (burstTimer) burstTimer.cancel()
const wait = Math.max(0, Math.min(WATCH_BURST_MS, burstStart + WATCH_BURST_MAX_MS - now))
burstTimer = $.clock.after(wait, () => {
burstTimer = null
burstStart = 0
flushWatch($)
})
}
} catch {}
if (watch === it) watch = null
})()
}
function stopWatch() {
woken = null
watchEpoch++
const it = watch
watch = null
pending = []
submitting = false
if (burstTimer) burstTimer.cancel()
burstTimer = null
burstStart = 0
if (it) {
try { it.return() } catch {}
}
}
// The pane's auto-refresh. It runs only while the pane is open (TASK-3970):
// a closed pane leaves no timer behind.
let refresh = null
function startRefresh($) {
if (refresh) return
refresh = $.clock.every(5000, () => {
if (!open || !settings.refreshSec || busy || mode || view === 'find' || view === 'settings') return
if (!loadedAt || Date.now() - Date.parse(loadedAt) >= settings.refreshSec * 1000) load($)
})
}
function stopRefresh() {
if (refresh) refresh.cancel()
refresh = null
}
// ---------------------------------------------------------------- chat seen
// TASK-4071: a chat line delivered to the agent is shown to the person at this
// terminal too. `pad watch --stream --for-session` appends each chat line it
// prints to ~/.pad/chat-seen/<harness pid>.log; this tails that file into a band
// above the prompt (the last few lines, until the person's next prompt) and a
// toast per burst. One reader for both launch paths: the mod's own stream and
// the /pad:connect monitor write the same file, keyed by the same pid.
//
// It is a display cue, never a record (the agent's stdout is). Reading starts
// at the end of whatever the file holds, so a /clear or a recycled pid never
// replays old lines. A rotation (the writer moves a full log to .1) is seen as
// the file shrinking; lines that rotated away between two polls are not shown,
// and nothing catches them up (TASK-4071 C3). A rotated log that grows back past
// the old position within one poll is misread as a continuation, for one poll.
const SEEN_POLL_MS = 1000
const SEEN_BAND_LINES = 3
const SEEN_LINE_MAX = 300
let seenTimer = null
let seenStarting = false
let seenEpoch = 0 // bumped by stopSeen: a start or poll still awaiting then changes nothing
let seenPath = ''
let seenSize = -1 // bytes at the last successful look; -1 until the first
let seenChars = 0 // characters of the file already handled, up to its last newline
let seenPrimed = false // the first look only finds the end
let seenPolling = false
let seenBand = [] // the lines the band shows
let toastLines = [] // new lines not yet toasted: one toast per burst
let toastTimer = null
let toastStart = 0
// Chat text is another party's words, drawn on the person's terminal: drop
// control characters (C0, DEL, C1, so no escape sequence reaches it) and the
// bidirectional controls that can make a line read as something else. The
// log itself stays byte for byte what the agent got.
const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
export const displayLine = (s) => String(s).replace(UNSAFE, '')
// Cut by code points, so an emoji at the edge is never split in half.
export function clipLine(s, n) {
const cps = Array.from(s)
return cps.length > n ? cps.slice(0, n - 1).join('') + '…' : s
}
// The person's own prompt, typed here or through Remote Control: what clears
// the band. A prompt this mod relays, or a background task's, does not.
export function isPersonPrompt(e) {
const kind = e?.origin?.kind
return kind === 'composer' || kind === 'bridge'
}
async function chatSeenPath($) {
const pid = await sessionPid($)
if (!pid) return ''
try {
const r = await $.process.run(['sh', '-c', 'printf %s "$HOME"'], { timeoutMs: 5000 })
const home = String(r.stdout || '')
return home.startsWith('/') ? home.replace(/\/+$/, '') + '/.pad/chat-seen/' + pid + '.log' : ''
} catch {
return ''
}
}
async function startSeen($) {
if (seenTimer || seenStarting) return
seenStarting = true
const epoch = seenEpoch
try {
const path = await chatSeenPath($)
if (!path || epoch !== seenEpoch) return
seenPath = path
await pollSeen($)
if (epoch !== seenEpoch) return
seenTimer = $.clock.every(SEEN_POLL_MS, () => { pollSeen($).catch(() => {}) })
} finally {
if (epoch === seenEpoch) seenStarting = false
}
}
function stopSeen() {
seenEpoch++
seenStarting = false
seenPolling = false // a poll still in flight finishes under the old epoch
if (seenTimer) seenTimer.cancel()
seenTimer = null
if (toastTimer) toastTimer.cancel()
toastTimer = null
toastLines = []
toastStart = 0
seenPath = ''
seenSize = -1
seenChars = 0
seenPrimed = false
seenBand = []
}
async function pollSeen($) {
if (seenPolling || !seenPath) return
const epoch = seenEpoch
const path = seenPath
seenPolling = true
try {
let size = -1 // unknown: the stat failed, or the path is a symlink
try {
const st = await $.fs.stat(path)
if (st.kind === 'file' && !st.isLink) size = st.size
} catch {}
if (epoch !== seenEpoch) return
if (size < 0) {
// No log at the first look: every line from now on is new. A look that
// only FAILED proves nothing, so it leaves the start to the first good
// read; and once started, an unreadable moment changes nothing. Either
// way it cannot cause a replay.
if (!seenPrimed && (await logAbsent($, path)) && epoch === seenEpoch) {
seenSize = 0
seenChars = 0
seenPrimed = true
}
return
}
if (size === seenSize) return
if (!size) {
seenSize = 0
seenChars = 0
seenPrimed = true
return
}
let text
try {
text = await $.fs.read(path)
} catch {
return // nothing advanced: the next poll tries again
}
if (epoch !== seenEpoch) return
// Rotated (or removed and recreated): read the new log from its start. In
// bytes here and in characters below, since a log of multi-byte text can
// shrink in one while it grows in the other.
if (size < seenSize) seenChars = 0
seenSize = size
if (!seenPrimed) {
// The first look skips what the file held at the stat; a line written
// between that stat and this read is new, and shows.
const cut = charIndexAtByte(text, size)
seenChars = text.lastIndexOf('\n', cut - 1) + 1
seenPrimed = true
}
if (text.length < seenChars) seenChars = 0
const end = text.lastIndexOf('\n') + 1 // a line still being written waits for its newline
if (end <= seenChars) return
const fresh = text.slice(seenChars, end).split('\n').map(displayLine).filter((l) => l.trim())
seenChars = end
if (!fresh.length) return
seenBand = [...seenBand, ...fresh].slice(-SEEN_BAND_LINES).map((l) => clipLine(l, SEEN_LINE_MAX))
redraw($)
await queueToast($, fresh, epoch)
} finally {
if (epoch === seenEpoch) seenPolling = false
}
}
// True only when the listing shows no log at path (or no directory for it).
async function logAbsent($, path) {
const cut = path.lastIndexOf('/')
const dir = path.slice(0, cut)
const name = path.slice(cut + 1)
try {
return !(await $.fs.list(dir)).some((e) => e.name === name)
} catch {}
const up = dir.lastIndexOf('/')
try {
return !(await $.fs.list(dir.slice(0, up))).some((e) => e.name === dir.slice(up + 1))
} catch {
return false
}
}
// The index in text (UTF-16 units) where its first `bytes` UTF-8 bytes end.
export function charIndexAtByte(text, bytes) {
let b = 0
let i = 0
while (i < text.length && b < bytes) {
const cp = text.codePointAt(i)
b += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4
i += cp > 0xffff ? 2 : 1
}
return i
}
// One toast per burst, on the same clock as the lines relayed to Claude: a
// burst ends after WATCH_BURST_MS of quiet, or WATCH_BURST_MAX_MS after it began.
async function queueToast($, lines, epoch) {
toastLines.push(...lines)
const now = await $.clock.now()
if (epoch !== seenEpoch) return
if (!toastStart) toastStart = now
if (toastTimer) toastTimer.cancel()
const wait = Math.max(0, Math.min(WATCH_BURST_MS, toastStart + WATCH_BURST_MAX_MS - now))
toastTimer = $.clock.after(wait, () => {
toastTimer = null
toastStart = 0
const burst = toastLines
toastLines = []
if (!burst.length) return
const last = clipLine(burst[burst.length - 1], 120)
$.ui.toast(burst.length > 1 ? last + ' (+' + (burst.length - 1) + ' more)' : last)
})
}
function drawSeenBand($, e) {
const { Box, Text } = $.ui.resolve(e)
return Box({
flexDirection: 'column',
children: [
Text({ dimColor: true, children: ['Pad chat (delivered to the agent)'] }),
...seenBand.map((l) => Text({ wrap: 'truncate-end', children: [l] })),
],
})
}
// ---------------------------------------------------------------- hooks
export function register(on) {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'pad-pane', description: 'Open or close the Pad pane for this workspace', immediate: true })
const saved = await $.store.get('settings')
if (saved && typeof saved === 'object') settings = { ...DEFAULTS, ...saved }
// After a reload (an update, or a --plugin-dir edit) the pane may still be
// open while this module starts empty: pick it back up.
try {
const panes = await $.ui.panes()
if (panes.some((p) => p.id === PANE)) {
open = true
startRefresh($)
load($)
wake($)
}
} catch {}
// Nothing else (TASK-3970): no CLI, no stream, no timer. See wake.
return next(e)
})
// /clear and /resume keep the process and its messaging socket, which is
// what consent is keyed on, and session.start does not fire again after
// them: the stream stays. Any other end stops it.
on('session.end', async ($, e, next) => {
if (e.reason !== 'clear' && e.reason !== 'resume') {
stopWatch()
stopSeen()
}
return next(e)
})
on('command.run', { command: 'pad-pane' }, async ($) => {
if (open) {
await $.ui.close({ id: PANE })
open = false
stopRefresh()
return {}
}
view = settings.startView
stack.length = 0
mode = null
open = true
await $.ui.open(OPEN_ARGS)
// The command runs while its own text is still in the prompt, and a pane only
// takes the keys from an empty prompt: ask again once the prompt has cleared.
$.clock.after(150, () => { if (open) $.ui.open(OPEN_ARGS) })
startRefresh($)
load($)
wake($)
return {}
})
// Esc steps back one level (an open form, an item, a collection, another
// view); at the top level it closes the pane. Claude Code reports Esc as the
// person closing the pane, which a hook may refuse.
on('ui.close', async ($, e, next) => {
if (e.id !== PANE) return next(e)
if (e.origin?.kind === 'person' && stepBack($)) {
$.clock.after(50, () => { if (open) $.ui.open(OPEN_ARGS) })
// ui.close yields void: answering { value } without next keeps the pane
// open. A bare {} is the wrong shape and the engine prints it to the chat.
return { value: undefined }
}
open = false
stopRefresh()
return next(e)
})
const remember = (k) => {
ring.cur = k
if (k && LIST_KEY.test(k)) ring.lastRow[view + ':' + (browseCollection || '')] = k
}
// Tab / Shift+Tab: one step through the menu, the list counting once (tabStep).
on('ui.focus', async ($, e, next) => {
if (e.requestId !== PANE) return next(e)
const step = e.origin?.kind === 'person' && ring.terminal
? tabStep(ring.order, ring.cur, e.element, firstRowFor())
: null
if (!step) { remember(e.element); return next(e) }
remember(step.element)
if (step.engineStop) {
// the engine won't retarget a move headed for its own stop: keep the ring,
// then move it ourselves
$.clock.after(0, () => $.ui.focus({ requestId: PANE, key: step.element }).catch(() => {}))
return {}
}
// an engine stop's event names no plugin; a redirect must name ours
return next({ ...e, plugin: e.plugin || PLUGIN, element: step.element })
})
// Up / Down: a one-row scroll becomes a move to the next row (arrowStep). Page
// keys, Home/End and fast wheel spins still scroll.
on('ui.scroll', async ($, e, next) => {
if (e.requestId !== PANE || e.origin?.kind !== 'person' || !ring.terminal) return next(e)
const to = arrowStep(ring.order, ring.cur, e.by, firstRowFor())
if (!to) return next(e)
remember(to)
$.ui.focus({ requestId: PANE, key: to }).catch(() => {})
return {}
})
// The chat-seen band (TASK-4071) stays until the person's own next prompt;
// a prompt this mod or anything else submits does not clear it.
on('prompt.submit', async ($, e, next) => {
if (seenBand.length && isPersonPrompt(e)) {
seenBand = []
redraw($)
}
return next(e)
})
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
if (!seenBand.length || e.props?.hasSurvey) return next(e)
return drawSeenBand($, e)
})
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (e.requestId !== PANE) return next(e)
return draw($, e)
})
// Keep an open pane fresh when Claude changes Pad, through the CLI or MCP.
on('tool.call', async ($, e, next) => {
// The first pad command runs after presence is recorded (see wake).
if (usesPad(e)) {
try { await wake($) } catch {}
}
const result = await next(e)
// Never let the pane's refresh disturb the tool call it watched.
try {
if (!open || busy || mode) return result
const viaCli = e.tool === 'Bash' && /\bpad\s+(item|project|collection)\b/.test(String(e.command || ''))
const viaMcp = /^mcp__.+__pad_(item|project|collection)$/i.test(String(e.tool || ''))
if (viaCli || viaMcp) load($).catch(() => {})
} catch {}
return result
})
}