pi-code 1.0.35 → 1.0.37

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.
@@ -5,8 +5,8 @@
5
5
  * handing `.claude/commands` to pi's prompt-template loader. Owning registration
6
6
  * is what makes the rest of Claude's command contract reachable: `$ARGUMENTS` and
7
7
  * positional substitution, `` !`cmd` `` bash output, `@file` inlining, subdirectory
8
- * commands (registered as `/frontend:build`; current Claude docs name a command by
9
- * file name alone, so the qualified form is a pi-code divergence), and the
8
+ * commands (named by file name alone, as Claude documents; subdirectories only
9
+ * organize the files), and the
10
10
  * `allowed-tools`, `argument-hint` and `model` frontmatter (`model` switches the
11
11
  * session model for the command's run via `pi.setModel`, restored on agent_end).
12
12
  * `shell: powershell` runs a command's injected spans through PowerShell when a
@@ -47,7 +47,7 @@ import { Type } from 'typebox'
47
47
  import { matchesBashRules } from './internal/bash-rules.js'
48
48
  import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
49
49
  import { claudeConfigDir } from './internal/config-dir.js'
50
- import { readManagedSettings } from './internal/managed-settings.js'
50
+ import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
51
51
  import { capForContext } from './internal/output-guard.js'
52
52
  import { matchesPathRules } from './internal/path-rules.js'
53
53
  import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
@@ -115,15 +115,17 @@ function isDirectory(target: string): boolean {
115
115
  }
116
116
  }
117
117
 
118
- /** Existing `.claude/commands` directories, user first then project. The project
119
- * directory is the nearest at or above cwd (bounded at the repository root, matching
120
- * the approval walk) and is included only for approved projects. */
118
+ /** Existing `.claude/commands` directories in Claude's precedence order (later
119
+ * directories win in collectCommands): project first, then personal, then the
120
+ * enterprise directory beside the managed settings file, per "enterprise
121
+ * overrides personal, and personal overrides project". The project directories
122
+ * are included only for approved projects. */
121
123
  export function commandDirs(cwd: string, home: string, trusted: boolean): string[] {
122
- const candidates = [path.join(claudeConfigDir(home), 'commands')]
124
+ const candidates: string[] = []
123
125
  // Claude scans every .claude/commands between cwd and the repository root, the
124
- // nearest winning a name clash: collectCommands lets later directories win, so
125
- // the project list goes root-first with the nearest last.
126
+ // nearest winning an intra-project name clash: root-first with the nearest last.
126
127
  if (trusted) candidates.push(...ancestorDirs(cwd, path.join('.claude', 'commands')).reverse())
128
+ candidates.push(path.join(claudeConfigDir(home), 'commands'), path.join(path.dirname(managedSettingsFile()), '.claude', 'commands'))
127
129
  const dirs: string[] = []
128
130
  for (const dir of candidates) {
129
131
  if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
@@ -270,23 +272,22 @@ export function slashCommandBudget(contextWindow: number | undefined, env: Recor
270
272
  /** The slash_command tool description: usage framing plus the budgeted command
271
273
  * list, each entry `/name - description (argument-hint)`. */
272
274
  export function slashCommandToolDescription(commands: SlashCommandEntry[], budget: number): string {
273
- const lines: string[] = []
274
- let used = 0
275
- let omitted = 0
276
- for (const command of commands) {
275
+ // Claude: "The listing always contains every skill name"; the budget shortens
276
+ // descriptions (later entries lose theirs first), never drops a name, so an
277
+ // omitted-but-invocable command can no longer contradict the listing.
278
+ const lines = commands.map((command) => `/${command.name}`)
279
+ let used = lines.reduce((total, line) => total + line.length + 1, 0)
280
+ for (const [index, command] of commands.entries()) {
277
281
  const hintSuffix = command.argumentHint ? ` (${command.argumentHint})` : ''
278
282
  // when_to_use is model-facing trigger text, appended after the description and before
279
283
  // the argument hint; it shares the per-entry cap and never reaches the user surface.
280
284
  const whenSuffix = command.whenToUse ? ` ${command.whenToUse}` : ''
281
285
  const entry = `/${command.name} - ${command.description}${whenSuffix}${hintSuffix}`.slice(0, ENTRY_CHAR_CAP)
282
- if (used + entry.length + 1 > budget) {
283
- omitted++
284
- continue // a shorter later entry may still fit the remaining budget
285
- }
286
- used += entry.length + 1
287
- lines.push(entry)
286
+ const growth = entry.length - lines[index].length
287
+ if (used + growth > budget) continue
288
+ used += growth
289
+ lines[index] = entry
288
290
  }
289
- if (omitted > 0) lines.push(`(${omitted} more ${omitted === 1 ? 'command was' : 'commands were'} omitted: raise SLASH_COMMAND_TOOL_CHAR_BUDGET to list them)`)
290
291
  return ["Execute a custom slash command on the user's behalf. The command expands to instructions for you to follow in this conversation.", '', 'Available commands:', ...lines].join('\n')
291
292
  }
292
293
 
@@ -101,6 +101,7 @@ import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.
101
101
  import { installedPlugins } from '../internal/plugins.js'
102
102
  import { isProjectApproved } from '../internal/project-approval.js'
103
103
  import { repoRoot } from '../internal/project-root.js'
104
+ import { watchSettingsFiles } from '../internal/settings-watch.js'
104
105
  import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
105
106
  import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
106
107
  import { setSubagentStartHookRunner } from '../internal/subagent-hooks.js'
@@ -207,6 +208,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
207
208
  /** Set inside a subagent child that carries agent-frontmatter hooks: the child's
208
209
  * own agent end fires their SubagentStop, per Claude's Stop conversion. */
209
210
  let agentIdentity: { agent: string; id?: string } | undefined
211
+ /** Claude's allowManagedHooksOnly: only the managed hook set runs. */
212
+ let managedHooksOnly = false
213
+ /** Skill hooks registered this session, re-applied when a settings edit reloads. */
214
+ const registeredSkillHooks: Array<{ skillName: string; hooks: Record<string, unknown> }> = []
215
+ /** Stops the settings watcher of the previous session. */
216
+ let disposeSettingsWatch: () => void = () => {}
210
217
  /** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
211
218
  let hooksDisabled = false
212
219
  /** Which settings file each resolved entry came from, for the /hooks viewer. */
@@ -300,7 +307,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
300
307
  // Claude documents; a session restart reloads config and drops them.
301
308
  pi.events.on(SKILL_HOOKS_CHANNEL, (data) => {
302
309
  if (!isSkillHooksEvent(data)) return
303
- if (hooksDisabled) return
310
+ // Blocked under the escape hatch and under allowManagedHooksOnly, which
311
+ // covers every non-managed hook source.
312
+ if (hooksDisabled || managedHooksOnly) return
313
+ registeredSkillHooks.push({ skillName: data.skillName, hooks: data.hooks })
304
314
  mergeSkillHooks(config, data.skillName, data.hooks, hookSources)
305
315
  })
306
316
 
@@ -376,20 +386,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
376
386
  return results.map((result) => promptContext(result.stdout)).filter(Boolean)
377
387
  })
378
388
 
379
- pi.on('session_start', async (event, ctx) => {
380
- sessionCtx = ctx
381
- // One extension instance serves every session. A mid-turn /new fires session_start on
382
- // the same instance while a Stop-hook continuation streak is in flight; it must not
383
- // carry into the next session, so reset before any early return (disableAllHooks below).
384
- stopHookActive = false
385
- stopHookBlockCount = 0
386
- pendingToolContext.clear()
387
- const trusted = await isProjectApproved(ctx)
388
- // Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
389
- // referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
390
- // subdirectory session too.
391
- projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
392
- const files = hookFiles(ctx.cwd, os.homedir(), trusted)
389
+ /** Resolve the whole hook configuration from disk. Runs at session start and
390
+ * again when the settings watcher sees an edit, so mid-session changes to
391
+ * hooks, disableAllHooks, or allowedHttpHookUrls apply without a restart. */
392
+ function resolveConfig(cwd: string, trusted: boolean): void {
393
+ const files = hookFiles(cwd, os.homedir(), trusted)
393
394
  hookSources.clear()
394
395
  allowedHttpHookUrls = readAllowedHttpHookUrls(files)
395
396
  // The disableAllHooks escape hatch, checked before any config loads. The tiers
@@ -400,15 +401,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
400
401
  hooksDisabled = readDisableAllHooks(files, managedSettings)
401
402
  if (managedSettings.disableAllHooks === true) {
402
403
  config = {}
403
- pendingSessionContext = []
404
404
  return
405
405
  }
406
406
  config = loadManagedHooks(hookSources, managedSettings)
407
- if (readSettingsDisableAllHooks(files)) {
408
- pendingSessionContext = []
409
- return
410
- }
411
- for (const [event, matchers] of Object.entries(loadHooks(files, hookSources))) config[event] = [...(config[event] ?? []), ...matchers]
407
+ // Claude's allowManagedHooksOnly: user, project, local, plugin, and skill
408
+ // hooks are blocked; only the managed set runs.
409
+ managedHooksOnly = managedSettings.allowManagedHooksOnly === true
410
+ if (managedHooksOnly || readSettingsDisableAllHooks(files)) return
411
+ for (const [eventName, matchers] of Object.entries(loadHooks(files, hookSources))) config[eventName] = [...(config[eventName] ?? []), ...matchers]
412
412
  // Plugins are user-installed and enabled by user settings (see installedPlugins),
413
413
  // so a checked-out repo cannot toggle which code-bearing plugin hooks run.
414
414
  loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
@@ -416,6 +416,31 @@ export default function hooksExtension(pi: ExtensionAPI) {
416
416
  // env (Stop already converted to SubagentStop, per Claude); they run only for
417
417
  // this child process.
418
418
  agentIdentity = mergeAgentEnvHooks(config, hookSources)
419
+ // A reload must not drop the skill hooks the session already registered.
420
+ for (const skill of registeredSkillHooks) mergeSkillHooks(config, skill.skillName, skill.hooks, hookSources)
421
+ }
422
+
423
+ pi.on('session_start', async (event, ctx) => {
424
+ sessionCtx = ctx
425
+ // One extension instance serves every session. A mid-turn /new fires session_start on
426
+ // the same instance while a Stop-hook continuation streak is in flight; it must not
427
+ // carry into the next session, so reset before any early return (disableAllHooks below).
428
+ stopHookActive = false
429
+ stopHookBlockCount = 0
430
+ pendingToolContext.clear()
431
+ registeredSkillHooks.length = 0
432
+ const trusted = await isProjectApproved(ctx)
433
+ // Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
434
+ // referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
435
+ // subdirectory session too.
436
+ projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
437
+ resolveConfig(ctx.cwd, trusted)
438
+ // Claude picks up direct settings edits mid-session via a file watcher.
439
+ disposeSettingsWatch()
440
+ disposeSettingsWatch = watchSettingsFiles(hookFiles(ctx.cwd, os.homedir(), trusted), () => resolveConfig(ctx.cwd, trusted))
441
+ // A disabled or managed-only resolution leaves config empty (or managed-only),
442
+ // so the SessionStart run below fires exactly what remains active.
443
+ pendingSessionContext = []
419
444
  // "reload" re-fires in-process with the same conversation and would double-run hooks;
420
445
  // a fork is a genuine session begin, which Claude reports as source "fork".
421
446
  if (event.reason === 'reload') return
@@ -394,9 +394,11 @@ export function substituteVars(text: string, vars: Record<string, string | undef
394
394
  return text.replaceAll(/\$\{(CLAUDE_[A-Z0-9_]+)\}/g, (token, name: string) => vars[name] ?? token)
395
395
  }
396
396
 
397
- /** `a/b/c.md` becomes Claude's `a:b:c`. */
397
+ /** Claude: "You invoke a command file by its file name"; subdirectories organize
398
+ * files without namespacing, so `a/b/c.md` is `/c`. A same-name file in another
399
+ * subdirectory takes the name over (scan order decides), as with Claude. */
398
400
  export function commandNameFor(relativePath: string): string {
399
- return relativePath.replace(/\.md$/, '').split(path.sep).join(':')
401
+ return path.basename(relativePath, '.md')
400
402
  }
401
403
 
402
404
  /** Every `*.md` under a commands directory, including nested ones. */
@@ -9,16 +9,22 @@
9
9
 
10
10
  import * as path from 'node:path'
11
11
  import { claudeConfigDir } from './config-dir.js'
12
- import { findNearestFile } from './project-root.js'
12
+ import { repoRoot } from './project-root.js'
13
13
 
14
- /** The user settings.json, then (only when `includeProject`) the nearest project
15
- * settings.json and settings.local.json at or above cwd, with cwd's own `.claude/` as
16
- * the fallback for each. Later files win. */
14
+ /** The user settings.json, then (only when `includeProject`) the project files by
15
+ * Claude's placement rules: the shared `.claude/settings.json` is read from the
16
+ * session's primary working directory (never an ancestor; "to use a file committed
17
+ * at the repository root, start Claude Code there"), while `settings.local.json`
18
+ * lives at the repository root, falling back to the primary directory outside a
19
+ * repository or when the root is the home directory. A legacy local file at the
20
+ * primary directory is still read, with the root's values winning. Later files win. */
17
21
  export function claudeSettingsChain(cwd: string, home: string, includeProject: boolean): string[] {
18
22
  const files = [path.join(claudeConfigDir(home), 'settings.json')]
19
23
  if (!includeProject) return files
20
- for (const name of ['settings.json', 'settings.local.json']) {
21
- files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
22
- }
24
+ files.push(path.join(cwd, '.claude', 'settings.json'))
25
+ const root = repoRoot(cwd)
26
+ const localDir = root !== undefined && root !== home ? root : cwd
27
+ if (localDir !== cwd) files.push(path.join(cwd, '.claude', 'settings.local.json'))
28
+ files.push(path.join(localDir, '.claude', 'settings.local.json'))
23
29
  return files
24
30
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Mid-session settings watching, Claude's "picked up automatically by the file
3
+ * watcher". Polling stat watchers rather than fs.watch: editors replace files via
4
+ * rename, which event watchers miss on some platforms, and a missing file that
5
+ * appears later must start reporting too, which stat polling handles uniformly.
6
+ */
7
+
8
+ import * as fs from 'node:fs'
9
+
10
+ // Captured at module load: the poll must run on real time even under a test's
11
+ // fake timers (the stat watcher it replaced lived in libuv and was immune too);
12
+ // otherwise fake-timer advances spin the poll and freeze real detection.
13
+ const realSetInterval = globalThis.setInterval
14
+ const realClearInterval = globalThis.clearInterval
15
+
16
+ /** One file's content, or undefined when absent or unreadable. */
17
+ function snapshot(file: string): string | undefined {
18
+ try {
19
+ return fs.readFileSync(file, 'utf-8')
20
+ } catch {
21
+ return undefined
22
+ }
23
+ }
24
+
25
+ /** Watch the given settings files, calling `reload` when any of them changes.
26
+ * Returns a dispose function. The poll compares content, not stats: a same-size
27
+ * rewrite within one timestamp tick is invisible to an mtime comparison on a
28
+ * coarse-granularity filesystem, which made detection flaky. Settings files are
29
+ * small, so re-reading them on the poll is negligible. The interval is
30
+ * env-tunable for tests. */
31
+ export function watchSettingsFiles(files: string[], reload: () => void): () => void {
32
+ const interval = Number(process.env.PI_CODE_SETTINGS_WATCH_INTERVAL_MS) || 2000
33
+ let last = files.map(snapshot)
34
+ const timer = realSetInterval(() => {
35
+ const next = files.map(snapshot)
36
+ if (next.some((content, index) => content !== last[index])) {
37
+ last = next
38
+ reload()
39
+ }
40
+ }, interval)
41
+ // A watcher alone must never keep a one-shot run alive.
42
+ timer.unref?.()
43
+ return () => realClearInterval(timer)
44
+ }
@@ -5,10 +5,14 @@
5
5
  * with the session JSON on stdin (model, workspace, cost, context_window, effort,
6
6
  * output_style, session ids) and its first stdout line becomes the footer segment,
7
7
  * padded per `padding`. It re-runs, debounced 300ms as Claude does, at session
8
- * start, after turns, after compaction, on plan-mode changes (the permission-mode
9
- * analogue, off the shared bus), and on the optional `refreshInterval` timer
10
- * (minimum 1s). A project-defined command is arbitrary shell, so project settings
11
- * count only once the project is already approved, read without prompting.
8
+ * start, after turns and each assistant message, after compaction, on plan-mode
9
+ * changes (the permission-mode analogue, off the shared bus), when a rate-limit
10
+ * window in the last payload reaches its resets_at time, when the statusLine
11
+ * settings change mid-session (file watcher), and on the optional
12
+ * `refreshInterval` timer (minimum 1s). A new trigger while the script is still
13
+ * running cancels the in-flight run, as Claude does. A project-defined command is
14
+ * arbitrary shell, so project settings count only once the project is already
15
+ * approved, read without prompting.
12
16
  * Claude's `disableAllHooks` setting turns the configured command off too, and
13
17
  * the built-in segment stands in.
14
18
  *
@@ -29,8 +33,10 @@ import * as path from 'node:path'
29
33
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
30
34
 
31
35
  import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks/index.js'
36
+ import { readManagedSettings } from './internal/managed-settings.js'
32
37
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
33
38
  import { isProjectApprovedSilently } from './internal/project-approval.js'
39
+ import { watchSettingsFiles } from './internal/settings-watch.js'
34
40
  import { readActiveStyleName, settingsFiles } from './output-styles.js'
35
41
 
36
42
  const COMMAND_TIMEOUT_MS = 5_000
@@ -149,22 +155,33 @@ export interface StatusLineConfig {
149
155
  refreshInterval: number | undefined
150
156
  }
151
157
 
152
- /** The `statusLine` recorded in settings, last file winning. Claude's shape is
153
- * `{type: "command", command, padding?, refreshInterval?}`; entries without a
154
- * command string are ignored, and refreshInterval has a documented minimum of 1. */
155
- export function readStatusLineConfig(files: string[]): StatusLineConfig | undefined {
158
+ /** One settings `statusLine` entry parsed into a config, or undefined when it is
159
+ * not Claude's `{type: "command", command, padding?, refreshInterval?}` shape;
160
+ * refreshInterval has a documented minimum of 1. */
161
+ function parseStatusLineEntry(entry: unknown): StatusLineConfig | undefined {
162
+ if (entry === null || typeof entry !== 'object') return undefined
163
+ const record = entry as { type?: unknown; command?: unknown; padding?: unknown; refreshInterval?: unknown }
164
+ if (typeof record.command !== 'string') return undefined
165
+ if (record.type !== undefined && record.type !== 'command') return undefined
166
+ return {
167
+ command: record.command,
168
+ padding: typeof record.padding === 'number' && record.padding > 0 ? record.padding : 0,
169
+ refreshInterval: typeof record.refreshInterval === 'number' && record.refreshInterval >= 1 ? record.refreshInterval : undefined,
170
+ }
171
+ }
172
+
173
+ /** The `statusLine` recorded in settings, last file winning; a managed policy
174
+ * entry wins over every file, and allowManagedHooksOnly narrows the setting to
175
+ * managed settings entirely, as Claude documents. */
176
+ export function readStatusLineConfig(files: string[], managed: Record<string, unknown> = readManagedSettings()): StatusLineConfig | undefined {
177
+ const managedConfig = parseStatusLineEntry(managed.statusLine)
178
+ if (managedConfig) return managedConfig
179
+ if (managed.allowManagedHooksOnly === true) return undefined
156
180
  let found: StatusLineConfig | undefined
157
181
  for (const file of files) {
158
182
  try {
159
183
  const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
160
- const entry = settings.statusLine
161
- if (!entry || typeof entry.command !== 'string') continue
162
- if (entry.type !== undefined && entry.type !== 'command') continue
163
- found = {
164
- command: entry.command,
165
- padding: typeof entry.padding === 'number' && entry.padding > 0 ? entry.padding : 0,
166
- refreshInterval: typeof entry.refreshInterval === 'number' && entry.refreshInterval >= 1 ? entry.refreshInterval : undefined,
167
- }
184
+ found = parseStatusLineEntry(settings.statusLine) ?? found
168
185
  } catch {
169
186
  // missing or invalid file: skip
170
187
  }
@@ -205,6 +222,11 @@ export default function statusLine(pi: ExtensionAPI) {
205
222
  let rateLimitWarned = false
206
223
  let refreshTimer: ReturnType<typeof setInterval> | undefined
207
224
  let debounceTimer: ReturnType<typeof setTimeout> | undefined
225
+ let expiryTimer: ReturnType<typeof setTimeout> | undefined
226
+ /** Kills the script currently in flight; Claude cancels it on a new trigger. */
227
+ let killInflight: (() => void) | undefined
228
+ /** Stops the settings watcher of the previous session. */
229
+ let disposeSettingsWatch: () => void = () => {}
208
230
  let running = false
209
231
  let rerunQueued = false
210
232
 
@@ -293,6 +315,9 @@ export default function statusLine(pi: ExtensionAPI) {
293
315
  async function runCommand(ctx: ExtensionContext): Promise<void> {
294
316
  if (!config) return
295
317
  if (running) {
318
+ // Claude cancels the in-flight script when a new update triggers; the
319
+ // rerun below then runs the fresh one.
320
+ killInflight?.()
296
321
  rerunQueued = true
297
322
  return
298
323
  }
@@ -301,7 +326,9 @@ export default function statusLine(pi: ExtensionAPI) {
301
326
  // Everything below can touch ctx after an await, and every ctx getter throws
302
327
  // once the session is disposed. This promise is started from a timer with no
303
328
  // awaiter, so an escaping rejection becomes an uncaughtException and exits pi.
304
- const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS)
329
+ const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS, undefined, undefined, (kill) => {
330
+ killInflight = kill
331
+ })
305
332
  const first = result.stdout.split('\n')[0].trimEnd()
306
333
  const pad = ' '.repeat(config.padding)
307
334
  commandLine = first ? `${pad}${first}${pad}` : undefined
@@ -310,6 +337,7 @@ export default function statusLine(pi: ExtensionAPI) {
310
337
  // A replaced or reloaded session invalidates ctx while the command is in
311
338
  // flight; there is nothing left to update, and the next session starts fresh.
312
339
  } finally {
340
+ killInflight = undefined
313
341
  running = false
314
342
  if (rerunQueued) {
315
343
  rerunQueued = false
@@ -318,6 +346,18 @@ export default function statusLine(pi: ExtensionAPI) {
318
346
  }
319
347
  }
320
348
 
349
+ /** Claude re-runs the script when a rate-limit window in the last data reaches
350
+ * its resets_at time, so an expired segment clears without another event. */
351
+ function scheduleExpiryRefresh(snapshot: RateLimitSnapshot): void {
352
+ clearTimeout(expiryTimer)
353
+ const resets = [snapshot.five_hour?.resets_at, snapshot.seven_day?.resets_at].filter((value): value is number => typeof value === 'number')
354
+ if (resets.length === 0) return
355
+ const delayMs = Math.min(...resets) * 1000 - Date.now()
356
+ if (delayMs <= 0) return
357
+ expiryTimer = setTimeout(() => scheduleRefresh(), delayMs)
358
+ expiryTimer.unref?.()
359
+ }
360
+
321
361
  /** Claude debounces statusline updates at 300ms so rapid triggers batch. */
322
362
  function scheduleRefresh(): void {
323
363
  if (!config || !sessionCtx) return
@@ -361,7 +401,10 @@ export default function statusLine(pi: ExtensionAPI) {
361
401
  // names and presence vary, so parse only what is there and never throw.
362
402
  const headers = normalizeHeaders(event.headers)
363
403
  const snapshot = parseRateLimits(headers)
364
- if (snapshot) rateLimits = snapshot
404
+ if (snapshot) {
405
+ rateLimits = snapshot
406
+ scheduleExpiryRefresh(snapshot)
407
+ }
365
408
  if (event.status === 429 && !rateLimitWarned) {
366
409
  rateLimitWarned = true
367
410
  const retryAfter = headers['retry-after']
@@ -380,6 +423,8 @@ export default function statusLine(pi: ExtensionAPI) {
380
423
  if (!usage) return
381
424
  lastUsage = usage
382
425
  costTotal += usage.cost?.total ?? 0
426
+ // Claude re-runs the status line after each assistant message.
427
+ scheduleRefresh()
383
428
  })
384
429
 
385
430
  pi.on('session_start', async (_event, ctx) => {
@@ -415,6 +460,13 @@ export default function statusLine(pi: ExtensionAPI) {
415
460
  if (config?.refreshInterval) {
416
461
  refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
417
462
  }
463
+ // Claude re-runs the script when the statusLine settings change mid-session; a
464
+ // command change re-resolves and re-runs.
465
+ disposeSettingsWatch()
466
+ disposeSettingsWatch = watchSettingsFiles(files, () => {
467
+ config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
468
+ scheduleRefresh()
469
+ })
418
470
  show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
419
471
  scheduleRefresh()
420
472
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.35",
3
+ "version": "1.0.37",
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",