@tduyng/pi-subagent 0.0.0-stage → 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.
@@ -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
+ }
@@ -0,0 +1,212 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { stat } from 'node:fs/promises'
3
+ import { basename, dirname } from 'node:path'
4
+ import { promisify } from 'node:util'
5
+
6
+ import { DEFAULT_MAX_TURNS, MAX_CONCURRENT } from './types.js'
7
+
8
+ export const MAX_DELAY_MS = 2_147_483_647
9
+
10
+ export const MIN_TIMEOUT_MS = 30 * 60_000
11
+
12
+ export async function abortableDelay(ms: number, signal: AbortSignal | undefined): Promise<void> {
13
+ if (signal?.aborted) throw new Error('Subagent aborted.')
14
+ ms = Math.min(ms, MAX_DELAY_MS)
15
+ await new Promise<void>((resolve, reject) => {
16
+ const cleanup = () => signal?.removeEventListener('abort', onAbort)
17
+ const timer = setTimeout(() => {
18
+ cleanup()
19
+ resolve()
20
+ }, ms)
21
+ const onAbort = () => {
22
+ clearTimeout(timer)
23
+ cleanup()
24
+ reject(new Error('Subagent aborted.'))
25
+ }
26
+ signal?.addEventListener('abort', onAbort, { once: true })
27
+ })
28
+ }
29
+
30
+ export function resolveTimeoutMs(ms: number | undefined): number | undefined {
31
+ return ms !== undefined && Number.isFinite(ms) && ms > 0 ? Math.max(ms, MIN_TIMEOUT_MS) : undefined
32
+ }
33
+
34
+ export function resolveMaxTurns(requested: number | undefined, profile: number | undefined): number {
35
+ const valid = (value: number | undefined): value is number =>
36
+ value !== undefined && Number.isFinite(value) && value >= 1
37
+ const fallback = valid(profile) ? profile : DEFAULT_MAX_TURNS
38
+ return Math.floor(valid(requested) ? requested : fallback)
39
+ }
40
+
41
+ export function wrapUpMessage(hasSchema: boolean): string {
42
+ const close = hasSchema
43
+ ? 'call `structured_output` now with what you already have'
44
+ : 'reply now with your final answer'
45
+ return `Turn budget spent: this is your last turn. Stop investigating and ${close}, naming what you could not finish.`
46
+ }
47
+
48
+ export function structuredRecoveryMessage(attempt: number): string {
49
+ const lead =
50
+ 'You finished without calling the `structured_output` tool. Do not investigate further. Call `structured_output` now, exactly once, with the fields the task requires, using the findings you already have.'
51
+ return attempt <= 1 ? lead : `${lead} The parent cannot use a plain text answer for this task; this is reminder ${attempt}.`
52
+ }
53
+
54
+ export function isProviderSchemaError(error: Error | string): boolean {
55
+ const message = error instanceof Error ? error.message : error
56
+ return (
57
+ /tool[_ ]schema|schema.*invalid|invalid[_ ]schema|structured[_ ]output|json_schema|response[_ ]format|response_format/i.test(
58
+ message
59
+ ) || /invalid request parameters/i.test(message)
60
+ )
61
+ }
62
+
63
+ export function shouldRetryWithoutSchema(error: Error | string, hasSchema: boolean): boolean {
64
+ return hasSchema && isProviderSchemaError(error)
65
+ }
66
+
67
+ export interface RunOutcome {
68
+ hasAssistant: boolean
69
+ timedOut: boolean
70
+ duration: string | undefined
71
+ hitTurnLimit: boolean
72
+ maxTurns: number
73
+ aborted: boolean
74
+ hasSchema: boolean
75
+ hasStructured: boolean
76
+ output: string
77
+ stopReason: string | undefined
78
+ errorMessage: string | undefined
79
+ }
80
+
81
+ export function failureReason(outcome: RunOutcome): string | undefined {
82
+ if (outcome.timedOut) {
83
+ return `Timed out after ${outcome.duration ?? 'the deadline'}. Raise timeoutMs or narrow the task.`
84
+ }
85
+ if (outcome.aborted || (!outcome.hitTurnLimit && outcome.stopReason === 'aborted')) return 'Cancelled'
86
+ if (!outcome.hasAssistant) return 'Child produced no response.'
87
+ if (outcome.stopReason === 'error') return outcome.errorMessage ?? 'Child produced no response.'
88
+ if (outcome.hitTurnLimit && !outcome.output && !outcome.hasStructured) return 'Child produced no response.'
89
+ return undefined
90
+ }
91
+
92
+ export function salvageOutput(assistant: { content?: unknown } | undefined): string | undefined {
93
+ if (!assistant) return undefined
94
+ const content = assistant.content
95
+ if (!Array.isArray(content)) return undefined
96
+ const text = content
97
+ .filter((part): part is { type: 'text'; text: string } => {
98
+ if (!part) return false
99
+ const p = part as { type: unknown; text: unknown }
100
+ return p.type === 'text' && p.text != null
101
+ })
102
+ .map((part) => String(part.text))
103
+ .join('')
104
+ .trim()
105
+ return text || undefined
106
+ }
107
+
108
+ export function salvageFromMessages(messages: readonly unknown[] | undefined): string | undefined {
109
+ if (!messages) return undefined
110
+ for (let index = messages.length - 1; index >= 0; index--) {
111
+ const message = messages[index] as { role?: unknown; content?: unknown } | undefined
112
+ if (!message || message.role !== 'assistant') continue
113
+ const text = salvageOutput(message)
114
+ if (text) return text
115
+ }
116
+ return undefined
117
+ }
118
+
119
+ export function childPromptExtras(
120
+ model: { baseUrl?: string } | undefined,
121
+ parentPrompt: string,
122
+ schemaInstruction: string | undefined
123
+ ): string[] {
124
+ const inherited = model?.baseUrl === 'claude-bridge' ? parentPrompt.trim() : ''
125
+ return [inherited, schemaInstruction].filter((part): part is string => Boolean(part))
126
+ }
127
+
128
+ export async function validateCwd(cwd: string): Promise<void> {
129
+ let info
130
+ try {
131
+ info = await stat(cwd)
132
+ } catch {
133
+ throw new Error(`Subagent working directory does not exist: ${cwd}`)
134
+ }
135
+ if (!info.isDirectory()) throw new Error(`Subagent working directory is not a directory: ${cwd}`)
136
+ }
137
+
138
+ const execFileAsync = promisify(execFile)
139
+
140
+ export async function createWorktree(repo: string, target: string): Promise<void> {
141
+ try {
142
+ await execFileAsync('git', ['-C', repo, 'worktree', 'add', '-b', worktreeBranch(target), target, 'HEAD'])
143
+ } catch (error) {
144
+ const reason = error instanceof Error ? error.message : String(error)
145
+ throw new Error(`Could not create a worktree under ${repo}: ${reason}`, { cause: error })
146
+ }
147
+ }
148
+
149
+ /** The branch a worktree at `target` commits to, named after the task directory holding it. */
150
+ export function worktreeBranch(target: string): string {
151
+ return `pi-task-${basename(dirname(target))}`
152
+ }
153
+
154
+ export function gitStatus(repo: string): Promise<string> {
155
+ return execFileAsync('git', ['-C', repo, 'status', '--porcelain']).then(
156
+ (result) => result.stdout,
157
+ () => ''
158
+ )
159
+ }
160
+
161
+ export function changedPaths(status: string): string[] {
162
+ return status
163
+ .split('\n')
164
+ .filter((line) => line.trim())
165
+ .map((line) => {
166
+ const arrow = line.indexOf(' -> ')
167
+ const raw = arrow >= 0 ? line.slice(arrow + 4) : line.slice(3)
168
+ return raw.trim().replace(/^"|"$/g, '')
169
+ })
170
+ }
171
+
172
+ let permitsInUse = 0
173
+ const permitWaiters: Array<() => void> = []
174
+
175
+ export async function acquirePermit(signal: AbortSignal | undefined): Promise<void> {
176
+ if (signal?.aborted) throw new Error('Subagent aborted while queued.')
177
+ if (permitsInUse < MAX_CONCURRENT) {
178
+ permitsInUse++
179
+ return
180
+ }
181
+ await new Promise<void>((resolve, reject) => {
182
+ const waiter = () => {
183
+ signal?.removeEventListener('abort', onAbort)
184
+ permitsInUse++
185
+ resolve()
186
+ }
187
+ const onAbort = () => {
188
+ const index = permitWaiters.indexOf(waiter)
189
+ if (index >= 0) permitWaiters.splice(index, 1)
190
+ reject(new Error('Subagent aborted while queued.'))
191
+ }
192
+ permitWaiters.push(waiter)
193
+ signal?.addEventListener('abort', onAbort, { once: true })
194
+ })
195
+ }
196
+
197
+ export function releasePermit(): void {
198
+ permitsInUse--
199
+ permitWaiters.shift()?.()
200
+ }
201
+
202
+ export function findDuplicateTask(tasks: readonly string[]): { first: number; second: number } | undefined {
203
+ const seen = new Map<string, number>()
204
+ for (let index = 0; index < tasks.length; index++) {
205
+ const key = (tasks[index] ?? '').trim().replace(/\s+/g, ' ').toLowerCase()
206
+ if (!key) continue
207
+ const first = seen.get(key)
208
+ if (first !== undefined) return { first, second: index }
209
+ seen.set(key, index)
210
+ }
211
+ return undefined
212
+ }