pi-code 0.3.1 → 0.3.2
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/extensions/git-checkpoint.ts +15 -4
- package/extensions/hooks.ts +5 -2
- package/extensions/internal/web-transport.ts +19 -6
- package/extensions/mcp.ts +36 -10
- package/extensions/plan-mode/index.ts +16 -0
- package/extensions/plan-mode/utils.ts +5 -3
- package/extensions/status-line.ts +2 -0
- package/extensions/subagent/background.ts +9 -2
- package/extensions/subagent/index.ts +9 -2
- package/package.json +1 -1
|
@@ -106,18 +106,29 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
106
106
|
const createdAt = new Date().toISOString()
|
|
107
107
|
const add = await gitShadow(['add', '-A'])
|
|
108
108
|
if (add.code !== 0) return undefined
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
// Decide "nothing changed" from the index, not from the commit exit code: a commit can
|
|
110
|
+
// also fail on the user's global signing or hooks config, and reusing HEAD then would
|
|
111
|
+
// record a ref that predates the current tree, so /rewind restores the wrong state.
|
|
112
|
+
const status = await gitShadow(['status', '--porcelain'])
|
|
113
|
+
const nothingChanged = status.code === 0 && status.stdout.trim() === ''
|
|
114
|
+
if (nothingChanged) {
|
|
112
115
|
const head = await gitShadow(['rev-parse', 'HEAD'])
|
|
113
116
|
if (head.code === 0) return { ref: head.stdout.trim(), createdAt }
|
|
114
|
-
const empty = await
|
|
117
|
+
const empty = await commitShadow(['--allow-empty'])
|
|
115
118
|
if (empty.code !== 0) return undefined
|
|
119
|
+
} else {
|
|
120
|
+
const commit = await commitShadow([])
|
|
121
|
+
if (commit.code !== 0) return undefined // real failure: do not record a stale ref
|
|
116
122
|
}
|
|
117
123
|
const sha = await gitShadow(['rev-parse', 'HEAD'])
|
|
118
124
|
return sha.code === 0 ? { ref: sha.stdout.trim(), createdAt } : undefined
|
|
119
125
|
}
|
|
120
126
|
|
|
127
|
+
/** Commit in the shadow repo, isolated from the user's global signing and hook config. */
|
|
128
|
+
function commitShadow(extra: string[]): ReturnType<ExtensionAPI['exec']> {
|
|
129
|
+
return gitShadow(['-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', ...extra, '-m', 'checkpoint'])
|
|
130
|
+
}
|
|
131
|
+
|
|
121
132
|
async function restoreCode(ctx: ExtensionCommandContext, checkpoint: Checkpoint): Promise<boolean> {
|
|
122
133
|
if (!checkpoint.ref) {
|
|
123
134
|
ctx.ui.notify('Checkpoint has no code snapshot; code left untouched', 'warning')
|
package/extensions/hooks.ts
CHANGED
|
@@ -102,7 +102,7 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, name: stri
|
|
|
102
102
|
return result
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; decision?: string; reason?: string } | undefined {
|
|
105
|
+
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; decision?: string; reason?: string; continue?: boolean; stopReason?: string } | undefined {
|
|
106
106
|
try {
|
|
107
107
|
return JSON.parse(text)
|
|
108
108
|
} catch {
|
|
@@ -115,8 +115,11 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
|
|
|
115
115
|
if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
|
|
116
116
|
const parsed = tryParseJson(stdout)
|
|
117
117
|
const specific = parsed?.hookSpecificOutput
|
|
118
|
-
|
|
118
|
+
// pi's tool_call return is allow-or-block, so "ask" (confirm) maps to block-with-reason
|
|
119
|
+
// rather than a silent allow, which is the least-safe reading on a trust-gated path.
|
|
120
|
+
if (specific?.permissionDecision === 'deny' || specific?.permissionDecision === 'ask') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
|
|
119
121
|
if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
|
|
122
|
+
if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
|
|
120
123
|
return { block: false }
|
|
121
124
|
}
|
|
122
125
|
|
|
@@ -21,6 +21,9 @@ export interface TransportOptions {
|
|
|
21
21
|
userAgent: string
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Statuses the WHATWG Response constructor forbids a body on (per the fetch spec). */
|
|
25
|
+
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304])
|
|
26
|
+
|
|
24
27
|
/** One request, no redirect following (the caller re-validates and re-pins per hop). */
|
|
25
28
|
export function httpFetch(url: URL, opts: TransportOptions): Promise<Response> {
|
|
26
29
|
const request = url.protocol === 'https:' ? httpsRequest : httpRequest
|
|
@@ -36,13 +39,23 @@ export function httpFetch(url: URL, opts: TransportOptions): Promise<Response> {
|
|
|
36
39
|
// validation use the real host even though the socket connects to the pinned IP.
|
|
37
40
|
},
|
|
38
41
|
(res) => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
try {
|
|
43
|
+
const headers = new Headers()
|
|
44
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
45
|
+
if (typeof value === 'string') headers.set(key, value)
|
|
46
|
+
else if (Array.isArray(value)) headers.set(key, value.join(', '))
|
|
47
|
+
}
|
|
48
|
+
const status = res.statusCode ?? 0
|
|
49
|
+
// The Response constructor throws for a non-null body on a null-body status
|
|
50
|
+
// (204/205/304) and for status 0. That throw fires here, off the Promise
|
|
51
|
+
// executor, so without this guard it escapes as an uncaughtException and pi
|
|
52
|
+
// exits. Give those statuses a null body; reject anything else that throws.
|
|
53
|
+
const body = NULL_BODY_STATUSES.has(status) ? null : (Readable.toWeb(res) as ReadableStream<Uint8Array>)
|
|
54
|
+
resolve(new Response(body, { status, headers }))
|
|
55
|
+
} catch (err) {
|
|
56
|
+
res.resume() // drain so the socket can close
|
|
57
|
+
reject(err)
|
|
43
58
|
}
|
|
44
|
-
const body = Readable.toWeb(res) as ReadableStream<Uint8Array>
|
|
45
|
-
resolve(new Response(body, { status: res.statusCode ?? 0, headers }))
|
|
46
59
|
},
|
|
47
60
|
)
|
|
48
61
|
req.on('error', reject)
|
package/extensions/mcp.ts
CHANGED
|
@@ -113,20 +113,24 @@ interface McpContentBlock {
|
|
|
113
113
|
export type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
|
|
114
114
|
|
|
115
115
|
export function mapContent(content: McpContentBlock[] | undefined, structured?: unknown): ToolContent[] {
|
|
116
|
+
// capForContext every text output, whatever its source: a server can blow the tool-output
|
|
117
|
+
// budget through a resource block, a JSON-stringified block, or the structured fallback,
|
|
118
|
+
// not only a text block.
|
|
119
|
+
const text = (value: string): ToolContent => ({ type: 'text', text: capForContext(value) })
|
|
116
120
|
if (!content || content.length === 0) {
|
|
117
|
-
return [
|
|
121
|
+
return [text(structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)')]
|
|
118
122
|
}
|
|
119
123
|
return content.map((block) => {
|
|
120
124
|
if (block.type === 'text') {
|
|
121
|
-
return
|
|
125
|
+
return text(block.text ?? '')
|
|
122
126
|
}
|
|
123
127
|
if (block.type === 'image' && block.data) {
|
|
124
128
|
return { type: 'image', data: block.data, mimeType: block.mimeType ?? 'image/png' }
|
|
125
129
|
}
|
|
126
130
|
if (block.type === 'resource' && block.resource) {
|
|
127
|
-
return
|
|
131
|
+
return text(`[Resource: ${block.resource.uri ?? 'unknown'}]\n${block.resource.text ?? ''}`)
|
|
128
132
|
}
|
|
129
|
-
return
|
|
133
|
+
return text(JSON.stringify(block))
|
|
130
134
|
})
|
|
131
135
|
}
|
|
132
136
|
|
|
@@ -165,33 +169,53 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
|
165
169
|
cwd: config.cwd?.replace(/^~(?=\/|$)/, os.homedir()),
|
|
166
170
|
stderr: 'ignore',
|
|
167
171
|
})
|
|
168
|
-
await
|
|
172
|
+
await connectWithTimeout(client, transport, `connect ${name}`)
|
|
169
173
|
return client
|
|
170
174
|
}
|
|
171
175
|
const headers: Record<string, string> = {}
|
|
172
176
|
for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = interpolateEnv(value)
|
|
173
|
-
const token = config.bearerToken
|
|
177
|
+
const token = config.bearerToken ? interpolateEnv(config.bearerToken) : config.bearerTokenEnv ? process.env[config.bearerTokenEnv] : undefined
|
|
174
178
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
175
179
|
const url = new URL(interpolateEnv(config.url))
|
|
176
180
|
if (config.type === 'sse') {
|
|
177
181
|
const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: explicitly declared legacy transport
|
|
178
|
-
await
|
|
182
|
+
await connectWithTimeout(client, transport, `connect ${name} (sse)`)
|
|
179
183
|
return client
|
|
180
184
|
}
|
|
181
185
|
try {
|
|
182
186
|
const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers } })
|
|
183
|
-
await
|
|
187
|
+
await connectWithTimeout(client, transport, `connect ${name}`)
|
|
184
188
|
return client
|
|
185
189
|
} catch (error) {
|
|
186
190
|
// An explicitly declared streamable transport must not silently degrade to SSE.
|
|
187
191
|
if (config.type !== undefined || String(error).includes('Unauthorized')) throw error
|
|
188
192
|
const fallback = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
189
193
|
const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: deliberate legacy fallback
|
|
190
|
-
await
|
|
194
|
+
await connectWithTimeout(fallback, transport, `connect ${name} (sse)`)
|
|
191
195
|
return fallback
|
|
192
196
|
}
|
|
193
197
|
}
|
|
194
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Connect with a deadline, closing the client if the deadline (not a connect error) wins.
|
|
201
|
+
* Without this, a slow-but-successful server finishes connecting after the race is lost and
|
|
202
|
+
* lingers unreferenced: process/socket alive, never in `clients`, invisible to shutdown.
|
|
203
|
+
*/
|
|
204
|
+
async function connectWithTimeout(client: Client, transport: Parameters<Client['connect']>[0], label: string): Promise<void> {
|
|
205
|
+
const connecting = client.connect(transport)
|
|
206
|
+
try {
|
|
207
|
+
await withTimeout(connecting, CONNECT_TIMEOUT_MS, label)
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// Only a timeout can orphan a still-opening transport; a connect rejection means the
|
|
210
|
+
// SDK already tore it down, so closing again would be redundant.
|
|
211
|
+
if (String(error).includes('timed out after')) {
|
|
212
|
+
connecting.catch(() => {}) // a late rejection must not surface as unhandled
|
|
213
|
+
void client.close().catch(() => {})
|
|
214
|
+
}
|
|
215
|
+
throw error
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
195
219
|
async function listAllTools(client: Client): Promise<Array<{ name: string; description?: string; inputSchema?: unknown }>> {
|
|
196
220
|
const tools: Array<{ name: string; description?: string; inputSchema?: unknown }> = []
|
|
197
221
|
let cursor: string | undefined
|
|
@@ -236,7 +260,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
236
260
|
description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
|
|
237
261
|
parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
|
|
238
262
|
async execute(_id, params) {
|
|
239
|
-
|
|
263
|
+
// Pass the timeout to the SDK too: its own default request timeout is 60s and
|
|
264
|
+
// would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
|
|
265
|
+
const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: CALL_TIMEOUT_MS }), CALL_TIMEOUT_MS, toolName)
|
|
240
266
|
const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
241
267
|
const details: { error?: string } = {}
|
|
242
268
|
if (result.isError) {
|
|
@@ -114,6 +114,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
114
114
|
restoreTools()
|
|
115
115
|
ctx.ui.notify('Plan mode disabled. Full access restored.')
|
|
116
116
|
}
|
|
117
|
+
// Persist the toggle so a resume does not restore a state the user left.
|
|
118
|
+
persistState()
|
|
117
119
|
updateStatus(ctx)
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -158,6 +160,9 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
158
160
|
restoreTools()
|
|
159
161
|
updateStatus(ctx)
|
|
160
162
|
|
|
163
|
+
// Persist before the turn: a crash before the first turn_end must resume into
|
|
164
|
+
// execution, not back into plan mode.
|
|
165
|
+
persistState()
|
|
161
166
|
const execMessage = todoItems.length > 0 ? `Execute the plan. Start with: ${todoItems[0].text}` : 'Execute the plan you just created.'
|
|
162
167
|
pi.sendMessage({ customType: 'plan-mode-execute', content: execMessage, display: true }, { triggerTurn: true })
|
|
163
168
|
} else if (choice === 'Refine the plan') {
|
|
@@ -351,6 +356,13 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
351
356
|
|
|
352
357
|
// Restore state on session start/resume
|
|
353
358
|
pi.on('session_start', async (_event, ctx) => {
|
|
359
|
+
// One extension instance serves every session, so clear prior state first: a fresh
|
|
360
|
+
// session (/new, no plan entry) must not inherit the last session's plan or execution.
|
|
361
|
+
planModeEnabled = false
|
|
362
|
+
executionMode = false
|
|
363
|
+
todoItems = []
|
|
364
|
+
planFromTool = false
|
|
365
|
+
|
|
354
366
|
if (pi.getFlag('plan') === true) {
|
|
355
367
|
planModeEnabled = true
|
|
356
368
|
}
|
|
@@ -375,6 +387,10 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
375
387
|
|
|
376
388
|
if (planModeEnabled) {
|
|
377
389
|
enterPlanTools()
|
|
390
|
+
} else {
|
|
391
|
+
// A prior session in this instance may have shrunk the tool set; undo that when
|
|
392
|
+
// the restored/fresh state is not plan mode.
|
|
393
|
+
restoreTools()
|
|
378
394
|
}
|
|
379
395
|
updateStatus(ctx)
|
|
380
396
|
})
|
|
@@ -191,9 +191,11 @@ export function cleanStepText(text: string): string {
|
|
|
191
191
|
return cleaned
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
//
|
|
195
|
-
// the
|
|
196
|
-
|
|
194
|
+
// Anchored to line start (m flag) so a prose line merely ending in "plan:" is not taken
|
|
195
|
+
// for the header, which would slice the plan section mid-list and drop earlier steps.
|
|
196
|
+
// Horizontal whitespace only ([^\S\n]): \s would include \n itself and overlap the
|
|
197
|
+
// following \n, which is what backtracks super-linearly.
|
|
198
|
+
const PLAN_HEADER = /^[^\S\n]*\*{0,2}Plan:\*{0,2}[^\S\n]*\n/im
|
|
197
199
|
|
|
198
200
|
const isBlank = (ch: string | undefined): boolean => ch !== undefined && ch !== '\n' && ch.trim() === ''
|
|
199
201
|
|
|
@@ -40,6 +40,8 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
pi.on('session_start', async (_event, ctx) => {
|
|
43
|
+
// One instance serves every session, so a fresh session must not inherit the count.
|
|
44
|
+
turnCount = 0
|
|
43
45
|
showIdle(ctx, ctx.ui.theme.fg('dim', '○'))
|
|
44
46
|
})
|
|
45
47
|
|
|
@@ -83,6 +83,13 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
|
|
|
83
83
|
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
84
84
|
})
|
|
85
85
|
let stdout = ''
|
|
86
|
+
// Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
|
|
87
|
+
let completed = false
|
|
88
|
+
const complete = (): void => {
|
|
89
|
+
if (completed) return
|
|
90
|
+
completed = true
|
|
91
|
+
onComplete(run)
|
|
92
|
+
}
|
|
86
93
|
proc.stdout.on('data', (data) => {
|
|
87
94
|
stdout += data.toString()
|
|
88
95
|
})
|
|
@@ -92,12 +99,12 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
|
|
|
92
99
|
run.exitCode = code ?? 0
|
|
93
100
|
run.output = text
|
|
94
101
|
run.turns = turns
|
|
95
|
-
|
|
102
|
+
complete()
|
|
96
103
|
})
|
|
97
104
|
proc.on('error', () => {
|
|
98
105
|
run.state = 'failed'
|
|
99
106
|
run.exitCode = 1
|
|
100
|
-
|
|
107
|
+
complete()
|
|
101
108
|
})
|
|
102
109
|
return id
|
|
103
110
|
}
|
|
@@ -486,6 +486,10 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
|
|
|
486
486
|
for (const t of params.tasks ?? []) requestedAgentNames.add(t.agent)
|
|
487
487
|
const requestedProjectAgents = [...requestedAgentNames].map((name) => agents.find((a) => a.name === name)).filter((a): a is AgentConfig => a?.source === 'project')
|
|
488
488
|
|
|
489
|
+
// No project agents means nothing repo-controlled to gate; skip the approval check so a
|
|
490
|
+
// user-scope run never prompts or persists a trust decision it does not need.
|
|
491
|
+
if (requestedProjectAgents.length === 0) return null
|
|
492
|
+
|
|
489
493
|
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
490
494
|
const approved = await isProjectApproved(ctx)
|
|
491
495
|
const gate = projectAgentGate(requestedProjectAgents.length, approved, ctx.hasUI, params.confirmProjectAgents ?? true)
|
|
@@ -692,8 +696,11 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
|
|
|
692
696
|
signal,
|
|
693
697
|
// Per-task update callback
|
|
694
698
|
onUpdate: (partial) => {
|
|
695
|
-
|
|
696
|
-
|
|
699
|
+
const live = partial.details?.results[0]
|
|
700
|
+
if (live) {
|
|
701
|
+
// Keep the running sentinel until the child closes: the streamed result carries
|
|
702
|
+
// exitCode 0 mid-run, which would otherwise count and render the task as done.
|
|
703
|
+
allResults[index] = { ...live, exitCode: -1 }
|
|
697
704
|
emitParallelUpdate()
|
|
698
705
|
}
|
|
699
706
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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"
|