pi-code 1.0.8 → 1.0.10
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 +33 -29
- package/extensions/commands.ts +35 -4
- package/extensions/context-imports.ts +284 -51
- package/extensions/context-usage.ts +45 -0
- package/extensions/env-settings.ts +130 -0
- package/extensions/git-checkpoint.ts +21 -3
- package/extensions/hooks.ts +108 -18
- package/extensions/internal/command-file.ts +27 -2
- package/extensions/internal/config-dir.ts +24 -0
- package/extensions/internal/path-rules.ts +45 -0
- package/extensions/internal/plugins.ts +60 -3
- package/extensions/mcp.ts +186 -41
- package/extensions/memory.ts +118 -4
- package/extensions/notify.ts +3 -1
- package/extensions/output-styles.ts +3 -2
- package/extensions/skills.ts +2 -1
- package/extensions/status-line.ts +48 -14
- package/extensions/subagent/agents.ts +2 -1
- package/extensions/subagent/index.ts +19 -6
- package/extensions/thinking.ts +80 -0
- package/package.json +1 -1
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* the built-in segment stands in.
|
|
14
14
|
*
|
|
15
15
|
* Without a configured statusLine, the built-in segment shows turn state plus
|
|
16
|
-
* running session cost
|
|
17
|
-
*
|
|
16
|
+
* running session cost: a total seeded from the branch's per-message usage at
|
|
17
|
+
* session start, accumulated per message_end, and reseeded when compaction or
|
|
18
|
+
* /tree navigation reshapes the branch, so it stays correct across navigation
|
|
19
|
+
* and forks without re-walking the branch on every render. The built-in segment is also
|
|
18
20
|
* the fallback while a configured command produces no output. Multi-line output
|
|
19
21
|
* is truncated to its first line: the segment is one footer row in pi.
|
|
20
22
|
*
|
|
@@ -48,6 +50,8 @@ interface UsageEntry {
|
|
|
48
50
|
message?: { usage?: { cost?: { total?: number } } }
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/** Full branch walk: used only to (re)seed the running total, at session start
|
|
54
|
+
* and on the events that reshape the branch. Renders read the total instead. */
|
|
51
55
|
function sessionCost(ctx: ExtensionContext): number {
|
|
52
56
|
let total = 0
|
|
53
57
|
for (const entry of ctx.sessionManager.getBranch() as UsageEntry[]) {
|
|
@@ -95,8 +99,17 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
95
99
|
let sessionCtx: ExtensionContext | undefined
|
|
96
100
|
let commandLine: string | undefined
|
|
97
101
|
let permissionMode = 'default'
|
|
98
|
-
let projectApproved = false
|
|
99
102
|
let sessionStartMs = Date.now()
|
|
103
|
+
// Running session cost; seeded and reseeded by sessionCost(), see below.
|
|
104
|
+
let costTotal = 0
|
|
105
|
+
// The output-style settings chain and active style name, resolved once at
|
|
106
|
+
// session start: the chain's upward walk and per-file reads are too costly for
|
|
107
|
+
// every refresh tick. /output-style persists a choice straight to settings with
|
|
108
|
+
// no bus event, and the new style applies from the next turn anyway, so the
|
|
109
|
+
// cached name is re-read lazily at most once per turn (styleDirty, turn_start).
|
|
110
|
+
let styleFiles: string[] = []
|
|
111
|
+
let styleName: string | undefined
|
|
112
|
+
let styleDirty = false
|
|
100
113
|
// Lines changed, counted from successful edit/write inputs: newText and content
|
|
101
114
|
// lines add, oldText lines remove. An approximation of Claude's counters, which
|
|
102
115
|
// is honest for the tools pi has; bash-side changes are invisible to both.
|
|
@@ -114,8 +127,7 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
114
127
|
|
|
115
128
|
function segmentText(ctx: ExtensionContext, symbol: string): string {
|
|
116
129
|
const theme = ctx.ui.theme
|
|
117
|
-
const
|
|
118
|
-
const costText = cost > 0 ? theme.fg('muted', ` ${formatCost(cost)}`) : ''
|
|
130
|
+
const costText = costTotal > 0 ? theme.fg('muted', ` ${formatCost(costTotal)}`) : ''
|
|
119
131
|
const turnText = turnCount > 0 ? theme.fg('dim', ` turn ${turnCount}`) : theme.fg('dim', ' ready')
|
|
120
132
|
return symbol + turnText + costText
|
|
121
133
|
}
|
|
@@ -128,9 +140,11 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
128
140
|
function buildPayload(ctx: ExtensionContext): Record<string, unknown> {
|
|
129
141
|
const usage = ctx.getContextUsage() ?? { tokens: null, contextWindow: 0, percent: null }
|
|
130
142
|
const model = ctx.model as { id?: string; name?: string } | undefined
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
143
|
+
// Refresh the cached style name only when a turn boundary may have changed it.
|
|
144
|
+
if (styleDirty) {
|
|
145
|
+
styleName = readActiveStyleName(styleFiles)
|
|
146
|
+
styleDirty = false
|
|
147
|
+
}
|
|
134
148
|
const payload: Record<string, unknown> = {
|
|
135
149
|
hook_event_name: 'Status',
|
|
136
150
|
session_id: ctx.sessionManager.getSessionId(),
|
|
@@ -141,7 +155,7 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
141
155
|
// read .model.display_name and render the literal "null" when it is missing.
|
|
142
156
|
model: { id: model?.id ?? '', display_name: model?.name ?? model?.id ?? '' },
|
|
143
157
|
cost: {
|
|
144
|
-
total_cost_usd:
|
|
158
|
+
total_cost_usd: costTotal,
|
|
145
159
|
total_duration_ms: Date.now() - sessionStartMs,
|
|
146
160
|
total_api_duration_ms: apiDurationMs,
|
|
147
161
|
total_lines_added: linesAdded,
|
|
@@ -250,10 +264,13 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
250
264
|
if (requestStartMs !== undefined) apiDurationMs += Date.now() - requestStartMs
|
|
251
265
|
requestStartMs = undefined
|
|
252
266
|
})
|
|
253
|
-
// The last message's token usage, for the breakdown getContextUsage() omits
|
|
267
|
+
// The last message's token usage, for the breakdown getContextUsage() omits,
|
|
268
|
+
// and the running cost total, so renders never re-walk the branch.
|
|
254
269
|
pi.on('message_end', async (event) => {
|
|
255
|
-
const usage = (event as { message?: { usage?: NonNullable<typeof lastUsage> } }).message?.usage
|
|
256
|
-
if (usage)
|
|
270
|
+
const usage = (event as { message?: { usage?: NonNullable<typeof lastUsage> & { cost?: { total?: number } } } }).message?.usage
|
|
271
|
+
if (!usage) return
|
|
272
|
+
lastUsage = usage
|
|
273
|
+
costTotal += usage.cost?.total ?? 0
|
|
257
274
|
})
|
|
258
275
|
|
|
259
276
|
pi.on('session_start', async (_event, ctx) => {
|
|
@@ -268,11 +285,18 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
268
285
|
requestStartMs = undefined
|
|
269
286
|
lastUsage = undefined
|
|
270
287
|
clearInterval(refreshTimer)
|
|
288
|
+
// Seed the running cost from the branch: a resumed or forked session starts
|
|
289
|
+
// with history, and message_end only accumulates from here on.
|
|
290
|
+
costTotal = sessionCost(ctx)
|
|
271
291
|
// Reading config must never open a trust dialog: several extensions resolve
|
|
272
292
|
// approval at session start, and a second prompt stacks over the first and eats
|
|
273
293
|
// the keys meant for it. An undecided project simply skips project settings.
|
|
274
294
|
const trusted = isProjectApprovedSilently(ctx)
|
|
275
|
-
|
|
295
|
+
// Same gate for the style chain: an unapproved project's style is not applied,
|
|
296
|
+
// so reporting it in the payload would describe a style the session is not using.
|
|
297
|
+
styleFiles = settingsFiles(ctx.cwd, os.homedir(), trusted)
|
|
298
|
+
styleName = readActiveStyleName(styleFiles)
|
|
299
|
+
styleDirty = false
|
|
276
300
|
const files = hookFiles(ctx.cwd, os.homedir(), trusted)
|
|
277
301
|
// Claude's disableAllHooks also turns off the custom statusLine command; the
|
|
278
302
|
// built-in segment still renders as the fallback.
|
|
@@ -286,6 +310,9 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
286
310
|
|
|
287
311
|
pi.on('turn_start', async (_event, ctx) => {
|
|
288
312
|
turnCount++
|
|
313
|
+
// A /output-style between turns lands in settings silently; its style applies
|
|
314
|
+
// from this turn, so this is the moment the cached name can go stale.
|
|
315
|
+
styleDirty = true
|
|
289
316
|
const theme = ctx.ui.theme
|
|
290
317
|
show(ctx, theme.fg('accent', '●') + theme.fg('dim', ` turn ${turnCount}...`))
|
|
291
318
|
})
|
|
@@ -300,10 +327,17 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
300
327
|
scheduleRefresh()
|
|
301
328
|
})
|
|
302
329
|
|
|
303
|
-
pi.on('session_compact', async (_event,
|
|
330
|
+
pi.on('session_compact', async (_event, ctx) => {
|
|
331
|
+
// Compaction replaces the branch entries; reseed the total from what remains.
|
|
332
|
+
costTotal = sessionCost(ctx)
|
|
304
333
|
scheduleRefresh()
|
|
305
334
|
})
|
|
306
335
|
|
|
336
|
+
pi.on('session_tree', async (_event, ctx) => {
|
|
337
|
+
// Tree navigation swaps the branch wholesale with no message_end events.
|
|
338
|
+
costTotal = sessionCost(ctx)
|
|
339
|
+
})
|
|
340
|
+
|
|
307
341
|
pi.on('session_shutdown', async () => {
|
|
308
342
|
clearInterval(refreshTimer)
|
|
309
343
|
clearTimeout(debounceTimer)
|
|
@@ -11,6 +11,7 @@ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works
|
|
|
11
11
|
// Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
|
|
12
12
|
// is not merely ignored, it narrows the child's registry.
|
|
13
13
|
import { parseToolGrants } from '../internal/command-file.js'
|
|
14
|
+
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
14
15
|
import { installedPlugins } from '../internal/plugins.js'
|
|
15
16
|
import { findNearestDir } from '../internal/project-root.js'
|
|
16
17
|
|
|
@@ -291,7 +292,7 @@ function pluginAgentDirs(home: string): string[] {
|
|
|
291
292
|
|
|
292
293
|
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
293
294
|
const userDir = path.join(getAgentDir(), 'agents')
|
|
294
|
-
const claudeUserDir = path.join(os.homedir(), '
|
|
295
|
+
const claudeUserDir = path.join(claudeConfigDir(os.homedir()), 'agents')
|
|
295
296
|
const projectPiDir = findNearestDir(cwd, path.join('.pi', 'agents'))
|
|
296
297
|
const projectClaudeDir = findNearestDir(cwd, path.join('.claude', 'agents'))
|
|
297
298
|
|
|
@@ -24,6 +24,7 @@ import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme,
|
|
|
24
24
|
import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
|
|
25
25
|
import { type Static, Type } from 'typebox'
|
|
26
26
|
import { type AgentRunRequest, setAgentRunner } from '../internal/agent-run.js'
|
|
27
|
+
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
27
28
|
import { capForContext } from '../internal/output-guard.js'
|
|
28
29
|
import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
29
30
|
import { repoRoot } from '../internal/project-root.js'
|
|
@@ -697,7 +698,7 @@ export function agentMemoryDir(scope: AgentMemoryScope, name: string, cwd: strin
|
|
|
697
698
|
const sanitized = name.replace(/[^\w.-]+/g, '_')
|
|
698
699
|
// A name of only dots ('.', '..') survives the character filter but still traverses.
|
|
699
700
|
const segment = /^\.+$/.test(sanitized) ? '_' : sanitized
|
|
700
|
-
if (scope === 'user') return path.join(home, '
|
|
701
|
+
if (scope === 'user') return path.join(claudeConfigDir(home), 'agent-memory', segment)
|
|
701
702
|
const root = repoRoot(cwd) ?? cwd
|
|
702
703
|
return path.join(root, '.claude', scope === 'project' ? 'agent-memory' : 'agent-memory-local', segment)
|
|
703
704
|
}
|
|
@@ -1352,7 +1353,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1352
1353
|
// available model list are captured per session so a hook run lands in the right repo.
|
|
1353
1354
|
let hookCwd = process.cwd()
|
|
1354
1355
|
let hookModels: ReadonlyArray<{ id: string }> = []
|
|
1356
|
+
|
|
1357
|
+
// Discovery walks the plugin cache, the builtin dir, and every agent dir, parsing
|
|
1358
|
+
// each file: dozens of fs ops per call. The roster injection below runs every turn
|
|
1359
|
+
// for a list that almost never changes mid-session, so it reuses one discovery per
|
|
1360
|
+
// (cwd, scope), dropped on session_start. The tool's execute() keeps rediscovering
|
|
1361
|
+
// per invocation, so a just-added agent is still runnable without a restart.
|
|
1362
|
+
let rosterCache: { key: string; agents: AgentConfig[] } | null = null
|
|
1363
|
+
|
|
1355
1364
|
pi.on('session_start', async (_event, ctx) => {
|
|
1365
|
+
rosterCache = null
|
|
1356
1366
|
hookCwd = ctx.cwd
|
|
1357
1367
|
try {
|
|
1358
1368
|
hookModels = ctx.modelRegistry?.getAvailable?.() ?? []
|
|
@@ -1378,12 +1388,15 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1378
1388
|
})
|
|
1379
1389
|
|
|
1380
1390
|
// Claude surfaces each agent's description so the model can pick one autonomously.
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
1391
|
+
// Served from the session-level cache above (keyed on cwd and scope, so an approval
|
|
1392
|
+
// granted mid-session still widens it); project agents are included only when the
|
|
1393
|
+
// project is already approved, read without prompting, since a trust dialog must
|
|
1394
|
+
// not appear mid-turn and their descriptions are project text.
|
|
1384
1395
|
pi.on('before_agent_start', async (event, ctx) => {
|
|
1385
|
-
const scope = isProjectApprovedSilently(ctx) ? 'both' : 'user'
|
|
1386
|
-
const
|
|
1396
|
+
const scope: AgentScope = isProjectApprovedSilently(ctx) ? 'both' : 'user'
|
|
1397
|
+
const key = `${scope}\n${ctx.cwd}`
|
|
1398
|
+
if (rosterCache?.key !== key) rosterCache = { key, agents: discoverAgents(ctx.cwd, scope).agents }
|
|
1399
|
+
const { agents } = rosterCache
|
|
1387
1400
|
if (agents.length === 0) return
|
|
1388
1401
|
const line = (text: string): string => text.replace(/\s+/g, ' ').trim().slice(0, 200)
|
|
1389
1402
|
const roster = agents.map((agent) => `- ${agent.name} (${agent.source}): ${line(agent.description)}`).join('\n')
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thinking Keyword Escalation
|
|
3
|
+
*
|
|
4
|
+
* Claude Code raises reasoning effort for a single turn when the prompt carries a
|
|
5
|
+
* think keyword: `ultrathink` asks for the maximum, `think hard`/`think harder` for
|
|
6
|
+
* a high level, and a bare `think` for a medium one. The word stays in the prompt
|
|
7
|
+
* (the input is observed, never consumed or transformed), the escalation only ever
|
|
8
|
+
* raises the level, and the prior level is restored once the turn settles, mirroring
|
|
9
|
+
* how commands.ts restores a per-command model override on agent_settled.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
13
|
+
|
|
14
|
+
// The pi ThinkingLevel union, taken from the setter's parameter so it tracks the SDK.
|
|
15
|
+
type ThinkingLevel = Parameters<ExtensionAPI['setThinkingLevel']>[0]
|
|
16
|
+
|
|
17
|
+
/** Lowest to highest, matching pi's ThinkingLevel union; rank is the index. */
|
|
18
|
+
const THINKING_ORDER: readonly ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
19
|
+
|
|
20
|
+
export function thinkingRank(level: ThinkingLevel): number {
|
|
21
|
+
const i = THINKING_ORDER.indexOf(level)
|
|
22
|
+
return Math.max(i, 0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The reasoning level a prompt requests through Claude's think keywords, or undefined
|
|
26
|
+
* when it names none. Checked most-specific first so `think harder` does not fall
|
|
27
|
+
* through to the bare-`think` branch, and on word boundaries so `rethink`/`thinking`
|
|
28
|
+
* and the whole word `ultrathink` never trip the bare match. */
|
|
29
|
+
export function requestedThinkingLevel(text: string): ThinkingLevel | undefined {
|
|
30
|
+
if (/\bultrathink\b/i.test(text)) return 'max'
|
|
31
|
+
if (/\bthink harder\b/i.test(text) || /\bthink hard\b/i.test(text)) return 'high'
|
|
32
|
+
if (/\bthink\b/i.test(text)) return 'medium'
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
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
|
|
48
|
+
|
|
49
|
+
pi.on('input', (event, ctx) => {
|
|
50
|
+
// Only genuine user input escalates. sendUserMessage emits an input event with
|
|
51
|
+
// source 'extension' (a subagent prompt, a command body replayed through it); a
|
|
52
|
+
// think keyword the user did not type must not escalate, mirroring hooks.ts's guard.
|
|
53
|
+
if (event.source === 'extension') return
|
|
54
|
+
const target = requestedThinkingLevel(event.text)
|
|
55
|
+
if (!target) return
|
|
56
|
+
const current = pi.getThinkingLevel?.() ?? ctx.thinkingLevel ?? 'off'
|
|
57
|
+
// A keyword only raises reasoning: leave a level already at or above the target.
|
|
58
|
+
if (thinkingRank(current) >= thinkingRank(target)) return
|
|
59
|
+
pendingRestore = pendingRestore ?? current
|
|
60
|
+
pendingTarget = target
|
|
61
|
+
pi.setThinkingLevel?.(target)
|
|
62
|
+
// Return nothing so the input is neither consumed nor transformed: Claude keeps
|
|
63
|
+
// the keyword in the prompt.
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
pi.on('agent_settled', () => {
|
|
67
|
+
if (pendingRestore === undefined) return
|
|
68
|
+
const restore = pendingRestore
|
|
69
|
+
const target = pendingTarget
|
|
70
|
+
pendingRestore = undefined
|
|
71
|
+
pendingTarget = undefined
|
|
72
|
+
// Restore only if nothing else moved the level since this extension set it. If a
|
|
73
|
+
// command's effort restore or the user's manual change already took over (current
|
|
74
|
+
// no longer equals our target), leave that value in place and stand down. When the
|
|
75
|
+
// level cannot be read, restore unconditionally, the prior best-effort behavior.
|
|
76
|
+
const current = pi.getThinkingLevel?.()
|
|
77
|
+
if (current !== undefined && current !== target) return
|
|
78
|
+
pi.setThinkingLevel?.(restore)
|
|
79
|
+
})
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
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",
|