@cr1ms0n/pi-subagent 0.8.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/CHANGELOG.md +352 -0
- package/LICENSE +21 -0
- package/README.md +543 -0
- package/docs/ARCHITECTURE.md +125 -0
- package/docs/COST-ACCOUNTING.md +66 -0
- package/docs/PLAN.md +325 -0
- package/docs/RELEASING.md +32 -0
- package/docs/ROADMAP.md +252 -0
- package/docs/SECURITY.md +85 -0
- package/docs/UI-OVERHAUL.md +186 -0
- package/docs/UX.md +141 -0
- package/extensions/subagent.ts +1 -0
- package/package.json +58 -0
- package/skills/subagent/SKILL.md +103 -0
- package/src/agents.ts +285 -0
- package/src/backend.ts +146 -0
- package/src/backends/claude.ts +384 -0
- package/src/backends/codex.ts +330 -0
- package/src/backends/index.ts +26 -0
- package/src/backends/pi.ts +94 -0
- package/src/btw.ts +34 -0
- package/src/config.ts +254 -0
- package/src/distill.ts +222 -0
- package/src/extension.ts +1527 -0
- package/src/format.ts +365 -0
- package/src/index.ts +60 -0
- package/src/launch.ts +120 -0
- package/src/maintenance.ts +6 -0
- package/src/model-policy.ts +157 -0
- package/src/notifications.ts +106 -0
- package/src/orchestrator.ts +247 -0
- package/src/output.ts +124 -0
- package/src/persistence.ts +334 -0
- package/src/policy.ts +500 -0
- package/src/process-lock.ts +687 -0
- package/src/protocol.ts +290 -0
- package/src/registry.ts +632 -0
- package/src/runner.ts +850 -0
- package/src/schema.ts +166 -0
- package/src/semaphore.ts +123 -0
- package/src/structured.ts +169 -0
- package/src/transcript.ts +360 -0
- package/src/types.ts +197 -0
- package/src/ui.ts +545 -0
- package/src/usage.ts +274 -0
- package/src/worktree.ts +753 -0
package/src/format.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import type { UsageStats, RunSnapshot, RunState, RunMode, TimeoutPhase } from './types.js';
|
|
2
|
+
import type { Theme } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import { truncateToWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Formatting helpers for pi-subagent UI.
|
|
8
|
+
* ANSI-safe, respects terminal width, supports spinners, elapsed, etc.
|
|
9
|
+
* Independent of runner/registry.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const SPINNERS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
13
|
+
|
|
14
|
+
const ACTIVE_STATES: ReadonlySet<string> = new Set(['queued', 'running']);
|
|
15
|
+
|
|
16
|
+
export function isActiveState(state: string | undefined): boolean {
|
|
17
|
+
return state !== undefined && ACTIVE_STATES.has(state);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Collapse whitespace/newlines into a single display line. */
|
|
21
|
+
export function oneLine(text: string, max = 120): string {
|
|
22
|
+
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
23
|
+
return collapsed.length > max ? `${collapsed.slice(0, Math.max(0, max - 1))}…` : collapsed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatDuration(ms: number): string {
|
|
27
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
28
|
+
if (seconds < 60) return `${seconds}s`;
|
|
29
|
+
const minutes = Math.floor(seconds / 60);
|
|
30
|
+
if (minutes < 60) return `${minutes}m${seconds % 60 ? `${seconds % 60}s` : ''}`;
|
|
31
|
+
const hours = Math.floor(minutes / 60);
|
|
32
|
+
return `${hours}h${minutes % 60 ? `${minutes % 60}m` : ''}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatElapsed(ms: number | undefined, now = Date.now()): string {
|
|
36
|
+
if (!ms) return '0s';
|
|
37
|
+
return formatDuration(Math.max(0, now - ms));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatTokens(n: number | undefined): string {
|
|
41
|
+
if (!n || n === 0) return '0';
|
|
42
|
+
if (n < 1000) return n.toString();
|
|
43
|
+
if (n < 10000) return (n / 1000).toFixed(1) + 'k';
|
|
44
|
+
if (n < 1_000_000) return Math.round(n / 1000) + 'k';
|
|
45
|
+
return (n / 1_000_000).toFixed(1) + 'M';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatCost(cost: number): string {
|
|
49
|
+
if (cost >= 0.095) return `$${cost.toFixed(2)}`;
|
|
50
|
+
if (cost >= 0.00095) return `$${cost.toFixed(3)}`;
|
|
51
|
+
return '<$0.001';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatUsage(usage: UsageStats, model?: string, compact = true): string {
|
|
55
|
+
const parts: string[] = [];
|
|
56
|
+
if (usage.turns && usage.turns > 0) parts.push(`${usage.turns}t`);
|
|
57
|
+
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
58
|
+
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
59
|
+
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
60
|
+
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
61
|
+
if (usage.cost > 0.0001) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
62
|
+
if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
63
|
+
if (model && !compact) parts.push(model);
|
|
64
|
+
return parts.join(' ');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatPath(p?: string): string {
|
|
68
|
+
if (!p) return '(none)';
|
|
69
|
+
const home = os.homedir();
|
|
70
|
+
if (p.startsWith(home)) return '~' + p.slice(home.length);
|
|
71
|
+
return p.length > 40 ? '...' + p.slice(-37) : p;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatState(state: RunState, exitCode?: number | null): string {
|
|
75
|
+
switch (state) {
|
|
76
|
+
case 'running': return 'running';
|
|
77
|
+
case 'completed': return typeof exitCode === 'number' && exitCode !== 0 ? 'failed' : 'done';
|
|
78
|
+
case 'failed': return 'failed';
|
|
79
|
+
case 'cancelled': return 'cancelled';
|
|
80
|
+
case 'queued': return 'queued';
|
|
81
|
+
case 'partial': return 'partial';
|
|
82
|
+
case 'lost': return 'lost';
|
|
83
|
+
case 'timeout': return 'timeout';
|
|
84
|
+
default: return state;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Single-cell themed state glyph. Running states animate via spinnerFrame. */
|
|
89
|
+
export function stateGlyph(state: RunState | undefined, theme: Theme, spinnerFrame = 0): string {
|
|
90
|
+
switch (state) {
|
|
91
|
+
case 'queued': return theme.fg('dim', '◌');
|
|
92
|
+
case 'running': return theme.fg('accent', SPINNERS[spinnerFrame % SPINNERS.length]!);
|
|
93
|
+
case 'completed': return theme.fg('success', '✓');
|
|
94
|
+
case 'partial': return theme.fg('warning', '◐');
|
|
95
|
+
case 'cancelled': return theme.fg('muted', '−');
|
|
96
|
+
case 'timeout': return theme.fg('warning', '◷');
|
|
97
|
+
case 'lost': return theme.fg('error', '?');
|
|
98
|
+
case 'failed': return theme.fg('error', '✗');
|
|
99
|
+
default: return theme.fg('dim', '·');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Status line preview (metadata only, not full summary). Duration freezes at endedAt. */
|
|
104
|
+
export function formatStatusPreview(snapshot: RunSnapshot, now = Date.now()): string {
|
|
105
|
+
const done = snapshot.delivered ? 'delivered' : snapshot.resumeBlocked ? 'blocked' : 'ready';
|
|
106
|
+
const elapsed = formatElapsed(snapshot.startedAt, snapshot.endedAt ?? now);
|
|
107
|
+
const phase = snapshot.results.find((r) => r.timeoutPhase)?.timeoutPhase;
|
|
108
|
+
const phaseTag = snapshot.state === 'timeout' && phase ? `/${phase}` : '';
|
|
109
|
+
// Reliability flags from task results (attempt count, stall watchdog).
|
|
110
|
+
let maxAttempts = 0;
|
|
111
|
+
let stalledSince: number | undefined;
|
|
112
|
+
for (const r of snapshot.results) {
|
|
113
|
+
if (typeof r.attempts === 'number' && r.attempts > maxAttempts) maxAttempts = r.attempts;
|
|
114
|
+
if (r.stalledSince && isActiveState(r.state)) {
|
|
115
|
+
stalledSince = stalledSince === undefined ? r.stalledSince : Math.min(stalledSince, r.stalledSince);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const flags: string[] = [];
|
|
119
|
+
if (maxAttempts > 1) flags.push(`[attempt ${maxAttempts}]`);
|
|
120
|
+
if (stalledSince !== undefined && isActiveState(snapshot.state)) {
|
|
121
|
+
flags.push(`[stalled ${formatDuration(now - stalledSince)}]`);
|
|
122
|
+
}
|
|
123
|
+
const flagText = flags.length ? ` ${flags.join(' ')}` : '';
|
|
124
|
+
return `[${snapshot.id.slice(0, 8)}] ${snapshot.mode} ${formatState(snapshot.state)}${phaseTag} ${elapsed} ${done}${flagText}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Inline tool-block rendering ─────────────────────────────────────────────
|
|
128
|
+
//
|
|
129
|
+
// Pi's tool shell (Box) already paints pending/success/error backgrounds and
|
|
130
|
+
// state, so inline blocks stay compact: a stats line plus a `⎿ activity`
|
|
131
|
+
// line, fixed height while streaming, mutating in place.
|
|
132
|
+
|
|
133
|
+
export interface InlineTaskView {
|
|
134
|
+
label?: string;
|
|
135
|
+
state?: RunState;
|
|
136
|
+
usage?: Partial<UsageStats>;
|
|
137
|
+
model?: string;
|
|
138
|
+
stopReason?: string;
|
|
139
|
+
timeoutPhase?: TimeoutPhase;
|
|
140
|
+
errorMessage?: string;
|
|
141
|
+
finalOutput?: string;
|
|
142
|
+
outputFile?: string;
|
|
143
|
+
sessionId?: string;
|
|
144
|
+
worktree?: { cwd: string; branch: string };
|
|
145
|
+
wrappedUp?: boolean;
|
|
146
|
+
stalledSince?: number;
|
|
147
|
+
attempts?: number;
|
|
148
|
+
structuredOutput?: unknown;
|
|
149
|
+
structuredError?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface InlineRunView {
|
|
153
|
+
mode: RunMode;
|
|
154
|
+
state?: RunState;
|
|
155
|
+
startedAt?: number;
|
|
156
|
+
endedAt?: number;
|
|
157
|
+
results: InlineTaskView[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface InlineRenderOptions {
|
|
161
|
+
theme: Theme;
|
|
162
|
+
width: number;
|
|
163
|
+
expanded?: boolean;
|
|
164
|
+
isPartial?: boolean;
|
|
165
|
+
spinnerFrame?: number;
|
|
166
|
+
now?: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface AggregateStats { turns: number; tokens: number; cost: number }
|
|
170
|
+
|
|
171
|
+
function usageAggregate(results: InlineTaskView[]): AggregateStats {
|
|
172
|
+
let turns = 0, tokens = 0, cost = 0;
|
|
173
|
+
for (const r of results) {
|
|
174
|
+
turns += r.usage?.turns ?? 0;
|
|
175
|
+
tokens += (r.usage?.input ?? 0) + (r.usage?.output ?? 0);
|
|
176
|
+
cost += r.usage?.cost ?? 0;
|
|
177
|
+
}
|
|
178
|
+
return { turns, tokens, cost };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function statsText(agg: AggregateStats, durationMs?: number): string {
|
|
182
|
+
const parts: string[] = [];
|
|
183
|
+
if (agg.turns > 0) parts.push(`↻${agg.turns}`);
|
|
184
|
+
if (agg.tokens > 0) parts.push(`${formatTokens(agg.tokens)} tok`);
|
|
185
|
+
if (agg.cost > 0.00005) parts.push(formatCost(agg.cost));
|
|
186
|
+
if (durationMs !== undefined && durationMs >= 0) parts.push(formatDuration(durationMs));
|
|
187
|
+
return parts.join(' · ');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function taskAnnotations(task: InlineTaskView, now: number): string[] {
|
|
191
|
+
const notes: string[] = [];
|
|
192
|
+
if (task.attempts && task.attempts > 1) notes.push(`attempt ${task.attempts}`);
|
|
193
|
+
if (task.stalledSince && isActiveState(task.state)) notes.push(`stalled ${formatDuration(now - task.stalledSince)}`);
|
|
194
|
+
if (!isActiveState(task.state)) {
|
|
195
|
+
if (task.structuredOutput !== undefined) notes.push('✓ schema');
|
|
196
|
+
else if (task.structuredError) notes.push('schema ✗');
|
|
197
|
+
}
|
|
198
|
+
return notes;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function pickLine(text: string | undefined, which: 'first' | 'last'): string | undefined {
|
|
202
|
+
if (!text) return undefined;
|
|
203
|
+
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
204
|
+
if (!lines.length) return undefined;
|
|
205
|
+
return which === 'last' ? lines[lines.length - 1] : lines[0];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** One-line collapsed call header: `subagent <preview>`. */
|
|
209
|
+
export function renderCallLine(args: any, theme: Theme, width: number): string {
|
|
210
|
+
const title = theme.fg('toolTitle', theme.bold('subagent'));
|
|
211
|
+
let preview = '';
|
|
212
|
+
if (args?.action) {
|
|
213
|
+
preview = `${args.action}${args.id ? ` ${String(args.id).slice(0, 8)}` : ''}`;
|
|
214
|
+
} else if (Array.isArray(args?.tasks)) {
|
|
215
|
+
const first = args.tasks[0]?.task;
|
|
216
|
+
preview = `${args.tasks.length} parallel tasks${first ? ` — ${oneLine(String(first), 60)}` : ''}`;
|
|
217
|
+
} else if (args?.resume) {
|
|
218
|
+
preview = `resume ${String(args.resume).slice(0, 8)}${args.task ? ` — ${oneLine(String(args.task))}` : ''}`;
|
|
219
|
+
} else if (args?.task) {
|
|
220
|
+
preview = oneLine(String(args.task));
|
|
221
|
+
}
|
|
222
|
+
const tag = args?.async ? ` ${theme.fg('accent', '· background')}` : '';
|
|
223
|
+
return truncateToWidth(`${title} ${theme.fg('muted', preview)}${tag}`, width);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function wrapLines(text: string, width: number): string[] {
|
|
227
|
+
const wrapped = wrapTextWithAnsi(text, Math.max(10, width));
|
|
228
|
+
return Array.isArray(wrapped) ? wrapped : String(wrapped).split('\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
type ThemeColor = Parameters<Theme['fg']>[0];
|
|
232
|
+
|
|
233
|
+
function terminalTaskLine(theme: Theme, task: InlineTaskView): { text: string; color: ThemeColor } | undefined {
|
|
234
|
+
switch (task.state) {
|
|
235
|
+
case 'failed':
|
|
236
|
+
case 'lost':
|
|
237
|
+
return { text: `${formatState(task.state)} — ${oneLine(task.errorMessage ?? task.stopReason ?? 'unknown error')}`, color: 'error' };
|
|
238
|
+
case 'cancelled':
|
|
239
|
+
return { text: 'cancelled', color: 'muted' };
|
|
240
|
+
case 'timeout':
|
|
241
|
+
return { text: `timed out${task.timeoutPhase ? ` (${task.timeoutPhase})` : ''}`, color: 'warning' };
|
|
242
|
+
case 'partial': {
|
|
243
|
+
if (task.wrappedUp) {
|
|
244
|
+
const first = pickLine(task.finalOutput, 'first');
|
|
245
|
+
return { text: `wrapped up (${(task.stopReason ?? 'budget').replace('_', ' ')})${first ? ` — ${oneLine(first, 80)}` : ''}`, color: 'warning' };
|
|
246
|
+
}
|
|
247
|
+
if (task.stopReason === 'stalled') {
|
|
248
|
+
return { text: `stalled — ${oneLine(task.errorMessage ?? 'no activity', 80)}`, color: 'warning' };
|
|
249
|
+
}
|
|
250
|
+
const first = pickLine(task.finalOutput, 'first');
|
|
251
|
+
return first ? { text: oneLine(first), color: 'toolOutput' } : undefined;
|
|
252
|
+
}
|
|
253
|
+
default: {
|
|
254
|
+
const first = pickLine(task.finalOutput, 'first');
|
|
255
|
+
return first ? { text: oneLine(first), color: 'toolOutput' } : undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function pointerText(task: InlineTaskView, expanded: boolean): string | undefined {
|
|
261
|
+
const parts: string[] = [];
|
|
262
|
+
if (task.outputFile) parts.push(`→ ${formatPath(task.outputFile)}`);
|
|
263
|
+
if (task.worktree) parts.push(`⎇ ${task.worktree.branch}`);
|
|
264
|
+
if (expanded && task.sessionId) parts.push(`session ${task.sessionId.slice(0, 8)}`);
|
|
265
|
+
return parts.length ? parts.join(' · ') : undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function expandedOutputLines(theme: Theme, task: InlineTaskView, width: number, cap: number): string[] {
|
|
269
|
+
if (!task.finalOutput) return [];
|
|
270
|
+
const lines: string[] = [''];
|
|
271
|
+
const wrapped = wrapLines(task.finalOutput, width - 2);
|
|
272
|
+
for (const line of wrapped.slice(0, cap)) lines.push(` ${theme.fg('toolOutput', line)}`);
|
|
273
|
+
if (wrapped.length > cap) {
|
|
274
|
+
lines.push(theme.fg('dim', ` … +${wrapped.length - cap} lines (full output in ${task.outputFile ? formatPath(task.outputFile) : 'the child session'})`));
|
|
275
|
+
}
|
|
276
|
+
return lines;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Compact run block. Fixed shape while streaming:
|
|
281
|
+
* ⠹ ↻3 · 12.4k tok · 8s
|
|
282
|
+
* ⎿ reading src/auth/middleware.ts…
|
|
283
|
+
* Terminal:
|
|
284
|
+
* ↻8 · 33.8k tok · $0.012 · 12s
|
|
285
|
+
* ⎿ Found 5 middleware call sites…
|
|
286
|
+
* Parallel collapsed: one line per task.
|
|
287
|
+
*/
|
|
288
|
+
export function renderRunLines(run: InlineRunView, opts: InlineRenderOptions): string[] {
|
|
289
|
+
const { theme, width } = opts;
|
|
290
|
+
const now = opts.now ?? Date.now();
|
|
291
|
+
const frame = opts.spinnerFrame ?? 0;
|
|
292
|
+
const running = opts.isPartial ?? isActiveState(run.state);
|
|
293
|
+
const durationMs = run.startedAt ? (run.endedAt ?? now) - run.startedAt : undefined;
|
|
294
|
+
const agg = usageAggregate(run.results);
|
|
295
|
+
const stats = statsText(agg, durationMs);
|
|
296
|
+
const spin = theme.fg('accent', SPINNERS[frame % SPINNERS.length]!);
|
|
297
|
+
const lines: string[] = [];
|
|
298
|
+
|
|
299
|
+
if (run.mode === 'parallel' && run.results.length > 1) {
|
|
300
|
+
const total = run.results.length;
|
|
301
|
+
const done = run.results.filter((r) => r.state && !isActiveState(r.state)).length;
|
|
302
|
+
lines.push(running
|
|
303
|
+
? `${spin} ${theme.fg('dim', `${done}/${total} done${stats ? ` · ${stats}` : ''}`)}`
|
|
304
|
+
: theme.fg('dim', `${total} tasks${stats ? ` · ${stats}` : ''}`));
|
|
305
|
+
|
|
306
|
+
const shown = opts.expanded ? run.results : run.results.slice(0, 6);
|
|
307
|
+
for (const task of shown) {
|
|
308
|
+
const glyph = stateGlyph(task.state, theme, frame);
|
|
309
|
+
const mini = statsText(usageAggregate([task]));
|
|
310
|
+
const active = isActiveState(task.state);
|
|
311
|
+
// The state glyph already communicates the outcome; parallel rows show
|
|
312
|
+
// just the message/preview without repeating the state word.
|
|
313
|
+
const tail = active
|
|
314
|
+
? pickLine(task.finalOutput, 'last')
|
|
315
|
+
: ['failed', 'lost'].includes(task.state ?? '')
|
|
316
|
+
? (task.errorMessage ?? task.stopReason ?? formatState(task.state!))
|
|
317
|
+
: task.state === 'timeout'
|
|
318
|
+
? `timed out${task.timeoutPhase ? ` (${task.timeoutPhase})` : ''}`
|
|
319
|
+
: task.state === 'cancelled'
|
|
320
|
+
? undefined
|
|
321
|
+
: pickLine(task.finalOutput, 'first');
|
|
322
|
+
const tailColor: ThemeColor = !active && ['failed', 'lost'].includes(task.state ?? '') ? 'error' : 'muted';
|
|
323
|
+
let line = ` ${glyph} ${theme.fg('dim', task.model ?? 'model unknown')} · ${theme.fg('text', task.label ?? 'task')}`;
|
|
324
|
+
if (mini) line += theme.fg('dim', ` · ${mini}`);
|
|
325
|
+
const notes = taskAnnotations(task, now);
|
|
326
|
+
if (notes.length) line += ` ${theme.fg('warning', `[${notes.join(' · ')}]`)}`;
|
|
327
|
+
if (task.wrappedUp && !active) line += ` ${theme.fg('warning', '◐ wrapped up')}`;
|
|
328
|
+
if (tail) line += ` ${theme.fg(tailColor, `— ${oneLine(tail, 80)}`)}`;
|
|
329
|
+
lines.push(line);
|
|
330
|
+
if (opts.expanded) {
|
|
331
|
+
const pointers = pointerText(task, true);
|
|
332
|
+
if (pointers) lines.push(theme.fg('dim', ` ${pointers}`));
|
|
333
|
+
lines.push(...expandedOutputLines(theme, task, width, 12).map((l) => l ? ` ${l}` : l));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!opts.expanded && total > shown.length) {
|
|
337
|
+
lines.push(theme.fg('dim', ` … +${total - shown.length} more`));
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
const task = run.results[0] ?? {};
|
|
341
|
+
if (running) {
|
|
342
|
+
const notes = taskAnnotations(task, now);
|
|
343
|
+
const noteText = notes.length ? ` ${theme.fg('warning', `[${notes.join(' · ')}]`)}` : '';
|
|
344
|
+
const modelText = theme.fg('dim', task.model ?? 'model unknown');
|
|
345
|
+
lines.push(`${spin} ${modelText} · ${theme.fg('dim', stats || 'starting…')}${noteText}`);
|
|
346
|
+
const activity = pickLine(task.finalOutput, 'last');
|
|
347
|
+
if (activity) lines.push(` ${theme.fg('dim', '⎿')} ${theme.fg('muted', oneLine(activity, width))}`);
|
|
348
|
+
} else {
|
|
349
|
+
const modelText = task.model ?? 'model unknown';
|
|
350
|
+
lines.push(theme.fg('dim', `${modelText} · ${stats || formatState(run.state ?? task.state ?? 'completed')}`));
|
|
351
|
+
const summary = terminalTaskLine(theme, task);
|
|
352
|
+
if (summary && !opts.expanded) lines.push(` ${theme.fg('dim', '⎿')} ${theme.fg(summary.color, oneLine(summary.text, width))}`);
|
|
353
|
+
const pointers = pointerText(task, opts.expanded ?? false);
|
|
354
|
+
if (pointers) lines.push(theme.fg('dim', ` ${pointers}`));
|
|
355
|
+
if (opts.expanded) {
|
|
356
|
+
if (summary && ['error', 'warning', 'muted'].includes(summary.color)) {
|
|
357
|
+
lines.push(` ${theme.fg('dim', '⎿')} ${theme.fg(summary.color, oneLine(summary.text, width))}`);
|
|
358
|
+
}
|
|
359
|
+
lines.push(...expandedOutputLines(theme, task, width, 40));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
365
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable public SDK for library consumers (e.g. pi-workflows).
|
|
3
|
+
*
|
|
4
|
+
* Import from `@parke.dev/pi-subagent` — do not reach into `src/*` internals.
|
|
5
|
+
* The Pi extension entry remains `extensions/subagent.ts` via package `pi.extensions`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { runTasks } from "./orchestrator.js";
|
|
9
|
+
export type { OrchestratedRun, OrchestratorDeps } from "./orchestrator.js";
|
|
10
|
+
|
|
11
|
+
export { ChildRunner, runSubagent } from "./runner.js";
|
|
12
|
+
export type { GetPiCommand, RunnerOptions } from "./runner.js";
|
|
13
|
+
|
|
14
|
+
export { WorktreeManager } from "./worktree.js";
|
|
15
|
+
export type {
|
|
16
|
+
CreateWorktreeOptions,
|
|
17
|
+
GlobalSweepReport,
|
|
18
|
+
SweepReport,
|
|
19
|
+
WorktreeApplyResult,
|
|
20
|
+
WorktreeDiffResult,
|
|
21
|
+
WorktreeHandle,
|
|
22
|
+
} from "./worktree.js";
|
|
23
|
+
|
|
24
|
+
export { Semaphore } from "./semaphore.js";
|
|
25
|
+
|
|
26
|
+
export { ProcessLockManager } from "./process-lock.js";
|
|
27
|
+
export type {
|
|
28
|
+
ProcessIdentity,
|
|
29
|
+
ProcessLockOptions,
|
|
30
|
+
RunProcessRecord,
|
|
31
|
+
SessionLockOwner,
|
|
32
|
+
SlotToken,
|
|
33
|
+
} from "./process-lock.js";
|
|
34
|
+
|
|
35
|
+
export type {
|
|
36
|
+
BackendAdapter,
|
|
37
|
+
BackendCapabilities,
|
|
38
|
+
BackendName,
|
|
39
|
+
} from "./backend.js";
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
addUsage,
|
|
43
|
+
buildUsageLedger,
|
|
44
|
+
formatLedger,
|
|
45
|
+
hasBilledUsage,
|
|
46
|
+
normalizeUsage,
|
|
47
|
+
toPiUsage,
|
|
48
|
+
} from "./usage.js";
|
|
49
|
+
export type { UsageLedger } from "./usage.js";
|
|
50
|
+
|
|
51
|
+
export { emptyUsage } from "./types.js";
|
|
52
|
+
export type {
|
|
53
|
+
RunMode,
|
|
54
|
+
RunSnapshot,
|
|
55
|
+
RunState,
|
|
56
|
+
TaskProfile,
|
|
57
|
+
TaskResult,
|
|
58
|
+
TaskSpec,
|
|
59
|
+
UsageStats,
|
|
60
|
+
} from "./types.js";
|
package/src/launch.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { GetPiCommand } from "./runner.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the command used to spawn child `pi` processes.
|
|
7
|
+
*
|
|
8
|
+
* Preference order:
|
|
9
|
+
* 1. `PI_SUBAGENT_BIN` (explicit override; path or PATH name)
|
|
10
|
+
* 2. Same Node runtime + current CLI entry (`process.execPath` + `process.argv[1]`)
|
|
11
|
+
* 3. Bare `"pi"` on PATH as a last-resort, logged fallback
|
|
12
|
+
*
|
|
13
|
+
* Pinning the executing entrypoint avoids ENOENT under GUI/editor launches and
|
|
14
|
+
* wrong-version silent failures when multiple Pi installs share PATH.
|
|
15
|
+
*/
|
|
16
|
+
export interface LaunchResolution {
|
|
17
|
+
command: string;
|
|
18
|
+
/** Prefix args inserted before child flags (usually the CLI entry path). */
|
|
19
|
+
argsPrefix: string[];
|
|
20
|
+
source: "env" | "exec-path" | "path-fallback";
|
|
21
|
+
note?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function looksLikeJsEntry(file: string): boolean {
|
|
25
|
+
const lower = file.toLowerCase();
|
|
26
|
+
return (
|
|
27
|
+
lower.endsWith(".js") ||
|
|
28
|
+
lower.endsWith(".mjs") ||
|
|
29
|
+
lower.endsWith(".cjs") ||
|
|
30
|
+
lower.endsWith(".ts") ||
|
|
31
|
+
lower.endsWith(".mts") ||
|
|
32
|
+
lower.endsWith(".cts")
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fileExists(file: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
return fs.statSync(file).isFile();
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve an argv[1] candidate to a real JS entry file, following symlinks.
|
|
46
|
+
* npm/Homebrew global installs expose extensionless bin shims (e.g.
|
|
47
|
+
* `/opt/homebrew/bin/pi -> …/dist/cli.js`), so the symlink target — not the
|
|
48
|
+
* shim path — is what identifies a Node CLI entry.
|
|
49
|
+
*/
|
|
50
|
+
function resolveJsEntry(candidate: string): string | undefined {
|
|
51
|
+
let entry = path.resolve(candidate);
|
|
52
|
+
try {
|
|
53
|
+
entry = fs.realpathSync(entry);
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return looksLikeJsEntry(entry) && fileExists(entry) ? entry : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Pure resolver suitable for tests; does not touch process globals until called. */
|
|
61
|
+
export function resolvePiLaunch(
|
|
62
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
63
|
+
execPath = process.execPath,
|
|
64
|
+
argv: readonly string[] = process.argv,
|
|
65
|
+
): LaunchResolution {
|
|
66
|
+
const override = env.PI_SUBAGENT_BIN?.trim();
|
|
67
|
+
if (override) {
|
|
68
|
+
return { command: override, argsPrefix: [], source: "env" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// `argv[1]` is the userland entry for a normal Node CLI / tsx / “node path/to/pi.js”
|
|
72
|
+
// launch. Global installs go through an extensionless bin symlink, so follow
|
|
73
|
+
// symlinks to the real file before judging it. Bundled / pkg-style binaries
|
|
74
|
+
// often leave argv[1] unset or not a real path.
|
|
75
|
+
const entry = argv[1] ? resolveJsEntry(argv[1]) : undefined;
|
|
76
|
+
if (entry && execPath) {
|
|
77
|
+
return {
|
|
78
|
+
command: execPath,
|
|
79
|
+
argsPrefix: [entry],
|
|
80
|
+
source: "exec-path",
|
|
81
|
+
note: `Using ${execPath} ${entry}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
command: "pi",
|
|
87
|
+
argsPrefix: [],
|
|
88
|
+
source: "path-fallback",
|
|
89
|
+
note: "Could not resolve the current Pi CLI entry; falling back to bare 'pi' on PATH. Set PI_SUBAGENT_BIN to pin an executable.",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let cached: LaunchResolution | undefined;
|
|
94
|
+
let warnedFallback = false;
|
|
95
|
+
|
|
96
|
+
/** Cached resolution for the process lifetime. */
|
|
97
|
+
export function getLaunchResolution(force = false): LaunchResolution {
|
|
98
|
+
if (!cached || force) cached = resolvePiLaunch();
|
|
99
|
+
return cached;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** GetPiCommand wired to the resolved launch. */
|
|
103
|
+
export function createGetPiCommand(resolution?: LaunchResolution): GetPiCommand {
|
|
104
|
+
const resolved = resolution ?? getLaunchResolution();
|
|
105
|
+
if (resolved.source === "path-fallback" && !warnedFallback) {
|
|
106
|
+
warnedFallback = true;
|
|
107
|
+
// eslint-disable-next-line no-console
|
|
108
|
+
console.warn(`[pi-subagent] ${resolved.note ?? "Falling back to bare 'pi' on PATH."}`);
|
|
109
|
+
}
|
|
110
|
+
return (args) => ({
|
|
111
|
+
command: resolved.command,
|
|
112
|
+
args: [...resolved.argsPrefix, ...args],
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Reset module state (tests only). */
|
|
117
|
+
export function _resetLaunchCacheForTests(): void {
|
|
118
|
+
cached = undefined;
|
|
119
|
+
warnedFallback = false;
|
|
120
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Resolve an aborted signal into a promise for Promise.race patterns. */
|
|
2
|
+
export function abortAsPromise(signal: AbortSignal | undefined): Promise<"aborted"> | undefined {
|
|
3
|
+
if (!signal) return undefined;
|
|
4
|
+
if (signal.aborted) return Promise.resolve("aborted");
|
|
5
|
+
return new Promise((resolve) => signal.addEventListener("abort", () => resolve("aborted"), { once: true }));
|
|
6
|
+
}
|