pi-code 1.0.75 → 1.0.77
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 +1 -1
- package/extensions/commands.ts +5 -2
- package/extensions/env-settings.ts +14 -2
- package/extensions/git-checkpoint.ts +17 -1
- package/extensions/goal.ts +2 -1
- package/extensions/hooks/index.ts +2 -1
- package/extensions/hooks/matcher.ts +8 -2
- package/extensions/internal/html-markdown.ts +112 -17
- package/extensions/internal/managed-settings.ts +20 -3
- package/extensions/internal/plugins.ts +16 -12
- package/extensions/internal/tool-target.ts +4 -2
- package/extensions/mcp/policy.ts +14 -2
- package/extensions/memory.ts +12 -5
- package/extensions/notify.ts +34 -11
- package/extensions/plan-mode/index.ts +2 -1
- package/extensions/session-title.ts +10 -4
- package/extensions/subagent/index.ts +6 -5
- package/extensions/thinking.ts +2 -1
- package/extensions/web.ts +35 -8
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ What a repository ships is treated as untrusted until you approve it: project MC
|
|
|
19
19
|
|
|
20
20
|
## Requirements
|
|
21
21
|
|
|
22
|
-
pi `>=0.
|
|
22
|
+
pi `>=0.80.4` (0.84.x recommended) and Node `>=22.19` for current pi.
|
|
23
23
|
|
|
24
24
|
## Install
|
|
25
25
|
|
package/extensions/commands.ts
CHANGED
|
@@ -413,7 +413,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
413
413
|
const rules = pendingPathRules?.[event.toolName as PathRuleTool]
|
|
414
414
|
if (!rules) return
|
|
415
415
|
// pi's read/edit/write accept `file_path` as an alias for `path`; the shared reader
|
|
416
|
-
// handles both
|
|
416
|
+
// handles both and normalises the value as pi resolves it. The paired guard in
|
|
417
|
+
// hooks/matcher.ts judges the same paths against the same rules, so it normalises
|
|
418
|
+
// through the same helper rather than only aliasing the two keys.
|
|
417
419
|
const filePath = fileToolTarget(event) ?? ''
|
|
418
420
|
const anchors = { cwd: ctx.cwd, projectRoot: checkoutRoot(ctx.cwd), home: os.homedir() }
|
|
419
421
|
if (filePath && matchesPathRules(filePath, rules, anchors)) return
|
|
@@ -554,7 +556,8 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
554
556
|
}
|
|
555
557
|
|
|
556
558
|
pi.on('session_start', async (_event, ctx) => {
|
|
557
|
-
//
|
|
559
|
+
// pi's CLI builds a fresh extension instance per session replacement; only RPC mode can
|
|
560
|
+
// reuse one across sessions. A mid-turn /new there fires session_start on
|
|
558
561
|
// the same instance while a command's per-run scoping is still pending (its agent_settled
|
|
559
562
|
// never came). Carrying that into the next session would restore an unrelated tool set,
|
|
560
563
|
// bash/path scope, model, or effort onto it, so drop the pending state here. Drop only:
|
|
@@ -103,7 +103,8 @@ function userEnv(home: string): Record<string, string> {
|
|
|
103
103
|
* documented drop list: variables that choose where config and files are written
|
|
104
104
|
* (redirecting later home-scope reads and every subprocess), variables that export
|
|
105
105
|
* session content, and variables that change how the agent starts or syncs.
|
|
106
|
-
* PI_CODING_AGENT_DIR is pi's own config-dir analogue of CLAUDE_CONFIG_DIR
|
|
106
|
+
* PI_CODING_AGENT_DIR is pi's own config-dir analogue of CLAUDE_CONFIG_DIR, and the
|
|
107
|
+
* PI_CODE_ prefix covers pi-code's own control variables (see isRepoHostileEnvKey). */
|
|
107
108
|
const REPO_HOSTILE_ENV_KEYS = new Set([
|
|
108
109
|
'CLAUDE_CONFIG_DIR',
|
|
109
110
|
'CLAUDE_CODE_TMPDIR',
|
|
@@ -122,13 +123,24 @@ const REPO_HOSTILE_ENV_KEYS = new Set([
|
|
|
122
123
|
'PI_CODING_AGENT_DIR',
|
|
123
124
|
])
|
|
124
125
|
|
|
126
|
+
/** Whether a repository's settings must not set this key: Claude's documented drop list,
|
|
127
|
+
* the XDG_ family, and pi-code's own PI_CODE_ control variables. The last matter because
|
|
128
|
+
* they are read as instructions rather than data: PI_CODE_SUBAGENT makes a session believe
|
|
129
|
+
* it is a subagent child, which suppresses the USER's own SessionStart, UserPromptSubmit,
|
|
130
|
+
* Stop and SessionEnd hooks and their auto memory, and PI_CODE_AGENT_HOOKS is then parsed
|
|
131
|
+
* into hook definitions, which are shell commands. Approving a repository means running the
|
|
132
|
+
* config it ships, never silently disabling the user's own guardrails. */
|
|
133
|
+
function isRepoHostileEnvKey(key: string): boolean {
|
|
134
|
+
return REPO_HOSTILE_ENV_KEYS.has(key) || key.startsWith('XDG_') || key.startsWith('PI_CODE_')
|
|
135
|
+
}
|
|
136
|
+
|
|
125
137
|
/** Drop the keys a repository's settings must not set, warning each, as Claude
|
|
126
138
|
* documents ("Claude Code drops each one and logs a warning"). Set them in the
|
|
127
139
|
* shell, user settings, or managed settings instead. */
|
|
128
140
|
export function sanitizeProjectEnv(env: Record<string, string>, warn: (key: string) => void = (key) => console.warn(`pi-code-env: dropping ${key} from project settings env (a checked-out repository must not control it; set it in user or managed settings)`)): Record<string, string> {
|
|
129
141
|
const kept: Record<string, string> = {}
|
|
130
142
|
for (const [key, value] of Object.entries(env)) {
|
|
131
|
-
if (
|
|
143
|
+
if (isRepoHostileEnvKey(key)) warn(key)
|
|
132
144
|
else kept[key] = value
|
|
133
145
|
}
|
|
134
146
|
return kept
|
|
@@ -172,6 +172,9 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
172
172
|
let promptedRun = false
|
|
173
173
|
let shadowDir: string | undefined
|
|
174
174
|
let workTree: string | undefined
|
|
175
|
+
// Set in ensureShadow: whether the live session has no session file (--no-session),
|
|
176
|
+
// whose shadow repo session_shutdown then knows is safe to remove on a real quit.
|
|
177
|
+
let ephemeralShadow = false
|
|
175
178
|
// Absolute paths this session's edit tools targeted: the whole of what a checkpoint
|
|
176
179
|
// captures. Seeded on resume from the last commit, so a resumed session keeps
|
|
177
180
|
// snapshotting the files it was already tracking.
|
|
@@ -191,6 +194,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
191
194
|
async function ensureShadow(ctx: ExtensionContext): Promise<void> {
|
|
192
195
|
workTree = ctx.cwd
|
|
193
196
|
const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined }).getSessionFile?.()
|
|
197
|
+
ephemeralShadow = sessionFile === undefined
|
|
194
198
|
const checkpointsRoot = path.join(getAgentDir(), 'checkpoints')
|
|
195
199
|
shadowDir = path.join(checkpointsRoot, sessionSlug(sessionFile))
|
|
196
200
|
// A resumed session can arrive from a different directory than the one the shadow
|
|
@@ -454,7 +458,8 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
454
458
|
}
|
|
455
459
|
|
|
456
460
|
pi.on('session_start', async (event, ctx) => {
|
|
457
|
-
//
|
|
461
|
+
// pi's CLI builds a fresh extension instance per session replacement; only RPC mode can
|
|
462
|
+
// reuse one across sessions. A mid-turn /new there fires session_start on
|
|
458
463
|
// the same instance after turn_start took the pre-run snapshot but before turn_end saved
|
|
459
464
|
// it; that pending ref belongs to the previous session and must not attach to the next
|
|
460
465
|
// session's first turn_end. Re-arm runNeedsSnapshot too, so the next run snapshots its
|
|
@@ -480,6 +485,17 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
480
485
|
for (const checkpoint of capCheckpoints(stored)) checkpoints.set(checkpoint.entryId, checkpoint)
|
|
481
486
|
})
|
|
482
487
|
|
|
488
|
+
// A --no-session run has no session file, so nothing can ever resume it or run
|
|
489
|
+
// /rewind from it again once the process exits: its shadow repo, left in place, was
|
|
490
|
+
// pure waste for the 30 days until the retention sweep reached it. Only a genuine
|
|
491
|
+
// quit removes it eagerly; 'new', 'resume' and 'reload' keep the process (and this
|
|
492
|
+
// extension instance) alive, and 'fork' can write a session from the live run's
|
|
493
|
+
// in-memory entries and then fetch refs from exactly this shadow at its own
|
|
494
|
+
// session_start, so this one case is left for the retention sweep as before.
|
|
495
|
+
pi.on('session_shutdown', async (event) => {
|
|
496
|
+
if (ephemeralShadow && shadowDir && event.reason === 'quit') fs.rmSync(shadowDir, { recursive: true, force: true })
|
|
497
|
+
})
|
|
498
|
+
|
|
483
499
|
// A new agent loop starts a run: the next turn_start snapshots the pre-run tree.
|
|
484
500
|
// agent_start, not before_agent_start: before_agent_start does not fire for a queued
|
|
485
501
|
// follow-up message delivered through agent.continue, so gating on it would leave that
|
package/extensions/goal.ts
CHANGED
|
@@ -446,7 +446,8 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
446
446
|
})
|
|
447
447
|
|
|
448
448
|
pi.on('session_start', (_event, ctx) => {
|
|
449
|
-
//
|
|
449
|
+
// pi's CLI builds a fresh extension instance per session replacement; RPC mode can reuse
|
|
450
|
+
// one across sessions, so drop the previous session's goal and
|
|
450
451
|
// timers before reading this session's persisted state.
|
|
451
452
|
sessionCtx = ctx
|
|
452
453
|
goal = undefined
|
|
@@ -446,7 +446,8 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
446
446
|
|
|
447
447
|
pi.on('session_start', async (event, ctx) => {
|
|
448
448
|
sessionCtx = ctx
|
|
449
|
-
//
|
|
449
|
+
// pi's CLI builds a fresh extension instance per session replacement; only RPC mode can
|
|
450
|
+
// reuse one across sessions. A mid-turn /new there fires session_start on
|
|
450
451
|
// the same instance while a Stop-hook continuation streak is in flight; it must not
|
|
451
452
|
// carry into the next session, so reset before any early return (disableAllHooks below).
|
|
452
453
|
stopHookActive = false
|
|
@@ -8,6 +8,7 @@ import { matchesBashIfFilter } from '../internal/bash-rules.js'
|
|
|
8
8
|
import { CLAUDE_TOOL_MAP } from '../internal/claude-tool-names.js'
|
|
9
9
|
import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
|
|
10
10
|
import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from '../internal/scope-rules.js'
|
|
11
|
+
import { asPiReadsIt } from '../internal/tool-target.js'
|
|
11
12
|
import { errorMessage } from '../internal/values.js'
|
|
12
13
|
import type { HookCommand, HookMatcher } from './config.js'
|
|
13
14
|
|
|
@@ -219,8 +220,13 @@ function matchesToolPattern(piName: string, input: Record<string, unknown> | nul
|
|
|
219
220
|
case 'slash_command':
|
|
220
221
|
return matchesSkillRules(str(input?.command), [pattern])
|
|
221
222
|
default: {
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
// Normalised as pi's file tools resolve it (~, a leading @, a file:// URL), because
|
|
224
|
+
// the rule side expands ~ against home too: comparing the raw string let an
|
|
225
|
+
// `if: Edit(~/.ssh/*)` guard miss the very call it names. The value is normalised
|
|
226
|
+
// rather than routed through fileToolTarget, which would drop every tool outside
|
|
227
|
+
// read/edit/write and so stop matching rules that match today.
|
|
228
|
+
const named = str(input?.path) || str(input?.file_path)
|
|
229
|
+
return named.length > 0 && matchesPathRules(asPiReadsIt(named), [pattern], anchors)
|
|
224
230
|
}
|
|
225
231
|
}
|
|
226
232
|
}
|
|
@@ -58,30 +58,125 @@ const trimNewlines = (value: string): string => {
|
|
|
58
58
|
return value.slice(start, end)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Every match of the global regex `re` in `html`, as `{start, end}` spans. One linear
|
|
62
|
+
* scan: `matchAll` resumes after each match rather than restarting the search. */
|
|
63
|
+
function matchSpans(html: string, re: RegExp): Array<{ start: number; end: number }> {
|
|
64
|
+
return [...html.matchAll(re)].map((m) => ({ start: m.index, end: m.index + m[0].length }))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Replace every `open...close` span with `transform(open, body)`. `closeSource(open)`
|
|
69
|
+
* gives the close pattern's regex source for this particular open (a fixed literal for
|
|
70
|
+
* most callers; a backreference to `open[1]` for a shared tag family like
|
|
71
|
+
* script|style|noscript, so each open pairs only with its own tag name). An open with no
|
|
72
|
+
* reachable close is left as literal text, the same as a non-matching `[\s\S]*?` regex
|
|
73
|
+
* would leave it.
|
|
74
|
+
*
|
|
75
|
+
* Both the opens and each distinct close pattern are found with one bounded, linear scan
|
|
76
|
+
* (`openRe`'s attrs never cross a `<`/`>`, and neither does a close tag's), then paired by
|
|
77
|
+
* a single forward walk with a cursor per close pattern that only advances. A page that
|
|
78
|
+
* repeats one unclosed tag thousands of times used to cost one rescan to the end of the
|
|
79
|
+
* document per occurrence (O(n^2) for the lazy `[\s\S]*?<\/tag>` shape this replaces);
|
|
80
|
+
* this costs one pass.
|
|
81
|
+
*/
|
|
82
|
+
function replaceTagSpans(html: string, openRe: RegExp, closeSource: (open: RegExpMatchArray) => string, transform: (open: RegExpMatchArray, body: string) => string): string {
|
|
83
|
+
const opens = [...html.matchAll(openRe)]
|
|
84
|
+
if (opens.length === 0) return html
|
|
85
|
+
|
|
86
|
+
const closeSpans = new Map<string, Array<{ start: number; end: number }>>()
|
|
87
|
+
const closeCursor = new Map<string, number>()
|
|
88
|
+
|
|
89
|
+
let out = ''
|
|
90
|
+
let cursor = 0
|
|
91
|
+
for (const open of opens) {
|
|
92
|
+
const openStart = open.index ?? 0
|
|
93
|
+
if (openStart < cursor) continue // inside a span an earlier open of this pass already consumed
|
|
94
|
+
const source = closeSource(open)
|
|
95
|
+
if (!closeSpans.has(source)) {
|
|
96
|
+
closeSpans.set(source, matchSpans(html, new RegExp(source, 'gi')))
|
|
97
|
+
closeCursor.set(source, 0)
|
|
98
|
+
}
|
|
99
|
+
const spans = closeSpans.get(source) as Array<{ start: number; end: number }>
|
|
100
|
+
const openEnd = openStart + open[0].length
|
|
101
|
+
let idx = closeCursor.get(source) as number
|
|
102
|
+
while (idx < spans.length && spans[idx].start < openEnd) idx++
|
|
103
|
+
closeCursor.set(source, idx)
|
|
104
|
+
if (idx >= spans.length) continue // no close anywhere after this open: leave it as text
|
|
105
|
+
out += html.slice(cursor, openStart) + transform(open, html.slice(openEnd, spans[idx].start))
|
|
106
|
+
cursor = spans[idx].end
|
|
107
|
+
}
|
|
108
|
+
return out + html.slice(cursor)
|
|
109
|
+
}
|
|
110
|
+
|
|
61
111
|
export function htmlToMarkdown(html: string): string {
|
|
62
112
|
// Pre blocks are lifted out first so no later transform touches their content.
|
|
63
113
|
const preBodies: string[] = []
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
114
|
+
// Each of these bodies can legitimately hold anything up to and including another `<`, so
|
|
115
|
+
// the body itself cannot be bounded like an open tag's attrs; replaceTagSpans keeps the
|
|
116
|
+
// pass linear instead by pairing opens and closes in one pass rather than rescanning the
|
|
117
|
+
// document from every open that turns out to have no close (a broken template or a fetch
|
|
118
|
+
// truncated mid-tag repeats that shape often enough to matter).
|
|
119
|
+
let work = replaceTagSpans(
|
|
120
|
+
html,
|
|
121
|
+
/<!--/g,
|
|
122
|
+
() => '-->',
|
|
123
|
+
() => ' ',
|
|
124
|
+
)
|
|
125
|
+
work = replaceTagSpans(
|
|
126
|
+
work,
|
|
127
|
+
/<(script|style|noscript|head|svg)\b[^<>]*>/gi,
|
|
128
|
+
(open) => `</${open[1]}[^<>]*>`,
|
|
129
|
+
() => ' ',
|
|
130
|
+
)
|
|
131
|
+
work = replaceTagSpans(
|
|
132
|
+
work,
|
|
133
|
+
/<pre\b[^<>]*>/gi,
|
|
134
|
+
() => '</pre[^<>]*>',
|
|
135
|
+
(_open, body) => {
|
|
136
|
+
preBodies.push(trimNewlines(decodeAllEntities(removeTags(body))))
|
|
69
137
|
return `\n\n\uE000PRE${preBodies.length - 1}\uE000\n\n`
|
|
70
|
-
}
|
|
138
|
+
},
|
|
139
|
+
)
|
|
71
140
|
|
|
72
|
-
work =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
141
|
+
work = replaceTagSpans(
|
|
142
|
+
work,
|
|
143
|
+
/<code\b[^<>]*>/gi,
|
|
144
|
+
() => '</code[^<>]*>',
|
|
145
|
+
(_open, body) => `\`${removeTags(body)}\``,
|
|
146
|
+
)
|
|
147
|
+
// Only real web links become markdown links; fragment and javascript hrefs
|
|
148
|
+
// keep their label and lose the target.
|
|
149
|
+
work = replaceTagSpans(
|
|
150
|
+
work,
|
|
151
|
+
/<a\b[^<>]*?href=(?:"([^"]*)"|'([^']*)')[^<>]*>/gi,
|
|
152
|
+
() => '</a[^<>]*>',
|
|
153
|
+
(open, body) => {
|
|
154
|
+
const href = decodeAllEntities(open[1] ?? open[2] ?? '')
|
|
155
|
+
const label = removeTags(body).trim()
|
|
79
156
|
if (!label) return ' '
|
|
80
157
|
return /^https?:\/\//i.test(href) ? `[${label}](${href})` : label
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
work = replaceTagSpans(
|
|
161
|
+
work,
|
|
162
|
+
/<(strong|b)\b[^<>]*>/gi,
|
|
163
|
+
(open) => `</${open[1]}[^<>]*>`,
|
|
164
|
+
(_open, body) => `**${removeTags(body).trim()}**`,
|
|
165
|
+
)
|
|
166
|
+
work = replaceTagSpans(
|
|
167
|
+
work,
|
|
168
|
+
/<(em|i)\b[^<>]*>/gi,
|
|
169
|
+
(open) => `</${open[1]}[^<>]*>`,
|
|
170
|
+
(_open, body) => `*${removeTags(body).trim()}*`,
|
|
171
|
+
)
|
|
172
|
+
work = replaceTagSpans(
|
|
173
|
+
work,
|
|
174
|
+
/<h([1-6])\b[^<>]*>/gi,
|
|
175
|
+
(open) => `</h${open[1]}[^<>]*>`,
|
|
176
|
+
(open, body) => `\n\n${'#'.repeat(Number(open[1]))} ${removeTags(body).trim()}\n\n`,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
work = work
|
|
85
180
|
.replace(/<img\b[^<>]*?alt=(?:"([^"]*)"|'([^']*)')[^<>]*>/gi, (_whole, dq?: string, sq?: string) => dq ?? sq ?? '')
|
|
86
181
|
.replace(/<li\b[^<>]*>/gi, '\n- ')
|
|
87
182
|
.replace(/<blockquote\b[^<>]*>/gi, '\n\n> ')
|
|
@@ -34,12 +34,29 @@ export function managedSettingsFile(): string {
|
|
|
34
34
|
return managedSettingsFileOverride ?? managedSettingsPath()
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** Files already reported as unparsable. Managed settings are read from sixteen call
|
|
38
|
+
* sites, several of them once per turn, so one warning per broken file is the whole
|
|
39
|
+
* budget; the same warn-once shape project-approval.ts uses for its runtime notice. */
|
|
40
|
+
const warnedUnparsable = new Set<string>()
|
|
41
|
+
|
|
37
42
|
function readOneSettingsFile(file: string): Record<string, unknown> {
|
|
43
|
+
let raw: string
|
|
44
|
+
try {
|
|
45
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
46
|
+
} catch {
|
|
47
|
+
return {} // no such file: genuinely no managed policy on this machine
|
|
48
|
+
}
|
|
38
49
|
try {
|
|
39
|
-
const parsed = JSON.parse(
|
|
40
|
-
if (
|
|
50
|
+
const parsed: unknown = JSON.parse(raw)
|
|
51
|
+
if (isRecord(parsed)) return parsed
|
|
41
52
|
} catch {
|
|
42
|
-
//
|
|
53
|
+
// Present but unparsable is an administrator's typo, not an absent policy. Silence
|
|
54
|
+
// there disabled every policy the file carries (exclusion lists, MCP allow and deny,
|
|
55
|
+
// enabledPlugins, disableAllHooks) on every session, with nothing said anywhere.
|
|
56
|
+
if (!warnedUnparsable.has(file)) {
|
|
57
|
+
warnedUnparsable.add(file)
|
|
58
|
+
console.warn(`pi-code: ignoring ${file}: not valid JSON; the managed policy it carries is not applied`)
|
|
59
|
+
}
|
|
43
60
|
}
|
|
44
61
|
return {}
|
|
45
62
|
}
|
|
@@ -19,7 +19,8 @@ import * as fs from 'node:fs'
|
|
|
19
19
|
import * as path from 'node:path'
|
|
20
20
|
import { claudeConfigDir } from './config-dir.js'
|
|
21
21
|
import { readManagedSettings } from './managed-settings.js'
|
|
22
|
-
import {
|
|
22
|
+
import { statToken } from './stat-token.js'
|
|
23
|
+
import { errorMessage, isRecord } from './values.js'
|
|
23
24
|
|
|
24
25
|
export interface InstalledPlugin {
|
|
25
26
|
name: string
|
|
@@ -40,8 +41,10 @@ function readJson(file: string): Record<string, unknown> {
|
|
|
40
41
|
return {} // no such file: nothing to read, and most callers expect that
|
|
41
42
|
}
|
|
42
43
|
try {
|
|
43
|
-
const parsed = JSON.parse(raw)
|
|
44
|
-
|
|
44
|
+
const parsed: unknown = JSON.parse(raw)
|
|
45
|
+
// isRecord, not a hand-written object check: a manifest or settings file that is a
|
|
46
|
+
// JSON array is not a config object, and every caller here reads it by key.
|
|
47
|
+
return isRecord(parsed) ? parsed : {}
|
|
45
48
|
} catch (error) {
|
|
46
49
|
// A manifest that does not parse leaves the plugin with no components at all, and
|
|
47
50
|
// settings that do not parse drop the enablement or configuration they carried.
|
|
@@ -176,12 +179,13 @@ export function resetInstalledPluginsCache(): void {
|
|
|
176
179
|
pluginCache.clear()
|
|
177
180
|
}
|
|
178
181
|
|
|
179
|
-
/** mtime plus size; cheap, but blind to a same-size rewrite within one
|
|
180
|
-
*
|
|
181
|
-
|
|
182
|
+
/** mtime plus size; cheap, but blind to a same-size rewrite within one timestamp tick, so
|
|
183
|
+
* only directory-tree entries use it. The shared token is the one that owns the format
|
|
184
|
+
* (internal/stat-token.ts); it throws where this caller wants a value for a path that is
|
|
185
|
+
* simply not there, which is the catch below rather than a second copy of the format. */
|
|
186
|
+
function statTokenOrMissing(target: string): string {
|
|
182
187
|
try {
|
|
183
|
-
|
|
184
|
-
return `${stat.mtimeMs}:${stat.size}`
|
|
188
|
+
return statToken(target)
|
|
185
189
|
} catch {
|
|
186
190
|
return 'missing'
|
|
187
191
|
}
|
|
@@ -208,18 +212,18 @@ function pluginFingerprint(cacheDir: string, settingsFiles: string[], index: Map
|
|
|
208
212
|
const parts = settingsFiles.map(contentToken)
|
|
209
213
|
for (const marketplace of listDirs(cacheDir)) {
|
|
210
214
|
const marketplaceDir = path.join(cacheDir, marketplace)
|
|
211
|
-
parts.push(`${marketplace}:${
|
|
215
|
+
parts.push(`${marketplace}:${statTokenOrMissing(marketplaceDir)}`)
|
|
212
216
|
for (const pluginDir of listPluginDirs(marketplaceDir)) {
|
|
213
217
|
const pluginPath = path.join(marketplaceDir, pluginDir)
|
|
214
|
-
parts.push(`${marketplace}/${pluginDir}:${
|
|
218
|
+
parts.push(`${marketplace}/${pluginDir}:${statTokenOrMissing(pluginPath)}`)
|
|
215
219
|
const versions = listDirs(pluginPath)
|
|
216
220
|
for (const version of versions) {
|
|
217
|
-
parts.push(`${marketplace}/${pluginDir}/${version}:${
|
|
221
|
+
parts.push(`${marketplace}/${pluginDir}/${version}:${statTokenOrMissing(path.join(pluginPath, version))}`)
|
|
218
222
|
}
|
|
219
223
|
// resolvePlugin reads only the resolved version's manifest, so its stat token is
|
|
220
224
|
// what an in-place edit (no directory entry changing) must move.
|
|
221
225
|
const resolved = versionDir(pluginPath, index?.get(`${pluginDir}@${marketplace}`))
|
|
222
|
-
if (resolved) parts.push(`${marketplace}/${pluginDir}/${path.basename(resolved)}/manifest:${
|
|
226
|
+
if (resolved) parts.push(`${marketplace}/${pluginDir}/${path.basename(resolved)}/manifest:${statTokenOrMissing(path.join(resolved, '.claude-plugin', 'plugin.json'))}`)
|
|
223
227
|
}
|
|
224
228
|
}
|
|
225
229
|
return parts.join('\n')
|
|
@@ -21,8 +21,10 @@ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g
|
|
|
21
21
|
* some models add) stripped, ~ expanded, a file:// URL decoded, unicode spaces folded.
|
|
22
22
|
* pi does this in resolveToCwd (dist/core/tools/path-utils), which the package does not
|
|
23
23
|
* export. A reader that skips it judges a different file than the one pi opens: a
|
|
24
|
-
* command scoped to `Read(*.md)` allowed `~/secret/notes.md`, read as <cwd>/~/secret.
|
|
25
|
-
|
|
24
|
+
* command scoped to `Read(*.md)` allowed `~/secret/notes.md`, read as <cwd>/~/secret.
|
|
25
|
+
* Exported for the hook `if` filter, which judges the same paths against the same rules
|
|
26
|
+
* but reaches them through tool names this module's FILE_TOOLS set does not cover. */
|
|
27
|
+
export function asPiReadsIt(target: string): string {
|
|
26
28
|
const folded = target.replace(UNICODE_SPACES, ' ')
|
|
27
29
|
const bare = folded.startsWith('@') ? folded.slice(1) : folded
|
|
28
30
|
if (bare === '~') return os.homedir()
|
package/extensions/mcp/policy.ts
CHANGED
|
@@ -8,7 +8,7 @@ import * as fs from 'node:fs'
|
|
|
8
8
|
import * as path from 'node:path'
|
|
9
9
|
import { managedSettingsFile } from '../internal/managed-settings.js'
|
|
10
10
|
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
11
|
-
import { errorMessage, escapeRegExp } from '../internal/values.js'
|
|
11
|
+
import { errorMessage, escapeRegExp, isRecord } from '../internal/values.js'
|
|
12
12
|
import { interpolateEnv, type ServerConfig } from './config.js'
|
|
13
13
|
|
|
14
14
|
export interface ProjectServerPolicy {
|
|
@@ -29,10 +29,22 @@ export interface ProjectServerPolicy {
|
|
|
29
29
|
* disabledMcpjsonServers counts from every file, including the repo's own, and wins
|
|
30
30
|
* over consent: a repo may always restrict itself further, never less. */
|
|
31
31
|
export function projectServerPolicy(cwd: string, home: string, projectApproved: boolean): ProjectServerPolicy {
|
|
32
|
+
// Tells a missing file from an unparsable one, like mcpAllowDeny below: silence for the
|
|
33
|
+
// second silently emptied disabledMcpjsonServers, so a server the repository had
|
|
34
|
+
// explicitly disabled connected. That direction fails open, against this function's own
|
|
35
|
+
// rule that a repo may always restrict itself further, never less.
|
|
32
36
|
const read = (file: string): Record<string, unknown> => {
|
|
37
|
+
let raw: string
|
|
38
|
+
try {
|
|
39
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
40
|
+
} catch {
|
|
41
|
+
return {} // no such file: genuinely no policy
|
|
42
|
+
}
|
|
33
43
|
try {
|
|
34
|
-
|
|
44
|
+
const parsed: unknown = JSON.parse(raw)
|
|
45
|
+
return isRecord(parsed) ? parsed : {}
|
|
35
46
|
} catch {
|
|
47
|
+
console.warn(`pi-code-mcp: ignoring ${file}: not valid JSON; its MCP server consent and disable lists are not applied`)
|
|
36
48
|
return {}
|
|
37
49
|
}
|
|
38
50
|
}
|
package/extensions/memory.ts
CHANGED
|
@@ -243,18 +243,25 @@ function readMemory(dir: string, name: string): MemoryToolResult {
|
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
/** The delete action: remove a memory
|
|
247
|
-
* like save (single key, no deadlock). The index
|
|
248
|
-
* and
|
|
249
|
-
*
|
|
246
|
+
/** The delete action: remove a memory's index line and then its file, queued on the index
|
|
247
|
+
* like save (single key, no deadlock). The index moves first because it is what the prompt
|
|
248
|
+
* and /memory show: a failure before it is written leaves both the file and the index as
|
|
249
|
+
* they were, and a failure after it leaves an orphan file rather than an index line
|
|
250
|
+
* pointing at a file that is gone. Each outcome is reported as what actually happened. */
|
|
250
251
|
async function deleteMemory(dir: string, indexPath: string, name: string): Promise<MemoryToolResult> {
|
|
251
252
|
try {
|
|
252
253
|
return await withFileMutationQueue(indexPath, async (): Promise<MemoryToolResult> => {
|
|
253
254
|
const index = readIndex(dir)
|
|
254
|
-
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
255
255
|
const remaining = removeIndexLine(index, name)
|
|
256
256
|
if (remaining) writeIndex(indexPath, remaining)
|
|
257
257
|
else fs.rmSync(indexPath, { force: true })
|
|
258
|
+
try {
|
|
259
|
+
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
260
|
+
} catch (error) {
|
|
261
|
+
// Out of the index, so it is gone from every surface the user sees; saying the
|
|
262
|
+
// delete failed would be false, and silence would leave the stray file unexplained.
|
|
263
|
+
return { content: [{ type: 'text', text: `Deleted memory ${name} from the index, but its file could not be removed: ${errorMessage(error)}.` }], details: {} }
|
|
264
|
+
}
|
|
258
265
|
return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
|
|
259
266
|
})
|
|
260
267
|
} catch (error) {
|
package/extensions/notify.ts
CHANGED
|
@@ -86,7 +86,11 @@ function notifyWindows(title: string, body: string): void {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
function notifyDesktop(title: string, body: string): void {
|
|
89
|
-
|
|
89
|
+
// Windows Terminal sets WT_SESSION for a WSL session it hosts too, but WSL is a Linux
|
|
90
|
+
// process: notifyWindows's fixed C:\Windows\... path is a Windows path and cannot
|
|
91
|
+
// resolve there, so PowerShell never actually launched and no toast ever fired. The
|
|
92
|
+
// escape-sequence fallback below travels over the same pty either way.
|
|
93
|
+
if (process.env.WT_SESSION && process.platform === 'win32') {
|
|
90
94
|
notifyWindows(title, body)
|
|
91
95
|
} else if (process.env.KITTY_WINDOW_ID) {
|
|
92
96
|
notifyOSC99(title, body)
|
|
@@ -100,25 +104,44 @@ export default function notifyExtension(pi: ExtensionAPI) {
|
|
|
100
104
|
// When the user last submitted a prompt, so a turn's duration can stand in for
|
|
101
105
|
// Claude's "appear to be away" check. Undefined until the first prompt this session.
|
|
102
106
|
let lastInputAt: number | undefined
|
|
107
|
+
// Set by agent_end, consumed and cleared by agent_settled. agent_end alone cannot
|
|
108
|
+
// tell a genuine "done, waiting for you" end from one an automatic retry, a /goal
|
|
109
|
+
// continuation, or a compaction is about to follow with no user involved: each of
|
|
110
|
+
// those fires its own agent_end too, with nobody ever actually waiting until the
|
|
111
|
+
// last one. agent_settled ("no automatic retry, compaction, or queued continuation
|
|
112
|
+
// will run") is that signal, but only firing there would delay the common, single-
|
|
113
|
+
// turn case behind a peer extension's agent_end handler blocking on a UI dialog
|
|
114
|
+
// (plan mode); capturing state at agent_end and only acting on it once agent_settled
|
|
115
|
+
// confirms this was the final step keeps both properties.
|
|
116
|
+
let pending = false
|
|
117
|
+
// Whether the run's own last assistant message ended with stopReason: 'aborted', i.e.
|
|
118
|
+
// the user pressed Esc: they are at the keyboard by definition, whatever isAway's
|
|
119
|
+
// timer-based guess would otherwise say.
|
|
120
|
+
let lastAborted = false
|
|
103
121
|
|
|
104
122
|
pi.on('session_start', async (_event, _ctx) => {
|
|
105
123
|
channel = resolveNotifChannel(readPreferredNotifChannel(os.homedir()))
|
|
106
124
|
lastInputAt = undefined
|
|
125
|
+
pending = false
|
|
107
126
|
})
|
|
108
127
|
|
|
109
|
-
pi.on('input', async () => {
|
|
128
|
+
pi.on('input', async (event) => {
|
|
129
|
+
// A goal continuation or a subagent's own prompt is not the user; only their own
|
|
130
|
+
// input is evidence they are at the keyboard (mirroring goal.ts's own check).
|
|
131
|
+
if (event.source === 'extension') return
|
|
110
132
|
lastInputAt = Date.now()
|
|
111
133
|
})
|
|
112
134
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
pi.on('agent_end', async (event) => {
|
|
136
|
+
const last = [...event.messages].reverse().find((message) => message.role === 'assistant')
|
|
137
|
+
lastAborted = last?.stopReason === 'aborted'
|
|
138
|
+
pending = channel !== 'off' && process.stdout.isTTY === true
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
pi.on('agent_settled', async () => {
|
|
142
|
+
if (!pending) return
|
|
143
|
+
pending = false
|
|
144
|
+
if (lastAborted) return
|
|
122
145
|
if (!isAway(lastInputAt, Date.now(), AWAY_AFTER_MS)) return
|
|
123
146
|
if (channel === 'bell') {
|
|
124
147
|
process.stdout.write('\x07')
|
|
@@ -426,7 +426,8 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
426
426
|
|
|
427
427
|
// Restore state on session start/resume
|
|
428
428
|
pi.on('session_start', async (_event, ctx) => {
|
|
429
|
-
//
|
|
429
|
+
// pi's CLI builds a fresh extension instance per session replacement; RPC mode can reuse
|
|
430
|
+
// one across sessions, so clear prior state first there too: a fresh
|
|
430
431
|
// session (/new, no plan entry) must not inherit the last session's plan or execution.
|
|
431
432
|
planModeEnabled = false
|
|
432
433
|
executionMode = false
|
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
* (pi changelog); a separate ctx.ui.setTitle call would only duplicate that, so there is none.
|
|
9
9
|
*
|
|
10
10
|
* It runs in every mode, not just the TUI: naming a session is cheap and harmless, and a
|
|
11
|
-
* headless run that persists its session still benefits from a readable name later.
|
|
12
|
-
* is
|
|
13
|
-
*
|
|
14
|
-
* session
|
|
11
|
+
* headless run that persists its session still benefits from a readable name later. The one
|
|
12
|
+
* exception is a subagent child (PI_CODE_SUBAGENT=1): its session is never browsed by name,
|
|
13
|
+
* so the call is skipped outright rather than spending a model round trip nobody sees.
|
|
14
|
+
* Titling is best-effort throughout: a session that already has a name, a run with no user
|
|
15
|
+
* text (a slash-command-only turn), a headless run with no model, or any provider error
|
|
16
|
+
* leaves the session untitled and never throws.
|
|
15
17
|
*
|
|
16
18
|
* Cost: one model call per session at most. The guard is claimed before the completion so
|
|
17
19
|
* repeated settles cannot each fire a call, and a failed attempt is not retried until a
|
|
@@ -98,6 +100,10 @@ export default function sessionTitleExtension(pi: ExtensionAPI) {
|
|
|
98
100
|
// small/fast-model request that generates the session title." setSessionName is pi's
|
|
99
101
|
// only title sink, so skipping the call here skips both effects at once.
|
|
100
102
|
if (process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE === '1') return
|
|
103
|
+
// A subagent child's session (--no-session, or its own --session-dir once it persists
|
|
104
|
+
// one for a resumable follow-up) is never browsed by name in a session picker: the
|
|
105
|
+
// model call would only add latency and cost to how soon the child can exit.
|
|
106
|
+
if (process.env.PI_CODE_SUBAGENT === '1') return
|
|
101
107
|
if (titled) return
|
|
102
108
|
// Never clobber an existing name: a user-chosen or resumed name wins.
|
|
103
109
|
if (pi.getSessionName?.()) return
|
|
@@ -250,11 +250,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
250
250
|
if (params.tasks?.length) return runParallelMode(params.tasks, mode)
|
|
251
251
|
if (params.agent && params.task) return runSingleMode(params.agent, params.task, params.cwd, mode)
|
|
252
252
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
253
|
+
// Unreachable: the modeCount guard above returns unless exactly one of these three
|
|
254
|
+
// is set, and nothing between it and here touches params. It was a second copy of
|
|
255
|
+
// that guard's message, which no input could ever produce, so a reader had to work
|
|
256
|
+
// out for themselves that it was dead. Stated as the invariant it actually is, so a
|
|
257
|
+
// future edit that breaks it says so instead of printing a confusing refusal.
|
|
258
|
+
throw new Error('subagent: exactly one mode must be set here; the mode guard should have returned')
|
|
258
259
|
},
|
|
259
260
|
|
|
260
261
|
renderCall(args, theme, _context) {
|
package/extensions/thinking.ts
CHANGED
|
@@ -56,7 +56,8 @@ export default function thinkingExtension(pi: ExtensionAPI) {
|
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
pi.on('session_start', () => {
|
|
59
|
-
//
|
|
59
|
+
// pi's CLI builds a fresh extension instance per session replacement; only RPC mode can
|
|
60
|
+
// reuse one across sessions. A mid-turn /new there fires session_start on
|
|
60
61
|
// the same instance while an escalation is still pending (its agent_settled never came),
|
|
61
62
|
// and that stale restore must be dropped rather than fired into the next session, whose
|
|
62
63
|
// level the new session owns. Drop only: do NOT setThinkingLevel here.
|
package/extensions/web.ts
CHANGED
|
@@ -178,11 +178,28 @@ async function resolveAndPin(url: URL): Promise<LookupFunction> {
|
|
|
178
178
|
|
|
179
179
|
const MAX_REDIRECTS = 5
|
|
180
180
|
|
|
181
|
-
/**
|
|
181
|
+
/** The charset a content-type header declares, or 'utf-8' when it names none. */
|
|
182
|
+
function declaredCharset(contentType: string): string {
|
|
183
|
+
const match = /charset=(?:"([^"]*)"|'([^']*)'|([^;\s]*))/i.exec(contentType)
|
|
184
|
+
return (match?.[1] ?? match?.[2] ?? match?.[3] ?? '').trim() || 'utf-8'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A decoder for `contentType`'s declared charset, or the platform default (UTF-8) for a
|
|
188
|
+
* label TextDecoder does not recognize: a bad or made-up charset must not fail the fetch. */
|
|
189
|
+
function decoderFor(contentType: string): TextDecoder {
|
|
190
|
+
try {
|
|
191
|
+
return new TextDecoder(declaredCharset(contentType))
|
|
192
|
+
} catch {
|
|
193
|
+
return new TextDecoder()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Read a response body up to MAX_RAW_CHARS, decoded as the content-type header's charset
|
|
198
|
+
* (UTF-8 when it names none), then stop the download. Bounds memory and parsing cost. */
|
|
182
199
|
async function readCapped(response: Response): Promise<string> {
|
|
200
|
+
const decoder = decoderFor(response.headers.get('content-type') ?? '')
|
|
183
201
|
const reader = response.body?.getReader()
|
|
184
|
-
if (!reader) return (await response.
|
|
185
|
-
const decoder = new TextDecoder()
|
|
202
|
+
if (!reader) return decoder.decode(await response.arrayBuffer()).slice(0, MAX_RAW_CHARS)
|
|
186
203
|
let text = ''
|
|
187
204
|
while (text.length < MAX_RAW_CHARS) {
|
|
188
205
|
const { done, value } = await reader.read()
|
|
@@ -234,13 +251,23 @@ function redirectTarget(response: Response, url: URL, rawUrl: string, crossHost:
|
|
|
234
251
|
return { kind: 'next', next }
|
|
235
252
|
}
|
|
236
253
|
|
|
237
|
-
|
|
254
|
+
/** The per-hop timeout, combined with the caller's own signal (the tool call's, fired on
|
|
255
|
+
* Esc) when there is one: cancelling must not lose the ceiling that keeps a silently
|
|
256
|
+
* hanging host from holding the turn forever, but Esc must not have to wait for it either. */
|
|
257
|
+
function hopSignal(signal: AbortSignal | undefined): AbortSignal {
|
|
258
|
+
const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
259
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function fetchText(rawUrl: string, crossHost: CrossHost, signal?: AbortSignal, transport = httpFetch): Promise<FetchOutcome> {
|
|
238
263
|
let url = new URL(rawUrl)
|
|
239
264
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
265
|
+
// Cancelled between hops (a redirect chain), not just mid-request.
|
|
266
|
+
signal?.throwIfAborted()
|
|
240
267
|
// Resolve, validate and pin per hop: a redirect target gets the same guarantee.
|
|
241
268
|
const lookup = await resolveAndPin(url)
|
|
242
269
|
const response = await transport(url, {
|
|
243
|
-
signal:
|
|
270
|
+
signal: hopSignal(signal),
|
|
244
271
|
lookup,
|
|
245
272
|
userAgent: USER_AGENT,
|
|
246
273
|
})
|
|
@@ -317,9 +344,9 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
317
344
|
allowed_domains: Type.Optional(Type.Array(Type.String(), { description: 'Only include results from these domains' })),
|
|
318
345
|
blocked_domains: Type.Optional(Type.Array(Type.String(), { description: 'Exclude results from these domains' })),
|
|
319
346
|
}),
|
|
320
|
-
async execute(_id, params) {
|
|
347
|
+
async execute(_id, params, signal) {
|
|
321
348
|
// Claude documents allowed/blocked domains as mutually exclusive; allowed wins.
|
|
322
|
-
const outcome = await fetchText(SEARCH_ENDPOINT + encodeURIComponent(params.query), 'follow')
|
|
349
|
+
const outcome = await fetchText(SEARCH_ENDPOINT + encodeURIComponent(params.query), 'follow', signal)
|
|
323
350
|
const text = outcome.kind === 'body' ? outcome.text : ''
|
|
324
351
|
const limit = Math.min(params.count ?? 5, 10)
|
|
325
352
|
const results = filterByDomain(parseSearchResults(text, 10), params.allowed_domains, params.blocked_domains).slice(0, limit)
|
|
@@ -356,7 +383,7 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
356
383
|
if (cached && cached.expires > now) {
|
|
357
384
|
body = cached.body
|
|
358
385
|
} else {
|
|
359
|
-
const outcome = await fetchText(target, 'report')
|
|
386
|
+
const outcome = await fetchText(target, 'report', signal)
|
|
360
387
|
// A cross-host redirect has no body to cache or summarize: the naming result is
|
|
361
388
|
// the answer, and Claude fetches the target with a second call if it wants it.
|
|
362
389
|
if (outcome.kind === 'redirect') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.77",
|
|
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, subagents, and goals",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
"typebox": "^1.3.6"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@earendil-works/pi-ai": ">=0.
|
|
61
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
62
|
-
"@earendil-works/pi-tui": ">=0.
|
|
60
|
+
"@earendil-works/pi-ai": ">=0.80.4",
|
|
61
|
+
"@earendil-works/pi-coding-agent": ">=0.80.4",
|
|
62
|
+
"@earendil-works/pi-tui": ">=0.80.4"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@biomejs/biome": "^2.5.4",
|