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.
- package/extensions/hooks/index.ts +46 -21
- package/extensions/hooks/runners.ts +7 -1
- package/extensions/internal/managed-settings.ts +37 -2
- package/extensions/internal/settings-chain.ts +13 -7
- package/extensions/internal/settings-watch.ts +26 -0
- package/extensions/mcp/transport.ts +3 -1
- package/extensions/status-line.ts +101 -24
- package/package.json +1 -1
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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,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
|
-
|
|
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
|
|
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
|
|
@@ -66,7 +72,8 @@ function formatCost(cost: number): string {
|
|
|
66
72
|
|
|
67
73
|
interface RateLimitWindow {
|
|
68
74
|
used_percentage: number
|
|
69
|
-
|
|
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)
|
|
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
|
-
/**
|
|
136
|
-
* `{type: "command", command, padding?, refreshInterval?}
|
|
137
|
-
*
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
264
|
-
|
|
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)
|
|
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.
|
|
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",
|