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.
- package/extensions/claude-rules.ts +9 -8
- package/extensions/internal/path-rules.ts +2 -4
- package/extensions/internal/project-approval.ts +8 -3
- package/extensions/internal/project-root.ts +45 -10
- package/extensions/internal/settings-chain.ts +33 -6
- package/extensions/status-line.ts +59 -15
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* that directory's contents. `./` and
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* the rule set's
|
|
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)}$`)
|
|
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
|
-
|
|
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
|
|
50
|
-
* directory
|
|
51
|
-
|
|
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
|
-
/**
|
|
17
|
-
|
|
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
|
|
26
|
+
/** Project root at or above `from`, or undefined outside a repository. */
|
|
20
27
|
export function repoRoot(from: string): string | undefined {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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",
|