pi-code 1.0.34 → 1.0.36

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.
@@ -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
@@ -99,8 +99,14 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, p
99
99
  // the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
100
100
  // reference project files regardless of the shell's cwd. CLAUDECODE=1 marks every
101
101
  // subprocess Claude spawns, so it is set on the child unconditionally.
102
- const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1' }
102
+ // CLAUDE_CODE_CHILD_SESSION marks per-call children (hook and status line
103
+ // commands), never long-lived stdio MCP servers, as Claude documents; COLUMNS
104
+ // and LINES carry the terminal dimensions since the script's own width
105
+ // detection cannot see the captured terminal.
106
+ const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1', CLAUDE_CODE_CHILD_SESSION: '1' }
103
107
  if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
108
+ if (process.stdout.columns) env.COLUMNS = String(process.stdout.columns)
109
+ if (process.stdout.rows) env.LINES = String(process.stdout.rows)
104
110
  // An exec-form hook (an `args` array) spawns the executable directly with those args
105
111
  // and no shell, so shell metacharacters in the args arrive literally; $ARGUMENTS in
106
112
  // each arg is replaced with the event JSON by a replacer function (so $$/$& in the
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import * as fs from 'node:fs'
13
+ import * as path from 'node:path'
13
14
 
14
15
  /** The OS managed-settings.json path Claude Code documents per platform. */
15
16
  export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
@@ -31,8 +32,7 @@ export function managedSettingsFile(): string {
31
32
  return managedSettingsFileOverride ?? managedSettingsPath()
32
33
  }
33
34
 
34
- /** The parsed managed settings object, or {} when absent or malformed. */
35
- export function readManagedSettings(file: string = managedSettingsFileOverride ?? managedSettingsPath()): Record<string, unknown> {
35
+ function readOneSettingsFile(file: string): Record<string, unknown> {
36
36
  try {
37
37
  const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
38
38
  if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as Record<string, unknown>
@@ -41,3 +41,38 @@ export function readManagedSettings(file: string = managedSettingsFileOverride ?
41
41
  }
42
42
  return {}
43
43
  }
44
+
45
+ function isRecordValue(value: unknown): value is Record<string, unknown> {
46
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
47
+ }
48
+
49
+ /** Claude's managed-settings.d merge rules: a later single value replaces, lists
50
+ * combine with duplicates removed, and nested blocks merge key by key with each
51
+ * key following these same rules. */
52
+ function mergeManagedKey(base: unknown, next: unknown): unknown {
53
+ if (Array.isArray(base) && Array.isArray(next)) return [...new Set([...base, ...next])]
54
+ if (isRecordValue(base) && isRecordValue(next)) {
55
+ const merged: Record<string, unknown> = { ...base }
56
+ for (const [key, value] of Object.entries(next)) merged[key] = key in merged ? mergeManagedKey(merged[key], value) : value
57
+ return merged
58
+ }
59
+ return next
60
+ }
61
+
62
+ /** The parsed managed settings object, or {} when absent or malformed. Claude also
63
+ * merges an optional managed-settings.d/ directory next to the file: every *.json
64
+ * in alphabetical order after the base file, hidden files and non-json ignored. */
65
+ export function readManagedSettings(file: string = managedSettingsFileOverride ?? managedSettingsPath()): Record<string, unknown> {
66
+ let merged = readOneSettingsFile(file)
67
+ const dropInDir = path.join(path.dirname(file), 'managed-settings.d')
68
+ let entries: string[]
69
+ try {
70
+ entries = fs.readdirSync(dropInDir)
71
+ } catch {
72
+ return merged
73
+ }
74
+ for (const entry of entries.filter((name) => name.endsWith('.json') && !name.startsWith('.')).sort((a, b) => a.localeCompare(b, 'en'))) {
75
+ merged = mergeManagedKey(merged, readOneSettingsFile(path.join(dropInDir, entry))) as Record<string, unknown>
76
+ }
77
+ return merged
78
+ }
@@ -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,26 @@
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
+ /** Watch the given settings files, calling `reload` when any of them changes.
11
+ * Returns a dispose function. The poll interval is env-tunable for tests. */
12
+ export function watchSettingsFiles(files: string[], reload: () => void): () => void {
13
+ const interval = Number(process.env.PI_CODE_SETTINGS_WATCH_INTERVAL_MS) || 2000
14
+ const listeners: Array<[string, (curr: fs.Stats, prev: fs.Stats) => void]> = []
15
+ for (const file of files) {
16
+ const listener = (curr: fs.Stats, prev: fs.Stats): void => {
17
+ if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) reload()
18
+ }
19
+ // persistent: false, so a watcher alone never keeps a one-shot run alive.
20
+ fs.watchFile(file, { interval, persistent: false }, listener)
21
+ listeners.push([file, listener])
22
+ }
23
+ return () => {
24
+ for (const [file, listener] of listeners) fs.unwatchFile(file, listener)
25
+ }
26
+ }
@@ -184,7 +184,9 @@ function helperEnv(name: string, config: HttpServerConfig): NodeJS.ProcessEnv {
184
184
  * env block, and Claude's path variables (CLAUDE_PROJECT_DIR, and CLAUDE_PLUGIN_ROOT
185
185
  * for a plugin's server). */
186
186
  function stdioEnv(config: StdioServerConfig, fill: (value: string) => string, session?: SessionDirs): Record<string, string> {
187
- const env: Record<string, string> = { ...getDefaultEnvironment() }
187
+ // CLAUDECODE marks every subprocess; the long-lived server deliberately gets no
188
+ // CLAUDE_CODE_CHILD_SESSION, which Claude reserves for per-call children.
189
+ const env: Record<string, string> = { ...getDefaultEnvironment(), CLAUDECODE: '1' }
188
190
  for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
189
191
  if (session) env.CLAUDE_PROJECT_DIR = session.projectDir
190
192
  if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
@@ -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
@@ -66,7 +72,8 @@ function formatCost(cost: number): string {
66
72
 
67
73
  interface RateLimitWindow {
68
74
  used_percentage: number
69
- resets_at?: string
75
+ /** Unix epoch seconds when the window resets, per Claude's documented field. */
76
+ resets_at?: number
70
77
  }
71
78
  interface RateLimitSnapshot {
72
79
  five_hour?: RateLimitWindow
@@ -109,10 +116,26 @@ function readRateLimitWindow(headers: Record<string, string>, prefix: string): R
109
116
  // the computed value negative); clamp so the payload never carries a nonsense percentage.
110
117
  const window: RateLimitWindow = { used_percentage: Math.max(0, Math.min(100, usedPercentage)) }
111
118
  const resetsAt = headers[`${base}-reset`] ?? headers[`${base}-resets-at`]
112
- if (resetsAt) window.resets_at = resetsAt
119
+ if (resetsAt) {
120
+ // Claude documents resets_at as Unix epoch seconds; the header carries an ISO
121
+ // timestamp (or, from some providers, a bare epoch number already).
122
+ const epoch = /^\d+$/.test(resetsAt.trim()) ? Number(resetsAt.trim()) : Math.floor(Date.parse(resetsAt) / 1000)
123
+ if (Number.isFinite(epoch)) window.resets_at = epoch
124
+ }
113
125
  return window
114
126
  }
115
127
 
128
+ /** The snapshot with expired windows dropped, so a window whose reset time has
129
+ * passed never lingers in the payload; undefined when nothing remains. */
130
+ function liveRateLimits(snapshot: RateLimitSnapshot): RateLimitSnapshot | undefined {
131
+ const nowSeconds = Date.now() / 1000
132
+ const keep = (window?: RateLimitWindow): RateLimitWindow | undefined => (window && (window.resets_at === undefined || window.resets_at > nowSeconds) ? window : undefined)
133
+ const fiveHour = keep(snapshot.five_hour)
134
+ const sevenDay = keep(snapshot.seven_day)
135
+ if (!fiveHour && !sevenDay) return undefined
136
+ return { ...(fiveHour ? { five_hour: fiveHour } : {}), ...(sevenDay ? { seven_day: sevenDay } : {}) }
137
+ }
138
+
116
139
  /** The five-hour and seven-day utilization windows Claude's statusline reports,
117
140
  * from the unified rate-limit response headers. Undefined when neither is present
118
141
  * so a response without them never clobbers an earlier snapshot. */
@@ -132,22 +155,33 @@ export interface StatusLineConfig {
132
155
  refreshInterval: number | undefined
133
156
  }
134
157
 
135
- /** The `statusLine` recorded in settings, last file winning. Claude's shape is
136
- * `{type: "command", command, padding?, refreshInterval?}`; entries without a
137
- * command string are ignored, and refreshInterval has a documented minimum of 1. */
138
- 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
139
180
  let found: StatusLineConfig | undefined
140
181
  for (const file of files) {
141
182
  try {
142
183
  const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
143
- const entry = settings.statusLine
144
- if (!entry || typeof entry.command !== 'string') continue
145
- if (entry.type !== undefined && entry.type !== 'command') continue
146
- found = {
147
- command: entry.command,
148
- padding: typeof entry.padding === 'number' && entry.padding > 0 ? entry.padding : 0,
149
- refreshInterval: typeof entry.refreshInterval === 'number' && entry.refreshInterval >= 1 ? entry.refreshInterval : undefined,
150
- }
184
+ found = parseStatusLineEntry(settings.statusLine) ?? found
151
185
  } catch {
152
186
  // missing or invalid file: skip
153
187
  }
@@ -188,6 +222,11 @@ export default function statusLine(pi: ExtensionAPI) {
188
222
  let rateLimitWarned = false
189
223
  let refreshTimer: ReturnType<typeof setInterval> | undefined
190
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 = () => {}
191
230
  let running = false
192
231
  let rerunQueued = false
193
232
 
@@ -216,7 +255,9 @@ export default function statusLine(pi: ExtensionAPI) {
216
255
  session_id: ctx.sessionManager.getSessionId(),
217
256
  cwd: ctx.cwd,
218
257
  version: PACKAGE_VERSION,
219
- workspace: { current_dir: ctx.cwd, project_dir: ctx.cwd },
258
+ // added_dirs is always empty: pi has no /add-dir; the field stays present
259
+ // because Claude documents "Empty array if none have been added".
260
+ workspace: { current_dir: ctx.cwd, project_dir: ctx.cwd, added_dirs: [] },
220
261
  // Both fields, per Claude's documented contract: published statusline scripts
221
262
  // read .model.display_name and render the literal "null" when it is missing.
222
263
  model: { id: model?.id ?? '', display_name: model?.name ?? model?.id ?? '' },
@@ -255,19 +296,28 @@ export default function statusLine(pi: ExtensionAPI) {
255
296
  const sessionName = ctx.sessionManager.getSessionName?.()
256
297
  if (sessionName) payload.session_name = sessionName
257
298
  if (ctx.thinkingLevel) {
258
- payload.effort = { level: ctx.thinkingLevel }
299
+ // pi's off/minimal are outside Claude's effort vocabulary: minimal maps to
300
+ // low, and off omits effort entirely (thinking disabled says the rest).
259
301
  payload.thinking = { enabled: ctx.thinkingLevel !== 'off' }
302
+ if (ctx.thinkingLevel !== 'off') payload.effort = { level: ctx.thinkingLevel === 'minimal' ? 'low' : ctx.thinkingLevel }
260
303
  }
261
304
  if (styleName) payload.output_style = { name: styleName }
262
305
  // The current utilization of the account's rate-limit windows, when the
263
- // provider reported them; omitted entirely until a response has carried them.
264
- if (rateLimits) payload.rate_limits = rateLimits
306
+ // provider reported them; omitted until a response has carried them, and an
307
+ // expired window is dropped rather than left stale.
308
+ if (rateLimits) {
309
+ const live = liveRateLimits(rateLimits)
310
+ if (live) payload.rate_limits = live
311
+ }
265
312
  return payload
266
313
  }
267
314
 
268
315
  async function runCommand(ctx: ExtensionContext): Promise<void> {
269
316
  if (!config) return
270
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?.()
271
321
  rerunQueued = true
272
322
  return
273
323
  }
@@ -276,7 +326,9 @@ export default function statusLine(pi: ExtensionAPI) {
276
326
  // Everything below can touch ctx after an await, and every ctx getter throws
277
327
  // once the session is disposed. This promise is started from a timer with no
278
328
  // awaiter, so an escaping rejection becomes an uncaughtException and exits pi.
279
- 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
+ })
280
332
  const first = result.stdout.split('\n')[0].trimEnd()
281
333
  const pad = ' '.repeat(config.padding)
282
334
  commandLine = first ? `${pad}${first}${pad}` : undefined
@@ -285,6 +337,7 @@ export default function statusLine(pi: ExtensionAPI) {
285
337
  // A replaced or reloaded session invalidates ctx while the command is in
286
338
  // flight; there is nothing left to update, and the next session starts fresh.
287
339
  } finally {
340
+ killInflight = undefined
288
341
  running = false
289
342
  if (rerunQueued) {
290
343
  rerunQueued = false
@@ -293,6 +346,18 @@ export default function statusLine(pi: ExtensionAPI) {
293
346
  }
294
347
  }
295
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
+
296
361
  /** Claude debounces statusline updates at 300ms so rapid triggers batch. */
297
362
  function scheduleRefresh(): void {
298
363
  if (!config || !sessionCtx) return
@@ -336,7 +401,10 @@ export default function statusLine(pi: ExtensionAPI) {
336
401
  // names and presence vary, so parse only what is there and never throw.
337
402
  const headers = normalizeHeaders(event.headers)
338
403
  const snapshot = parseRateLimits(headers)
339
- if (snapshot) rateLimits = snapshot
404
+ if (snapshot) {
405
+ rateLimits = snapshot
406
+ scheduleExpiryRefresh(snapshot)
407
+ }
340
408
  if (event.status === 429 && !rateLimitWarned) {
341
409
  rateLimitWarned = true
342
410
  const retryAfter = headers['retry-after']
@@ -355,6 +423,8 @@ export default function statusLine(pi: ExtensionAPI) {
355
423
  if (!usage) return
356
424
  lastUsage = usage
357
425
  costTotal += usage.cost?.total ?? 0
426
+ // Claude re-runs the status line after each assistant message.
427
+ scheduleRefresh()
358
428
  })
359
429
 
360
430
  pi.on('session_start', async (_event, ctx) => {
@@ -390,6 +460,13 @@ export default function statusLine(pi: ExtensionAPI) {
390
460
  if (config?.refreshInterval) {
391
461
  refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
392
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
+ })
393
470
  show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
394
471
  scheduleRefresh()
395
472
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.34",
3
+ "version": "1.0.36",
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",