pi-code 1.0.10 → 1.0.11

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.
@@ -3,7 +3,14 @@
3
3
  *
4
4
  * Runs Claude Code's `.claude/settings.json` hooks on pi's lifecycle events, so
5
5
  * a project's existing hooks work under pi:
6
- * - PreToolUse -> pi `tool_call` (can block the tool or rewrite its input)
6
+ * - PreToolUse -> pi `tool_call` (can block the tool or rewrite its input), plus
7
+ * pi `user_bash` for a `!`/`!!` command the user runs directly (the
8
+ * model never issues these, so a deny-list guard would otherwise miss
9
+ * them). No pi tool call exists there, so the payload reports the
10
+ * Claude name "Bash"; a deny hands pi a synthetic failed result so
11
+ * the command never runs. UserBashEvent carries no execution result
12
+ * and fires only before the command runs, so it has no PostToolUse
13
+ * counterpart (pi never delivers the output to observe).
7
14
  * - PostToolUse -> pi `tool_result` (block reasons and additionalContext are
8
15
  * appended next to the tool result, as Claude documents)
9
16
  * - SessionStart -> pi `session_start` (stdout/additionalContext is injected as
@@ -1021,6 +1028,31 @@ export default function hooksExtension(pi: ExtensionAPI) {
1021
1028
  return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
1022
1029
  })
1023
1030
 
1031
+ // Claude's PreToolUse for Bash, extended to a command the user runs directly with the
1032
+ // `!`/`!!` prefix. pi fires user_bash before executing it, and the model never sees it,
1033
+ // so without this a guard that blocks `git push -f` from the model would not stop the
1034
+ // same command typed by hand. There is no pi tool call, so the matcher sees both pi's
1035
+ // "bash" and the Claude name "Bash" (exactly as an MCP alias is bridged) and the payload
1036
+ // reports "Bash", the tool_name a Claude-written PreToolUse Bash hook expects. The
1037
+ // payload carries no tool_use_id (no model tool call produced it). UserBashEventResult
1038
+ // exposes no block flag: a deny is enforced through `result` ("extension handled
1039
+ // execution, use this result"), a synthetic failed BashResult that stands in for the
1040
+ // command so it never runs and its deny reason shows as the output. The event delivers
1041
+ // no execution result and fires only before the command runs, so there is deliberately
1042
+ // no PostToolUse for it.
1043
+ pi.on('user_bash', async (event, ctx) => {
1044
+ const decision = await runPreToolUse(config, 'bash', { command: event.command }, boundRunner(ctx), 'Bash', (message) => ctx.ui.notify(message, 'warning'))
1045
+ if (!decision.block) return undefined
1046
+ // Claude's "ask": prompt before running and let the command through on approval; with
1047
+ // no UI (headless) the block stands, the same safe default as the tool_call path.
1048
+ if (decision.ask && ctx.hasUI) {
1049
+ const approved = await ctx.ui.confirm('Allow this command?', decision.reason ?? 'A hook asks you to confirm this command.')
1050
+ if (approved) return undefined
1051
+ }
1052
+ const reason = decision.reason ?? 'Command blocked by hook'
1053
+ return { result: { output: `Blocked by hook: ${reason}`, exitCode: 1, cancelled: false, truncated: false } }
1054
+ })
1055
+
1024
1056
  pi.on('input', async (event, ctx) => {
1025
1057
  // Only genuine user input; extension-injected messages (plan-mode, subagent) are not
1026
1058
  // prompts the user submitted.
@@ -200,6 +200,13 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
200
200
 
201
201
  pi.registerCommand('output-style', {
202
202
  description: 'Choose the active Claude output style (or /output-style <name>)',
203
+ // /output-style <name> takes a style name, so complete the discovered names by the
204
+ // typed prefix (case-insensitive, like the handler's own name lookup). An empty
205
+ // prefix offers every style.
206
+ getArgumentCompletions: (argumentPrefix) => {
207
+ const prefix = argumentPrefix.trim().toLowerCase()
208
+ return styles.filter((style) => style.name.toLowerCase().startsWith(prefix)).map((style) => ({ value: style.name, label: style.name, ...(style.description ? { description: style.description } : {}) }))
209
+ },
203
210
  handler: async (args, ctx) => {
204
211
  const requested = args.trim()
205
212
  if (requested) {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Session Auto-Title Extension
3
+ *
4
+ * Claude auto-names a new conversation from its first message; this does the same for
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 two ways: setSessionName
7
+ * (the name shown in the session selector) and the terminal window/tab title.
8
+ *
9
+ * 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. The
11
+ * window-title update is the only terminal-specific part, so it is optional-called rather
12
+ * than gated on hasUI. Titling is best-effort throughout: a session that already has a
13
+ * name, a run with no user text (a slash-command-only turn), a headless run with no model,
14
+ * or any provider error leaves the session untitled and never throws.
15
+ *
16
+ * Cost: one model call per session at most. The guard is claimed before the completion so
17
+ * repeated settles cannot each fire a call, and a failed attempt is not retried until a
18
+ * new session resets the guard.
19
+ */
20
+
21
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
22
+
23
+ import { completeText } from './internal/model-complete.js'
24
+
25
+ const TITLE_SYSTEM = 'You name a coding session from its first user message. Reply with a terse 3 to 6 word title in Title Case that captures the task. No quotes, no surrounding punctuation, no trailing period. Output the title only, nothing else.'
26
+ /** A title is a few words; a tight cap keeps the extra call cheap and stops a runaway reply. */
27
+ const TITLE_MAX_TOKENS = 24
28
+ /** The first message can be huge; only its opening is needed to name the session, and a
29
+ * bounded prompt keeps the input cost of the extra call small. */
30
+ const MAX_PROMPT_CHARS = 1000
31
+
32
+ /** Join the text of a message's content, mirroring git-checkpoint's extraction: content is
33
+ * either a plain string or an array of parts, of which only text parts carry a title's worth. */
34
+ function extractText(content: unknown): string {
35
+ if (typeof content === 'string') return content
36
+ if (!Array.isArray(content)) return ''
37
+ return content
38
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
39
+ .map((part) => part.text)
40
+ .join(' ')
41
+ }
42
+
43
+ /** Text of the first user message in the branch, or empty when the run carried no user text
44
+ * (for example a slash-command-only turn), in which case there is nothing to title from. */
45
+ export function firstUserText(ctx: ExtensionContext): string {
46
+ for (const entry of ctx.sessionManager.getBranch()) {
47
+ if (entry?.type === 'message' && entry.message.role === 'user') {
48
+ return extractText(entry.message.content).trim()
49
+ }
50
+ }
51
+ return ''
52
+ }
53
+
54
+ /** Wrapping quotes (straight, smart, and backtick) and trailing sentence punctuation that
55
+ * cleanTitle peels, held as plain strings so the trims below can test membership by index
56
+ * rather than with an anchored regex. */
57
+ const WRAPPING_QUOTES = '"\'`“”‘’'
58
+ const TRAILING_PUNCTUATION = '.,;:!?'
59
+
60
+ /** Strip runs of `chars` from both ends of `value` in linear time. The equivalent
61
+ * /^[chars]+|[chars]+$/g backtracks super-linearly on a long run (S8786). */
62
+ function trimBothEnds(value: string, chars: string): string {
63
+ let start = 0
64
+ let end = value.length
65
+ while (start < end && chars.includes(value[start])) start++
66
+ while (end > start && chars.includes(value[end - 1])) end--
67
+ return value.slice(start, end)
68
+ }
69
+
70
+ /** Strip a trailing run of `chars` from `value` in linear time. The equivalent
71
+ * /[chars]+$/g backtracks super-linearly on a long trailing run (S8786). */
72
+ function trimTrailing(value: string, chars: string): string {
73
+ let end = value.length
74
+ while (end > 0 && chars.includes(value[end - 1])) end--
75
+ return value.slice(0, end)
76
+ }
77
+
78
+ /** Trim the model's reply to a bare title: collapse whitespace, then peel wrapping quotes
79
+ * and trailing punctuation until stable, so `"Fix The Parser."` and `Fix The Parser.` both
80
+ * land on the plain phrase. */
81
+ export function cleanTitle(raw: string): string {
82
+ let title = raw.trim().replace(/\s+/g, ' ')
83
+ let prev: string
84
+ do {
85
+ prev = title
86
+ title = trimTrailing(trimBothEnds(title, WRAPPING_QUOTES), TRAILING_PUNCTUATION).trim()
87
+ } while (title !== prev)
88
+ return title
89
+ }
90
+
91
+ export default function sessionTitleExtension(pi: ExtensionAPI) {
92
+ // One title per session, reset when a new session takes over so a resumed or forked
93
+ // session can still earn its own name.
94
+ let titled = false
95
+
96
+ pi.on('session_start', () => {
97
+ titled = false
98
+ })
99
+
100
+ pi.on('agent_settled', async (_event, ctx) => {
101
+ if (titled) return
102
+ // Never clobber an existing name: a user-chosen or resumed name wins.
103
+ if (pi.getSessionName?.()) return
104
+ const model = ctx.model
105
+ if (!model) return // headless with no model: nothing to name with, and the guard is left unspent
106
+ const prompt = firstUserText(ctx)
107
+ if (!prompt) return // no user text this run: leave the guard unspent for a later real message
108
+
109
+ // Claim the single attempt before the await, so overlapping or repeated settles cannot
110
+ // each fire a model call; a failed attempt below is not retried within this session.
111
+ titled = true
112
+ let title: string
113
+ try {
114
+ const { text } = await completeText(model, `First user message of a new coding session:\n\n${prompt.slice(0, MAX_PROMPT_CHARS)}`, {
115
+ system: TITLE_SYSTEM,
116
+ maxTokens: TITLE_MAX_TOKENS,
117
+ })
118
+ title = cleanTitle(text)
119
+ } catch {
120
+ return // no model, provider error: leave the session untitled (best-effort)
121
+ }
122
+ if (!title) return
123
+ pi.setSessionName(title)
124
+ ctx.ui.setTitle?.(title)
125
+ })
126
+ }
@@ -64,6 +64,66 @@ function formatCost(cost: number): string {
64
64
  return cost >= 0.01 ? `$${cost.toFixed(2)}` : `$${cost.toFixed(4)}`
65
65
  }
66
66
 
67
+ interface RateLimitWindow {
68
+ used_percentage: number
69
+ resets_at?: string
70
+ }
71
+ interface RateLimitSnapshot {
72
+ five_hour?: RateLimitWindow
73
+ seven_day?: RateLimitWindow
74
+ }
75
+
76
+ /** Lowercase every header name (HTTP names are case-insensitive) and drop null
77
+ * values, so a single lookup shape works regardless of how the provider cased
78
+ * them. ProviderHeaders values are string | null. */
79
+ function normalizeHeaders(raw: Record<string, string | null> | undefined): Record<string, string> {
80
+ const out: Record<string, string> = {}
81
+ for (const [key, value] of Object.entries(raw ?? {})) {
82
+ if (typeof value === 'string') out[key.toLowerCase()] = value
83
+ }
84
+ return out
85
+ }
86
+
87
+ function toNumber(value: string | undefined): number | undefined {
88
+ if (value === undefined || value.trim() === '') return undefined
89
+ const parsed = Number(value)
90
+ return Number.isFinite(parsed) ? parsed : undefined
91
+ }
92
+
93
+ /** One rate-limit window from the `anthropic-ratelimit-<prefix>-*` header family,
94
+ * taking a direct `-utilization` percentage when present, else computing it from
95
+ * `-limit` and `-remaining`. resets_at comes from `-reset` when the header is set.
96
+ * Header names vary, so only what is present is read and a bare number is enough. */
97
+ function readRateLimitWindow(headers: Record<string, string>, prefix: string): RateLimitWindow | undefined {
98
+ const base = `anthropic-ratelimit-${prefix}`
99
+ let usedPercentage = toNumber(headers[`${base}-utilization`])
100
+ if (usedPercentage === undefined) {
101
+ const limit = toNumber(headers[`${base}-limit`])
102
+ const remaining = toNumber(headers[`${base}-remaining`])
103
+ if (limit !== undefined && limit > 0 && remaining !== undefined) {
104
+ usedPercentage = ((limit - remaining) / limit) * 100
105
+ }
106
+ }
107
+ if (usedPercentage === undefined) return undefined
108
+ const window: RateLimitWindow = { used_percentage: usedPercentage }
109
+ const resetsAt = headers[`${base}-reset`] ?? headers[`${base}-resets-at`]
110
+ if (resetsAt) window.resets_at = resetsAt
111
+ return window
112
+ }
113
+
114
+ /** The five-hour and seven-day utilization windows Claude's statusline reports,
115
+ * from the unified rate-limit response headers. Undefined when neither is present
116
+ * so a response without them never clobbers an earlier snapshot. */
117
+ function parseRateLimits(headers: Record<string, string>): RateLimitSnapshot | undefined {
118
+ const fiveHour = readRateLimitWindow(headers, 'unified-5h')
119
+ const sevenDay = readRateLimitWindow(headers, 'unified-7d')
120
+ if (!fiveHour && !sevenDay) return undefined
121
+ const snapshot: RateLimitSnapshot = {}
122
+ if (fiveHour) snapshot.five_hour = fiveHour
123
+ if (sevenDay) snapshot.seven_day = sevenDay
124
+ return snapshot
125
+ }
126
+
67
127
  export interface StatusLineConfig {
68
128
  command: string
69
129
  padding: number
@@ -120,6 +180,10 @@ export default function statusLine(pi: ExtensionAPI) {
120
180
  let apiDurationMs = 0
121
181
  let requestStartMs: number | undefined
122
182
  let lastUsage: { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number } | undefined
183
+ // The most recent rate-limit snapshot parsed from provider response headers, and
184
+ // a once-per-session guard so a 429 warns the user only the first time it lands.
185
+ let rateLimits: RateLimitSnapshot | undefined
186
+ let rateLimitWarned = false
123
187
  let refreshTimer: ReturnType<typeof setInterval> | undefined
124
188
  let debounceTimer: ReturnType<typeof setTimeout> | undefined
125
189
  let running = false
@@ -193,6 +257,9 @@ export default function statusLine(pi: ExtensionAPI) {
193
257
  payload.thinking = { enabled: ctx.thinkingLevel !== 'off' }
194
258
  }
195
259
  if (styleName) payload.output_style = { name: styleName }
260
+ // The current utilization of the account's rate-limit windows, when the
261
+ // provider reported them; omitted entirely until a response has carried them.
262
+ if (rateLimits) payload.rate_limits = rateLimits
196
263
  return payload
197
264
  }
198
265
 
@@ -260,9 +327,24 @@ export default function statusLine(pi: ExtensionAPI) {
260
327
  pi.on('before_provider_request', async () => {
261
328
  requestStartMs = Date.now()
262
329
  })
263
- pi.on('after_provider_response', async () => {
330
+ pi.on('after_provider_response', async (event, ctx) => {
264
331
  if (requestStartMs !== undefined) apiDurationMs += Date.now() - requestStartMs
265
332
  requestStartMs = undefined
333
+ // Rate-limit windows and 429 handling ride on the same response event. Header
334
+ // names and presence vary, so parse only what is there and never throw.
335
+ const headers = normalizeHeaders(event.headers)
336
+ const snapshot = parseRateLimits(headers)
337
+ if (snapshot) rateLimits = snapshot
338
+ if (event.status === 429 && !rateLimitWarned) {
339
+ rateLimitWarned = true
340
+ const retryAfter = headers['retry-after']
341
+ const detail = retryAfter ? `; retry after ${retryAfter}s` : ''
342
+ try {
343
+ ctx.ui.notify(`Provider rate limit reached (429)${detail}`, 'warning')
344
+ } catch {
345
+ // The session may be gone by the time a late response lands; nothing to warn.
346
+ }
347
+ }
266
348
  })
267
349
  // The last message's token usage, for the breakdown getContextUsage() omits,
268
350
  // and the running cost total, so renders never re-walk the branch.
@@ -284,6 +366,8 @@ export default function statusLine(pi: ExtensionAPI) {
284
366
  apiDurationMs = 0
285
367
  requestStartMs = undefined
286
368
  lastUsage = undefined
369
+ rateLimits = undefined
370
+ rateLimitWarned = false
287
371
  clearInterval(refreshTimer)
288
372
  // Seed the running cost from the branch: a resumed or forked session starts
289
373
  // with history, and message_end only accumulates from here on.
@@ -327,6 +411,15 @@ export default function statusLine(pi: ExtensionAPI) {
327
411
  scheduleRefresh()
328
412
  })
329
413
 
414
+ // The model and effort segments of the payload go stale between turns; a switch
415
+ // fires these events, so refresh at once instead of waiting for the next tick.
416
+ pi.on('model_select', async () => {
417
+ scheduleRefresh()
418
+ })
419
+ pi.on('thinking_level_select', async () => {
420
+ scheduleRefresh()
421
+ })
422
+
330
423
  pi.on('session_compact', async (_event, ctx) => {
331
424
  // Compaction replaces the branch entries; reseed the total from what remains.
332
425
  costTotal = sessionCost(ctx)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
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",