pi-code 1.0.13 → 1.0.14

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.
@@ -17,7 +17,9 @@ import { Type } from 'typebox'
17
17
  import { claudeConfigDir } from './internal/config-dir.js'
18
18
  import { capForContext } from './internal/output-guard.js'
19
19
  import { isProjectApprovedSilently } from './internal/project-approval.js'
20
- import { findNearestFile, repoRoot } from './internal/project-root.js'
20
+ import { repoRoot } from './internal/project-root.js'
21
+ import { claudeSettingsChain } from './internal/settings-chain.js'
22
+ import { statToken } from './internal/stat-token.js'
21
23
 
22
24
  export const INDEX_FILE = 'MEMORY.md'
23
25
 
@@ -148,7 +150,7 @@ type MemoryToolResult = { content: Array<{ type: 'text'; text: string }>; detail
148
150
  * also on the memory file: a second nested queue self-deadlocks when a memory name
149
151
  * canonicalizes to the same key as the index (e.g. `memory.md` and `MEMORY.md` under a
150
152
  * case-insensitive filesystem, since the queue keys on realpath). */
151
- export async function saveMemory(dir: string, indexPath: string, name: string | undefined, description: string | undefined, content: string | undefined, now: string = new Date().toISOString()): Promise<MemoryToolResult> {
153
+ async function saveMemory(dir: string, indexPath: string, name: string | undefined, description: string | undefined, content: string | undefined, now: string = new Date().toISOString()): Promise<MemoryToolResult> {
152
154
  if (!name || !description || !content) {
153
155
  return { content: [{ type: 'text', text: 'save requires name, description, and content.' }], details: {} }
154
156
  }
@@ -289,12 +291,7 @@ function writeIndex(indexPath: string, content: string): void {
289
291
  * approved, since a project's `autoMemoryDirectory` is honored under the same trust
290
292
  * rule as hooks in settings files. Later files win. */
291
293
  export function memorySettingsFiles(cwd: string, home: string, approved: boolean): string[] {
292
- const files = [path.join(claudeConfigDir(home), 'settings.json')]
293
- if (!approved) return files
294
- for (const name of ['settings.json', 'settings.local.json']) {
295
- files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
296
- }
297
- return files
294
+ return claudeSettingsChain(cwd, home, approved)
298
295
  }
299
296
 
300
297
  /** Merge the two memory settings across the chain, later files winning per key. */
@@ -361,8 +358,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
361
358
 
362
359
  const indexStatToken = (): string => {
363
360
  try {
364
- const stat = fs.statSync(path.join(dir, INDEX_FILE))
365
- return `${stat.mtimeMs}:${stat.size}`
361
+ return statToken(path.join(dir, INDEX_FILE))
366
362
  } catch {
367
363
  return 'missing'
368
364
  }
@@ -28,6 +28,7 @@ import { claudeConfigDir } from './internal/config-dir.js'
28
28
  import { installedPlugins } from './internal/plugins.js'
29
29
  import { isProjectApproved } from './internal/project-approval.js'
30
30
  import { findNearestDir, findNearestFile } from './internal/project-root.js'
31
+ import { claudeSettingsChain } from './internal/settings-chain.js'
31
32
 
32
33
  export interface OutputStyle {
33
34
  name: string
@@ -130,12 +131,7 @@ export function loadStyles(dirs: string[]): OutputStyle[] {
130
131
  /** Settings files that carry `outputStyle`. Project settings apply only when trusted,
131
132
  * each the nearest of its name at or above cwd, as the hooks settings chain reads. */
132
133
  export function settingsFiles(cwd: string, home: string, trusted: boolean): string[] {
133
- const files = [path.join(claudeConfigDir(home), 'settings.json')]
134
- if (!trusted) return files
135
- for (const name of ['settings.json', 'settings.local.json']) {
136
- files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
137
- }
138
- return files
134
+ return claudeSettingsChain(cwd, home, trusted)
139
135
  }
140
136
 
141
137
  /** The `outputStyle` recorded in settings, last file winning. */
@@ -201,7 +201,7 @@ export function extractTodoItems(message: string): TodoItem[] {
201
201
  return items
202
202
  }
203
203
 
204
- export function extractDoneSteps(message: string): number[] {
204
+ function extractDoneSteps(message: string): number[] {
205
205
  const steps: number[] = []
206
206
  for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
207
207
  const step = Number(match[1])
@@ -58,7 +58,7 @@ export interface QuestionSpec {
58
58
  }
59
59
 
60
60
  /** Normalize either accepted shape into the list of questions to ask. */
61
- export function questionList(params: Partial<QuestionSpec> & { questions?: QuestionSpec[] }): QuestionSpec[] {
61
+ function questionList(params: Partial<QuestionSpec> & { questions?: QuestionSpec[] }): QuestionSpec[] {
62
62
  if (params.questions && params.questions.length > 0) return params.questions
63
63
  if (typeof params.question === 'string') return [{ question: params.question, header: shortHeader(params.header), options: params.options ?? [], multiSelect: params.multiSelect }]
64
64
  return []
@@ -67,7 +67,7 @@ export function questionList(params: Partial<QuestionSpec> & { questions?: Quest
67
67
  /** Claude keeps a header short for the label slot. Truncating is the forgiving read:
68
68
  * rejecting the call costs a turn while the model recovers from a validation error,
69
69
  * which is a poor trade for a display detail. */
70
- export const HEADER_MAX = 12
70
+ const HEADER_MAX = 12
71
71
  export const shortHeader = (header: string | undefined): string | undefined => (header === undefined ? undefined : header.slice(0, HEADER_MAX))
72
72
 
73
73
  function checkbox(checked: boolean | undefined): string {
@@ -28,7 +28,7 @@ import * as os from 'node:os'
28
28
  import * as path from 'node:path'
29
29
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
30
30
 
31
- import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks.js'
31
+ import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks/index.js'
32
32
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
33
33
  import { isProjectApprovedSilently } from './internal/project-approval.js'
34
34
  import { readActiveStyleName, settingsFiles } from './output-styles.js'
@@ -278,7 +278,7 @@ function buildAgentMap(userAgents: AgentConfig[], projectAgents: AgentConfig[],
278
278
  export type AgentSource = 'user' | 'project' | 'builtin' | 'plugin'
279
279
 
280
280
  /** Bundled default agents (Explore, Plan, general-purpose), lowest precedence. */
281
- export const BUILTIN_AGENTS_DIR = path.join(import.meta.dirname, 'agents')
281
+ const BUILTIN_AGENTS_DIR = path.join(import.meta.dirname, 'agents')
282
282
 
283
283
  /** Agent directories of every enabled plugin: `agents/` unless the manifest
284
284
  * points elsewhere. Plugins are user-installed, so user scope only decides. */
@@ -33,105 +33,16 @@ import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES,
33
33
  import { skillDirs } from '../skills.js'
34
34
  import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
35
35
  import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelAllBackgroundRuns, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
36
+ import { type DisplayItem, formatToolCall, formatUsageStats, getDisplayItems, getFinalOutput } from './render.js'
37
+
38
+ // Re-exported so the render formatters stay importable from the subagent entry point,
39
+ // where the tests and the tool itself have always reached for them.
40
+ export { formatTokens, formatToolCall, formatUsageStats, getDisplayItems, getFinalOutput } from './render.js'
36
41
 
37
42
  const MAX_PARALLEL_TASKS = 8
38
43
  const MAX_CONCURRENCY = 4
39
44
  const COLLAPSED_ITEM_COUNT = 10
40
45
 
41
- export function formatTokens(count: number): string {
42
- if (count < 1000) return count.toString()
43
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`
44
- if (count < 1000000) return `${Math.round(count / 1000)}k`
45
- return `${(count / 1000000).toFixed(1)}M`
46
- }
47
-
48
- export function formatUsageStats(
49
- usage: {
50
- input: number
51
- output: number
52
- cacheRead: number
53
- cacheWrite: number
54
- cost: number
55
- contextTokens?: number
56
- turns?: number
57
- },
58
- model?: string,
59
- ): string {
60
- const parts: string[] = []
61
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? 's' : ''}`)
62
- if (usage.input) parts.push(`↑${formatTokens(usage.input)}`)
63
- if (usage.output) parts.push(`↓${formatTokens(usage.output)}`)
64
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`)
65
- if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`)
66
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`)
67
- if (usage.contextTokens && usage.contextTokens > 0) {
68
- parts.push(`ctx:${formatTokens(usage.contextTokens)}`)
69
- }
70
- if (model) parts.push(model)
71
- return parts.join(' ')
72
- }
73
-
74
- export function formatToolCall(toolName: string, args: Record<string, unknown>, themeFg: Theme['fg']): string {
75
- const shortenPath = (p: string) => {
76
- const home = os.homedir()
77
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p
78
- }
79
-
80
- switch (toolName) {
81
- case 'bash': {
82
- const command = (args.command as string) || '...'
83
- const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command
84
- return themeFg('muted', '$ ') + themeFg('toolOutput', preview)
85
- }
86
- case 'read': {
87
- const rawPath = (args.file_path || args.path || '...') as string
88
- const filePath = shortenPath(rawPath)
89
- const offset = args.offset as number | undefined
90
- const limit = args.limit as number | undefined
91
- let text = themeFg('accent', filePath)
92
- if (offset !== undefined || limit !== undefined) {
93
- const startLine = offset ?? 1
94
- const endLine = limit !== undefined ? startLine + limit - 1 : ''
95
- const rangeSuffix = endLine ? `-${endLine}` : ''
96
- text += themeFg('warning', `:${startLine}${rangeSuffix}`)
97
- }
98
- return themeFg('muted', 'read ') + text
99
- }
100
- case 'write': {
101
- const rawPath = (args.file_path || args.path || '...') as string
102
- const filePath = shortenPath(rawPath)
103
- const content = (args.content || '') as string
104
- const lines = content.split('\n').length
105
- let text = themeFg('muted', 'write ') + themeFg('accent', filePath)
106
- if (lines > 1) text += themeFg('dim', ` (${lines} lines)`)
107
- return text
108
- }
109
- case 'edit': {
110
- const rawPath = (args.file_path || args.path || '...') as string
111
- return themeFg('muted', 'edit ') + themeFg('accent', shortenPath(rawPath))
112
- }
113
- case 'ls': {
114
- const rawPath = (args.path || '.') as string
115
- return themeFg('muted', 'ls ') + themeFg('accent', shortenPath(rawPath))
116
- }
117
- case 'find': {
118
- const pattern = (args.pattern || '*') as string
119
- const rawPath = (args.path || '.') as string
120
- return themeFg('muted', 'find ') + themeFg('accent', pattern) + themeFg('dim', ` in ${shortenPath(rawPath)}`)
121
- }
122
- case 'grep': {
123
- const pattern = (args.pattern || '') as string
124
- const rawPath = (args.path || '.') as string
125
- return themeFg('muted', 'grep ') + themeFg('accent', `/${pattern}/`) + themeFg('dim', ` in ${shortenPath(rawPath)}`)
126
- }
127
- default: {
128
- const argsStr = JSON.stringify(args)
129
- const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr
130
- return themeFg('accent', toolName) + themeFg('dim', ` ${preview}`)
131
- }
132
- }
133
- }
134
-
135
46
  interface UsageStats {
136
47
  input: number
137
48
  output: number
@@ -163,34 +74,6 @@ interface SubagentDetails {
163
74
  results: SingleResult[]
164
75
  }
165
76
 
166
- export function getFinalOutput(messages: Message[]): string {
167
- for (let i = messages.length - 1; i >= 0; i--) {
168
- const msg = messages[i]
169
- if (msg.role === 'assistant') {
170
- // The complete text of the last assistant message: a message can carry more than one
171
- // text part, and taking only the first diverged from the background parser.
172
- const parts = msg.content.filter((part) => part.type === 'text').map((part) => part.text)
173
- if (parts.length > 0) return parts.join('\n')
174
- }
175
- }
176
- return ''
177
- }
178
-
179
- type DisplayItem = { type: 'text'; text: string } | { type: 'toolCall'; name: string; args: Record<string, unknown> }
180
-
181
- export function getDisplayItems(messages: Message[]): DisplayItem[] {
182
- const items: DisplayItem[] = []
183
- for (const msg of messages) {
184
- if (msg.role === 'assistant') {
185
- for (const part of msg.content) {
186
- if (part.type === 'text') items.push({ type: 'text', text: part.text })
187
- else if (part.type === 'toolCall') items.push({ type: 'toolCall', name: part.name, args: part.arguments })
188
- }
189
- }
190
- }
191
- return items
192
- }
193
-
194
77
  export async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise<TOut>): Promise<TOut[]> {
195
78
  if (items.length === 0) return []
196
79
  const limit = Math.max(1, Math.min(concurrency, items.length))
@@ -724,7 +607,7 @@ export function agentMemorySection(dir: string, memoryMd: string): string {
724
607
  /** The memory section for one run, or undefined when the agent declares no memory,
725
608
  * auto memory is off, or a repo-scoped store is not approved. Subagent memory is part
726
609
  * of auto memory, so the same settings chain and env kill switch gate it. */
727
- export function agentMemoryPromptSection(agent: Pick<AgentConfig, 'memory' | 'name'>, cwd: string, projectApproved: boolean): string | undefined {
610
+ function agentMemoryPromptSection(agent: Pick<AgentConfig, 'memory' | 'name'>, cwd: string, projectApproved: boolean): string | undefined {
728
611
  if (!agent.memory) return undefined
729
612
  // project and local stores live under the repository's .claude, a repo-controlled
730
613
  // path; like rules, they are only read once the project is approved.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Subagent display formatters: the pure helpers that turn a run's messages and usage
3
+ * into the strings the subagent tool renders. No process spawning, no pi API, no state,
4
+ * so they render the same way for the live tool and for tests.
5
+ */
6
+
7
+ import * as os from 'node:os'
8
+ import type { Message } from '@earendil-works/pi-ai'
9
+ import type { Theme } from '@earendil-works/pi-coding-agent'
10
+
11
+ export function formatTokens(count: number): string {
12
+ if (count < 1000) return count.toString()
13
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`
14
+ if (count < 1000000) return `${Math.round(count / 1000)}k`
15
+ return `${(count / 1000000).toFixed(1)}M`
16
+ }
17
+
18
+ export function formatUsageStats(
19
+ usage: {
20
+ input: number
21
+ output: number
22
+ cacheRead: number
23
+ cacheWrite: number
24
+ cost: number
25
+ contextTokens?: number
26
+ turns?: number
27
+ },
28
+ model?: string,
29
+ ): string {
30
+ const parts: string[] = []
31
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? 's' : ''}`)
32
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`)
33
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`)
34
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`)
35
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`)
36
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`)
37
+ if (usage.contextTokens && usage.contextTokens > 0) {
38
+ parts.push(`ctx:${formatTokens(usage.contextTokens)}`)
39
+ }
40
+ if (model) parts.push(model)
41
+ return parts.join(' ')
42
+ }
43
+
44
+ export function formatToolCall(toolName: string, args: Record<string, unknown>, themeFg: Theme['fg']): string {
45
+ const shortenPath = (p: string) => {
46
+ const home = os.homedir()
47
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p
48
+ }
49
+
50
+ switch (toolName) {
51
+ case 'bash': {
52
+ const command = (args.command as string) || '...'
53
+ const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command
54
+ return themeFg('muted', '$ ') + themeFg('toolOutput', preview)
55
+ }
56
+ case 'read': {
57
+ const rawPath = (args.file_path || args.path || '...') as string
58
+ const filePath = shortenPath(rawPath)
59
+ const offset = args.offset as number | undefined
60
+ const limit = args.limit as number | undefined
61
+ let text = themeFg('accent', filePath)
62
+ if (offset !== undefined || limit !== undefined) {
63
+ const startLine = offset ?? 1
64
+ const endLine = limit !== undefined ? startLine + limit - 1 : ''
65
+ const rangeSuffix = endLine ? `-${endLine}` : ''
66
+ text += themeFg('warning', `:${startLine}${rangeSuffix}`)
67
+ }
68
+ return themeFg('muted', 'read ') + text
69
+ }
70
+ case 'write': {
71
+ const rawPath = (args.file_path || args.path || '...') as string
72
+ const filePath = shortenPath(rawPath)
73
+ const content = (args.content || '') as string
74
+ const lines = content.split('\n').length
75
+ let text = themeFg('muted', 'write ') + themeFg('accent', filePath)
76
+ if (lines > 1) text += themeFg('dim', ` (${lines} lines)`)
77
+ return text
78
+ }
79
+ case 'edit': {
80
+ const rawPath = (args.file_path || args.path || '...') as string
81
+ return themeFg('muted', 'edit ') + themeFg('accent', shortenPath(rawPath))
82
+ }
83
+ case 'ls': {
84
+ const rawPath = (args.path || '.') as string
85
+ return themeFg('muted', 'ls ') + themeFg('accent', shortenPath(rawPath))
86
+ }
87
+ case 'find': {
88
+ const pattern = (args.pattern || '*') as string
89
+ const rawPath = (args.path || '.') as string
90
+ return themeFg('muted', 'find ') + themeFg('accent', pattern) + themeFg('dim', ` in ${shortenPath(rawPath)}`)
91
+ }
92
+ case 'grep': {
93
+ const pattern = (args.pattern || '') as string
94
+ const rawPath = (args.path || '.') as string
95
+ return themeFg('muted', 'grep ') + themeFg('accent', `/${pattern}/`) + themeFg('dim', ` in ${shortenPath(rawPath)}`)
96
+ }
97
+ default: {
98
+ const argsStr = JSON.stringify(args)
99
+ const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr
100
+ return themeFg('accent', toolName) + themeFg('dim', ` ${preview}`)
101
+ }
102
+ }
103
+ }
104
+
105
+ export function getFinalOutput(messages: Message[]): string {
106
+ for (let i = messages.length - 1; i >= 0; i--) {
107
+ const msg = messages[i]
108
+ if (msg.role === 'assistant') {
109
+ // The complete text of the last assistant message: a message can carry more than one
110
+ // text part, and taking only the first diverged from the background parser.
111
+ const parts = msg.content.filter((part) => part.type === 'text').map((part) => part.text)
112
+ if (parts.length > 0) return parts.join('\n')
113
+ }
114
+ }
115
+ return ''
116
+ }
117
+
118
+ export type DisplayItem = { type: 'text'; text: string } | { type: 'toolCall'; name: string; args: Record<string, unknown> }
119
+
120
+ export function getDisplayItems(messages: Message[]): DisplayItem[] {
121
+ const items: DisplayItem[] = []
122
+ for (const msg of messages) {
123
+ if (msg.role === 'assistant') {
124
+ for (const part of msg.content) {
125
+ if (part.type === 'text') items.push({ type: 'text', text: part.text })
126
+ else if (part.type === 'toolCall') items.push({ type: 'toolCall', name: part.name, args: part.arguments })
127
+ }
128
+ }
129
+ }
130
+ return items
131
+ }
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
13
+ import { createTurnOverride } from './internal/turn-override.js'
13
14
 
14
15
  // The pi ThinkingLevel union, taken from the setter's parameter so it tracks the SDK.
15
16
  type ThinkingLevel = Parameters<ExtensionAPI['setThinkingLevel']>[0]
@@ -34,25 +35,26 @@ export function requestedThinkingLevel(text: string): ThinkingLevel | undefined
34
35
  }
35
36
 
36
37
  export default function thinkingExtension(pi: ExtensionAPI) {
37
- // The level to restore once the escalated turn settles, captured the first time a
38
- // turn escalates so back-to-back keywords in one turn still restore the original.
39
- // Cleared on agent_settled, the run's true end past any retry/compaction/Stop
40
- // continuation, the same clearing point commands.ts uses for its model override.
41
- let pendingRestore: ThinkingLevel | undefined
42
- // The level this extension last escalated to. The restore is conditional on the level
43
- // still being this target at settle: commands.ts also restores an `effort:` override
44
- // on agent_settled, so both fire on the same event. Keying the restore on the target
45
- // makes the outcome order-independent: if a command's restore (or a manual change)
46
- // already moved the level, thinking stands down instead of clobbering it.
47
- let pendingTarget: ThinkingLevel | undefined
38
+ // A per-turn escalation: the level to restore, captured the first time a turn escalates
39
+ // so back-to-back keywords still restore the original, and the target this extension
40
+ // moved to. The restore is conditional on the level still being that target at settle:
41
+ // commands.ts also restores an `effort:` override on agent_settled, so both fire on the
42
+ // same event. Keying the restore on the target makes the outcome order-independent: if a
43
+ // command's restore (or a manual change) already moved the level, thinking stands down
44
+ // instead of clobbering it. The capture clears on agent_settled, the run's true end past
45
+ // any retry/compaction/Stop continuation, the same clearing point commands.ts uses.
46
+ const override = createTurnOverride<ThinkingLevel>({
47
+ set: (level) => pi.setThinkingLevel?.(level),
48
+ get: () => pi.getThinkingLevel?.(),
49
+ conditional: true,
50
+ })
48
51
 
49
52
  pi.on('session_start', () => {
50
53
  // One extension instance serves every session. A mid-turn /new fires session_start on
51
54
  // the same instance while an escalation is still pending (its agent_settled never came),
52
55
  // and that stale restore must be dropped rather than fired into the next session, whose
53
56
  // level the new session owns. Drop only: do NOT setThinkingLevel here.
54
- pendingRestore = undefined
55
- pendingTarget = undefined
57
+ override.reset()
56
58
  })
57
59
 
58
60
  pi.on('input', (event, ctx) => {
@@ -65,35 +67,27 @@ export default function thinkingExtension(pi: ExtensionAPI) {
65
67
  // signal that the prior prompt is gone: if this extension still owns the level (it is
66
68
  // exactly our escalation target), restore before handling this input. In the normal
67
69
  // path a settle already cleared pending, so this fires only for the blocked case.
68
- if (pendingRestore !== undefined && (pi.getThinkingLevel?.() ?? ctx.thinkingLevel) === pendingTarget) {
69
- pi.setThinkingLevel?.(pendingRestore)
70
- pendingRestore = undefined
71
- pendingTarget = undefined
70
+ const armedPrior = override.prior
71
+ if (armedPrior !== undefined && (pi.getThinkingLevel?.() ?? ctx.thinkingLevel) === override.target) {
72
+ pi.setThinkingLevel?.(armedPrior)
73
+ override.reset()
72
74
  }
73
75
  const target = requestedThinkingLevel(event.text)
74
76
  if (!target) return
75
77
  const current = pi.getThinkingLevel?.() ?? ctx.thinkingLevel ?? 'off'
76
78
  // A keyword only raises reasoning: leave a level already at or above the target.
77
79
  if (thinkingRank(current) >= thinkingRank(target)) return
78
- pendingRestore = pendingRestore ?? current
79
- pendingTarget = target
80
+ override.arm(current, target)
80
81
  pi.setThinkingLevel?.(target)
81
82
  // Return nothing so the input is neither consumed nor transformed: Claude keeps
82
83
  // the keyword in the prompt.
83
84
  })
84
85
 
85
86
  pi.on('agent_settled', () => {
86
- if (pendingRestore === undefined) return
87
- const restore = pendingRestore
88
- const target = pendingTarget
89
- pendingRestore = undefined
90
- pendingTarget = undefined
91
- // Restore only if nothing else moved the level since this extension set it. If a
92
- // command's effort restore or the user's manual change already took over (current
93
- // no longer equals our target), leave that value in place and stand down. When the
94
- // level cannot be read, restore unconditionally, the prior best-effort behavior.
95
- const current = pi.getThinkingLevel?.()
96
- if (current !== undefined && current !== target) return
97
- pi.setThinkingLevel?.(restore)
87
+ // Restore the pre-escalation level, but only if nothing else moved it since (a
88
+ // command's effort restore, a manual change): the conditional override stands down in
89
+ // that case and restores unconditionally when the level cannot be read, the prior
90
+ // best-effort behavior.
91
+ override.settle()
98
92
  })
99
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi",