pi-code 0.1.0 → 0.2.0
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/README.md +24 -7
- package/extensions/claude-rules.ts +7 -3
- package/extensions/context-imports.ts +56 -29
- package/extensions/git-checkpoint.ts +16 -16
- package/extensions/hooks.ts +2 -1
- package/extensions/mcp.ts +24 -10
- package/extensions/memory.ts +15 -4
- package/extensions/notify.ts +6 -4
- package/extensions/output-styles.ts +1 -1
- package/extensions/plan-mode/index.ts +83 -58
- package/extensions/plan-mode/utils.ts +76 -16
- package/extensions/project-trust.ts +65 -0
- package/extensions/question.ts +63 -49
- package/extensions/subagent/agents.ts +12 -11
- package/extensions/subagent/index.ts +609 -465
- package/extensions/todo.ts +48 -31
- package/extensions/web.ts +49 -27
- package/package.json +1 -1
package/extensions/todo.ts
CHANGED
|
@@ -134,7 +134,7 @@ class TodoOverlay {
|
|
|
134
134
|
private uiCtx?: ExtensionUIContext
|
|
135
135
|
private widgetRegistered = false
|
|
136
136
|
private tui?: TUI
|
|
137
|
-
private getTodos: () => Todo[]
|
|
137
|
+
private readonly getTodos: () => Todo[]
|
|
138
138
|
|
|
139
139
|
constructor(getTodos: () => Todo[]) {
|
|
140
140
|
this.getTodos = getTodos
|
|
@@ -225,9 +225,9 @@ class TodoOverlay {
|
|
|
225
225
|
* UI component for the /todos command
|
|
226
226
|
*/
|
|
227
227
|
class TodoListComponent {
|
|
228
|
-
private todos: Todo[]
|
|
229
|
-
private theme: Theme
|
|
230
|
-
private onClose: () => void
|
|
228
|
+
private readonly todos: Todo[]
|
|
229
|
+
private readonly theme: Theme
|
|
230
|
+
private readonly onClose: () => void
|
|
231
231
|
private cachedWidth?: number
|
|
232
232
|
private cachedLines?: string[]
|
|
233
233
|
|
|
@@ -254,15 +254,14 @@ class TodoListComponent {
|
|
|
254
254
|
lines.push('')
|
|
255
255
|
const title = th.fg('accent', ' Todos ')
|
|
256
256
|
const headerLine = th.fg('borderMuted', '─'.repeat(3)) + title + th.fg('borderMuted', '─'.repeat(Math.max(0, width - 10)))
|
|
257
|
-
lines.push(truncateToWidth(headerLine, width))
|
|
258
|
-
lines.push('')
|
|
257
|
+
lines.push(truncateToWidth(headerLine, width), '')
|
|
259
258
|
|
|
260
259
|
if (this.todos.length === 0) {
|
|
261
260
|
lines.push(truncateToWidth(` ${th.fg('dim', 'No todos yet. Ask the agent to add some!')}`, width))
|
|
262
261
|
} else {
|
|
263
262
|
const completed = this.todos.filter((t) => t.status === 'completed').length
|
|
264
|
-
|
|
265
|
-
lines.push('')
|
|
263
|
+
const completedLabel = `${completed}/${this.todos.length} completed`
|
|
264
|
+
lines.push(truncateToWidth(` ${th.fg('muted', completedLabel)}`, width), '')
|
|
266
265
|
|
|
267
266
|
for (const todo of this.todos) {
|
|
268
267
|
const glyph = statusGlyph(todo.status, th)
|
|
@@ -270,15 +269,14 @@ class TodoListComponent {
|
|
|
270
269
|
const text = todo.status === 'completed' ? th.fg('dim', todo.text) : th.fg('text', todo.text)
|
|
271
270
|
let line = ` ${glyph} ${id} ${text}`
|
|
272
271
|
if (todo.status === 'in_progress' && todo.activeForm) {
|
|
273
|
-
|
|
272
|
+
const activeFormLabel = `(${todo.activeForm})`
|
|
273
|
+
line += ` ${th.fg('dim', activeFormLabel)}`
|
|
274
274
|
}
|
|
275
275
|
lines.push(truncateToWidth(line, width))
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
lines.push('')
|
|
280
|
-
lines.push(truncateToWidth(` ${th.fg('dim', 'Press Escape to close')}`, width))
|
|
281
|
-
lines.push('')
|
|
279
|
+
lines.push('', truncateToWidth(` ${th.fg('dim', 'Press Escape to close')}`, width), '')
|
|
282
280
|
|
|
283
281
|
this.cachedWidth = width
|
|
284
282
|
this.cachedLines = lines
|
|
@@ -296,7 +294,27 @@ class TodoListComponent {
|
|
|
296
294
|
// genuine replay bugs still propagate instead of being silently swallowed.
|
|
297
295
|
const isStaleCtxError = (e: unknown): boolean => /stale after session replacement/.test(String(e))
|
|
298
296
|
|
|
299
|
-
|
|
297
|
+
const LIST_RESULT_PREVIEW = 5
|
|
298
|
+
|
|
299
|
+
const renderTodoListResult = (todoList: Todo[], expanded: boolean, theme: Theme): Text => {
|
|
300
|
+
if (todoList.length === 0) {
|
|
301
|
+
return new Text(theme.fg('dim', 'No todos'), 0, 0)
|
|
302
|
+
}
|
|
303
|
+
let listText = theme.fg('muted', `${todoList.length} todo(s):`)
|
|
304
|
+
const display = expanded ? todoList : todoList.slice(0, LIST_RESULT_PREVIEW)
|
|
305
|
+
for (const t of display) {
|
|
306
|
+
const idLabel = `#${t.id}`
|
|
307
|
+
const itemText = t.status === 'completed' ? theme.fg('dim', t.text) : theme.fg('muted', t.text)
|
|
308
|
+
listText += `\n${statusGlyph(t.status, theme)} ${theme.fg('accent', idLabel)} ${itemText}`
|
|
309
|
+
}
|
|
310
|
+
if (!expanded && todoList.length > LIST_RESULT_PREVIEW) {
|
|
311
|
+
const moreLabel = `... ${todoList.length - LIST_RESULT_PREVIEW} more`
|
|
312
|
+
listText += `\n${theme.fg('dim', moreLabel)}`
|
|
313
|
+
}
|
|
314
|
+
return new Text(listText, 0, 0)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export default function todoExtension(pi: ExtensionAPI) {
|
|
300
318
|
// In-memory state (reconstructed from session on load)
|
|
301
319
|
let todos: Todo[] = []
|
|
302
320
|
let nextId = 1
|
|
@@ -382,7 +400,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
382
400
|
todo.status = 'in_progress'
|
|
383
401
|
if (params.activeForm) todo.activeForm = params.activeForm
|
|
384
402
|
let text = `Started #${todo.id}: ${todo.text}`
|
|
385
|
-
if (demoted.length > 0)
|
|
403
|
+
if (demoted.length > 0) {
|
|
404
|
+
const movedIds = demoted.map((t) => `#${t.id}`).join(', ')
|
|
405
|
+
text += ` (moved ${movedIds} back to pending)`
|
|
406
|
+
}
|
|
386
407
|
return ok('start', text)
|
|
387
408
|
}
|
|
388
409
|
|
|
@@ -446,9 +467,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
446
467
|
|
|
447
468
|
renderCall(args, theme, _context) {
|
|
448
469
|
let text = theme.fg('toolTitle', theme.bold('todo ')) + theme.fg('muted', args.action)
|
|
449
|
-
if (args.id !== undefined)
|
|
450
|
-
|
|
451
|
-
|
|
470
|
+
if (args.id !== undefined) {
|
|
471
|
+
const idLabel = `#${args.id}`
|
|
472
|
+
text += ` ${theme.fg('accent', idLabel)}`
|
|
473
|
+
}
|
|
474
|
+
if (args.text) {
|
|
475
|
+
const textLabel = `"${args.text}"`
|
|
476
|
+
text += ` ${theme.fg('dim', textLabel)}`
|
|
477
|
+
}
|
|
478
|
+
if (args.activeForm) {
|
|
479
|
+
const activeFormLabel = `(${args.activeForm})`
|
|
480
|
+
text += ` ${theme.fg('dim', activeFormLabel)}`
|
|
481
|
+
}
|
|
452
482
|
return new Text(text, 0, 0)
|
|
453
483
|
},
|
|
454
484
|
|
|
@@ -464,20 +494,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
464
494
|
}
|
|
465
495
|
|
|
466
496
|
if (details.action === 'list') {
|
|
467
|
-
|
|
468
|
-
if (todoList.length === 0) {
|
|
469
|
-
return new Text(theme.fg('dim', 'No todos'), 0, 0)
|
|
470
|
-
}
|
|
471
|
-
let listText = theme.fg('muted', `${todoList.length} todo(s):`)
|
|
472
|
-
const display = expanded ? todoList : todoList.slice(0, 5)
|
|
473
|
-
for (const t of display) {
|
|
474
|
-
const itemText = t.status === 'completed' ? theme.fg('dim', t.text) : theme.fg('muted', t.text)
|
|
475
|
-
listText += `\n${statusGlyph(t.status, theme)} ${theme.fg('accent', `#${t.id}`)} ${itemText}`
|
|
476
|
-
}
|
|
477
|
-
if (!expanded && todoList.length > 5) {
|
|
478
|
-
listText += `\n${theme.fg('dim', `... ${todoList.length - 5} more`)}`
|
|
479
|
-
}
|
|
480
|
-
return new Text(listText, 0, 0)
|
|
497
|
+
return renderTodoListResult(details.todos, expanded, theme)
|
|
481
498
|
}
|
|
482
499
|
|
|
483
500
|
const text = result.content[0]
|
package/extensions/web.ts
CHANGED
|
@@ -32,14 +32,16 @@ export function decodeEntities(text: string): string {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function stripTags(html: string): string {
|
|
35
|
-
|
|
35
|
+
// [^<>] rather than [^>]: excluding `<` bounds a failed match at the next tag start
|
|
36
|
+
// instead of rescanning to end of input, which is what makes the strip linear.
|
|
37
|
+
return decodeEntities(html.replace(/<[^<>]*>/g, ''))
|
|
36
38
|
.replace(/\s+/g, ' ')
|
|
37
39
|
.trim()
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/** Resolve DuckDuckGo's redirect links (/l/?uddg=<encoded>) to the target URL. */
|
|
41
43
|
export function resolveResultUrl(href: string): string {
|
|
42
|
-
const match =
|
|
44
|
+
const match = /[?&]uddg=([^&]+)/.exec(href)
|
|
43
45
|
if (match) {
|
|
44
46
|
try {
|
|
45
47
|
return decodeURIComponent(match[1])
|
|
@@ -72,35 +74,38 @@ export function htmlToText(html: string): string {
|
|
|
72
74
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
73
75
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
74
76
|
.replace(/<(br|\/p|\/div|\/h[1-6]|\/li|\/tr)[^>]*>/gi, '\n')
|
|
75
|
-
const text = decodeEntities(withoutBlocks.replace(/<[
|
|
77
|
+
const text = decodeEntities(withoutBlocks.replace(/<[^<>]*>/g, ' '))
|
|
76
78
|
.replace(/[ \t]+/g, ' ')
|
|
77
79
|
.replace(/\n\s+/g, '\n')
|
|
78
80
|
.trim()
|
|
79
81
|
return text.length > MAX_FETCH_CHARS ? `${text.slice(0, MAX_FETCH_CHARS)}\n[truncated ${text.length - MAX_FETCH_CHARS} chars]` : text
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
84
|
+
/**
|
|
85
|
+
* IPv4-mapped (::ffff:...) and NAT64 (64:ff9b::...) forms embed an IPv4 in the low 32 bits,
|
|
86
|
+
* in either dotted-decimal or hex; the WHATWG URL parser normalizes literals to the hex form.
|
|
87
|
+
*/
|
|
88
|
+
function embeddedIpv4(addr: string): string | null {
|
|
89
|
+
const dotted = /^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/.exec(addr)
|
|
90
|
+
if (dotted) return dotted[1]
|
|
91
|
+
const hex = /^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(addr)
|
|
92
|
+
if (!hex) return null
|
|
93
|
+
const hi = Number.parseInt(hex[1], 16)
|
|
94
|
+
const lo = Number.parseInt(hex[2], 16)
|
|
95
|
+
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isPrivateIpv6(addr: string): boolean {
|
|
99
|
+
const embedded = embeddedIpv4(addr)
|
|
100
|
+
if (embedded) return isPrivateAddress(embedded)
|
|
101
|
+
if (addr === '::1' || addr === '::') return true
|
|
102
|
+
if (/^fe[89ab]/.test(addr)) return true // link-local fe80::/10
|
|
103
|
+
if (/^fe[cdef]/.test(addr)) return true // site-local fec0::/10, deprecated but still routed
|
|
104
|
+
if (addr.startsWith('ff')) return true // multicast ff00::/8
|
|
105
|
+
return /^f[cd]/.test(addr) // unique local fc00::/7
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isPrivateIpv4(addr: string): boolean {
|
|
104
109
|
const parts = addr.split('.').map(Number)
|
|
105
110
|
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) return true
|
|
106
111
|
const [a, b] = parts
|
|
@@ -108,13 +113,27 @@ export function isPrivateAddress(ip: string): boolean {
|
|
|
108
113
|
if (a === 169 && b === 254) return true
|
|
109
114
|
if (a === 172 && b >= 16 && b <= 31) return true
|
|
110
115
|
if (a === 192 && b === 168) return true
|
|
111
|
-
if (a ===
|
|
112
|
-
return
|
|
116
|
+
if (a === 192 && b === 0 && parts[2] === 0) return true // protocol assignments 192.0.0/24
|
|
117
|
+
if (a === 198 && (b === 18 || b === 19)) return true // benchmarking 198.18/15
|
|
118
|
+
if (a >= 224) return true // multicast 224/4, reserved 240/4, broadcast 255.255.255.255
|
|
119
|
+
return a === 100 && b >= 64 && b <= 127
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** SSRF guard: true for loopback, RFC1918, link-local, CGNAT, and private IPv6 ranges. Fails closed on unparseable input. */
|
|
123
|
+
export function isPrivateAddress(ip: string): boolean {
|
|
124
|
+
const addr = ip
|
|
125
|
+
.toLowerCase()
|
|
126
|
+
.replace(/^\[|\]$/g, '')
|
|
127
|
+
.split('%')[0]
|
|
128
|
+
return addr.includes(':') ? isPrivateIpv6(addr) : isPrivateIpv4(addr)
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
async function assertPublicHost(url: URL): Promise<void> {
|
|
116
132
|
const host = url.hostname.replace(/^\[|\]$/g, '')
|
|
117
133
|
const addresses = await lookup(host, { all: true, verbatim: true })
|
|
134
|
+
// An empty list would leave nothing for the loop to reject, so the guard would pass
|
|
135
|
+
// vacuously. Schemes without a host (data:, file:) reach here the same way.
|
|
136
|
+
if (addresses.length === 0) throw new Error(`${url.hostname || url.protocol} did not resolve to any address`)
|
|
118
137
|
for (const { address } of addresses) {
|
|
119
138
|
if (isPrivateAddress(address)) throw new Error(`refusing to fetch private/internal address for ${url.hostname} (${address})`)
|
|
120
139
|
}
|
|
@@ -150,6 +169,9 @@ async function fetchText(rawUrl: string): Promise<{ text: string; contentType: s
|
|
|
150
169
|
const location = response.headers.get('location')
|
|
151
170
|
if (!location) throw new Error(`redirect without location from ${url.hostname}`)
|
|
152
171
|
url = new URL(location, url)
|
|
172
|
+
// Only the caller's URL was scheme-checked; a redirect could hand back data: or
|
|
173
|
+
// file:, which carry no host for the address guard to inspect.
|
|
174
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error(`unsupported redirect scheme ${url.protocol} from ${rawUrl}`)
|
|
153
175
|
continue
|
|
154
176
|
}
|
|
155
177
|
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|