pi-code 1.0.33 → 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.
@@ -81,6 +81,21 @@ import { fenceMarker, stripBlockComments } from './internal/strip-comments.js'
81
81
 
82
82
  /** Claude documents "a maximum depth of four hops" for recursive imports. */
83
83
  const MAX_IMPORT_DEPTH = 4
84
+
85
+ /** Claude loads a context file (CLAUDE.md and friends) of up to 4 MiB in full and
86
+ * skips a larger one. */
87
+ const CONTEXT_FILE_MAX_BYTES = 4 * 1024 * 1024
88
+
89
+ /** One context file's content, or undefined when it is absent, unreadable, or over
90
+ * the 4 MiB limit Claude documents. */
91
+ function readContextFile(filePath: string): string | undefined {
92
+ try {
93
+ if (fs.statSync(filePath).size > CONTEXT_FILE_MAX_BYTES) return undefined
94
+ return fs.readFileSync(filePath, 'utf-8')
95
+ } catch {
96
+ return undefined
97
+ }
98
+ }
84
99
  export const MAX_IMPORT_FILES = 50
85
100
  export const MAX_IMPORT_BYTES = 256 * 1024
86
101
 
@@ -292,11 +307,8 @@ export function additionalDirContextFiles(dir: string, includeLocal: boolean): A
292
307
  if (includeLocal) candidates.push(path.join(dir, 'CLAUDE.local.md'))
293
308
  const files: Array<{ path: string; content: string }> = []
294
309
  for (const candidate of candidates) {
295
- try {
296
- files.push({ path: candidate, content: fs.readFileSync(candidate, 'utf-8') })
297
- } catch {
298
- // absent or unreadable: treat as not there
299
- }
310
+ const content = readContextFile(candidate)
311
+ if (content !== undefined) files.push({ path: candidate, content })
300
312
  }
301
313
  return files
302
314
  }
@@ -717,12 +729,9 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
717
729
 
718
730
  // ~/.claude/CLAUDE.md, Claude's user-scope memory. The user's own file, so no
719
731
  // project approval is required; a missing file simply leaves it unset.
720
- try {
721
- const userClaudeMd = path.join(claudeConfigDir(os.homedir()), 'CLAUDE.md')
722
- userContext = { path: userClaudeMd, content: fs.readFileSync(userClaudeMd, 'utf-8') }
723
- } catch {
724
- // no user CLAUDE.md
725
- }
732
+ const userClaudeMd = path.join(claudeConfigDir(os.homedir()), 'CLAUDE.md')
733
+ const userContent = readContextFile(userClaudeMd)
734
+ if (userContent !== undefined) userContext = { path: userClaudeMd, content: userContent }
726
735
 
727
736
  // CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
728
737
  // skips it. A cloned repo can ship one, so it is gated like other project config.
@@ -735,18 +744,12 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
735
744
  const dotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
736
745
  if ((candidates.length > 0 || dotClaudeMd !== null) && (await isProjectApproved(ctx))) {
737
746
  for (const candidate of candidates) {
738
- try {
739
- localContexts.push({ path: candidate, content: fs.readFileSync(candidate, 'utf-8') })
740
- } catch {
741
- // unreadable: treat as absent
742
- }
747
+ const content = readContextFile(candidate)
748
+ if (content !== undefined) localContexts.push({ path: candidate, content })
743
749
  }
744
750
  if (dotClaudeMd !== null) {
745
- try {
746
- projectDotClaude = { path: dotClaudeMd, content: fs.readFileSync(dotClaudeMd, 'utf-8') }
747
- } catch {
748
- // unreadable: treat as absent
749
- }
751
+ const content = readContextFile(dotClaudeMd)
752
+ if (content !== undefined) projectDotClaude = { path: dotClaudeMd, content }
750
753
  }
751
754
  }
752
755
  // Read after the local-context flow so an approval it just recorded is honored.
@@ -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
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: Concise
3
+ description: Leads with the result and keeps responses short by default, without cutting the engineering work
4
+ keep-coding-instructions: true
5
+ ---
6
+
7
+ Lead with the result. Skip preamble, narration, and restating the request; answer first, support after.
8
+
9
+ Keep responses short by default while doing the engineering work as thoroughly as ever: brevity applies to the writing, never to the analysis, testing, or care taken.
10
+
11
+ When the user asks for an explanation or more detail, answer in full.
12
+
13
+ Always keep the complete content of error reports, security warnings, and confirmations for destructive actions; never shorten those.
@@ -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
+ }
@@ -198,9 +198,14 @@ export function globCompileStats(): { compiled: number; evaluated: number } {
198
198
  /** Rule `paths:` globs compiled once for repeated matching, with claude-rules'
199
199
  * pathMatchesGlobs semantics: `./` and leading `/` anchors are stripped, a trailing
200
200
  * slash scopes to the directory's contents, and blank entries drop out. */
201
+ /** Claude's shared list budget: rule patterns past ~1000 compiled entries are
202
+ * ignored rather than compiled without bound. */
203
+ const LIST_PATTERN_BUDGET = 1000
204
+
201
205
  export function compileGlobs(globs: string[]): CompiledGlob[] {
202
206
  const compiled: CompiledGlob[] = []
203
207
  for (const raw of globs) {
208
+ if (compiled.length >= LIST_PATTERN_BUDGET) break
204
209
  let glob = raw.trim()
205
210
  if (!glob) continue
206
211
  if (glob.startsWith('./')) glob = glob.slice(2)
@@ -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
@@ -15,6 +15,7 @@ import { StringEnum } from '@earendil-works/pi-ai'
15
15
  import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
16
16
  import { Type } from 'typebox'
17
17
  import { claudeConfigDir } from './internal/config-dir.js'
18
+ import { readManagedSettings } from './internal/managed-settings.js'
18
19
  import { capForContext } from './internal/output-guard.js'
19
20
  import { isProjectApprovedSilently } from './internal/project-approval.js'
20
21
  import { repoRoot } from './internal/project-root.js'
@@ -141,6 +142,18 @@ export function indexWouldOverflow(index: string, name: string, description: str
141
142
  return next.split('\n').length > INDEX_MAX_LINES || Buffer.byteLength(next, 'utf-8') > INDEX_MAX_BYTES
142
143
  }
143
144
 
145
+ /** Where the index stands against the read limits, measured on the loaded content
146
+ * (frontmatter and comments stripped): 'over' past either bound, 'near' within
147
+ * 10% of one, else 'ok'. Claude reminds near a limit and errors over it. */
148
+ export function indexReadState(index: string): 'ok' | 'near' | 'over' {
149
+ const loaded = stripNonLoaded(index)
150
+ const lines = loaded.split('\n').length
151
+ const bytes = Buffer.byteLength(loaded, 'utf-8')
152
+ if (lines > INDEX_MAX_LINES || bytes > INDEX_MAX_BYTES) return 'over'
153
+ if (lines > INDEX_MAX_LINES * 0.9 || bytes > INDEX_MAX_BYTES * 0.9) return 'near'
154
+ return 'ok'
155
+ }
156
+
144
157
  type MemoryToolResult = { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> }
145
158
 
146
159
  /** Write a memory and its index line, or say why it cannot be written. The whole
@@ -156,18 +169,29 @@ async function saveMemory(dir: string, indexPath: string, name: string | undefin
156
169
  }
157
170
  return withFileMutationQueue(indexPath, async (): Promise<MemoryToolResult> => {
158
171
  const index = readIndex(dir)
159
- // Claude reports an explicit error rather than writing a memory the next session
160
- // would never load, and says what to do about it.
161
- if (indexWouldOverflow(index, name, description)) {
172
+ fs.mkdirSync(dir, { recursive: true })
173
+ // A memory with frontmatter records its write time; one without is left as-is.
174
+ fs.writeFileSync(path.join(dir, `${name}.md`), stampModified(content, now))
175
+ const nextIndex = upsertIndexLine(index, name, description)
176
+ writeIndex(indexPath, nextIndex)
177
+ // Claude measures the index after the write: over a read limit the write still
178
+ // succeeds, but an error tells Claude to rewrite the index (everything past
179
+ // the limit is dropped on the next load); near a limit, a reminder to shorten.
180
+ const state = indexReadState(nextIndex)
181
+ if (state === 'over') {
162
182
  return {
163
- content: [{ type: 'text', text: `Memory index is full (${INDEX_MAX_LINES} entries or ${INDEX_MAX_BYTES} bytes). Delete or consolidate memories before saving ${name}.` }],
183
+ content: [
184
+ {
185
+ type: 'text',
186
+ text: `Saved memory ${name}, but the memory index is over its read limit (${INDEX_MAX_LINES} lines / ${INDEX_MAX_BYTES} bytes): rewrite MEMORY.md now. Keep one line per entry, move detail into topic files, and merge or drop stale entries; everything past the limit is dropped on the next load.`,
187
+ },
188
+ ],
164
189
  details: {},
165
190
  }
166
191
  }
167
- fs.mkdirSync(dir, { recursive: true })
168
- // A memory with frontmatter records its write time; one without is left as-is.
169
- fs.writeFileSync(path.join(dir, `${name}.md`), stampModified(content, now))
170
- writeIndex(indexPath, upsertIndexLine(index, name, description))
192
+ if (state === 'near') {
193
+ return { content: [{ type: 'text', text: `Saved memory ${name}. The memory index is near its read limit; shorten it: keep one line per entry, move detail into topic files, and merge or drop stale entries.` }], details: {} }
194
+ }
171
195
  return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
172
196
  })
173
197
  }
@@ -294,8 +318,9 @@ export function memorySettingsFiles(cwd: string, home: string, approved: boolean
294
318
  return claudeSettingsChain(cwd, home, approved)
295
319
  }
296
320
 
297
- /** Merge the two memory settings across the chain, later files winning per key. */
298
- export function readMemorySettings(files: string[]): { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } {
321
+ /** Merge the two memory settings across the chain, later files winning per key;
322
+ * managed policy settings win over every file, per Claude's settings precedence. */
323
+ export function readMemorySettings(files: string[], managed: Record<string, unknown> = readManagedSettings()): { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } {
299
324
  const merged: { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } = {}
300
325
  for (const file of files) {
301
326
  try {
@@ -307,6 +332,8 @@ export function readMemorySettings(files: string[]): { autoMemoryEnabled?: unkno
307
332
  // missing or invalid settings file: skip
308
333
  }
309
334
  }
335
+ if ('autoMemoryEnabled' in managed) merged.autoMemoryEnabled = managed.autoMemoryEnabled
336
+ if ('autoMemoryDirectory' in managed) merged.autoMemoryDirectory = managed.autoMemoryDirectory
310
337
  return merged
311
338
  }
312
339
 
@@ -405,7 +432,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
405
432
  pi.registerTool({
406
433
  name: 'memory',
407
434
  label: 'Memory',
408
- description: 'Persistent memory across sessions. Save durable facts, user preferences, corrections, and project decisions that are not derivable from the code. Actions: save (name + description + content), read (name), delete (name), list.',
435
+ description:
436
+ 'Persistent memory across sessions. Save durable facts, user preferences, corrections, and project decisions that are not derivable from the code. Give each saved memory `type` frontmatter from the documented vocabulary: user (who the user is), feedback (guidance on how to work), project (ongoing work and constraints), or reference (pointers to external resources). Actions: save (name + description + content), read (name), delete (name), list.',
409
437
  parameters: MemoryParams,
410
438
  async execute(_id, params) {
411
439
  if (inSubagent()) {
@@ -492,6 +520,10 @@ export default function memoryExtension(pi: ExtensionAPI) {
492
520
  ` Index: ${path.join(store, INDEX_FILE)}`,
493
521
  ` User memory (CLAUDE.md): ${path.join(home, '.claude', 'CLAUDE.md')}`,
494
522
  ` Project memory (CLAUDE.md): ${path.join(ctx.cwd, 'CLAUDE.md')}`,
523
+ // Claude's /memory lists every documented location, including files that
524
+ // do not exist yet.
525
+ ` Project memory (CLAUDE.local.md): ${path.join(ctx.cwd, 'CLAUDE.local.md')}`,
526
+ ` Project memory (alternate): ${path.join(ctx.cwd, '.claude', 'CLAUDE.md')}`,
495
527
  'Toggle with /memory on or /memory off.',
496
528
  ]
497
529
  ctx.ui.notify(lines.join('\n'), 'info')
@@ -25,6 +25,7 @@ import * as path from 'node:path'
25
25
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
26
26
 
27
27
  import { claudeConfigDir } from './internal/config-dir.js'
28
+ import { readManagedSettings } from './internal/managed-settings.js'
28
29
  import { installedPlugins } from './internal/plugins.js'
29
30
  import { isProjectApproved } from './internal/project-approval.js'
30
31
  import { ancestorDirs, findNearestDir, findNearestFile } from './internal/project-root.js'
@@ -35,6 +36,8 @@ export interface OutputStyle {
35
36
  description: string
36
37
  body: string
37
38
  keepCodingInstructions: boolean
39
+ /** Claude's `force-for-plugin`: a plugin style applying automatically. */
40
+ forceForPlugin: boolean
38
41
  }
39
42
 
40
43
  function field(frontmatter: string, key: string): string {
@@ -47,7 +50,20 @@ export function parseStyle(content: string, fallbackName: string): OutputStyle {
47
50
  const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)
48
51
  const frontmatter = match ? match[1] : ''
49
52
  const body = match ? content.slice(match[0].length) : content
50
- return { name: field(frontmatter, 'name') || fallbackName, description: field(frontmatter, 'description'), body: body.trim(), keepCodingInstructions: field(frontmatter, 'keep-coding-instructions') === 'true' }
53
+ return {
54
+ name: field(frontmatter, 'name') || fallbackName,
55
+ description: field(frontmatter, 'description'),
56
+ body: body.trim(),
57
+ keepCodingInstructions: field(frontmatter, 'keep-coding-instructions') === 'true',
58
+ forceForPlugin: field(frontmatter, 'force-for-plugin') === 'true',
59
+ }
60
+ }
61
+
62
+ /** Claude's `force-for-plugin` (plugin output styles only): the first loaded style
63
+ * carrying it applies automatically, overriding the outputStyle setting. The
64
+ * caller passes only plugin-loaded styles. */
65
+ export function forcedPluginStyle(styles: OutputStyle[]): OutputStyle | undefined {
66
+ return styles.find((style) => style.forceForPlugin)
51
67
  }
52
68
 
53
69
  /** Equivalents of Claude's built-in styles, shipped with pi-code as the
@@ -137,8 +153,10 @@ export function settingsFiles(cwd: string, home: string, trusted: boolean): stri
137
153
  return claudeSettingsChain(cwd, home, trusted)
138
154
  }
139
155
 
140
- /** The `outputStyle` recorded in settings, last file winning. */
141
- export function readActiveStyleName(files: string[]): string | undefined {
156
+ /** The `outputStyle` recorded in settings, last file winning; a managed policy
157
+ * value wins over every file, per Claude's settings precedence. */
158
+ export function readActiveStyleName(files: string[], managed: Record<string, unknown> = readManagedSettings()): string | undefined {
159
+ if (typeof managed.outputStyle === 'string') return managed.outputStyle
142
160
  let name: string | undefined
143
161
  for (const file of files) {
144
162
  try {
@@ -186,7 +204,10 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
186
204
  const nearestLocal = findNearestFile(ctx.cwd, path.join('.claude', 'settings.local.json'))
187
205
  const claudeDir = findNearestDir(ctx.cwd, '.claude') ?? path.join(ctx.cwd, '.claude')
188
206
  localSettingsPath = nearestLocal ?? path.join(claudeDir, 'settings.local.json')
189
- activeName = readActiveStyleName(settingsFiles(ctx.cwd, home, trusted))
207
+ // Claude's force-for-plugin: the first loaded forced plugin style applies
208
+ // automatically, overriding the outputStyle setting.
209
+ const forced = forcedPluginStyle(loadStyles(pluginStyleDirs(home)))
210
+ activeName = forced?.name ?? readActiveStyleName(settingsFiles(ctx.cwd, home, trusted))
190
211
  const active = styleForName(styles, activeName)
191
212
  if (active) ctx.ui.notify(`Output style: ${active.name}`, 'info')
192
213
  })
@@ -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.33",
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",