@tduyng/pi-subagent 0.0.1 → 0.0.3
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 +80 -43
- package/agents/scout.md +43 -0
- package/package.json +3 -2
- package/src/subagent/agent.ts +9 -1
- package/src/subagent/durable.ts +20 -13
- package/src/subagent/guards/cloak.ts +396 -0
- package/src/subagent/guards/git-interceptor.ts +73 -0
- package/src/subagent/index.ts +1 -1
- package/src/subagent/session.ts +2 -2
- package/src/subagent/state.ts +5 -1
package/README.md
CHANGED
|
@@ -1,68 +1,100 @@
|
|
|
1
1
|
# @tduyng/pi-subagent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A subagent that stays small on purpose. It reads docs, logs, and source with a cheap model, returns one answer, and none of that reading lands in your context.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What it costs
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
session. At most `MAX_CONCURRENT` children run at once. Each child runs on the
|
|
9
|
-
current model unless a task, profile, or `subagent.json` override changes it.
|
|
10
|
-
The child sees the task prompt, does the work, and returns one message. The
|
|
11
|
-
parent never carries the child's exploration.
|
|
7
|
+
One tool, `subagent`, at 379 tokens a request. The bulk-read guard and the log reducer are event handlers, so they add no schema and no prompt text.
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
short sourced summaries. Omitting `agent` selects `scout`. It is the cheap
|
|
15
|
-
default for I/O-heavy work. `subagent.json` supplies its low-cost model.
|
|
9
|
+
The child is a separate session with its own context that dies with the task. A parent that would have carried 40 KB of file dumps carries one paragraph and a file list instead, and every later turn of the parent pays for the paragraph rather than the dumps.
|
|
16
10
|
|
|
17
|
-
|
|
18
|
-
after an artifact exists and the task explicitly asks for verification or
|
|
19
|
-
review. Keep ordinary implementation, debugging, design, builds, tests, and lint
|
|
20
|
-
in the parent session.
|
|
11
|
+
## What it is for
|
|
21
12
|
|
|
22
|
-
|
|
13
|
+
Not parallel implementation. Fanning four edit-capable children out over one change costs more than doing it in the parent: every child builds its own system prompt and tool list, and their edits still have to be reconciled.
|
|
23
14
|
|
|
24
|
-
|
|
15
|
+
The cheap and safe case is reading: search twenty files, trace one call path, read a build log, summarize a doc. That is where a session spends its turns and its tokens, and it is the work a cheap model does as well as a frontier one.
|
|
25
16
|
|
|
26
|
-
|
|
17
|
+
```text
|
|
18
|
+
subagent(task: "Which files construct the retry policy? Return path:line for each.",
|
|
19
|
+
agent: "scout")
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Scout ships with it
|
|
23
|
+
|
|
24
|
+
`agents/scout.md` is bundled, so `agent: "scout"` works with no setup and no profile of your own. The default when you omit `agent` is scout, so the cheap read-only child is what you get by typing less.
|
|
25
|
+
|
|
26
|
+
Scout carries `tools: read, grep, find, ls`, `inheritSkills: false`, and `thinking: low`. It never edits, never runs a command, never reaches the network, and answers in a fixed `STATUS / FINDING / NEXT` shape with file:line evidence.
|
|
27
|
+
|
|
28
|
+
Override it by dropping your own copy in `~/.pi/agent/agents/scout.md` or `.pi/agents/scout.md`. A project file shadows a user file, and both shadow the bundled one. Copy the [bundled profile](agents/scout.md) as the starting point.
|
|
29
|
+
|
|
30
|
+
## Point the child at a cheap model
|
|
31
|
+
|
|
32
|
+
The child inherits the parent's model unless you say otherwise, and inheriting defeats the point. Set it once:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"model": "claude/claude-haiku-5-5",
|
|
37
|
+
"thinking": "low"
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`~/.pi/agent/subagent.json` sets the default, `.pi/subagent.json` overrides either field per project. Both fields are optional. `thinking` is one of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, default `medium`. A `model` on the profile wins over the file.
|
|
42
|
+
|
|
43
|
+
## Tasks
|
|
44
|
+
|
|
45
|
+
The `subagent` tool takes one task or up to 8, with at most 4 running at once. Each child sees one prompt and returns one message.
|
|
46
|
+
|
|
47
|
+
| Field | Meaning |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `task` | What the child does |
|
|
50
|
+
| `agent` | Profile name, `scout` by default |
|
|
51
|
+
| `cwd` | Working directory for the child |
|
|
52
|
+
| `isolation` | `worktree` to give the child its own git checkout |
|
|
53
|
+
| `schema` | Ask for structured output |
|
|
54
|
+
| `context` | `fork` to replay the parent's history into the child |
|
|
55
|
+
| `resume` | Task id, or `all`, to continue a stopped child |
|
|
27
56
|
|
|
28
|
-
|
|
57
|
+
`label`, `thinking`, `model`, `timeoutMs`, and `maxTurns` are profile policy rather than model choices. A scripted caller can still pass them, and they still work.
|
|
58
|
+
|
|
59
|
+
### Resume instead of re-reading
|
|
60
|
+
|
|
61
|
+
A child that hits its turn budget is told to wrap up and gets a fresh budget in the same session, so a cap never turns work into a failure. If it still stops early its last text comes back marked `[partial answer]`, and salvage walks the whole transcript for it, so a child stopped mid tool-burst still returns what it wrote.
|
|
62
|
+
|
|
63
|
+
Every failed task prints a `resume:` id. Pass it back to continue where the child stopped instead of paying for the investigation again. An optional `task` alongside `resume` steers the continuation. Failed sessions are kept 30 minutes, completed ones 5.
|
|
64
|
+
|
|
65
|
+
A deadline is reported as a timeout, not a cancellation: pi keeps any final text produced before it and marks it partial.
|
|
66
|
+
|
|
67
|
+
Every run also writes `run.json` under `<agent-dir>/subagents/<session-id>/<run-id>/` with the task prompt, model, tools, and the path to each child session, rewritten after each transition. Reopening the session reads it back: a child interrupted less than ten minutes ago is resumed from its saved session and its answer arrives as a `subagent-durable` message, and older interruptions are listed with their resume ids. `resume` therefore works after a restart, not only inside the process that started the child.
|
|
29
68
|
|
|
30
69
|
### Worktree isolation
|
|
31
70
|
|
|
32
|
-
With `isolation: "worktree"
|
|
71
|
+
With `isolation: "worktree"` each child runs in its own git worktree on a branch named `pi-task-<id>`, so concurrent children never edit the same checkout and a commit the child makes outlives it. The result names the branch. This is also the answer to two sessions committing in one repo, which no path reservation can cover.
|
|
33
72
|
|
|
34
73
|
### Structured output
|
|
35
74
|
|
|
36
|
-
Pass `schema` to ask the child to call
|
|
75
|
+
Pass `schema` to ask the child to call `structured_output` exactly once. The parsed payload comes back in the task result. A child that finishes without calling it is re-prompted in the same session up to three times, so the investigation is not paid for twice. If it still refuses, the text answer stands and the task completes marked partial. If a provider rejects the schema tool, the extension removes it and retries once as plain text.
|
|
37
76
|
|
|
38
77
|
### Fork context
|
|
39
78
|
|
|
40
|
-
|
|
79
|
+
`context: "fork"` replays the parent's conversation history into the child as context entries.
|
|
41
80
|
|
|
42
|
-
##
|
|
81
|
+
## Profiles
|
|
43
82
|
|
|
44
|
-
|
|
83
|
+
A profile is a markdown file with YAML frontmatter in `~/.pi/agent/agents/`, in `.pi/agents/`, or bundled. It sets the tool list, model, thinking level, limits, and an optional color for the widget, plus `inheritProjectContext` and `inheritSkills` to decide what the child picks up from the parent, and `systemPromptMode: replace` to swap the default system prompt instead of appending to it.
|
|
45
84
|
|
|
46
|
-
|
|
85
|
+
`/agents` lists them. The `subagent` widget shows up to 8 running and recently finished children with a status summary line: Down and Up to move, Enter to open a live transcript, d or x to dismiss.
|
|
47
86
|
|
|
48
|
-
|
|
87
|
+
Children cannot spawn their own subagents.
|
|
49
88
|
|
|
50
|
-
##
|
|
89
|
+
## Bulk-read guard
|
|
90
|
+
|
|
91
|
+
A read of a file above `BULK_READ_MIN_LINES` lines (default 350) is blocked, and so is `cat`, `less`, or `more` that would dump a large file. The block names `outline` first, so the model fetches a map of declarations rather than the text.
|
|
51
92
|
|
|
52
|
-
|
|
93
|
+
The second call for the same path passes through, so the file stays reachable and a model with a reason to read it can. A read passes when it returns fewer lines than the threshold, whether from a `limit`, an `offset`, a small file, or `head` and `tail` on their 10-line default, and so does a piped command.
|
|
53
94
|
|
|
54
|
-
##
|
|
95
|
+
## Log reducer
|
|
55
96
|
|
|
56
|
-
|
|
57
|
-
- `verifier` is reserved for explicit verification of an existing artifact. It may write only its verification report, never implementation files.
|
|
58
|
-
- Ordinary implementation, debugging, design, builds, tests, and lint stay in the parent session.
|
|
59
|
-
- The tool rejects concurrent write-capable tasks. Read-only scouts may still run in parallel. A call whose task texts normalize to the same request is rejected, so one question does not get paid for twice.
|
|
60
|
-
- Children see one prompt and return one message. No sequential pipelines.
|
|
61
|
-
- Children cannot spawn their own subagents. No grandchildren.
|
|
62
|
-
- The `subagent` tool schema is present on every turn, and it lists only the fields a model sets: `task`, `agent`, `cwd`, `isolation`, `schema`, `context`, and `resume`. `label`, `thinking`, `model`, `timeoutMs`, and `maxTurns` are profile policy. They still work when a scripted caller passes them, and three of them already resolved to the profile before the split.
|
|
63
|
-
- The `subagent` tool schema is present on every turn. The `bulk-read` and `log-reducer` extensions add no tool schemas. They run as event handlers. The registry holds at most 50 tasks. The widget shows at most 8 rows. The `touched` file list per task is capped at 12 entries. Progress updates emit at most once every 250ms.
|
|
64
|
-
- Child deadlines default to 30 minutes. A shorter `timeoutMs` or profile `maxExecutionTimeMs` is raised to 30 minutes, while longer values pass through. A profile deadline beyond the 32-bit setTimeout ceiling (2,147,483,647 ms) clamps to the ceiling. Node would round a larger delay down to 1 ms and kill the child instantly, so the clamp degrades a bad value to a very long deadline.
|
|
65
|
-
- Children inherit the parent's model by default. Thinking does not inherit: it resolves from the task's `thinking`, then the profile, then `subagent.json`, then `medium`.
|
|
97
|
+
A build or test log above 4,096 bytes, from a diagnostic command pattern, is replaced with a short receipt of quoted lines. Every quote is verified byte for byte against the archived original, and if validation fails the full log reaches the model untouched. The reducer runs on `tool_result`, so the swap happens before the result enters history, and the raw log is archived next to the session.
|
|
66
98
|
|
|
67
99
|
## Install
|
|
68
100
|
|
|
@@ -74,8 +106,13 @@ Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-subagent`
|
|
|
74
106
|
|
|
75
107
|
## Configure
|
|
76
108
|
|
|
77
|
-
- `subagent.json` in the agent dir
|
|
78
|
-
- `BULK_READ=0` disables the
|
|
79
|
-
- `PI_LOG_REDUCER=0` disables the
|
|
80
|
-
|
|
81
|
-
|
|
109
|
+
- `subagent.json` in the agent dir sets the child model and thinking. Shape: `{ "model": "provider/model-id", "thinking": "medium" }`, both optional.
|
|
110
|
+
- `BULK_READ=0` disables the guard. `BULK_READ_MIN_LINES` sets the line threshold.
|
|
111
|
+
- `PI_LOG_REDUCER=0` disables the reducer. `PI_LOG_REDUCER_TIMEOUT_MS` sets its deadline, default 15 seconds, maximum 60.
|
|
112
|
+
|
|
113
|
+
## Design notes
|
|
114
|
+
|
|
115
|
+
- Child deadlines default to 30 minutes. A shorter `timeoutMs` or profile `maxExecutionTimeMs` is raised to it, and longer values pass through. A profile deadline past the 32-bit setTimeout ceiling clamps to the ceiling, because Node rounds a larger delay down to 1 ms and would kill the child instantly.
|
|
116
|
+
- Child thinking resolves from the task, then the profile, then `subagent.json`, then `medium`. It does not inherit the parent's.
|
|
117
|
+
- Read-only scouts run in parallel. A write-capable child is never run alongside another one, and a call whose tasks normalize to the same request is refused.
|
|
118
|
+
- The registry holds at most 50 tasks, the widget 8 rows, the `touched` list per task 12 entries, and progress updates emit at most every 250ms.
|
package/agents/scout.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scout
|
|
3
|
+
description: Cheap read-only I/O and analysis agent. Searches files and docs, traces code, answers one bounded question, and returns a short sourced summary. Never edits, writes, runs commands, or reaches the internet.
|
|
4
|
+
tools: read, grep, find, ls
|
|
5
|
+
inheritSkills: false
|
|
6
|
+
thinking: low
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You are a SCOUT, a cheap read-only I/O and analysis subagent. The caller hands
|
|
10
|
+
you exactly one bounded question in a fresh context. Answer it from what is on
|
|
11
|
+
disk and return a short, sourced answer. You change nothing.
|
|
12
|
+
|
|
13
|
+
Use this profile for file and document search, code tracing, comparison, and
|
|
14
|
+
summarization. If the task asks you to implement, edit, run a check, or
|
|
15
|
+
make a design decision, return that the parent must handle it.
|
|
16
|
+
|
|
17
|
+
Rules:
|
|
18
|
+
|
|
19
|
+
- Read only what the question needs. Use `grep` and `find` to locate, then read
|
|
20
|
+
the specific range. Never read a whole file to answer a narrow question.
|
|
21
|
+
- Prefer file paths, line numbers, exact text, and literal command output over
|
|
22
|
+
general impressions. A summary is useful only when its evidence is included.
|
|
23
|
+
- Answer with evidence: file paths with line numbers, exact text you found, or
|
|
24
|
+
the literal output you observed. No paraphrase of code you did not read.
|
|
25
|
+
- A negative result is a real result. If the answer is not on disk, say what you
|
|
26
|
+
searched and what you ruled out.
|
|
27
|
+
- You have no write, edit, bash, or web tools. Do not ask for them, and do not
|
|
28
|
+
propose patches. Name the gap instead. A task that needs those capabilities
|
|
29
|
+
belongs in the parent session. A question about the
|
|
30
|
+
internet cannot be answered by you at all: say so in one line under NEXT and
|
|
31
|
+
stop, rather than searching the disk for a substitute.
|
|
32
|
+
- Spend your turns on the search that answers the question, and when you are
|
|
33
|
+
told to wrap up, answer with what you have and say what is still unchecked
|
|
34
|
+
under NEXT.
|
|
35
|
+
|
|
36
|
+
Reply with exactly this, and nothing else:
|
|
37
|
+
|
|
38
|
+
STATUS: solved|continue
|
|
39
|
+
FINDING: <the answer, with file:line evidence, at most five lines>
|
|
40
|
+
NEXT: <up to three bullets, or "none">
|
|
41
|
+
|
|
42
|
+
When a `structured_output` tool is available, call it once with the same
|
|
43
|
+
STATUS, FINDING, and NEXT content as its fields, and write no text after it.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tduyng/pi-subagent",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Cheap delegation: a child session reads docs, logs, and source, returns one answer, and its context is thrown away. Scout profile bundled.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
7
7
|
"pi-package",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
"src",
|
|
27
27
|
"!src/**/*.test.ts",
|
|
28
|
+
"agents",
|
|
28
29
|
"README.md",
|
|
29
30
|
"LICENSE"
|
|
30
31
|
],
|
package/src/subagent/agent.ts
CHANGED
|
@@ -101,8 +101,16 @@ export function loadAgentsFrom(dirs: string[]): Map<string, AgentProfile> {
|
|
|
101
101
|
return agents
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
const BUNDLED_AGENTS = path.join(import.meta.dirname, '..', '..', 'agents')
|
|
105
|
+
|
|
106
|
+
// The default `agent` is scout, so a checkout with no profile on disk would refuse every
|
|
107
|
+
// delegation that omits it. The bundled profile is the floor, not an override.
|
|
104
108
|
export function loadAgents(cwd: string): Map<string, AgentProfile> {
|
|
105
|
-
|
|
109
|
+
const agents = loadAgentsFrom(agentDirs(cwd))
|
|
110
|
+
for (const [name, profile] of loadAgentsFrom([BUNDLED_AGENTS])) {
|
|
111
|
+
if (!agents.has(name)) agents.set(name, profile)
|
|
112
|
+
}
|
|
113
|
+
return agents
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
export function parseModelName(name: string): { provider: string; id: string } | undefined {
|
package/src/subagent/durable.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { getAgentDir, type SessionManager } from '@earendil-works/pi-coding-agen
|
|
|
5
5
|
import { Validator, type Infer } from 'ata-validator'
|
|
6
6
|
import { t } from 'ata-validator/t'
|
|
7
7
|
|
|
8
|
-
|
|
9
8
|
import type { JsonPayload } from './structured.js'
|
|
10
9
|
import { RUNS_DIR, type TaskDetails, type ThinkingLevel } from './types.js'
|
|
11
10
|
|
|
@@ -35,21 +34,14 @@ const DurableTaskSchema = t.object({
|
|
|
35
34
|
schema: t.optional(JsonValueSchema),
|
|
36
35
|
worktree: t.optional(t.string()),
|
|
37
36
|
sessionFile: t.optional(t.string()),
|
|
38
|
-
status: t.union([
|
|
39
|
-
t.literal('queued'),
|
|
40
|
-
t.literal('running'),
|
|
41
|
-
t.literal('completed'),
|
|
42
|
-
t.literal('failed')
|
|
43
|
-
]),
|
|
37
|
+
status: t.union([t.literal('queued'), t.literal('running'), t.literal('completed'), t.literal('failed')]),
|
|
44
38
|
output: t.optional(t.string()),
|
|
45
39
|
error: t.optional(t.string()),
|
|
46
40
|
partial: t.optional(t.boolean()),
|
|
47
41
|
structured: t.optional(JsonValueSchema),
|
|
48
42
|
turnCount: t.optional(t.number()),
|
|
49
43
|
toolCallCount: t.optional(t.number()),
|
|
50
|
-
usage: t.optional(
|
|
51
|
-
t.object({ input: t.number(), output: t.number(), cost: t.number() })
|
|
52
|
-
),
|
|
44
|
+
usage: t.optional(t.object({ input: t.number(), output: t.number(), cost: t.number() })),
|
|
53
45
|
touched: t.optional(t.array(t.string())),
|
|
54
46
|
changes: t.optional(t.array(t.string())),
|
|
55
47
|
startedAt: t.optional(t.number()),
|
|
@@ -203,7 +195,10 @@ export function resolveSessionFile(record: DurableTask): string | undefined {
|
|
|
203
195
|
if (record.sessionFile && existsSync(record.sessionFile)) return record.sessionFile
|
|
204
196
|
try {
|
|
205
197
|
const newest = readdirSync(record.sessionDir)
|
|
206
|
-
.map((file) => ({
|
|
198
|
+
.map((file) => ({
|
|
199
|
+
file: path.join(record.sessionDir, file),
|
|
200
|
+
stat: statSync(path.join(record.sessionDir, file))
|
|
201
|
+
}))
|
|
207
202
|
.filter((entry) => entry.file.endsWith('.jsonl') && entry.stat.size > 0)
|
|
208
203
|
.map((entry) => ({ file: entry.file, at: entry.stat.mtimeMs }))
|
|
209
204
|
.toSorted((a, b) => b.at - a.at)[0]
|
|
@@ -247,14 +242,26 @@ function ownerPid(file: string): number | undefined {
|
|
|
247
242
|
|
|
248
243
|
export function claimRun(sessionId: string, runId: string): boolean {
|
|
249
244
|
const ownerFile = path.join(runDirectory(sessionId, runId), OWNER_FILE)
|
|
245
|
+
const claim = (): boolean => {
|
|
246
|
+
try {
|
|
247
|
+
writeFileSync(ownerFile, JSON.stringify({ pid: process.pid, at: Date.now() }), {
|
|
248
|
+
mode: 0o600,
|
|
249
|
+
flag: 'wx'
|
|
250
|
+
})
|
|
251
|
+
return true
|
|
252
|
+
} catch {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (claim()) return true
|
|
250
257
|
const pid = ownerPid(ownerFile)
|
|
251
258
|
if (pid !== undefined && pid !== process.pid && pidAlive(pid)) return false
|
|
252
259
|
try {
|
|
253
|
-
|
|
254
|
-
return true
|
|
260
|
+
rmSync(ownerFile, { force: true })
|
|
255
261
|
} catch {
|
|
256
262
|
return false
|
|
257
263
|
}
|
|
264
|
+
return claim()
|
|
258
265
|
}
|
|
259
266
|
|
|
260
267
|
export function releaseClaim(sessionId: string, runId: string): void {
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
4
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import { Validator } from 'ata-validator'
|
|
6
|
+
import { t } from 'ata-validator/t'
|
|
7
|
+
|
|
8
|
+
const isOpenObject = new Validator(t.object({}, { additionalProperties: true })).isValidObject
|
|
9
|
+
const isString = new Validator(t.string()).isValidObject
|
|
10
|
+
const isNumber = new Validator(t.number()).isValidObject
|
|
11
|
+
|
|
12
|
+
export const CLOAK_CONFIG_FILE = 'cloak.json'
|
|
13
|
+
|
|
14
|
+
function configPath(): string {
|
|
15
|
+
return join(getAgentDir(), CLOAK_CONFIG_FILE)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function projectConfigPath(): string {
|
|
19
|
+
return join(process.cwd(), '.pi', CLOAK_CONFIG_FILE)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CloakPattern {
|
|
23
|
+
pattern: string
|
|
24
|
+
replace?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CloakRule {
|
|
28
|
+
filePattern: string | string[]
|
|
29
|
+
cloakPattern: string | CloakPattern[]
|
|
30
|
+
replace?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CloakConfig {
|
|
34
|
+
enabled?: boolean
|
|
35
|
+
cloakCharacter?: string
|
|
36
|
+
cloakLength?: number | null
|
|
37
|
+
tryAllPatterns?: boolean
|
|
38
|
+
patterns?: CloakRule[]
|
|
39
|
+
vendorKeys?: boolean
|
|
40
|
+
pemBlocks?: boolean
|
|
41
|
+
sensitiveFileTypes?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the file on disk parsed to: the two scalars below are narrowed there, once. */
|
|
45
|
+
interface LoadedConfig extends CloakConfig {
|
|
46
|
+
cloakCharacter: string
|
|
47
|
+
cloakLength?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface CompiledRegex {
|
|
51
|
+
regex: RegExp
|
|
52
|
+
replace?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface CompiledRule {
|
|
56
|
+
fileRegex: RegExp
|
|
57
|
+
regexes: CompiledRegex[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function globToRegExp(glob: string): RegExp {
|
|
61
|
+
let out = '^'
|
|
62
|
+
for (let index = 0; index < glob.length; index++) {
|
|
63
|
+
const char = glob[index]
|
|
64
|
+
if (char === undefined) break
|
|
65
|
+
const next = glob[index + 1]
|
|
66
|
+
if (char === '*' && next === '*') {
|
|
67
|
+
out += '.*'
|
|
68
|
+
index++
|
|
69
|
+
if (glob[index + 1] === '/') index++
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (char === '*') {
|
|
73
|
+
out += '[^/]*'
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
out += char.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
77
|
+
}
|
|
78
|
+
return new RegExp(`${out}$`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function compileRule(rule: CloakRule): CompiledRule {
|
|
82
|
+
const filePatterns = Array.isArray(rule.filePattern) ? rule.filePattern : [rule.filePattern]
|
|
83
|
+
const specs: CloakPattern[] = Array.isArray(rule.cloakPattern)
|
|
84
|
+
? rule.cloakPattern
|
|
85
|
+
: [{ pattern: rule.cloakPattern, replace: rule.replace }]
|
|
86
|
+
return {
|
|
87
|
+
fileRegex: new RegExp(filePatterns.map((pattern) => globToRegExp(pattern).source).join('|')),
|
|
88
|
+
regexes: specs.map((spec) => ({ regex: new RegExp(spec.pattern, 'gi'), replace: spec.replace }))
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function compileRules(patterns: CloakRule[]): CompiledRule[] {
|
|
93
|
+
const compiled: CompiledRule[] = []
|
|
94
|
+
for (const rule of patterns) {
|
|
95
|
+
try {
|
|
96
|
+
compiled.push(compileRule(rule))
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
return compiled
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readConfigFile(path: string): CloakConfig {
|
|
103
|
+
try {
|
|
104
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
105
|
+
return isOpenObject(parsed) && !Array.isArray(parsed) ? (parsed as CloakConfig) : {}
|
|
106
|
+
} catch {
|
|
107
|
+
return {}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function mergeCloakConfig(base: CloakConfig, project: CloakConfig): CloakConfig {
|
|
112
|
+
return {
|
|
113
|
+
...base,
|
|
114
|
+
patterns: [
|
|
115
|
+
...(Array.isArray(base.patterns) ? base.patterns : []),
|
|
116
|
+
...(Array.isArray(project.patterns) ? project.patterns : [])
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function loadConfig(): LoadedConfig {
|
|
122
|
+
try {
|
|
123
|
+
const parsed = mergeCloakConfig(readConfigFile(configPath()), readConfigFile(projectConfigPath()))
|
|
124
|
+
const length = parsed.cloakLength
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
enabled: true,
|
|
128
|
+
...parsed,
|
|
129
|
+
cloakCharacter: isString(parsed.cloakCharacter) ? parsed.cloakCharacter : '*',
|
|
130
|
+
cloakLength: isNumber(length) && Number.isFinite(length) && length >= 0 ? length : undefined,
|
|
131
|
+
patterns: Array.isArray(parsed.patterns) ? parsed.patterns : []
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
return { enabled: true, cloakCharacter: '*', patterns: [] }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function mask(match: string, cloakChar: string, cloakLength: number | null | undefined): string {
|
|
139
|
+
const visible = match.slice(0, 1)
|
|
140
|
+
const target = cloakLength ?? Math.max(match.length, 1)
|
|
141
|
+
return visible + cloakChar.repeat(Math.max(0, target - 1))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface Match {
|
|
145
|
+
start: number
|
|
146
|
+
end: number
|
|
147
|
+
replacement: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface VendorPattern {
|
|
151
|
+
name: string
|
|
152
|
+
prefix: string
|
|
153
|
+
tail: RegExp
|
|
154
|
+
wordBoundary: boolean
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const VENDOR: VendorPattern[] = [
|
|
158
|
+
{ name: 'AWS Access Key', prefix: 'AKIA', tail: /^AKIA[A-Z0-9]{16}\b/, wordBoundary: true },
|
|
159
|
+
{ name: 'AWS Temp Access Key', prefix: 'ASIA', tail: /^ASIA[A-Z0-9]{16}\b/, wordBoundary: true },
|
|
160
|
+
{ name: 'Bearer Token', prefix: 'Bearer', tail: /^Bearer\s+[a-zA-Z0-9._-]{20,}\b/, wordBoundary: true },
|
|
161
|
+
{ name: 'OpenAI/Anthropic API Key', prefix: 'sk-', tail: /^sk-[a-zA-Z0-9._-]{20,}\b/, wordBoundary: true },
|
|
162
|
+
{ name: 'Stripe Live Key', prefix: 'sk_live_', tail: /^sk_live_[a-zA-Z0-9]{20,}\b/, wordBoundary: true },
|
|
163
|
+
{ name: 'Stripe Test Key', prefix: 'sk_test_', tail: /^sk_test_[a-zA-Z0-9]{20,}\b/, wordBoundary: true },
|
|
164
|
+
{ name: 'GitHub Token', prefix: 'gh', tail: /^gh[pousr]_[a-zA-Z0-9]{36,}\b/, wordBoundary: true },
|
|
165
|
+
{
|
|
166
|
+
name: 'GitHub Fine-grained PAT',
|
|
167
|
+
prefix: 'github_pat_',
|
|
168
|
+
tail: /^github_pat_[a-zA-Z0-9_]{20,}\b/,
|
|
169
|
+
wordBoundary: true
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'JWT',
|
|
173
|
+
prefix: 'eyJ',
|
|
174
|
+
tail: /^eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
|
|
175
|
+
wordBoundary: true
|
|
176
|
+
},
|
|
177
|
+
{ name: 'Slack Token', prefix: 'xoxb-', tail: /^xox[baprs]-[A-Za-z0-9-]{20,}\b/, wordBoundary: true },
|
|
178
|
+
{ name: 'GitLab Token', prefix: 'glpat-', tail: /^glpat-[A-Za-z0-9_-]{20,}\b/, wordBoundary: true },
|
|
179
|
+
{ name: 'Google API Key', prefix: 'AIza', tail: /^AIza[A-Za-z0-9_-]{35}\b/, wordBoundary: true },
|
|
180
|
+
{ name: 'npm Token', prefix: 'npm_', tail: /^npm_[a-zA-Z0-9]{36,}\b/, wordBoundary: true },
|
|
181
|
+
{
|
|
182
|
+
name: 'SendGrid API Key',
|
|
183
|
+
prefix: 'SG.',
|
|
184
|
+
tail: /^SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{32,}\b/,
|
|
185
|
+
wordBoundary: true
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
function isWordChar(c: string): boolean {
|
|
190
|
+
const code = c.charCodeAt(0)
|
|
191
|
+
return (
|
|
192
|
+
(code >= 0x30 && code <= 0x39) ||
|
|
193
|
+
(code >= 0x41 && code <= 0x5a) ||
|
|
194
|
+
(code >= 0x61 && code <= 0x7a) ||
|
|
195
|
+
code === 0x5f
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function buildMarker(value: string, type: string): string {
|
|
200
|
+
const prefix = value.slice(0, 4)
|
|
201
|
+
const stars = '*'.repeat(Math.min(20, Math.max(8, value.length - 4)))
|
|
202
|
+
return `${prefix}${stars}[REDACTED:${type}]`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function vendorScan(text: string): Match[] {
|
|
206
|
+
const matches: Match[] = []
|
|
207
|
+
for (const { name, prefix, tail, wordBoundary } of VENDOR) {
|
|
208
|
+
let idx = 0
|
|
209
|
+
while (idx < text.length) {
|
|
210
|
+
const found = text.indexOf(prefix, idx)
|
|
211
|
+
if (found === -1) break
|
|
212
|
+
if (wordBoundary) {
|
|
213
|
+
const prev = found > 0 ? text[found - 1] : ''
|
|
214
|
+
if (prev && isWordChar(prev)) {
|
|
215
|
+
idx = found + 1
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const window = text.slice(found)
|
|
220
|
+
const m = tail.exec(window)
|
|
221
|
+
if (!m) {
|
|
222
|
+
idx = found + 1
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
const start = found
|
|
226
|
+
const end = found + m[0].length
|
|
227
|
+
matches.push({ start, end, replacement: buildMarker(m[0], name) })
|
|
228
|
+
idx = end
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return matches
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const PEM_BLOCK_REGEX = /-----BEGIN ([A-Z ]+)-----\r?\n([\s\S]*?)(?:\r?\n-----END \1-----|$)/g
|
|
235
|
+
|
|
236
|
+
export function pemScan(text: string): Match[] {
|
|
237
|
+
const matches: Match[] = []
|
|
238
|
+
PEM_BLOCK_REGEX.lastIndex = 0
|
|
239
|
+
let m: RegExpExecArray | null
|
|
240
|
+
while ((m = PEM_BLOCK_REGEX.exec(text)) !== null) {
|
|
241
|
+
matches.push({ start: m.index, end: m.index + m[0].length, replacement: `[REDACTED:PEM ${m[1] ?? ''}]` })
|
|
242
|
+
}
|
|
243
|
+
return matches
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A quoted JSON field whose name is a credential word, and whose value is shaped like one:
|
|
248
|
+
* no whitespace, at least 16 characters, and a digit. Without the shape the name alone
|
|
249
|
+
* masks `"tokenizers": "0.22.2"` and `"token": "ts-node -T -s scripts/cli/ts/token"`,
|
|
250
|
+
* which is most of a package.json.
|
|
251
|
+
*/
|
|
252
|
+
const CREDENTIAL_FIELD_REGEX =
|
|
253
|
+
/("(?:[a-z0-9_-]*(?:password|passwd|secret|token|api[_-]?key|authorization|private[_-]?key)[a-z0-9_-]*)"\s*:\s*")(?=[^"\s]{16,}")(?=[^"]*\d)[^"]+"/gi
|
|
254
|
+
|
|
255
|
+
export function credentialFieldScan(text: string): Match[] {
|
|
256
|
+
const matches: Match[] = []
|
|
257
|
+
CREDENTIAL_FIELD_REGEX.lastIndex = 0
|
|
258
|
+
let m: RegExpExecArray | null
|
|
259
|
+
while ((m = CREDENTIAL_FIELD_REGEX.exec(text)) !== null) {
|
|
260
|
+
const start = m.index + m[1]!.length
|
|
261
|
+
matches.push({ start, end: m.index + m[0].length - 1, replacement: '[REDACTED:credential field]' })
|
|
262
|
+
}
|
|
263
|
+
return matches
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function applyMatches(text: string, matches: Match[]): string {
|
|
267
|
+
if (matches.length === 0) return text
|
|
268
|
+
const sorted = matches.toSorted((a, b) => a.start - b.start)
|
|
269
|
+
const parts: string[] = []
|
|
270
|
+
let cursor = 0
|
|
271
|
+
for (const m of sorted) {
|
|
272
|
+
if (m.start < cursor) continue
|
|
273
|
+
if (m.start > cursor) parts.push(text.slice(cursor, m.start))
|
|
274
|
+
parts.push(m.replacement)
|
|
275
|
+
cursor = m.end
|
|
276
|
+
}
|
|
277
|
+
if (cursor < text.length) parts.push(text.slice(cursor))
|
|
278
|
+
return parts.join('')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const SENSITIVE_PATH_PATTERNS: { pattern: RegExp; type: string }[] = [
|
|
282
|
+
{ pattern: /\.pem$/i, type: 'PEM File' },
|
|
283
|
+
{ pattern: /\.crt$/i, type: 'Certificate File' },
|
|
284
|
+
{ pattern: /\.cer$/i, type: 'Certificate File' },
|
|
285
|
+
{ pattern: /\.p12$/i, type: 'PKCS12 File' },
|
|
286
|
+
{ pattern: /\.pfx$/i, type: 'PKCS12 File' },
|
|
287
|
+
{ pattern: /(^|\/)\.ssh\/id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i, type: 'SSH Key' },
|
|
288
|
+
{ pattern: /\.ssh\/known_hosts$/i, type: 'SSH Known Hosts' },
|
|
289
|
+
{ pattern: /\.ssh\/authorized_keys$/i, type: 'SSH Authorized Keys' },
|
|
290
|
+
{ pattern: /\.aws\/credentials$/i, type: 'AWS Credentials' },
|
|
291
|
+
{ pattern: /\.npmrc$/i, type: 'npm Config' },
|
|
292
|
+
{ pattern: /\.pypirc$/i, type: 'PyPI Config' },
|
|
293
|
+
{ pattern: /\.netrc$/i, type: 'Netrc File' },
|
|
294
|
+
{ pattern: /\.docker\/config\.json$/i, type: 'Docker Config' },
|
|
295
|
+
{ pattern: /\.kube\/config$/i, type: 'Kube Config' },
|
|
296
|
+
{ pattern: /(?:^|\/)\.env(?:\.[\w-]+)?$/i, type: 'Environment File' }
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
export function matchSensitivePath(path: string): string | null {
|
|
300
|
+
for (const { pattern, type } of SENSITIVE_PATH_PATTERNS) if (pattern.test(path)) return type
|
|
301
|
+
return null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Canonical copy; pi-subagent/src/redact.ts mirrors it so that package stands alone.
|
|
305
|
+
const SIDE_MODEL_REDACTIONS: Array<[RegExp, string]> = [
|
|
306
|
+
[
|
|
307
|
+
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
|
|
308
|
+
'[redacted private key block]'
|
|
309
|
+
],
|
|
310
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, '[redacted aws access key]'],
|
|
311
|
+
[/\bsk-[A-Za-z0-9_-]{20,}/g, '[redacted api key]'],
|
|
312
|
+
[/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer [redacted]'],
|
|
313
|
+
[/^(\s*(?:export\s+)?[A-Za-z_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z_]*\s*=\s*)("?)[^"]+\2$/gm, '$1$2[redacted]$2']
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
export function redactSecrets(text: string): string {
|
|
317
|
+
let out = text
|
|
318
|
+
for (const [re, replacement] of SIDE_MODEL_REDACTIONS) out = out.replace(re, replacement)
|
|
319
|
+
return out
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export default function (pi: ExtensionAPI) {
|
|
323
|
+
const config = loadConfig()
|
|
324
|
+
|
|
325
|
+
pi.registerCommand('cloak-status', {
|
|
326
|
+
description: 'Show cloak config status',
|
|
327
|
+
handler: async (_args, ctx) => {
|
|
328
|
+
const status = config.enabled === false ? 'disabled' : 'enabled'
|
|
329
|
+
ctx.ui.notify(
|
|
330
|
+
`cloak ${status}, patterns=${(config.patterns ?? []).length}, ` +
|
|
331
|
+
`vendorKeys=${config.vendorKeys !== false}, pemBlocks=${config.pemBlocks !== false}, ` +
|
|
332
|
+
`sensitiveFileTypes=${config.sensitiveFileTypes !== false}`,
|
|
333
|
+
'info'
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
if (config.enabled === false) return
|
|
339
|
+
const rules = compileRules(config.patterns ?? [])
|
|
340
|
+
const vendorKeysEnabled = config.vendorKeys !== false
|
|
341
|
+
const pemBlocksEnabled = config.pemBlocks !== false
|
|
342
|
+
const sensitiveFileTypesEnabled = config.sensitiveFileTypes !== false
|
|
343
|
+
if (rules.length === 0 && !vendorKeysEnabled && !pemBlocksEnabled && !sensitiveFileTypesEnabled) return
|
|
344
|
+
|
|
345
|
+
const cloakChar = config.cloakCharacter
|
|
346
|
+
const cloakLength = config.cloakLength ?? null
|
|
347
|
+
|
|
348
|
+
pi.on('tool_result', (event) => {
|
|
349
|
+
if (!Array.isArray(event.content)) return
|
|
350
|
+
const input = event.input as { path?: string }
|
|
351
|
+
const rawPath = event.toolName === 'read' ? (input.path ?? '') : ''
|
|
352
|
+
|
|
353
|
+
const sensitiveType = sensitiveFileTypesEnabled && rawPath ? matchSensitivePath(rawPath) : null
|
|
354
|
+
const matchingRules = rawPath ? rules.filter((rule) => rule.fileRegex.test(rawPath)) : []
|
|
355
|
+
if (!sensitiveType && matchingRules.length === 0 && !vendorKeysEnabled && !pemBlocksEnabled) return
|
|
356
|
+
|
|
357
|
+
let changed = false
|
|
358
|
+
const content = event.content.map((part) => {
|
|
359
|
+
if (part.type !== 'text') return part
|
|
360
|
+
|
|
361
|
+
if (sensitiveType) {
|
|
362
|
+
changed = true
|
|
363
|
+
return { ...part, text: `[REDACTED:Protected File (${sensitiveType})]` }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let text = part.text
|
|
367
|
+
for (const rule of matchingRules) {
|
|
368
|
+
for (const compiled of rule.regexes) {
|
|
369
|
+
const next =
|
|
370
|
+
compiled.replace !== undefined
|
|
371
|
+
? text.replace(compiled.regex, compiled.replace)
|
|
372
|
+
: text.replace(compiled.regex, (match) => mask(match, cloakChar, cloakLength))
|
|
373
|
+
if (next !== text) {
|
|
374
|
+
changed = true
|
|
375
|
+
text = next
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (config.tryAllPatterns === false) break
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const structuralMatches = [
|
|
382
|
+
...(vendorKeysEnabled ? [...vendorScan(text), ...credentialFieldScan(text)] : []),
|
|
383
|
+
...(pemBlocksEnabled ? pemScan(text) : [])
|
|
384
|
+
]
|
|
385
|
+
if (structuralMatches.length > 0) {
|
|
386
|
+
changed = true
|
|
387
|
+
text = applyMatches(text, structuralMatches)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return changed ? { ...part, text } : part
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
if (!changed) return
|
|
394
|
+
return { content }
|
|
395
|
+
})
|
|
396
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { Validator } from 'ata-validator'
|
|
3
|
+
import { t } from 'ata-validator/t'
|
|
4
|
+
|
|
5
|
+
const SENSITIVE_COMMAND_RE =
|
|
6
|
+
/(?:^|[\s"'=:~]|\/)\.env(?:\.[A-Za-z0-9_-]+)?(?=$|[\s"';&|),\]} ])|(?:^|[\s"'=:~]|\/)(?:[^\s"';&|]*\/)?(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)(?=$|[\s"';&|),\]} ])|(?:^|[\s"'=:~]|\/)[^\s"';&|]*\.(?:pem|key|p12|pfx)(?=$|[\s"';&|),\]} ])/i
|
|
7
|
+
|
|
8
|
+
const SENSITIVE_REASON =
|
|
9
|
+
'BLOCKED: shell access to a credential or private-key file is not allowed, and a read of one ' +
|
|
10
|
+
'comes back redacted. Ask the user for the value you need.'
|
|
11
|
+
|
|
12
|
+
const BLOCK_REASON =
|
|
13
|
+
'BLOCKED: --no-verify is not allowed. Git hooks exist for a reason. ' +
|
|
14
|
+
'Do not attempt to bypass them. Instead: fix the underlying issue causing ' +
|
|
15
|
+
'the hook to fail, or ask the user for help.'
|
|
16
|
+
|
|
17
|
+
const CommandInput = t.object({
|
|
18
|
+
command: t.optional(t.string()),
|
|
19
|
+
then_run: t.optional(t.union([t.string(), t.object({ command: t.string() })]))
|
|
20
|
+
})
|
|
21
|
+
const isCommandInput = new Validator(CommandInput).isValidObject
|
|
22
|
+
const isThenRunCommand = new Validator(t.object({ command: t.string() })).isValidObject
|
|
23
|
+
const isThenRunText = new Validator(t.string()).isValidObject
|
|
24
|
+
|
|
25
|
+
function words(segment: string): string[] {
|
|
26
|
+
const found = segment.match(/"(?:[^"\\]|\\.)*"|'[^']*'|[^\s]+/g) ?? []
|
|
27
|
+
return found.map((word) => word.replace(/^(["'])(.*)\1$/, '$2'))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function blocksHooks(command: string): boolean {
|
|
31
|
+
if (!/\bgit\b/.test(command)) return false
|
|
32
|
+
for (const segment of command.split(/[;&|\n]+/)) {
|
|
33
|
+
const tokens = words(segment)
|
|
34
|
+
const git = tokens.findIndex((token) => token === 'git' || token.endsWith('/git'))
|
|
35
|
+
if (git === -1) continue
|
|
36
|
+
const args = tokens.slice(git + 1)
|
|
37
|
+
const commit = args.findIndex((token) => token === 'commit')
|
|
38
|
+
if (commit === -1) continue
|
|
39
|
+
if (args.includes('--no-verify')) return true
|
|
40
|
+
if (args.some((token, index) => token === '-c' && (args[index + 1]?.startsWith('core.hooksPath=') ?? false)))
|
|
41
|
+
return true
|
|
42
|
+
const after = args.slice(commit + 1)
|
|
43
|
+
const message = after.findIndex(
|
|
44
|
+
(token) => token === '-m' || token === '--message' || token.startsWith('--message=')
|
|
45
|
+
)
|
|
46
|
+
const flags = message === -1 ? after : after.slice(0, message)
|
|
47
|
+
if (flags.some((token) => /^-[A-Za-z]*n[A-Za-z]*$/.test(token))) return true
|
|
48
|
+
}
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function commands(input: { command?: string; then_run?: string | { command: string } }): string[] {
|
|
53
|
+
const found: string[] = []
|
|
54
|
+
if (input.command !== undefined) found.push(input.command)
|
|
55
|
+
if (isThenRunCommand(input.then_run)) found.push(input.then_run.command)
|
|
56
|
+
else if (isThenRunText(input.then_run)) found.push(input.then_run)
|
|
57
|
+
return found
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default function (pi: ExtensionAPI) {
|
|
61
|
+
process.env['GIT_EDITOR'] = 'true'
|
|
62
|
+
process.env['GIT_SEQUENCE_EDITOR'] = 'true'
|
|
63
|
+
process.env['GIT_MERGE_AUTOEDIT'] = 'no'
|
|
64
|
+
|
|
65
|
+
pi.on('tool_call', (event) => {
|
|
66
|
+
if (!isCommandInput(event.input)) return
|
|
67
|
+
for (const command of commands(event.input)) {
|
|
68
|
+
if (command.trim() === '') continue
|
|
69
|
+
if (blocksHooks(command)) return { block: true, reason: BLOCK_REASON }
|
|
70
|
+
if (SENSITIVE_COMMAND_RE.test(command)) return { block: true, reason: SENSITIVE_REASON }
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
package/src/subagent/index.ts
CHANGED
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
} from './types.js'
|
|
70
70
|
import { SubagentViewer, widgetLines } from './viewer.js'
|
|
71
71
|
|
|
72
|
-
export { parseFrontmatter, loadAgentsFrom, parseModelName } from './agent.js'
|
|
72
|
+
export { parseFrontmatter, loadAgents, loadAgentsFrom, parseModelName } from './agent.js'
|
|
73
73
|
|
|
74
74
|
const MAX_FORK_CONTEXT_CHARS = 120_000
|
|
75
75
|
const RUN_SAVE_INTERVAL_MS = 5_000
|
package/src/subagent/session.ts
CHANGED
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
type AgentSession
|
|
9
9
|
} from '@earendil-works/pi-coding-agent'
|
|
10
10
|
|
|
11
|
-
import cloak from '
|
|
12
|
-
import gitInterceptor from '
|
|
11
|
+
import cloak from './guards/cloak.js'
|
|
12
|
+
import gitInterceptor from './guards/git-interceptor.js'
|
|
13
13
|
import { childPromptExtras } from './helpers.js'
|
|
14
14
|
import { makeStructuredOutputTool, structuredOutputInstruction, type JsonPayload } from './structured.js'
|
|
15
15
|
import type { AgentProfile, ThinkingLevel } from './types.js'
|
package/src/subagent/state.ts
CHANGED
|
@@ -26,7 +26,11 @@ export function sweepRegistry(): void {
|
|
|
26
26
|
const finished = task.status === 'completed' || task.status === 'failed'
|
|
27
27
|
if (finished && task.finishedAt !== undefined && now - task.finishedAt > retentionFor(task)) evict(i)
|
|
28
28
|
}
|
|
29
|
-
while (registry.length > MAX_REGISTRY)
|
|
29
|
+
while (registry.length > MAX_REGISTRY) {
|
|
30
|
+
const index = registry.findIndex((task) => task.status === 'completed' || task.status === 'failed')
|
|
31
|
+
if (index === -1) break
|
|
32
|
+
evict(index)
|
|
33
|
+
}
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export function nextExpiryAt(): number | undefined {
|