pi-code 1.0.66 → 1.0.68
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/git-checkpoint.ts +4 -2
- package/extensions/hooks/runners.ts +10 -1
- package/extensions/internal/command-file.ts +2 -4
- package/extensions/internal/frontmatter.ts +59 -0
- package/extensions/internal/project-approval.ts +7 -0
- package/extensions/internal/project-root.ts +17 -0
- package/extensions/internal/tool-target.ts +28 -3
- package/extensions/memory.ts +14 -6
- package/extensions/output-styles.ts +3 -2
- package/extensions/session-title.ts +6 -4
- package/extensions/skills.ts +37 -8
- package/extensions/status-line.ts +7 -4
- package/extensions/subagent/agents.ts +3 -2
- package/extensions/thinking.ts +3 -1
- package/package.json +1 -1
|
@@ -263,9 +263,11 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
263
263
|
* matches a pathspec through this, so its removal is staged instead of the stale index
|
|
264
264
|
* entry silently riding along into the next checkpoint. */
|
|
265
265
|
async function committedPaths(): Promise<Set<string>> {
|
|
266
|
-
|
|
266
|
+
// -z: without it git quotes and octal-escapes a non-ASCII name, and the quoted form
|
|
267
|
+
// is a pathspec that matches nothing, which fails every later add of the session.
|
|
268
|
+
const listed = await gitShadow(['ls-tree', '-r', '--name-only', '-z', 'HEAD'])
|
|
267
269
|
if (listed.code !== 0) return new Set()
|
|
268
|
-
return new Set(listed.stdout.split('\
|
|
270
|
+
return new Set(listed.stdout.split('\0').filter(Boolean))
|
|
269
271
|
}
|
|
270
272
|
|
|
271
273
|
/** git rejects the whole pathspec when one entry is ignored, so the ignored paths are
|
|
@@ -163,7 +163,16 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, {
|
|
|
163
163
|
}
|
|
164
164
|
// On Windows `detached` means DETACHED_PROCESS, which gives a console child its own
|
|
165
165
|
// window; windowsHide keeps every hook invisible (a no-op elsewhere).
|
|
166
|
-
|
|
166
|
+
// node defers only a few spawn errors to the 'error' event; the rest (E2BIG from a
|
|
167
|
+
// payload past the argv limit) throw here, inside the promise executor, which would
|
|
168
|
+
// reject the runner and lose every sibling hook's verdict for the event.
|
|
169
|
+
let child: ReturnType<typeof spawn>
|
|
170
|
+
try {
|
|
171
|
+
child = spawn(target.file, target.spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, windowsHide: true, env })
|
|
172
|
+
} catch (error) {
|
|
173
|
+
resolve({ code: 0, stdout: '', stderr: errorMessage(error), timedOut: false, spawnFailed: true })
|
|
174
|
+
return
|
|
175
|
+
}
|
|
167
176
|
onChild?.(() => killTree(child))
|
|
168
177
|
let stdout = ''
|
|
169
178
|
let stderr = ''
|
|
@@ -15,10 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
import * as fs from 'node:fs'
|
|
17
17
|
import * as path from 'node:path'
|
|
18
|
-
|
|
19
|
-
import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
20
|
-
|
|
21
18
|
import { CLAUDE_TOOL_MAP } from './claude-tool-names.js'
|
|
19
|
+
import { parseClaudeFrontmatter } from './frontmatter.js'
|
|
22
20
|
import { escapeRegExp } from './values.js'
|
|
23
21
|
|
|
24
22
|
/** The pi file tools a Claude path rule can govern. */
|
|
@@ -291,7 +289,7 @@ export function parseCommandFile(content: string): ParsedCommand {
|
|
|
291
289
|
// pi's own parser, rather than a hand-rolled one: it reads the YAML shapes Claude
|
|
292
290
|
// command files actually use (flow sequences, block lists, quoted and multi-line
|
|
293
291
|
// values), and a value this misreads is a restriction silently not applied.
|
|
294
|
-
const { frontmatter, body: raw } =
|
|
292
|
+
const { frontmatter, body: raw } = parseClaudeFrontmatter(content)
|
|
295
293
|
const body = raw.trim()
|
|
296
294
|
const firstLine = body.split('\n').find((line) => line.trim().length > 0) ?? ''
|
|
297
295
|
const disable = frontmatter['disable-model-invocation']
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontmatter of the markdown files Claude Code defines: agents, commands, skills and
|
|
3
|
+
* output styles.
|
|
4
|
+
*
|
|
5
|
+
* pi's parser is strict YAML. Claude's is not: its own /agents generator and Anthropic's
|
|
6
|
+
* plugins write `description: Use this agent when ... Examples: <example>Context: ...`,
|
|
7
|
+
* and the command reference documents `argument-hint: [pr-number] [priority]`. Strict
|
|
8
|
+
* YAML throws on ": " inside a plain scalar and on a second flow sequence, and a throw
|
|
9
|
+
* costs the whole file, so files that work in Claude Code did not exist here.
|
|
10
|
+
*
|
|
11
|
+
* The strict parse is tried first and kept whenever it succeeds. Only when it throws are
|
|
12
|
+
* the free-text fields quoted and the parse retried. The restriction fields
|
|
13
|
+
* (allowed-tools, tools, model, ...) are never rewritten: a value misread there is a
|
|
14
|
+
* restriction silently not applied, so a file whose restrictions do not parse stays
|
|
15
|
+
* rejected.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
19
|
+
|
|
20
|
+
/** Top-level keys whose value is prose. `name` is an identifier and stays strict. */
|
|
21
|
+
const FREE_TEXT_KEYS = new Set(['description', 'argument-hint', 'when_to_use'])
|
|
22
|
+
|
|
23
|
+
/** A plain scalar, as opposed to a quoted one or a block scalar indicator. */
|
|
24
|
+
const isPlainScalar = (value: string): boolean => value !== '' && !'"\'|>'.includes(value[0])
|
|
25
|
+
|
|
26
|
+
/** `line` with its free-text plain scalar quoted. A JSON string is a YAML double-quoted
|
|
27
|
+
* scalar, and its escaping keeps a literal backslash literal, as a plain scalar does. */
|
|
28
|
+
function quoteFreeText(line: string): string {
|
|
29
|
+
const colon = line.indexOf(':')
|
|
30
|
+
if (colon === -1 || !FREE_TEXT_KEYS.has(line.slice(0, colon))) return line
|
|
31
|
+
const value = line.slice(colon + 1).trim()
|
|
32
|
+
return isPlainScalar(value) ? `${line.slice(0, colon)}: ${JSON.stringify(value)}` : line
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `content` with the free-text fields of its frontmatter block quoted, or undefined
|
|
36
|
+
* when there is no block or nothing in it changed. */
|
|
37
|
+
function withQuotedFreeText(content: string): string | undefined {
|
|
38
|
+
if (!content.startsWith('---')) return undefined
|
|
39
|
+
const start = content.indexOf('\n') + 1
|
|
40
|
+
const end = content.indexOf('\n---', start)
|
|
41
|
+
if (start === 0 || end === -1) return undefined
|
|
42
|
+
const block = content.slice(start, end)
|
|
43
|
+
const quoted = block.split('\n').map(quoteFreeText).join('\n')
|
|
44
|
+
return quoted === block ? undefined : content.slice(0, start) + quoted + content.slice(end)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseClaudeFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string } {
|
|
48
|
+
try {
|
|
49
|
+
return parseFrontmatter<T>(content)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const retry = withQuotedFreeText(content)
|
|
52
|
+
if (retry === undefined) throw error
|
|
53
|
+
try {
|
|
54
|
+
return parseFrontmatter<T>(retry)
|
|
55
|
+
} catch {
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -23,6 +23,7 @@ import * as path from 'node:path'
|
|
|
23
23
|
import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
|
|
24
24
|
|
|
25
25
|
import { ROOT_MARKERS } from './project-root.js'
|
|
26
|
+
import { localSettingsFile } from './settings-chain.js'
|
|
26
27
|
|
|
27
28
|
/** Project files pi-code acts on that pi's own trust check does not look for. */
|
|
28
29
|
const CLAUDE_SHAPED = [
|
|
@@ -52,6 +53,12 @@ const CLAUDE_SHAPED = [
|
|
|
52
53
|
* home that is in no repository would otherwise walk up into it and report the user's
|
|
53
54
|
* own settings as a project waiting to be approved. */
|
|
54
55
|
export function hasClaudeShapedConfig(cwd: string, home: string = os.homedir()): boolean {
|
|
56
|
+
// settings.local.json is read from the main checkout, which a worktree's .git file
|
|
57
|
+
// names and which is a sibling of cwd, never on the walk below. An archive can carry
|
|
58
|
+
// both ends of that pointer, so the file the chain will read is checked where it is.
|
|
59
|
+
// At home the file is the user's own, as the walk also holds.
|
|
60
|
+
const relocated = localSettingsFile(cwd, home)
|
|
61
|
+
if (path.dirname(path.dirname(relocated)) !== home && fs.existsSync(relocated)) return true
|
|
55
62
|
let currentDir = cwd
|
|
56
63
|
while (true) {
|
|
57
64
|
// The home check comes first: at home itself the .claude found is the user's own.
|
|
@@ -77,9 +77,26 @@ function mainCheckout(root: string): string {
|
|
|
77
77
|
const marker = `${path.sep}.git${path.sep}worktrees${path.sep}`
|
|
78
78
|
const cut = worktreeDir.lastIndexOf(marker)
|
|
79
79
|
if (cut === -1) return root
|
|
80
|
+
// The .git file is attacker-writable: an unpacked archive can ship one naming any
|
|
81
|
+
// directory, and following it as text moved the repository root, and with it the
|
|
82
|
+
// CLAUDE.md import boundary, to wherever it pointed.
|
|
83
|
+
if (!pointsBackAt(worktreeDir, dotGit)) return root
|
|
80
84
|
return worktreeDir.slice(0, cut)
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
/** Whether a worktree's admin directory names `dotGit` as its worktree. git writes
|
|
88
|
+
* `<main>/.git/worktrees/<name>/gitdir` holding the path of the worktree's `.git` file
|
|
89
|
+
* (relative to that directory since git 2.48), and nothing outside an archive can be made
|
|
90
|
+
* to. Compared by realpath: git records the real path, a session cwd may be a symlink. */
|
|
91
|
+
function pointsBackAt(worktreeDir: string, dotGit: string): boolean {
|
|
92
|
+
try {
|
|
93
|
+
const [line = ''] = fs.readFileSync(path.join(worktreeDir, 'gitdir'), 'utf-8').split('\n')
|
|
94
|
+
return fs.realpathSync(path.resolve(worktreeDir, line.trim())) === fs.realpathSync(dotGit)
|
|
95
|
+
} catch {
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
function statOf(target: string): fs.Stats | null {
|
|
84
101
|
try {
|
|
85
102
|
return fs.statSync(target)
|
|
@@ -7,16 +7,41 @@
|
|
|
7
7
|
* context-imports and the command path-scope guard all go through it).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import * as os from 'node:os'
|
|
11
|
+
import * as path from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
|
|
10
14
|
/** The tools that name a file pi-code acts on. */
|
|
11
15
|
const FILE_TOOLS: ReadonlySet<string> = new Set(['read', 'edit', 'write'])
|
|
12
16
|
|
|
17
|
+
/** The spaces pi folds to a plain one before resolving (pi dist/utils/paths). */
|
|
18
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g
|
|
19
|
+
|
|
20
|
+
/** `target` as pi's file tools read it before resolving against cwd: a leading @ (which
|
|
21
|
+
* some models add) stripped, ~ expanded, a file:// URL decoded, unicode spaces folded.
|
|
22
|
+
* pi does this in resolveToCwd (dist/core/tools/path-utils), which the package does not
|
|
23
|
+
* export. A reader that skips it judges a different file than the one pi opens: a
|
|
24
|
+
* command scoped to `Read(*.md)` allowed `~/secret/notes.md`, read as <cwd>/~/secret. */
|
|
25
|
+
function asPiReadsIt(target: string): string {
|
|
26
|
+
const folded = target.replace(UNICODE_SPACES, ' ')
|
|
27
|
+
const bare = folded.startsWith('@') ? folded.slice(1) : folded
|
|
28
|
+
if (bare === '~') return os.homedir()
|
|
29
|
+
if (bare.startsWith('~/') || (process.platform === 'win32' && bare.startsWith('~\\'))) return path.join(os.homedir(), bare.slice(2))
|
|
30
|
+
if (!bare.startsWith('file://')) return bare
|
|
31
|
+
try {
|
|
32
|
+
return fileURLToPath(bare)
|
|
33
|
+
} catch {
|
|
34
|
+
return bare
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
13
38
|
/** The path a file tool call named, or undefined for any other tool, an errored call,
|
|
14
|
-
* or a call that named none.
|
|
15
|
-
* against their own cwd. */
|
|
39
|
+
* or a call that named none. Normalised as pi normalises it, and still relative when
|
|
40
|
+
* the model gave a relative path: callers resolve it against their own cwd. */
|
|
16
41
|
export function fileToolTarget(event: { toolName: string; input?: unknown; isError?: boolean }): string | undefined {
|
|
17
42
|
if (event.isError === true) return undefined
|
|
18
43
|
if (!FILE_TOOLS.has(event.toolName)) return undefined
|
|
19
44
|
const input = event.input as { path?: unknown; file_path?: unknown } | undefined
|
|
20
45
|
const target = typeof input?.path === 'string' ? input.path : input?.file_path
|
|
21
|
-
return typeof target === 'string' && target.length > 0 ? target : undefined
|
|
46
|
+
return typeof target === 'string' && target.length > 0 ? asPiReadsIt(target) : undefined
|
|
22
47
|
}
|
package/extensions/memory.ts
CHANGED
|
@@ -277,14 +277,22 @@ export function capIndexForPrompt(index: string): string {
|
|
|
277
277
|
return `${text}\n(${dropped} more memories not shown; use the memory tool with action "list")`
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/** A memory's file name. Letters and digits of any script survive: an ASCII-only slug
|
|
281
|
+
* reduced every non-Latin name to one fallback, so distinct memories overwrote each
|
|
282
|
+
* other. `memory` is never produced, because on a case-insensitive filesystem memory.md
|
|
283
|
+
* is the MEMORY.md index, and writing one destroys the other. Cut by code point, so a
|
|
284
|
+
* character outside the basic plane is never split. */
|
|
280
285
|
export function slugifyName(name: string): string {
|
|
281
|
-
|
|
282
|
-
name
|
|
286
|
+
const slug = [
|
|
287
|
+
...name
|
|
283
288
|
.toLowerCase()
|
|
284
|
-
.replaceAll(/[
|
|
285
|
-
.replaceAll(/^-|-$/g, '')
|
|
286
|
-
|
|
287
|
-
|
|
289
|
+
.replaceAll(/[^\p{L}\p{N}]+/gu, '-')
|
|
290
|
+
.replaceAll(/^-|-$/g, ''),
|
|
291
|
+
]
|
|
292
|
+
.slice(0, 64)
|
|
293
|
+
.join('')
|
|
294
|
+
if (slug === '') return 'untitled'
|
|
295
|
+
return slug === 'memory' ? 'memory-note' : slug
|
|
288
296
|
}
|
|
289
297
|
|
|
290
298
|
/** The exact key prefix of a memory's index line; matching on a substring would also
|
|
@@ -22,10 +22,11 @@
|
|
|
22
22
|
import * as fs from 'node:fs'
|
|
23
23
|
import * as os from 'node:os'
|
|
24
24
|
import * as path from 'node:path'
|
|
25
|
-
import {
|
|
25
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
26
26
|
import { atomicWriteFile } from './internal/atomic-write.js'
|
|
27
27
|
import { isFlagEnabled } from './internal/command-file.js'
|
|
28
28
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
29
|
+
import { parseClaudeFrontmatter } from './internal/frontmatter.js'
|
|
29
30
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
30
31
|
import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
31
32
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
@@ -54,7 +55,7 @@ function field(frontmatter: Record<string, unknown>, key: string): string {
|
|
|
54
55
|
* own YAML frontmatter parser, as the command loader uses: a line regex captured to
|
|
55
56
|
* end of line, so `"Terse" # short` came back as `Terse" # short`. */
|
|
56
57
|
export function parseStyle(content: string, fallbackName: string): OutputStyle {
|
|
57
|
-
const { frontmatter, body } =
|
|
58
|
+
const { frontmatter, body } = parseClaudeFrontmatter(content)
|
|
58
59
|
return {
|
|
59
60
|
name: field(frontmatter, 'name') || fallbackName,
|
|
60
61
|
description: field(frontmatter, 'description'),
|
|
@@ -121,12 +121,14 @@ export default function sessionTitleExtension(pi: ExtensionAPI) {
|
|
|
121
121
|
return // no model, provider error: leave the session untitled (best-effort)
|
|
122
122
|
}
|
|
123
123
|
if (!title) return
|
|
124
|
-
// A /new during the await moved us to a different session, or a name has since been set;
|
|
125
|
-
// applying this title now would rename the wrong session, so drop it.
|
|
126
|
-
if (generation !== startedGeneration || pi.getSessionName?.()) return
|
|
127
124
|
// Post-await ctx getters throw once the session is disposed, and an escaping rejection
|
|
128
|
-
// from this un-awaited settle can exit pi; apply the title best-effort.
|
|
125
|
+
// from this un-awaited settle can exit pi; apply the title best-effort. The name read
|
|
126
|
+
// belongs inside the guard too: pi's CLI never delivers the next session_start to this
|
|
127
|
+
// instance, so only that throw reveals a session replaced during the call.
|
|
129
128
|
try {
|
|
129
|
+
// A /new during the await moved us to a different session, or a name has since been
|
|
130
|
+
// set; applying this title now would rename the wrong session, so drop it.
|
|
131
|
+
if (generation !== startedGeneration || pi.getSessionName?.()) return
|
|
130
132
|
// pi.setSessionName refreshes the terminal/tab title natively (pi changelog), so a
|
|
131
133
|
// separate ctx.ui.setTitle call would only duplicate that. The guard stays: a
|
|
132
134
|
// disposed session throws from setSessionName post-await, and an escaping rejection
|
package/extensions/skills.ts
CHANGED
|
@@ -22,11 +22,12 @@
|
|
|
22
22
|
import * as fs from 'node:fs'
|
|
23
23
|
import * as os from 'node:os'
|
|
24
24
|
import * as path from 'node:path'
|
|
25
|
-
import {
|
|
25
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
26
26
|
import { expandCommand, shellExecutionDisabled } from './commands.js'
|
|
27
27
|
import { runAgent } from './internal/agent-run.js'
|
|
28
28
|
import { parseCommandFile } from './internal/command-file.js'
|
|
29
29
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
30
|
+
import { parseClaudeFrontmatter } from './internal/frontmatter.js'
|
|
30
31
|
import { managedSettingsFile } from './internal/managed-settings.js'
|
|
31
32
|
import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
32
33
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
@@ -103,7 +104,7 @@ function skillAt(root: string, dirName: string): { name: string; filePath: strin
|
|
|
103
104
|
}
|
|
104
105
|
let name = dirName
|
|
105
106
|
try {
|
|
106
|
-
const declared =
|
|
107
|
+
const declared = parseClaudeFrontmatter<Record<string, unknown>>(content).frontmatter.name
|
|
107
108
|
if (typeof declared === 'string' && declared.trim()) name = declared.trim()
|
|
108
109
|
} catch {
|
|
109
110
|
// Malformed frontmatter: pi's loader falls back to the directory name too.
|
|
@@ -166,11 +167,37 @@ function refusedByOverride(name: string, ctx: ExtensionContext, trusted: boolean
|
|
|
166
167
|
return { action: 'handled' }
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
/** Forked skills still running in the background, by name. */
|
|
171
|
+
const runningForks = new Set<string>()
|
|
172
|
+
|
|
173
|
+
/** Whether a context: fork skill holds the invoking turn for its result. Claude: "The
|
|
174
|
+
* forked subagent runs in the background: you keep working while it runs, and its result
|
|
175
|
+
* arrives in your conversation when it completes. Set background: false in the frontmatter
|
|
176
|
+
* to instead wait for the result in the turn that invoked the skill." It also waits in
|
|
177
|
+
* non-interactive mode, when CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is 1, and "when you
|
|
178
|
+
* invoke a forked skill while an earlier invocation of the same skill is still running". */
|
|
179
|
+
function forkWaits(name: string, frontmatter: Record<string, unknown>, ctx: ExtensionContext): boolean {
|
|
180
|
+
if (String(frontmatter.background).trim().toLowerCase() === 'false') return true
|
|
181
|
+
return !ctx.hasUI || process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS === '1' || runningForks.has(name)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Start a forked skill in the background and hand its result to the conversation when it
|
|
185
|
+
* completes. The promise has no awaiter, and sendMessage on a pi whose session was
|
|
186
|
+
* replaced meanwhile throws: the catch keeps that from exiting pi, at the cost of the
|
|
187
|
+
* delivery, as a background subagent run's completion notice is lost the same way. */
|
|
188
|
+
function startForkedSkill(pi: ExtensionAPI, ctx: ExtensionContext, fork: { name: string; filePath: string; expanded: string; agentName: string | undefined }): { action: 'handled' } {
|
|
189
|
+
runningForks.add(fork.name)
|
|
190
|
+
ctx.ui.notify(`Skill ${fork.name} is running in a forked subagent in the background; its result arrives here when it completes.`, 'info')
|
|
191
|
+
void runForkedSkill(fork.name, fork.filePath, fork.expanded, fork.agentName)
|
|
192
|
+
.then((result) => pi.sendMessage({ customType: 'skill-fork', content: result.text, display: true }, { triggerTurn: true }))
|
|
193
|
+
.catch(() => {})
|
|
194
|
+
.finally(() => runningForks.delete(fork.name))
|
|
195
|
+
return { action: 'handled' }
|
|
196
|
+
}
|
|
197
|
+
|
|
169
198
|
/** Claude's context: fork run: the expanded skill content becomes the prompt that
|
|
170
|
-
* drives a subagent, without the conversation history.
|
|
171
|
-
*
|
|
172
|
-
* turn (Claude's background: false behavior, which is also what Claude itself
|
|
173
|
-
* does in -p and SDK runs). */
|
|
199
|
+
* drives a subagent, without the conversation history. Never rejects: a failed run
|
|
200
|
+
* is reported in the returned block. */
|
|
174
201
|
async function runForkedSkill(name: string, filePath: string, expanded: string, agentName: string | undefined): Promise<{ action: 'transform'; text: string }> {
|
|
175
202
|
try {
|
|
176
203
|
const output = await runAgent({ prompt: expanded, fullTools: true, ...(agentName ? { agent: agentName } : {}) })
|
|
@@ -210,14 +237,16 @@ async function expandSkillInvocation(pi: ExtensionAPI, rawText: string, ctx: Ext
|
|
|
210
237
|
// Claude registers hooks a skill's frontmatter declares when the skill is
|
|
211
238
|
// invoked, for the rest of the session; the hooks extension owns running them,
|
|
212
239
|
// so the declaration is announced over the shared bus.
|
|
213
|
-
const frontmatter =
|
|
240
|
+
const frontmatter = parseClaudeFrontmatter<Record<string, unknown>>(content).frontmatter
|
|
214
241
|
const declaredHooks = frontmatter.hooks
|
|
215
242
|
if (declaredHooks !== null && typeof declaredHooks === 'object' && !Array.isArray(declaredHooks)) {
|
|
216
243
|
pi.events?.emit(SKILL_HOOKS_CHANNEL, { skillName: name, hooks: declaredHooks })
|
|
217
244
|
}
|
|
218
245
|
const expanded = await expandCommand(pi, parsed, args, { cwd: ctx.cwd }, found.filePath, undefined, { allowShell: !shellExecutionDisabled(ctx.cwd, os.homedir(), trusted) })
|
|
219
246
|
if (typeof frontmatter.context === 'string' && frontmatter.context.trim().toLowerCase() === 'fork') {
|
|
220
|
-
|
|
247
|
+
const agentName = typeof frontmatter.agent === 'string' ? frontmatter.agent.trim() : undefined
|
|
248
|
+
if (forkWaits(name, frontmatter, ctx)) return runForkedSkill(name, found.filePath, expanded, agentName)
|
|
249
|
+
return startForkedSkill(pi, ctx, { name, filePath: found.filePath, expanded, agentName })
|
|
221
250
|
}
|
|
222
251
|
return { action: 'transform', text: `<skill name="${name}" location="${found.filePath}">\nReferences are relative to ${found.baseDir}.\n\n${expanded}\n</skill>` }
|
|
223
252
|
}
|
|
@@ -114,12 +114,15 @@ function toNumber(value: string | undefined): number | undefined {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/** One rate-limit window from the `anthropic-ratelimit-<prefix>-*` header family,
|
|
117
|
-
* taking
|
|
118
|
-
* `-
|
|
119
|
-
*
|
|
117
|
+
* taking `-utilization` when present, else computing the percentage from `-limit` and
|
|
118
|
+
* `-remaining`. `-utilization` is a 0 to 1 fraction, while Claude documents
|
|
119
|
+
* used_percentage as "from 0 to 100": it is scaled and rounded to one decimal, as
|
|
120
|
+
* Claude Code does (Math.round(utilization * 1000) / 10). resets_at comes from `-reset`
|
|
121
|
+
* when the header is set. Header names vary, so only what is present is read. */
|
|
120
122
|
function readRateLimitWindow(headers: Record<string, string>, prefix: string): RateLimitWindow | undefined {
|
|
121
123
|
const base = `anthropic-ratelimit-${prefix}`
|
|
122
|
-
|
|
124
|
+
const utilization = toNumber(headers[`${base}-utilization`])
|
|
125
|
+
let usedPercentage = utilization === undefined ? undefined : Math.round(utilization * 1000) / 10
|
|
123
126
|
if (usedPercentage === undefined) {
|
|
124
127
|
const limit = toNumber(headers[`${base}-limit`])
|
|
125
128
|
const remaining = toNumber(headers[`${base}-remaining`])
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
import * as fs from 'node:fs'
|
|
6
6
|
import * as os from 'node:os'
|
|
7
7
|
import * as path from 'node:path'
|
|
8
|
-
import { getAgentDir,
|
|
8
|
+
import { getAgentDir, stripFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
9
9
|
// The same mapping a command's `allowed-tools` gets: an agent's `tools:` is the same
|
|
10
10
|
// Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
|
|
11
11
|
// is not merely ignored, it narrows the child's registry.
|
|
12
12
|
import { parseToolGrants } from '../internal/command-file.js'
|
|
13
13
|
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
14
|
+
import { parseClaudeFrontmatter } from '../internal/frontmatter.js'
|
|
14
15
|
import { findModel } from '../internal/model-lookup.js'
|
|
15
16
|
import { installedPlugins, pluginComponentPath } from '../internal/plugins.js'
|
|
16
17
|
import { ancestorDirs, findNearestDir } from '../internal/project-root.js'
|
|
@@ -176,7 +177,7 @@ function agentName(frontmatter: Record<string, unknown>, filePath: string, plugi
|
|
|
176
177
|
function parseAgentFile(content: string, source: AgentSource, filePath: string, pluginName?: string): AgentConfig | null {
|
|
177
178
|
let parsed: { frontmatter: Record<string, unknown>; body: string }
|
|
178
179
|
try {
|
|
179
|
-
parsed =
|
|
180
|
+
parsed = parseClaudeFrontmatter<Record<string, unknown>>(content)
|
|
180
181
|
} catch (error) {
|
|
181
182
|
// Malformed YAML must not abort discovery for the whole directory, but a silent drop
|
|
182
183
|
// reads as "that agent does not exist", so it is named like the other rejections here.
|
package/extensions/thinking.ts
CHANGED
|
@@ -83,8 +83,10 @@ export default function thinkingExtension(pi: ExtensionAPI) {
|
|
|
83
83
|
const current = pi.getThinkingLevel?.() ?? ctx.thinkingLevel ?? 'off'
|
|
84
84
|
// A keyword only raises reasoning: leave a level already at or above the target.
|
|
85
85
|
if (thinkingRank(current) >= thinkingRank(target)) return
|
|
86
|
-
|
|
86
|
+
// Armed with the level pi actually stored: it clamps to what the model supports, and
|
|
87
|
+
// an override armed with the level asked for never recognises its own escalation.
|
|
87
88
|
pi.setThinkingLevel?.(target)
|
|
89
|
+
override.arm(current, pi.getThinkingLevel?.() ?? target)
|
|
88
90
|
// Return nothing so the input is neither consumed nor transformed: Claude keeps
|
|
89
91
|
// the keyword in the prompt.
|
|
90
92
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.68",
|
|
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",
|