pi-code 1.0.51 → 1.0.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/claude-rules.ts +41 -6
- package/extensions/commands.ts +4 -2
- package/extensions/env-settings.ts +10 -8
- package/extensions/git-checkpoint.ts +17 -1
- package/extensions/hooks/decisions.ts +14 -8
- package/extensions/hooks/index.ts +55 -27
- package/extensions/hooks/matcher.ts +8 -6
- package/extensions/internal/atomic-write.ts +14 -0
- package/extensions/internal/bash-rules.ts +44 -0
- package/extensions/internal/command-file.ts +8 -1
- package/extensions/internal/effort.ts +13 -0
- package/extensions/internal/mcp-oauth.ts +25 -6
- package/extensions/internal/project-approval.ts +2 -0
- package/extensions/mcp/config.ts +13 -1
- package/extensions/mcp/index.ts +8 -2
- package/extensions/mcp/transport.ts +25 -4
- package/extensions/memory.ts +6 -9
- package/extensions/output-styles.ts +30 -14
- package/extensions/status-line.ts +12 -6
- package/extensions/subagent/background.ts +11 -1
- package/extensions/subagent/index.ts +16 -26
- package/package.json +1 -1
|
@@ -39,12 +39,47 @@ function unquote(value: string): string {
|
|
|
39
39
|
return value.replace(/^["']|["']$/g, '')
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Split a YAML flow sequence on its top-level commas. A comma inside a brace group
|
|
43
|
+
* (`*.{ts,tsx}`, which Claude expands) or inside quotes belongs to its entry, so
|
|
44
|
+
* splitting on every comma produced patterns that match nothing. */
|
|
42
45
|
function splitInline(value: string): string[] {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const entries: string[] = []
|
|
47
|
+
let current = ''
|
|
48
|
+
let depth = 0
|
|
49
|
+
let quote: string | undefined
|
|
50
|
+
for (const char of value.replace(/^\[/, '').replace(/\]$/, '')) {
|
|
51
|
+
if (quote !== undefined) {
|
|
52
|
+
if (char === quote) quote = undefined
|
|
53
|
+
current += char
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (char === '"' || char === "'") {
|
|
57
|
+
quote = char
|
|
58
|
+
current += char
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (char === '{') depth++
|
|
62
|
+
else if (char === '}') depth = Math.max(0, depth - 1)
|
|
63
|
+
else if (char === ',' && depth === 0) {
|
|
64
|
+
entries.push(current)
|
|
65
|
+
current = ''
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
current += char
|
|
69
|
+
}
|
|
70
|
+
entries.push(current)
|
|
71
|
+
return entries.map((entry) => unquote(entry.trim())).filter(Boolean)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A flow sequence that opens on the `paths:` line and closes on a later one, joined
|
|
75
|
+
* back into a single value. An unterminated list runs to the end of the frontmatter. */
|
|
76
|
+
function joinFlowSequence(lines: string[], index: number, opening: string): string {
|
|
77
|
+
let joined = opening
|
|
78
|
+
for (let i = index + 1; i < lines.length; i++) {
|
|
79
|
+
joined += ` ${lines[i].trim()}`
|
|
80
|
+
if (lines[i].includes(']')) break
|
|
81
|
+
}
|
|
82
|
+
return joined
|
|
48
83
|
}
|
|
49
84
|
|
|
50
85
|
function parsePaths(frontmatter: string): string[] {
|
|
@@ -52,7 +87,7 @@ function parsePaths(frontmatter: string): string[] {
|
|
|
52
87
|
const index = lines.findIndex((line) => /^\s*paths\s*:/.test(line))
|
|
53
88
|
if (index === -1) return []
|
|
54
89
|
const inline = lines[index].replace(/^\s*paths\s*:/, '').trim()
|
|
55
|
-
if (inline) return splitInline(inline)
|
|
90
|
+
if (inline) return splitInline(inline.startsWith('[') && !inline.includes(']') ? joinFlowSequence(lines, index, inline) : inline)
|
|
56
91
|
const items: string[] = []
|
|
57
92
|
// Matched with string ops rather than a regex: the equivalent pattern needs two
|
|
58
93
|
// adjacent whitespace quantifiers, which backtracks super-linearly on long lines.
|
package/extensions/commands.ts
CHANGED
|
@@ -43,10 +43,10 @@ import * as os from 'node:os'
|
|
|
43
43
|
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
|
-
|
|
47
46
|
import { matchesBashRules } from './internal/bash-rules.js'
|
|
48
47
|
import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, type PathRuleTool, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
|
|
49
48
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
49
|
+
import { claudeEffortLevel } from './internal/effort.js'
|
|
50
50
|
import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
|
|
51
51
|
import { capForContext } from './internal/output-guard.js'
|
|
52
52
|
import { matchesPathRules } from './internal/path-rules.js'
|
|
@@ -187,7 +187,9 @@ function commandVars(ctx: { cwd: string }, filePath: string, plugin?: CommandPlu
|
|
|
187
187
|
const varCtx = ctx as unknown as VarContext
|
|
188
188
|
return {
|
|
189
189
|
CLAUDE_SESSION_ID: varCtx.sessionManager?.getSessionId?.(),
|
|
190
|
-
|
|
190
|
+
// Claude vocabulary only, and unset when thinking is off: the variable promises
|
|
191
|
+
// low|medium|high|xhigh|max, and an unset one stays literal in the body.
|
|
192
|
+
CLAUDE_EFFORT: claudeEffortLevel(varCtx.thinkingLevel),
|
|
191
193
|
CLAUDE_SKILL_DIR: path.dirname(filePath),
|
|
192
194
|
CLAUDE_PROJECT_DIR: repoRoot(ctx.cwd) ?? ctx.cwd,
|
|
193
195
|
CLAUDE_PLUGIN_ROOT: plugin?.root,
|
|
@@ -40,7 +40,7 @@ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-a
|
|
|
40
40
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
41
41
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
42
42
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
43
|
-
import {
|
|
43
|
+
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
44
44
|
|
|
45
45
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
46
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
@@ -137,12 +137,14 @@ export function sanitizeProjectEnv(env: Record<string, string>, warn: (key: stri
|
|
|
137
137
|
return kept
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
/** The project scope's env
|
|
141
|
-
*
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
140
|
+
/** The project scope's env, resolved through the shared settings chain so placement
|
|
141
|
+
* matches every other consumer: the shared settings.json comes from the session's own
|
|
142
|
+
* directory and never an ancestor, settings.local.json from the repository root. Later
|
|
143
|
+
* files win. */
|
|
144
|
+
function projectEnv(cwd: string, home: string): Record<string, string> {
|
|
145
|
+
const merged: Record<string, string> = {}
|
|
146
|
+
for (const file of claudeSettingsChain(cwd, home, true).slice(1)) Object.assign(merged, envFromSettings(readSettingsFile(file)))
|
|
147
|
+
return sanitizeProjectEnv(merged)
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
@@ -157,6 +159,6 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
|
157
159
|
apply(os.homedir(), {})
|
|
158
160
|
|
|
159
161
|
pi.on('session_start', async (_event, ctx: ExtensionContext) => {
|
|
160
|
-
apply(os.homedir(), isProjectApprovedSilently(ctx) ? projectEnv(ctx.cwd) : {})
|
|
162
|
+
apply(os.homedir(), isProjectApprovedSilently(ctx) ? projectEnv(ctx.cwd, os.homedir()) : {})
|
|
161
163
|
})
|
|
162
164
|
}
|
|
@@ -18,6 +18,7 @@ import * as fs from 'node:fs'
|
|
|
18
18
|
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
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
21
22
|
|
|
22
23
|
const CUSTOM_TYPE = 'git-checkpoint'
|
|
23
24
|
/** Sidecar inside the bare shadow repo recording the work tree it snapshots. */
|
|
@@ -37,6 +38,21 @@ interface Checkpoint {
|
|
|
37
38
|
* for the life of the machine. */
|
|
38
39
|
export const CHECKPOINT_RETENTION_DAYS = 30
|
|
39
40
|
|
|
41
|
+
/** The retention period in effect: Claude keeps checkpoints for 30 days and says to
|
|
42
|
+
* "change the period with cleanupPeriodDays". Read from the user scope, which is where a
|
|
43
|
+
* setting about the user's own disk belongs; a non-positive or unreadable value keeps the
|
|
44
|
+
* default rather than sweeping everything away. */
|
|
45
|
+
export function checkpointRetentionDays(home: string = os.homedir()): number {
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8'))
|
|
48
|
+
const declared = (parsed as { cleanupPeriodDays?: unknown }).cleanupPeriodDays
|
|
49
|
+
if (typeof declared === 'number' && Number.isFinite(declared) && declared > 0) return declared
|
|
50
|
+
} catch {
|
|
51
|
+
// No user settings, or unreadable: the default period stands.
|
|
52
|
+
}
|
|
53
|
+
return CHECKPOINT_RETENTION_DAYS
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
/** Claude keeps the 100 most recent checkpoints per session. Older ones drop off the
|
|
41
57
|
* rewind list; their commits stay in the shadow repo until the retention sweep. */
|
|
42
58
|
export const MAX_CHECKPOINTS_PER_SESSION = 100
|
|
@@ -179,7 +195,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
179
195
|
shadowDir = path.join(checkpointsRoot, `${sessionSlug(sessionFile)}-${cwdSlug(ctx.cwd)}`)
|
|
180
196
|
ctx.ui.notify(`Checkpoints for this session were recorded in ${recorded}; starting fresh checkpoints for ${ctx.cwd} (earlier ones are not restorable here)`, 'warning')
|
|
181
197
|
}
|
|
182
|
-
pruneCheckpointRepos(checkpointsRoot,
|
|
198
|
+
pruneCheckpointRepos(checkpointsRoot, checkpointRetentionDays(os.homedir()), shadowDir)
|
|
183
199
|
const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
|
|
184
200
|
if (check.code !== 0) {
|
|
185
201
|
const init = await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
|
|
@@ -147,7 +147,19 @@ function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[],
|
|
|
147
147
|
// Claude shows a `<hook> hook error` notice when {..}-shaped stdout cannot be
|
|
148
148
|
// read as JSON output (exit 2 still blocks and reads its own channels).
|
|
149
149
|
const jsonError = result.code === 2 ? undefined : hookJsonError(result.stdout)
|
|
150
|
-
if (jsonError !== undefined)
|
|
150
|
+
if (jsonError !== undefined) {
|
|
151
|
+
notify(`${commands[i].command} hook error: ${jsonError}`)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
// A non-zero exit that is neither a block (2) nor a timeout, with nothing parseable
|
|
155
|
+
// on stdout, is Claude's non-blocking error: the action proceeds and the notice
|
|
156
|
+
// carries the first line of stderr. Without it a mistyped path in settings.json
|
|
157
|
+
// leaves a policy hook silently disabled, since the shell exits 127 and says so only
|
|
158
|
+
// on stderr. A spawn failure is reported above and skipped here.
|
|
159
|
+
if (!result.spawnFailed && !result.timedOut && result.code !== 0 && result.code !== 2) {
|
|
160
|
+
const firstLine = result.stderr.trim().split('\n')[0]
|
|
161
|
+
notify(`${commands[i].command} hook error: Failed with non-blocking status code: ${firstLine || result.code}`)
|
|
162
|
+
}
|
|
151
163
|
}
|
|
152
164
|
}
|
|
153
165
|
|
|
@@ -250,8 +262,6 @@ export interface PromptDecision {
|
|
|
250
262
|
block: boolean
|
|
251
263
|
reason?: string
|
|
252
264
|
context: string
|
|
253
|
-
/** Claude's suppressOriginalPrompt: the hook's context replaces the prompt. */
|
|
254
|
-
suppress?: boolean
|
|
255
265
|
}
|
|
256
266
|
|
|
257
267
|
/** Additional context a UserPromptSubmit hook contributes: an explicit
|
|
@@ -279,17 +289,13 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
|
|
|
279
289
|
}
|
|
280
290
|
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
281
291
|
const contexts: string[] = []
|
|
282
|
-
let suppress = false
|
|
283
292
|
for (const result of results) {
|
|
284
293
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
285
294
|
if (decision.block) return { block: true, reason: decision.reason, context: '' }
|
|
286
|
-
// Claude's suppressOriginalPrompt: any hook setting it hides the original
|
|
287
|
-
// prompt, and the collected context is what reaches the model.
|
|
288
|
-
if (tryParseJson(result.stdout)?.hookSpecificOutput?.suppressOriginalPrompt === true) suppress = true
|
|
289
295
|
const context = promptContext(result.stdout)
|
|
290
296
|
if (context) contexts.push(context)
|
|
291
297
|
}
|
|
292
|
-
return { block: false, context: contexts.join('\n')
|
|
298
|
+
return { block: false, context: contexts.join('\n') }
|
|
293
299
|
}
|
|
294
300
|
|
|
295
301
|
/** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
|
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
|
|
97
97
|
import * as os from 'node:os'
|
|
98
98
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
99
|
+
import { claudeEffortLevel } from '../internal/effort.js'
|
|
99
100
|
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from '../internal/instruction-events.js'
|
|
100
101
|
import { readManagedSettings } from '../internal/managed-settings.js'
|
|
101
102
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
|
|
@@ -186,6 +187,25 @@ const IDLE_PROMPT_DELAY_MS = 60_000
|
|
|
186
187
|
* Claude Code restores on resume. */
|
|
187
188
|
const MODEL_SELECT_SOURCE: Record<string, string> = { set: 'command', cycle: 'picker', restore: 'resume' }
|
|
188
189
|
|
|
190
|
+
/** One Stop hook result read as a verdict. Claude: `continue` "takes precedence over any
|
|
191
|
+
* event-specific decision fields", and stopReason is the message shown when it is false,
|
|
192
|
+
* so a hook asking to stop wins over its own block whatever exit code carried it. Any
|
|
193
|
+
* blocking spelling counts, including the prompt and agent hook reply schemas, and a
|
|
194
|
+
* non-error additionalContext feeds back the same way so the block cap still bounds it. */
|
|
195
|
+
function stopVerdict(result: HookRunResult, stopMessages: string[]): { block: boolean; reason: string } {
|
|
196
|
+
const parsed = tryParseJson(result.stdout)
|
|
197
|
+
if (parsed?.continue === false) {
|
|
198
|
+
if (parsed.stopReason) stopMessages.push(String(parsed.stopReason))
|
|
199
|
+
return { block: false, reason: '' }
|
|
200
|
+
}
|
|
201
|
+
if (result.code === 2) return { block: true, reason: jsonBlockVerdict(parsed, 'Stop blocked by hook')?.reason ?? (result.stderr.trim() || 'Stop blocked by hook') }
|
|
202
|
+
const verdict = jsonBlockVerdict(parsed, 'Stop blocked by hook')
|
|
203
|
+
if (verdict) return { block: true, reason: verdict.reason }
|
|
204
|
+
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
205
|
+
if (typeof context === 'string' && context.length > 0) return { block: true, reason: context }
|
|
206
|
+
return { block: false, reason: '' }
|
|
207
|
+
}
|
|
208
|
+
|
|
189
209
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
190
210
|
let config: HooksConfig = {}
|
|
191
211
|
let projectDir = ''
|
|
@@ -228,7 +248,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
228
248
|
const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
|
|
229
249
|
const transcript = ctx.sessionManager.getSessionFile()
|
|
230
250
|
if (transcript) common.transcript_path = transcript
|
|
231
|
-
|
|
251
|
+
// Claude vocabulary only: pi's minimal maps to low and off carries no effort.
|
|
252
|
+
const effort = claudeEffortLevel(ctx.thinkingLevel)
|
|
253
|
+
if (effort) common.effort = { level: effort }
|
|
232
254
|
return common
|
|
233
255
|
}
|
|
234
256
|
/** Claude's prompt-hook `model` override, resolved against the models this user
|
|
@@ -512,7 +534,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
512
534
|
const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input, textContent(event.content), event.isError, ctx.cwd) : undefined) ?? { content: event.content, details: event.details, isError: event.isError }
|
|
513
535
|
const startedAt = toolStartTimes.get(event.toolCallId)
|
|
514
536
|
toolStartTimes.delete(event.toolCallId)
|
|
515
|
-
|
|
537
|
+
// Claude delivers a failure as top-level fields rather than a tool_response: "error
|
|
538
|
+
// information as top-level fields ... error ... is_interrupt". is_interrupt is false
|
|
539
|
+
// here because pi reports a cancelled tool through the result, not this event.
|
|
540
|
+
const failure = event.isError ? { error: textContent(event.content), is_interrupt: false } : { tool_response: response }
|
|
541
|
+
const payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, ...failure, ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }) }
|
|
516
542
|
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
517
543
|
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
518
544
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
@@ -576,10 +602,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
576
602
|
return { action: 'handled' }
|
|
577
603
|
}
|
|
578
604
|
// Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
|
|
579
|
-
// pi's seam for rewriting the submitted text.
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
605
|
+
// pi's seam for rewriting the submitted text. The prompt itself always survives:
|
|
606
|
+
// "UserPromptSubmit: can't replace the prompt; it only injects additionalContext
|
|
607
|
+
// alongside it". suppressOriginalPrompt scopes to the block message, which never
|
|
608
|
+
// carries the prompt here, so it needs nothing of its own.
|
|
609
|
+
if (decision.context) return { action: 'transform', text: `${decision.context}\n\n${event.text}` }
|
|
583
610
|
return { action: 'continue' }
|
|
584
611
|
})
|
|
585
612
|
|
|
@@ -593,12 +620,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
593
620
|
// handler on a UI dialog, which would starve the Stop hook and idle notification
|
|
594
621
|
// until the user answers it. agent_end can fire slightly early before a rare
|
|
595
622
|
// automatic retry or compaction; that is the better tradeoff.
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
// codes and JSON output are ignored, as Claude documents for this event.
|
|
623
|
+
/** Claude's Notification event, for the one type pi can honestly source: the agent
|
|
624
|
+
* finished and is waiting for input. idle_prompt fires when the turn ended about 60
|
|
625
|
+
* seconds ago and the user has not typed since, so it arms here and input or the next
|
|
626
|
+
* turn cancels it. Observational only; exit codes and JSON output are ignored. */
|
|
627
|
+
const armIdlePrompt = (ctx: ExtensionContext): void => {
|
|
602
628
|
cancelIdlePrompt()
|
|
603
629
|
const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
|
|
604
630
|
if (notifyCommands.length > 0) {
|
|
@@ -608,6 +634,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
608
634
|
}, IDLE_PROMPT_DELAY_MS)
|
|
609
635
|
idlePromptTimer.unref?.()
|
|
610
636
|
}
|
|
637
|
+
}
|
|
638
|
+
pi.on('agent_end', async (event, ctx) => {
|
|
639
|
+
armIdlePrompt(ctx)
|
|
611
640
|
|
|
612
641
|
// In a subagent child, the agent-frontmatter Stop hooks were converted to
|
|
613
642
|
// SubagentStop and fire here, at the child's own end, notify-style; before the
|
|
@@ -643,24 +672,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
643
672
|
const run = boundRunner(ctx)
|
|
644
673
|
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
645
674
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
675
|
+
const stopMessages: string[] = []
|
|
646
676
|
const block = results
|
|
647
677
|
.filter((result) => !result.timedOut)
|
|
648
|
-
.map((result) =>
|
|
649
|
-
const parsed = tryParseJson(result.stdout)
|
|
650
|
-
if (result.code === 2) return { block: true, reason: jsonBlockVerdict(parsed, 'Stop blocked by hook')?.reason ?? (result.stderr.trim() || 'Stop blocked by hook') }
|
|
651
|
-
// Any JSON blocking spelling counts, including the prompt/agent hook reply
|
|
652
|
-
// schemas (permissionDecision deny, ok:false), which arrive as stdout here.
|
|
653
|
-
const verdict = jsonBlockVerdict(parsed, 'Stop blocked by hook')
|
|
654
|
-
if (verdict) return { block: true, reason: verdict.reason }
|
|
655
|
-
// Claude's non-error continue: additionalContext feeds back and the
|
|
656
|
-
// conversation continues so Claude can act on it. It rides the same
|
|
657
|
-
// continuation path (and the same block cap) so a hook emitting it every
|
|
658
|
-
// firing cannot loop the turn forever.
|
|
659
|
-
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
660
|
-
if (typeof context === 'string' && context.length > 0) return { block: true, reason: context }
|
|
661
|
-
return { block: false, reason: '' }
|
|
662
|
-
})
|
|
678
|
+
.map((result) => stopVerdict(result, stopMessages))
|
|
663
679
|
.find((verdict) => verdict.block)
|
|
680
|
+
for (const message of stopMessages) ctx.ui.notify(message, 'warning')
|
|
664
681
|
if (!block) {
|
|
665
682
|
// A non-blocking Stop breaks the streak: the next block starts a fresh count.
|
|
666
683
|
stopHookActive = false
|
|
@@ -689,6 +706,17 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
689
706
|
const payload = { hook_event_name: 'PreCompact', trigger: trigger.value, custom_instructions: event.customInstructions ?? '' }
|
|
690
707
|
const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), payload, boundRunner(ctx))
|
|
691
708
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
709
|
+
// Claude: "Exit with code 2 to block compaction. For a manual /compact, the stderr
|
|
710
|
+
// message is shown to the user. You can also block by returning JSON with
|
|
711
|
+
// `decision: block`." pi cancels through the result, and a blocked automatic
|
|
712
|
+
// compaction is worth a notice too: the context stays full either way.
|
|
713
|
+
for (const [index, result] of results.entries()) {
|
|
714
|
+
const parsed = tryParseJson(result.stdout)
|
|
715
|
+
const blocked = result.code === 2 && !result.timedOut ? { reason: result.stderr.trim() || 'Compaction blocked by hook' } : jsonBlockVerdict(parsed, 'Compaction blocked by hook')
|
|
716
|
+
if (!blocked) continue
|
|
717
|
+
ctx.ui.notify(`Compaction blocked by ${matchingCommands(config.PreCompact, trigger.names)[index]?.command ?? 'hook'}: ${blocked.reason}`, 'warning')
|
|
718
|
+
return { cancel: true }
|
|
719
|
+
}
|
|
692
720
|
})
|
|
693
721
|
|
|
694
722
|
pi.on('session_compact', async (event, ctx) => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* commands an event fires. Owns the module-level compiled-matcher cache.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { matchesBashIfFilter } from '../internal/bash-rules.js'
|
|
8
8
|
import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
|
|
9
9
|
import type { HookCommand, HookMatcher } from './config.js'
|
|
10
10
|
|
|
@@ -159,14 +159,16 @@ export interface IfFilterTarget {
|
|
|
159
159
|
|
|
160
160
|
/** Claude's `if` handler field: permission-rule syntax evaluated only on tool
|
|
161
161
|
* events; on any other event a hook carrying `if` never runs. A bare tool name
|
|
162
|
-
* matches by name; `Bash(pattern)` evaluates against the command
|
|
163
|
-
*
|
|
164
|
-
* permission path rules. A pattern for any other tool matches nothing, which is
|
|
162
|
+
* matches by name; `Bash(pattern)` evaluates against the command through the if-filter
|
|
163
|
+
* matcher, which is best effort and errs toward running the hook, and file-tool patterns
|
|
164
|
+
* against the path via the shared permission path rules. A pattern for any other tool matches nothing, which is
|
|
165
165
|
* also what an unparseable rule does. */
|
|
166
166
|
export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undefined): boolean {
|
|
167
167
|
if (hook.if === undefined) return true
|
|
168
168
|
if (target === undefined) return false
|
|
169
|
-
|
|
169
|
+
// Digits and hyphens are part of a tool name: an MCP tool is mcp__<server>__<tool> and
|
|
170
|
+
// server names carry both, so a stricter class silently matched nothing.
|
|
171
|
+
const parsed = /^([\w|-]+?)(?:\((.*)\))?$/.exec(hook.if.trim())
|
|
170
172
|
if (!parsed) return false
|
|
171
173
|
const fold = (name: string): string => name.toLowerCase().replaceAll('-', '_')
|
|
172
174
|
const ruleTools = new Set(parsed[1].split('|').map(fold))
|
|
@@ -177,7 +179,7 @@ export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undef
|
|
|
177
179
|
const input = target.input as Record<string, unknown> | null
|
|
178
180
|
if (fold(target.piName) === 'bash' || (target.claudeName !== undefined && fold(target.claudeName) === 'bash')) {
|
|
179
181
|
const command = typeof input?.command === 'string' ? input.command : ''
|
|
180
|
-
return command.length > 0 &&
|
|
182
|
+
return command.length > 0 && matchesBashIfFilter(command, pattern)
|
|
181
183
|
}
|
|
182
184
|
let filePath = ''
|
|
183
185
|
if (typeof input?.path === 'string') filePath = input.path
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace a file through a temp file and a rename, so a crash mid-write cannot leave the
|
|
3
|
+
* target truncated. Used wherever pi-code rewrites a file the user owns and would have to
|
|
4
|
+
* repair by hand: settings and the memory index.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'node:fs'
|
|
8
|
+
|
|
9
|
+
/** The tmp name carries the pid so concurrent processes do not collide. */
|
|
10
|
+
export function atomicWriteFile(filePath: string, content: string): void {
|
|
11
|
+
const tmp = `${filePath}.${process.pid}.tmp`
|
|
12
|
+
fs.writeFileSync(tmp, content)
|
|
13
|
+
fs.renameSync(tmp, filePath)
|
|
14
|
+
}
|
|
@@ -30,6 +30,50 @@ function matchesRule(segment: string, rule: string): boolean {
|
|
|
30
30
|
return segment === normalized
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Leading `VAR=value` assignments, which Claude strips before matching an `if` pattern. */
|
|
34
|
+
const LEADING_ASSIGNMENTS = /^(?:[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/
|
|
35
|
+
|
|
36
|
+
/** The bodies of `$(...)`, `` `...` ``, `<(...)` and `>(...)`, which run commands of their own. */
|
|
37
|
+
function substitutionBodies(command: string): string[] {
|
|
38
|
+
const bodies: string[] = []
|
|
39
|
+
for (const match of command.matchAll(/\$\(([^()]*)\)|`([^`]*)`|[<>]\(([^()]*)\)/g)) {
|
|
40
|
+
const body = match[1] ?? match[2] ?? match[3]
|
|
41
|
+
if (body?.trim()) bodies.push(body.trim())
|
|
42
|
+
}
|
|
43
|
+
return bodies
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether the first word of a segment is something only the shell can resolve. */
|
|
47
|
+
const headUnresolvable = (segment: string): boolean => /[$`]/.test(segment.split(/\s+/, 1)[0] ?? '')
|
|
48
|
+
|
|
49
|
+
/** Whether a pattern names more than the command itself, like `git push *` against `git *`. */
|
|
50
|
+
const namesMoreThanCommand = (rule: string): boolean => rule.split('*', 1)[0].trim().includes(' ')
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Claude's `if` filter for a Bash call, which decides whether a hook gets to SEE the
|
|
54
|
+
* call. That makes it the mirror of a permission rule: a grant must hold for every
|
|
55
|
+
* segment and fails closed on anything it cannot read, while this runs the hook when any
|
|
56
|
+
* segment matches and when the input cannot be resolved at all. Claude: "When Claude Code
|
|
57
|
+
* can't determine which commands the Bash input runs, it runs your hook regardless of the
|
|
58
|
+
* pattern. Because the `if` filter is best-effort, use the permission system rather than
|
|
59
|
+
* a hook to enforce a hard allow or deny."
|
|
60
|
+
*
|
|
61
|
+
* Each top-level segment is checked with its leading assignments stripped, and so is the
|
|
62
|
+
* body of every substitution, since one can sit at any argument position. An unresolvable
|
|
63
|
+
* command name runs the hook whatever the pattern; a substitution anywhere runs it when
|
|
64
|
+
* the pattern names more than the command.
|
|
65
|
+
*/
|
|
66
|
+
export function matchesBashIfFilter(command: string, rule: string): boolean {
|
|
67
|
+
const trimmed = rule.trim()
|
|
68
|
+
const candidates = splitSegments(command).flatMap((segment) => {
|
|
69
|
+
const stripped = segment.replace(LEADING_ASSIGNMENTS, '')
|
|
70
|
+
return [stripped, ...substitutionBodies(stripped).flatMap((body) => splitSegments(body).map((inner) => inner.replace(LEADING_ASSIGNMENTS, '')))]
|
|
71
|
+
})
|
|
72
|
+
if (candidates.some((candidate) => matchesRule(candidate, trimmed))) return true
|
|
73
|
+
if (candidates.some(headUnresolvable)) return true
|
|
74
|
+
return namesMoreThanCommand(trimmed) && (hasSubstitution(command) || /\$[A-Za-z_{]/.test(command))
|
|
75
|
+
}
|
|
76
|
+
|
|
33
77
|
export function matchesBashRules(command: string, rules: string[]): boolean {
|
|
34
78
|
if (hasSubstitution(command)) return false
|
|
35
79
|
const segments = splitSegments(command)
|
|
@@ -377,7 +377,14 @@ export function substituteArgsDetailed(body: string, args: string, names: string
|
|
|
377
377
|
return all || argsDefault
|
|
378
378
|
}
|
|
379
379
|
if (shorthandIdx !== undefined) return fill(parts[Number(shorthandIdx)], token)
|
|
380
|
-
if (name !== undefined)
|
|
380
|
+
if (name !== undefined) {
|
|
381
|
+
// Claude: "A named placeholder counts even when its position has no argument,
|
|
382
|
+
// because it expands to an empty string", unlike an indexed one, which stays
|
|
383
|
+
// literal and does not count. Otherwise a skill using named arguments still got
|
|
384
|
+
// the ARGUMENTS: block appended.
|
|
385
|
+
consumed = true
|
|
386
|
+
return fill(parts[names.indexOf(name)], '')
|
|
387
|
+
}
|
|
381
388
|
consumed = true
|
|
382
389
|
return all // $ARGUMENTS or $@
|
|
383
390
|
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi's thinking levels translated to Claude's effort vocabulary, which is
|
|
3
|
+
* `low | medium | high | xhigh | max`. pi adds two levels outside it: `minimal`, which
|
|
4
|
+
* reads as Claude's lowest, and `off`, which has no effort at all. Every surface that
|
|
5
|
+
* exposes the level to a script or a hook goes through here, so `off` and `minimal`
|
|
6
|
+
* never leak into a payload or an environment variable that promises Claude's set.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The Claude effort level for a pi thinking level, or undefined when thinking is off. */
|
|
10
|
+
export function claudeEffortLevel(thinkingLevel: string | undefined): string | undefined {
|
|
11
|
+
if (!thinkingLevel || thinkingLevel === 'off') return undefined
|
|
12
|
+
return thinkingLevel === 'minimal' ? 'low' : thinkingLevel
|
|
13
|
+
}
|
|
@@ -121,8 +121,11 @@ export class FileOAuthProvider implements OAuthClientProvider {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** The localhost spelling, which is what a server with a pre-registered redirect URI
|
|
125
|
+
* expects: Claude sent the 127.0.0.1 form for one version and "servers that exact-match
|
|
126
|
+
* the registered redirect URI rejected the sign-in with a redirect URI mismatch". */
|
|
124
127
|
get redirectUrl(): string {
|
|
125
|
-
return `http://
|
|
128
|
+
return `http://localhost:${this.port}/callback`
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
get clientMetadata(): OAuthClientMetadata {
|
|
@@ -193,13 +196,24 @@ export class FileOAuthProvider implements OAuthClientProvider {
|
|
|
193
196
|
* taken, an ephemeral port is used. */
|
|
194
197
|
export async function startCallbackServer(preferredPort?: number): Promise<{ server: http.Server; port: number }> {
|
|
195
198
|
const server = http.createServer()
|
|
196
|
-
const listen = (port: number): Promise<void> => new Promise((resolve, reject) => server.listen(port,
|
|
199
|
+
const listen = (port: number, host: string): Promise<void> => new Promise((resolve, reject) => server.listen(port, host, resolve).once('error', reject))
|
|
197
200
|
try {
|
|
198
|
-
await listen(preferredPort ?? 0)
|
|
201
|
+
await listen(preferredPort ?? 0, '127.0.0.1')
|
|
199
202
|
} catch {
|
|
200
|
-
await listen(0)
|
|
203
|
+
await listen(0, '127.0.0.1')
|
|
201
204
|
}
|
|
202
|
-
|
|
205
|
+
const port = (server.address() as { port: number }).port
|
|
206
|
+
// The redirect names localhost, which resolves to ::1 first on a host with IPv6, so a
|
|
207
|
+
// second listener answers there too. Best effort: where it cannot bind, the IPv4
|
|
208
|
+
// listener above still answers every browser that resolves localhost to 127.0.0.1.
|
|
209
|
+
const ipv6 = http.createServer()
|
|
210
|
+
ipv6.on('error', () => {})
|
|
211
|
+
await new Promise<void>((resolve) => {
|
|
212
|
+
ipv6.listen(port, '::1', () => resolve()).once('error', () => resolve())
|
|
213
|
+
})
|
|
214
|
+
server.on('close', () => ipv6.close())
|
|
215
|
+
ipv6.on('request', (request, response) => server.emit('request', request, response))
|
|
216
|
+
return { server, port }
|
|
203
217
|
}
|
|
204
218
|
|
|
205
219
|
export function waitForAuthCode(server: http.Server, timeoutMs: number, expectedState?: string): Promise<string> {
|
|
@@ -244,7 +258,12 @@ export function openBrowser(url: string): void {
|
|
|
244
258
|
else if (process.platform === 'win32') command = 'cmd'
|
|
245
259
|
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
|
|
246
260
|
try {
|
|
247
|
-
spawn(command, args, { stdio: 'ignore', detached: true })
|
|
261
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true })
|
|
262
|
+
// A missing launcher (a container or an SSH session with no xdg-open) reports itself
|
|
263
|
+
// asynchronously through 'error'; with no listener node raises it as an
|
|
264
|
+
// uncaughtException and pi exits. The caller has already shown the URL to open by hand.
|
|
265
|
+
child.on('error', () => {})
|
|
266
|
+
child.unref()
|
|
248
267
|
} catch {
|
|
249
268
|
// the notified URL is the fallback
|
|
250
269
|
}
|
|
@@ -33,6 +33,8 @@ const CLAUDE_SHAPED = [
|
|
|
33
33
|
path.join('.claude', 'rules'),
|
|
34
34
|
path.join('.claude', 'skills'),
|
|
35
35
|
path.join('.claude', 'commands'),
|
|
36
|
+
// Injected into the prompt verbatim, and context-imports treats it as approval-gated.
|
|
37
|
+
path.join('.claude', 'CLAUDE.md'),
|
|
36
38
|
'CLAUDE.local.md',
|
|
37
39
|
'.mcp.json',
|
|
38
40
|
path.join('.pi', 'mcp.json'),
|
package/extensions/mcp/config.ts
CHANGED
|
@@ -21,6 +21,10 @@ export interface StdioServerConfig {
|
|
|
21
21
|
timeout?: number
|
|
22
22
|
/** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
|
|
23
23
|
aliasPrefix?: string
|
|
24
|
+
/** The server name as its manifest declares it, without the plugin: scope the registry
|
|
25
|
+
* key carries. pi-side tool names derive from this, so a plugin server contributes
|
|
26
|
+
* <server>_<tool> the way a user server does. */
|
|
27
|
+
baseName?: string
|
|
24
28
|
/** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
|
|
25
29
|
pluginRoot?: string
|
|
26
30
|
/** Loaded from the project scope, whose helpers run credential-stripped. */
|
|
@@ -42,6 +46,10 @@ export interface HttpServerConfig {
|
|
|
42
46
|
timeout?: number
|
|
43
47
|
/** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
|
|
44
48
|
aliasPrefix?: string
|
|
49
|
+
/** The server name as its manifest declares it, without the plugin: scope the registry
|
|
50
|
+
* key carries. pi-side tool names derive from this, so a plugin server contributes
|
|
51
|
+
* <server>_<tool> the way a user server does. */
|
|
52
|
+
baseName?: string
|
|
45
53
|
/** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
|
|
46
54
|
pluginRoot?: string
|
|
47
55
|
/** Loaded from the project scope, whose helpers run credential-stripped. */
|
|
@@ -213,7 +221,11 @@ export function loadPluginServers(plugins: InstalledPlugin[], projectDir?: strin
|
|
|
213
221
|
for (const plugin of plugins) {
|
|
214
222
|
for (const [name, config] of Object.entries(rawPluginServerEntries(plugin))) {
|
|
215
223
|
const substituted = substitutedPluginServer(plugin, name, config, projectDir)
|
|
216
|
-
|
|
224
|
+
// Claude: "The server itself registers under the scoped name
|
|
225
|
+
// plugin:<plugin-name>:<server-name>", which is what an mcp_tool hook names and what
|
|
226
|
+
// keeps a same-named user server from replacing a plugin's. The tool alias keeps its
|
|
227
|
+
// own flat spelling, mcp__plugin_<plugin>_<server>__<tool>.
|
|
228
|
+
if (substituted) servers[`plugin:${plugin.name}:${name}`] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__`, baseName: name, pluginRoot: plugin.root }
|
|
217
229
|
}
|
|
218
230
|
}
|
|
219
231
|
return servers
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -131,7 +131,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
131
131
|
function registerTools(name: string, config: ServerConfig, tools: McpToolInfo[]): number {
|
|
132
132
|
let count = 0
|
|
133
133
|
for (const tool of tools) {
|
|
134
|
-
|
|
134
|
+
// A plugin server's registry key carries its plugin: scope; its tools keep the bare
|
|
135
|
+
// server name, as their Claude alias does.
|
|
136
|
+
const toolName = formatToolName(config.baseName ?? name, tool.name)
|
|
135
137
|
const owner = registered.get(toolName)
|
|
136
138
|
if (owner === name) continue // already registered for this server: a refresh re-listing it
|
|
137
139
|
if (RESERVED_NAMES.has(toolName) || owner !== undefined) {
|
|
@@ -563,8 +565,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
563
565
|
// is why serverToolCount reads from `registered` to recover the true count here.
|
|
564
566
|
status.clear()
|
|
565
567
|
// A same-process session switch (/new, /resume) shut the last session down;
|
|
566
|
-
// this one may reconnect again.
|
|
568
|
+
// this one may reconnect again. projectConnected guards against connecting the project
|
|
569
|
+
// scope twice within one session, so it belongs to the session that set it: leaving it
|
|
570
|
+
// set here left every project server disconnected for the rest of the process, since
|
|
571
|
+
// the shutdown had already dropped their clients.
|
|
567
572
|
shuttingDown = false
|
|
573
|
+
projectConnected = false
|
|
568
574
|
// Claude answers roots/list with the session's launch directory and exports the
|
|
569
575
|
// project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
|
|
570
576
|
sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd }
|
|
@@ -284,12 +284,33 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
|
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
/** Transport-level failure codes worth another attempt. */
|
|
288
|
+
const TRANSIENT_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET'])
|
|
289
|
+
|
|
290
|
+
/** Every `code` reachable from an error: its own, those of its `cause` chain, and those
|
|
291
|
+
* of an AggregateError's members. fetch reports a refused connection as a bare
|
|
292
|
+
* `TypeError: fetch failed` whose cause carries ECONNREFUSED, so the text says nothing. */
|
|
293
|
+
function errorCodes(error: unknown, seen = new Set<unknown>()): string[] {
|
|
294
|
+
if (error === null || typeof error !== 'object' || seen.has(error)) return []
|
|
295
|
+
seen.add(error)
|
|
296
|
+
const codes: string[] = []
|
|
297
|
+
const code = (error as { code?: unknown }).code
|
|
298
|
+
if (typeof code === 'string') codes.push(code)
|
|
299
|
+
const inner = (error as { errors?: unknown }).errors
|
|
300
|
+
if (Array.isArray(inner)) for (const one of inner) codes.push(...errorCodes(one, seen))
|
|
301
|
+
codes.push(...errorCodes((error as { cause?: unknown }).cause, seen))
|
|
302
|
+
return codes
|
|
303
|
+
}
|
|
304
|
+
|
|
287
305
|
/** Whether a connect failure is worth retrying: a 5xx response, a refused or reset
|
|
288
|
-
* connection, or a timeout
|
|
289
|
-
|
|
306
|
+
* connection, or a timeout, per Claude's "a 5xx response, a connection refused, or a
|
|
307
|
+
* timeout ... retries up to three times". Auth and not-found errors need a configuration
|
|
308
|
+
* change instead. Exported for the test that pins the shape node actually throws. */
|
|
309
|
+
export function isTransientConnectError(error: unknown): boolean {
|
|
290
310
|
if (isUnauthorized(error)) return false
|
|
291
|
-
const
|
|
292
|
-
if (typeof
|
|
311
|
+
const status = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined
|
|
312
|
+
if (typeof status === 'number') return status >= 500
|
|
313
|
+
if (errorCodes(error).some((code) => TRANSIENT_CODES.has(code))) return true
|
|
293
314
|
return /ECONNREFUSED|ECONNRESET|ETIMEDOUT|timed out after/.test(String(error))
|
|
294
315
|
}
|
|
295
316
|
|
package/extensions/memory.ts
CHANGED
|
@@ -14,6 +14,7 @@ import * as path from 'node:path'
|
|
|
14
14
|
import { StringEnum } from '@earendil-works/pi-ai'
|
|
15
15
|
import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
16
16
|
import { Type } from 'typebox'
|
|
17
|
+
import { atomicWriteFile } from './internal/atomic-write.js'
|
|
17
18
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
18
19
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
19
20
|
import { capForContext } from './internal/output-guard.js'
|
|
@@ -79,6 +80,9 @@ export function resolveMemoryDir(cwd: string, override?: string): string {
|
|
|
79
80
|
export function autoMemoryEnabled(setting: unknown, env: NodeJS.ProcessEnv): boolean {
|
|
80
81
|
const disable = (env.CLAUDE_CODE_DISABLE_AUTO_MEMORY ?? '').trim().toLowerCase()
|
|
81
82
|
if (disable === '1' || disable === 'true') return false
|
|
83
|
+
// Claude: "Set to 0 to force auto memory on even when --bare mode or
|
|
84
|
+
// autoMemoryEnabled: false would otherwise disable it."
|
|
85
|
+
if (disable === '0' || disable === 'false') return true
|
|
82
86
|
return setting !== false
|
|
83
87
|
}
|
|
84
88
|
|
|
@@ -322,14 +326,6 @@ function readIndexQuietly(dir: string): string {
|
|
|
322
326
|
}
|
|
323
327
|
}
|
|
324
328
|
|
|
325
|
-
/** Write through a temp file and rename onto the target, so a crash mid-write cannot
|
|
326
|
-
* truncate it. The tmp name carries the pid so concurrent processes do not collide. */
|
|
327
|
-
function atomicWriteFile(filePath: string, content: string): void {
|
|
328
|
-
const tmp = `${filePath}.${process.pid}.tmp`
|
|
329
|
-
fs.writeFileSync(tmp, content)
|
|
330
|
-
fs.renameSync(tmp, filePath)
|
|
331
|
-
}
|
|
332
|
-
|
|
333
329
|
/** Replace the index through a rename so a crash mid-write cannot truncate it. */
|
|
334
330
|
function writeIndex(indexPath: string, content: string): void {
|
|
335
331
|
atomicWriteFile(indexPath, content)
|
|
@@ -543,7 +539,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
543
539
|
` Auto memory: ${isEnabled ? 'on' : 'off'}`,
|
|
544
540
|
` Store: ${store}`,
|
|
545
541
|
` Index: ${path.join(store, INDEX_FILE)}`,
|
|
546
|
-
|
|
542
|
+
// The loader reads it from the configured directory, so CLAUDE_CONFIG_DIR moves it.
|
|
543
|
+
` User memory (CLAUDE.md): ${path.join(claudeConfigDir(home), 'CLAUDE.md')}`,
|
|
547
544
|
` Project memory (CLAUDE.md): ${path.join(ctx.cwd, 'CLAUDE.md')}`,
|
|
548
545
|
// Claude's /memory lists every documented location, including files that
|
|
549
546
|
// do not exist yet.
|
|
@@ -23,6 +23,7 @@ import * as fs from 'node:fs'
|
|
|
23
23
|
import * as os from 'node:os'
|
|
24
24
|
import * as path from 'node:path'
|
|
25
25
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
26
|
+
import { atomicWriteFile } from './internal/atomic-write.js'
|
|
26
27
|
|
|
27
28
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
28
29
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
@@ -101,10 +102,10 @@ function isDirectory(target: string): boolean {
|
|
|
101
102
|
*/
|
|
102
103
|
export function styleDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
103
104
|
const dirs = [path.join(claudeConfigDir(home), 'output-styles')]
|
|
104
|
-
// Claude loads every .claude/output-styles between cwd and the repository root,
|
|
105
|
-
//
|
|
106
|
-
// so the
|
|
107
|
-
if (trusted) dirs.push(...ancestorDirs(cwd, path.join('.claude', 'output-styles')))
|
|
105
|
+
// Claude loads every .claude/output-styles between cwd and the repository root, using
|
|
106
|
+
// the one closest to cwd for a name clash. loadStyles keys by name and later dirs win,
|
|
107
|
+
// so the ancestors go farthest first and the nearest lands last.
|
|
108
|
+
if (trusted) dirs.push(...ancestorDirs(cwd, path.join('.claude', 'output-styles')).reverse())
|
|
108
109
|
return dirs.filter((dir) => isDirectory(dir))
|
|
109
110
|
}
|
|
110
111
|
|
|
@@ -173,16 +174,31 @@ export function styleForName(styles: OutputStyle[], name: string | undefined): O
|
|
|
173
174
|
return name ? styles.find((style) => style.name === name) : undefined
|
|
174
175
|
}
|
|
175
176
|
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
/** Record the choice in the local settings file, returning a message to show when it
|
|
178
|
+
* cannot be recorded. The file also carries permissions, hooks and env, so a present but
|
|
179
|
+
* unparseable one is left alone rather than replaced by the style choice alone. */
|
|
180
|
+
function persistActiveStyle(file: string, name: string): string | undefined {
|
|
181
|
+
const label = path.basename(file)
|
|
182
|
+
let raw: string | undefined
|
|
178
183
|
try {
|
|
179
|
-
|
|
180
|
-
} catch {
|
|
181
|
-
//
|
|
184
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// Only a missing file means start fresh; anything else is the user's file to keep.
|
|
187
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return `${label} could not be read; the style applies to this session only`
|
|
188
|
+
}
|
|
189
|
+
let config: Record<string, unknown> = {}
|
|
190
|
+
if (raw !== undefined) {
|
|
191
|
+
try {
|
|
192
|
+
const parsed: unknown = JSON.parse(raw)
|
|
193
|
+
if (parsed !== null && typeof parsed === 'object') config = parsed as Record<string, unknown>
|
|
194
|
+
} catch {
|
|
195
|
+
return `${label} is not valid JSON; the style applies to this session only`
|
|
196
|
+
}
|
|
182
197
|
}
|
|
183
198
|
config.outputStyle = name
|
|
184
199
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
185
|
-
|
|
200
|
+
atomicWriteFile(file, `${JSON.stringify(config, null, 2)}\n`)
|
|
201
|
+
return undefined
|
|
186
202
|
}
|
|
187
203
|
|
|
188
204
|
export default function outputStylesExtension(pi: ExtensionAPI) {
|
|
@@ -236,8 +252,8 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
|
|
|
236
252
|
return
|
|
237
253
|
}
|
|
238
254
|
activeName = picked.name
|
|
239
|
-
persistActiveStyle(localSettingsPath, picked.name)
|
|
240
|
-
ctx.ui.notify(`Output style set to ${picked.name} (applies next turn)`, 'info')
|
|
255
|
+
const failure = persistActiveStyle(localSettingsPath, picked.name)
|
|
256
|
+
ctx.ui.notify(failure ?? `Output style set to ${picked.name} (applies next turn)`, failure ? 'error' : 'info')
|
|
241
257
|
return
|
|
242
258
|
}
|
|
243
259
|
if (!ctx.hasUI) {
|
|
@@ -253,8 +269,8 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
|
|
|
253
269
|
if (!choice) return
|
|
254
270
|
const picked = styles[labels.indexOf(choice)]
|
|
255
271
|
activeName = picked.name
|
|
256
|
-
persistActiveStyle(localSettingsPath, picked.name)
|
|
257
|
-
ctx.ui.notify(`Output style set to ${picked.name} (applies next turn)`, 'info')
|
|
272
|
+
const failure = persistActiveStyle(localSettingsPath, picked.name)
|
|
273
|
+
ctx.ui.notify(failure ?? `Output style set to ${picked.name} (applies next turn)`, failure ? 'error' : 'info')
|
|
258
274
|
},
|
|
259
275
|
})
|
|
260
276
|
}
|
|
@@ -31,8 +31,8 @@ import * as fs from 'node:fs'
|
|
|
31
31
|
import * as os from 'node:os'
|
|
32
32
|
import * as path from 'node:path'
|
|
33
33
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
34
|
-
|
|
35
34
|
import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks/index.js'
|
|
35
|
+
import { claudeEffortLevel } from './internal/effort.js'
|
|
36
36
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
37
37
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
38
38
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
@@ -296,10 +296,10 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
296
296
|
const sessionName = ctx.sessionManager.getSessionName?.()
|
|
297
297
|
if (sessionName) payload.session_name = sessionName
|
|
298
298
|
if (ctx.thinkingLevel) {
|
|
299
|
-
//
|
|
300
|
-
// low, and off omits effort entirely (thinking disabled says the rest).
|
|
299
|
+
// Thinking disabled says the rest, so off carries no effort field of its own.
|
|
301
300
|
payload.thinking = { enabled: ctx.thinkingLevel !== 'off' }
|
|
302
|
-
|
|
301
|
+
const effort = claudeEffortLevel(ctx.thinkingLevel)
|
|
302
|
+
if (effort) payload.effort = { level: effort }
|
|
303
303
|
}
|
|
304
304
|
if (styleName) payload.output_style = { name: styleName }
|
|
305
305
|
// The current utilization of the account's rate-limit windows, when the
|
|
@@ -457,14 +457,20 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
457
457
|
// Claude's disableAllHooks also turns off the custom statusLine command; the
|
|
458
458
|
// built-in segment still renders as the fallback.
|
|
459
459
|
config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
|
|
460
|
-
|
|
461
|
-
|
|
460
|
+
// Re-armed rather than armed once: a refreshInterval added or changed mid-session
|
|
461
|
+
// had no effect until the next session, though Claude applies a settings change as
|
|
462
|
+
// soon as the file is saved.
|
|
463
|
+
const armRefresh = (): void => {
|
|
464
|
+
clearInterval(refreshTimer)
|
|
465
|
+
refreshTimer = config?.refreshInterval ? setInterval(() => scheduleRefresh(), config.refreshInterval * 1000) : undefined
|
|
462
466
|
}
|
|
467
|
+
armRefresh()
|
|
463
468
|
// Claude re-runs the script when the statusLine settings change mid-session; a
|
|
464
469
|
// command change re-resolves and re-runs.
|
|
465
470
|
disposeSettingsWatch()
|
|
466
471
|
disposeSettingsWatch = watchSettingsFiles(files, () => {
|
|
467
472
|
config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
|
|
473
|
+
armRefresh()
|
|
468
474
|
scheduleRefresh()
|
|
469
475
|
})
|
|
470
476
|
show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
|
|
@@ -177,6 +177,13 @@ export function backgroundStatusText(): string {
|
|
|
177
177
|
return formatStatus(runs.values())
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/** Every run this process knows about, live and finished. The registry is module state
|
|
181
|
+
* that outlives a session switch; a per-session list is not, because pi re-invokes each
|
|
182
|
+
* extension factory per session. */
|
|
183
|
+
export function allBackgroundRuns(): BackgroundRun[] {
|
|
184
|
+
return [...runs.values()]
|
|
185
|
+
}
|
|
186
|
+
|
|
180
187
|
/** A finished run, so a caller can continue its session with a follow-up task. */
|
|
181
188
|
export function backgroundRun(id: string): BackgroundRun | undefined {
|
|
182
189
|
return runs.get(id)
|
|
@@ -200,7 +207,10 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
|
|
|
200
207
|
const rebuilt = withRebuiltPrompt(run.spawn)
|
|
201
208
|
run.spawn = { ...run.spawn, args: rebuilt.args }
|
|
202
209
|
if (rebuilt.dir) run.rebuiltPromptDir = rebuilt.dir
|
|
203
|
-
|
|
210
|
+
// The task prompt is always the final argument: both spawn paths push it last and the
|
|
211
|
+
// invocation helper only prepends. Matching a 'Task: ' prefix missed it whenever
|
|
212
|
+
// SubagentStart hooks placed their context ahead of it, so the resume re-ran the old task.
|
|
213
|
+
const args = rebuilt.args.map((arg, index) => (index === rebuilt.args.length - 1 ? `Task: ${task}` : arg))
|
|
204
214
|
run.state = 'running'
|
|
205
215
|
run.task = task
|
|
206
216
|
run.output = undefined
|
|
@@ -34,7 +34,7 @@ import { runSubagentStartHooks } from '../internal/subagent-hooks.js'
|
|
|
34
34
|
import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
|
|
35
35
|
import { skillDirs } from '../skills.js'
|
|
36
36
|
import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, expandMcpToolPatterns, resolveModelAlias, withPreloadedSkills } from './agents.js'
|
|
37
|
-
import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelAllBackgroundRuns, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
37
|
+
import { activeBackgroundRuns, allBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelAllBackgroundRuns, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
38
38
|
import { type DisplayItem, formatToolCall, formatUsageStats, getDisplayItems, getFinalOutput } from './render.js'
|
|
39
39
|
import { type AgentWorktree, cleanupAgentWorktree, createAgentWorktree } from './worktree.js'
|
|
40
40
|
|
|
@@ -388,8 +388,11 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
388
388
|
resolve(code ?? 0)
|
|
389
389
|
})
|
|
390
390
|
|
|
391
|
-
proc.on('error', () => {
|
|
391
|
+
proc.on('error', (error: Error) => {
|
|
392
392
|
cleanup()
|
|
393
|
+
// A child that never started leaves no stdout and no stderr, so this message is the
|
|
394
|
+
// only diagnostic; without it the failure reads as "(no output)".
|
|
395
|
+
if (!currentResult.stderr) currentResult.stderr = error.message
|
|
393
396
|
resolve(1)
|
|
394
397
|
})
|
|
395
398
|
|
|
@@ -566,7 +569,7 @@ function runOutputTail(run: BackgroundRunView): string | undefined {
|
|
|
566
569
|
/** The /tasks listing: one line per background run, plus the short output tail the
|
|
567
570
|
* registry's own status lines omit. A pure formatter so it tests against a plain list. */
|
|
568
571
|
export function tasksStatusText(runs: ReadonlyArray<BackgroundRunView>): string {
|
|
569
|
-
if (runs.length === 0) return 'No background subagent runs
|
|
572
|
+
if (runs.length === 0) return 'No background subagent runs.'
|
|
570
573
|
return runs
|
|
571
574
|
.map((run) => {
|
|
572
575
|
const plural = run.turns === 1 ? '' : 's'
|
|
@@ -761,7 +764,7 @@ export function setKnownMcpAliases(aliases: ReadonlyArray<{ pi: string; claude:
|
|
|
761
764
|
/** pi's built-in ToolName union (core/tools/index.d.ts; the package's export map
|
|
762
765
|
* does not expose allToolNames, so this mirrors it) plus the tools pi-code's own
|
|
763
766
|
* extensions register in a child. Claude's capitalized spellings fold onto these. */
|
|
764
|
-
const CHILD_TOOL_NAMES = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'web_fetch', 'web_search', 'list_mcp_resources', 'read_mcp_resource'])
|
|
767
|
+
const CHILD_TOOL_NAMES = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'web_fetch', 'web_search', 'list_mcp_resources', 'read_mcp_resource', 'todo', 'question', 'memory', 'slash_command', 'plan_mode_complete'])
|
|
765
768
|
|
|
766
769
|
/** Claude: when no entry in a `tools` list resolves to a tool, the subagent fails
|
|
767
770
|
* to launch with an error naming the entries, instead of running tool-less. */
|
|
@@ -782,7 +785,12 @@ function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[]
|
|
|
782
785
|
// order (invocation model, frontmatter model, this variable, the session model).
|
|
783
786
|
// pi reads a thinking level from the model pattern's :suffix when a model is
|
|
784
787
|
// pinned, and from --thinking otherwise.
|
|
785
|
-
|
|
788
|
+
// Claude exempts the two built-ins from the environment variable: "Setting
|
|
789
|
+
// CLAUDE_CODE_SUBAGENT_MODEL by itself doesn't change the model the built-in Explore and
|
|
790
|
+
// Plan subagents run on." A model they name themselves, or one the invocation names,
|
|
791
|
+
// still applies.
|
|
792
|
+
const exemptFromEnvModel = agent.source === 'builtin' && (agent.name === 'Explore' || agent.name === 'Plan')
|
|
793
|
+
const model = agent.model ?? aliasModel ?? (exemptFromEnvModel ? undefined : process.env.CLAUDE_CODE_SUBAGENT_MODEL)
|
|
786
794
|
if (model) args.push('--model', agent.effort ? `${model}:${agent.effort}` : model)
|
|
787
795
|
else if (agent.effort) args.push('--thinking', agent.effort)
|
|
788
796
|
// Claude's mcp__<server> / mcp__* patterns expand against the parent's MCP roster;
|
|
@@ -858,7 +866,7 @@ interface BackgroundContext {
|
|
|
858
866
|
projectApproved: boolean
|
|
859
867
|
}
|
|
860
868
|
|
|
861
|
-
async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext
|
|
869
|
+
async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext): Promise<ToolResult> {
|
|
862
870
|
const { agents, defaultCwd, pi, makeDetails, skillRoots, availableModels, projectApproved } = context
|
|
863
871
|
const task = params.task
|
|
864
872
|
const agentName = params.agent
|
|
@@ -955,7 +963,6 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
|
|
|
955
963
|
removeTmpPrompt(tmpPrompt)
|
|
956
964
|
return backgroundCapResult(makeDetails)
|
|
957
965
|
}
|
|
958
|
-
onStarted?.(id)
|
|
959
966
|
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
|
|
960
967
|
return {
|
|
961
968
|
content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
|
|
@@ -1437,21 +1444,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1437
1444
|
if (isMcpToolAliases(data)) setKnownMcpAliases(data)
|
|
1438
1445
|
})
|
|
1439
1446
|
|
|
1440
|
-
// /tasks resolves these against the registry at print time. background.ts owns the
|
|
1441
|
-
// run records but does not enumerate them, so the ids started here are remembered;
|
|
1442
|
-
// a run the registry has since evicted simply drops out of the listing.
|
|
1443
|
-
const startedBackgroundRuns = new Set<string>()
|
|
1444
|
-
|
|
1445
|
-
// The registry self-caps and evicts old runs, so an id kept here after its record is
|
|
1446
|
-
// gone is dead weight. Drop those on every add, bounding the set to the registry's
|
|
1447
|
-
// live capacity rather than letting it grow for the whole session.
|
|
1448
|
-
const rememberBackgroundRun = (id: string): void => {
|
|
1449
|
-
for (const known of startedBackgroundRuns) {
|
|
1450
|
-
if (!backgroundRun(known)) startedBackgroundRuns.delete(known)
|
|
1451
|
-
}
|
|
1452
|
-
startedBackgroundRuns.add(id)
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
1447
|
const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
|
|
1456
1448
|
// Runs through driveRun's guard, same as the background-mode callback above.
|
|
1457
1449
|
// The stop event fires here too, so SubagentStop hooks see resumed runs end.
|
|
@@ -1585,7 +1577,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1585
1577
|
|
|
1586
1578
|
if (params.resume) {
|
|
1587
1579
|
const onResumed = (run: { id: string; agent: string }): void => {
|
|
1588
|
-
rememberBackgroundRun(run.id)
|
|
1589
1580
|
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: run.agent, agentId: run.id })
|
|
1590
1581
|
}
|
|
1591
1582
|
return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion, onResumed) }], details: makeDetails('single')([]) }
|
|
@@ -1629,7 +1620,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1629
1620
|
// unavailable tier still falls back to the session model.
|
|
1630
1621
|
const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
|
|
1631
1622
|
|
|
1632
|
-
if (wantsBackground(params, agents)) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }
|
|
1623
|
+
if (wantsBackground(params, agents)) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved })
|
|
1633
1624
|
|
|
1634
1625
|
const mode: ModeContext = {
|
|
1635
1626
|
agents,
|
|
@@ -1686,8 +1677,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1686
1677
|
pi.registerCommand('tasks', {
|
|
1687
1678
|
description: 'Show background subagent runs',
|
|
1688
1679
|
handler: async (_args, ctx) => {
|
|
1689
|
-
|
|
1690
|
-
ctx.ui.notify(tasksStatusText(runs), 'info')
|
|
1680
|
+
ctx.ui.notify(tasksStatusText(allBackgroundRuns()), 'info')
|
|
1691
1681
|
},
|
|
1692
1682
|
})
|
|
1693
1683
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.53",
|
|
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, subagents, and goals",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|