pi-code 1.0.63 → 1.0.65
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/README.md +1 -1
- package/extensions/commands.ts +9 -6
- package/extensions/context-imports.ts +3 -3
- package/extensions/env-settings.ts +5 -2
- package/extensions/git-checkpoint.ts +137 -21
- package/extensions/hooks/config.ts +16 -10
- package/extensions/hooks/decisions.ts +9 -9
- package/extensions/hooks/index.ts +22 -11
- package/extensions/hooks/matcher.ts +12 -7
- package/extensions/hooks/runners.ts +7 -28
- package/extensions/internal/agent-run.ts +1 -1
- package/extensions/internal/bash-rules.ts +1 -2
- package/extensions/internal/claude-tool-names.ts +4 -7
- package/extensions/internal/command-file.ts +2 -3
- package/extensions/internal/external-imports.ts +4 -5
- package/extensions/internal/goal-evaluator.ts +4 -3
- package/extensions/internal/instruction-events.ts +5 -5
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +13 -3
- package/extensions/internal/model-complete.ts +7 -1
- package/extensions/internal/model-lookup.ts +10 -3
- package/extensions/internal/path-rules.ts +10 -13
- package/extensions/internal/plugins.ts +5 -5
- package/extensions/internal/process-tree.ts +39 -0
- package/extensions/internal/project-approval.ts +7 -7
- package/extensions/internal/project-root.ts +13 -13
- package/extensions/internal/settings-chain.ts +19 -0
- package/extensions/internal/settings-watch.ts +3 -1
- package/extensions/internal/shell-resolve.ts +3 -1
- package/extensions/internal/tool-target.ts +4 -5
- package/extensions/internal/values.ts +20 -4
- package/extensions/mcp/config.ts +11 -4
- package/extensions/mcp/index.ts +40 -4
- package/extensions/mcp/policy.ts +14 -12
- package/extensions/mcp/transport.ts +2 -8
- package/extensions/memory.ts +4 -4
- package/extensions/notify.ts +2 -7
- package/extensions/output-styles.ts +22 -17
- package/extensions/question.ts +2 -7
- package/extensions/skills.ts +5 -5
- package/extensions/status-line.ts +4 -0
- package/extensions/subagent/agents.ts +4 -4
- package/extensions/subagent/background.ts +35 -41
- package/extensions/subagent/child.ts +2 -2
- package/extensions/subagent/modes.ts +18 -13
- package/extensions/subagent/params.ts +1 -2
- package/extensions/subagent/run.ts +3 -14
- package/extensions/thinking.ts +3 -3
- package/extensions/web.ts +3 -4
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Each topic links to its own doc with the full contract and any divergences from
|
|
|
56
56
|
- **[Goal](docs/goal.md)**: `/goal <condition>` keeps the session working until a separate model check confirms the condition holds, with status, clear, block cap, background-work deferral, and resume.
|
|
57
57
|
- **[Session extras](docs/session-extras.md)** — project trust, plan mode, todos, checkpoints/rewind, AskUserQuestion, notifications, think keywords, session titles, `/context`, `/init`.
|
|
58
58
|
|
|
59
|
-
Slash commands: `/init`, `/context`, `/goal`, `/memory`, `/todos`, `/rewind`, `/tasks`, `/agents`, `/plan`, `/mcp`, `/hooks`, and `/output-style`, alongside your own `/dir:name` commands, `/skill:name` skills, `/plugin:name` plugin commands, and each connected server's `/mcp__server__prompt` prompts.
|
|
59
|
+
Slash commands: `/init`, `/context`, `/goal`, `/memory`, `/todos`, `/rewind`, `/tasks`, `/agents`, `/plan`, `/plan-todos`, `/mcp`, `/hooks`, and `/output-style`, alongside your own `/dir:name` commands, `/skill:name` skills, `/plugin:name` plugin commands, and each connected server's `/mcp__server__prompt` prompts.
|
|
60
60
|
|
|
61
61
|
pi has no general permission system, so most of what Claude routes through a permission prompt maps to hard behavior here: `allowed-tools` restricts the turn's tool set instead of pre-approving calls, and a hook that times out on PreToolUse or UserPromptSubmit fails closed. A hook's `permissionDecision: "ask"` is the exception: it shows a confirm dialog and lets the call through when you approve (a headless run has no dialog, so it blocks). Where a Claude restriction cannot be expressed at all (an argument-scoped grant in an agent's `tools:`), the definition is rejected rather than widened.
|
|
62
62
|
|
package/extensions/commands.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { type CommandExec, expandDynamicContent, resolvePowershellBinary, spanEx
|
|
|
49
49
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
50
50
|
import { claudeEffortLevel } from './internal/effort.js'
|
|
51
51
|
import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
|
|
52
|
+
import { findModel } from './internal/model-lookup.js'
|
|
52
53
|
import { capForContext } from './internal/output-guard.js'
|
|
53
54
|
import { matchesPathRules } from './internal/path-rules.js'
|
|
54
55
|
import { type InstalledPlugin, installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
@@ -56,8 +57,9 @@ import { isProjectApproved } from './internal/project-approval.js'
|
|
|
56
57
|
import { ancestorDirs, repoRoot } from './internal/project-root.js'
|
|
57
58
|
import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from './internal/scope-rules.js'
|
|
58
59
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
60
|
+
import { fileToolTarget } from './internal/tool-target.js'
|
|
59
61
|
import { createTurnOverride } from './internal/turn-override.js'
|
|
60
|
-
import { errorMessage, isDirectory } from './internal/values.js'
|
|
62
|
+
import { errorMessage, isDirectory, parseNumericEnv } from './internal/values.js'
|
|
61
63
|
|
|
62
64
|
/** Just enough of pi's Model to match and restore; getAvailable returns these. */
|
|
63
65
|
interface ModelLike {
|
|
@@ -72,8 +74,7 @@ interface ModelLike {
|
|
|
72
74
|
* subagent uses). `inherit` and an unresolvable name leave the model unchanged. */
|
|
73
75
|
function resolveCommandModel(model: string | undefined, available: ReadonlyArray<ModelLike>): ModelLike | undefined {
|
|
74
76
|
if (!model || model.toLowerCase() === 'inherit') return undefined
|
|
75
|
-
|
|
76
|
-
return available.find((m) => m.id.toLowerCase() === needle) ?? available.find((m) => m.id.toLowerCase().includes(needle) || (m.name ?? '').toLowerCase().includes(needle))
|
|
77
|
+
return findModel(model, available)
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/** Substitute ${CLAUDE_*} into a path rule. A variable that expands to an absolute
|
|
@@ -276,8 +277,8 @@ const DEFAULT_TOOL_CHAR_BUDGET = 15_000
|
|
|
276
277
|
* 1% of the model's context window (1% of the tokens at ~4 characters per token
|
|
277
278
|
* is window / 25), else Claude's documented default. */
|
|
278
279
|
export function slashCommandBudget(contextWindow: number | undefined, env: Record<string, string | undefined> = process.env): number {
|
|
279
|
-
const override =
|
|
280
|
-
if (Number.isInteger(override) && override > 0) return override
|
|
280
|
+
const override = parseNumericEnv(env.SLASH_COMMAND_TOOL_CHAR_BUDGET)
|
|
281
|
+
if (override !== undefined && Number.isInteger(override) && override > 0) return override
|
|
281
282
|
if (contextWindow && contextWindow > 0) return Math.floor(contextWindow / 25)
|
|
282
283
|
return DEFAULT_TOOL_CHAR_BUDGET
|
|
283
284
|
}
|
|
@@ -394,7 +395,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
394
395
|
if (event.toolName === 'slash_command') return scopeVerdict(pendingSkillRules, 'slash_command', 'Command', text(input.command), (rules) => matchesSkillRules(text(input.command), rules))
|
|
395
396
|
const rules = pendingPathRules?.[event.toolName as PathRuleTool]
|
|
396
397
|
if (!rules) return
|
|
397
|
-
|
|
398
|
+
// pi's read/edit/write accept `file_path` as an alias for `path`; the shared reader
|
|
399
|
+
// handles both, and the paired guard in hooks/matcher.ts reads both too.
|
|
400
|
+
const filePath = fileToolTarget(event) ?? ''
|
|
398
401
|
const anchors = { cwd: ctx.cwd, projectRoot: repoRoot(ctx.cwd) ?? ctx.cwd, home: os.homedir() }
|
|
399
402
|
if (filePath && matchesPathRules(filePath, rules, anchors)) return
|
|
400
403
|
return {
|
|
@@ -743,9 +743,6 @@ function additionalDirsAddition(extras: Array<{ path: string; content: string; d
|
|
|
743
743
|
return addition
|
|
744
744
|
}
|
|
745
745
|
|
|
746
|
-
/** The `## Imported context (@)` section for every resolved @import, with the
|
|
747
|
-
* budget-exhaustion notice, announcing each as an `include`. Empty when nothing
|
|
748
|
-
* was imported. */
|
|
749
746
|
/** The refusal notice: every existing file an @import named that its importer may not
|
|
750
747
|
* reach. Claude asks about these through an approval dialog and loads the ones you
|
|
751
748
|
* allow; pi-code refuses them, and this is what says so. Silence was the real defect:
|
|
@@ -759,6 +756,9 @@ function refusedImportsAddition(refused: Set<string>): string {
|
|
|
759
756
|
return `\n\n## Imports not loaded (@)\n\nThese files resolve outside what the file importing them may read, so their contents are not in context:\n\n${list}`
|
|
760
757
|
}
|
|
761
758
|
|
|
759
|
+
/** The `## Imported context (@)` section for every resolved @import, with the
|
|
760
|
+
* budget-exhaustion notice, announcing each as an `include`. Empty when nothing
|
|
761
|
+
* was imported. */
|
|
762
762
|
function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
|
|
763
763
|
if (imported.length === 0) return ''
|
|
764
764
|
const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
|
|
@@ -160,7 +160,10 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
|
160
160
|
pi.on('session_start', async (_event, ctx: ExtensionContext) => {
|
|
161
161
|
const home = os.homedir()
|
|
162
162
|
const approved = isProjectApprovedSilently(ctx)
|
|
163
|
-
|
|
163
|
+
// The watcher's reapply closes over the cwd value, never ctx: the poll has no awaiter,
|
|
164
|
+
// and every getter of a replaced session's ctx throws, which would exit pi.
|
|
165
|
+
const cwd = ctx.cwd
|
|
166
|
+
const reapply = (): void => apply(home, approved ? projectEnv(cwd, home) : {})
|
|
164
167
|
reapply()
|
|
165
168
|
// Claude: "Claude Code watches your settings files and reloads them when they change,
|
|
166
169
|
// so it applies most edits to the running session without a restart." `env` is not
|
|
@@ -168,7 +171,7 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
|
168
171
|
// edit has to reach process.env now rather than at the next session. applyEnvSettings
|
|
169
172
|
// tracks what it owns, so a key removed from the file is restored, not left behind.
|
|
170
173
|
disposeWatch()
|
|
171
|
-
disposeWatch = watchSettingsFiles(claudeSettingsChain(
|
|
174
|
+
disposeWatch = watchSettingsFiles(claudeSettingsChain(cwd, home, approved), reapply)
|
|
172
175
|
})
|
|
173
176
|
|
|
174
177
|
pi.on('session_shutdown', async () => {
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Claude Code style /rewind built on a per-session shadow git repo.
|
|
5
5
|
*
|
|
6
|
-
* Snapshots commit the
|
|
7
|
-
*
|
|
6
|
+
* Snapshots commit the files this session's edit tools touched (the project's
|
|
7
|
+
* .gitignore and .git/info/exclude are honored) into a bare repo under
|
|
8
8
|
* ~/.pi/agent/checkpoints/<session>, using --git-dir/--work-tree so the
|
|
9
|
-
* project's own git state is never touched.
|
|
9
|
+
* project's own git state is never touched. Scope is the edit set, not the
|
|
10
|
+
* working tree: Claude tracks "only files that have been edited within the
|
|
11
|
+
* current session", and a whole-tree snapshot instead sizes every checkpoint
|
|
12
|
+
* by cwd, which has no bound when cwd is $HOME. Each user prompt gets one
|
|
10
13
|
* checkpoint persisted as {entryId, ref, prompt, createdAt} in the session
|
|
11
14
|
* file, so /rewind works across restarts, resumes, and forks. Code restore
|
|
12
15
|
* checks the snapshot out over the working tree, resetting file contents to
|
|
@@ -17,13 +20,22 @@ import { createHash } from 'node:crypto'
|
|
|
17
20
|
import * as fs from 'node:fs'
|
|
18
21
|
import * as os from 'node:os'
|
|
19
22
|
import * as path from 'node:path'
|
|
20
|
-
import type
|
|
23
|
+
import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
21
24
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
25
|
+
import { readSettingsFile } from './internal/settings-chain.js'
|
|
26
|
+
import { fileToolTarget } from './internal/tool-target.js'
|
|
22
27
|
import { contentText, errorMessage } from './internal/values.js'
|
|
23
28
|
|
|
24
29
|
const CUSTOM_TYPE = 'git-checkpoint'
|
|
25
30
|
/** Sidecar inside the bare shadow repo recording the work tree it snapshots. */
|
|
26
31
|
const WORK_TREE_FILE = 'pi-work-tree'
|
|
32
|
+
/** pi's tools that change a file. `read` is deliberately absent: checkpoint scope is
|
|
33
|
+
* the set of files the session edited, and that set is what bounds the snapshot. */
|
|
34
|
+
const EDIT_TOOLS: ReadonlySet<string> = new Set(['edit', 'write'])
|
|
35
|
+
/** A run's checkpoint commit is published under a ref rather than recorded as a raw
|
|
36
|
+
* sha, so a file first edited in a later turn of the same run can still fold its
|
|
37
|
+
* pre-edit baseline into that run's checkpoint by moving the ref. */
|
|
38
|
+
const CHECKPOINT_REF_PREFIX = 'refs/pi-code/checkpoints'
|
|
27
39
|
const PROMPT_SNIPPET_LENGTH = 60
|
|
28
40
|
const RESTORE_MODES = ['Code and conversation', 'Conversation only', 'Code only']
|
|
29
41
|
|
|
@@ -34,9 +46,9 @@ interface Checkpoint {
|
|
|
34
46
|
createdAt: string
|
|
35
47
|
}
|
|
36
48
|
|
|
37
|
-
/** Claude deletes checkpoints after 30 days (cleanupPeriodDays). Shadow repos hold
|
|
38
|
-
*
|
|
39
|
-
*
|
|
49
|
+
/** Claude deletes checkpoints after 30 days (cleanupPeriodDays). Shadow repos hold a
|
|
50
|
+
* snapshot per edited file, so unbounded retention grows under $HOME for the life of
|
|
51
|
+
* the machine. */
|
|
40
52
|
export const CHECKPOINT_RETENTION_DAYS = 30
|
|
41
53
|
|
|
42
54
|
/** The retention period in effect: Claude keeps checkpoints for 30 days and says to
|
|
@@ -44,13 +56,8 @@ export const CHECKPOINT_RETENTION_DAYS = 30
|
|
|
44
56
|
* setting about the user's own disk belongs; a non-positive or unreadable value keeps the
|
|
45
57
|
* default rather than sweeping everything away. */
|
|
46
58
|
export function checkpointRetentionDays(home: string = os.homedir()): number {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const declared = (parsed as { cleanupPeriodDays?: unknown }).cleanupPeriodDays
|
|
50
|
-
if (typeof declared === 'number' && Number.isFinite(declared) && declared > 0) return declared
|
|
51
|
-
} catch {
|
|
52
|
-
// No user settings, or unreadable: the default period stands.
|
|
53
|
-
}
|
|
59
|
+
const declared = readSettingsFile(path.join(claudeConfigDir(home), 'settings.json'))?.cleanupPeriodDays
|
|
60
|
+
if (typeof declared === 'number' && Number.isFinite(declared) && declared > 0) return declared
|
|
54
61
|
return CHECKPOINT_RETENTION_DAYS
|
|
55
62
|
}
|
|
56
63
|
|
|
@@ -163,6 +170,13 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
163
170
|
let runNeedsSnapshot = true
|
|
164
171
|
let shadowDir: string | undefined
|
|
165
172
|
let workTree: string | undefined
|
|
173
|
+
// Absolute paths this session's edit tools targeted: the whole of what a checkpoint
|
|
174
|
+
// captures. Seeded on resume from the last commit, so a resumed session keeps
|
|
175
|
+
// snapshotting the files it was already tracking.
|
|
176
|
+
const touched = new Set<string>()
|
|
177
|
+
// The ref holding the in-flight run's checkpoint, moved as later baselines arrive.
|
|
178
|
+
let runRef: string | undefined
|
|
179
|
+
let refSeq = 0
|
|
166
180
|
|
|
167
181
|
function gitShadow(args: string[]): ReturnType<ExtensionAPI['exec']> {
|
|
168
182
|
if (!shadowDir || !workTree) return Promise.resolve({ stdout: '', stderr: 'shadow repo not initialized', code: 1, killed: false })
|
|
@@ -175,7 +189,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
175
189
|
async function ensureShadow(ctx: ExtensionContext): Promise<void> {
|
|
176
190
|
workTree = ctx.cwd
|
|
177
191
|
const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined }).getSessionFile?.()
|
|
178
|
-
const checkpointsRoot = path.join(
|
|
192
|
+
const checkpointsRoot = path.join(getAgentDir(), 'checkpoints')
|
|
179
193
|
shadowDir = path.join(checkpointsRoot, sessionSlug(sessionFile))
|
|
180
194
|
// A resumed session can arrive from a different directory than the one the shadow
|
|
181
195
|
// snapshotted; restoring those commits here would silently overwrite unrelated
|
|
@@ -233,6 +247,79 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
233
247
|
}
|
|
234
248
|
}
|
|
235
249
|
|
|
250
|
+
/** A tracked file as git names it: a work-tree-relative slash-separated path, or
|
|
251
|
+
* undefined for anything outside the work tree, which git refuses as a pathspec.
|
|
252
|
+
* Paths are compared in this form throughout, never as absolutes: git echoes a
|
|
253
|
+
* pathspec back verbatim, while the same file can spell its absolute path two ways
|
|
254
|
+
* on Windows (a short 8.3 temp directory against its long form). */
|
|
255
|
+
function workTreePath(file: string): string | undefined {
|
|
256
|
+
if (!workTree) return undefined
|
|
257
|
+
const rel = path.relative(workTree, file)
|
|
258
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return undefined
|
|
259
|
+
return rel.split(path.sep).join('/')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The paths the last checkpoint commit holds. A tracked file deleted since then still
|
|
263
|
+
* matches a pathspec through this, so its removal is staged instead of the stale index
|
|
264
|
+
* entry silently riding along into the next checkpoint. */
|
|
265
|
+
async function committedPaths(): Promise<Set<string>> {
|
|
266
|
+
const listed = await gitShadow(['ls-tree', '-r', '--name-only', 'HEAD'])
|
|
267
|
+
if (listed.code !== 0) return new Set()
|
|
268
|
+
return new Set(listed.stdout.split('\n').filter(Boolean))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** git rejects the whole pathspec when one entry is ignored, so the ignored paths are
|
|
272
|
+
* dropped before the add rather than costing the run its checkpoint. */
|
|
273
|
+
async function withoutIgnored(files: string[]): Promise<string[]> {
|
|
274
|
+
const checked = await gitShadow(['-c', 'core.quotePath=false', 'check-ignore', '--', ...files])
|
|
275
|
+
if (checked.code !== 0) return files
|
|
276
|
+
const ignored = new Set(checked.stdout.split('\n').filter(Boolean))
|
|
277
|
+
return files.filter((file) => !ignored.has(file))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The tracked files git will accept: inside the work tree, and either on disk or in
|
|
281
|
+
* the last commit. A pathspec matching neither aborts the entire add, taking the
|
|
282
|
+
* checkpoint with it. */
|
|
283
|
+
async function addablePaths(): Promise<string[]> {
|
|
284
|
+
const inside: Array<{ file: string; rel: string }> = []
|
|
285
|
+
for (const file of touched) {
|
|
286
|
+
const rel = workTreePath(file)
|
|
287
|
+
if (rel !== undefined) inside.push({ file, rel })
|
|
288
|
+
}
|
|
289
|
+
if (inside.length === 0) return []
|
|
290
|
+
const committed = await committedPaths()
|
|
291
|
+
const live = inside.filter(({ file, rel }) => fs.existsSync(file) || committed.has(rel)).map(({ rel }) => rel)
|
|
292
|
+
return live.length === 0 ? [] : withoutIgnored(live)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Point this run's ref at the commit. Publishing a ref rather than the raw sha lets a
|
|
296
|
+
* baseline captured later in the run move the checkpoint forward without rewriting a
|
|
297
|
+
* commit that an earlier checkpoint may share. */
|
|
298
|
+
async function publishRun(sha: string, createdAt: string): Promise<{ ref: string; createdAt: string }> {
|
|
299
|
+
const ref = `${CHECKPOINT_REF_PREFIX}/${Date.now().toString(36)}-${++refSeq}`
|
|
300
|
+
const update = await gitShadow(['update-ref', ref, sha])
|
|
301
|
+
if (update.code !== 0) return { ref: sha, createdAt }
|
|
302
|
+
runRef = ref
|
|
303
|
+
return { ref, createdAt }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Fold a file's pre-edit content into the run's checkpoint. A file first edited part
|
|
307
|
+
* way through a run is absent from that run's pre-run snapshot, so without this its
|
|
308
|
+
* checkpoint holds no baseline and /rewind cannot undo that first edit. */
|
|
309
|
+
async function captureBaseline(file: string): Promise<void> {
|
|
310
|
+
const rel = workTreePath(file)
|
|
311
|
+
if (!runRef || rel === undefined || !fs.existsSync(file)) return
|
|
312
|
+
if ((await withoutIgnored([rel])).length === 0) return
|
|
313
|
+
const add = await gitShadow(['add', '--', rel])
|
|
314
|
+
if (add.code !== 0) return
|
|
315
|
+
// A commit, never an amend: the run's checkpoint can be a commit an earlier
|
|
316
|
+
// checkpoint also points at, and rewriting it would strand that one.
|
|
317
|
+
const commit = await commitShadow([])
|
|
318
|
+
if (commit.code !== 0) return
|
|
319
|
+
const sha = await gitShadow(['rev-parse', 'HEAD'])
|
|
320
|
+
if (sha.code === 0) await gitShadow(['update-ref', runRef, sha.stdout.trim()])
|
|
321
|
+
}
|
|
322
|
+
|
|
236
323
|
/** `checkout -f <ref> -- .` errors when the ref's tree holds no files, so an empty
|
|
237
324
|
* snapshot restores as a no-op rather than vetoing the whole rewind. */
|
|
238
325
|
async function snapshotIsEmpty(ref: string): Promise<boolean> {
|
|
@@ -242,16 +329,21 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
242
329
|
|
|
243
330
|
async function snapshot(): Promise<{ ref: string; createdAt: string } | undefined> {
|
|
244
331
|
const createdAt = new Date().toISOString()
|
|
245
|
-
const
|
|
246
|
-
if (
|
|
332
|
+
const paths = await addablePaths()
|
|
333
|
+
if (paths.length > 0) {
|
|
334
|
+
const add = await gitShadow(['add', '--', ...paths])
|
|
335
|
+
if (add.code !== 0) return undefined
|
|
336
|
+
}
|
|
247
337
|
// Decide "nothing changed" from the index, not from the commit exit code: a commit can
|
|
248
338
|
// also fail on the user's global signing or hooks config, and reusing HEAD then would
|
|
249
339
|
// record a ref that predates the current tree, so /rewind restores the wrong state.
|
|
250
|
-
|
|
340
|
+
// Untracked files are excluded because the work tree is full of files this session
|
|
341
|
+
// never edited; counting them would make every run look changed.
|
|
342
|
+
const status = await gitShadow(['status', '--porcelain', '--untracked-files=no'])
|
|
251
343
|
const nothingChanged = status.code === 0 && status.stdout.trim() === ''
|
|
252
344
|
if (nothingChanged) {
|
|
253
345
|
const head = await gitShadow(['rev-parse', 'HEAD'])
|
|
254
|
-
if (head.code === 0) return
|
|
346
|
+
if (head.code === 0) return publishRun(head.stdout.trim(), createdAt)
|
|
255
347
|
const empty = await commitShadow(['--allow-empty'])
|
|
256
348
|
if (empty.code !== 0) return undefined
|
|
257
349
|
} else {
|
|
@@ -259,7 +351,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
259
351
|
if (commit.code !== 0) return undefined // real failure: do not record a stale ref
|
|
260
352
|
}
|
|
261
353
|
const sha = await gitShadow(['rev-parse', 'HEAD'])
|
|
262
|
-
return sha.code === 0 ?
|
|
354
|
+
return sha.code === 0 ? publishRun(sha.stdout.trim(), createdAt) : undefined
|
|
263
355
|
}
|
|
264
356
|
|
|
265
357
|
/** Commit in the shadow repo, isolated from the user's global signing and hook config. */
|
|
@@ -300,7 +392,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
300
392
|
// own tree even though the prior run left it false.
|
|
301
393
|
pending = undefined
|
|
302
394
|
runNeedsSnapshot = true
|
|
395
|
+
runRef = undefined
|
|
303
396
|
await ensureShadow(ctx)
|
|
397
|
+
touched.clear()
|
|
398
|
+
for (const rel of await committedPaths()) touched.add(path.resolve(ctx.cwd, rel))
|
|
304
399
|
checkpoints.clear()
|
|
305
400
|
const stored: Checkpoint[] = []
|
|
306
401
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
@@ -338,13 +433,34 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
338
433
|
pending = await snapshot()
|
|
339
434
|
})
|
|
340
435
|
|
|
436
|
+
// A checkpoint covers the files the session edited, so the tracked set grows as the
|
|
437
|
+
// model works. The baseline is taken here, before the tool writes, because once the
|
|
438
|
+
// tool has run the pre-edit content is gone.
|
|
439
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
440
|
+
if (!EDIT_TOOLS.has(event.toolName)) return
|
|
441
|
+
const target = fileToolTarget(event)
|
|
442
|
+
if (target === undefined) return
|
|
443
|
+
const file = path.resolve(ctx.cwd, target)
|
|
444
|
+
if (touched.has(file)) return
|
|
445
|
+
touched.add(file)
|
|
446
|
+
await captureBaseline(file)
|
|
447
|
+
})
|
|
448
|
+
|
|
341
449
|
pi.on('turn_end', async (_event, ctx) => {
|
|
342
450
|
const snap = pending
|
|
343
451
|
pending = undefined
|
|
344
452
|
if (!snap) return
|
|
345
453
|
|
|
346
454
|
const target = findLastUserMessage(ctx)
|
|
347
|
-
if (!target
|
|
455
|
+
if (!target) return
|
|
456
|
+
const recorded = checkpoints.get(target.entryId)
|
|
457
|
+
if (recorded) {
|
|
458
|
+
// A retry or follow-up re-ran agent_start and took a fresh snapshot, but this user
|
|
459
|
+
// message already has its checkpoint. Discard the new one and keep folding later
|
|
460
|
+
// baselines into the checkpoint that is actually recorded.
|
|
461
|
+
runRef = recorded.ref
|
|
462
|
+
return
|
|
463
|
+
}
|
|
348
464
|
|
|
349
465
|
const checkpoint: Checkpoint = { entryId: target.entryId, ref: snap.ref, prompt: target.prompt, createdAt: snap.createdAt }
|
|
350
466
|
checkpoints.set(checkpoint.entryId, checkpoint)
|
|
@@ -9,7 +9,7 @@ import * as path from 'node:path'
|
|
|
9
9
|
import { readManagedSettings } from '../internal/managed-settings.js'
|
|
10
10
|
import { type InstalledPlugin, pluginComponentPath, substitutePluginVars } from '../internal/plugins.js'
|
|
11
11
|
import { claudeSettingsChain, readSettingsChain } from '../internal/settings-chain.js'
|
|
12
|
-
import { errorMessage, isRecord } from '../internal/values.js'
|
|
12
|
+
import { errorMessage, escapeRegExp, isRecord } from '../internal/values.js'
|
|
13
13
|
|
|
14
14
|
export interface HookCommand {
|
|
15
15
|
type?: string
|
|
@@ -159,7 +159,7 @@ export function readAllowedHttpHookUrls(files: string[], managed: Record<string,
|
|
|
159
159
|
export function httpUrlAllowed(url: string, allowlist: string[] | undefined): boolean {
|
|
160
160
|
if (allowlist === undefined) return true
|
|
161
161
|
return allowlist.some((pattern) => {
|
|
162
|
-
const literal = pattern.split('*').map(
|
|
162
|
+
const literal = pattern.split('*').map(escapeRegExp)
|
|
163
163
|
return new RegExp(`^${literal.join('.*')}$`).test(url)
|
|
164
164
|
})
|
|
165
165
|
}
|
|
@@ -182,7 +182,7 @@ export function loadHooks(files: string[], sources?: Map<HookMatcher, string>):
|
|
|
182
182
|
* stays separate, so plugin entries carry their origin into the dedup key. */
|
|
183
183
|
function stampOrigin(entries: HookMatcher[], origin: string): void {
|
|
184
184
|
for (const entry of entries) {
|
|
185
|
-
for (const hook of entry.hooks ?? []) hook.origin = origin
|
|
185
|
+
for (const hook of entry.hooks ?? []) if (isRecord(hook)) hook.origin = origin
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
|
|
@@ -209,10 +209,9 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
|
|
|
209
209
|
}
|
|
210
210
|
for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
|
|
211
211
|
if (!Array.isArray(matchers)) continue
|
|
212
|
-
// Entries are validated here rather than where they run: a
|
|
213
|
-
//
|
|
214
|
-
// the
|
|
215
|
-
// call for the rest of the session failed with an opaque type error.
|
|
212
|
+
// Entries are validated here rather than where they run: a malformed entry that
|
|
213
|
+
// throws from the tool_call handler becomes an error result there, so every tool
|
|
214
|
+
// call for the rest of the session fails with an opaque type error.
|
|
216
215
|
const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
|
|
217
216
|
if (usable.length === 0) continue
|
|
218
217
|
if (origin !== undefined) stampOrigin(usable, origin)
|
|
@@ -224,9 +223,6 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
|
|
|
224
223
|
}
|
|
225
224
|
}
|
|
226
225
|
|
|
227
|
-
/** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
|
|
228
|
-
* with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
|
|
229
|
-
* hook can name its bundled scripts by real path. */
|
|
230
226
|
/** The substitution here lands inside raw JSON, so values must arrive escaped:
|
|
231
227
|
* an unescaped Windows root injected \U-style sequences, the parse threw, and
|
|
232
228
|
* every hook the plugin declared silently vanished. */
|
|
@@ -270,6 +266,9 @@ function withoutUserConfigShellCommands(raw: string, source: string): string {
|
|
|
270
266
|
return dropped ? JSON.stringify(parsed) : raw
|
|
271
267
|
}
|
|
272
268
|
|
|
269
|
+
/** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
|
|
270
|
+
* with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
|
|
271
|
+
* hook can name its bundled scripts by real path. */
|
|
273
272
|
export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
|
|
274
273
|
for (const plugin of plugins) {
|
|
275
274
|
const declared = plugin.manifest.hooks
|
|
@@ -304,6 +303,13 @@ function isUsableMatcher(entry: unknown, file: string, event: string): entry is
|
|
|
304
303
|
console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "hooks" must be a list`)
|
|
305
304
|
return false
|
|
306
305
|
}
|
|
306
|
+
// The list's entries too: the dispatcher reads `.type` of each, so one `null` (a
|
|
307
|
+
// hand-edit that removed an entry's body) failed every tool call for the session.
|
|
308
|
+
if (Array.isArray(candidate.hooks)) {
|
|
309
|
+
const objects = candidate.hooks.filter((hook: unknown) => isRecord(hook))
|
|
310
|
+
if (objects.length !== candidate.hooks.length) console.warn(`pi-code-hooks: ignoring ${candidate.hooks.length - objects.length} non-object hook(s) in a ${event} entry in ${file}`)
|
|
311
|
+
candidate.hooks = objects as HookCommand[]
|
|
312
|
+
}
|
|
307
313
|
if (candidate.matcher !== undefined && typeof candidate.matcher !== 'string') {
|
|
308
314
|
console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "matcher" must be a string`)
|
|
309
315
|
return false
|
|
@@ -170,15 +170,6 @@ export interface PreToolUseOutcome extends HookDecision {
|
|
|
170
170
|
context?: string[]
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
/** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
|
|
174
|
-
* verdict in config order wins. The payload reports the Claude vocabulary: the MCP
|
|
175
|
-
* alias for MCP tools, and the documented name and tool_input shape for pi's
|
|
176
|
-
* built-ins (see claude-tools). Every hook sees the original tool input;
|
|
177
|
-
* hookSpecificOutput.updatedInput replaces the input in place as each hook
|
|
178
|
-
* completes, translated back to the pi shape for a built-in (an incomplete rewrite
|
|
179
|
-
* keeps the original input rather than corrupting it), so with several rewrites
|
|
180
|
-
* the last to finish takes effect (the docs leave multi-rewrite ordering
|
|
181
|
-
* unspecified). */
|
|
182
173
|
/** Apply a hook's updatedInput rewrite in place, translating a built-in rewrite
|
|
183
174
|
* back to the pi shape; an incomplete built-in rewrite keeps the original input
|
|
184
175
|
* rather than corrupting it. */
|
|
@@ -215,6 +206,15 @@ function preToolContexts(results: HookRunResult[]): string[] {
|
|
|
215
206
|
})
|
|
216
207
|
}
|
|
217
208
|
|
|
209
|
+
/** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
|
|
210
|
+
* verdict in config order wins. The payload reports the Claude vocabulary: the MCP
|
|
211
|
+
* alias for MCP tools, and the documented name and tool_input shape for pi's
|
|
212
|
+
* built-ins (see claude-tools). Every hook sees the original tool input;
|
|
213
|
+
* hookSpecificOutput.updatedInput replaces the input in place as each hook
|
|
214
|
+
* completes, translated back to the pi shape for a built-in (an incomplete rewrite
|
|
215
|
+
* keeps the original input rather than corrupting it), so with several rewrites
|
|
216
|
+
* the last to finish takes effect (the docs leave multi-rewrite ordering
|
|
217
|
+
* unspecified). */
|
|
218
218
|
export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink, anchors?: PathAnchors): Promise<PreToolUseOutcome> {
|
|
219
219
|
const cwd = anchors?.cwd ?? process.cwd()
|
|
220
220
|
const translatedName = claudeName ?? claudeToolName(toolName)
|
|
@@ -109,7 +109,7 @@ import { watchSettingsFiles } from '../internal/settings-watch.js'
|
|
|
109
109
|
import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
|
|
110
110
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
111
111
|
import { setSubagentStartHookRunner } from '../internal/subagent-hooks.js'
|
|
112
|
-
import { contentText } from '../internal/values.js'
|
|
112
|
+
import { contentText, parseNumericEnv } from '../internal/values.js'
|
|
113
113
|
import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } from './claude-tools.js'
|
|
114
114
|
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadManagedHooks, loadPluginHooks, mergeAgentEnvHooks, mergeSkillHooks, readAllowedHttpHookUrls, readDisableAllHooks, readSettingsDisableAllHooks } from './config.js'
|
|
115
115
|
import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
|
|
@@ -142,9 +142,9 @@ const DEFAULT_STOP_HOOK_BLOCK_CAP = 8
|
|
|
142
142
|
* zero-cap that would suppress the very first block), else the default for a negative
|
|
143
143
|
* or malformed value. */
|
|
144
144
|
export function stopHookBlockCap(env: Record<string, string | undefined> = process.env): number {
|
|
145
|
-
const override =
|
|
145
|
+
const override = parseNumericEnv(env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP)
|
|
146
146
|
if (override === 0) return Number.POSITIVE_INFINITY
|
|
147
|
-
return Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
|
|
147
|
+
return override !== undefined && Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<HookRunResult[]> {
|
|
@@ -337,10 +337,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
337
337
|
})
|
|
338
338
|
// Claude's InstructionsLoaded hook has NO decision control: exit codes are
|
|
339
339
|
// ignored and every JSON output field (systemMessage included) is discarded, so
|
|
340
|
-
// dispatch is fire-and-forget on all paths.
|
|
341
|
-
//
|
|
342
|
-
// `
|
|
343
|
-
//
|
|
340
|
+
// dispatch is fire-and-forget on all paths. `nested_traversal` arrives from
|
|
341
|
+
// context-imports when a CLAUDE.md below cwd is attached on a file touch. The
|
|
342
|
+
// `compact` reason can never fire honestly and is a deliberate gap, not an
|
|
343
|
+
// approximation: pi does not re-load instruction files after compaction.
|
|
344
344
|
const fireInstructionsLoaded = (payload: Record<string, unknown>): void => {
|
|
345
345
|
if (!sessionCtx) return
|
|
346
346
|
const commands = matchingCommands(config.InstructionsLoaded, String(payload.load_reason))
|
|
@@ -453,9 +453,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
453
453
|
// subdirectory session too.
|
|
454
454
|
projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
455
455
|
resolveConfig(ctx.cwd, trusted)
|
|
456
|
-
// Claude picks up direct settings edits mid-session via a file watcher.
|
|
456
|
+
// Claude picks up direct settings edits mid-session via a file watcher. The reload
|
|
457
|
+
// closes over the cwd value, never ctx: the poll has no awaiter, and every getter of
|
|
458
|
+
// a replaced session's ctx throws, which would exit pi as an uncaughtException.
|
|
459
|
+
const watchCwd = ctx.cwd
|
|
457
460
|
disposeSettingsWatch()
|
|
458
|
-
disposeSettingsWatch = watchSettingsFiles(hookFiles(
|
|
461
|
+
disposeSettingsWatch = watchSettingsFiles(hookFiles(watchCwd, os.homedir(), trusted), () => resolveConfig(watchCwd, trusted))
|
|
459
462
|
// A disabled or managed-only resolution leaves config empty (or managed-only),
|
|
460
463
|
// so the SessionStart run below fires exactly what remains active.
|
|
461
464
|
pendingSessionContext = []
|
|
@@ -703,7 +706,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
703
706
|
// Claude's custom_instructions: the /compact arguments on a manual run, empty
|
|
704
707
|
// on an automatic one; pi carries them on the event directly.
|
|
705
708
|
const payload = { hook_event_name: 'PreCompact', trigger: trigger.value, custom_instructions: event.customInstructions ?? '' }
|
|
706
|
-
|
|
709
|
+
// Filtered here the way runNotifyHooks filters, so `results[index]` and
|
|
710
|
+
// `commands[index]` name the same hook: indexing the unfiltered list named the
|
|
711
|
+
// wrong command in the notice whenever an `if`-carrying hook preceded the blocker.
|
|
712
|
+
const commands = matchingCommands(config.PreCompact, trigger.names).filter((command) => passesIfFilter(command, undefined))
|
|
713
|
+
const results = await runNotifyHooks(commands, payload, boundRunner(ctx))
|
|
707
714
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
708
715
|
// Claude: "Exit with code 2 to block compaction. For a manual /compact, the stderr
|
|
709
716
|
// message is shown to the user. You can also block by returning JSON with
|
|
@@ -713,7 +720,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
713
720
|
const parsed = tryParseJson(result.stdout)
|
|
714
721
|
const blocked = result.code === 2 && !result.timedOut ? { reason: result.stderr.trim() || 'Compaction blocked by hook' } : jsonBlockVerdict(parsed, 'Compaction blocked by hook')
|
|
715
722
|
if (!blocked) continue
|
|
716
|
-
ctx.ui.notify(`Compaction blocked by ${
|
|
723
|
+
ctx.ui.notify(`Compaction blocked by ${commands[index]?.command ?? 'hook'}: ${blocked.reason}`, 'warning')
|
|
717
724
|
return { cancel: true }
|
|
718
725
|
}
|
|
719
726
|
})
|
|
@@ -755,6 +762,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
755
762
|
})
|
|
756
763
|
|
|
757
764
|
pi.on('session_shutdown', async (event, ctx) => {
|
|
765
|
+
// pi's CLI loads a fresh extension instance for every session replacement, so no later
|
|
766
|
+
// session_start reaches this watcher: left armed, it polls for the life of the process.
|
|
767
|
+
disposeSettingsWatch()
|
|
768
|
+
disposeSettingsWatch = () => {}
|
|
758
769
|
const reason = claudeSpelling(SESSION_END_REASON, event.reason)
|
|
759
770
|
// SessionEnd rides Claude's short shared budget (see sessionEndTimeoutMs) so a
|
|
760
771
|
// slow hook cannot stall session exit, /new or /resume.
|
|
@@ -93,7 +93,13 @@ function isRunnableHook(hook: HookCommand): boolean {
|
|
|
93
93
|
if (hook.type === 'http') return typeof hook.url === 'string' && /^https?:\/\//.test(hook.url)
|
|
94
94
|
if (hook.type === 'prompt' || hook.type === 'agent') return typeof hook.prompt === 'string' && hook.prompt.length > 0
|
|
95
95
|
if (hook.type === 'mcp_tool') return typeof hook.server === 'string' && typeof hook.tool === 'string'
|
|
96
|
-
|
|
96
|
+
// A command hook needs a non-empty command, and exec-form args must all be strings:
|
|
97
|
+
// spawn('') throws ERR_INVALID_ARG_VALUE and a number has no replaceAll for argument
|
|
98
|
+
// substitution, both inside the runner's Promise executor, so the event's handler
|
|
99
|
+
// rejected instead of the hook reporting spawnFailed.
|
|
100
|
+
if (typeof hook.command !== 'string' || hook.command.length === 0) return false
|
|
101
|
+
if (hook.args !== undefined && !(Array.isArray(hook.args) && hook.args.every((arg) => typeof arg === 'string'))) return false
|
|
102
|
+
return hook.type === undefined || hook.type === 'command'
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
/** The synthetic identity of a non-shell hook entry: an http/prompt/agent/mcp_tool
|
|
@@ -188,18 +194,17 @@ export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undef
|
|
|
188
194
|
|
|
189
195
|
/** One `if` pattern against a call's arguments. Claude evaluates the rule "against
|
|
190
196
|
* the tool name and arguments together" in permission-rule syntax, so each tool uses
|
|
191
|
-
* the same specifier engine its permission rules use:
|
|
197
|
+
* the same specifier engine its permission rules use: a command pattern for Bash, a
|
|
198
|
+
* `domain:` host for WebFetch, an agent name for Agent, a skill name for Skill, and a
|
|
199
|
+
* path rule for the file tools. A tool with no specifier syntax matches nothing,
|
|
200
|
+
* which is also what an unparseable rule does.
|
|
192
201
|
*
|
|
193
202
|
* PAIRED WITH commands.ts's tool_call guard, which dispatches the same tools to the
|
|
194
203
|
* same matchers for `allowed-tools` scopes. The two are deliberately NOT merged: bash
|
|
195
204
|
* differs on purpose (an allow scope requires every segment to match, an `if` filter is
|
|
196
205
|
* best effort and errs toward running the hook), and merging would hide that. They are
|
|
197
206
|
* two lists that must agree, so a new scoped tool has to be added in both places; the
|
|
198
|
-
* shared roster is ARG_RULE_TOOLS in internal/command-file.ts.
|
|
199
|
-
* a command pattern for Bash, a
|
|
200
|
-
* `domain:` host for WebFetch, an agent name for Agent, a skill name for Skill, and a
|
|
201
|
-
* path rule for the file tools. A tool with no specifier syntax matches nothing,
|
|
202
|
-
* which is also what an unparseable rule does. */
|
|
207
|
+
* shared roster is ARG_RULE_TOOLS in internal/command-file.ts. */
|
|
203
208
|
function matchesToolPattern(piName: string, input: Record<string, unknown> | null, pattern: string, anchors: PathAnchors): boolean {
|
|
204
209
|
const str = (value: unknown): string => (typeof value === 'string' ? value : '')
|
|
205
210
|
switch (piName) {
|