cld-pi 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/js/host.js ADDED
@@ -0,0 +1,131 @@
1
+ // node sidecar spawned by `claude-p` in interactive mode. Speaks JSON lines
2
+ // on stdio (see src/pi/sidecar.zig). stderr is discarded by the binary, so
3
+ // everything diagnostic goes to ~/claude/-p/host.log -- only while that file
4
+ // exists (touch it to turn logging on; claude-p removes it on exit).
5
+ import { spawn } from 'node:child_process'
6
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
7
+ import inspector from 'node:inspector'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import { pathToFileURL } from 'node:url'
11
+ import { createHost, lineSplitter } from './rpc.js'
12
+ import { commandStubs } from './stubs.js'
13
+ import { openTab, pickTab } from './tab.js'
14
+
15
+ const dataDir = process.env.CLAUDE_P_DIR || join(homedir(), 'claude', '-p')
16
+ mkdirSync(dataDir, { recursive: true })
17
+ const logFile = join(dataDir, 'host.log')
18
+ const log = (...a) => {
19
+ const line = a.map(x => (x instanceof Error ? x.stack : typeof x === 'string' ? x : JSON.stringify(x))).join(' ')
20
+ try {
21
+ if (existsSync(logFile)) appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`)
22
+ } catch {}
23
+ }
24
+
25
+ // Desktop notification; best effort (notify-send on Linux, osascript on macOS).
26
+ const notify = msg => {
27
+ const [cmd, args] =
28
+ process.platform === 'darwin'
29
+ ? ['osascript', ['-e', `display notification ${JSON.stringify(msg)} with title "Claude Code"`]]
30
+ : ['notify-send', ['-a', 'Claude Code', 'Claude Code', msg]]
31
+ const p = spawn(cmd, args, { stdio: 'ignore', detached: true })
32
+ p.on('error', e => log('notify failed', e.message))
33
+ p.unref()
34
+ }
35
+
36
+ // pi.sh: one shell line. Resolves at the shell's exit (plus a moment for
37
+ // output to drain) so `cmd &` doesn't hold it up.
38
+ const sh = line =>
39
+ new Promise(resolve => {
40
+ const p = spawn('/bin/sh', ['-c', line], { stdio: ['ignore', 'pipe', 'pipe'] })
41
+ let stdout = ''
42
+ let stderr = ''
43
+ p.stdout.on('data', d => (stdout += d))
44
+ p.stderr.on('data', d => (stderr += d))
45
+ p.on('error', e => resolve({ code: 127, stdout, stderr: String(e) }))
46
+ p.on('exit', code => setTimeout(() => resolve({ code: code ?? 1, stdout, stderr }), 50))
47
+ })
48
+
49
+ const stubs = commandStubs(process.argv[2] || process.env.CLAUDE_P_PLUGIN_DIR)
50
+ process.on('exit', stubs.cleanup)
51
+ // The binary closes our stdin on exit; signals take the shorter path (and
52
+ // still run the 'exit' cleanup above).
53
+ for (const sig of ['SIGTERM', 'SIGHUP', 'SIGINT']) process.on(sig, () => process.exit(0))
54
+ const send = msg => process.stdout.write(JSON.stringify(msg) + '\n')
55
+ const host = createHost(send, log, { notify, sh, tab: pickTab(), openTab, stubs })
56
+ const { pi, dispatch } = host
57
+ // Timers plugins create are charged to them (cleared when turned off).
58
+ host.installTimerHooks()
59
+
60
+ // Debugging is on by default: open chrome://inspect (F12) and poke the global
61
+ // `pi`, e.g. pi`hello`. Listens on 127.0.0.1 only; 9229 is what chrome://inspect
62
+ // discovers out of the box, a random port is the fallback when it's taken
63
+ // (another claude-p). CLAUDE_P_INSPECT=0 turns it off, =<port> picks one.
64
+ globalThis.pi = pi
65
+ const inspectEnv = process.env.CLAUDE_P_INSPECT
66
+ if (inspectEnv !== '0') {
67
+ const ports = inspectEnv ? [Number(inspectEnv)] : [9229, 0]
68
+ for (const port of ports) {
69
+ // A busy port doesn't throw: node prints to stderr and url() stays empty.
70
+ try {
71
+ inspector.open(port, '127.0.0.1')
72
+ } catch (e) {
73
+ log(`inspector: port ${port}`, e.message)
74
+ }
75
+ if (inspector.url()) break
76
+ log(`inspector: port ${port} unavailable`)
77
+ }
78
+ const url = inspector.url()
79
+ if (url) {
80
+ log('inspector', url)
81
+ try {
82
+ writeFileSync(join(dataDir, 'inspect'), url + '\n')
83
+ } catch {}
84
+ }
85
+ }
86
+
87
+ pi.on({
88
+ init: m => log('init', { cols: m.cols, rows: m.rows, pid: m.pid }),
89
+ sessionStart: p => log('sessionStart', p?.session_id),
90
+ })
91
+
92
+ // Default plugins shipped in js/plugins/, then the user's ~/claude/-p/*.js
93
+ // (listed as ~/name.js). Each can be turned off from the badge's list.
94
+ const loadDir = async (dir, prefix) => {
95
+ let files = []
96
+ try {
97
+ // .js relies on Node's ESM syntax detection (22.12+); .mjs works everywhere.
98
+ files = readdirSync(dir).filter(f => /\.m?js$/.test(f)).sort()
99
+ } catch {}
100
+ for (const f of files) {
101
+ try {
102
+ const mod = await import(pathToFileURL(join(dir, f)).href)
103
+ if (typeof mod.default !== 'function') throw new Error('no default export function')
104
+ await host.loadPlugin(prefix + f, mod.default)
105
+ log('plugin', prefix + f)
106
+ } catch (e) {
107
+ log(`plugin ${prefix + f} failed`, e)
108
+ }
109
+ }
110
+ }
111
+ await loadDir(join(import.meta.dirname, 'plugins'), '')
112
+ await loadDir(dataDir, '~/')
113
+ // The binary waits for this before spawning claude: the command stubs must
114
+ // be on disk by the time claude scans --plugin-dir.
115
+ send({ t: 'ready' })
116
+
117
+ process.stdin.setEncoding('utf8')
118
+ process.stdin.on(
119
+ 'data',
120
+ lineSplitter(line => {
121
+ try {
122
+ dispatch(JSON.parse(line))
123
+ } catch (e) {
124
+ log('bad line', line, e)
125
+ }
126
+ }),
127
+ )
128
+ // The binary closes our stdin on exit; follow it down.
129
+ process.stdin.on('end', () => process.exit(0))
130
+ process.on('uncaughtException', e => log('uncaught', e))
131
+ process.on('unhandledRejection', e => log('unhandled', e))
@@ -0,0 +1 @@
1
+ { "type": "module" }
@@ -0,0 +1,15 @@
1
+ // /ad <dir>: /add-dir it (answering CC's "Yes, for this session"), then
2
+ // show `tree -L 2` of it.
3
+ const confirm = 'Yes, for this session'
4
+
5
+ export default pi => {
6
+ pi.command('ad', async ([dir]) => {
7
+ if (!dir) return pi.dialog({ body: 'usage: /ad <dir>', timeout: 2500 })
8
+ await pi`/add-dir ${dir}`
9
+ if (await pi.tui.wait(confirm, 3000)) {
10
+ await pi.tui.press('enter')
11
+ await pi.tui.wait(s => !s.text.includes(confirm), 3000)
12
+ }
13
+ await pi`!tree -L 2 ${dir}`
14
+ })
15
+ }
@@ -0,0 +1,296 @@
1
+ // Chrome tabs (P5) + pi.ext.f12.
2
+ // `//` + Tab fzf over the open tabs (title — host); the pick is
3
+ // screenshotted to $TMPDIR and typed as `@/tmp/….png`
4
+ // plus `@/tmp/….f12.md`: its URL, what F12 shows
5
+ // (console, exceptions, network errors) and how to reach
6
+ // the same tab through the chrome-devtools MCP.
7
+ // `//gh/` + Tab URLs from the tabs and Chrome's history whose host
8
+ // matches `gh` (github.com), typed as-is.
9
+ // Every site picked either way goes into ~/.F12-mcp.json's allowedUrlPattern
10
+ // (pi.ext.f12.addTrust), the config of the chrome-devtools MCP that gives
11
+ // Claude the browser; the first `//` offers that MCP's install command.
12
+ // pi.ext.f12.devtools(urlOrTargetId, {reload, ms}) reads a tab's live F12
13
+ // (console, exceptions, network errors) for plugins and for `%%js` cells
14
+ // (goal-v1.ipynb) driven through scripts/cdp-eval.mjs.
15
+ // Chrome must have remote debugging allowed (chrome://inspect/#remote-debugging);
16
+ // CLAUDE_P_CDP=<port|url> points at another browser (agent tests: 9224).
17
+ import { existsSync } from 'node:fs'
18
+ import { readFile, writeFile } from 'node:fs/promises'
19
+ import { homedir, tmpdir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import * as cdpLib from '../cdp.js'
22
+
23
+ export const inspectUrl = 'chrome://inspect/#remote-debugging'
24
+ export const mcpInstall =
25
+ 'claude mcp add chrome-devtools --scope user -- npx -y chrome-devtools-mcp@latest \\\n --autoConnect --config ~/.F12-mcp.json --watchConfig'
26
+
27
+ /** `//gh/rest` → { site: 'gh', rest }; `//` or `//title` (tab mode) → null. */
28
+ export function parseToken(tok) {
29
+ const m = /^\/\/([^/\s]+)\/(.*)$/.exec(tok)
30
+ return m ? { site: m[1].toLowerCase(), rest: m[2] } : null
31
+ }
32
+
33
+ const bareHost = url => {
34
+ try {
35
+ return new URL(url).host.replace(/^www\./, '')
36
+ } catch {
37
+ return ''
38
+ }
39
+ }
40
+
41
+ /** `gh` names github.com: a prefix of the host, or its letters in order from the first. */
42
+ export function siteMatch(site, host) {
43
+ host = host.toLowerCase()
44
+ if (!site || !host) return false
45
+ if (host.startsWith(site)) return true
46
+ if (host[0] !== site[0]) return false
47
+ let i = 0
48
+ for (const ch of host.split('.')[0]) if (ch === site[i]) i++
49
+ return i === site.length
50
+ }
51
+
52
+ /** http(s) URLs on a matching site, deduplicated, in the given order. */
53
+ export function urlItems(site, urls) {
54
+ const seen = new Set()
55
+ return urls.filter(u => /^https?:/.test(u) && siteMatch(site, bareHost(u)) && !seen.has(u) && seen.add(u))
56
+ }
57
+
58
+ /** One fzf line per tab: `title — host` (unique, single line). */
59
+ export function tabItems(targets) {
60
+ const seen = new Map()
61
+ return targets.map(t => {
62
+ const base = `${(t.title || t.url).replace(/\s+/g, ' ').trim()} — ${bareHost(t.url) || t.url}`
63
+ const n = (seen.get(base) ?? 0) + 1
64
+ seen.set(base, n)
65
+ return { label: n > 1 ? `${base} (${n})` : base, target: t }
66
+ })
67
+ }
68
+
69
+ /** The tab a key names: its targetId, its URL exactly, or a URL prefix. */
70
+ export function tabByKey(targets, key) {
71
+ key = String(key ?? '')
72
+ if (!key) return null
73
+ return (
74
+ targets.find(t => t.targetId === key) ??
75
+ targets.find(t => t.url === key) ??
76
+ targets.find(t => t.url.startsWith(key)) ??
77
+ null
78
+ )
79
+ }
80
+
81
+ /** allowedUrlPattern for a page: its origin, any path. null for non-web URLs. */
82
+ export function trustPattern(url) {
83
+ try {
84
+ const u = new URL(url)
85
+ return /^https?:$/.test(u.protocol) ? `${u.protocol}//${u.host}/*` : null
86
+ } catch {
87
+ return null
88
+ }
89
+ }
90
+
91
+ /** Append `pattern` to the config's allowedUrlPattern (creating the file); true if it was new. */
92
+ export async function addTrust(file, pattern) {
93
+ let cfg = {}
94
+ try {
95
+ cfg = JSON.parse(await readFile(file, 'utf8'))
96
+ } catch (e) {
97
+ if (e.code !== 'ENOENT') throw new Error(`${file}: ${e.message}`)
98
+ }
99
+ const list = cfg.allowedUrlPattern ?? []
100
+ if (list.includes(pattern)) return false
101
+ cfg.allowedUrlPattern = [...list, pattern]
102
+ await writeFile(file, JSON.stringify(cfg, null, 2) + '\n')
103
+ return true
104
+ }
105
+
106
+ /**
107
+ * Drop every addTrust grant. The key goes entirely: the chrome-devtools MCP
108
+ * rejects an empty allowedUrlPattern, `{}` means "nothing trusted".
109
+ */
110
+ export async function clearTrust(file) {
111
+ let cfg
112
+ try {
113
+ cfg = JSON.parse(await readFile(file, 'utf8')) || {}
114
+ } catch (e) {
115
+ if (e.code === 'ENOENT') return false // nothing was trusted
116
+ throw new Error(`${file}: ${e.message}`)
117
+ }
118
+ if (!('allowedUrlPattern' in cfg)) return false
119
+ delete cfg.allowedUrlPattern
120
+ await writeFile(file, JSON.stringify(cfg, null, 2) + '\n')
121
+ return true
122
+ }
123
+
124
+ /** Whether ~/.claude.json has a chrome-devtools MCP server (user or any project scope). */
125
+ export function mcpInstalled(claudeJson) {
126
+ let cfg
127
+ try {
128
+ cfg = JSON.parse(claudeJson)
129
+ } catch {
130
+ return false
131
+ }
132
+ const scopes = [cfg.mcpServers, ...Object.values(cfg.projects ?? {}).map(p => p?.mcpServers)]
133
+ return scopes.some(s => s && 'chrome-devtools' in s)
134
+ }
135
+
136
+ /**
137
+ * The `.f12.md` handed to Claude with a tab's screenshot. `entries` from
138
+ * cdp.devtools (null: couldn't read them); the last `max` are kept.
139
+ */
140
+ export function f12Report({ title, url, png, entries, mcp, max = 60 }) {
141
+ const lines = [`# Chrome tab: ${title || url}`, '', `URL: ${url}`, `Screenshot: ${png}`, '']
142
+ lines.push(
143
+ mcp
144
+ ? 'This tab is open in the user\'s Chrome. For more than this snapshot (DOM, network, scripts, clicks) use the chrome-devtools MCP: list_pages, then select_page on this URL.'
145
+ : 'This tab is open in the user\'s Chrome. (The chrome-devtools MCP is not installed, so this snapshot is all there is.)',
146
+ '',
147
+ )
148
+ if (!entries) return [...lines, 'DevTools: could not read the console.', ''].join('\n')
149
+ const cut = s => (s.length > 400 ? `${s.slice(0, 400)}…` : s)
150
+ const kept = entries.slice(-max)
151
+ lines.push(`## DevTools console (${entries.length}${entries.length > kept.length ? `, last ${kept.length}` : ''})`, '')
152
+ if (!kept.length) lines.push('(empty)')
153
+ for (const e of kept) lines.push(`- [${e.kind === 'console' ? e.level : `${e.kind} ${e.level}`}] ${cut(e.text.replace(/\s*\n\s*/g, ' ⏎ '))}${e.at ? ` (${e.at})` : ''}`)
154
+ return [...lines, ''].join('\n')
155
+ }
156
+
157
+ const copy = (pi, text) => pi.sh`printf %s ${text} | (wl-copy || xclip -selection clipboard || pbcopy) 2>/dev/null`
158
+
159
+ /**
160
+ * The plugin, with its world injectable for tests: `home` (for
161
+ * ~/.F12-mcp.json and ~/.claude.json), `env`, and the CDP functions.
162
+ */
163
+ export const f12 = ({ home = homedir(), env = process.env, cdp = cdpLib, shots = tmpdir() } = {}) => pi => {
164
+ const mcpConfig = join(home, '.F12-mcp.json')
165
+ const trust = async pattern => {
166
+ try {
167
+ await addTrust(mcpConfig, pattern)
168
+ } catch (e) {
169
+ pi.dialog({ body: `allowedUrlPattern not updated: ${e.message}`, timeout: 4000 })
170
+ }
171
+ }
172
+ const ext = { addTrust: trust }
173
+ pi.ext.f12 = ext
174
+
175
+ // chrome-devtools-mcp --config fails when the file is missing. `{}`, not an
176
+ // empty allowedUrlPattern: the MCP rejects an empty list.
177
+ const installed = async () => mcpInstalled(await readFile(join(home, '.claude.json'), 'utf8').catch(() => ''))
178
+ const ensureConfig = async () => {
179
+ if (!existsSync(mcpConfig)) await writeFile(mcpConfig, '{}\n')
180
+ }
181
+ installed()
182
+ .then(yes => yes && ensureConfig())
183
+ .catch(() => {})
184
+ pi.tui.dispose(() => delete pi.ext.f12)
185
+
186
+ // One connection for the session: with chrome://inspect's switch, each
187
+ // new one may ask the user to Allow it.
188
+ let browser = null // Promise<{ cdp, dir } | null>
189
+ const open = async () => {
190
+ const b = await browser
191
+ if (b?.cdp && !b.cdp.closed) return b
192
+ browser = (async () => {
193
+ const at = await cdp.findBrowser({ env, home })
194
+ if (!at) return null
195
+ const allow = setTimeout(() => pi.dialog({ body: 'Chrome: click Allow to let cld-pi connect', timeout: 6000 }), 1500)
196
+ try {
197
+ return { cdp: await cdp.connect(at.url), dir: at.dir }
198
+ } catch {
199
+ return { cdp: null, dir: at.dir }
200
+ } finally {
201
+ clearTimeout(allow)
202
+ }
203
+ })()
204
+ return browser
205
+ }
206
+ pi.tui.dispose(() => browser?.then(b => b?.cdp?.close()))
207
+
208
+ const offline = () =>
209
+ pi.dialog({
210
+ title: 'Chrome remote debugging is off',
211
+ body: `Open ${inspectUrl} in Chrome,\nallow remote debugging, then Tab again.\nallowedUrlPattern needs Chrome 149+.`,
212
+ 'Copy URL': () => void copy(pi, inspectUrl),
213
+ OK: () => {},
214
+ })
215
+
216
+ /** Live F12 for a tab (`url`, URL prefix, or targetId); null if there is none. */
217
+ const devtools = async (target, { reload = false, ms = 300 } = {}) => {
218
+ const b = await open()
219
+ if (!b?.cdp) {
220
+ offline()
221
+ return null
222
+ }
223
+ const t = tabByKey(await cdp.tabs(b.cdp).catch(() => []), target)
224
+ if (!t) {
225
+ pi.dialog({ body: `No Chrome tab for ${target}`, timeout: 4000 })
226
+ return null
227
+ }
228
+ // A reload starts the console over, so the replay is the fresh page's.
229
+ if (reload) await cdp.reload(b.cdp, t.targetId).catch(() => {})
230
+ return cdp.devtools(b.cdp, t.targetId, ms)
231
+ }
232
+ ext.devtools = devtools
233
+
234
+ // First `//`: offer the chrome-devtools MCP (its --config file has to exist first).
235
+ const offerMcp = async () => {
236
+ const marker = join(pi.tui.state.init?.dataDir ?? join(home, 'claude', '-p'), 'f12-mcp-offered')
237
+ if (existsSync(marker)) return
238
+ await writeFile(marker, '')
239
+ await ensureConfig()
240
+ if (await installed()) return
241
+ pi.dialog({
242
+ title: 'Let Claude use this Chrome?',
243
+ body: `Install the chrome-devtools MCP (once, in a shell):\n\n${mcpInstall}\n\nSites you pick with // are added to ~/.F12-mcp.json.`,
244
+ Copy: () => void copy(pi, mcpInstall.replace('\\\n ', '')),
245
+ Later: () => {},
246
+ })
247
+ }
248
+
249
+ let shown = new Map() // fzf line → tab, from the last `//` Tab
250
+ pi.complete(
251
+ '//',
252
+ async tok => {
253
+ const url = parseToken(tok)
254
+ const b = await open()
255
+ if (url) {
256
+ const onTabs = b?.cdp ? (await cdp.tabs(b.cdp).catch(() => [])).map(t => t.url) : []
257
+ const visited = (await cdp.history(b?.dir)).map(h => h.url)
258
+ return urlItems(url.site, [...onTabs, ...visited])
259
+ }
260
+ if (!b?.cdp) {
261
+ offline()
262
+ return []
263
+ }
264
+ const items = tabItems(await cdp.tabs(b.cdp))
265
+ shown = new Map(items.map(i => [i.label, i.target]))
266
+ return items.map(i => i.label)
267
+ },
268
+ async (item, tok) => {
269
+ if (parseToken(tok)) {
270
+ const p = trustPattern(item)
271
+ if (p) await trust(p)
272
+ return item
273
+ }
274
+ const t = shown.get(item)
275
+ const b = await browser
276
+ if (!t || !b?.cdp) return null
277
+ let png
278
+ try {
279
+ png = await cdp.screenshot(b.cdp, t.targetId)
280
+ } catch (e) {
281
+ pi.dialog({ body: `screenshot failed: ${e.message}`, timeout: 4000 })
282
+ return null
283
+ }
284
+ const base = join(shots, `cld-pi-${bareHost(t.url).replace(/[^\w.-]/g, '_') || 'tab'}-${Date.now()}`)
285
+ await writeFile(`${base}.png`, png)
286
+ const entries = await cdp.devtools(b.cdp, t.targetId).catch(() => null)
287
+ await writeFile(`${base}.f12.md`, f12Report({ title: t.title, url: t.url, png: `${base}.png`, entries, mcp: await installed() }))
288
+ const p = trustPattern(t.url)
289
+ if (p) await trust(p)
290
+ offerMcp().catch(() => {})
291
+ return `@${base}.png @${base}.f12.md`
292
+ },
293
+ )
294
+ }
295
+
296
+ export default f12()
@@ -0,0 +1,52 @@
1
+ // herdry: when Claude finishes or needs you (the same Stop / Notification
2
+ // hooks notify.js listens to), spin the tab title the way CC spins its
3
+ // status line — "✻ <message>" — until the terminal gets focus.
4
+ // pi.tui.title sets the tab (D-Bus for Yakuake / Konsole / Guake, the
5
+ // window title elsewhere) and puts the old title back.
6
+
7
+ const base = [...'·✢*✶✻✽']
8
+ const frames = [...base, ...base.slice(1, -1).reverse()] // ·✢*✶✻✽✻✶*✢, CC's cycle
9
+ const frameMs = 150
10
+ const giveUpMs = 30 * 60 * 1000 // no focus reports at all? stop eventually
11
+ const firstLine = s => (s ?? '').trim().split('\n')[0].slice(0, 120)
12
+
13
+ export function spinner(pi, { every = frameMs } = {}) {
14
+ const { tui } = pi
15
+ let timer = null
16
+ let text = ''
17
+ let frame = 0
18
+ let started = 0
19
+ let busy = false
20
+
21
+ const stop = async () => {
22
+ if (!timer) return
23
+ clearInterval(timer)
24
+ timer = null
25
+ await tui.title(null)
26
+ }
27
+
28
+ const spin = msg => {
29
+ if (tui.state.focused === true) return
30
+ text = msg
31
+ if (timer) return // already spinning: just show the newer message
32
+ started = Date.now()
33
+ timer = setInterval(async () => {
34
+ if (Date.now() - started > giveUpMs) return stop()
35
+ if (busy) return // a slow D-Bus call: skip a frame rather than pile up
36
+ busy = true
37
+ await tui.title(`${frames[frame++ % frames.length]} ${text}`)
38
+ busy = false
39
+ }, every)
40
+ }
41
+
42
+ pi.on({
43
+ stop: p => spin(firstLine(p?.last_assistant_message) || 'Claude is done'),
44
+ notification: p => spin(p?.message || 'Claude needs your attention'),
45
+ focus: focused => {
46
+ if (focused) stop()
47
+ },
48
+ })
49
+ tui.dispose(stop)
50
+ }
51
+
52
+ export default pi => spinner(pi)
@@ -0,0 +1,35 @@
1
+ // img: click a screen row that names a .png / .webp file and look at it.
2
+ // pi.click gives the row the click landed on (rows above CC's input box);
3
+ // the file opens in imv, or xdg-open's image viewer where imv is missing.
4
+ import { existsSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { isAbsolute, join, resolve } from 'node:path'
7
+
8
+ /** Path-like tokens on one grid row that end in .png / .webp. */
9
+ export function imagePaths(row) {
10
+ const out = []
11
+ const re = /(?:^|[\s"'`(\[@])((?:~|\.{1,2})?[^\s"'`()\[\]<>|]*\.(?:png|webp))(?=$|[\s"'`)\].,;:])/gi
12
+ for (const m of String(row ?? '').matchAll(re)) {
13
+ const tok = m[1].replace(/^@/, '') // f12 types `@/tmp/shot.png`
14
+ if (tok && !out.includes(tok)) out.push(tok)
15
+ }
16
+ return out
17
+ }
18
+
19
+ /** `~` and relative paths → the path the sidecar can open. */
20
+ export const imagePath = (tok, cwd = process.cwd()) => {
21
+ if (tok === '~' || tok.startsWith('~/')) return join(homedir(), tok.slice(1))
22
+ return isAbsolute(tok) ? tok : resolve(cwd, tok)
23
+ }
24
+
25
+ export default pi => {
26
+ pi.click(row => {
27
+ const line = pi.tui.state.scr?.rows?.[row]
28
+ for (const tok of imagePaths(line)) {
29
+ const file = imagePath(tok)
30
+ if (!existsSync(file)) continue
31
+ pi.sh`if command -v imv >/dev/null 2>&1; then imv ${file} >/dev/null 2>&1; else xdg-open ${file} >/dev/null 2>&1; fi &`
32
+ return
33
+ }
34
+ })
35
+ }
@@ -0,0 +1,14 @@
1
+ // /m: open the magpie panel; offer the install command when it's missing.
2
+ const install = 'curl -fsSL https://usemagpie.ai/install.sh | sh'
3
+
4
+ export default pi => {
5
+ pi.command('m', async () => {
6
+ const bin = `${process.env.HOME}/.local/bin/magpie`
7
+ if ((await pi.sh`test -x ${bin}`).code === 0) return pi.sh`${bin} panel >/dev/null 2>&1 &`
8
+ await pi.dialog({
9
+ title: 'magpie is not installed',
10
+ body: `OK copies the installer:\n${install}`,
11
+ OK: () => pi.sh`printf %s ${install} | (wl-copy || xclip -selection clipboard || pbcopy) 2>/dev/null`.then(() => {}),
12
+ })
13
+ })
14
+ }
@@ -0,0 +1,13 @@
1
+ // Desktop notification when Claude finishes a turn (Stop) or needs you
2
+ // (Notification) — unless the terminal is known to have focus.
3
+ const firstLine = s => (s ?? '').trim().split('\n')[0].slice(0, 120)
4
+
5
+ export default pi => {
6
+ const ping = msg => {
7
+ if (pi.tui.state.focused !== true) pi.notify(msg)
8
+ }
9
+ pi.on({
10
+ stop: p => ping(firstLine(p?.last_assistant_message) || 'Claude is done'),
11
+ notification: p => ping(p?.message || 'Claude needs your attention'),
12
+ })
13
+ }
@@ -0,0 +1,75 @@
1
+ // Bottom-right snack: how long the last ● took (one assistant content
2
+ // block; ticking while the next one is on its way) and what the last 3
3
+ // minutes cost. Goes see-through while the wheel is scrolling (back 0.5 s
4
+ // after the last notch). Follows the transcript (pi.tui.state.transcript) a few new
5
+ // lines at a time; costs come from session.js like the Ctrl+P panel's.
6
+ import { messageCost, tailEntries } from '../session.js'
7
+
8
+ const windowMs = 3 * 60_000
9
+
10
+ export const duration = ms => {
11
+ const s = ms / 1000
12
+ return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, '0')}s`
13
+ }
14
+
15
+ /** The numbers, fed transcript entries; `view(now)` is the snack text. */
16
+ export function createPace() {
17
+ const seen = new Set()
18
+ const costs = [] // [ts, $]
19
+ let prev = null // ts of the latest user / assistant entry
20
+ let last = null // ms the latest ● took
21
+ let running = false
22
+ return {
23
+ add(d) {
24
+ const ts = Date.parse(d.timestamp)
25
+ if ((d.type !== 'user' && d.type !== 'assistant') || Number.isNaN(ts)) return
26
+ if (d.type === 'assistant' && prev != null) last = ts - prev
27
+ const c = messageCost(d, seen)
28
+ if (c) costs.push([ts, c])
29
+ prev = ts
30
+ running = true
31
+ },
32
+ /** The turn ended (Stop): stop ticking. */
33
+ stop() {
34
+ running = false
35
+ },
36
+ view(now) {
37
+ while (costs.length && costs[0][0] < now - windowMs) costs.shift()
38
+ const spent = costs.reduce((sum, [, c]) => sum + c, 0)
39
+ const live = running && prev != null ? now - prev : null
40
+ const took = live ?? last
41
+ if (took == null && !costs.length) return null
42
+ return `● ${took == null ? '–' : duration(took)}${live != null ? '…' : ''} · 3m $${spent.toFixed(2)}`
43
+ },
44
+ }
45
+ }
46
+
47
+ export default pi => {
48
+ const tail = tailEntries()
49
+ let pace = createPace()
50
+ let path = null
51
+ let scrolling = null // timer until the wheel counts as settled
52
+ const tick = () => {
53
+ const p = pi.tui.state.transcript
54
+ const fresh = p !== path
55
+ if (fresh) [path, pace] = [p, createPace()]
56
+ for (const d of tail(p)) pace.add(d)
57
+ if (fresh) pace.stop() // history (a resumed session), not a running turn
58
+ const text = pace.view(Date.now())
59
+ pi.notify(text ?? '', 'snackRD', scrolling ? { bg: 'none' } : {})
60
+ }
61
+ pi.on({
62
+ wheel: () => {
63
+ const was = scrolling
64
+ clearTimeout(scrolling)
65
+ scrolling = setTimeout(() => ((scrolling = null), tick()), 500)
66
+ if (!was) tick()
67
+ },
68
+ stop: () => {
69
+ tick()
70
+ pace.stop()
71
+ tick()
72
+ },
73
+ })
74
+ setInterval(tick, 1000)
75
+ }