@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,271 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
2
+ import * as path from 'node:path'
3
+
4
+ import { getAgentDir, type SessionManager } from '@earendil-works/pi-coding-agent'
5
+ import { Validator, type Infer } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+ import type { JsonPayload } from './structured.js'
9
+ import { RUNS_DIR, type TaskDetails, type ThinkingLevel } from './types.js'
10
+
11
+ const RUN_FILE = 'run.json'
12
+ const OWNER_FILE = 'owner.json'
13
+ export const MAX_CHECKPOINT_OUTPUT = 64 * 1024
14
+ export const CONTINUE_PROMPT =
15
+ 'Continue your previous task from where you stopped. Finish the work and reply with your final answer, naming anything you could not finish.'
16
+
17
+ const JsonValueSchema = t.any()
18
+
19
+ const DurableTaskSchema = t.object({
20
+ id: t.string(),
21
+ index: t.number(),
22
+ label: t.string(),
23
+ task: t.string(),
24
+ cwd: t.string(),
25
+ provider: t.string(),
26
+ model: t.string(),
27
+ thinking: t.string(),
28
+ maxTurns: t.number(),
29
+ timeoutMs: t.optional(t.number()),
30
+ isolation: t.optional(t.literal('worktree')),
31
+ forkContext: t.boolean(),
32
+ sessionDir: t.string(),
33
+ agent: t.optional(t.string()),
34
+ schema: t.optional(JsonValueSchema),
35
+ worktree: t.optional(t.string()),
36
+ sessionFile: t.optional(t.string()),
37
+ status: t.union([t.literal('queued'), t.literal('running'), t.literal('completed'), t.literal('failed')]),
38
+ output: t.optional(t.string()),
39
+ error: t.optional(t.string()),
40
+ partial: t.optional(t.boolean()),
41
+ structured: t.optional(JsonValueSchema),
42
+ turnCount: t.optional(t.number()),
43
+ toolCallCount: t.optional(t.number()),
44
+ usage: t.optional(t.object({ input: t.number(), output: t.number(), cost: t.number() })),
45
+ touched: t.optional(t.array(t.string())),
46
+ changes: t.optional(t.array(t.string())),
47
+ startedAt: t.optional(t.number()),
48
+ finishedAt: t.optional(t.number())
49
+ })
50
+
51
+ const OwnerSchema = t.object({ pid: t.number(), at: t.number() })
52
+ const RecoveryDetailsSchema = t.object({ runId: t.string() })
53
+ const isRecoveryDetailsSchema = new Validator(RecoveryDetailsSchema).isValidObject
54
+
55
+ const DurableRunSchema = t.object({
56
+ version: t.literal(1),
57
+ runId: t.string(),
58
+ sessionId: t.string(),
59
+ toolCallId: t.optional(t.string()),
60
+ startedAt: t.number(),
61
+ finishedAt: t.optional(t.number()),
62
+ tasks: t.array(DurableTaskSchema)
63
+ })
64
+
65
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] }
66
+ export type DurableTask = Mutable<Omit<Infer<typeof DurableTaskSchema>, 'thinking' | 'schema' | 'structured'>> & {
67
+ thinking: ThinkingLevel
68
+ schema?: JsonPayload
69
+ structured?: JsonPayload
70
+ }
71
+ export type DurableRun = Mutable<Omit<Infer<typeof DurableRunSchema>, 'tasks'>> & { tasks: DurableTask[] }
72
+ const isDurableRunSchema = new Validator(DurableRunSchema).isValidObject
73
+ const isOwnerSchema = new Validator(OwnerSchema).isValidObject
74
+
75
+ function isDurableRun(value: unknown): value is DurableRun {
76
+ return isDurableRunSchema(value)
77
+ }
78
+
79
+ export function runDirectory(sessionId: string, runId: string): string {
80
+ return path.join(getAgentDir(), RUNS_DIR, sessionId, runId)
81
+ }
82
+
83
+ export function saveRun(run: DurableRun): void {
84
+ try {
85
+ const directory = runDirectory(run.sessionId, run.runId)
86
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
87
+ const target = path.join(directory, RUN_FILE)
88
+ const temporary = `${target}.tmp`
89
+ writeFileSync(temporary, JSON.stringify(boundedRun(run)), { mode: 0o600 })
90
+ renameSync(temporary, target)
91
+ } catch {}
92
+ }
93
+
94
+ function boundedRun(run: DurableRun): DurableRun {
95
+ return {
96
+ ...run,
97
+ tasks: run.tasks.map((task) =>
98
+ task.output && task.output.length > MAX_CHECKPOINT_OUTPUT
99
+ ? { ...task, output: `${task.output.slice(0, MAX_CHECKPOINT_OUTPUT)}\n[checkpoint truncated]` }
100
+ : task
101
+ )
102
+ }
103
+ }
104
+
105
+ export function readRun(file: string): DurableRun | undefined {
106
+ try {
107
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
108
+ return isDurableRun(parsed) ? parsed : undefined
109
+ } catch {
110
+ return undefined
111
+ }
112
+ }
113
+
114
+ export function runUpdatedAt(sessionId: string, runId: string): number | undefined {
115
+ try {
116
+ return statSync(path.join(runDirectory(sessionId, runId), RUN_FILE)).mtimeMs
117
+ } catch {
118
+ return undefined
119
+ }
120
+ }
121
+
122
+ export function listRuns(sessionId: string): DurableRun[] {
123
+ const root = path.join(getAgentDir(), RUNS_DIR, sessionId)
124
+ let entries: string[]
125
+ try {
126
+ entries = readdirSync(root)
127
+ } catch {
128
+ return []
129
+ }
130
+ const runs: DurableRun[] = []
131
+ for (const entry of entries) {
132
+ const run = readRun(path.join(root, entry, RUN_FILE))
133
+ if (run) runs.push(run)
134
+ }
135
+ return runs.toSorted((a, b) => a.startedAt - b.startedAt)
136
+ }
137
+
138
+ export function findRunForTask(sessionId: string, taskId: string): DurableRun | undefined {
139
+ return listRuns(sessionId).find((run) => run.tasks.some((task) => task.id === taskId))
140
+ }
141
+
142
+ export function detailsFromRecord(record: DurableTask): TaskDetails {
143
+ return {
144
+ id: record.id,
145
+ index: record.index,
146
+ label: record.label,
147
+ agent: record.agent,
148
+ status: record.status,
149
+ cwd: record.cwd,
150
+ provider: record.provider,
151
+ model: record.model,
152
+ thinking: record.thinking,
153
+ activity: undefined,
154
+ turnCount: record.turnCount,
155
+ toolCallCount: record.toolCallCount,
156
+ touched: record.touched,
157
+ usage: record.usage,
158
+ output: record.output,
159
+ partial: record.partial,
160
+ hasSchema: record.schema !== undefined,
161
+ maxTurns: record.maxTurns,
162
+ structured: record.structured,
163
+ error: record.error,
164
+ sessionFile: record.sessionFile,
165
+ worktree: record.worktree,
166
+ changes: record.changes,
167
+ forkContext: record.forkContext,
168
+ startedAt: record.startedAt,
169
+ finishedAt: record.finishedAt
170
+ }
171
+ }
172
+
173
+ export function syncRecord(record: DurableTask, task: TaskDetails): void {
174
+ record.status = task.status
175
+ record.output = task.output
176
+ record.error = task.error
177
+ record.partial = task.partial
178
+ record.structured = task.structured
179
+ record.turnCount = task.turnCount
180
+ record.toolCallCount = task.toolCallCount
181
+ record.usage = task.usage
182
+ record.touched = task.touched
183
+ record.changes = task.changes
184
+ record.sessionFile = task.sessionFile ?? record.sessionFile
185
+ record.worktree = task.worktree ?? record.worktree
186
+ record.startedAt = task.startedAt ?? record.startedAt
187
+ record.finishedAt = task.finishedAt ?? record.finishedAt
188
+ }
189
+
190
+ export function pendingTasks(run: DurableRun): DurableTask[] {
191
+ return run.tasks.filter((task) => task.status === 'queued' || task.status === 'running')
192
+ }
193
+
194
+ export function resolveSessionFile(record: DurableTask): string | undefined {
195
+ if (record.sessionFile && existsSync(record.sessionFile)) return record.sessionFile
196
+ try {
197
+ const newest = readdirSync(record.sessionDir)
198
+ .map((file) => ({
199
+ file: path.join(record.sessionDir, file),
200
+ stat: statSync(path.join(record.sessionDir, file))
201
+ }))
202
+ .filter((entry) => entry.file.endsWith('.jsonl') && entry.stat.size > 0)
203
+ .map((entry) => ({ file: entry.file, at: entry.stat.mtimeMs }))
204
+ .toSorted((a, b) => b.at - a.at)[0]
205
+ return newest?.file
206
+ } catch {
207
+ return undefined
208
+ }
209
+ }
210
+
211
+ export function deliveredKeys(manager: Pick<SessionManager, 'getEntries'>): Set<string> {
212
+ const keys = new Set<string>()
213
+ for (const entry of manager.getEntries()) {
214
+ if (entry.type === 'custom_message' && entry.customType === 'subagent-durable') {
215
+ if (isRecoveryDetailsSchema(entry.details)) keys.add(entry.details.runId)
216
+ continue
217
+ }
218
+ if (entry.type !== 'message') continue
219
+ const message = entry.message as { role?: string; toolCallId?: string }
220
+ if (message.role === 'toolResult' && message.toolCallId) keys.add(message.toolCallId)
221
+ }
222
+ return keys
223
+ }
224
+
225
+ function pidAlive(pid: number): boolean {
226
+ try {
227
+ process.kill(pid, 0)
228
+ return true
229
+ } catch (error) {
230
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
231
+ }
232
+ }
233
+
234
+ function ownerPid(file: string): number | undefined {
235
+ try {
236
+ const owner = JSON.parse(readFileSync(file, 'utf8'))
237
+ return isOwnerSchema(owner) ? owner.pid : undefined
238
+ } catch {
239
+ return undefined
240
+ }
241
+ }
242
+
243
+ export function claimRun(sessionId: string, runId: string): boolean {
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
257
+ const pid = ownerPid(ownerFile)
258
+ if (pid !== undefined && pid !== process.pid && pidAlive(pid)) return false
259
+ try {
260
+ rmSync(ownerFile, { force: true })
261
+ } catch {
262
+ return false
263
+ }
264
+ return claim()
265
+ }
266
+
267
+ export function releaseClaim(sessionId: string, runId: string): void {
268
+ try {
269
+ rmSync(path.join(runDirectory(sessionId, runId), OWNER_FILE), { force: true })
270
+ } catch {}
271
+ }
@@ -0,0 +1,241 @@
1
+ import * as path from 'node:path'
2
+
3
+ import type { Theme } from '@earendil-works/pi-coding-agent'
4
+ import type { AssistantMessage, JsonValue, UserMessage } from '@earendil-works/pi-ai'
5
+ import { Validator } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+
9
+ import { type RunDetails, type RunStatus, type TaskDetails, type ToolCallArguments } from './types.js'
10
+ const isString = new Validator(t.string()).isValidObject
11
+
12
+ export function formatDuration(startedAt: number | undefined, finishedAt = Date.now()): string | undefined {
13
+ if (startedAt === undefined) return undefined
14
+ const seconds = Math.max(0, Math.round((finishedAt - startedAt) / 1000))
15
+ if (seconds < 60) return `${seconds}s`
16
+ return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
17
+ }
18
+
19
+ export function statusIcon(status: RunStatus, theme: Theme): string {
20
+ if (status === 'completed') return theme.fg('success', '✓')
21
+ if (status === 'failed') return theme.fg('error', '✗')
22
+ return theme.fg('warning', status === 'queued' ? '◦' : '●')
23
+ }
24
+
25
+ export function taskHeadline(task: TaskDetails): string {
26
+ const bits = [
27
+ task.status,
28
+ formatDuration(task.startedAt, task.finishedAt),
29
+ `${task.provider}/${task.model} (${task.thinking})`
30
+ ]
31
+ if (task.status === 'running' && task.activity) bits.push(task.activity)
32
+ if (task.turnCount !== undefined && task.turnCount > 0) bits.push(`${task.turnCount} turns`)
33
+ const name = `${task.label}${task.agent ? ` · ${task.agent}` : ''}`
34
+ return `${name}: ${bits.filter(Boolean).join(' · ')}`
35
+ }
36
+
37
+ export function fmtTokens(count: number): string {
38
+ return count < 1000 ? String(count) : `${(count / 1000).toFixed(1)}k`
39
+ }
40
+
41
+ export function usageLine(task: TaskDetails): string | undefined {
42
+ if (!task.usage) return undefined
43
+ const { input, output, cost } = task.usage
44
+ const costText = cost > 0 ? ` ($${cost.toFixed(4)})` : ''
45
+ return ` tokens: ${fmtTokens(input)} in / ${fmtTokens(output)} out${costText}`
46
+ }
47
+
48
+ function shortenPath(p: string): string {
49
+ const home = process.env['HOME'] ?? ''
50
+ return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p
51
+ }
52
+
53
+ export function truncate(text: string, max: number): string {
54
+ return text.length > max ? `${text.slice(0, max)}…` : text
55
+ }
56
+
57
+ export function touchedPath(toolName: string, args: ToolCallArguments | undefined, cwd: string): string | undefined {
58
+ if (!args || (toolName !== 'read' && toolName !== 'write' && toolName !== 'edit')) return undefined
59
+ const raw = args.path ?? args.file_path
60
+ if (!raw) return undefined
61
+ const absolute = path.resolve(cwd, raw)
62
+ const relative = path.relative(cwd, absolute)
63
+ return relative && !relative.startsWith('..') ? relative : shortenPath(absolute)
64
+ }
65
+
66
+ export function describeActivity(toolName: string, args: ToolCallArguments | undefined): string {
67
+ if (!args) return toolName
68
+ switch (toolName) {
69
+ case 'bash': {
70
+ const cmd = String(args.command ?? '')
71
+ .replace(/\s+/g, ' ')
72
+ .trim()
73
+ return `$ ${truncate(cmd, 50)}`
74
+ }
75
+ case 'read': {
76
+ const p = shortenPath(String(args.path ?? args.file_path ?? ''))
77
+ return `read ${p}`
78
+ }
79
+ case 'write': {
80
+ const p = shortenPath(String(args.path ?? args.file_path ?? ''))
81
+ const lines = args.content ? String(args.content).split('\n').length : 0
82
+ return lines > 1 ? `write ${p} (${lines} lines)` : `write ${p}`
83
+ }
84
+ case 'edit': {
85
+ const p = shortenPath(String(args.path ?? args.file_path ?? ''))
86
+ return `edit ${p}`
87
+ }
88
+ case 'grep': {
89
+ const pat = String(args.pattern ?? '')
90
+ const p = shortenPath(String(args.path ?? '.'))
91
+ return `grep /${truncate(pat, 30)}/ in ${p}`
92
+ }
93
+ case 'find': {
94
+ const pat = String(args.pattern ?? '*')
95
+ const p = shortenPath(String(args.path ?? '.'))
96
+ return `find ${truncate(pat, 30)} in ${p}`
97
+ }
98
+ case 'ls': {
99
+ const p = shortenPath(String(args.path ?? '.'))
100
+ return `ls ${p}`
101
+ }
102
+ default: {
103
+ const raw = args.command ?? args.path ?? args.url ?? args.query
104
+ if (!raw) return toolName
105
+ const text = String(raw).replace(/\s+/g, ' ').trim()
106
+ return `${toolName}(${truncate(text, 40)})`
107
+ }
108
+ }
109
+ }
110
+
111
+ export function countByStatus(tasks: TaskDetails[]): string {
112
+ const counts = new Map<RunStatus, number>()
113
+ for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1)
114
+ return [...counts].map(([status, count]) => `${count} ${status}`).join(', ')
115
+ }
116
+
117
+ export function partialText(details: RunDetails): string {
118
+ const lines = [`Subagent run: ${countByStatus(details.tasks)}.`]
119
+ for (const task of details.tasks) lines.push('', taskHeadline(task))
120
+ return lines.join('\n')
121
+ }
122
+
123
+ export function resultText(details: RunDetails): string {
124
+ const duration = formatDuration(details.startedAt, details.finishedAt)
125
+ const lines = [
126
+ `Subagent run${duration ? ` finished after ${duration}` : ''}: ${countByStatus(details.tasks)}.`,
127
+ 'Each child ran in-process in this same pi session.'
128
+ ]
129
+ for (const task of details.tasks) {
130
+ lines.push('', `── ${taskHeadline(task)}`)
131
+ if (task.sessionFile) lines.push(` session: ${task.sessionFile}`)
132
+ if (task.worktree) {
133
+ lines.push(` worktree: ${task.worktree}`)
134
+ if (task.changes?.length) {
135
+ const listed = task.changes.slice(0, 10).join(', ')
136
+ lines.push(` changed (${task.changes.length}): ${listed}${task.changes.length > 10 ? ', …' : ''}`)
137
+ } else lines.push(' no changes in the worktree')
138
+ }
139
+ const usage = usageLine(task)
140
+ if (usage) lines.push(usage)
141
+ if (task.toolCallCount !== undefined && task.toolCallCount > 0) lines.push(` tools: ${task.toolCallCount}`)
142
+ if (task.touched?.length) lines.push(` read/wrote: ${task.touched.join(', ')}`)
143
+ if (task.error) lines.push(` error: ${task.error}`)
144
+ if (task.partial) lines.push(' [partial answer — resume to finish]')
145
+ if (task.status === 'failed') lines.push(` resume: ${task.id}`)
146
+ if (task.structured !== undefined) lines.push(` structured: ${JSON.stringify(task.structured)}`)
147
+ if (task.output) lines.push('', task.output)
148
+ }
149
+ return lines.join('\n')
150
+ }
151
+
152
+ export function extractUserText(message: UserMessage): string {
153
+ const content = message.content
154
+ if (!Array.isArray(content)) return content
155
+ return content
156
+ .filter((part): part is Extract<typeof part, { type: 'text' }> => part.type === 'text')
157
+ .map((part) => part.text)
158
+ .join('\n')
159
+ }
160
+
161
+ const TextPartSchema = t.object({ type: t.literal('text'), text: t.string() }, { additionalProperties: true })
162
+ const isTextPartSchema = new Validator(TextPartSchema).isValidObject
163
+
164
+ export function extractForkText(content: JsonValue | undefined): string | undefined {
165
+ if (isString(content)) return content.trim() || undefined
166
+ if (Array.isArray(content)) {
167
+ return (
168
+ content
169
+ .flatMap((part) => (isTextPartSchema(part) ? [part.text] : []))
170
+ .join('\n')
171
+ .trim() || undefined
172
+ )
173
+ }
174
+ return undefined
175
+ }
176
+
177
+ export function summarizeToolCall(name: string, args: ToolCallArguments): string {
178
+ return describeActivity(name, args)
179
+ }
180
+
181
+ export function buildTranscriptLines(messages: readonly unknown[], theme: Theme): string[] {
182
+ const lines: string[] = []
183
+ for (const message of messages) {
184
+ const role = (message as { role?: string }).role
185
+ if (role === 'user') {
186
+ const text = extractUserText(message as UserMessage)
187
+ if (text) lines.push('', theme.fg('accent', `› ${text}`))
188
+ } else if (role === 'assistant') {
189
+ for (const part of (message as AssistantMessage).content) {
190
+ if (part.type === 'thinking') {
191
+ lines.push(theme.fg('dim', `· ${part.thinking.replace(/\s+/g, ' ').trim()}`))
192
+ } else if (part.type === 'text' && part.text.trim()) {
193
+ lines.push(...part.text.split('\n'))
194
+ } else if (part.type === 'toolCall') {
195
+ lines.push(theme.fg('toolTitle', `▸ ${summarizeToolCall(part.name, part.arguments)}`))
196
+ }
197
+ }
198
+ } else if (role === 'toolResult') {
199
+ const result = message as { content: Array<{ type: string; text: string }>; isError?: boolean }
200
+ const text = result.content
201
+ .filter((part): part is { type: 'text'; text: string } => part.type === 'text')
202
+ .map((part) => part.text)
203
+ .join('\n')
204
+ const preview = text.split('\n').slice(0, 5).join('\n')
205
+ if (preview)
206
+ lines.push(
207
+ ...preview.split('\n').map((l) => theme.fg(result.isError ? 'error' : 'toolOutput', ` ${l}`))
208
+ )
209
+ } else {
210
+ const label = (message as { customType?: string }).customType
211
+ if (label) lines.push('', theme.fg('muted', `— ${label} —`))
212
+ }
213
+ }
214
+ return lines
215
+ }
216
+
217
+ export function toLabel(value: string, index: number): string {
218
+ const cleaned = value
219
+ .split('\n', 1)[0]
220
+ ?.trim()
221
+ .replace(/[^\w. -]/g, '')
222
+ .trim()
223
+ .slice(0, 24)
224
+ return cleaned ? `${index + 1}-${cleaned.replace(/\s+/g, '-')}` : `task-${index + 1}`
225
+ }
226
+
227
+ export function textFromAssistant(message: AssistantMessage): string {
228
+ return message.content
229
+ .filter((part): part is Extract<AssistantMessage['content'][number], { type: 'text' }> => part.type === 'text')
230
+ .map((part) => part.text)
231
+ .join('\n')
232
+ }
233
+
234
+ export function findLastAssistant(messages: readonly unknown[]): AssistantMessage | undefined {
235
+ for (let index = messages.length - 1; index >= 0; index--) {
236
+ const message = messages[index] as { role?: string } | undefined
237
+ if (message?.role !== 'assistant') continue
238
+ return message as AssistantMessage
239
+ }
240
+ return undefined
241
+ }