pi-code 1.0.12 → 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.
- package/README.md +4 -0
- package/extensions/commands.ts +35 -28
- package/extensions/context-imports.ts +6 -14
- package/extensions/git-checkpoint.ts +7 -0
- package/extensions/hooks/config.ts +219 -0
- package/extensions/hooks/decisions.ts +169 -0
- package/extensions/hooks/index.ts +440 -0
- package/extensions/hooks/matcher.ts +126 -0
- package/extensions/hooks/runners.ts +261 -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/session-title.ts +11 -8
- package/extensions/status-line.ts +1 -1
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/background.ts +15 -2
- package/extensions/subagent/index.ts +24 -124
- package/extensions/subagent/render.ts +131 -0
- package/extensions/thinking.ts +32 -29
- package/package.json +10 -9
- package/extensions/hooks.ts +0 -1167
- package/extensions/mcp.ts +0 -1336
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 {
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Claude auto-names a new conversation from its first message; this does the same for
|
|
5
5
|
* pi. After the first run of an unnamed session settles, it asks the current model for
|
|
6
|
-
* a short title based on the first user message and applies it
|
|
7
|
-
*
|
|
6
|
+
* a short title based on the first user message and applies it with setSessionName, which
|
|
7
|
+
* names the session in the selector and refreshes the terminal window/tab title natively
|
|
8
|
+
* (pi changelog); a separate ctx.ui.setTitle call would only duplicate that, so there is none.
|
|
8
9
|
*
|
|
9
10
|
* It runs in every mode, not just the TUI: naming a session is cheap and harmless, and a
|
|
10
|
-
* headless run that persists its session still benefits from a readable name later.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* or any provider error leaves the session untitled and never throws.
|
|
11
|
+
* headless run that persists its session still benefits from a readable name later. Titling
|
|
12
|
+
* is best-effort throughout: a session that already has a name, a run with no user text (a
|
|
13
|
+
* slash-command-only turn), a headless run with no model, or any provider error leaves the
|
|
14
|
+
* session untitled and never throws.
|
|
15
15
|
*
|
|
16
16
|
* Cost: one model call per session at most. The guard is claimed before the completion so
|
|
17
17
|
* repeated settles cannot each fire a call, and a failed attempt is not retried until a
|
|
@@ -132,8 +132,11 @@ export default function sessionTitleExtension(pi: ExtensionAPI) {
|
|
|
132
132
|
// Post-await ctx getters throw once the session is disposed, and an escaping rejection
|
|
133
133
|
// from this un-awaited settle can exit pi; apply the title best-effort.
|
|
134
134
|
try {
|
|
135
|
+
// pi.setSessionName refreshes the terminal/tab title natively (pi changelog), so a
|
|
136
|
+
// separate ctx.ui.setTitle call would only duplicate that. The guard stays: a
|
|
137
|
+
// disposed session throws from setSessionName post-await, and an escaping rejection
|
|
138
|
+
// from this un-awaited settle can exit pi.
|
|
135
139
|
pi.setSessionName(title)
|
|
136
|
-
ctx.ui.setTitle?.(title)
|
|
137
140
|
} catch {
|
|
138
141
|
// disposed session or a setter failure: leave the session untitled.
|
|
139
142
|
}
|
|
@@ -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. */
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Background subagent runs: fire-and-forget children whose completion wakes
|
|
3
3
|
* the parent agent via a notification message. State lives in an in-memory
|
|
4
|
-
* registry queried via {status: true}; it is lost on restart
|
|
5
|
-
*
|
|
4
|
+
* registry queried via {status: true}; it is lost on restart. A child still
|
|
5
|
+
* running when pi quits is SIGTERMed (cancelAllBackgroundRuns); one still running
|
|
6
|
+
* across a same-process session switch keeps going under the new session.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { spawn } from 'node:child_process'
|
|
@@ -141,6 +142,18 @@ export function cancelBackgroundRun(id: string): 'cancelled' | 'not-running' | '
|
|
|
141
142
|
return 'cancelled'
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/** SIGTERM every live background child, killing each process group the way a single
|
|
146
|
+
* cancel does. Called on quit: a detached child would otherwise keep running (and
|
|
147
|
+
* spending tokens) after pi exits, its completion swallowed. Returns how many were
|
|
148
|
+
* signalled; each cancelled child still holds its slot until it actually dies. */
|
|
149
|
+
export function cancelAllBackgroundRuns(): number {
|
|
150
|
+
let count = 0
|
|
151
|
+
for (const id of Array.from(runs.keys())) {
|
|
152
|
+
if (cancelBackgroundRun(id) === 'cancelled') count++
|
|
153
|
+
}
|
|
154
|
+
return count
|
|
155
|
+
}
|
|
156
|
+
|
|
144
157
|
export function backgroundStatusText(): string {
|
|
145
158
|
return formatStatus(runs.values())
|
|
146
159
|
}
|
|
@@ -32,106 +32,17 @@ import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
|
32
32
|
import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
|
|
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
|
-
import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
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.
|
|
@@ -1387,6 +1270,23 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1387
1270
|
})
|
|
1388
1271
|
})
|
|
1389
1272
|
|
|
1273
|
+
pi.on('session_shutdown', (event, ctx) => {
|
|
1274
|
+
// On quit pi is exiting, so a detached background child would keep running (and
|
|
1275
|
+
// spending tokens) with its completion swallowed: SIGTERM every live run, killing the
|
|
1276
|
+
// process group the way a cancel does. On a same-process session switch
|
|
1277
|
+
// (new/resume/fork) the children keep running under the new session, so leave them be
|
|
1278
|
+
// and warn once that they are still spending; /tasks inspects them. reload re-imports
|
|
1279
|
+
// this module (losing the registry), so it neither kills nor warns.
|
|
1280
|
+
if (event.reason === 'quit') {
|
|
1281
|
+
cancelAllBackgroundRuns()
|
|
1282
|
+
return
|
|
1283
|
+
}
|
|
1284
|
+
if (event.reason === 'new' || event.reason === 'resume' || event.reason === 'fork') {
|
|
1285
|
+
const active = activeBackgroundRuns()
|
|
1286
|
+
if (active > 0) ctx.ui?.notify(`${active} background run${active === 1 ? '' : 's'} still active; /tasks to inspect`, 'warning')
|
|
1287
|
+
}
|
|
1288
|
+
})
|
|
1289
|
+
|
|
1390
1290
|
// Claude surfaces each agent's description so the model can pick one autonomously.
|
|
1391
1291
|
// Served from the session-level cache above (keyed on cwd and scope, so an approval
|
|
1392
1292
|
// granted mid-session still widens it); project agents are included only when the
|
|
@@ -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,17 +35,27 @@ 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
|
+
})
|
|
51
|
+
|
|
52
|
+
pi.on('session_start', () => {
|
|
53
|
+
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
54
|
+
// the same instance while an escalation is still pending (its agent_settled never came),
|
|
55
|
+
// and that stale restore must be dropped rather than fired into the next session, whose
|
|
56
|
+
// level the new session owns. Drop only: do NOT setThinkingLevel here.
|
|
57
|
+
override.reset()
|
|
58
|
+
})
|
|
48
59
|
|
|
49
60
|
pi.on('input', (event, ctx) => {
|
|
50
61
|
// Only genuine user input escalates. sendUserMessage emits an input event with
|
|
@@ -56,35 +67,27 @@ export default function thinkingExtension(pi: ExtensionAPI) {
|
|
|
56
67
|
// signal that the prior prompt is gone: if this extension still owns the level (it is
|
|
57
68
|
// exactly our escalation target), restore before handling this input. In the normal
|
|
58
69
|
// path a settle already cleared pending, so this fires only for the blocked case.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
const armedPrior = override.prior
|
|
71
|
+
if (armedPrior !== undefined && (pi.getThinkingLevel?.() ?? ctx.thinkingLevel) === override.target) {
|
|
72
|
+
pi.setThinkingLevel?.(armedPrior)
|
|
73
|
+
override.reset()
|
|
63
74
|
}
|
|
64
75
|
const target = requestedThinkingLevel(event.text)
|
|
65
76
|
if (!target) return
|
|
66
77
|
const current = pi.getThinkingLevel?.() ?? ctx.thinkingLevel ?? 'off'
|
|
67
78
|
// A keyword only raises reasoning: leave a level already at or above the target.
|
|
68
79
|
if (thinkingRank(current) >= thinkingRank(target)) return
|
|
69
|
-
|
|
70
|
-
pendingTarget = target
|
|
80
|
+
override.arm(current, target)
|
|
71
81
|
pi.setThinkingLevel?.(target)
|
|
72
82
|
// Return nothing so the input is neither consumed nor transformed: Claude keeps
|
|
73
83
|
// the keyword in the prompt.
|
|
74
84
|
})
|
|
75
85
|
|
|
76
86
|
pi.on('agent_settled', () => {
|
|
77
|
-
if
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// Restore only if nothing else moved the level since this extension set it. If a
|
|
83
|
-
// command's effort restore or the user's manual change already took over (current
|
|
84
|
-
// no longer equals our target), leave that value in place and stand down. When the
|
|
85
|
-
// level cannot be read, restore unconditionally, the prior best-effort behavior.
|
|
86
|
-
const current = pi.getThinkingLevel?.()
|
|
87
|
-
if (current !== undefined && current !== target) return
|
|
88
|
-
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()
|
|
89
92
|
})
|
|
90
93
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
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",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"todo"
|
|
21
21
|
],
|
|
22
22
|
"license": "MIT",
|
|
23
|
+
"author": "ilovepixelart",
|
|
23
24
|
"type": "module",
|
|
24
25
|
"files": [
|
|
25
26
|
"extensions"
|
|
@@ -51,20 +52,20 @@
|
|
|
51
52
|
"provenance": true
|
|
52
53
|
},
|
|
53
54
|
"dependencies": {
|
|
54
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
55
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
55
56
|
"typebox": "^1.3.6"
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
|
-
"@earendil-works/pi-ai": "
|
|
59
|
-
"@earendil-works/pi-coding-agent": "
|
|
60
|
-
"@earendil-works/pi-tui": "
|
|
59
|
+
"@earendil-works/pi-ai": ">=0.79.1",
|
|
60
|
+
"@earendil-works/pi-coding-agent": ">=0.79.1",
|
|
61
|
+
"@earendil-works/pi-tui": ">=0.79.1"
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
63
64
|
"@biomejs/biome": "^2.5.4",
|
|
64
|
-
"@earendil-works/pi-agent-core": "^0.84.
|
|
65
|
-
"@earendil-works/pi-ai": "^0.84.
|
|
66
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
67
|
-
"@earendil-works/pi-tui": "^0.84.
|
|
65
|
+
"@earendil-works/pi-agent-core": "^0.84.2",
|
|
66
|
+
"@earendil-works/pi-ai": "^0.84.2",
|
|
67
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
68
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
68
69
|
"@types/node": "^26.1.1",
|
|
69
70
|
"@vitest/coverage-v8": "^4.1.10",
|
|
70
71
|
"typescript": "^7.0.2",
|