pi-code 1.0.13 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/extensions/commands.ts +27 -30
- package/extensions/context-imports.ts +6 -14
- package/extensions/hooks/config.ts +234 -0
- package/extensions/hooks/decisions.ts +169 -0
- package/extensions/hooks/index.ts +497 -0
- package/extensions/hooks/matcher.ts +126 -0
- package/extensions/hooks/runners.ts +269 -0
- package/extensions/internal/command-file.ts +1 -1
- package/extensions/internal/managed-settings.ts +5 -0
- package/extensions/internal/output-guard.ts +1 -1
- package/extensions/internal/settings-chain.ts +24 -0
- package/extensions/internal/stat-token.ts +14 -0
- package/extensions/internal/turn-override.ts +73 -0
- package/extensions/mcp/config.ts +159 -0
- package/extensions/mcp/index.ts +513 -0
- package/extensions/mcp/listing.ts +92 -0
- package/extensions/mcp/mapping.ts +173 -0
- package/extensions/mcp/oauth-flow.ts +80 -0
- package/extensions/mcp/policy.ts +140 -0
- package/extensions/mcp/transport.ts +258 -0
- package/extensions/memory.ts +6 -10
- package/extensions/output-styles.ts +2 -6
- package/extensions/plan-mode/utils.ts +1 -1
- package/extensions/question.ts +2 -2
- package/extensions/status-line.ts +1 -1
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/index.ts +6 -123
- package/extensions/subagent/render.ts +131 -0
- package/extensions/thinking.ts +25 -31
- package/package.json +1 -1
- package/extensions/hooks.ts +0 -1179
- package/extensions/mcp.ts +0 -1358
package/extensions/memory.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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])
|
package/extensions/question.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/extensions/thinking.ts
CHANGED
|
@@ -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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.15",
|
|
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",
|