pi-code 0.5.0 → 0.7.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 +5 -5
- package/extensions/hooks.ts +182 -45
- package/extensions/internal/builtin-styles/explanatory.md +15 -0
- package/extensions/internal/builtin-styles/learning.md +15 -0
- package/extensions/internal/builtin-styles/proactive.md +9 -0
- package/extensions/internal/plan-mode-state.ts +15 -0
- package/extensions/internal/project-approval.ts +10 -0
- package/extensions/internal/subagent-events.ts +18 -0
- package/extensions/mcp.ts +52 -5
- package/extensions/output-styles.ts +43 -7
- package/extensions/plan-mode/index.ts +10 -0
- package/extensions/status-line.ts +154 -12
- package/extensions/subagent/README.md +15 -34
- package/extensions/subagent/agents/explore.md +18 -0
- package/extensions/subagent/agents/general-purpose.md +10 -0
- package/extensions/subagent/agents/plan.md +16 -0
- package/extensions/subagent/agents.ts +9 -4
- package/extensions/subagent/index.ts +40 -3
- package/package.json +1 -1
- package/extensions/subagent/agents/planner.md +0 -37
- package/extensions/subagent/agents/reviewer.md +0 -35
- package/extensions/subagent/agents/scout.md +0 -50
- package/extensions/subagent/agents/worker.md +0 -24
- package/extensions/subagent/prompts/implement-and-review.md +0 -10
- package/extensions/subagent/prompts/implement.md +0 -10
- package/extensions/subagent/prompts/scout-and-plan.md +0 -9
package/README.md
CHANGED
|
@@ -38,19 +38,19 @@ 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` |
|
|
42
|
-
| Output styles | `.claude/output-styles` + active `outputStyle
|
|
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
|
+
| Output styles | `.claude/output-styles` + active `outputStyle`; Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `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` (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` |
|
|
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; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); 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
|
-
| Subagents / Task | `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; background runs | `subagent/` |
|
|
46
|
+
| Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; agent roster with descriptions in the system prompt; background runs | `subagent/` |
|
|
47
47
|
| Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
|
|
48
48
|
| Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
|
|
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
52
|
| AskUserQuestion | one question with `header`, single- or `multiSelect` options, plus free-text; no multi-question batching | `question.ts` |
|
|
53
|
-
| Statusline | turn state + session cost | `status-line.ts` |
|
|
53
|
+
| Statusline | Claude `statusLine` command contract (stdin JSON, `padding`, `refreshInterval`); built-in turn state + session cost fallback | `status-line.ts` |
|
|
54
54
|
| Notifications | vendored example | `notify.ts` |
|
|
55
55
|
|
|
56
56
|
`CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
|
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
|
|
@@ -35,10 +48,12 @@ import { type ChildProcess, spawn } from 'node:child_process'
|
|
|
35
48
|
import * as fs from 'node:fs'
|
|
36
49
|
import * as os from 'node:os'
|
|
37
50
|
import * as path from 'node:path'
|
|
38
|
-
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
51
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
39
52
|
|
|
40
53
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
|
|
54
|
+
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
41
55
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
56
|
+
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
|
|
42
57
|
|
|
43
58
|
const DEFAULT_TIMEOUT_S = 60
|
|
44
59
|
|
|
@@ -144,7 +159,7 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
|
|
|
144
159
|
return result
|
|
145
160
|
}
|
|
146
161
|
|
|
147
|
-
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 {
|
|
148
163
|
try {
|
|
149
164
|
return JSON.parse(text)
|
|
150
165
|
} catch {
|
|
@@ -242,24 +257,50 @@ function timeoutMs(command: HookCommand): number {
|
|
|
242
257
|
return seconds * 1000
|
|
243
258
|
}
|
|
244
259
|
|
|
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
|
+
|
|
245
271
|
/** Run PreToolUse hooks for a tool; the first blocking verdict wins. For MCP tools the
|
|
246
272
|
* matcher sees both the pi name and the Claude alias, and the payload reports the alias,
|
|
247
|
-
* which is the name a Claude-written hook script expects in tool_name.
|
|
248
|
-
|
|
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> {
|
|
249
277
|
const names = claudeName ? [toolName, claudeName] : [toolName]
|
|
250
278
|
for (const command of matchingCommands(config.PreToolUse, names)) {
|
|
251
279
|
const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command))
|
|
252
280
|
// A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
|
|
253
281
|
// would otherwise read as a clean allow. Fail closed instead.
|
|
254
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)
|
|
255
286
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
256
287
|
if (decision.block) return decision
|
|
257
288
|
}
|
|
258
289
|
return { block: false }
|
|
259
290
|
}
|
|
260
291
|
|
|
261
|
-
async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<
|
|
262
|
-
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
|
+
}
|
|
263
304
|
}
|
|
264
305
|
|
|
265
306
|
export interface PromptDecision {
|
|
@@ -278,11 +319,12 @@ function promptContext(stdout: string): string {
|
|
|
278
319
|
|
|
279
320
|
/** Run UserPromptSubmit hooks: the first blocking verdict wins; otherwise their
|
|
280
321
|
* additional context is concatenated for injection ahead of the prompt. */
|
|
281
|
-
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> {
|
|
282
323
|
const contexts: string[] = []
|
|
283
324
|
for (const command of matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')) {
|
|
284
325
|
const result = await runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))
|
|
285
326
|
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}`, context: '' }
|
|
327
|
+
if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
|
|
286
328
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
287
329
|
if (decision.block) return { block: true, reason: decision.reason, context: '' }
|
|
288
330
|
const context = promptContext(result.stdout)
|
|
@@ -291,9 +333,6 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
|
|
|
291
333
|
return { block: false, context: contexts.join('\n') }
|
|
292
334
|
}
|
|
293
335
|
|
|
294
|
-
/** Bound on remembered tool inputs, in case a blocked or aborted call never ends. */
|
|
295
|
-
const MAX_PENDING_INPUTS = 100
|
|
296
|
-
|
|
297
336
|
/** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
|
|
298
337
|
* offered both spellings so existing configs keep firing either way, and the payload
|
|
299
338
|
* reports the Claude value, which is what a Claude-written hook script parses. */
|
|
@@ -310,10 +349,22 @@ function claudeSpelling(map: Record<string, string>, raw: string): { names: stri
|
|
|
310
349
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
311
350
|
let config: HooksConfig = {}
|
|
312
351
|
let projectDir = ''
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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)
|
|
317
368
|
// Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
|
|
318
369
|
// <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
|
|
319
370
|
const mcpAliases = new Map<string, string>()
|
|
@@ -322,8 +373,25 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
322
373
|
mcpAliases.clear()
|
|
323
374
|
for (const entry of data) mcpAliases.set(entry.pi, entry.claude)
|
|
324
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
|
+
})
|
|
325
392
|
|
|
326
393
|
pi.on('session_start', async (event, ctx) => {
|
|
394
|
+
sessionCtx = ctx
|
|
327
395
|
const trusted = await isProjectApproved(ctx)
|
|
328
396
|
projectDir = ctx.cwd
|
|
329
397
|
config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
|
|
@@ -331,37 +399,77 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
331
399
|
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
332
400
|
if (event.reason === 'reload') return
|
|
333
401
|
const source = claudeSpelling(SESSION_START_SOURCE, event.reason)
|
|
334
|
-
|
|
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)
|
|
335
408
|
})
|
|
336
409
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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'))
|
|
344
422
|
if (!decision.block) return undefined
|
|
345
|
-
// pi still emits tool_execution_end (isError) for a blocked call, which also
|
|
346
|
-
// cleans up; deleting here just avoids relying on that host detail.
|
|
347
|
-
pendingInputs.delete(event.toolCallId)
|
|
348
423
|
return { block: true, reason: decision.reason }
|
|
349
424
|
})
|
|
350
425
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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) => {
|
|
355
431
|
const alias = mcpAliases.get(event.toolName)
|
|
356
432
|
const names = alias ? [event.toolName, alias] : [event.toolName]
|
|
357
|
-
|
|
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 }))] }
|
|
358
466
|
})
|
|
359
467
|
|
|
360
468
|
pi.on('input', async (event, ctx) => {
|
|
361
469
|
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
362
470
|
// prompts the user submitted.
|
|
363
471
|
if (event.source === 'extension') return { action: 'continue' }
|
|
364
|
-
const decision = await runUserPromptSubmit(config, event.text,
|
|
472
|
+
const decision = await runUserPromptSubmit(config, event.text, boundRunner(ctx), (message) => ctx.ui.notify(message, 'warning'))
|
|
365
473
|
if (decision.block) {
|
|
366
474
|
// pi's input result has no reason channel, so surface why before consuming it.
|
|
367
475
|
ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
|
|
@@ -373,19 +481,48 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
373
481
|
return { action: 'continue' }
|
|
374
482
|
})
|
|
375
483
|
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
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'))
|
|
380
515
|
})
|
|
381
516
|
|
|
382
|
-
pi.on('
|
|
517
|
+
pi.on('session_compact', async (event, ctx) => {
|
|
383
518
|
const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
|
|
384
|
-
await runNotifyHooks(matchingCommands(config.
|
|
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'))
|
|
385
521
|
})
|
|
386
522
|
|
|
387
|
-
pi.on('session_shutdown', async (event) => {
|
|
523
|
+
pi.on('session_shutdown', async (event, ctx) => {
|
|
388
524
|
const reason = claudeSpelling(SESSION_END_REASON, event.reason)
|
|
389
|
-
await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value },
|
|
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'))
|
|
390
527
|
})
|
|
391
528
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Explanatory
|
|
3
|
+
description: Educational insights while completing tasks
|
|
4
|
+
keep-coding-instructions: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Between completing software engineering tasks, provide brief educational "Insights" that help the user understand implementation choices and codebase patterns.
|
|
8
|
+
|
|
9
|
+
Mark each one clearly:
|
|
10
|
+
|
|
11
|
+
`✻ Insight ─────`
|
|
12
|
+
(2-3 sentences on why this approach, what pattern it follows, or what tradeoff it makes)
|
|
13
|
+
`─────`
|
|
14
|
+
|
|
15
|
+
Share an insight when there is a genuine decision or pattern worth understanding, not after every step. Keep the work itself unchanged: insights are commentary, never a substitute for doing the task.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Learning
|
|
3
|
+
description: Collaborative learn-by-doing with small human-written pieces
|
|
4
|
+
keep-coding-instructions: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Work collaboratively, learn-by-doing style: share brief "Insights" explaining implementation choices as you work, and regularly ask the user to contribute small, strategic pieces of code themselves.
|
|
8
|
+
|
|
9
|
+
When a piece is well-scoped for the user to write (a condition, a small function body, a test assertion), leave a marker instead of writing it:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
// TODO(human): <one sentence describing exactly what to implement here>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then stop and ask the user to fill it in, explaining what the piece needs to do and why it matters. Choose pieces that teach something about the codebase or the problem, not busywork. Keep your own contributions moving the task forward between their turns.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Proactive
|
|
3
|
+
description: Execute immediately, prefer action over planning
|
|
4
|
+
keep-coding-instructions: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Execute immediately. Make reasonable assumptions instead of pausing for routine decisions, and prefer action over planning: when a step is reversible and follows from the task, do it rather than proposing it.
|
|
8
|
+
|
|
9
|
+
Ask only when a decision is genuinely the user's to make (destructive actions, real scope changes). Report what you did and why afterward, concisely.
|
|
@@ -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
|
+
}
|
|
@@ -69,6 +69,16 @@ const APPROVAL_BODY = 'It ships Claude Code configuration that pi-code loads. MC
|
|
|
69
69
|
* `defaultProjectTrust` at all, so there is no user preference to fall back on. A run
|
|
70
70
|
* that cannot ask has not been approved.
|
|
71
71
|
*/
|
|
72
|
+
/** The same decision as isProjectApproved, but never prompts: an undecided project
|
|
73
|
+
* reads as unapproved. For surfaces that only display project config, like the
|
|
74
|
+
* subagent roster, where a mid-turn dialog would be wrong. */
|
|
75
|
+
export function isProjectApprovedSilently(ctx: Pick<ApprovalContext, 'cwd' | 'isProjectTrusted'>, deps: ApprovalDeps = defaultDeps): boolean {
|
|
76
|
+
if (ctx.isProjectTrusted?.() !== true) return false
|
|
77
|
+
if (!deps.hasClaudeShaped(ctx.cwd)) return true
|
|
78
|
+
if (deps.piWouldAsk(ctx.cwd)) return true
|
|
79
|
+
return deps.savedDecision(ctx.cwd) === true
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
export async function isProjectApproved(ctx: ApprovalContext, deps: ApprovalDeps = defaultDeps): Promise<boolean> {
|
|
73
83
|
if (ctx.isProjectTrusted?.() !== true) return false // pi already declined, or never trusted
|
|
74
84
|
if (!deps.hasClaudeShaped(ctx.cwd)) return true // nothing here pi's own check would miss
|
|
@@ -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
|
@@ -92,6 +92,37 @@ export function projectConfigPaths(cwd: string): string[] {
|
|
|
92
92
|
return [path.join(cwd, '.mcp.json'), path.join(cwd, '.pi', 'mcp.json')]
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
export interface ProjectServerPolicy {
|
|
96
|
+
disabled: Set<string>
|
|
97
|
+
consented: Set<string>
|
|
98
|
+
consentAll: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Claude's per-server approvals for project .mcp.json servers. Consent-granting keys
|
|
102
|
+
* (enabledMcpjsonServers, enableAllProjectMcpServers) count only from files the repo
|
|
103
|
+
* does not control (user settings and settings.local.json), so a checked-in
|
|
104
|
+
* settings.json cannot approve its own servers. disabledMcpjsonServers counts from
|
|
105
|
+
* every file and wins over consent. Lists union across files: for denies the union is
|
|
106
|
+
* the restrictive reading, and consent is the union of the user's own two files. */
|
|
107
|
+
export function projectServerPolicy(cwd: string, home: string): ProjectServerPolicy {
|
|
108
|
+
const read = (file: string): Record<string, unknown> => {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
111
|
+
} catch {
|
|
112
|
+
return {}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
|
|
116
|
+
const userSettings = read(path.join(home, '.claude', 'settings.json'))
|
|
117
|
+
const projectSettings = read(path.join(cwd, '.claude', 'settings.json'))
|
|
118
|
+
const localSettings = read(path.join(cwd, '.claude', 'settings.local.json'))
|
|
119
|
+
const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
|
|
120
|
+
const consentSources = [userSettings, localSettings]
|
|
121
|
+
const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
|
|
122
|
+
const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
|
|
123
|
+
return { disabled, consented, consentAll }
|
|
124
|
+
}
|
|
125
|
+
|
|
95
126
|
export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
96
127
|
const servers: Record<string, ServerConfig> = {}
|
|
97
128
|
for (const file of files) {
|
|
@@ -325,11 +356,27 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
325
356
|
userConnected = true
|
|
326
357
|
await connectServers(loadUserScope(os.homedir(), ctx.cwd))
|
|
327
358
|
}
|
|
328
|
-
// A project .mcp.json can run arbitrary commands on connect, so only honor it once
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
359
|
+
// A project .mcp.json can run arbitrary commands on connect, so only honor it once
|
|
360
|
+
// the project is trusted. Per-server settings refine that: disabled servers never
|
|
361
|
+
// connect, servers the user consented to individually connect without the
|
|
362
|
+
// whole-project confirm, and the rest stay behind it. Reconnect attempts after a
|
|
363
|
+
// refusal are safe: connectServers skips names that already connected.
|
|
364
|
+
if (!projectConnected) {
|
|
365
|
+
const policy = projectServerPolicy(ctx.cwd, os.homedir())
|
|
366
|
+
const candidates = loadConfigFrom(projectConfigPaths(ctx.cwd))
|
|
367
|
+
const consented: Record<string, ServerConfig> = {}
|
|
368
|
+
const gated: Record<string, ServerConfig> = {}
|
|
369
|
+
for (const [name, config] of Object.entries(candidates)) {
|
|
370
|
+
if (policy.disabled.has(name)) continue
|
|
371
|
+
if (policy.consentAll || policy.consented.has(name)) consented[name] = config
|
|
372
|
+
else gated[name] = config
|
|
373
|
+
}
|
|
374
|
+
if (Object.keys(consented).length > 0) await connectServers(consented)
|
|
375
|
+
if (Object.keys(gated).length === 0) projectConnected = true
|
|
376
|
+
else if (await isProjectApproved(ctx)) {
|
|
377
|
+
projectConnected = true
|
|
378
|
+
await connectServers(gated)
|
|
379
|
+
}
|
|
333
380
|
}
|
|
334
381
|
|
|
335
382
|
pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* tone and role. `/output-style` lists the styles and persists a choice to the
|
|
9
9
|
* project's settings.local.json.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Claude semantics: a style replaces the built-in coding instructions unless its
|
|
12
|
+
* frontmatter sets `keep-coding-instructions: true`. The replacement excises pi's
|
|
13
|
+
* default coding prose up to a stable marker line and keeps everything after it
|
|
14
|
+
* (append text, project context, skills, other extensions' additions); when the
|
|
15
|
+
* marker is absent (custom SYSTEM.md), the style falls back to appending.
|
|
13
16
|
*
|
|
14
17
|
* Docs: https://code.claude.com/docs/en/output-styles.md
|
|
15
18
|
*/
|
|
@@ -25,6 +28,7 @@ export interface OutputStyle {
|
|
|
25
28
|
name: string
|
|
26
29
|
description: string
|
|
27
30
|
body: string
|
|
31
|
+
keepCodingInstructions: boolean
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
function field(frontmatter: string, key: string): string {
|
|
@@ -37,7 +41,27 @@ export function parseStyle(content: string, fallbackName: string): OutputStyle {
|
|
|
37
41
|
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)
|
|
38
42
|
const frontmatter = match ? match[1] : ''
|
|
39
43
|
const body = match ? content.slice(match[0].length) : content
|
|
40
|
-
return { name: field(frontmatter, 'name') || fallbackName, description: field(frontmatter, 'description'), body: body.trim() }
|
|
44
|
+
return { name: field(frontmatter, 'name') || fallbackName, description: field(frontmatter, 'description'), body: body.trim(), keepCodingInstructions: field(frontmatter, 'keep-coding-instructions') === 'true' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Equivalents of Claude's built-in styles, shipped with pi-code as the
|
|
48
|
+
* lowest-precedence source: a user or project style of the same name wins. */
|
|
49
|
+
export const BUILTIN_STYLES_DIR = path.join(import.meta.dirname, 'internal', 'builtin-styles')
|
|
50
|
+
|
|
51
|
+
/** The last line of pi's default coding instructions. Everything after it (append
|
|
52
|
+
* text, project context, skills, cwd, other extensions' additions) survives a style
|
|
53
|
+
* replacement. Tracks pi's dist/core/system-prompt.js; a canary test pins it. */
|
|
54
|
+
export const CODING_BASE_MARKER = '- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)'
|
|
55
|
+
|
|
56
|
+
/** Apply a style per Claude semantics: replace the coding instructions unless the
|
|
57
|
+
* style keeps them; fall back to appending when the marker is absent. */
|
|
58
|
+
export function applyStyle(systemPrompt: string, style: OutputStyle): string {
|
|
59
|
+
const styleSection = `## Output Style: ${style.name}\n\n${style.body}`
|
|
60
|
+
if (!style.keepCodingInstructions) {
|
|
61
|
+
const idx = systemPrompt.indexOf(CODING_BASE_MARKER)
|
|
62
|
+
if (idx !== -1) return `${styleSection}${systemPrompt.slice(idx + CODING_BASE_MARKER.length)}`
|
|
63
|
+
}
|
|
64
|
+
return `${systemPrompt}\n\n${styleSection}`
|
|
41
65
|
}
|
|
42
66
|
|
|
43
67
|
function isDirectory(target: string): boolean {
|
|
@@ -132,7 +156,7 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
|
|
|
132
156
|
// project styles / selection once the project is approved. isProjectTrusted alone
|
|
133
157
|
// is true for a repo pi never asked about; see project-approval.
|
|
134
158
|
const trusted = await isProjectApproved(ctx)
|
|
135
|
-
styles = loadStyles(styleDirs(ctx.cwd, home, trusted))
|
|
159
|
+
styles = loadStyles([BUILTIN_STYLES_DIR, ...styleDirs(ctx.cwd, home, trusted)])
|
|
136
160
|
localSettingsPath = path.join(ctx.cwd, '.claude', 'settings.local.json')
|
|
137
161
|
activeName = readActiveStyleName(settingsFiles(ctx.cwd, home, trusted))
|
|
138
162
|
const active = styleForName(styles, activeName)
|
|
@@ -142,12 +166,24 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
|
|
|
142
166
|
pi.on('before_agent_start', async (event) => {
|
|
143
167
|
const active = styleForName(styles, activeName)
|
|
144
168
|
if (!active || active.body.length === 0) return
|
|
145
|
-
return { systemPrompt:
|
|
169
|
+
return { systemPrompt: applyStyle(event.systemPrompt, active) }
|
|
146
170
|
})
|
|
147
171
|
|
|
148
172
|
pi.registerCommand('output-style', {
|
|
149
|
-
description: 'Choose the active Claude output style',
|
|
150
|
-
handler: async (
|
|
173
|
+
description: 'Choose the active Claude output style (or /output-style <name>)',
|
|
174
|
+
handler: async (args, ctx) => {
|
|
175
|
+
const requested = args.trim()
|
|
176
|
+
if (requested) {
|
|
177
|
+
const picked = styles.find((style) => style.name.toLowerCase() === requested.toLowerCase())
|
|
178
|
+
if (!picked) {
|
|
179
|
+
ctx.ui.notify(`Unknown output style: ${requested}. Available: ${styles.map((style) => style.name).join(', ')}`, 'error')
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
activeName = picked.name
|
|
183
|
+
persistActiveStyle(localSettingsPath, picked.name)
|
|
184
|
+
ctx.ui.notify(`Output style set to ${picked.name} (applies next turn)`, 'info')
|
|
185
|
+
return
|
|
186
|
+
}
|
|
151
187
|
if (!ctx.hasUI) {
|
|
152
188
|
ctx.ui.notify('/output-style requires interactive mode', 'error')
|
|
153
189
|
return
|