pi-code 1.0.59 → 1.0.61

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.
@@ -111,14 +111,15 @@ export function parseFrontmatter(content: string): Frontmatter {
111
111
  }
112
112
 
113
113
  /**
114
- * Whether a file path matches at least one of a rule's `paths:` globs. `*` stays in
115
- * a segment, `**` crosses directories, a slashless pattern (`*.ts`) matches the
116
- * basename at any depth (gitignore-style), and a trailing slash (`docs/`) scopes to
117
- * that directory's contents. `./` and a leading `/` are stripped so a project-root
118
- * anchored glob resolves the same as a bare one. A bare directory name without a
119
- * trailing slash or `**` matches a file of that name, not the directory's contents;
120
- * write `dir/**` to scope to a directory. `relPath` is the touched file relative to
121
- * the rule set's root.
114
+ * Whether a file path matches at least one of a rule's `paths:` globs. `*` stays in a
115
+ * segment and `**` crosses directories. A slashless pattern (`*.ts`, `README.md`) is
116
+ * the project root, per Claude's table; a leading globstar segment is how a rule says
117
+ * any depth. A trailing slash (`docs/`) scopes to that directory's contents. `./` and
118
+ * a leading `/` are stripped so a project-root anchored glob resolves the same as a
119
+ * bare one. A bare directory name without a trailing slash or a globstar matches a
120
+ * file of that name, not the directory's contents; write `dir` followed by a globstar
121
+ * to scope to a directory. `relPath` is the touched file relative to the rule set's
122
+ * root.
122
123
  */
123
124
  export function pathMatchesGlobs(relPath: string, globs: string[]): boolean {
124
125
  return matchesCompiledGlobs(relPath, compileGlobs(globs))
@@ -188,7 +188,6 @@ function resolveRule(rule: string, anchors: PathAnchors): string {
188
188
  * root-relative path. */
189
189
  export interface CompiledGlob {
190
190
  regex: RegExp
191
- matchesBasename: boolean
192
191
  }
193
192
 
194
193
  let globsCompiled = 0
@@ -219,7 +218,7 @@ export function compileGlobs(globs: string[]): CompiledGlob[] {
219
218
  // would compile to `^docs/$` and match nothing.
220
219
  if (glob.endsWith('/')) glob += '**'
221
220
  globsCompiled += 1
222
- compiled.push({ regex: new RegExp(`^${globToRegExpSource(glob)}$`), matchesBasename: !glob.includes('/') })
221
+ compiled.push({ regex: new RegExp(`^${globToRegExpSource(glob)}$`) })
223
222
  }
224
223
  return compiled
225
224
  }
@@ -229,8 +228,7 @@ export function compileGlobs(globs: string[]): CompiledGlob[] {
229
228
  export function matchesCompiledGlobs(relPath: string, globs: CompiledGlob[]): boolean {
230
229
  globsEvaluated += 1
231
230
  const posix = relPath.split(path.sep).join('/')
232
- const base = posix.split('/').pop() ?? posix
233
- return globs.some((glob) => glob.regex.test(glob.matchesBasename ? base : posix))
231
+ return globs.some((glob) => glob.regex.test(posix))
234
232
  }
235
233
 
236
234
  /** Whether the accessed file matches at least one rule. No rules means no match:
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import * as fs from 'node:fs'
21
+ import * as os from 'node:os'
21
22
  import * as path from 'node:path'
22
23
  import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
23
24
 
@@ -46,11 +47,15 @@ const CLAUDE_SHAPED = [
46
47
  * The walk matters: agent discovery already searches upward, so starting pi in a
47
48
  * subdirectory of a repository whose `.claude/agents` sits at the root found those
48
49
  * agents while a cwd-only check reported nothing to gate, and the short-circuit
49
- * approved the project without ever asking. The bound is the repository root, so a
50
- * directory outside any repository never inherits a parent's config. */
51
- export function hasClaudeShapedConfig(cwd: string): boolean {
50
+ * approved the project without ever asking. The bound is the repository root and the home
51
+ * directory, because `~/.claude` is the user's own configuration: a directory under
52
+ * home that is in no repository would otherwise walk up into it and report the user's
53
+ * own settings as a project waiting to be approved. */
54
+ export function hasClaudeShapedConfig(cwd: string, home: string = os.homedir()): boolean {
52
55
  let currentDir = cwd
53
56
  while (true) {
57
+ // The home check comes first: at home itself the .claude found is the user's own.
58
+ if (currentDir === home) return false
54
59
  if (CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(currentDir, entry)))) return true
55
60
  if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return false
56
61
  const parentDir = path.dirname(currentDir)
@@ -13,18 +13,21 @@
13
13
  import * as fs from 'node:fs'
14
14
  import * as path from 'node:path'
15
15
 
16
- /** Project root markers ending the walk. `.git` is a file in worktrees and submodules. */
17
- export const ROOT_MARKERS = ['.git', 'package.json']
16
+ /** The project root marker. `.git` is a file in worktrees and submodules, a directory
17
+ * in an ordinary clone.
18
+ *
19
+ * `package.json` used to count too, which made every package of a monorepo its own
20
+ * project: its own memory directory, its own settings.local.json, its own
21
+ * CLAUDE_PROJECT_DIR, its own trust decision. Claude's project is the repository, and
22
+ * a repository can add a package.json wherever it likes, so a marker it controls was
23
+ * also a marker it could move. */
24
+ export const ROOT_MARKERS = ['.git']
18
25
 
19
- /** Project root at or above `from`, or undefined when no marker is found. */
26
+ /** Project root at or above `from`, or undefined outside a repository. */
20
27
  export function repoRoot(from: string): string | undefined {
21
- let currentDir = from
22
- while (true) {
23
- if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return currentDir
24
- const parentDir = path.dirname(currentDir)
25
- if (parentDir === currentDir) return undefined
26
- currentDir = parentDir
27
- }
28
+ const root = gitRoot(from)
29
+ if (root === undefined) return undefined
30
+ return mainCheckout(root)
28
31
  }
29
32
 
30
33
  /** The git checkout at or above `from`, or undefined outside one.
@@ -45,6 +48,38 @@ export function gitRoot(from: string): string | undefined {
45
48
  }
46
49
  }
47
50
 
51
+ /** The main checkout for a git directory.
52
+ *
53
+ * A worktree carries a `.git` FILE holding `gitdir: <main>/.git/worktrees/<name>`, so
54
+ * resolving it gives the checkout the repository's state actually belongs to. Claude
55
+ * reads settings.local.json from "the file at the main checkout's root" and shares one
56
+ * auto memory directory across "all worktrees and subdirectories within the same repo",
57
+ * so a worktree is not its own project. An unreadable or unexpected `.git` file leaves
58
+ * the directory as its own root, which is the safe direction. */
59
+ function mainCheckout(root: string): string {
60
+ const dotGit = path.join(root, '.git')
61
+ let pointer: string
62
+ try {
63
+ if (!fs.statSync(dotGit).isFile()) return root
64
+ pointer = fs.readFileSync(dotGit, 'utf-8')
65
+ } catch {
66
+ return root
67
+ }
68
+ // Parsed rather than matched: the file is one `gitdir: <path>` line, and a regex
69
+ // over an arbitrary-length path is a backtracking cost for nothing.
70
+ const [firstLine = ''] = pointer.split('\n')
71
+ const prefix = 'gitdir:'
72
+ if (!firstLine.startsWith(prefix)) return root
73
+ const target = firstLine.slice(prefix.length).trim()
74
+ if (!target) return root
75
+ // <main>/.git/worktrees/<name> -> <main>
76
+ const worktreeDir = path.resolve(root, target)
77
+ const marker = `${path.sep}.git${path.sep}worktrees${path.sep}`
78
+ const cut = worktreeDir.lastIndexOf(marker)
79
+ if (cut === -1) return root
80
+ return worktreeDir.slice(0, cut)
81
+ }
82
+
48
83
  function statOf(target: string): fs.Stats | null {
49
84
  try {
50
85
  return fs.statSync(target)
@@ -13,19 +13,46 @@ import { claudeConfigDir } from './config-dir.js'
13
13
  import { repoRoot } from './project-root.js'
14
14
  import { isRecord } from './values.js'
15
15
 
16
+ /** Whether every path given exists and belongs to the user running this process.
17
+ * A path that is absent is not someone else's, so it does not disqualify the root. */
18
+ function ownedByUser(paths: string[]): boolean {
19
+ const uid = process.getuid?.()
20
+ if (uid === undefined) return true
21
+ return paths.every((target) => {
22
+ try {
23
+ return fs.statSync(target).uid === uid
24
+ } catch {
25
+ return true
26
+ }
27
+ })
28
+ }
29
+
30
+ /** Where `settings.local.json` lives, per Claude's four exceptions: it sits at the
31
+ * repository root, except outside a repository, when that root is the home directory,
32
+ * on Windows, or when the root or its `.git` or `.claude` entry belongs to someone
33
+ * else. In each of those it stays beside `.claude/settings.json` in the working
34
+ * directory instead. In a worktree the root is the main checkout, which repoRoot
35
+ * resolves. */
36
+ function localSettingsDir(cwd: string, home: string, platform: NodeJS.Platform, owned: (paths: string[]) => boolean): string {
37
+ const root = repoRoot(cwd)
38
+ if (root === undefined || root === home) return cwd
39
+ if (platform === 'win32') return cwd
40
+ if (!owned([root, path.join(root, '.git'), path.join(root, '.claude')])) return cwd
41
+ return root
42
+ }
43
+
16
44
  /** The user settings.json, then (only when `includeProject`) the project files by
17
45
  * Claude's placement rules: the shared `.claude/settings.json` is read from the
18
46
  * session's primary working directory (never an ancestor; "to use a file committed
19
47
  * at the repository root, start Claude Code there"), while `settings.local.json`
20
- * lives at the repository root, falling back to the primary directory outside a
21
- * repository or when the root is the home directory. A legacy local file at the
22
- * primary directory is still read, with the root's values winning. Later files win. */
23
- export function claudeSettingsChain(cwd: string, home: string, includeProject: boolean): string[] {
48
+ * lives at the repository root, subject to the exceptions in localSettingsDir. A
49
+ * legacy local file at the primary directory is still read, with the root's values
50
+ * winning. Later files win. */
51
+ export function claudeSettingsChain(cwd: string, home: string, includeProject: boolean, platform: NodeJS.Platform = process.platform, owned: (paths: string[]) => boolean = ownedByUser): string[] {
24
52
  const files = [path.join(claudeConfigDir(home), 'settings.json')]
25
53
  if (!includeProject) return files
26
54
  files.push(path.join(cwd, '.claude', 'settings.json'))
27
- const root = repoRoot(cwd)
28
- const localDir = root !== undefined && root !== home ? root : cwd
55
+ const localDir = localSettingsDir(cwd, home, platform, owned)
29
56
  if (localDir !== cwd) files.push(path.join(cwd, '.claude', 'settings.local.json'))
30
57
  files.push(path.join(localDir, '.claude', 'settings.local.json'))
31
58
  return files
@@ -36,6 +36,7 @@ import { claudeEffortLevel } from './internal/effort.js'
36
36
  import { readManagedSettings } from './internal/managed-settings.js'
37
37
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
38
38
  import { isProjectApprovedSilently } from './internal/project-approval.js'
39
+ import { gitRoot, repoRoot } from './internal/project-root.js'
39
40
  import { readSettingsChain } from './internal/settings-chain.js'
40
41
  import { watchSettingsFiles } from './internal/settings-watch.js'
41
42
  import { readActiveStyleName, settingsFiles } from './output-styles.js'
@@ -43,6 +44,20 @@ import { readActiveStyleName, settingsFiles } from './output-styles.js'
43
44
  const COMMAND_TIMEOUT_MS = 5_000
44
45
  const DEBOUNCE_MS = 300
45
46
 
47
+ /** Whether cwd sits in a git worktree: `.git` is a file pointing at the main
48
+ * checkout there, and a directory in an ordinary clone. */
49
+ function isGitWorktree(cwd: string): boolean {
50
+ // gitRoot, not repoRoot: repoRoot resolves a worktree to its main checkout, which is
51
+ // exactly the thing this needs to see before it is resolved away.
52
+ const root = gitRoot(cwd)
53
+ if (root === undefined) return false
54
+ try {
55
+ return fs.statSync(path.join(root, '.git')).isFile()
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
46
61
  /** Claude sends its CLI version; pi-code's own version is the honest analogue. */
47
62
  const PACKAGE_VERSION = (() => {
48
63
  try {
@@ -235,6 +250,16 @@ export default function statusLine(pi: ExtensionAPI) {
235
250
  ctx.ui.setStatus('pi-code-status', commandLine ?? builtIn)
236
251
  }
237
252
 
253
+ /** The --add-dir directories, the flag pi-code registers for Claude's additional
254
+ * working directories. Empty when none were given, which is the documented shape. */
255
+ function addedDirs(): string[] {
256
+ const raw = String(pi.getFlag?.('add-dir') ?? '')
257
+ return raw
258
+ .split(',')
259
+ .map((dir) => dir.trim())
260
+ .filter(Boolean)
261
+ }
262
+
238
263
  /** The stdin payload per Claude's documented statusline contract. */
239
264
  function buildPayload(ctx: ExtensionContext): Record<string, unknown> {
240
265
  const usage = ctx.getContextUsage() ?? { tokens: null, contextWindow: 0, percent: null }
@@ -249,9 +274,16 @@ export default function statusLine(pi: ExtensionAPI) {
249
274
  session_id: ctx.sessionManager.getSessionId(),
250
275
  cwd: ctx.cwd,
251
276
  version: PACKAGE_VERSION,
252
- // added_dirs is always empty: pi has no /add-dir; the field stays present
253
- // because Claude documents "Empty array if none have been added".
254
- workspace: { current_dir: ctx.cwd, project_dir: ctx.cwd, added_dirs: [] },
277
+ // project_dir is the repository, not the directory the session started in: a
278
+ // script labelling the project showed whichever subdirectory it was launched
279
+ // from. git_worktree reports the .git file a worktree carries in place of a
280
+ // directory. added_dirs comes from the --add-dir flag pi-code registers.
281
+ workspace: {
282
+ current_dir: ctx.cwd,
283
+ project_dir: repoRoot(ctx.cwd) ?? ctx.cwd,
284
+ git_worktree: isGitWorktree(ctx.cwd),
285
+ added_dirs: addedDirs(),
286
+ },
255
287
  // Both fields, per Claude's documented contract: published statusline scripts
256
288
  // read .model.display_name and render the literal "null" when it is missing.
257
289
  model: { id: model?.id ?? '', display_name: model?.name ?? model?.id ?? '' },
@@ -269,17 +301,17 @@ export default function statusLine(pi: ExtensionAPI) {
269
301
  total_input_tokens: usage.tokens,
270
302
  // The per-component breakdown from the last message's usage, which
271
303
  // ctx.getContextUsage() (input-side estimate only) cannot provide.
272
- ...(lastUsage
304
+ // Present before the first response too, as Claude sends them: a script
305
+ // reading current_usage gets null rather than undefined.
306
+ total_output_tokens: lastUsage?.output ?? 0,
307
+ current_usage: lastUsage
273
308
  ? {
274
- total_output_tokens: lastUsage.output,
275
- current_usage: {
276
- input_tokens: lastUsage.input,
277
- output_tokens: lastUsage.output,
278
- cache_read_input_tokens: lastUsage.cacheRead,
279
- cache_creation_input_tokens: lastUsage.cacheWrite,
280
- },
309
+ input_tokens: lastUsage.input,
310
+ output_tokens: lastUsage.output,
311
+ cache_read_input_tokens: lastUsage.cacheRead,
312
+ cache_creation_input_tokens: lastUsage.cacheWrite,
281
313
  }
282
- : {}),
314
+ : null,
283
315
  },
284
316
  // The true combined total when a message usage is known, else the input-side estimate.
285
317
  exceeds_200k_tokens: (lastUsage?.totalTokens ?? usage.tokens ?? 0) > 200_000,
@@ -323,9 +355,17 @@ export default function statusLine(pi: ExtensionAPI) {
323
355
  const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS, undefined, undefined, (kill) => {
324
356
  killInflight = kill
325
357
  })
326
- const first = result.stdout.split('\n')[0].trimEnd()
358
+ // Claude: "Your script can output multiple lines to create a richer display."
359
+ // pi has one row for every extension status and replaces newlines with spaces
360
+ // before rendering it (footer.js sanitizeStatusText), so the rows are joined
361
+ // here rather than dropped; taking only the first silently lost the rest.
362
+ const rows = result.stdout
363
+ .split('\n')
364
+ .map((line) => line.trim())
365
+ .filter(Boolean)
366
+ .join(' ')
327
367
  const pad = ' '.repeat(config.padding)
328
- commandLine = first ? `${pad}${first}${pad}` : undefined
368
+ commandLine = rows ? `${pad}${rows}${pad}` : undefined
329
369
  show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
330
370
  } catch {
331
371
  // A replaced or reloaded session invalidates ctx while the command is in
@@ -463,9 +503,13 @@ export default function statusLine(pi: ExtensionAPI) {
463
503
  // command change re-resolves and re-runs.
464
504
  disposeSettingsWatch()
465
505
  disposeSettingsWatch = watchSettingsFiles(files, () => {
506
+ const previousCommand = config?.command
466
507
  config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
467
508
  armRefresh()
468
- scheduleRefresh()
509
+ // The debounce batches rapid triggers, but a command the user just edited has
510
+ // nothing to batch with: run it now so the result of the edit is immediate.
511
+ if (config?.command !== previousCommand) void runCommand(ctx)
512
+ else scheduleRefresh()
469
513
  })
470
514
  show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
471
515
  scheduleRefresh()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.59",
3
+ "version": "1.0.61",
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, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",