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.
- package/extensions/commands.ts +21 -20
- package/extensions/hooks/index.ts +46 -21
- package/extensions/internal/command-file.ts +4 -2
- package/extensions/internal/settings-chain.ts +13 -7
- package/extensions/internal/settings-watch.ts +44 -0
- package/extensions/status-line.ts +70 -18
- package/package.json +1 -1
package/extensions/commands.ts
CHANGED
|
@@ -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 (
|
|
9
|
-
*
|
|
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
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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 = [
|
|
124
|
+
const candidates: string[] = []
|
|
123
125
|
// Claude scans every .claude/commands between cwd and the repository root, the
|
|
124
|
-
// nearest winning
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
for (const [
|
|
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
|
-
/**
|
|
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
|
|
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 {
|
|
12
|
+
import { repoRoot } from './project-root.js'
|
|
13
13
|
|
|
14
|
-
/** The user settings.json, then (only when `includeProject`) the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
9
|
-
* analogue, off the shared bus),
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
/**
|
|
153
|
-
* `{type: "command", command, padding?, refreshInterval?}
|
|
154
|
-
*
|
|
155
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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",
|