@tduyng/pi-subagent 0.0.1 → 0.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tduyng/pi-subagent",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Delegation to in-process child sessions: a subagent tool with agent profiles and worktree isolation, a side chat, a guard that redirects bulk reads, and a verified log reducer.",
5
5
  "keywords": [
6
6
  "pi",
@@ -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) => ({ file: path.join(record.sessionDir, file), stat: statSync(path.join(record.sessionDir, 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
- writeFileSync(ownerFile, JSON.stringify({ pid: process.pid, at: Date.now() }), { mode: 0o600 })
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
+ }
@@ -8,8 +8,8 @@ import {
8
8
  type AgentSession
9
9
  } from '@earendil-works/pi-coding-agent'
10
10
 
11
- import cloak from '../../../pi-guard/src/cloak.js'
12
- import gitInterceptor from '../../../pi-guard/src/git-interceptor.js'
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'
@@ -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) evict(0)
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 {