pi-code 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/extensions/git-checkpoint.ts +15 -4
- package/extensions/hooks.ts +75 -5
- package/extensions/internal/web-transport.ts +19 -6
- package/extensions/mcp.ts +78 -22
- package/extensions/plan-mode/index.ts +16 -0
- package/extensions/plan-mode/utils.ts +5 -3
- package/extensions/question.ts +70 -28
- package/extensions/status-line.ts +2 -0
- package/extensions/subagent/background.ts +13 -5
- package/extensions/subagent/index.ts +14 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,10 +38,10 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
38
38
|
| Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
|
|
39
39
|
| Custom slash commands | `.claude/commands/*.md` → pi prompt templates | `commands.ts` |
|
|
40
40
|
| Skills | `.claude/skills` → pi skill discovery (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
|
|
41
|
-
| Hooks | `.claude/settings.json` hooks
|
|
41
|
+
| Hooks | `.claude/settings.json` hooks: PreToolUse, PostToolUse, SessionStart, UserPromptSubmit (blocks and injects context), Stop, PreCompact, SessionEnd | `hooks.ts` |
|
|
42
42
|
| Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.ts` |
|
|
43
43
|
| CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips; loads `CLAUDE.local.md` (approval-gated) | `context-imports.ts` |
|
|
44
|
-
| MCP servers | user `~/.claude.json
|
|
44
|
+
| MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT` | `mcp.ts` |
|
|
45
45
|
| Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles) that pi would otherwise trust silently | `internal/project-approval.ts` |
|
|
46
46
|
| Subagents / Task | `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; background runs | `subagent/` |
|
|
47
47
|
| Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
|
|
@@ -49,7 +49,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
49
49
|
| Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later | `git-checkpoint.ts` |
|
|
50
50
|
| Persistent memory | per-project memories, index injected each session | `memory.ts` |
|
|
51
51
|
| WebSearch / WebFetch | key-free DuckDuckGo search, SSRF-guarded fetch | `web.ts` |
|
|
52
|
-
| AskUserQuestion |
|
|
52
|
+
| AskUserQuestion | one question with `header`, single- or `multiSelect` options, plus free-text; no multi-question batching | `question.ts` |
|
|
53
53
|
| Statusline | turn state + session cost | `status-line.ts` |
|
|
54
54
|
| Notifications | vendored example | `notify.ts` |
|
|
55
55
|
|
|
@@ -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
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs Claude Code's `.claude/settings.json` hooks on pi's lifecycle events, so
|
|
5
5
|
* a project's existing hooks work under pi:
|
|
6
|
-
* - PreToolUse
|
|
7
|
-
* - PostToolUse
|
|
8
|
-
* - SessionStart-> pi `session_start` (fire-and-forget)
|
|
6
|
+
* - PreToolUse -> pi `tool_call` (can block the tool)
|
|
7
|
+
* - PostToolUse -> pi `tool_execution_end` (fire-and-forget)
|
|
8
|
+
* - SessionStart -> pi `session_start` (fire-and-forget)
|
|
9
|
+
* - UserPromptSubmit-> pi `input` (can block the prompt via `handled`, or inject
|
|
10
|
+
* additional context by transforming the submitted text)
|
|
11
|
+
* - Stop -> pi `agent_end` (fire-and-forget; cannot prevent stopping)
|
|
12
|
+
* - PreCompact -> pi `session_before_compact` (fire-and-forget)
|
|
13
|
+
* - SessionEnd -> pi `session_shutdown` (fire-and-forget)
|
|
14
|
+
*
|
|
15
|
+
* Claude's SubagentStop has no pi lifecycle seam (the subagent tool spawns child pi
|
|
16
|
+
* processes, and pi emits no subagent-completion event), so it is not bridged.
|
|
9
17
|
*
|
|
10
18
|
* Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
|
|
11
19
|
* hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
|
|
@@ -102,7 +110,7 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, name: stri
|
|
|
102
110
|
return result
|
|
103
111
|
}
|
|
104
112
|
|
|
105
|
-
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; decision?: string; reason?: string } | undefined {
|
|
113
|
+
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string }; decision?: string; reason?: string; continue?: boolean; stopReason?: string } | undefined {
|
|
106
114
|
try {
|
|
107
115
|
return JSON.parse(text)
|
|
108
116
|
} catch {
|
|
@@ -115,8 +123,11 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
|
|
|
115
123
|
if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
|
|
116
124
|
const parsed = tryParseJson(stdout)
|
|
117
125
|
const specific = parsed?.hookSpecificOutput
|
|
118
|
-
|
|
126
|
+
// pi's tool_call return is allow-or-block, so "ask" (confirm) maps to block-with-reason
|
|
127
|
+
// rather than a silent allow, which is the least-safe reading on a trust-gated path.
|
|
128
|
+
if (specific?.permissionDecision === 'deny' || specific?.permissionDecision === 'ask') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
|
|
119
129
|
if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
|
|
130
|
+
if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
|
|
120
131
|
return { block: false }
|
|
121
132
|
}
|
|
122
133
|
|
|
@@ -214,6 +225,35 @@ async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner:
|
|
|
214
225
|
await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
|
|
215
226
|
}
|
|
216
227
|
|
|
228
|
+
export interface PromptDecision {
|
|
229
|
+
block: boolean
|
|
230
|
+
reason?: string
|
|
231
|
+
context: string
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Additional context a UserPromptSubmit hook contributes: an explicit
|
|
235
|
+
* hookSpecificOutput.additionalContext, or the raw stdout of a plain exit-0 hook. */
|
|
236
|
+
function promptContext(stdout: string): string {
|
|
237
|
+
const parsed = tryParseJson(stdout)
|
|
238
|
+
if (parsed) return parsed.hookSpecificOutput?.additionalContext ?? ''
|
|
239
|
+
return stdout.trim()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Run UserPromptSubmit hooks: the first blocking verdict wins; otherwise their
|
|
243
|
+
* additional context is concatenated for injection ahead of the prompt. */
|
|
244
|
+
export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner): Promise<PromptDecision> {
|
|
245
|
+
const contexts: string[] = []
|
|
246
|
+
for (const command of matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')) {
|
|
247
|
+
const result = await runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))
|
|
248
|
+
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}`, context: '' }
|
|
249
|
+
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
250
|
+
if (decision.block) return { block: true, reason: decision.reason, context: '' }
|
|
251
|
+
const context = promptContext(result.stdout)
|
|
252
|
+
if (context) contexts.push(context)
|
|
253
|
+
}
|
|
254
|
+
return { block: false, context: contexts.join('\n') }
|
|
255
|
+
}
|
|
256
|
+
|
|
217
257
|
/** Bound on remembered tool inputs, in case a blocked or aborted call never ends. */
|
|
218
258
|
const MAX_PENDING_INPUTS = 100
|
|
219
259
|
|
|
@@ -255,4 +295,34 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
255
295
|
if (event.isError) return
|
|
256
296
|
await runNotifyHooks(matchingCommands(config.PostToolUse, event.toolName), { hook_event_name: 'PostToolUse', tool_name: event.toolName, tool_input: toolInput, tool_response: event.result }, runner)
|
|
257
297
|
})
|
|
298
|
+
|
|
299
|
+
pi.on('input', async (event, ctx) => {
|
|
300
|
+
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
301
|
+
// prompts the user submitted.
|
|
302
|
+
if (event.source === 'extension') return { action: 'continue' }
|
|
303
|
+
const decision = await runUserPromptSubmit(config, event.text, runner)
|
|
304
|
+
if (decision.block) {
|
|
305
|
+
// pi's input result has no reason channel, so surface why before consuming it.
|
|
306
|
+
ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
|
|
307
|
+
return { action: 'handled' }
|
|
308
|
+
}
|
|
309
|
+
// Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
|
|
310
|
+
// pi's seam for rewriting the submitted text.
|
|
311
|
+
if (decision.context) return { action: 'transform', text: `${decision.context}\n\n${event.text}` }
|
|
312
|
+
return { action: 'continue' }
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// Notify-style Claude events with a matching pi lifecycle seam. None can block: pi's
|
|
316
|
+
// agent_end, session_before_compact and session_shutdown are fire-and-forget here.
|
|
317
|
+
pi.on('agent_end', async () => {
|
|
318
|
+
await runNotifyHooks(matchingCommands(config.Stop, 'Stop'), { hook_event_name: 'Stop' }, runner)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
pi.on('session_before_compact', async (event) => {
|
|
322
|
+
await runNotifyHooks(matchingCommands(config.PreCompact, event.reason), { hook_event_name: 'PreCompact', trigger: event.reason }, runner)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
pi.on('session_shutdown', async (event) => {
|
|
326
|
+
await runNotifyHooks(matchingCommands(config.SessionEnd, event.reason), { hook_event_name: 'SessionEnd', reason: event.reason }, runner)
|
|
327
|
+
})
|
|
258
328
|
}
|
|
@@ -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
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
* failures skip with a notice; stdio and HTTP (streamable with SSE fallback)
|
|
8
8
|
* transports; /mcp shows status.
|
|
9
9
|
*
|
|
10
|
-
* Reads Claude Code's MCP config too. User config (~/.claude.json
|
|
11
|
-
* ~/.pi/agent/mcp.json) is the
|
|
12
|
-
* config (.mcp.json, .pi/mcp.json)
|
|
13
|
-
* loads only once the project is approved
|
|
14
|
-
* are loaded separately, not merged; user config
|
|
15
|
-
* server cannot take the name of a user server that connected.
|
|
16
|
-
* Values support ${VAR} interpolation, and
|
|
17
|
-
*
|
|
10
|
+
* Reads Claude Code's MCP config too. User config (~/.claude.json top-level plus its
|
|
11
|
+
* per-project `projects[cwd].mcpServers` local scope, and ~/.pi/agent/mcp.json) is the
|
|
12
|
+
* user's own and loads on the first session. Project config (.mcp.json, .pi/mcp.json)
|
|
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; user config
|
|
15
|
+
* connects first, so a project server cannot take the name of a user server that connected.
|
|
16
|
+
* Values support ${VAR} / ${VAR:-default} interpolation, connect and per-call timeouts
|
|
17
|
+
* honor MCP_TIMEOUT / MCP_TOOL_TIMEOUT, and a stdio server receives only the SDK's default
|
|
18
|
+
* environment plus its own `env` block, not the whole process environment.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import * as fs from 'node:fs'
|
|
@@ -31,8 +32,20 @@ import { Type } from 'typebox'
|
|
|
31
32
|
import { capForContext } from './internal/output-guard.js'
|
|
32
33
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
33
34
|
|
|
34
|
-
const
|
|
35
|
-
const
|
|
35
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
|
36
|
+
const DEFAULT_CALL_TIMEOUT_MS = 120_000
|
|
37
|
+
|
|
38
|
+
/** A positive-integer env override, or the default when unset or unparseable. */
|
|
39
|
+
function envTimeout(name: string, fallback: number): number {
|
|
40
|
+
const raw = process.env[name]
|
|
41
|
+
if (raw === undefined) return fallback
|
|
42
|
+
const value = Number.parseInt(raw, 10)
|
|
43
|
+
return Number.isInteger(value) && value > 0 ? value : fallback
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
|
|
47
|
+
const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
|
|
48
|
+
const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
|
|
36
49
|
// Tool names an MCP server must never take over. formatToolName always emits
|
|
37
50
|
// `<server>_<tool>`, so only names containing an underscore are actually reachable:
|
|
38
51
|
// pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
|
|
@@ -91,6 +104,23 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
|
91
104
|
return servers
|
|
92
105
|
}
|
|
93
106
|
|
|
107
|
+
/**
|
|
108
|
+
* All user-owned servers for this session: the global user servers plus Claude's "local"
|
|
109
|
+
* scope, the per-project user servers under `projects[cwd].mcpServers` in ~/.claude.json.
|
|
110
|
+
* Both are the user's own config, so neither needs project trust; local wins on a name
|
|
111
|
+
* clash (Claude's precedence is local over user).
|
|
112
|
+
*/
|
|
113
|
+
export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
|
|
114
|
+
const servers = loadConfigFrom(userConfigPaths(home))
|
|
115
|
+
try {
|
|
116
|
+
const claudeJson = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf-8'))
|
|
117
|
+
Object.assign(servers, claudeJson.projects?.[cwd]?.mcpServers ?? {})
|
|
118
|
+
} catch {
|
|
119
|
+
// missing or invalid ~/.claude.json: the top-level user servers already loaded
|
|
120
|
+
}
|
|
121
|
+
return servers
|
|
122
|
+
}
|
|
123
|
+
|
|
94
124
|
export function formatToolName(server: string, tool: string): string {
|
|
95
125
|
return `${server}_${tool}`.replaceAll('-', '_')
|
|
96
126
|
}
|
|
@@ -113,20 +143,24 @@ interface McpContentBlock {
|
|
|
113
143
|
export type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
|
|
114
144
|
|
|
115
145
|
export function mapContent(content: McpContentBlock[] | undefined, structured?: unknown): ToolContent[] {
|
|
146
|
+
// capForContext every text output, whatever its source: a server can blow the tool-output
|
|
147
|
+
// budget through a resource block, a JSON-stringified block, or the structured fallback,
|
|
148
|
+
// not only a text block.
|
|
149
|
+
const text = (value: string): ToolContent => ({ type: 'text', text: capForContext(value) })
|
|
116
150
|
if (!content || content.length === 0) {
|
|
117
|
-
return [
|
|
151
|
+
return [text(structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)')]
|
|
118
152
|
}
|
|
119
153
|
return content.map((block) => {
|
|
120
154
|
if (block.type === 'text') {
|
|
121
|
-
return
|
|
155
|
+
return text(block.text ?? '')
|
|
122
156
|
}
|
|
123
157
|
if (block.type === 'image' && block.data) {
|
|
124
158
|
return { type: 'image', data: block.data, mimeType: block.mimeType ?? 'image/png' }
|
|
125
159
|
}
|
|
126
160
|
if (block.type === 'resource' && block.resource) {
|
|
127
|
-
return
|
|
161
|
+
return text(`[Resource: ${block.resource.uri ?? 'unknown'}]\n${block.resource.text ?? ''}`)
|
|
128
162
|
}
|
|
129
|
-
return
|
|
163
|
+
return text(JSON.stringify(block))
|
|
130
164
|
})
|
|
131
165
|
}
|
|
132
166
|
|
|
@@ -165,33 +199,53 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
|
165
199
|
cwd: config.cwd?.replace(/^~(?=\/|$)/, os.homedir()),
|
|
166
200
|
stderr: 'ignore',
|
|
167
201
|
})
|
|
168
|
-
await
|
|
202
|
+
await connectWithTimeout(client, transport, `connect ${name}`)
|
|
169
203
|
return client
|
|
170
204
|
}
|
|
171
205
|
const headers: Record<string, string> = {}
|
|
172
206
|
for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = interpolateEnv(value)
|
|
173
|
-
const token = config.bearerToken
|
|
207
|
+
const token = config.bearerToken ? interpolateEnv(config.bearerToken) : config.bearerTokenEnv ? process.env[config.bearerTokenEnv] : undefined
|
|
174
208
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
175
209
|
const url = new URL(interpolateEnv(config.url))
|
|
176
210
|
if (config.type === 'sse') {
|
|
177
211
|
const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: explicitly declared legacy transport
|
|
178
|
-
await
|
|
212
|
+
await connectWithTimeout(client, transport, `connect ${name} (sse)`)
|
|
179
213
|
return client
|
|
180
214
|
}
|
|
181
215
|
try {
|
|
182
216
|
const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers } })
|
|
183
|
-
await
|
|
217
|
+
await connectWithTimeout(client, transport, `connect ${name}`)
|
|
184
218
|
return client
|
|
185
219
|
} catch (error) {
|
|
186
220
|
// An explicitly declared streamable transport must not silently degrade to SSE.
|
|
187
221
|
if (config.type !== undefined || String(error).includes('Unauthorized')) throw error
|
|
188
222
|
const fallback = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
189
223
|
const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: deliberate legacy fallback
|
|
190
|
-
await
|
|
224
|
+
await connectWithTimeout(fallback, transport, `connect ${name} (sse)`)
|
|
191
225
|
return fallback
|
|
192
226
|
}
|
|
193
227
|
}
|
|
194
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Connect with a deadline, closing the client if the deadline (not a connect error) wins.
|
|
231
|
+
* Without this, a slow-but-successful server finishes connecting after the race is lost and
|
|
232
|
+
* lingers unreferenced: process/socket alive, never in `clients`, invisible to shutdown.
|
|
233
|
+
*/
|
|
234
|
+
async function connectWithTimeout(client: Client, transport: Parameters<Client['connect']>[0], label: string): Promise<void> {
|
|
235
|
+
const connecting = client.connect(transport)
|
|
236
|
+
try {
|
|
237
|
+
await withTimeout(connecting, connectTimeoutMs(), label)
|
|
238
|
+
} catch (error) {
|
|
239
|
+
// Only a timeout can orphan a still-opening transport; a connect rejection means the
|
|
240
|
+
// SDK already tore it down, so closing again would be redundant.
|
|
241
|
+
if (String(error).includes('timed out after')) {
|
|
242
|
+
connecting.catch(() => {}) // a late rejection must not surface as unhandled
|
|
243
|
+
void client.close().catch(() => {})
|
|
244
|
+
}
|
|
245
|
+
throw error
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
195
249
|
async function listAllTools(client: Client): Promise<Array<{ name: string; description?: string; inputSchema?: unknown }>> {
|
|
196
250
|
const tools: Array<{ name: string; description?: string; inputSchema?: unknown }> = []
|
|
197
251
|
let cursor: string | undefined
|
|
@@ -220,7 +274,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
220
274
|
try {
|
|
221
275
|
const client = await connect(name, config)
|
|
222
276
|
clients.set(name, client)
|
|
223
|
-
const tools = await withTimeout(listAllTools(client),
|
|
277
|
+
const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
|
|
224
278
|
let count = 0
|
|
225
279
|
for (const tool of tools) {
|
|
226
280
|
const toolName = formatToolName(name, tool.name)
|
|
@@ -236,7 +290,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
236
290
|
description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
|
|
237
291
|
parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
|
|
238
292
|
async execute(_id, params) {
|
|
239
|
-
|
|
293
|
+
// Pass the timeout to the SDK too: its own default request timeout is 60s and
|
|
294
|
+
// would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
|
|
295
|
+
const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: callTimeoutMs() }), callTimeoutMs(), toolName)
|
|
240
296
|
const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
241
297
|
const details: { error?: string } = {}
|
|
242
298
|
if (result.isError) {
|
|
@@ -263,7 +319,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
263
319
|
// the factory: pi runs the factory for invocations that never start a session.
|
|
264
320
|
if (!userConnected) {
|
|
265
321
|
userConnected = true
|
|
266
|
-
await connectServers(
|
|
322
|
+
await connectServers(loadUserScope(os.homedir(), ctx.cwd))
|
|
267
323
|
}
|
|
268
324
|
// A project .mcp.json can run arbitrary commands on connect, so only honor it once the project is trusted.
|
|
269
325
|
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
@@ -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
|
|
package/extensions/question.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Question Tool -
|
|
3
|
-
* Full custom UI: options list + inline editor for "Type something..."
|
|
4
|
-
*
|
|
2
|
+
* Question Tool - a question with options, single- or multi-select.
|
|
3
|
+
* Full custom UI: options list + inline editor for "Type something..." (single-select
|
|
4
|
+
* only), or space-toggled checkboxes when `multiSelect` is set. An optional `header`
|
|
5
|
+
* labels the question. Escape in the editor returns to options; Escape in options cancels.
|
|
6
|
+
* Multiple questions per call are not batched; ask sequentially.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import type { ExtensionAPI, Theme } from '@earendil-works/pi-coding-agent'
|
|
@@ -17,9 +19,11 @@ type DisplayOption = OptionWithDesc & { isOther?: boolean }
|
|
|
17
19
|
|
|
18
20
|
interface QuestionDetails {
|
|
19
21
|
question: string
|
|
22
|
+
header?: string
|
|
20
23
|
options: string[]
|
|
21
24
|
answer: string | null
|
|
22
25
|
wasCustom?: boolean
|
|
26
|
+
multiSelect?: boolean
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
// Options with labels and optional descriptions
|
|
@@ -30,11 +34,18 @@ const OptionSchema = Type.Object({
|
|
|
30
34
|
|
|
31
35
|
const QuestionParams = Type.Object({
|
|
32
36
|
question: Type.String({ description: 'The question to ask the user' }),
|
|
37
|
+
header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it' })),
|
|
33
38
|
options: Type.Array(OptionSchema, { description: 'Options for the user to choose from' }),
|
|
39
|
+
multiSelect: Type.Optional(Type.Boolean({ description: 'Allow selecting several options (space toggles, enter confirms)' })),
|
|
34
40
|
})
|
|
35
41
|
|
|
36
|
-
function
|
|
37
|
-
|
|
42
|
+
function checkbox(checked: boolean | undefined): string {
|
|
43
|
+
if (checked === undefined) return ''
|
|
44
|
+
return checked ? '[x] ' : '[ ] '
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function optionLine(opt: DisplayOption, index: number, selected: boolean, editMode: boolean, checked: boolean | undefined, theme: Theme): string {
|
|
48
|
+
const label = `${index + 1}. ${checkbox(checked)}${opt.label}`
|
|
38
49
|
const prefix = selected ? theme.fg('accent', '> ') : ' '
|
|
39
50
|
if (opt.isOther === true && editMode) {
|
|
40
51
|
return prefix + theme.fg('accent', `${label} ✎`)
|
|
@@ -48,25 +59,30 @@ function optionLine(opt: DisplayOption, index: number, selected: boolean, editMo
|
|
|
48
59
|
interface QuestionView {
|
|
49
60
|
width: number
|
|
50
61
|
question: string
|
|
62
|
+
header?: string
|
|
51
63
|
options: DisplayOption[]
|
|
52
64
|
optionIndex: number
|
|
53
65
|
editMode: boolean
|
|
66
|
+
multiSelect: boolean
|
|
67
|
+
checked: boolean[]
|
|
54
68
|
editor: Editor
|
|
55
69
|
theme: Theme
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
function buildQuestionLines(view: QuestionView): string[] {
|
|
59
|
-
const { width, question, options, optionIndex, editMode, editor, theme } = view
|
|
73
|
+
const { width, question, header, options, optionIndex, editMode, multiSelect, checked, editor, theme } = view
|
|
60
74
|
const lines: string[] = []
|
|
61
75
|
const add = (s: string) => lines.push(truncateToWidth(s, width))
|
|
62
76
|
|
|
63
77
|
add(theme.fg('accent', '─'.repeat(width)))
|
|
78
|
+
if (header) add(theme.fg('muted', ` [${header}]`))
|
|
64
79
|
add(theme.fg('text', ` ${question}`))
|
|
65
80
|
lines.push('')
|
|
66
81
|
|
|
67
82
|
for (let i = 0; i < options.length; i++) {
|
|
68
83
|
const opt = options[i]
|
|
69
|
-
|
|
84
|
+
const box = multiSelect && opt.isOther !== true ? checked[i] : undefined
|
|
85
|
+
add(optionLine(opt, i, i === optionIndex, editMode, box, theme))
|
|
70
86
|
if (opt.description) {
|
|
71
87
|
add(` ${theme.fg('muted', opt.description)}`)
|
|
72
88
|
}
|
|
@@ -81,12 +97,26 @@ function buildQuestionLines(view: QuestionView): string[] {
|
|
|
81
97
|
}
|
|
82
98
|
|
|
83
99
|
lines.push('')
|
|
84
|
-
add(theme.fg('dim', editMode
|
|
100
|
+
add(theme.fg('dim', navHint(editMode, multiSelect)))
|
|
85
101
|
add(theme.fg('accent', '─'.repeat(width)))
|
|
86
102
|
|
|
87
103
|
return lines
|
|
88
104
|
}
|
|
89
105
|
|
|
106
|
+
function navHint(editMode: boolean, multiSelect: boolean): string {
|
|
107
|
+
if (editMode) return ' Enter to submit • Esc to go back'
|
|
108
|
+
if (multiSelect) return ' ↑↓ navigate • Space to toggle • Enter to confirm • Esc to cancel'
|
|
109
|
+
return ' ↑↓ navigate • Enter to select • Esc to cancel'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The comma-joined labels of the checked options, in order. */
|
|
113
|
+
function selectedLabels(options: DisplayOption[], checked: boolean[]): string {
|
|
114
|
+
return options
|
|
115
|
+
.filter((_, i) => checked[i])
|
|
116
|
+
.map((o) => o.label)
|
|
117
|
+
.join(', ')
|
|
118
|
+
}
|
|
119
|
+
|
|
90
120
|
export default function question(pi: ExtensionAPI) {
|
|
91
121
|
pi.registerTool({
|
|
92
122
|
name: 'question',
|
|
@@ -113,11 +143,14 @@ export default function question(pi: ExtensionAPI) {
|
|
|
113
143
|
}
|
|
114
144
|
}
|
|
115
145
|
|
|
116
|
-
const
|
|
146
|
+
const multiSelect = params.multiSelect === true
|
|
147
|
+
// The free-text option does not compose with checkbox selection, so it is single-select only.
|
|
148
|
+
const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }]
|
|
117
149
|
|
|
118
150
|
const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>((tui, theme, _kb, done) => {
|
|
119
151
|
let optionIndex = 0
|
|
120
152
|
let editMode = false
|
|
153
|
+
const checked: boolean[] = allOptions.map(() => false)
|
|
121
154
|
let cachedLines: string[] | undefined
|
|
122
155
|
let cachedWidth: number | undefined
|
|
123
156
|
|
|
@@ -173,7 +206,17 @@ export default function question(pi: ExtensionAPI) {
|
|
|
173
206
|
return
|
|
174
207
|
}
|
|
175
208
|
|
|
209
|
+
if (multiSelect && data === ' ') {
|
|
210
|
+
checked[optionIndex] = !checked[optionIndex]
|
|
211
|
+
refresh()
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
176
215
|
if (matchesKey(data, Key.enter)) {
|
|
216
|
+
if (multiSelect) {
|
|
217
|
+
done({ answer: selectedLabels(allOptions, checked), wasCustom: false })
|
|
218
|
+
return
|
|
219
|
+
}
|
|
177
220
|
const selected = allOptions[optionIndex]
|
|
178
221
|
if (selected.isOther) {
|
|
179
222
|
editMode = true
|
|
@@ -192,7 +235,7 @@ export default function question(pi: ExtensionAPI) {
|
|
|
192
235
|
function render(width: number): string[] {
|
|
193
236
|
if (cachedLines && cachedWidth === width) return cachedLines
|
|
194
237
|
cachedWidth = width
|
|
195
|
-
cachedLines = buildQuestionLines({ width, question: params.question, options: allOptions, optionIndex, editMode, editor, theme })
|
|
238
|
+
cachedLines = buildQuestionLines({ width, question: params.question, header: params.header, options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme })
|
|
196
239
|
return cachedLines
|
|
197
240
|
}
|
|
198
241
|
|
|
@@ -206,45 +249,41 @@ export default function question(pi: ExtensionAPI) {
|
|
|
206
249
|
}
|
|
207
250
|
})
|
|
208
251
|
|
|
209
|
-
// Build simple options list for details
|
|
252
|
+
// Build simple options list for details; header/multiSelect appear only when set,
|
|
253
|
+
// so single-select details are unchanged.
|
|
210
254
|
const simpleOptions = params.options.map((o) => o.label)
|
|
255
|
+
const base = { question: params.question, options: simpleOptions, ...(params.header ? { header: params.header } : {}), ...(multiSelect ? { multiSelect: true } : {}) }
|
|
211
256
|
|
|
212
257
|
if (!result) {
|
|
213
258
|
return {
|
|
214
259
|
content: [{ type: 'text', text: 'User cancelled the selection' }],
|
|
215
|
-
details: {
|
|
260
|
+
details: { ...base, answer: null } as QuestionDetails,
|
|
216
261
|
}
|
|
217
262
|
}
|
|
218
263
|
|
|
219
264
|
if (result.wasCustom) {
|
|
220
265
|
return {
|
|
221
266
|
content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
|
|
222
|
-
details: {
|
|
223
|
-
question: params.question,
|
|
224
|
-
options: simpleOptions,
|
|
225
|
-
answer: result.answer,
|
|
226
|
-
wasCustom: true,
|
|
227
|
-
} as QuestionDetails,
|
|
267
|
+
details: { ...base, answer: result.answer, wasCustom: true } as QuestionDetails,
|
|
228
268
|
}
|
|
229
269
|
}
|
|
270
|
+
const selectionText = multiSelect ? `User selected: ${result.answer || '(none)'}` : `User selected: ${result.index}. ${result.answer}`
|
|
230
271
|
return {
|
|
231
|
-
content: [{ type: 'text', text:
|
|
232
|
-
details: {
|
|
233
|
-
question: params.question,
|
|
234
|
-
options: simpleOptions,
|
|
235
|
-
answer: result.answer,
|
|
236
|
-
wasCustom: false,
|
|
237
|
-
} as QuestionDetails,
|
|
272
|
+
content: [{ type: 'text', text: selectionText }],
|
|
273
|
+
details: { ...base, answer: result.answer, wasCustom: false } as QuestionDetails,
|
|
238
274
|
}
|
|
239
275
|
},
|
|
240
276
|
|
|
241
277
|
renderCall(args, theme, _context) {
|
|
242
|
-
|
|
278
|
+
const multi = args.multiSelect === true
|
|
279
|
+
const heading = args.header ? `[${args.header}] ` : ''
|
|
280
|
+
let text = theme.fg('toolTitle', theme.bold('question ')) + theme.fg('muted', heading + String(args.question ?? ''))
|
|
243
281
|
const opts = Array.isArray(args.options) ? args.options : []
|
|
244
282
|
if (opts.length) {
|
|
245
283
|
const labels = opts.map((o: OptionWithDesc) => o.label)
|
|
246
|
-
const
|
|
247
|
-
const
|
|
284
|
+
const shown = multi ? labels : [...labels, 'Type something.']
|
|
285
|
+
const numbered = shown.map((o, i) => `${i + 1}. ${o}`)
|
|
286
|
+
const optionsLine = ` Options${multi ? ' (multi)' : ''}: ${numbered.join(', ')}`
|
|
248
287
|
text += `\n${theme.fg('dim', optionsLine)}`
|
|
249
288
|
}
|
|
250
289
|
return new Text(text, 0, 0)
|
|
@@ -264,6 +303,9 @@ export default function question(pi: ExtensionAPI) {
|
|
|
264
303
|
if (details.wasCustom) {
|
|
265
304
|
return new Text(theme.fg('success', '✓ ') + theme.fg('muted', '(wrote) ') + theme.fg('accent', details.answer), 0, 0)
|
|
266
305
|
}
|
|
306
|
+
if (details.multiSelect) {
|
|
307
|
+
return new Text(theme.fg('success', '✓ ') + theme.fg('accent', details.answer || '(none)'), 0, 0)
|
|
308
|
+
}
|
|
267
309
|
const idx = details.options.indexOf(details.answer) + 1
|
|
268
310
|
const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer
|
|
269
311
|
return new Text(theme.fg('success', '✓ ') + theme.fg('accent', display), 0, 0)
|
|
@@ -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
|
|
|
@@ -47,9 +47,10 @@ export function parseFinalOutputFromJsonl(jsonl: string): { text: string; turns:
|
|
|
47
47
|
}
|
|
48
48
|
if (event.type !== 'message_end' || event.message?.role !== 'assistant') continue
|
|
49
49
|
turns++
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// The complete text of the last assistant message, matching getFinalOutput on the
|
|
51
|
+
// foreground path so a multi-part message reads the same in both.
|
|
52
|
+
const parts = (event.message.content ?? []).filter((p) => p.type === 'text' && p.text).map((p) => p.text as string)
|
|
53
|
+
if (parts.length > 0) text = parts.join('\n')
|
|
53
54
|
}
|
|
54
55
|
return { text, turns }
|
|
55
56
|
}
|
|
@@ -83,6 +84,13 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
|
|
|
83
84
|
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
84
85
|
})
|
|
85
86
|
let stdout = ''
|
|
87
|
+
// Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
|
|
88
|
+
let completed = false
|
|
89
|
+
const complete = (): void => {
|
|
90
|
+
if (completed) return
|
|
91
|
+
completed = true
|
|
92
|
+
onComplete(run)
|
|
93
|
+
}
|
|
86
94
|
proc.stdout.on('data', (data) => {
|
|
87
95
|
stdout += data.toString()
|
|
88
96
|
})
|
|
@@ -92,12 +100,12 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
|
|
|
92
100
|
run.exitCode = code ?? 0
|
|
93
101
|
run.output = text
|
|
94
102
|
run.turns = turns
|
|
95
|
-
|
|
103
|
+
complete()
|
|
96
104
|
})
|
|
97
105
|
proc.on('error', () => {
|
|
98
106
|
run.state = 'failed'
|
|
99
107
|
run.exitCode = 1
|
|
100
|
-
|
|
108
|
+
complete()
|
|
101
109
|
})
|
|
102
110
|
return id
|
|
103
111
|
}
|
|
@@ -160,9 +160,10 @@ export function getFinalOutput(messages: Message[]): string {
|
|
|
160
160
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
161
161
|
const msg = messages[i]
|
|
162
162
|
if (msg.role === 'assistant') {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
// The complete text of the last assistant message: a message can carry more than one
|
|
164
|
+
// text part, and taking only the first diverged from the background parser.
|
|
165
|
+
const parts = msg.content.filter((part) => part.type === 'text').map((part) => part.text)
|
|
166
|
+
if (parts.length > 0) return parts.join('\n')
|
|
166
167
|
}
|
|
167
168
|
}
|
|
168
169
|
return ''
|
|
@@ -486,6 +487,10 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
|
|
|
486
487
|
for (const t of params.tasks ?? []) requestedAgentNames.add(t.agent)
|
|
487
488
|
const requestedProjectAgents = [...requestedAgentNames].map((name) => agents.find((a) => a.name === name)).filter((a): a is AgentConfig => a?.source === 'project')
|
|
488
489
|
|
|
490
|
+
// No project agents means nothing repo-controlled to gate; skip the approval check so a
|
|
491
|
+
// user-scope run never prompts or persists a trust decision it does not need.
|
|
492
|
+
if (requestedProjectAgents.length === 0) return null
|
|
493
|
+
|
|
489
494
|
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
490
495
|
const approved = await isProjectApproved(ctx)
|
|
491
496
|
const gate = projectAgentGate(requestedProjectAgents.length, approved, ctx.hasUI, params.confirmProjectAgents ?? true)
|
|
@@ -634,7 +639,7 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
|
|
|
634
639
|
details: makeDetails('chain')(results),
|
|
635
640
|
}
|
|
636
641
|
}
|
|
637
|
-
previousOutput = getFinalOutput(result.messages)
|
|
642
|
+
previousOutput = capForContext(getFinalOutput(result.messages))
|
|
638
643
|
}
|
|
639
644
|
return {
|
|
640
645
|
content: [{ type: 'text', text: capForContext(getFinalOutput(results.at(-1)?.messages ?? [])) || '(no output)' }],
|
|
@@ -692,8 +697,11 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
|
|
|
692
697
|
signal,
|
|
693
698
|
// Per-task update callback
|
|
694
699
|
onUpdate: (partial) => {
|
|
695
|
-
|
|
696
|
-
|
|
700
|
+
const live = partial.details?.results[0]
|
|
701
|
+
if (live) {
|
|
702
|
+
// Keep the running sentinel until the child closes: the streamed result carries
|
|
703
|
+
// exitCode 0 mid-run, which would otherwise count and render the task as done.
|
|
704
|
+
allResults[index] = { ...live, exitCode: -1 }
|
|
697
705
|
emitParallelUpdate()
|
|
698
706
|
}
|
|
699
707
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|