@tduyng/pi-subagent 0.0.0-stage → 0.0.1

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,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
+ }