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.
@@ -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, summed from per-message usage on the current branch so it
17
- * stays correct across /tree navigation and forks. The built-in segment is also
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 cost = sessionCost(ctx)
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
- // Same gate as the config read above: an unapproved project's style is not applied,
132
- // so reporting it here would describe a style the session is not using.
133
- const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), projectApproved))
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: sessionCost(ctx),
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) lastUsage = 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
- projectApproved = trusted
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, _ctx) => {
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(), '.claude', 'agents')
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, '.claude', 'agent-memory', segment)
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
- // Rebuilt per turn (agents are rediscovered per invocation too); project agents are
1382
- // included only when the project is already approved, read without prompting, since
1383
- // a trust dialog must not appear mid-turn and their descriptions are project text.
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 { agents } = discoverAgents(ctx.cwd, scope)
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.8",
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",