pi-code 1.0.6 → 1.0.7
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.
|
@@ -13,12 +13,15 @@
|
|
|
13
13
|
* the checkpoint (files created after the checkpoint are left in place).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
16
17
|
import * as fs from 'node:fs'
|
|
17
18
|
import * as os from 'node:os'
|
|
18
19
|
import * as path from 'node:path'
|
|
19
20
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
20
21
|
|
|
21
22
|
const CUSTOM_TYPE = 'git-checkpoint'
|
|
23
|
+
/** Sidecar inside the bare shadow repo recording the work tree it snapshots. */
|
|
24
|
+
const WORK_TREE_FILE = 'pi-work-tree'
|
|
22
25
|
const PROMPT_SNIPPET_LENGTH = 60
|
|
23
26
|
const RESTORE_MODES = ['Code and conversation', 'Conversation only', 'Code only']
|
|
24
27
|
|
|
@@ -72,6 +75,31 @@ export function sessionSlug(sessionFile: string | undefined): string {
|
|
|
72
75
|
return path.basename(sessionFile).replace(/[^\w.-]+/g, '_')
|
|
73
76
|
}
|
|
74
77
|
|
|
78
|
+
/** A stable per-directory key, so a session resumed elsewhere gets its own shadow. */
|
|
79
|
+
function cwdSlug(cwd: string): string {
|
|
80
|
+
const resolved = path.resolve(cwd)
|
|
81
|
+
const hash = createHash('sha256').update(resolved).digest('hex').slice(0, 8)
|
|
82
|
+
return `${path.basename(resolved).replace(/[^\w.-]+/g, '_')}-${hash}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The work tree a shadow repo was created against, or undefined for a repo that
|
|
86
|
+
* predates the sidecar or does not exist yet. */
|
|
87
|
+
function recordedWorkTree(shadowDir: string): string | undefined {
|
|
88
|
+
try {
|
|
89
|
+
return fs.readFileSync(path.join(shadowDir, WORK_TREE_FILE), 'utf8').trim() || undefined
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rememberWorkTree(shadowDir: string, cwd: string): void {
|
|
96
|
+
try {
|
|
97
|
+
fs.writeFileSync(path.join(shadowDir, WORK_TREE_FILE), `${cwd}\n`)
|
|
98
|
+
} catch {
|
|
99
|
+
// best effort: without the marker the next resume simply cannot detect a move
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
75
103
|
function extractText(content: unknown): string {
|
|
76
104
|
if (typeof content === 'string') return content
|
|
77
105
|
if (!Array.isArray(content)) return ''
|
|
@@ -134,6 +162,16 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
134
162
|
const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined }).getSessionFile?.()
|
|
135
163
|
const checkpointsRoot = path.join(os.homedir(), '.pi', 'agent', 'checkpoints')
|
|
136
164
|
shadowDir = path.join(checkpointsRoot, sessionSlug(sessionFile))
|
|
165
|
+
// A resumed session can arrive from a different directory than the one the shadow
|
|
166
|
+
// snapshotted; restoring those commits here would silently overwrite unrelated
|
|
167
|
+
// same-named files. Key a fresh shadow to this directory instead of ever checking
|
|
168
|
+
// one tree out into another. Resuming back in the recorded directory takes the
|
|
169
|
+
// original shadow again, so its checkpoints stay restorable there.
|
|
170
|
+
const recorded = recordedWorkTree(shadowDir)
|
|
171
|
+
if (recorded && path.resolve(recorded) !== path.resolve(ctx.cwd)) {
|
|
172
|
+
shadowDir = path.join(checkpointsRoot, `${sessionSlug(sessionFile)}-${cwdSlug(ctx.cwd)}`)
|
|
173
|
+
ctx.ui.notify(`Checkpoints for this session were recorded in ${recorded}; starting fresh checkpoints for ${ctx.cwd} (earlier ones are not restorable here)`, 'warning')
|
|
174
|
+
}
|
|
137
175
|
pruneCheckpointRepos(checkpointsRoot, CHECKPOINT_RETENTION_DAYS, shadowDir)
|
|
138
176
|
const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
|
|
139
177
|
if (check.code !== 0) {
|
|
@@ -147,6 +185,8 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
147
185
|
await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.email', 'checkpoint@pi-code'], { cwd: ctx.cwd })
|
|
148
186
|
await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.name', 'pi-code-checkpoint'], { cwd: ctx.cwd })
|
|
149
187
|
}
|
|
188
|
+
// Written on every start, so repos that predate the sidecar pick it up too.
|
|
189
|
+
rememberWorkTree(shadowDir, ctx.cwd)
|
|
150
190
|
}
|
|
151
191
|
|
|
152
192
|
/** `checkout -f <ref> -- .` errors when the ref's tree holds no files, so an empty
|
package/extensions/hooks.ts
CHANGED
|
@@ -156,6 +156,42 @@ export function readDisableAllHooks(files: string[], managed: Record<string, unk
|
|
|
156
156
|
return false
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/** Claude's `allowedHttpHookUrls` setting: URL patterns http hooks may target, with
|
|
160
|
+
* `*` as a wildcard. Per Claude's documentation: undefined (no source sets the key)
|
|
161
|
+
* means no restrictions, an empty array blocks every http hook, and arrays merge
|
|
162
|
+
* across settings sources. Merging is a union of managed settings plus every file in
|
|
163
|
+
* the chain; the chain already gates project files on trust (see hookFiles), and a
|
|
164
|
+
* trusted project can run arbitrary shell hooks anyway, so letting it extend the
|
|
165
|
+
* allowlist is no escalation. */
|
|
166
|
+
export function readAllowedHttpHookUrls(files: string[], managed: Record<string, unknown> = readManagedSettings()): string[] | undefined {
|
|
167
|
+
let found: string[] | undefined
|
|
168
|
+
const collect = (value: unknown): void => {
|
|
169
|
+
if (!Array.isArray(value)) return
|
|
170
|
+
found = [...(found ?? []), ...value.filter((entry): entry is string => typeof entry === 'string')]
|
|
171
|
+
}
|
|
172
|
+
collect(managed.allowedHttpHookUrls)
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
try {
|
|
175
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
176
|
+
if (isRecord(parsed)) collect(parsed.allowedHttpHookUrls)
|
|
177
|
+
} catch {
|
|
178
|
+
// missing or invalid file: skip
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return found
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Whether an http hook may target `url`. `*` in an allowlist entry matches any run
|
|
185
|
+
* of characters; everything else is literal and the whole URL must match. An
|
|
186
|
+
* undefined allowlist means the setting is absent, so there are no restrictions. */
|
|
187
|
+
export function httpUrlAllowed(url: string, allowlist: string[] | undefined): boolean {
|
|
188
|
+
if (allowlist === undefined) return true
|
|
189
|
+
return allowlist.some((pattern) => {
|
|
190
|
+
const literal = pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
|
|
191
|
+
return new RegExp(`^${literal.join('.*')}$`).test(url)
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
159
195
|
export function loadHooks(files: string[], sources?: Map<HookMatcher, string>): HooksConfig {
|
|
160
196
|
const config: HooksConfig = {}
|
|
161
197
|
for (const file of files) {
|
|
@@ -459,12 +495,15 @@ function interpolateHeaders(headers: Record<string, string> | undefined, allowed
|
|
|
459
495
|
* with a valid JSON body renders a decision, read exactly like command stdout.
|
|
460
496
|
* Everything else, including non-2xx statuses, connection failures and timeouts,
|
|
461
497
|
* is a non-blocking error by contract, so none of these outcomes ever reports
|
|
462
|
-
* `timedOut`, which PreToolUse fails closed on.
|
|
463
|
-
*
|
|
464
|
-
*
|
|
498
|
+
* `timedOut`, which PreToolUse fails closed on. Claude's `allowedHttpHookUrls`
|
|
499
|
+
* allowlist gates the fetch itself: a URL matching no entry is never contacted,
|
|
500
|
+
* so a settings file cannot point a hook at an arbitrary endpoint and exfiltrate
|
|
501
|
+
* the payload; when the setting is absent there are no restrictions, as Claude
|
|
502
|
+
* documents. A blocked hook renders no decision, like every other http failure.
|
|
465
503
|
*/
|
|
466
|
-
export async function runHttpHook(hook: { type?: string; command: string; url?: string; headers?: Record<string, string>; allowedEnvVars?: string[] }, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
|
|
504
|
+
export async function runHttpHook(hook: { type?: string; command: string; url?: string; headers?: Record<string, string>; allowedEnvVars?: string[] }, payload: unknown, timeoutMs: number, allowedUrls?: string[]): Promise<HookRunResult> {
|
|
467
505
|
const url = hook.url ?? hook.command
|
|
506
|
+
if (!httpUrlAllowed(url, allowedUrls)) return { code: 1, stdout: '', stderr: `${url} does not match allowedHttpHookUrls; the hook was not called`, timedOut: false }
|
|
468
507
|
try {
|
|
469
508
|
const response = await fetch(url, {
|
|
470
509
|
method: 'POST',
|
|
@@ -615,8 +654,9 @@ function replaceRecord(target: Record<string, unknown>, next: Record<string, unk
|
|
|
615
654
|
Object.assign(target, next)
|
|
616
655
|
}
|
|
617
656
|
|
|
618
|
-
/** Claude surfaces a hook error notice
|
|
619
|
-
*
|
|
657
|
+
/** Claude surfaces a hook error notice; on ungated events the action proceeds, while
|
|
658
|
+
* PreToolUse and UserPromptSubmit additionally fail closed on the same results (see
|
|
659
|
+
* their spawnFailed checks). Silence would hide that a guard never ran. */
|
|
620
660
|
function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[], notify?: SystemMessageSink): void {
|
|
621
661
|
if (!notify) return
|
|
622
662
|
for (const [i, result] of results.entries()) {
|
|
@@ -648,6 +688,10 @@ export async function runPreToolUse(config: HooksConfig, toolName: string, toolI
|
|
|
648
688
|
// A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
|
|
649
689
|
// would otherwise read as a clean allow. Fail closed instead.
|
|
650
690
|
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
|
|
691
|
+
// A hook that never spawned (EMFILE, missing /bin/sh) reached no verdict either;
|
|
692
|
+
// its code 0 must fail closed like a timeout, not read as an allow exactly when
|
|
693
|
+
// the machine is degraded.
|
|
694
|
+
if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}` }
|
|
651
695
|
}
|
|
652
696
|
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
653
697
|
// A hard deny wins over an ask, matching Claude's deny > ask > allow precedence:
|
|
@@ -698,6 +742,8 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
|
|
|
698
742
|
surfaceHookFailures(commands, results, onSystemMessage)
|
|
699
743
|
for (const [i, result] of results.entries()) {
|
|
700
744
|
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
|
|
745
|
+
// No verdict was delivered, so fail closed like a timeout (see runPreToolUse).
|
|
746
|
+
if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`, context: '' }
|
|
701
747
|
}
|
|
702
748
|
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
703
749
|
const contexts: string[] = []
|
|
@@ -742,6 +788,8 @@ function postToolFeedback(result: HookRunResult, eventName: string, isError: boo
|
|
|
742
788
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
743
789
|
let config: HooksConfig = {}
|
|
744
790
|
let projectDir = ''
|
|
791
|
+
/** Claude's allowedHttpHookUrls allowlist, resolved from the settings chain. */
|
|
792
|
+
let allowedHttpHookUrls: string[] | undefined
|
|
745
793
|
let pendingSessionContext: string[] = []
|
|
746
794
|
let stopHookActive = false
|
|
747
795
|
let sessionCtx: ExtensionContext | undefined
|
|
@@ -763,7 +811,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
763
811
|
(ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
|
|
764
812
|
(hook, payload, ms) => {
|
|
765
813
|
const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
|
|
766
|
-
if (hook.type === 'http') return runHttpHook(hook, merged, ms)
|
|
814
|
+
if (hook.type === 'http') return runHttpHook(hook, merged, ms, allowedHttpHookUrls)
|
|
767
815
|
if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
|
|
768
816
|
if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
|
|
769
817
|
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
@@ -814,8 +862,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
814
862
|
const ctx = sessionCtx
|
|
815
863
|
const eventName = data.phase === 'start' ? 'SubagentStart' : 'SubagentStop'
|
|
816
864
|
const payload = { hook_event_name: eventName, agent_type: data.agentType, agent_id: data.agentId }
|
|
817
|
-
|
|
818
|
-
|
|
865
|
+
try {
|
|
866
|
+
const results = await runNotifyHooks(matchingCommands(config[eventName], data.agentType), payload, boundRunner(ctx))
|
|
867
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
868
|
+
} catch {
|
|
869
|
+
// The bus outlives the session: an event landing between /new disposing this
|
|
870
|
+
// ctx and the next session_start hits disposed getters, and nothing awaits a
|
|
871
|
+
// bus listener, so a throw here would escape as an unhandled rejection.
|
|
872
|
+
}
|
|
819
873
|
})
|
|
820
874
|
|
|
821
875
|
pi.on('session_start', async (event, ctx) => {
|
|
@@ -827,6 +881,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
827
881
|
projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
828
882
|
const files = hookFiles(ctx.cwd, os.homedir(), trusted)
|
|
829
883
|
hookSources.clear()
|
|
884
|
+
allowedHttpHookUrls = readAllowedHttpHookUrls(files)
|
|
830
885
|
// The disableAllHooks escape hatch, checked before any config loads: with no
|
|
831
886
|
// config resolved, no event, plugin hooks included, can fire a hook.
|
|
832
887
|
hooksDisabled = readDisableAllHooks(files)
|
|
@@ -49,6 +49,11 @@ export class FileOAuthProvider implements OAuthClientProvider {
|
|
|
49
49
|
private readonly data: StoredAuth
|
|
50
50
|
private port = 0
|
|
51
51
|
private readonly onRedirect: (authorizationUrl: URL) => void
|
|
52
|
+
// A fresh random CSRF token per login attempt. The SDK puts it in the authorization
|
|
53
|
+
// URL's `state` param, the server echoes it back on the redirect, and waitForAuthCode
|
|
54
|
+
// rejects any callback that does not carry it, so another local process or an open web
|
|
55
|
+
// page cannot inject an authorization code into this login (RFC 8252 8.9).
|
|
56
|
+
private readonly loginState = crypto.randomBytes(16).toString('hex')
|
|
52
57
|
|
|
53
58
|
constructor(serverName: string, onRedirect: (authorizationUrl: URL) => void) {
|
|
54
59
|
this.storePath = storeFileFor(serverName)
|
|
@@ -117,6 +122,12 @@ export class FileOAuthProvider implements OAuthClientProvider {
|
|
|
117
122
|
return this.data.tokens !== undefined
|
|
118
123
|
}
|
|
119
124
|
|
|
125
|
+
/** The CSRF token the SDK adds to the authorization URL as `state`; waitForAuthCode
|
|
126
|
+
* verifies the redirect echoes exactly this value. */
|
|
127
|
+
state(): string {
|
|
128
|
+
return this.loginState
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
redirectToAuthorization(authorizationUrl: URL): void {
|
|
121
132
|
this.onRedirect(authorizationUrl)
|
|
122
133
|
}
|
|
@@ -147,11 +158,30 @@ export async function startCallbackServer(preferredPort?: number): Promise<{ ser
|
|
|
147
158
|
return { server, port: (server.address() as { port: number }).port }
|
|
148
159
|
}
|
|
149
160
|
|
|
150
|
-
export function waitForAuthCode(server: http.Server, timeoutMs: number): Promise<string> {
|
|
161
|
+
export function waitForAuthCode(server: http.Server, timeoutMs: number, expectedState?: string): Promise<string> {
|
|
151
162
|
return new Promise((resolve, reject) => {
|
|
152
163
|
const timer = setTimeout(() => reject(new Error(`authorization timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
164
|
+
// Do not let the pending timer keep the process alive on its own: if the login is
|
|
165
|
+
// abandoned or resolved out of band, the event loop can still drain.
|
|
166
|
+
timer.unref?.()
|
|
153
167
|
server.on('request', (request, response) => {
|
|
154
168
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1')
|
|
169
|
+
// Only the redirect path settles the login. A stray request (a favicon fetch, a
|
|
170
|
+
// local port scan, or a forged redirect from another process or an open web page)
|
|
171
|
+
// is answered but ignored, so it can neither inject a code nor abort the login by
|
|
172
|
+
// rejecting the promise (a repeatable DoS on a stable, guessable loopback port).
|
|
173
|
+
if (url.pathname !== '/callback') {
|
|
174
|
+
response.writeHead(404, { 'content-type': 'text/plain' })
|
|
175
|
+
response.end('not found')
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
// The CSRF check: a callback that does not echo this login's state is rejected
|
|
179
|
+
// without settling, so an attacker who cannot read the state cannot complete it.
|
|
180
|
+
if (expectedState !== undefined && url.searchParams.get('state') !== expectedState) {
|
|
181
|
+
response.writeHead(400, { 'content-type': 'text/plain' })
|
|
182
|
+
response.end('state mismatch')
|
|
183
|
+
return
|
|
184
|
+
}
|
|
155
185
|
const code = url.searchParams.get('code')
|
|
156
186
|
const error = url.searchParams.get('error')
|
|
157
187
|
response.writeHead(200, { 'content-type': 'text/html' })
|
|
@@ -21,8 +21,66 @@ export interface PathAnchors {
|
|
|
21
21
|
|
|
22
22
|
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/** Cap on brace-expanded alternatives per pattern, mirroring Claude's ~1000
|
|
25
|
+
* budget; an over-budget pattern is used unexpanded. */
|
|
26
|
+
const BRACE_EXPANSION_LIMIT = 1000
|
|
27
|
+
|
|
28
|
+
interface BraceGroup {
|
|
29
|
+
start: number
|
|
30
|
+
end: number
|
|
31
|
+
options: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The `{...}` group opening at `open`, or null when it is unmatched or carries no
|
|
35
|
+
* top-level comma (literal braces). Options are split on commas at the group's own
|
|
36
|
+
* depth so a nested group stays inside one option. */
|
|
37
|
+
function parseBraceGroup(pattern: string, open: number): BraceGroup | null {
|
|
38
|
+
let depth = 1
|
|
39
|
+
let optionStart = open + 1
|
|
40
|
+
const options: string[] = []
|
|
41
|
+
for (let i = open + 1; i < pattern.length; i += 1) {
|
|
42
|
+
const ch = pattern[i]
|
|
43
|
+
if (ch === '{') depth += 1
|
|
44
|
+
else if (ch === ',' && depth === 1) {
|
|
45
|
+
options.push(pattern.slice(optionStart, i))
|
|
46
|
+
optionStart = i + 1
|
|
47
|
+
} else if (ch === '}' && --depth === 0) {
|
|
48
|
+
if (options.length === 0) return null // no top-level comma: literal braces
|
|
49
|
+
options.push(pattern.slice(optionStart, i))
|
|
50
|
+
return { start: open, end: i, options }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The first expandable `{...}` group. A comma-less or unmatched `{` is skipped as
|
|
57
|
+
* literal, so the scan can still find an expandable group nested inside it. */
|
|
58
|
+
function findBraceGroup(pattern: string): BraceGroup | null {
|
|
59
|
+
for (let open = pattern.indexOf('{'); open !== -1; open = pattern.indexOf('{', open + 1)) {
|
|
60
|
+
const group = parseBraceGroup(pattern, open)
|
|
61
|
+
if (group) return group
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Bash-style brace expansion of one pattern into its alternatives: each group
|
|
67
|
+
* multiplies out (Cartesian across groups, nested groups recurse). Returns null
|
|
68
|
+
* when the expansion would exceed the budget. */
|
|
69
|
+
function expandBraces(pattern: string): string[] | null {
|
|
70
|
+
const group = findBraceGroup(pattern)
|
|
71
|
+
if (group === null) return [pattern]
|
|
72
|
+
const expanded: string[] = []
|
|
73
|
+
for (const option of group.options) {
|
|
74
|
+
const branch = expandBraces(pattern.slice(0, group.start) + option + pattern.slice(group.end + 1))
|
|
75
|
+
if (branch === null) return null
|
|
76
|
+
expanded.push(...branch)
|
|
77
|
+
if (expanded.length > BRACE_EXPANSION_LIMIT) return null
|
|
78
|
+
}
|
|
79
|
+
return expanded
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One glob pattern, braces already expanded, as a regular expression source. */
|
|
83
|
+
function translateGlob(pattern: string): string {
|
|
26
84
|
let out = ''
|
|
27
85
|
let i = 0
|
|
28
86
|
while (i < pattern.length) {
|
|
@@ -54,6 +112,15 @@ export function globToRegExpSource(pattern: string): string {
|
|
|
54
112
|
return out
|
|
55
113
|
}
|
|
56
114
|
|
|
115
|
+
/** One gitignore-style pattern as an anchored regular expression source. Brace
|
|
116
|
+
* groups (`{ts,tsx}`, nested, Cartesian across groups) expand into ORed
|
|
117
|
+
* alternatives; an over-budget expansion falls back to the literal pattern. */
|
|
118
|
+
export function globToRegExpSource(pattern: string): string {
|
|
119
|
+
const alternatives = expandBraces(pattern) ?? [pattern]
|
|
120
|
+
if (alternatives.length === 1) return translateGlob(alternatives[0])
|
|
121
|
+
return `(?:${alternatives.map(translateGlob).join('|')})`
|
|
122
|
+
}
|
|
123
|
+
|
|
57
124
|
/** A rule resolved to an absolute glob per its anchor form. */
|
|
58
125
|
function resolveRule(rule: string, anchors: PathAnchors): string {
|
|
59
126
|
if (rule.startsWith('//')) return rule.slice(1)
|
package/extensions/mcp.ts
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* per-project `projects[cwd].mcpServers` local scope, and ~/.pi/agent/mcp.json) is the
|
|
12
12
|
* user's own and loads on the first session. Project config (.mcp.json, .pi/mcp.json)
|
|
13
13
|
* can run arbitrary commands on connect, so it loads only once the project is approved
|
|
14
|
-
* (see project-approval). The two scopes are loaded separately, not merged
|
|
15
|
-
*
|
|
14
|
+
* (see project-approval). The two scopes are loaded separately, not merged. Claude's
|
|
15
|
+
* precedence is project over user for a duplicate name, so a project server the user has
|
|
16
|
+
* consented to (or an approved project's) wins; a merely-present untrusted project entry
|
|
17
|
+
* cannot shadow a user server, and a gated project server does not preempt it.
|
|
16
18
|
* Values support ${VAR} / ${VAR:-default} interpolation, connect and per-call timeouts
|
|
17
19
|
* honor MCP_TIMEOUT / MCP_TOOL_TIMEOUT, and a stdio server receives only the SDK's default
|
|
18
20
|
* environment plus its own `env` block, not the whole process environment.
|
|
@@ -108,11 +110,18 @@ export type ServerConfig = StdioServerConfig | HttpServerConfig
|
|
|
108
110
|
|
|
109
111
|
/** Claude's .mcp.json expansion: ${VAR}, and ${VAR:-default}. The syntax borrows
|
|
110
112
|
* shell's `:-`, which substitutes when the variable is unset OR empty. */
|
|
111
|
-
export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
112
|
-
return value.replace(/\$\{(\w+)(:-([^}]*))?\}/g, (
|
|
113
|
+
export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.env, onMissing?: (name: string) => void): string {
|
|
114
|
+
return value.replace(/\$\{(\w+)(:-([^}]*))?\}/g, (fullMatch, name, hasDefault, fallback) => {
|
|
113
115
|
const current = env[name]
|
|
114
116
|
if (hasDefault !== undefined) return current || fallback
|
|
115
|
-
|
|
117
|
+
if (current === undefined) {
|
|
118
|
+
// A referenced variable with no value and no default: keep the literal ${VAR} and
|
|
119
|
+
// report it, matching Claude, rather than silently substituting an empty string that
|
|
120
|
+
// turns `Bearer ${TOKEN}` into a confusing `Bearer ` and a mystery 401.
|
|
121
|
+
onMissing?.(name)
|
|
122
|
+
return fullMatch
|
|
123
|
+
}
|
|
124
|
+
return current
|
|
116
125
|
})
|
|
117
126
|
}
|
|
118
127
|
|
|
@@ -387,11 +396,40 @@ export function promptMessageContent(messages: ReadonlyArray<{ content: unknown
|
|
|
387
396
|
return mapContent(messages.map((message) => message.content as McpContentBlock)).filter((block) => block.type !== 'text' || block.text.trim() !== '')
|
|
388
397
|
}
|
|
389
398
|
|
|
399
|
+
/** Merge the `properties` (and, for allOf, the `required`) of a root-level combinator's
|
|
400
|
+
* branches into one flat object schema. Without this a tool whose input schema is a bare
|
|
401
|
+
* anyOf/oneOf/allOf (no top-level `type`) would present no properties at all, so the model
|
|
402
|
+
* would be forced to call it with no arguments. */
|
|
403
|
+
function mergeCombinatorBranches(branches: unknown[]): { properties: Record<string, unknown>; required: string[] } {
|
|
404
|
+
const properties: Record<string, unknown> = {}
|
|
405
|
+
const required = new Set<string>()
|
|
406
|
+
for (const branch of branches) {
|
|
407
|
+
if (!branch || typeof branch !== 'object') continue
|
|
408
|
+
const b = branch as Record<string, unknown>
|
|
409
|
+
if (b.properties && typeof b.properties === 'object') Object.assign(properties, b.properties as Record<string, unknown>)
|
|
410
|
+
if (Array.isArray(b.required)) for (const name of b.required) if (typeof name === 'string') required.add(name)
|
|
411
|
+
}
|
|
412
|
+
return { properties, required: [...required] }
|
|
413
|
+
}
|
|
414
|
+
|
|
390
415
|
export function normalizeSchema(schema: unknown): object {
|
|
391
416
|
const base = (schema as Record<string, unknown>) ?? {}
|
|
392
417
|
const { $schema: _dropSchema, additionalProperties: _dropAdditional, ...rest } = base
|
|
393
|
-
if (
|
|
394
|
-
|
|
418
|
+
if (rest.type) return rest
|
|
419
|
+
// A root-level combinator carries the real parameters in its branches; flatten them
|
|
420
|
+
// into one object schema rather than emptying it. allOf means every branch applies, so
|
|
421
|
+
// its required union is kept; anyOf/oneOf branches are alternatives, so required is left
|
|
422
|
+
// open (the server still enforces its own).
|
|
423
|
+
const allOf = Array.isArray(rest.allOf) ? rest.allOf : undefined
|
|
424
|
+
let branches = allOf
|
|
425
|
+
if (!branches && Array.isArray(rest.anyOf)) branches = rest.anyOf
|
|
426
|
+
if (!branches && Array.isArray(rest.oneOf)) branches = rest.oneOf
|
|
427
|
+
if (!branches) return { type: 'object', properties: {} }
|
|
428
|
+
const { properties, required } = mergeCombinatorBranches(branches)
|
|
429
|
+
const merged: Record<string, unknown> = { type: 'object', properties }
|
|
430
|
+
if (typeof rest.description === 'string') merged.description = rest.description
|
|
431
|
+
if (allOf && required.length > 0) merged.required = required
|
|
432
|
+
return merged
|
|
395
433
|
}
|
|
396
434
|
|
|
397
435
|
interface McpContentBlock {
|
|
@@ -494,23 +532,32 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): P
|
|
|
494
532
|
|
|
495
533
|
async function connect(name: string, config: ServerConfig, authUi?: AuthUi): Promise<Client> {
|
|
496
534
|
const client = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
535
|
+
// Names referenced by ${VAR} with no value and no default, gathered across this
|
|
536
|
+
// server's interpolated fields so the connect can warn once rather than fail with a
|
|
537
|
+
// mystery 401 or a command that lost an argument.
|
|
538
|
+
const missing = new Set<string>()
|
|
539
|
+
const fill = (value: string): string => interpolateEnv(value, process.env, (varName) => missing.add(varName))
|
|
540
|
+
const warnMissing = (): void => {
|
|
541
|
+
if (missing.size > 0) console.warn(`pi-code-mcp: server ${name} references undefined variable(s) ${[...missing].join(', ')}; leaving them unexpanded`)
|
|
542
|
+
}
|
|
497
543
|
if (isStdio(config)) {
|
|
498
544
|
// Start from the SDK's allowlist (PATH, HOME, SHELL, ...) rather than the whole
|
|
499
545
|
// process env: a server should not receive ANTHROPIC_API_KEY or GITHUB_TOKEN just
|
|
500
546
|
// for being launched. A server that needs a variable names it in its own env block.
|
|
501
547
|
const env: Record<string, string> = { ...getDefaultEnvironment() }
|
|
502
|
-
for (const [key, value] of Object.entries(config.env ?? {})) env[key] =
|
|
548
|
+
for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
|
|
503
549
|
const transport = new StdioClientTransport({
|
|
504
|
-
command:
|
|
505
|
-
args: (config.args ?? []).map((arg) =>
|
|
550
|
+
command: fill(config.command),
|
|
551
|
+
args: (config.args ?? []).map((arg) => fill(arg)),
|
|
506
552
|
env,
|
|
507
553
|
cwd: expandCwd(config.cwd),
|
|
508
554
|
stderr: 'ignore',
|
|
509
555
|
})
|
|
556
|
+
warnMissing()
|
|
510
557
|
await connectWithTimeout(client, transport, `connect ${name}`)
|
|
511
558
|
return client
|
|
512
559
|
}
|
|
513
|
-
const url = new URL(
|
|
560
|
+
const url = new URL(fill(config.url))
|
|
514
561
|
if (config.type === 'ws' || config.type === 'websocket') {
|
|
515
562
|
// The SDK's WebSocket transport takes only a url: it carries no headers, bearer
|
|
516
563
|
// token, or headersHelper output. Warn rather than silently dropping configured
|
|
@@ -520,16 +567,18 @@ async function connect(name: string, config: ServerConfig, authUi?: AuthUi): Pro
|
|
|
520
567
|
console.warn(`pi-code-mcp: server ${name} is a WebSocket server; the SDK ws transport is url-only, so its headers/bearerToken/headersHelper are ignored`)
|
|
521
568
|
}
|
|
522
569
|
const transport = new WebSocketClientTransport(url)
|
|
570
|
+
warnMissing()
|
|
523
571
|
await connectWithTimeout(client, transport, `connect ${name} (ws)`)
|
|
524
572
|
return client
|
|
525
573
|
}
|
|
526
574
|
const headers: Record<string, string> = {}
|
|
527
|
-
for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] =
|
|
575
|
+
for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = fill(value)
|
|
528
576
|
const token = resolveBearerToken(config)
|
|
529
577
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
530
578
|
// A headersHelper generates connect-time headers for non-OAuth auth schemes; its
|
|
531
579
|
// JSON stdout merges over the static headers.
|
|
532
|
-
if (config.headersHelper) Object.assign(headers, await runHeadersHelper(
|
|
580
|
+
if (config.headersHelper) Object.assign(headers, await runHeadersHelper(fill(config.headersHelper)))
|
|
581
|
+
warnMissing()
|
|
533
582
|
const sseTransport = (authProvider?: OAuthClientProvider) => new SSEClientTransport(url, { requestInit: { headers }, authProvider }) // NOSONAR: explicitly declared or deliberate legacy transport
|
|
534
583
|
if (config.type === 'sse') {
|
|
535
584
|
return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
|
|
@@ -645,7 +694,9 @@ async function runInteractiveOAuth(name: string, config: { url: string }, makeTr
|
|
|
645
694
|
provider.bindRedirectPort(port)
|
|
646
695
|
try {
|
|
647
696
|
const transport = makeTransport(provider)
|
|
648
|
-
|
|
697
|
+
// Verify the redirect echoes this login's state, so a stray or forged callback to the
|
|
698
|
+
// loopback port cannot inject a code or abort the login (see waitForAuthCode).
|
|
699
|
+
const pendingCode = waitForAuthCode(server, OAUTH_FLOW_TIMEOUT_MS, provider.state())
|
|
649
700
|
pendingCode.catch(() => {}) // consumed below; an abandoned login must not surface as unhandled
|
|
650
701
|
const client = newClient()
|
|
651
702
|
try {
|
|
@@ -790,7 +841,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
790
841
|
const aliases: McpToolAlias[] = []
|
|
791
842
|
|
|
792
843
|
/** Register every not-yet-registered tool of a server; returns how many were added. */
|
|
793
|
-
function registerTools(name: string, config: ServerConfig,
|
|
844
|
+
function registerTools(name: string, config: ServerConfig, tools: McpToolInfo[]): number {
|
|
794
845
|
let count = 0
|
|
795
846
|
for (const tool of tools) {
|
|
796
847
|
const toolName = formatToolName(name, tool.name)
|
|
@@ -809,12 +860,18 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
809
860
|
description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
|
|
810
861
|
parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
|
|
811
862
|
async execute(_id, params) {
|
|
863
|
+
// Resolve the live client by name at call time rather than capturing the one
|
|
864
|
+
// present at registration: pi has no tool unregister, so after a server drops
|
|
865
|
+
// and a later session_start reconnects it, registerTools skips re-registration
|
|
866
|
+
// and this closure would otherwise keep calling the old, closed client.
|
|
867
|
+
const current = clients.get(name)
|
|
868
|
+
if (!current) throw new Error(`MCP server "${name}" is not connected`)
|
|
812
869
|
// Pass the timeout to the SDK too: its own default request timeout is 60s and
|
|
813
870
|
// would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
|
|
814
871
|
// Claude's per-server timeout wins over MCP_TOOL_TIMEOUT, with a 1s floor.
|
|
815
872
|
const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
|
|
816
873
|
const budget = declared ?? callTimeoutMs()
|
|
817
|
-
const result = await withTimeout(
|
|
874
|
+
const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: budget }), budget, toolName)
|
|
818
875
|
const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
819
876
|
const details: { error?: string } = {}
|
|
820
877
|
if (result.isError) {
|
|
@@ -839,7 +896,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
839
896
|
* no command unregister, so, like tools, a withdrawn prompt keeps its registration
|
|
840
897
|
* and surfaces the server's own error when invoked; an edit to a prompt's declared
|
|
841
898
|
* arguments only lands on new names, since an existing command keeps its binding. */
|
|
842
|
-
function registerPrompts(name: string,
|
|
899
|
+
function registerPrompts(name: string, prompts: McpPromptInfo[]): void {
|
|
843
900
|
for (const prompt of prompts) {
|
|
844
901
|
const commandName = formatPromptCommandName(name, prompt.name)
|
|
845
902
|
const owner = registeredPrompts.get(commandName)
|
|
@@ -855,11 +912,19 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
855
912
|
description: hint ? `${base} ${hint}` : base,
|
|
856
913
|
handler: async (args, ctx) => {
|
|
857
914
|
try {
|
|
915
|
+
// Resolve the live client at call time, not the one captured at registration:
|
|
916
|
+
// pi has no command unregister, so after a reconnect this closure must not keep
|
|
917
|
+
// calling the old, closed client (see registerTools for the same reason).
|
|
918
|
+
const current = clients.get(name)
|
|
919
|
+
if (!current) {
|
|
920
|
+
ctx.ui.notify(`${commandName}: MCP server "${name}" is not connected`, 'error')
|
|
921
|
+
return
|
|
922
|
+
}
|
|
858
923
|
const promptArgs = mapPromptArguments(prompt.arguments, args)
|
|
859
924
|
const params: { name: string; arguments?: Record<string, string> } = { name: prompt.name }
|
|
860
925
|
if (Object.keys(promptArgs).length > 0) params.arguments = promptArgs
|
|
861
926
|
const budget = callTimeoutMs()
|
|
862
|
-
const result = await withTimeout(
|
|
927
|
+
const result = await withTimeout(current.getPrompt(params, { timeout: budget }), budget, commandName)
|
|
863
928
|
// The prompt drives a turn exactly the way a custom slash command does
|
|
864
929
|
// (see commands.ts), carrying its image blocks through. A prompt that
|
|
865
930
|
// yields no content is reported rather than sent as an empty turn.
|
|
@@ -882,7 +947,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
882
947
|
async function connectPrompts(name: string, client: Client): Promise<void> {
|
|
883
948
|
if (!client.getServerCapabilities()?.prompts) return
|
|
884
949
|
try {
|
|
885
|
-
registerPrompts(name,
|
|
950
|
+
registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
|
|
886
951
|
} catch (error) {
|
|
887
952
|
console.warn(`pi-code-mcp: prompt listing failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
|
|
888
953
|
}
|
|
@@ -894,7 +959,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
894
959
|
try {
|
|
895
960
|
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => {
|
|
896
961
|
try {
|
|
897
|
-
registerPrompts(name,
|
|
962
|
+
registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
|
|
898
963
|
} catch (error) {
|
|
899
964
|
console.warn(`pi-code-mcp: prompt refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
|
|
900
965
|
}
|
|
@@ -978,7 +1043,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
978
1043
|
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
979
1044
|
try {
|
|
980
1045
|
const refreshed = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
|
|
981
|
-
const added = registerTools(name, config,
|
|
1046
|
+
const added = registerTools(name, config, refreshed)
|
|
982
1047
|
if (added === 0) return
|
|
983
1048
|
const current = status.get(name)
|
|
984
1049
|
status.set(name, { state: current?.state ?? 'connected', tools: (current?.tools ?? 0) + added })
|
|
@@ -1014,7 +1079,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1014
1079
|
const client = await connect(name, config, authUi)
|
|
1015
1080
|
clients.set(name, client)
|
|
1016
1081
|
const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
|
|
1017
|
-
const count = registerTools(name, config,
|
|
1082
|
+
const count = registerTools(name, config, tools)
|
|
1018
1083
|
subscribeToToolChanges(name, config, client)
|
|
1019
1084
|
// Prompts and resources are additive surfaces: their failures warn (inside
|
|
1020
1085
|
// connectPrompts) rather than flipping a tool-serving server to failed.
|
|
@@ -1075,7 +1140,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1075
1140
|
const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
|
|
1076
1141
|
const { allowed, denied } = mcpAllowDeny()
|
|
1077
1142
|
const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, allowed, denied)
|
|
1078
|
-
|
|
1143
|
+
// Claude's precedence is project over user for a duplicate name. A project .mcp.json
|
|
1144
|
+
// server only outranks the user's own when it will actually connect (the user already
|
|
1145
|
+
// consented to it, or an approved project's), so a merely-present untrusted project
|
|
1146
|
+
// entry cannot shadow a trusted user server by reusing its name. A gated project
|
|
1147
|
+
// server still awaiting the approval prompt does not preempt the user server: that is
|
|
1148
|
+
// a deliberate narrowing of Claude's rule to keep the safe default.
|
|
1149
|
+
const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
|
|
1150
|
+
const projectWinners = new Set(Object.keys(splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy).consented))
|
|
1151
|
+
const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
|
|
1079
1152
|
if (Object.keys(userServers).length > 0) await connectServers(userServers, authUiFor(ctx))
|
|
1080
1153
|
// A project .mcp.json can run arbitrary commands on connect, so only honor it once
|
|
1081
1154
|
// the project is trusted. Per-server settings refine that: disabled servers never
|
|
@@ -26,6 +26,10 @@ export interface BackgroundRun {
|
|
|
26
26
|
/** True until the child process actually closes: a cancelled child that ignores
|
|
27
27
|
* SIGTERM is still alive and must keep holding its concurrency slot. */
|
|
28
28
|
live?: boolean
|
|
29
|
+
/** Monotonic finish order, stamped when the run completes. Eviction drops the
|
|
30
|
+
* earliest-finished runs by this, not Map insertion (start) order: a long run
|
|
31
|
+
* started first but finished last must not vanish the instant it completes. */
|
|
32
|
+
finishedAt?: number
|
|
29
33
|
/** pi session the child ran under, so a follow-up can continue its context. */
|
|
30
34
|
sessionId: string
|
|
31
35
|
/** How the child was spawned, so a follow-up can repeat it with a new task. */
|
|
@@ -63,8 +67,13 @@ export function activeBackgroundRuns(): number {
|
|
|
63
67
|
return [...runs.values()].filter((run) => run.live || run.state === 'running').length
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
/** Stamps BackgroundRun.finishedAt; a counter rather than a clock so two runs
|
|
71
|
+
* completing in the same millisecond still evict in their true finish order. */
|
|
72
|
+
let finishSequence = 0
|
|
73
|
+
|
|
66
74
|
function evictFinishedRuns(): void {
|
|
67
75
|
const finished = [...runs.values()].filter((run) => !run.live && run.state !== 'running')
|
|
76
|
+
finished.sort((a, b) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0))
|
|
68
77
|
for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS))) runs.delete(stale.id)
|
|
69
78
|
}
|
|
70
79
|
|
|
@@ -160,6 +169,7 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
|
|
|
160
169
|
run.output = undefined
|
|
161
170
|
run.exitCode = undefined
|
|
162
171
|
run.stderr = undefined
|
|
172
|
+
run.finishedAt = undefined
|
|
163
173
|
driveRun(run, { ...run.spawn, args }, onComplete)
|
|
164
174
|
return 'resumed'
|
|
165
175
|
}
|
|
@@ -256,6 +266,7 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
256
266
|
const complete = (): void => {
|
|
257
267
|
if (completed) return
|
|
258
268
|
completed = true
|
|
269
|
+
run.finishedAt = ++finishSequence
|
|
259
270
|
evictFinishedRuns()
|
|
260
271
|
// A run outlives the session that started it, and pi's loader wires assertActive()
|
|
261
272
|
// into every runtime call, so notifying a disposed session throws. This fires from
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
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",
|