@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,109 @@
1
+ import type { Model } from '@earendil-works/pi-ai'
2
+
3
+ import type { JsonPayload } from './structured.js'
4
+
5
+ export const RUNS_DIR = 'subagents'
6
+ export const AGENTS_DIR = 'agents'
7
+ export const EMIT_INTERVAL_MS = 250
8
+ export const MAX_TASKS = 8
9
+ export const MAX_CONCURRENT = 4
10
+ export const DEFAULT_MAX_TURNS = 50
11
+ export const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
12
+ export const WIDGET_KEY = 'subagent-widget'
13
+ export const MAX_WIDGET_ROWS = 8
14
+ export const MAX_REGISTRY = 50
15
+ export const MAX_TOUCHED = 12
16
+ export const FINISHED_RETENTION_MS = 5 * 60_000
17
+ export const FAILED_RETENTION_MS = 30 * 60_000
18
+
19
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number]
20
+ export const DEFAULT_THINKING: ThinkingLevel = 'medium'
21
+ export type RunStatus = 'queued' | 'running' | 'completed' | 'failed'
22
+
23
+ export interface AgentProfile {
24
+ name: string
25
+ description: string
26
+ filePath: string
27
+ systemPrompt: string
28
+ systemPromptMode: 'append' | 'replace'
29
+ thinking?: ThinkingLevel
30
+ model?: string
31
+ tools: string[]
32
+ disallowedTools: string[]
33
+ inheritProjectContext: boolean
34
+ inheritSkills: boolean
35
+ maxExecutionTimeMs?: number
36
+ maxTurns?: number
37
+ color?: string
38
+ }
39
+
40
+ export interface UsageTotals {
41
+ input: number
42
+ output: number
43
+ cost: number
44
+ }
45
+
46
+ export interface TaskDetails {
47
+ id: string
48
+ index: number
49
+ label: string
50
+ agent?: string
51
+ status: RunStatus
52
+ cwd: string
53
+ provider: string
54
+ model: string
55
+ thinking: string
56
+ activity?: string
57
+ turnCount?: number
58
+ toolCallCount?: number
59
+ touched?: string[]
60
+ usage?: UsageTotals
61
+ output?: string
62
+ partial?: boolean
63
+ hasSchema?: boolean
64
+ maxTurns?: number
65
+ structured?: JsonPayload
66
+ error?: string
67
+ sessionFile?: string
68
+ worktree?: string
69
+ changes?: string[]
70
+ color?: string
71
+ forkContext?: boolean
72
+ startedAt?: number
73
+ finishedAt?: number
74
+ }
75
+
76
+ export interface RunDetails {
77
+ status: RunStatus
78
+ runId: string
79
+ startedAt: number
80
+ finishedAt?: number
81
+ tasks: TaskDetails[]
82
+ }
83
+
84
+ export interface TaskSpec {
85
+ index: number
86
+ id: string
87
+ label: string
88
+ task: string
89
+ agent?: AgentProfile
90
+ cwd: string
91
+ dir: string
92
+ thinking: ThinkingLevel
93
+ model?: Model<any>
94
+ timeoutMs?: number
95
+ maxTurns: number
96
+ isolation?: 'worktree'
97
+ forkContext?: boolean
98
+ schema?: JsonPayload
99
+ }
100
+
101
+ export interface ToolCallArguments {
102
+ command?: string
103
+ path?: string
104
+ file_path?: string
105
+ url?: string
106
+ query?: string
107
+ pattern?: string
108
+ content?: string
109
+ }
@@ -0,0 +1,184 @@
1
+ import type { AgentSession, Theme } from '@earendil-works/pi-coding-agent'
2
+ import {
3
+ isKeyRelease,
4
+ matchesKey,
5
+ truncateToWidth,
6
+ wrapTextWithAnsi,
7
+ type Component,
8
+ type TUI
9
+ } from '@earendil-works/pi-tui'
10
+
11
+ import { MAX_WIDGET_ROWS, type TaskDetails } from './types.js'
12
+ import { roster } from './state.js'
13
+ import { buildTranscriptLines, formatDuration, fmtTokens, statusIcon, truncate } from './format.js'
14
+
15
+ export function widgetLines(theme: Theme, highlightIndex: number | null): string[] {
16
+ const all = roster()
17
+ const items = all.slice(0, MAX_WIDGET_ROWS)
18
+ if (items.length === 0) return []
19
+
20
+ const validColors = new Set<string>([
21
+ 'accent',
22
+ 'success',
23
+ 'error',
24
+ 'warning',
25
+ 'muted',
26
+ 'dim',
27
+ 'text',
28
+ 'toolTitle',
29
+ 'toolOutput',
30
+ 'mdHeading',
31
+ 'mdLink'
32
+ ])
33
+
34
+ const lines: string[] = []
35
+
36
+ const running = items.filter((t) => t.status === 'running' || t.status === 'queued').length
37
+ const done = items.filter((t) => t.status === 'completed').length
38
+ const failed = items.filter((t) => t.status === 'failed').length
39
+ const parts = []
40
+ if (running > 0) parts.push(`${running} running`)
41
+ if (done > 0) parts.push(`${done} done`)
42
+ if (failed > 0) parts.push(theme.fg('error', `${failed} failed`))
43
+ if (all.length > MAX_WIDGET_ROWS) parts.push(`+${all.length - MAX_WIDGET_ROWS} more`)
44
+ lines.push(theme.fg('dim', `${all.length} agent${all.length !== 1 ? 's' : ''} (${parts.join(', ')})`))
45
+
46
+ items.forEach((task, i) => {
47
+ const marker = i === highlightIndex ? theme.fg('accent', theme.bold('▶ ')) : ' '
48
+ const duration = formatDuration(task.startedAt, task.finishedAt)
49
+
50
+ const tokenInfo = task.usage ? ` ${fmtTokens(task.usage.output)}↓` : ''
51
+ const costInfo = task.usage && task.usage.cost > 0 ? ` $${task.usage.cost.toFixed(3)}` : ''
52
+ const turnInfo = task.turnCount !== undefined && task.turnCount > 0 ? ` t${task.turnCount}` : ''
53
+
54
+ const modeTag = task.forkContext ? theme.fg('accent', ' fork') : task.worktree ? theme.fg('accent', ' wt') : ''
55
+
56
+ const wtInfo =
57
+ task.worktree && task.status !== 'running'
58
+ ? task.changes?.length
59
+ ? ` (${task.changes.length} changed)`
60
+ : ' (clean)'
61
+ : ''
62
+
63
+ const agentColor = (
64
+ validColors.has(task.color ?? '') ? task.color : 'dim'
65
+ ) as import('@earendil-works/pi-coding-agent').ThemeColor
66
+ const name = task.agent ? `${task.label} ${theme.fg(agentColor, `· ${task.agent}`)}` : task.label
67
+
68
+ const tag = theme.fg('dim', `${duration ?? ''}${turnInfo}${tokenInfo}${costInfo}`)
69
+ const errSnippet =
70
+ task.status === 'failed' && task.error
71
+ ? theme.fg('error', ` ${truncate(task.error.split('\n')[0]?.split('. ')[0]?.trim() ?? task.error, 60)}`)
72
+ : ''
73
+ lines.push(
74
+ `${marker}${statusIcon(task.status, theme)} ${name}${modeTag} ${tag}${wtInfo ? theme.fg('dim', wtInfo) : ''}${errSnippet}`
75
+ )
76
+ if (task.status === 'running' && task.activity) lines.push(` ${theme.fg('dim', task.activity)}`)
77
+ })
78
+ if (highlightIndex !== null) lines.push(theme.fg('muted', '↑/↓ navigate · enter view · d dismiss · esc close'))
79
+ return lines
80
+ }
81
+
82
+ const CHROME_LINES = 4
83
+ const MIN_VIEWPORT = 5
84
+
85
+ export class SubagentViewer implements Component {
86
+ private raw: string[] = []
87
+ private scroll = 0
88
+ private renderedBodyLength = 0
89
+ private autoScroll = true
90
+ private unsubscribe: () => void
91
+ private tui: TUI
92
+ private theme: Theme
93
+ private session: AgentSession
94
+ private task: TaskDetails
95
+ private done: (result: void) => void
96
+
97
+ constructor(tui: TUI, theme: Theme, session: AgentSession, task: TaskDetails, done: (result: void) => void) {
98
+ this.tui = tui
99
+ this.theme = theme
100
+ this.session = session
101
+ this.task = task
102
+ this.done = done
103
+ this.rebuild()
104
+ this.unsubscribe = session.subscribe((event) => {
105
+ if (
106
+ event.type === 'message_end' ||
107
+ event.type === 'tool_execution_start' ||
108
+ event.type === 'compaction_end' ||
109
+ event.type === 'entry_appended'
110
+ ) {
111
+ this.rebuild()
112
+ this.tui.requestRender()
113
+ }
114
+ })
115
+ }
116
+
117
+ private rebuild(): void {
118
+ this.raw = buildTranscriptLines(this.session.messages, this.theme)
119
+ if (this.raw.length === 0 && this.task.error) this.raw = [this.theme.fg('error', this.task.error)]
120
+ }
121
+
122
+ private viewportHeight(): number {
123
+ const rows = this.tui.terminal.rows || 24
124
+ return Math.max(MIN_VIEWPORT, Math.floor(rows * 0.7) - CHROME_LINES)
125
+ }
126
+
127
+ private wrapped(width: number): string[] {
128
+ return this.raw.flatMap((line) => (line ? wrapTextWithAnsi(line, width) : ['']))
129
+ }
130
+
131
+ render(width: number): string[] {
132
+ const theme = this.theme
133
+ const inner = Math.max(10, width - 2)
134
+ const body = this.wrapped(inner)
135
+ this.renderedBodyLength = body.length
136
+ const height = this.viewportHeight()
137
+ if (this.autoScroll) this.scroll = Math.max(0, body.length - height)
138
+ this.scroll = Math.min(this.scroll, Math.max(0, body.length - height))
139
+
140
+ const task = this.task
141
+ const duration = formatDuration(task.startedAt, task.finishedAt)
142
+ const header = truncateToWidth(
143
+ `${statusIcon(task.status, theme)} ${theme.bold(task.label)} ${theme.fg('dim', `${task.provider}/${task.model} (${task.thinking})${duration ? ` · ${duration}` : ''}`)}`,
144
+ width
145
+ )
146
+ const footer = theme.fg('muted', '↑/↓ scroll · pgup/pgdn page · esc close')
147
+
148
+ const visible = body.slice(this.scroll, this.scroll + height)
149
+ while (visible.length < height) visible.push('')
150
+
151
+ return [header, ''.padEnd(inner, '─'), ...visible, ''.padEnd(inner, '─'), footer]
152
+ }
153
+
154
+ handleInput(data: string): void {
155
+ if (isKeyRelease(data)) return
156
+ if (matchesKey(data, 'escape') || matchesKey(data, 'q')) {
157
+ this.unsubscribe()
158
+ this.done()
159
+ return
160
+ }
161
+ const height = this.viewportHeight()
162
+ const maxScroll = Math.max(0, this.renderedBodyLength - height)
163
+ if (matchesKey(data, 'up') || matchesKey(data, 'k')) {
164
+ this.autoScroll = false
165
+ this.scroll = Math.max(0, this.scroll - 1)
166
+ } else if (matchesKey(data, 'down') || matchesKey(data, 'j')) {
167
+ this.scroll = Math.min(maxScroll, this.scroll + 1)
168
+ this.autoScroll = this.scroll >= maxScroll
169
+ } else if (matchesKey(data, 'pageUp')) {
170
+ this.autoScroll = false
171
+ this.scroll = Math.max(0, this.scroll - height)
172
+ } else if (matchesKey(data, 'pageDown')) {
173
+ this.scroll = Math.min(maxScroll, this.scroll + height)
174
+ this.autoScroll = this.scroll >= maxScroll
175
+ } else {
176
+ return
177
+ }
178
+ this.tui.requestRender()
179
+ }
180
+
181
+ invalidate(): void {
182
+ this.rebuild()
183
+ }
184
+ }