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/LICENSE +22 -0
- package/README.md +179 -2
- package/README.zh-CN.md +166 -0
- package/bin/claude-p.js +47 -0
- package/js/cdp.js +247 -0
- package/js/host.js +131 -0
- package/js/package.json +1 -0
- package/js/plugins/add-dir.js +15 -0
- package/js/plugins/f12.js +296 -0
- package/js/plugins/herdry.js +52 -0
- package/js/plugins/img.js +35 -0
- package/js/plugins/magpie.js +14 -0
- package/js/plugins/notify.js +13 -0
- package/js/plugins/pace.js +75 -0
- package/js/plugins/panel.js +230 -0
- package/js/plugins/webpane.js +108 -0
- package/js/rpc.js +772 -0
- package/js/session.js +186 -0
- package/js/stubs.js +72 -0
- package/js/tab.js +94 -0
- package/package.json +55 -4
- package/scripts/install.js +133 -0
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))
|
package/js/package.json
ADDED
|
@@ -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
|
+
}
|