pi-code 1.0.35 → 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
@@ -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
+ }
@@ -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.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",