pi-code 1.0.55 → 1.0.57
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 +2 -2
- package/extensions/commands.ts +6 -12
- package/extensions/context-imports.ts +2 -10
- package/extensions/env-settings.ts +1 -5
- package/extensions/git-checkpoint.ts +4 -12
- package/extensions/goal.ts +2 -2
- package/extensions/hooks/config.ts +6 -21
- package/extensions/hooks/decisions.ts +3 -2
- package/extensions/hooks/index.ts +4 -16
- package/extensions/hooks/matcher.ts +2 -1
- package/extensions/hooks/runners.ts +10 -4
- package/extensions/internal/command-file.ts +7 -241
- package/extensions/internal/command-spans.ts +246 -0
- package/extensions/internal/managed-settings.ts +3 -5
- package/extensions/internal/plugins.ts +2 -2
- package/extensions/internal/settings-chain.ts +19 -0
- package/extensions/internal/values.ts +38 -0
- package/extensions/mcp/index.ts +6 -5
- package/extensions/mcp/listing.ts +2 -1
- package/extensions/mcp/oauth-flow.ts +2 -1
- package/extensions/mcp/policy.ts +9 -2
- package/extensions/memory.ts +10 -15
- package/extensions/output-styles.ts +4 -17
- package/extensions/plan-mode/index.ts +9 -9
- package/extensions/plan-mode/utils.ts +31 -0
- package/extensions/session-title.ts +2 -12
- package/extensions/skills.ts +6 -18
- package/extensions/status-line.ts +2 -8
- package/extensions/subagent/README.md +15 -5
- package/extensions/subagent/agents.ts +2 -2
- package/extensions/subagent/background.ts +2 -1
- package/extensions/subagent/child.ts +197 -0
- package/extensions/subagent/concurrency.ts +23 -0
- package/extensions/subagent/index.ts +36 -1426
- package/extensions/subagent/modes.ts +405 -0
- package/extensions/subagent/params.ts +56 -0
- package/extensions/subagent/registry-text.ts +105 -0
- package/extensions/subagent/render-result.ts +306 -0
- package/extensions/subagent/run.ts +375 -0
- package/extensions/subagent/types.ts +41 -0
- package/extensions/subagent/worktree.ts +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
Claude Code experience for the [pi](https://pi.dev) coding agent, in one package. Point pi at a project that already has a `.claude/` directory and it reads your existing config: rules, commands, skills, hooks, output styles, MCP servers, and agents. It also adds the Claude Code features pi lacks: a todo overlay, checkpoints, memory, web search, subagents, and goals.
|
|
15
15
|
|
|
16
|
-
What a repository ships is treated as untrusted until you approve it: project MCP servers, hooks, agents, rules, output styles, commands and skills load only once you say yes.
|
|
16
|
+
What a repository ships is treated as untrusted until you approve it: project MCP servers, hooks, agents, rules, output styles, commands and skills load only once you say yes. A headless run (`pi -p`) cannot ask, so an undecided project loads none of them there, which is stricter than Claude, where a headless run uses them without showing the dialog.
|
|
17
17
|
|
|
18
18
|

|
|
19
19
|
|
|
@@ -45,7 +45,7 @@ Each topic links to its own doc with the full contract and any divergences from
|
|
|
45
45
|
- **[MCP servers](docs/mcp.md)** — every Claude config scope, all four transports, OAuth, managed policy, timeouts, prompts, and resources.
|
|
46
46
|
- **[Custom slash commands](docs/commands.md)** — `.claude/commands` with arguments, bash spans, `@file` inlining, frontmatter, and model invocation.
|
|
47
47
|
- **[Skills](docs/skills.md)** — `.claude/skills` discovery plus the same dynamic content commands get.
|
|
48
|
-
- **[Subagents
|
|
48
|
+
- **[Subagents](docs/subagents.md)** — built-in and custom agents, background runs, per-agent memory, worktree isolation.
|
|
49
49
|
- **[CLAUDE.md, @imports, and rules](docs/claude-md.md)** — the context files and path-scoped rules pi does not load natively.
|
|
50
50
|
- **[Settings `env`](docs/settings-env.md)** — env blocks from every settings scope, exported with Claude's precedence.
|
|
51
51
|
- **[Output styles](docs/output-styles.md)** — replace semantics, bundled built-ins, `/output-style`.
|
package/extensions/commands.ts
CHANGED
|
@@ -44,7 +44,8 @@ import * as path from 'node:path'
|
|
|
44
44
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
|
|
45
45
|
import { Type } from 'typebox'
|
|
46
46
|
import { matchesBashRules } from './internal/bash-rules.js'
|
|
47
|
-
import { type
|
|
47
|
+
import { type DiscoveredCommand, discoverCommandFiles, type ParsedCommand, type PathRuleTool, parseCommandFile, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
|
|
48
|
+
import { type CommandExec, expandDynamicContent, resolvePowershellBinary, spanExec } from './internal/command-spans.js'
|
|
48
49
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
49
50
|
import { claudeEffortLevel } from './internal/effort.js'
|
|
50
51
|
import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
|
|
@@ -55,6 +56,7 @@ import { isProjectApproved } from './internal/project-approval.js'
|
|
|
55
56
|
import { ancestorDirs, repoRoot } from './internal/project-root.js'
|
|
56
57
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
57
58
|
import { createTurnOverride } from './internal/turn-override.js'
|
|
59
|
+
import { errorMessage, isDirectory } from './internal/values.js'
|
|
58
60
|
|
|
59
61
|
/** Just enough of pi's Model to match and restore; getAvailable returns these. */
|
|
60
62
|
interface ModelLike {
|
|
@@ -105,14 +107,6 @@ interface VarContext {
|
|
|
105
107
|
modelRegistry?: { getAvailable(): ReadonlyArray<ModelLike> }
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
function isDirectory(target: string): boolean {
|
|
109
|
-
try {
|
|
110
|
-
return fs.statSync(target).isDirectory()
|
|
111
|
-
} catch {
|
|
112
|
-
return false
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
110
|
/** Existing `.claude/commands` directories in Claude's precedence order (later
|
|
117
111
|
* directories win in collectCommands): project first, then personal, then the
|
|
118
112
|
* enterprise directory beside the managed settings file, per "enterprise
|
|
@@ -328,7 +322,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
328
322
|
// A refused switch leaves the turn on the session model rather than the one the
|
|
329
323
|
// command named, and the reply gives no sign of it, so the refusal is reported.
|
|
330
324
|
void pi.setModel(model as Parameters<typeof pi.setModel>[0]).catch((error: unknown) => {
|
|
331
|
-
console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${
|
|
325
|
+
console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${errorMessage(error)}`)
|
|
332
326
|
})
|
|
333
327
|
},
|
|
334
328
|
})
|
|
@@ -455,7 +449,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
455
449
|
} catch (error) {
|
|
456
450
|
// A failed injected command aborts the invocation; the model never sees a
|
|
457
451
|
// half-expanded body. The notify carries Claude's failure message format.
|
|
458
|
-
ctx.ui.notify(
|
|
452
|
+
ctx.ui.notify(errorMessage(error), 'error')
|
|
459
453
|
return
|
|
460
454
|
}
|
|
461
455
|
|
|
@@ -513,7 +507,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
513
507
|
// An unreadable file must not take down session start, but the command is then
|
|
514
508
|
// absent from /help and unresolvable by the model, which looks like one that was
|
|
515
509
|
// never written.
|
|
516
|
-
console.warn(`pi-code-commands: ignoring ${command.filePath}: ${
|
|
510
|
+
console.warn(`pi-code-commands: ignoring ${command.filePath}: ${errorMessage(error)}`)
|
|
517
511
|
continue
|
|
518
512
|
}
|
|
519
513
|
discovered.set(command.name, command)
|
|
@@ -76,7 +76,7 @@ import { sliceBytes } from './internal/output-guard.js'
|
|
|
76
76
|
import { globToRegExpSource } from './internal/path-rules.js'
|
|
77
77
|
import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
78
78
|
import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
|
|
79
|
-
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
79
|
+
import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
|
|
80
80
|
import { statToken } from './internal/stat-token.js'
|
|
81
81
|
import { type Fence, fenceMarker, stepFence, stripBlockComments } from './internal/strip-comments.js'
|
|
82
82
|
|
|
@@ -406,15 +406,7 @@ export function readClaudeMdExcludes(files: string[], managed: Record<string, un
|
|
|
406
406
|
if (typeof entry === 'string' && entry.trim().length > 0) globs.push(entry)
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
|
-
for (const
|
|
410
|
-
try {
|
|
411
|
-
const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
412
|
-
if (settings === null || typeof settings !== 'object') continue
|
|
413
|
-
collect((settings as Record<string, unknown>).claudeMdExcludes)
|
|
414
|
-
} catch {
|
|
415
|
-
// missing or invalid settings file: skip
|
|
416
|
-
}
|
|
417
|
-
}
|
|
409
|
+
for (const settings of readSettingsChain(files)) collect(settings.claudeMdExcludes)
|
|
418
410
|
collect(managed.claudeMdExcludes)
|
|
419
411
|
return globs
|
|
420
412
|
}
|
|
@@ -36,15 +36,11 @@ import * as fs from 'node:fs'
|
|
|
36
36
|
import * as os from 'node:os'
|
|
37
37
|
import * as path from 'node:path'
|
|
38
38
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
39
|
-
|
|
40
39
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
41
40
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
42
41
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
43
42
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
44
|
-
|
|
45
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
47
|
-
}
|
|
43
|
+
import { isRecord } from './internal/values.js'
|
|
48
44
|
|
|
49
45
|
/** The `env` object of one settings scope, coerced to string values. A string is kept
|
|
50
46
|
* as-is, a number or boolean becomes its String() form, and anything else (object,
|
|
@@ -19,6 +19,7 @@ import * as os from 'node:os'
|
|
|
19
19
|
import * as path from 'node:path'
|
|
20
20
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
21
21
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
22
|
+
import { contentText, errorMessage } from './internal/values.js'
|
|
22
23
|
|
|
23
24
|
const CUSTOM_TYPE = 'git-checkpoint'
|
|
24
25
|
/** Sidecar inside the bare shadow repo recording the work tree it snapshots. */
|
|
@@ -116,17 +117,8 @@ function rememberWorkTree(shadowDir: string, cwd: string): void {
|
|
|
116
117
|
}
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
function extractText(content: unknown): string {
|
|
120
|
-
if (typeof content === 'string') return content
|
|
121
|
-
if (!Array.isArray(content)) return ''
|
|
122
|
-
return content
|
|
123
|
-
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
124
|
-
.map((part) => part.text)
|
|
125
|
-
.join(' ')
|
|
126
|
-
}
|
|
127
|
-
|
|
128
120
|
function promptSnippet(content: unknown): string {
|
|
129
|
-
const text =
|
|
121
|
+
const text = contentText(content, ' ').replace(/\s+/g, ' ').trim()
|
|
130
122
|
if (text.length <= PROMPT_SNIPPET_LENGTH) return text
|
|
131
123
|
return `${text.slice(0, PROMPT_SNIPPET_LENGTH)}…`
|
|
132
124
|
}
|
|
@@ -156,7 +148,7 @@ async function restoreConversation(ctx: ExtensionCommandContext, entryId: string
|
|
|
156
148
|
if (typeof result.editorText === 'string') ctx.ui.setEditorText(result.editorText)
|
|
157
149
|
return true
|
|
158
150
|
} catch (error) {
|
|
159
|
-
const message =
|
|
151
|
+
const message = errorMessage(error)
|
|
160
152
|
ctx.ui.notify(`Conversation restore failed: ${message}`, 'error')
|
|
161
153
|
return false
|
|
162
154
|
}
|
|
@@ -237,7 +229,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
237
229
|
} catch (error) {
|
|
238
230
|
// Without the mirror, files the user excluded locally are snapshotted into the
|
|
239
231
|
// checkpoint store and restored by /rewind, so this is not a silent fallback.
|
|
240
|
-
ctx.ui.notify(`Checkpoints cannot honor this repository's .git/info/exclude: ${
|
|
232
|
+
ctx.ui.notify(`Checkpoints cannot honor this repository's .git/info/exclude: ${errorMessage(error)}`, 'warning')
|
|
241
233
|
}
|
|
242
234
|
}
|
|
243
235
|
|
package/extensions/goal.ts
CHANGED
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
|
|
38
38
|
import * as os from 'node:os'
|
|
39
39
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
40
|
-
|
|
41
40
|
import { hookFiles, readSettingsDisableAllHooks, stopHookBlockCap } from './hooks/index.js'
|
|
42
41
|
import {
|
|
43
42
|
checkinIntervalMs,
|
|
@@ -66,6 +65,7 @@ import { completeText } from './internal/model-complete.js'
|
|
|
66
65
|
import { resolveModelOverride } from './internal/model-lookup.js'
|
|
67
66
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
68
67
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
|
|
68
|
+
import { errorMessage } from './internal/values.js'
|
|
69
69
|
|
|
70
70
|
/** Session entry type the goal state persists under, and the custom message type its
|
|
71
71
|
* transcript lines (kickoff, verdicts, check-ins) carry. */
|
|
@@ -340,7 +340,7 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
340
340
|
// A user interrupt is not an evaluator failure: the goal stays, nothing to say.
|
|
341
341
|
if (ctx.signal?.aborted) return
|
|
342
342
|
// No verdict is a hook error in Claude's terms: the turn ends and the goal stays.
|
|
343
|
-
if (generation === startedGeneration) tell(ctx, `Goal evaluator error: ${
|
|
343
|
+
if (generation === startedGeneration) tell(ctx, `Goal evaluator error: ${errorMessage(error)}. The goal stays set; the next turn is evaluated again.`, 'warning')
|
|
344
344
|
return
|
|
345
345
|
}
|
|
346
346
|
// Interrupted, cleared, or replaced during the await: this verdict must not act.
|
|
@@ -8,7 +8,8 @@ import * as fs from 'node:fs'
|
|
|
8
8
|
import * as path from 'node:path'
|
|
9
9
|
import { readManagedSettings } from '../internal/managed-settings.js'
|
|
10
10
|
import { type InstalledPlugin, pluginComponentPath, substitutePluginVars } from '../internal/plugins.js'
|
|
11
|
-
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
11
|
+
import { claudeSettingsChain, readSettingsChain } from '../internal/settings-chain.js'
|
|
12
|
+
import { errorMessage, isRecord } from '../internal/values.js'
|
|
12
13
|
|
|
13
14
|
export interface HookCommand {
|
|
14
15
|
type?: string
|
|
@@ -67,10 +68,6 @@ export function isBackgroundHook(hook: HookCommand): boolean {
|
|
|
67
68
|
}
|
|
68
69
|
export type HooksConfig = Record<string, HookMatcher[]>
|
|
69
70
|
|
|
70
|
-
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
71
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
71
|
/** Settings files to read, newest-winning. Project files load only when trusted, each
|
|
75
72
|
* the nearest of its name at or above cwd (bounded at the repository root, matching
|
|
76
73
|
* the approval walk), so a subdirectory session reads the settings that gated it. */
|
|
@@ -92,13 +89,8 @@ export function readDisableAllHooks(files: string[], managed: Record<string, unk
|
|
|
92
89
|
* disableAllHooks cannot disable hooks configured through managed policy settings,
|
|
93
90
|
* so the caller keeps managed hooks running when only this half is set. */
|
|
94
91
|
export function readSettingsDisableAllHooks(files: string[]): boolean {
|
|
95
|
-
for (const
|
|
96
|
-
|
|
97
|
-
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
98
|
-
if (isRecord(parsed) && parsed.disableAllHooks === true) return true
|
|
99
|
-
} catch {
|
|
100
|
-
// missing or invalid file: skip
|
|
101
|
-
}
|
|
92
|
+
for (const settings of readSettingsChain(files)) {
|
|
93
|
+
if (settings.disableAllHooks === true) return true
|
|
102
94
|
}
|
|
103
95
|
return false
|
|
104
96
|
}
|
|
@@ -152,14 +144,7 @@ export function readAllowedHttpHookUrls(files: string[], managed: Record<string,
|
|
|
152
144
|
found = [...(found ?? []), ...value.filter((entry): entry is string => typeof entry === 'string')]
|
|
153
145
|
}
|
|
154
146
|
collect(managed.allowedHttpHookUrls)
|
|
155
|
-
for (const
|
|
156
|
-
try {
|
|
157
|
-
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
158
|
-
if (isRecord(parsed)) collect(parsed.allowedHttpHookUrls)
|
|
159
|
-
} catch {
|
|
160
|
-
// missing or invalid file: skip
|
|
161
|
-
}
|
|
162
|
-
}
|
|
147
|
+
for (const settings of readSettingsChain(files)) collect(settings.allowedHttpHookUrls)
|
|
163
148
|
return found
|
|
164
149
|
}
|
|
165
150
|
|
|
@@ -203,7 +188,7 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
|
|
|
203
188
|
} catch (error) {
|
|
204
189
|
// Every hook this source declares is now absent, a policy hook among them, so the
|
|
205
190
|
// failure is named rather than left to look like a file with no hooks in it.
|
|
206
|
-
console.warn(`pi-code-hooks: ignoring the hooks in ${source}: ${
|
|
191
|
+
console.warn(`pi-code-hooks: ignoring the hooks in ${source}: ${errorMessage(error)}`)
|
|
207
192
|
return
|
|
208
193
|
}
|
|
209
194
|
for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ToolCallEventResult } from '@earendil-works/pi-coding-agent'
|
|
8
8
|
import type { PathAnchors } from '../internal/path-rules.js'
|
|
9
|
+
import { errorMessage, isRecord } from '../internal/values.js'
|
|
9
10
|
import { claudeToolInput, claudeToolName, piToolInput } from './claude-tools.js'
|
|
10
|
-
import {
|
|
11
|
+
import type { HookCommand, HooksConfig } from './config.js'
|
|
11
12
|
import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
|
|
12
13
|
import { type HookRunner, type HookRunResult, timeoutMs } from './runners.js'
|
|
13
14
|
|
|
@@ -63,7 +64,7 @@ export function hookJsonError(text: string): string | undefined {
|
|
|
63
64
|
JSON.parse(trimmed)
|
|
64
65
|
return undefined
|
|
65
66
|
} catch (error) {
|
|
66
|
-
parseError =
|
|
67
|
+
parseError = errorMessage(error)
|
|
67
68
|
}
|
|
68
69
|
const lines = trimmed
|
|
69
70
|
.split('\n')
|
|
@@ -109,6 +109,7 @@ import { watchSettingsFiles } from '../internal/settings-watch.js'
|
|
|
109
109
|
import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
|
|
110
110
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
111
111
|
import { setSubagentStartHookRunner } from '../internal/subagent-hooks.js'
|
|
112
|
+
import { contentText } from '../internal/values.js'
|
|
112
113
|
import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } from './claude-tools.js'
|
|
113
114
|
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadManagedHooks, loadPluginHooks, mergeAgentEnvHooks, mergeSkillHooks, readAllowedHttpHookUrls, readDisableAllHooks, readSettingsDisableAllHooks } from './config.js'
|
|
114
115
|
import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
|
|
@@ -127,12 +128,7 @@ export function lastAssistantText(messages: ReadonlyArray<{ role: string; conten
|
|
|
127
128
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
128
129
|
const message = messages[i]
|
|
129
130
|
if (message.role !== 'assistant') continue
|
|
130
|
-
|
|
131
|
-
if (!Array.isArray(message.content)) return ''
|
|
132
|
-
return message.content
|
|
133
|
-
.filter((part): part is { type: 'text'; text: string } => typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'text')
|
|
134
|
-
.map((part) => part.text)
|
|
135
|
-
.join('')
|
|
131
|
+
return contentText(message.content)
|
|
136
132
|
}
|
|
137
133
|
return ''
|
|
138
134
|
}
|
|
@@ -157,14 +153,6 @@ async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner:
|
|
|
157
153
|
return await Promise.all(runnable.map((command) => runner(command, payload, timeoutMs(command))))
|
|
158
154
|
}
|
|
159
155
|
|
|
160
|
-
/** The text of a result's content blocks, for Claude-shaped tool_response fields. */
|
|
161
|
-
function textContent(content: ReadonlyArray<{ type: string; text?: string }>): string {
|
|
162
|
-
return content
|
|
163
|
-
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
|
164
|
-
.map((block) => block.text)
|
|
165
|
-
.join('\n')
|
|
166
|
-
}
|
|
167
|
-
|
|
168
156
|
/** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
|
|
169
157
|
* offered both spellings so existing configs keep firing either way, and the payload
|
|
170
158
|
* reports the Claude value, which is what a Claude-written hook script parses. */
|
|
@@ -531,13 +519,13 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
531
519
|
const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names).filter((command) => passesIfFilter(command, target))
|
|
532
520
|
if (commands.length === 0 && pending.length === 0) return
|
|
533
521
|
const translatedInput = alias === undefined ? claudeToolInput(event.toolName, event.input, ctx.cwd) : undefined
|
|
534
|
-
const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input,
|
|
522
|
+
const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input, contentText(event.content, '\n'), event.isError, ctx.cwd) : undefined) ?? { content: event.content, details: event.details, isError: event.isError }
|
|
535
523
|
const startedAt = toolStartTimes.get(event.toolCallId)
|
|
536
524
|
toolStartTimes.delete(event.toolCallId)
|
|
537
525
|
// Claude delivers a failure as top-level fields rather than a tool_response: "error
|
|
538
526
|
// information as top-level fields ... error ... is_interrupt". is_interrupt is false
|
|
539
527
|
// here because pi reports a cancelled tool through the result, not this event.
|
|
540
|
-
const failure = event.isError ? { error:
|
|
528
|
+
const failure = event.isError ? { error: contentText(event.content, '\n'), is_interrupt: false } : { tool_response: response }
|
|
541
529
|
const payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, ...failure, ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }) }
|
|
542
530
|
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
543
531
|
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { matchesBashIfFilter } from '../internal/bash-rules.js'
|
|
8
8
|
import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
|
|
9
|
+
import { errorMessage } from '../internal/values.js'
|
|
9
10
|
import type { HookCommand, HookMatcher } from './config.js'
|
|
10
11
|
|
|
11
12
|
/** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
|
|
@@ -62,7 +63,7 @@ function compileMatcher(matcher: string): CompiledMatcher {
|
|
|
62
63
|
} catch (error) {
|
|
63
64
|
// The fallback matches the literal text, which almost never matches a tool name, so
|
|
64
65
|
// the hook simply never fires. Say so: the matcher reads as merely wrong otherwise.
|
|
65
|
-
console.warn(`pi-code-hooks: matcher ${matcher} is not a valid regular expression (${
|
|
66
|
+
console.warn(`pi-code-hooks: matcher ${matcher} is not a valid regular expression (${errorMessage(error)}); it will only match a tool of that exact name`)
|
|
66
67
|
compiled = { tokens: exactTokens(matcher) }
|
|
67
68
|
}
|
|
68
69
|
}
|
|
@@ -11,6 +11,7 @@ import { runAgent } from '../internal/agent-run.js'
|
|
|
11
11
|
import { callMcpTool } from '../internal/mcp-call.js'
|
|
12
12
|
import { completeText } from '../internal/model-complete.js'
|
|
13
13
|
import { resolveShell } from '../internal/shell-resolve.js'
|
|
14
|
+
import { errorMessage } from '../internal/values.js'
|
|
14
15
|
import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
|
|
15
16
|
|
|
16
17
|
// Claude's defaults vary by type and event (600s for command/http/mcp_tool, 30s
|
|
@@ -224,7 +225,11 @@ export async function runHttpHook(hook: { type?: string; command: string; url?:
|
|
|
224
225
|
}
|
|
225
226
|
return { code: 0, stdout: body, stderr: '', timedOut: false }
|
|
226
227
|
} catch (error) {
|
|
227
|
-
|
|
228
|
+
// A timeout is the abort that AbortSignal.timeout raises. Mark it as one so a gated
|
|
229
|
+
// event fails closed on it, exactly as a command hook that ran out of time does: the
|
|
230
|
+
// hook never answered, whichever transport it used.
|
|
231
|
+
const name = error instanceof Error ? error.name : ''
|
|
232
|
+
return { code: 1, stdout: '', stderr: errorMessage(error), timedOut: name === 'TimeoutError' || name === 'AbortError' }
|
|
228
233
|
}
|
|
229
234
|
}
|
|
230
235
|
|
|
@@ -258,7 +263,7 @@ function substituteArguments(prompt: string | undefined, payload: unknown): stri
|
|
|
258
263
|
* produced no verdict and is non-blocking. */
|
|
259
264
|
function abortAwareFailure(signal: AbortSignal, error: unknown): HookRunResult {
|
|
260
265
|
const aborted = signal.aborted || (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'))
|
|
261
|
-
return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr:
|
|
266
|
+
return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr: errorMessage(error), timedOut: aborted }
|
|
262
267
|
}
|
|
263
268
|
|
|
264
269
|
export async function runPromptHook(hook: HookCommand, payload: unknown, model: Model<Api> | undefined, timeoutMs: number): Promise<HookRunResult> {
|
|
@@ -319,11 +324,12 @@ export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeou
|
|
|
319
324
|
const input = hook.input && typeof hook.input === 'object' ? (substituteInputPaths(hook.input, payload) as Record<string, unknown>) : {}
|
|
320
325
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
321
326
|
const deadline = new Promise<HookRunResult>((resolve) => {
|
|
322
|
-
|
|
327
|
+
// Marked as a timeout so a gated event fails closed on it, like a command hook.
|
|
328
|
+
timer = setTimeout(() => resolve({ code: 1, stdout: '', stderr: `mcp_tool hook timed out after ${timeoutMs}ms`, timedOut: true }), timeoutMs)
|
|
323
329
|
})
|
|
324
330
|
const call = callMcpTool(hook.server, hook.tool, input)
|
|
325
331
|
.then((result): HookRunResult => ({ code: result.isError ? 1 : 0, stdout: result.text, stderr: '', timedOut: false }))
|
|
326
|
-
.catch((error): HookRunResult => ({ code: 1, stdout: '', stderr:
|
|
332
|
+
.catch((error): HookRunResult => ({ code: 1, stdout: '', stderr: errorMessage(error), timedOut: false }))
|
|
327
333
|
try {
|
|
328
334
|
return await Promise.race([call, deadline])
|
|
329
335
|
} finally {
|