pi-code 0.4.2 → 0.6.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 +1 -1
- package/extensions/hooks.ts +258 -58
- package/extensions/internal/mcp-alias.ts +21 -0
- package/extensions/internal/plan-mode-state.ts +15 -0
- package/extensions/internal/subagent-events.ts +18 -0
- package/extensions/mcp.ts +6 -0
- package/extensions/memory.ts +4 -1
- package/extensions/plan-mode/index.ts +10 -0
- package/extensions/subagent/index.ts +25 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ 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: PreToolUse, PostToolUse, SessionStart, UserPromptSubmit (blocks and injects context), Stop, PreCompact, SessionEnd | `hooks.ts` |
|
|
41
|
+
| Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd; Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort | `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
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` |
|
package/extensions/hooks.ts
CHANGED
|
@@ -3,17 +3,30 @@
|
|
|
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 -> pi `tool_call` (can block the tool)
|
|
7
|
-
* - PostToolUse -> pi `
|
|
8
|
-
*
|
|
6
|
+
* - PreToolUse -> pi `tool_call` (can block the tool or rewrite its input)
|
|
7
|
+
* - PostToolUse -> pi `tool_result` (block reasons and additionalContext are
|
|
8
|
+
* appended next to the tool result, as Claude documents)
|
|
9
|
+
* - SessionStart -> pi `session_start` (stdout/additionalContext is injected as
|
|
10
|
+
* context before the first prompt via `before_agent_start`)
|
|
9
11
|
* - UserPromptSubmit-> pi `input` (can block the prompt via `handled`, or inject
|
|
10
12
|
* additional context by transforming the submitted text)
|
|
11
|
-
* - Stop -> pi `agent_end` (
|
|
13
|
+
* - Stop -> pi `agent_end` (a block feeds its reason back as a new turn,
|
|
14
|
+
* with stop_hook_active as the loop guard)
|
|
12
15
|
* - PreCompact -> pi `session_before_compact` (fire-and-forget)
|
|
16
|
+
* - PostCompact -> pi `session_compact` (fire-and-forget)
|
|
17
|
+
* - PostToolUseFailure -> pi `tool_result` error branch (fire-and-forget)
|
|
13
18
|
* - SessionEnd -> pi `session_shutdown` (fire-and-forget)
|
|
14
19
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
20
|
+
* Every payload carries session_id, transcript_path (pi's session file), cwd,
|
|
21
|
+
* permission_mode (plan-mode state off the shared bus) and effort; tool events add
|
|
22
|
+
* tool_use_id. Every event honors the universal `systemMessage` output (a
|
|
23
|
+
* user-facing warning).
|
|
24
|
+
* `suppressOutput` is accepted and inert: pi never echoes hook stdout to the
|
|
25
|
+
* transcript in the first place.
|
|
26
|
+
*
|
|
27
|
+
* SubagentStart/SubagentStop ride pi-code's own subagent extension, which publishes
|
|
28
|
+
* child-run lifecycle on the shared bus (notify-style: a child has already exited by
|
|
29
|
+
* the time SubagentStop fires, so its exit-2 block semantics cannot be honored).
|
|
17
30
|
*
|
|
18
31
|
* Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
|
|
19
32
|
* hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
|
|
@@ -22,9 +35,11 @@
|
|
|
22
35
|
*
|
|
23
36
|
* Config is merged from ~/.claude/settings.json (always) plus the project's
|
|
24
37
|
* .claude/settings.json and settings.local.json (only when the project is
|
|
25
|
-
* trusted, since hooks execute arbitrary shell).
|
|
26
|
-
*
|
|
27
|
-
*
|
|
38
|
+
* trusted, since hooks execute arbitrary shell). Matchers follow Claude's rule:
|
|
39
|
+
* `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
|
|
40
|
+
* anything with other regex characters is an unanchored regex. Claude matchers
|
|
41
|
+
* are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
|
|
42
|
+
* is case-insensitive and folds `-` to `_`.
|
|
28
43
|
*
|
|
29
44
|
* Docs: https://code.claude.com/docs/en/hooks.md
|
|
30
45
|
*/
|
|
@@ -33,9 +48,12 @@ import { type ChildProcess, spawn } from 'node:child_process'
|
|
|
33
48
|
import * as fs from 'node:fs'
|
|
34
49
|
import * as os from 'node:os'
|
|
35
50
|
import * as path from 'node:path'
|
|
36
|
-
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
51
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
37
52
|
|
|
53
|
+
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
|
|
54
|
+
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
38
55
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
56
|
+
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
|
|
39
57
|
|
|
40
58
|
const DEFAULT_TIMEOUT_S = 60
|
|
41
59
|
|
|
@@ -86,12 +104,34 @@ export function loadHooks(files: string[]): HooksConfig {
|
|
|
86
104
|
return config
|
|
87
105
|
}
|
|
88
106
|
|
|
89
|
-
|
|
107
|
+
/** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
|
|
108
|
+
* is a list of exact names; anything else is an unanchored regex. */
|
|
109
|
+
const EXACT_MATCHER = /^[\w\- ,|]*$/
|
|
110
|
+
|
|
111
|
+
/** Claude names are PascalCase and keep dashes (`Bash`, `mcp__brave-search__x`);
|
|
112
|
+
* pi names are lowercase with underscores, so comparison folds both. */
|
|
113
|
+
function foldName(name: string): string {
|
|
114
|
+
return name.toLowerCase().replaceAll('-', '_')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function exactListApplies(matcher: string, names: readonly string[]): boolean {
|
|
118
|
+
const tokens = new Set(
|
|
119
|
+
matcher
|
|
120
|
+
.split(/[|,]/)
|
|
121
|
+
.map((token) => foldName(token.trim()))
|
|
122
|
+
.filter(Boolean),
|
|
123
|
+
)
|
|
124
|
+
return names.some((name) => tokens.has(foldName(name)))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function matcherApplies(matcher: string | undefined, names: readonly string[]): boolean {
|
|
90
128
|
if (!matcher || matcher === '*') return true
|
|
129
|
+
if (EXACT_MATCHER.test(matcher)) return exactListApplies(matcher, names)
|
|
91
130
|
try {
|
|
92
|
-
|
|
131
|
+
const regex = new RegExp(matcher, 'i')
|
|
132
|
+
return names.some((name) => regex.test(name))
|
|
93
133
|
} catch {
|
|
94
|
-
return matcher
|
|
134
|
+
return exactListApplies(matcher, names)
|
|
95
135
|
}
|
|
96
136
|
}
|
|
97
137
|
|
|
@@ -101,16 +141,25 @@ function isRunnableHook(hook: HookCommand): boolean {
|
|
|
101
141
|
return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
|
|
102
142
|
}
|
|
103
143
|
|
|
104
|
-
/** Command specs whose matcher applies to the given tool/source
|
|
105
|
-
|
|
144
|
+
/** Command specs whose matcher applies to any of the given tool/source names.
|
|
145
|
+
* Multiple candidates let one event offer both the pi name and its Claude alias. */
|
|
146
|
+
export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
|
|
147
|
+
const candidates = typeof names === 'string' ? [names] : names
|
|
106
148
|
const result: HookCommand[] = []
|
|
149
|
+
const seen = new Set<string>()
|
|
107
150
|
for (const entry of matchers ?? []) {
|
|
108
|
-
if (matcherApplies(entry.matcher,
|
|
151
|
+
if (!matcherApplies(entry.matcher, candidates)) continue
|
|
152
|
+
for (const hook of (entry.hooks ?? []).filter(isRunnableHook)) {
|
|
153
|
+
// Claude runs a handler defined in more than one settings file once.
|
|
154
|
+
if (seen.has(hook.command)) continue
|
|
155
|
+
seen.add(hook.command)
|
|
156
|
+
result.push(hook)
|
|
157
|
+
}
|
|
109
158
|
}
|
|
110
159
|
return result
|
|
111
160
|
}
|
|
112
161
|
|
|
113
|
-
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string }; decision?: string; reason?: string; continue?: boolean; stopReason?: string } | undefined {
|
|
162
|
+
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }; decision?: string; reason?: string; continue?: boolean; stopReason?: string; systemMessage?: string } | undefined {
|
|
114
163
|
try {
|
|
115
164
|
return JSON.parse(text)
|
|
116
165
|
} catch {
|
|
@@ -208,21 +257,50 @@ function timeoutMs(command: HookCommand): number {
|
|
|
208
257
|
return seconds * 1000
|
|
209
258
|
}
|
|
210
259
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
260
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
261
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Claude's updatedInput replaces the whole tool_input, and pi's tool_call contract is
|
|
265
|
+
* in-place mutation, so the target object is emptied and refilled rather than reassigned. */
|
|
266
|
+
function replaceRecord(target: Record<string, unknown>, next: Record<string, unknown>): void {
|
|
267
|
+
for (const key of Object.keys(target)) delete target[key]
|
|
268
|
+
Object.assign(target, next)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Run PreToolUse hooks for a tool; the first blocking verdict wins. For MCP tools the
|
|
272
|
+
* matcher sees both the pi name and the Claude alias, and the payload reports the alias,
|
|
273
|
+
* which is the name a Claude-written hook script expects in tool_name. A hook's
|
|
274
|
+
* hookSpecificOutput.updatedInput replaces the tool input in place before the permission
|
|
275
|
+
* decision applies, and later hooks see the rewritten input in their payload. */
|
|
276
|
+
export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink): Promise<HookDecision> {
|
|
277
|
+
const names = claudeName ? [toolName, claudeName] : [toolName]
|
|
278
|
+
for (const command of matchingCommands(config.PreToolUse, names)) {
|
|
279
|
+
const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command))
|
|
215
280
|
// A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
|
|
216
281
|
// would otherwise read as a clean allow. Fail closed instead.
|
|
217
282
|
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}` }
|
|
283
|
+
if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
|
|
284
|
+
const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
|
|
285
|
+
if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
|
|
218
286
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
219
287
|
if (decision.block) return decision
|
|
220
288
|
}
|
|
221
289
|
return { block: false }
|
|
222
290
|
}
|
|
223
291
|
|
|
224
|
-
async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<
|
|
225
|
-
await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
|
|
292
|
+
async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<HookRunResult[]> {
|
|
293
|
+
return await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
type SystemMessageSink = (message: string) => void
|
|
297
|
+
|
|
298
|
+
/** Claude's universal systemMessage output field: a warning surfaced to the user. */
|
|
299
|
+
function surfaceSystemMessages(results: HookRunResult[], notify: SystemMessageSink): void {
|
|
300
|
+
for (const result of results) {
|
|
301
|
+
const message = tryParseJson(result.stdout)?.systemMessage
|
|
302
|
+
if (message) notify(message)
|
|
303
|
+
}
|
|
226
304
|
}
|
|
227
305
|
|
|
228
306
|
export interface PromptDecision {
|
|
@@ -241,11 +319,12 @@ function promptContext(stdout: string): string {
|
|
|
241
319
|
|
|
242
320
|
/** Run UserPromptSubmit hooks: the first blocking verdict wins; otherwise their
|
|
243
321
|
* additional context is concatenated for injection ahead of the prompt. */
|
|
244
|
-
export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner): Promise<PromptDecision> {
|
|
322
|
+
export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
|
|
245
323
|
const contexts: string[] = []
|
|
246
324
|
for (const command of matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')) {
|
|
247
325
|
const result = await runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))
|
|
248
326
|
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}`, context: '' }
|
|
327
|
+
if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
|
|
249
328
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
250
329
|
if (decision.block) return { block: true, reason: decision.reason, context: '' }
|
|
251
330
|
const context = promptContext(result.stdout)
|
|
@@ -254,53 +333,143 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
|
|
|
254
333
|
return { block: false, context: contexts.join('\n') }
|
|
255
334
|
}
|
|
256
335
|
|
|
257
|
-
/**
|
|
258
|
-
|
|
336
|
+
/** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
|
|
337
|
+
* offered both spellings so existing configs keep firing either way, and the payload
|
|
338
|
+
* reports the Claude value, which is what a Claude-written hook script parses. */
|
|
339
|
+
const SESSION_START_SOURCE: Record<string, string> = { startup: 'startup', new: 'clear', resume: 'resume', fork: 'fork' }
|
|
340
|
+
const PRECOMPACT_TRIGGER: Record<string, string> = { manual: 'manual', threshold: 'auto', overflow: 'auto' }
|
|
341
|
+
const SESSION_END_REASON: Record<string, string> = { quit: 'prompt_input_exit', new: 'clear', resume: 'resume', reload: 'other', fork: 'other' }
|
|
342
|
+
|
|
343
|
+
/** The raw pi value plus its Claude spelling, deduplicated, for matcher candidates. */
|
|
344
|
+
function claudeSpelling(map: Record<string, string>, raw: string): { names: string[]; value: string } {
|
|
345
|
+
const value = map[raw] ?? raw
|
|
346
|
+
return { names: value === raw ? [raw] : [raw, value], value }
|
|
347
|
+
}
|
|
259
348
|
|
|
260
349
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
261
350
|
let config: HooksConfig = {}
|
|
262
351
|
let projectDir = ''
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
352
|
+
let pendingSessionContext: string[] = []
|
|
353
|
+
let stopHookActive = false
|
|
354
|
+
let sessionCtx: ExtensionContext | undefined
|
|
355
|
+
/** Claude sends session_id, transcript_path, cwd and effort on every payload. */
|
|
356
|
+
const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
|
|
357
|
+
const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
|
|
358
|
+
const transcript = ctx.sessionManager.getSessionFile()
|
|
359
|
+
if (transcript) common.transcript_path = transcript
|
|
360
|
+
if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
|
|
361
|
+
return common
|
|
362
|
+
}
|
|
363
|
+
/** A runner bound to the firing context, filling the common fields into each stdin. */
|
|
364
|
+
const boundRunner =
|
|
365
|
+
(ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
|
|
366
|
+
(command, payload, ms) =>
|
|
367
|
+
runHookCommand(command, { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }, ms, projectDir)
|
|
368
|
+
// Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
|
|
369
|
+
// <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
|
|
370
|
+
const mcpAliases = new Map<string, string>()
|
|
371
|
+
pi.events.on(MCP_TOOLS_CHANNEL, (data) => {
|
|
372
|
+
if (!isMcpToolAliases(data)) return
|
|
373
|
+
mcpAliases.clear()
|
|
374
|
+
for (const entry of data) mcpAliases.set(entry.pi, entry.claude)
|
|
375
|
+
})
|
|
376
|
+
// Claude's permission_mode: pi has no permission system, but pi-code's plan mode is
|
|
377
|
+
// the documented "plan" mode; its extension publishes the state on the shared bus.
|
|
378
|
+
let permissionMode = 'default'
|
|
379
|
+
pi.events.on(PLAN_MODE_CHANNEL, (data) => {
|
|
380
|
+
if (isPlanModeState(data)) permissionMode = data.active ? 'plan' : 'default'
|
|
381
|
+
})
|
|
382
|
+
// Subagent lifecycle arrives over the bus without a pi context; the session context
|
|
383
|
+
// captured at session_start supplies the common payload fields.
|
|
384
|
+
pi.events.on(SUBAGENT_CHANNEL, async (data) => {
|
|
385
|
+
if (!isSubagentPhaseEvent(data) || !sessionCtx) return
|
|
386
|
+
const ctx = sessionCtx
|
|
387
|
+
const eventName = data.phase === 'start' ? 'SubagentStart' : 'SubagentStop'
|
|
388
|
+
const payload = { hook_event_name: eventName, agent_type: data.agentType, agent_id: data.agentId }
|
|
389
|
+
const results = await runNotifyHooks(matchingCommands(config[eventName], data.agentType), payload, boundRunner(ctx))
|
|
390
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
391
|
+
})
|
|
267
392
|
|
|
268
393
|
pi.on('session_start', async (event, ctx) => {
|
|
394
|
+
sessionCtx = ctx
|
|
269
395
|
const trusted = await isProjectApproved(ctx)
|
|
270
396
|
projectDir = ctx.cwd
|
|
271
397
|
config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
if (event.reason === 'reload'
|
|
275
|
-
|
|
398
|
+
// "reload" re-fires in-process with the same conversation and would double-run hooks;
|
|
399
|
+
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
400
|
+
if (event.reason === 'reload') return
|
|
401
|
+
const source = claudeSpelling(SESSION_START_SOURCE, event.reason)
|
|
402
|
+
const commands = matchingCommands(config.SessionStart, source.names)
|
|
403
|
+
const payload = { hook_event_name: 'SessionStart', source: source.value }
|
|
404
|
+
const run = boundRunner(ctx)
|
|
405
|
+
const results = await Promise.all(commands.map((command) => run(command.command, payload, timeoutMs(command))))
|
|
406
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
407
|
+
pendingSessionContext = results.map((result) => promptContext(result.stdout)).filter(Boolean)
|
|
276
408
|
})
|
|
277
409
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
410
|
+
// Claude adds a SessionStart hook's additionalContext (or plain stdout) to the
|
|
411
|
+
// conversation before the first prompt; pi's seam for that is a message injected
|
|
412
|
+
// on the next agent start.
|
|
413
|
+
pi.on('before_agent_start', async () => {
|
|
414
|
+
if (pendingSessionContext.length === 0) return
|
|
415
|
+
const content = pendingSessionContext.join('\n')
|
|
416
|
+
pendingSessionContext = []
|
|
417
|
+
return { message: { customType: 'claude-hook-context', content, display: false } }
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
421
|
+
const decision = await runPreToolUse(config, event.toolName, event.input, boundRunner(ctx, { tool_use_id: event.toolCallId }), mcpAliases.get(event.toolName), (message) => ctx.ui.notify(message, 'warning'))
|
|
285
422
|
if (!decision.block) return undefined
|
|
286
|
-
// pi still emits tool_execution_end (isError) for a blocked call, which also
|
|
287
|
-
// cleans up; deleting here just avoids relying on that host detail.
|
|
288
|
-
pendingInputs.delete(event.toolCallId)
|
|
289
423
|
return { block: true, reason: decision.reason }
|
|
290
424
|
})
|
|
291
425
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
426
|
+
// Claude's PostToolUse runs after a successful call and feeds back into the result:
|
|
427
|
+
// a decision:block reason (or exit-2 stderr) and additionalContext are appended next
|
|
428
|
+
// to the tool result, which is where Claude documents they land. Failed executions
|
|
429
|
+
// are skipped (Claude routes those to PostToolUseFailure, not bridged yet).
|
|
430
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
431
|
+
const alias = mcpAliases.get(event.toolName)
|
|
432
|
+
const names = alias ? [event.toolName, alias] : [event.toolName]
|
|
433
|
+
const response = { content: event.content, details: event.details, isError: event.isError }
|
|
434
|
+
// A failed execution fires Claude's PostToolUseFailure instead: notify-style, no
|
|
435
|
+
// result patch, since the error content is already what the model sees.
|
|
436
|
+
if (event.isError) {
|
|
437
|
+
const failCommands = matchingCommands(config.PostToolUseFailure, names)
|
|
438
|
+
if (failCommands.length === 0) return
|
|
439
|
+
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
440
|
+
const failPayload = { hook_event_name: 'PostToolUseFailure', tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
|
|
441
|
+
const failResults = await Promise.all(failCommands.map((command) => run(command.command, failPayload, timeoutMs(command))))
|
|
442
|
+
surfaceSystemMessages(failResults, (message) => ctx.ui.notify(message, 'warning'))
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
const commands = matchingCommands(config.PostToolUse, names)
|
|
446
|
+
if (commands.length === 0) return
|
|
447
|
+
const payload = {
|
|
448
|
+
hook_event_name: 'PostToolUse',
|
|
449
|
+
tool_name: alias ?? event.toolName,
|
|
450
|
+
tool_input: event.input,
|
|
451
|
+
tool_response: response,
|
|
452
|
+
}
|
|
453
|
+
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
454
|
+
const results = await Promise.all(commands.map((command) => run(command.command, payload, timeoutMs(command))))
|
|
455
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
456
|
+
const feedback: string[] = []
|
|
457
|
+
for (const result of results) {
|
|
458
|
+
const parsed = tryParseJson(result.stdout)
|
|
459
|
+
if (!result.timedOut && result.code === 2) feedback.push(`PostToolUse hook: ${result.stderr.trim() || 'Blocked by hook'}`)
|
|
460
|
+
else if (parsed?.decision === 'block') feedback.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
|
|
461
|
+
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
462
|
+
if (context) feedback.push(context)
|
|
463
|
+
}
|
|
464
|
+
if (feedback.length === 0) return
|
|
465
|
+
return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
|
|
297
466
|
})
|
|
298
467
|
|
|
299
468
|
pi.on('input', async (event, ctx) => {
|
|
300
469
|
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
301
470
|
// prompts the user submitted.
|
|
302
471
|
if (event.source === 'extension') return { action: 'continue' }
|
|
303
|
-
const decision = await runUserPromptSubmit(config, event.text,
|
|
472
|
+
const decision = await runUserPromptSubmit(config, event.text, boundRunner(ctx), (message) => ctx.ui.notify(message, 'warning'))
|
|
304
473
|
if (decision.block) {
|
|
305
474
|
// pi's input result has no reason channel, so surface why before consuming it.
|
|
306
475
|
ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
|
|
@@ -312,17 +481,48 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
312
481
|
return { action: 'continue' }
|
|
313
482
|
})
|
|
314
483
|
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
484
|
+
// Claude's Stop hook can prevent stopping: a block feeds its reason back as a new
|
|
485
|
+
// turn, and stop_hook_active in the payload tells the next firing it is already
|
|
486
|
+
// continuing from a stop hook, which is the hook script's documented loop guard.
|
|
487
|
+
// Only exit 2 and decision:"block" continue; continue:false means "stay stopped".
|
|
488
|
+
pi.on('agent_end', async (_event, ctx) => {
|
|
489
|
+
const commands = matchingCommands(config.Stop, 'Stop')
|
|
490
|
+
if (commands.length === 0) {
|
|
491
|
+
stopHookActive = false
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
const payload = { hook_event_name: 'Stop', stop_hook_active: stopHookActive }
|
|
495
|
+
const run = boundRunner(ctx)
|
|
496
|
+
const results = await Promise.all(commands.map((command) => run(command.command, payload, timeoutMs(command))))
|
|
497
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
498
|
+
const block = results
|
|
499
|
+
.filter((result) => !result.timedOut)
|
|
500
|
+
.map((result) => {
|
|
501
|
+
if (result.code === 2) return { block: true, reason: result.stderr.trim() || 'Stop blocked by hook' }
|
|
502
|
+
const parsed = tryParseJson(result.stdout)
|
|
503
|
+
if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Stop blocked by hook' }
|
|
504
|
+
return { block: false, reason: '' }
|
|
505
|
+
})
|
|
506
|
+
.find((verdict) => verdict.block)
|
|
507
|
+
stopHookActive = block !== undefined
|
|
508
|
+
if (block) pi.sendMessage({ customType: 'claude-stop-hook', content: block.reason, display: true }, { triggerTurn: true })
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
pi.on('session_before_compact', async (event, ctx) => {
|
|
512
|
+
const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
|
|
513
|
+
const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), { hook_event_name: 'PreCompact', trigger: trigger.value }, boundRunner(ctx))
|
|
514
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
319
515
|
})
|
|
320
516
|
|
|
321
|
-
pi.on('
|
|
322
|
-
|
|
517
|
+
pi.on('session_compact', async (event, ctx) => {
|
|
518
|
+
const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
|
|
519
|
+
const results = await runNotifyHooks(matchingCommands(config.PostCompact, trigger.names), { hook_event_name: 'PostCompact', trigger: trigger.value }, boundRunner(ctx))
|
|
520
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
323
521
|
})
|
|
324
522
|
|
|
325
|
-
pi.on('session_shutdown', async (event) => {
|
|
326
|
-
|
|
523
|
+
pi.on('session_shutdown', async (event, ctx) => {
|
|
524
|
+
const reason = claudeSpelling(SESSION_END_REASON, event.reason)
|
|
525
|
+
const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
|
|
526
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
327
527
|
})
|
|
328
528
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel and payload for the MCP tool-name registry the mcp extension publishes on
|
|
3
|
+
* pi's shared extension event bus. Claude Code names MCP tools `mcp__<server>__<tool>`
|
|
4
|
+
* (original names, dashes preserved); pi-code registers them as `<server>_<tool>` with
|
|
5
|
+
* dashes folded to underscores. Hook matchers written for Claude need the mapping, and
|
|
6
|
+
* pi loads every extension without a shared module cache, so cross-extension state must
|
|
7
|
+
* ride the bus rather than a module singleton.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const MCP_TOOLS_CHANNEL = 'pi-code:mcp-tools'
|
|
11
|
+
|
|
12
|
+
export interface McpToolAlias {
|
|
13
|
+
/** Tool name as registered in pi, e.g. `github_create_issue`. */
|
|
14
|
+
pi: string
|
|
15
|
+
/** Claude Code's name for the same tool, e.g. `mcp__github__create_issue`. */
|
|
16
|
+
claude: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isMcpToolAliases(data: unknown): data is McpToolAlias[] {
|
|
20
|
+
return Array.isArray(data) && data.every((entry) => typeof (entry as McpToolAlias)?.pi === 'string' && typeof (entry as McpToolAlias)?.claude === 'string')
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel and payload for the plan-mode state the plan-mode extension publishes on
|
|
3
|
+
* pi's shared extension event bus. Hooks report Claude's permission_mode from it;
|
|
4
|
+
* pi loads extensions without a shared module cache, so state rides the bus.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const PLAN_MODE_CHANNEL = 'pi-code:plan-mode'
|
|
8
|
+
|
|
9
|
+
export interface PlanModeState {
|
|
10
|
+
active: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isPlanModeState(data: unknown): data is PlanModeState {
|
|
14
|
+
return typeof (data as PlanModeState)?.active === 'boolean'
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel and payload for subagent lifecycle events the subagent extension publishes
|
|
3
|
+
* on pi's shared extension event bus. Hooks bridge them to Claude's SubagentStart and
|
|
4
|
+
* SubagentStop; pi loads extensions without a shared module cache, so state rides the bus.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const SUBAGENT_CHANNEL = 'pi-code:subagent'
|
|
8
|
+
|
|
9
|
+
export interface SubagentPhaseEvent {
|
|
10
|
+
phase: 'start' | 'stop'
|
|
11
|
+
agentType: string
|
|
12
|
+
agentId: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isSubagentPhaseEvent(data: unknown): data is SubagentPhaseEvent {
|
|
16
|
+
const event = data as SubagentPhaseEvent
|
|
17
|
+
return (event?.phase === 'start' || event?.phase === 'stop') && typeof event.agentType === 'string' && typeof event.agentId === 'string'
|
|
18
|
+
}
|
package/extensions/mcp.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' //
|
|
|
29
29
|
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
30
30
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
31
31
|
import { Type } from 'typebox'
|
|
32
|
+
import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
|
|
32
33
|
import { capForContext } from './internal/output-guard.js'
|
|
33
34
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
34
35
|
|
|
@@ -261,6 +262,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
261
262
|
const clients = new Map<string, Client>()
|
|
262
263
|
const status = new Map<string, { state: string; tools: number }>()
|
|
263
264
|
const registered = new Set<string>()
|
|
265
|
+
// Original server/tool names per registered pi name, for Claude-style hook matchers.
|
|
266
|
+
const aliases: McpToolAlias[] = []
|
|
264
267
|
|
|
265
268
|
async function connectServers(servers: Record<string, ServerConfig>): Promise<void> {
|
|
266
269
|
for (const [name, config] of Object.entries(servers)) {
|
|
@@ -283,6 +286,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
283
286
|
continue
|
|
284
287
|
}
|
|
285
288
|
registered.add(toolName)
|
|
289
|
+
aliases.push({ pi: toolName, claude: `mcp__${name}__${tool.name}` })
|
|
286
290
|
count++
|
|
287
291
|
pi.registerTool({
|
|
288
292
|
name: toolName,
|
|
@@ -328,6 +332,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
328
332
|
await connectServers(loadConfigFrom(projectConfigPaths(ctx.cwd)))
|
|
329
333
|
}
|
|
330
334
|
|
|
335
|
+
pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
|
|
336
|
+
|
|
331
337
|
const connected = [...status.values()].filter((s) => s.state === 'connected')
|
|
332
338
|
const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected')
|
|
333
339
|
if (connected.length > 0 || failed.length > 0) {
|
package/extensions/memory.ts
CHANGED
|
@@ -18,7 +18,10 @@ import { capForContext } from './internal/output-guard.js'
|
|
|
18
18
|
const INDEX_FILE = 'MEMORY.md'
|
|
19
19
|
|
|
20
20
|
export function projectSlug(cwd: string): string {
|
|
21
|
-
return cwd
|
|
21
|
+
return cwd
|
|
22
|
+
.replace(/^([A-Za-z]):(?=[/\\])/, '$1')
|
|
23
|
+
.replace(/[/\\]/g, '-')
|
|
24
|
+
.replace(/^-+/, '-')
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export function memoryDir(cwd: string): string {
|
|
@@ -17,6 +17,8 @@ import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai'
|
|
|
17
17
|
import type { ExtensionAPI, ExtensionContext, SessionEntry } from '@earendil-works/pi-coding-agent'
|
|
18
18
|
import { Key } from '@earendil-works/pi-tui'
|
|
19
19
|
import { Type } from 'typebox'
|
|
20
|
+
|
|
21
|
+
import { PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
|
|
20
22
|
import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type TodoItem } from './utils.js'
|
|
21
23
|
|
|
22
24
|
// Tools
|
|
@@ -70,6 +72,11 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
/** Hooks report Claude's permission_mode from this bus state. */
|
|
76
|
+
function publishPlanState(): void {
|
|
77
|
+
pi.events.emit(PLAN_MODE_CHANNEL, { active: planModeEnabled })
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
pi.registerFlag('plan', {
|
|
74
81
|
description: 'Start in plan mode (read-only exploration)',
|
|
75
82
|
type: 'boolean',
|
|
@@ -116,6 +123,7 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
116
123
|
}
|
|
117
124
|
// Persist the toggle so a resume does not restore a state the user left.
|
|
118
125
|
persistState()
|
|
126
|
+
publishPlanState()
|
|
119
127
|
updateStatus(ctx)
|
|
120
128
|
}
|
|
121
129
|
|
|
@@ -158,6 +166,7 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
158
166
|
executionMode = todoItems.length > 0
|
|
159
167
|
planFromTool = false
|
|
160
168
|
restoreTools()
|
|
169
|
+
publishPlanState()
|
|
161
170
|
updateStatus(ctx)
|
|
162
171
|
|
|
163
172
|
// Persist before the turn: a crash before the first turn_end must resume into
|
|
@@ -377,6 +386,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
377
386
|
todoItems = planModeEntry.data.todos ?? todoItems
|
|
378
387
|
executionMode = planModeEntry.data.executing ?? executionMode
|
|
379
388
|
}
|
|
389
|
+
publishPlanState()
|
|
380
390
|
|
|
381
391
|
// On resume: re-scan messages after the last "plan-mode-execute" to rebuild
|
|
382
392
|
// completion state without picking up [DONE:n] from previous plans
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { spawn } from 'node:child_process'
|
|
16
|
+
import { randomUUID } 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'
|
|
@@ -24,6 +25,7 @@ import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
|
|
|
24
25
|
import { type Static, Type } from 'typebox'
|
|
25
26
|
import { capForContext } from '../internal/output-guard.js'
|
|
26
27
|
import { isProjectApproved } from '../internal/project-approval.js'
|
|
28
|
+
import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
27
29
|
import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
|
|
28
30
|
import { activeBackgroundRuns, backgroundStatusText, MAX_BACKGROUND_RUNS, startBackgroundRun } from './background.js'
|
|
29
31
|
|
|
@@ -256,9 +258,25 @@ interface RunAgentOptions {
|
|
|
256
258
|
signal?: AbortSignal
|
|
257
259
|
onUpdate?: OnUpdateCallback
|
|
258
260
|
makeDetails: (results: SingleResult[]) => SubagentDetails
|
|
261
|
+
onPhase?: SubagentPhaseSink
|
|
259
262
|
}
|
|
260
263
|
|
|
264
|
+
/** Publishes a child run's start/stop for the hooks extension's SubagentStart/Stop. */
|
|
265
|
+
type SubagentPhaseSink = (phase: 'start' | 'stop', agentType: string, agentId: string) => void
|
|
266
|
+
|
|
261
267
|
async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
|
|
268
|
+
const agent = options.agents.find((a) => a.name === options.agentName)
|
|
269
|
+
if (!agent) return runSingleAgentInner(options)
|
|
270
|
+
const agentId = `fg-${randomUUID().slice(0, 8)}`
|
|
271
|
+
options.onPhase?.('start', agent.name, agentId)
|
|
272
|
+
try {
|
|
273
|
+
return await runSingleAgentInner(options)
|
|
274
|
+
} finally {
|
|
275
|
+
options.onPhase?.('stop', agent.name, agentId)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResult> {
|
|
262
280
|
const { defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails } = options
|
|
263
281
|
const agent = agents.find((a) => a.name === agentName)
|
|
264
282
|
|
|
@@ -478,6 +496,7 @@ interface ModeContext {
|
|
|
478
496
|
signal: AbortSignal | undefined
|
|
479
497
|
onUpdate: OnUpdateCallback | undefined
|
|
480
498
|
makeDetails: MakeDetails
|
|
499
|
+
onPhase?: SubagentPhaseSink
|
|
481
500
|
}
|
|
482
501
|
|
|
483
502
|
async function checkProjectAgentGate(params: SubagentParamsStatic, agents: AgentConfig[], ctx: ExtensionContext, projectAgentsDir: string | null, gateMode: SubagentMode, makeDetails: MakeDetails): Promise<ToolResult | null> {
|
|
@@ -572,6 +591,7 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
|
|
|
572
591
|
const invocation = getPiInvocation(args)
|
|
573
592
|
const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd }, (run) => {
|
|
574
593
|
removeTmpPrompt(tmpPrompt)
|
|
594
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
|
|
575
595
|
const output = capForContext(run.output ?? '') || '(no output)'
|
|
576
596
|
pi.sendMessage(
|
|
577
597
|
{
|
|
@@ -587,6 +607,7 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
|
|
|
587
607
|
removeTmpPrompt(tmpPrompt)
|
|
588
608
|
return backgroundCapResult(makeDetails)
|
|
589
609
|
}
|
|
610
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
|
|
590
611
|
return {
|
|
591
612
|
content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
|
|
592
613
|
details: makeDetails('single')([]),
|
|
@@ -628,6 +649,7 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
|
|
|
628
649
|
signal,
|
|
629
650
|
onUpdate: chainUpdate,
|
|
630
651
|
makeDetails: makeDetails('chain'),
|
|
652
|
+
onPhase: mode.onPhase,
|
|
631
653
|
})
|
|
632
654
|
results.push(result)
|
|
633
655
|
|
|
@@ -695,6 +717,7 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
|
|
|
695
717
|
task: t.task,
|
|
696
718
|
cwd: t.cwd,
|
|
697
719
|
signal,
|
|
720
|
+
onPhase: mode.onPhase,
|
|
698
721
|
// Per-task update callback
|
|
699
722
|
onUpdate: (partial) => {
|
|
700
723
|
const live = partial.details?.results[0]
|
|
@@ -741,6 +764,7 @@ async function runSingleMode(agentName: string, task: string, cwd: string | unde
|
|
|
741
764
|
signal,
|
|
742
765
|
onUpdate,
|
|
743
766
|
makeDetails: makeDetails('single'),
|
|
767
|
+
onPhase: mode.onPhase,
|
|
744
768
|
})
|
|
745
769
|
const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
|
|
746
770
|
if (isError) {
|
|
@@ -1109,7 +1133,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1109
1133
|
|
|
1110
1134
|
if (params.background) return runBackgroundMode(params, agents, ctx.cwd, pi, makeDetails)
|
|
1111
1135
|
|
|
1112
|
-
const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails }
|
|
1136
|
+
const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails, onPhase: (phase, agentType, agentId) => pi.events.emit(SUBAGENT_CHANNEL, { phase, agentType, agentId }) }
|
|
1113
1137
|
|
|
1114
1138
|
if (params.chain?.length) return runChainMode(params.chain, mode)
|
|
1115
1139
|
if (params.tasks?.length) return runParallelMode(params.tasks, mode)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|