pi-code 1.0.34 → 1.0.35

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.
@@ -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
+ }
@@ -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
@@ -66,7 +66,8 @@ function formatCost(cost: number): string {
66
66
 
67
67
  interface RateLimitWindow {
68
68
  used_percentage: number
69
- resets_at?: string
69
+ /** Unix epoch seconds when the window resets, per Claude's documented field. */
70
+ resets_at?: number
70
71
  }
71
72
  interface RateLimitSnapshot {
72
73
  five_hour?: RateLimitWindow
@@ -109,10 +110,26 @@ function readRateLimitWindow(headers: Record<string, string>, prefix: string): R
109
110
  // the computed value negative); clamp so the payload never carries a nonsense percentage.
110
111
  const window: RateLimitWindow = { used_percentage: Math.max(0, Math.min(100, usedPercentage)) }
111
112
  const resetsAt = headers[`${base}-reset`] ?? headers[`${base}-resets-at`]
112
- if (resetsAt) window.resets_at = resetsAt
113
+ if (resetsAt) {
114
+ // Claude documents resets_at as Unix epoch seconds; the header carries an ISO
115
+ // timestamp (or, from some providers, a bare epoch number already).
116
+ const epoch = /^\d+$/.test(resetsAt.trim()) ? Number(resetsAt.trim()) : Math.floor(Date.parse(resetsAt) / 1000)
117
+ if (Number.isFinite(epoch)) window.resets_at = epoch
118
+ }
113
119
  return window
114
120
  }
115
121
 
122
+ /** The snapshot with expired windows dropped, so a window whose reset time has
123
+ * passed never lingers in the payload; undefined when nothing remains. */
124
+ function liveRateLimits(snapshot: RateLimitSnapshot): RateLimitSnapshot | undefined {
125
+ const nowSeconds = Date.now() / 1000
126
+ const keep = (window?: RateLimitWindow): RateLimitWindow | undefined => (window && (window.resets_at === undefined || window.resets_at > nowSeconds) ? window : undefined)
127
+ const fiveHour = keep(snapshot.five_hour)
128
+ const sevenDay = keep(snapshot.seven_day)
129
+ if (!fiveHour && !sevenDay) return undefined
130
+ return { ...(fiveHour ? { five_hour: fiveHour } : {}), ...(sevenDay ? { seven_day: sevenDay } : {}) }
131
+ }
132
+
116
133
  /** The five-hour and seven-day utilization windows Claude's statusline reports,
117
134
  * from the unified rate-limit response headers. Undefined when neither is present
118
135
  * so a response without them never clobbers an earlier snapshot. */
@@ -216,7 +233,9 @@ export default function statusLine(pi: ExtensionAPI) {
216
233
  session_id: ctx.sessionManager.getSessionId(),
217
234
  cwd: ctx.cwd,
218
235
  version: PACKAGE_VERSION,
219
- workspace: { current_dir: ctx.cwd, project_dir: ctx.cwd },
236
+ // added_dirs is always empty: pi has no /add-dir; the field stays present
237
+ // because Claude documents "Empty array if none have been added".
238
+ workspace: { current_dir: ctx.cwd, project_dir: ctx.cwd, added_dirs: [] },
220
239
  // Both fields, per Claude's documented contract: published statusline scripts
221
240
  // read .model.display_name and render the literal "null" when it is missing.
222
241
  model: { id: model?.id ?? '', display_name: model?.name ?? model?.id ?? '' },
@@ -255,13 +274,19 @@ export default function statusLine(pi: ExtensionAPI) {
255
274
  const sessionName = ctx.sessionManager.getSessionName?.()
256
275
  if (sessionName) payload.session_name = sessionName
257
276
  if (ctx.thinkingLevel) {
258
- payload.effort = { level: ctx.thinkingLevel }
277
+ // pi's off/minimal are outside Claude's effort vocabulary: minimal maps to
278
+ // low, and off omits effort entirely (thinking disabled says the rest).
259
279
  payload.thinking = { enabled: ctx.thinkingLevel !== 'off' }
280
+ if (ctx.thinkingLevel !== 'off') payload.effort = { level: ctx.thinkingLevel === 'minimal' ? 'low' : ctx.thinkingLevel }
260
281
  }
261
282
  if (styleName) payload.output_style = { name: styleName }
262
283
  // 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
284
+ // provider reported them; omitted until a response has carried them, and an
285
+ // expired window is dropped rather than left stale.
286
+ if (rateLimits) {
287
+ const live = liveRateLimits(rateLimits)
288
+ if (live) payload.rate_limits = live
289
+ }
265
290
  return payload
266
291
  }
267
292
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.34",
3
+ "version": "1.0.35",
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",