@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.
- package/LICENSE +178 -0
- package/README.md +80 -2
- package/package.json +51 -4
- package/src/bulk-read.ts +147 -0
- package/src/lib/child-runtime.ts +18 -0
- package/src/log-reducer.ts +242 -0
- package/src/redact.ts +17 -0
- package/src/subagent/agent.ts +112 -0
- package/src/subagent/drive.ts +187 -0
- package/src/subagent/durable.ts +264 -0
- package/src/subagent/format.ts +241 -0
- package/src/subagent/helpers.ts +212 -0
- package/src/subagent/index.ts +853 -0
- package/src/subagent/recover.ts +268 -0
- package/src/subagent/session.ts +87 -0
- package/src/subagent/state.ts +59 -0
- package/src/subagent/structured.ts +202 -0
- package/src/subagent/types.ts +109 -0
- package/src/subagent/viewer.ts +184 -0
|
@@ -0,0 +1,264 @@
|
|
|
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
|
+
|
|
9
|
+
import type { JsonPayload } from './structured.js'
|
|
10
|
+
import { RUNS_DIR, type TaskDetails, type ThinkingLevel } from './types.js'
|
|
11
|
+
|
|
12
|
+
const RUN_FILE = 'run.json'
|
|
13
|
+
const OWNER_FILE = 'owner.json'
|
|
14
|
+
export const MAX_CHECKPOINT_OUTPUT = 64 * 1024
|
|
15
|
+
export const CONTINUE_PROMPT =
|
|
16
|
+
'Continue your previous task from where you stopped. Finish the work and reply with your final answer, naming anything you could not finish.'
|
|
17
|
+
|
|
18
|
+
const JsonValueSchema = t.any()
|
|
19
|
+
|
|
20
|
+
const DurableTaskSchema = t.object({
|
|
21
|
+
id: t.string(),
|
|
22
|
+
index: t.number(),
|
|
23
|
+
label: t.string(),
|
|
24
|
+
task: t.string(),
|
|
25
|
+
cwd: t.string(),
|
|
26
|
+
provider: t.string(),
|
|
27
|
+
model: t.string(),
|
|
28
|
+
thinking: t.string(),
|
|
29
|
+
maxTurns: t.number(),
|
|
30
|
+
timeoutMs: t.optional(t.number()),
|
|
31
|
+
isolation: t.optional(t.literal('worktree')),
|
|
32
|
+
forkContext: t.boolean(),
|
|
33
|
+
sessionDir: t.string(),
|
|
34
|
+
agent: t.optional(t.string()),
|
|
35
|
+
schema: t.optional(JsonValueSchema),
|
|
36
|
+
worktree: t.optional(t.string()),
|
|
37
|
+
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
|
+
]),
|
|
44
|
+
output: t.optional(t.string()),
|
|
45
|
+
error: t.optional(t.string()),
|
|
46
|
+
partial: t.optional(t.boolean()),
|
|
47
|
+
structured: t.optional(JsonValueSchema),
|
|
48
|
+
turnCount: t.optional(t.number()),
|
|
49
|
+
toolCallCount: t.optional(t.number()),
|
|
50
|
+
usage: t.optional(
|
|
51
|
+
t.object({ input: t.number(), output: t.number(), cost: t.number() })
|
|
52
|
+
),
|
|
53
|
+
touched: t.optional(t.array(t.string())),
|
|
54
|
+
changes: t.optional(t.array(t.string())),
|
|
55
|
+
startedAt: t.optional(t.number()),
|
|
56
|
+
finishedAt: t.optional(t.number())
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const OwnerSchema = t.object({ pid: t.number(), at: t.number() })
|
|
60
|
+
const RecoveryDetailsSchema = t.object({ runId: t.string() })
|
|
61
|
+
const isRecoveryDetailsSchema = new Validator(RecoveryDetailsSchema).isValidObject
|
|
62
|
+
|
|
63
|
+
const DurableRunSchema = t.object({
|
|
64
|
+
version: t.literal(1),
|
|
65
|
+
runId: t.string(),
|
|
66
|
+
sessionId: t.string(),
|
|
67
|
+
toolCallId: t.optional(t.string()),
|
|
68
|
+
startedAt: t.number(),
|
|
69
|
+
finishedAt: t.optional(t.number()),
|
|
70
|
+
tasks: t.array(DurableTaskSchema)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
type Mutable<T> = { -readonly [K in keyof T]: T[K] }
|
|
74
|
+
export type DurableTask = Mutable<Omit<Infer<typeof DurableTaskSchema>, 'thinking' | 'schema' | 'structured'>> & {
|
|
75
|
+
thinking: ThinkingLevel
|
|
76
|
+
schema?: JsonPayload
|
|
77
|
+
structured?: JsonPayload
|
|
78
|
+
}
|
|
79
|
+
export type DurableRun = Mutable<Omit<Infer<typeof DurableRunSchema>, 'tasks'>> & { tasks: DurableTask[] }
|
|
80
|
+
const isDurableRunSchema = new Validator(DurableRunSchema).isValidObject
|
|
81
|
+
const isOwnerSchema = new Validator(OwnerSchema).isValidObject
|
|
82
|
+
|
|
83
|
+
function isDurableRun(value: unknown): value is DurableRun {
|
|
84
|
+
return isDurableRunSchema(value)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function runDirectory(sessionId: string, runId: string): string {
|
|
88
|
+
return path.join(getAgentDir(), RUNS_DIR, sessionId, runId)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function saveRun(run: DurableRun): void {
|
|
92
|
+
try {
|
|
93
|
+
const directory = runDirectory(run.sessionId, run.runId)
|
|
94
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
95
|
+
const target = path.join(directory, RUN_FILE)
|
|
96
|
+
const temporary = `${target}.tmp`
|
|
97
|
+
writeFileSync(temporary, JSON.stringify(boundedRun(run)), { mode: 0o600 })
|
|
98
|
+
renameSync(temporary, target)
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function boundedRun(run: DurableRun): DurableRun {
|
|
103
|
+
return {
|
|
104
|
+
...run,
|
|
105
|
+
tasks: run.tasks.map((task) =>
|
|
106
|
+
task.output && task.output.length > MAX_CHECKPOINT_OUTPUT
|
|
107
|
+
? { ...task, output: `${task.output.slice(0, MAX_CHECKPOINT_OUTPUT)}\n[checkpoint truncated]` }
|
|
108
|
+
: task
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function readRun(file: string): DurableRun | undefined {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
116
|
+
return isDurableRun(parsed) ? parsed : undefined
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function runUpdatedAt(sessionId: string, runId: string): number | undefined {
|
|
123
|
+
try {
|
|
124
|
+
return statSync(path.join(runDirectory(sessionId, runId), RUN_FILE)).mtimeMs
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function listRuns(sessionId: string): DurableRun[] {
|
|
131
|
+
const root = path.join(getAgentDir(), RUNS_DIR, sessionId)
|
|
132
|
+
let entries: string[]
|
|
133
|
+
try {
|
|
134
|
+
entries = readdirSync(root)
|
|
135
|
+
} catch {
|
|
136
|
+
return []
|
|
137
|
+
}
|
|
138
|
+
const runs: DurableRun[] = []
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
const run = readRun(path.join(root, entry, RUN_FILE))
|
|
141
|
+
if (run) runs.push(run)
|
|
142
|
+
}
|
|
143
|
+
return runs.toSorted((a, b) => a.startedAt - b.startedAt)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function findRunForTask(sessionId: string, taskId: string): DurableRun | undefined {
|
|
147
|
+
return listRuns(sessionId).find((run) => run.tasks.some((task) => task.id === taskId))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function detailsFromRecord(record: DurableTask): TaskDetails {
|
|
151
|
+
return {
|
|
152
|
+
id: record.id,
|
|
153
|
+
index: record.index,
|
|
154
|
+
label: record.label,
|
|
155
|
+
agent: record.agent,
|
|
156
|
+
status: record.status,
|
|
157
|
+
cwd: record.cwd,
|
|
158
|
+
provider: record.provider,
|
|
159
|
+
model: record.model,
|
|
160
|
+
thinking: record.thinking,
|
|
161
|
+
activity: undefined,
|
|
162
|
+
turnCount: record.turnCount,
|
|
163
|
+
toolCallCount: record.toolCallCount,
|
|
164
|
+
touched: record.touched,
|
|
165
|
+
usage: record.usage,
|
|
166
|
+
output: record.output,
|
|
167
|
+
partial: record.partial,
|
|
168
|
+
hasSchema: record.schema !== undefined,
|
|
169
|
+
maxTurns: record.maxTurns,
|
|
170
|
+
structured: record.structured,
|
|
171
|
+
error: record.error,
|
|
172
|
+
sessionFile: record.sessionFile,
|
|
173
|
+
worktree: record.worktree,
|
|
174
|
+
changes: record.changes,
|
|
175
|
+
forkContext: record.forkContext,
|
|
176
|
+
startedAt: record.startedAt,
|
|
177
|
+
finishedAt: record.finishedAt
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function syncRecord(record: DurableTask, task: TaskDetails): void {
|
|
182
|
+
record.status = task.status
|
|
183
|
+
record.output = task.output
|
|
184
|
+
record.error = task.error
|
|
185
|
+
record.partial = task.partial
|
|
186
|
+
record.structured = task.structured
|
|
187
|
+
record.turnCount = task.turnCount
|
|
188
|
+
record.toolCallCount = task.toolCallCount
|
|
189
|
+
record.usage = task.usage
|
|
190
|
+
record.touched = task.touched
|
|
191
|
+
record.changes = task.changes
|
|
192
|
+
record.sessionFile = task.sessionFile ?? record.sessionFile
|
|
193
|
+
record.worktree = task.worktree ?? record.worktree
|
|
194
|
+
record.startedAt = task.startedAt ?? record.startedAt
|
|
195
|
+
record.finishedAt = task.finishedAt ?? record.finishedAt
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function pendingTasks(run: DurableRun): DurableTask[] {
|
|
199
|
+
return run.tasks.filter((task) => task.status === 'queued' || task.status === 'running')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function resolveSessionFile(record: DurableTask): string | undefined {
|
|
203
|
+
if (record.sessionFile && existsSync(record.sessionFile)) return record.sessionFile
|
|
204
|
+
try {
|
|
205
|
+
const newest = readdirSync(record.sessionDir)
|
|
206
|
+
.map((file) => ({ file: path.join(record.sessionDir, file), stat: statSync(path.join(record.sessionDir, file)) }))
|
|
207
|
+
.filter((entry) => entry.file.endsWith('.jsonl') && entry.stat.size > 0)
|
|
208
|
+
.map((entry) => ({ file: entry.file, at: entry.stat.mtimeMs }))
|
|
209
|
+
.toSorted((a, b) => b.at - a.at)[0]
|
|
210
|
+
return newest?.file
|
|
211
|
+
} catch {
|
|
212
|
+
return undefined
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function deliveredKeys(manager: Pick<SessionManager, 'getEntries'>): Set<string> {
|
|
217
|
+
const keys = new Set<string>()
|
|
218
|
+
for (const entry of manager.getEntries()) {
|
|
219
|
+
if (entry.type === 'custom_message' && entry.customType === 'subagent-durable') {
|
|
220
|
+
if (isRecoveryDetailsSchema(entry.details)) keys.add(entry.details.runId)
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
if (entry.type !== 'message') continue
|
|
224
|
+
const message = entry.message as { role?: string; toolCallId?: string }
|
|
225
|
+
if (message.role === 'toolResult' && message.toolCallId) keys.add(message.toolCallId)
|
|
226
|
+
}
|
|
227
|
+
return keys
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function pidAlive(pid: number): boolean {
|
|
231
|
+
try {
|
|
232
|
+
process.kill(pid, 0)
|
|
233
|
+
return true
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function ownerPid(file: string): number | undefined {
|
|
240
|
+
try {
|
|
241
|
+
const owner = JSON.parse(readFileSync(file, 'utf8'))
|
|
242
|
+
return isOwnerSchema(owner) ? owner.pid : undefined
|
|
243
|
+
} catch {
|
|
244
|
+
return undefined
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function claimRun(sessionId: string, runId: string): boolean {
|
|
249
|
+
const ownerFile = path.join(runDirectory(sessionId, runId), OWNER_FILE)
|
|
250
|
+
const pid = ownerPid(ownerFile)
|
|
251
|
+
if (pid !== undefined && pid !== process.pid && pidAlive(pid)) return false
|
|
252
|
+
try {
|
|
253
|
+
writeFileSync(ownerFile, JSON.stringify({ pid: process.pid, at: Date.now() }), { mode: 0o600 })
|
|
254
|
+
return true
|
|
255
|
+
} catch {
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function releaseClaim(sessionId: string, runId: string): void {
|
|
261
|
+
try {
|
|
262
|
+
rmSync(path.join(runDirectory(sessionId, runId), OWNER_FILE), { force: true })
|
|
263
|
+
} catch {}
|
|
264
|
+
}
|
|
@@ -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
|
+
}
|