@cr1ms0n/pi-subagent 0.8.8 → 0.9.0
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 +14 -6
- package/README.md +218 -128
- package/docs/ARCHITECTURE.md +168 -132
- package/docs/COST-ACCOUNTING.md +116 -66
- package/docs/RELEASING.md +32 -32
- package/docs/SECURITY.md +125 -97
- package/docs/UX.md +158 -141
- package/package.json +2 -2
- package/skills/subagent/SKILL.md +142 -121
- package/src/agents.ts +282 -288
- package/src/backends/pi.ts +164 -94
- package/src/child-preflight.ts +166 -0
- package/src/config.ts +254 -252
- package/src/dispatch-preflight.ts +87 -0
- package/src/dispatch-routing.ts +56 -0
- package/src/extension.ts +368 -187
- package/src/format.ts +436 -365
- package/src/jev-router.ts +1036 -0
- package/src/orchestrator.ts +303 -312
- package/src/persistence.ts +643 -335
- package/src/policy.ts +562 -561
- package/src/process-lock.ts +730 -687
- package/src/protocol.ts +320 -290
- package/src/registry.ts +730 -632
- package/src/routing-policy.ts +268 -0
- package/src/routing-types.ts +217 -0
- package/src/runner.ts +1299 -850
- package/src/schema.ts +189 -189
- package/src/startup-check.ts +481 -0
- package/src/types.ts +208 -198
- package/src/usage.ts +316 -274
- package/src/context-policy.ts +0 -169
- package/src/model-policy.ts +0 -169
package/src/format.ts
CHANGED
|
@@ -1,365 +1,436 @@
|
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function
|
|
269
|
-
if (!
|
|
270
|
-
const lines
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
:
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
:
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
+
/**
|
|
68
|
+
* Structural subset of the persisted Jev route metadata that the TUI can render.
|
|
69
|
+
* `TaskRouting` structurally satisfies this; legacy/empty input yields `undefined` so
|
|
70
|
+
* old runs simply render no route line.
|
|
71
|
+
*/
|
|
72
|
+
export interface RoutingLineInput {
|
|
73
|
+
selectedModel?: string;
|
|
74
|
+
selectorModel?: string;
|
|
75
|
+
selectorVersion?: string;
|
|
76
|
+
selectedTools?: readonly string[];
|
|
77
|
+
mandatoryTools?: readonly string[];
|
|
78
|
+
confidence?: number;
|
|
79
|
+
latencyMs?: number;
|
|
80
|
+
outcome?: string;
|
|
81
|
+
code?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ROUTE_MAX_TOOLS = 8;
|
|
85
|
+
const ROUTE_MAX_TOOL_NAME = 24;
|
|
86
|
+
|
|
87
|
+
function summarizeRoutingTools(tools: readonly string[]): string {
|
|
88
|
+
const shown = tools.slice(0, ROUTE_MAX_TOOLS).map((name) => (name.length > ROUTE_MAX_TOOL_NAME ? `${name.slice(0, ROUTE_MAX_TOOL_NAME - 1)}…` : name));
|
|
89
|
+
return tools.length > shown.length ? `${shown.join(',')} +${tools.length - shown.length}` : shown.join(',');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One minimal route line for existing expanded surfaces: selected model, selector
|
|
94
|
+
* version, chosen tools, locally added mandatory controls, outcome and latency.
|
|
95
|
+
* ANSI-free and capped so the caller's `truncateToWidth` stays authoritative.
|
|
96
|
+
*/
|
|
97
|
+
export function formatRouteLine(routing?: RoutingLineInput, max = 160): string | undefined {
|
|
98
|
+
if (!routing || typeof routing !== 'object') return undefined;
|
|
99
|
+
const str = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() ? value.trim() : undefined);
|
|
100
|
+
const list = (value: unknown): readonly string[] | undefined =>
|
|
101
|
+
Array.isArray(value)
|
|
102
|
+
? value.filter((item): item is string => typeof item === 'string' && !!item.trim()).slice(0, 64)
|
|
103
|
+
: undefined;
|
|
104
|
+
|
|
105
|
+
const selectedModel = str(routing.selectedModel);
|
|
106
|
+
const selectorModel = str(routing.selectorModel);
|
|
107
|
+
const selectorVersion = str(routing.selectorVersion);
|
|
108
|
+
const selectedTools = list(routing.selectedTools);
|
|
109
|
+
const mandatoryTools = list(routing.mandatoryTools);
|
|
110
|
+
const outcome = str(routing.outcome);
|
|
111
|
+
const code = str(routing.code);
|
|
112
|
+
const confidence = typeof routing.confidence === 'number' && Number.isFinite(routing.confidence) ? routing.confidence : undefined;
|
|
113
|
+
const latencyMs = typeof routing.latencyMs === 'number' && Number.isFinite(routing.latencyMs) && routing.latencyMs >= 0 ? routing.latencyMs : undefined;
|
|
114
|
+
|
|
115
|
+
const meaningful = !!(selectedModel || selectorModel || selectorVersion || outcome)
|
|
116
|
+
|| confidence !== undefined || latencyMs !== undefined
|
|
117
|
+
|| selectedTools !== undefined || mandatoryTools !== undefined;
|
|
118
|
+
if (!meaningful) return undefined;
|
|
119
|
+
|
|
120
|
+
const parts: string[] = [];
|
|
121
|
+
if (selectedModel) parts.push(selectedModel);
|
|
122
|
+
const selector = selectorModel ? (selectorVersion ? `${selectorModel}@${selectorVersion}` : selectorModel) : selectorVersion;
|
|
123
|
+
if (selector) parts.push(`sel ${selector}`);
|
|
124
|
+
if (confidence !== undefined) parts.push(`conf ${confidence.toFixed(2)}`);
|
|
125
|
+
parts.push(`tools ${selectedTools && selectedTools.length ? summarizeRoutingTools(selectedTools) : 'none'}`);
|
|
126
|
+
if (mandatoryTools && mandatoryTools.length) parts.push(`+${summarizeRoutingTools(mandatoryTools)}`);
|
|
127
|
+
if (outcome) parts.push(code ? `${outcome}/${code}` : outcome);
|
|
128
|
+
if (latencyMs !== undefined) parts.push(formatDuration(latencyMs));
|
|
129
|
+
return oneLine(`route ${parts.join(' · ')}`, max);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function formatPath(p?: string): string {
|
|
133
|
+
if (!p) return '(none)';
|
|
134
|
+
const home = os.homedir();
|
|
135
|
+
if (p.startsWith(home)) return '~' + p.slice(home.length);
|
|
136
|
+
return p.length > 40 ? '...' + p.slice(-37) : p;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function formatState(state: RunState, exitCode?: number | null): string {
|
|
140
|
+
switch (state) {
|
|
141
|
+
case 'running': return 'running';
|
|
142
|
+
case 'completed': return typeof exitCode === 'number' && exitCode !== 0 ? 'failed' : 'done';
|
|
143
|
+
case 'failed': return 'failed';
|
|
144
|
+
case 'cancelled': return 'cancelled';
|
|
145
|
+
case 'queued': return 'queued';
|
|
146
|
+
case 'partial': return 'partial';
|
|
147
|
+
case 'lost': return 'lost';
|
|
148
|
+
case 'timeout': return 'timeout';
|
|
149
|
+
default: return state;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Single-cell themed state glyph. Running states animate via spinnerFrame. */
|
|
154
|
+
export function stateGlyph(state: RunState | undefined, theme: Theme, spinnerFrame = 0): string {
|
|
155
|
+
switch (state) {
|
|
156
|
+
case 'queued': return theme.fg('dim', '◌');
|
|
157
|
+
case 'running': return theme.fg('accent', SPINNERS[spinnerFrame % SPINNERS.length]!);
|
|
158
|
+
case 'completed': return theme.fg('success', '✓');
|
|
159
|
+
case 'partial': return theme.fg('warning', '◐');
|
|
160
|
+
case 'cancelled': return theme.fg('muted', '−');
|
|
161
|
+
case 'timeout': return theme.fg('warning', '◷');
|
|
162
|
+
case 'lost': return theme.fg('error', '?');
|
|
163
|
+
case 'failed': return theme.fg('error', '✗');
|
|
164
|
+
default: return theme.fg('dim', '·');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Status line preview (metadata only, not full summary). Duration freezes at endedAt. */
|
|
169
|
+
export function formatStatusPreview(snapshot: RunSnapshot, now = Date.now()): string {
|
|
170
|
+
const done = snapshot.delivered ? 'delivered' : snapshot.resumeBlocked ? 'blocked' : 'ready';
|
|
171
|
+
const elapsed = formatElapsed(snapshot.startedAt, snapshot.endedAt ?? now);
|
|
172
|
+
const phase = snapshot.results.find((r) => r.timeoutPhase)?.timeoutPhase;
|
|
173
|
+
const phaseTag = snapshot.state === 'timeout' && phase ? `/${phase}` : '';
|
|
174
|
+
// Reliability flags from task results (attempt count, stall watchdog).
|
|
175
|
+
let maxAttempts = 0;
|
|
176
|
+
let stalledSince: number | undefined;
|
|
177
|
+
for (const r of snapshot.results) {
|
|
178
|
+
if (typeof r.attempts === 'number' && r.attempts > maxAttempts) maxAttempts = r.attempts;
|
|
179
|
+
if (r.stalledSince && isActiveState(r.state)) {
|
|
180
|
+
stalledSince = stalledSince === undefined ? r.stalledSince : Math.min(stalledSince, r.stalledSince);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const flags: string[] = [];
|
|
184
|
+
if (maxAttempts > 1) flags.push(`[attempt ${maxAttempts}]`);
|
|
185
|
+
if (stalledSince !== undefined && isActiveState(snapshot.state)) {
|
|
186
|
+
flags.push(`[stalled ${formatDuration(now - stalledSince)}]`);
|
|
187
|
+
}
|
|
188
|
+
const flagText = flags.length ? ` ${flags.join(' ')}` : '';
|
|
189
|
+
return `[${snapshot.id.slice(0, 8)}] ${snapshot.mode} ${formatState(snapshot.state)}${phaseTag} ${elapsed} ${done}${flagText}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Inline tool-block rendering ─────────────────────────────────────────────
|
|
193
|
+
//
|
|
194
|
+
// Pi's tool shell (Box) already paints pending/success/error backgrounds and
|
|
195
|
+
// state, so inline blocks stay compact: a stats line plus a `⎿ activity`
|
|
196
|
+
// line, fixed height while streaming, mutating in place.
|
|
197
|
+
|
|
198
|
+
export interface InlineTaskView {
|
|
199
|
+
label?: string;
|
|
200
|
+
state?: RunState;
|
|
201
|
+
usage?: Partial<UsageStats>;
|
|
202
|
+
model?: string;
|
|
203
|
+
stopReason?: string;
|
|
204
|
+
timeoutPhase?: TimeoutPhase;
|
|
205
|
+
errorMessage?: string;
|
|
206
|
+
finalOutput?: string;
|
|
207
|
+
outputFile?: string;
|
|
208
|
+
sessionId?: string;
|
|
209
|
+
worktree?: { cwd: string; branch: string };
|
|
210
|
+
wrappedUp?: boolean;
|
|
211
|
+
stalledSince?: number;
|
|
212
|
+
attempts?: number;
|
|
213
|
+
structuredOutput?: unknown;
|
|
214
|
+
structuredError?: string;
|
|
215
|
+
/** Bounded Jev route metadata; rendered only on expanded surfaces. */
|
|
216
|
+
routing?: RoutingLineInput;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface InlineRunView {
|
|
220
|
+
mode: RunMode;
|
|
221
|
+
state?: RunState;
|
|
222
|
+
startedAt?: number;
|
|
223
|
+
endedAt?: number;
|
|
224
|
+
results: InlineTaskView[];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface InlineRenderOptions {
|
|
228
|
+
theme: Theme;
|
|
229
|
+
width: number;
|
|
230
|
+
expanded?: boolean;
|
|
231
|
+
isPartial?: boolean;
|
|
232
|
+
spinnerFrame?: number;
|
|
233
|
+
now?: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
interface AggregateStats { turns: number; tokens: number; cost: number }
|
|
237
|
+
|
|
238
|
+
function usageAggregate(results: InlineTaskView[]): AggregateStats {
|
|
239
|
+
let turns = 0, tokens = 0, cost = 0;
|
|
240
|
+
for (const r of results) {
|
|
241
|
+
turns += r.usage?.turns ?? 0;
|
|
242
|
+
tokens += (r.usage?.input ?? 0) + (r.usage?.output ?? 0);
|
|
243
|
+
cost += r.usage?.cost ?? 0;
|
|
244
|
+
}
|
|
245
|
+
return { turns, tokens, cost };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function statsText(agg: AggregateStats, durationMs?: number): string {
|
|
249
|
+
const parts: string[] = [];
|
|
250
|
+
if (agg.turns > 0) parts.push(`↻${agg.turns}`);
|
|
251
|
+
if (agg.tokens > 0) parts.push(`${formatTokens(agg.tokens)} tok`);
|
|
252
|
+
if (agg.cost > 0.00005) parts.push(formatCost(agg.cost));
|
|
253
|
+
if (durationMs !== undefined && durationMs >= 0) parts.push(formatDuration(durationMs));
|
|
254
|
+
return parts.join(' · ');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function taskAnnotations(task: InlineTaskView, now: number): string[] {
|
|
258
|
+
const notes: string[] = [];
|
|
259
|
+
if (task.attempts && task.attempts > 1) notes.push(`attempt ${task.attempts}`);
|
|
260
|
+
if (task.stalledSince && isActiveState(task.state)) notes.push(`stalled ${formatDuration(now - task.stalledSince)}`);
|
|
261
|
+
if (!isActiveState(task.state)) {
|
|
262
|
+
if (task.structuredOutput !== undefined) notes.push('✓ schema');
|
|
263
|
+
else if (task.structuredError) notes.push('schema ✗');
|
|
264
|
+
}
|
|
265
|
+
return notes;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function pickLine(text: string | undefined, which: 'first' | 'last'): string | undefined {
|
|
269
|
+
if (!text) return undefined;
|
|
270
|
+
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
271
|
+
if (!lines.length) return undefined;
|
|
272
|
+
return which === 'last' ? lines[lines.length - 1] : lines[0];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** One-line collapsed call header: `subagent <preview>`. */
|
|
276
|
+
export function renderCallLine(args: any, theme: Theme, width: number): string {
|
|
277
|
+
const title = theme.fg('toolTitle', theme.bold('subagent'));
|
|
278
|
+
let preview = '';
|
|
279
|
+
if (args?.action) {
|
|
280
|
+
preview = `${args.action}${args.id ? ` ${String(args.id).slice(0, 8)}` : ''}`;
|
|
281
|
+
} else if (Array.isArray(args?.tasks)) {
|
|
282
|
+
const first = args.tasks[0]?.task;
|
|
283
|
+
preview = `${args.tasks.length} parallel tasks${first ? ` — ${oneLine(String(first), 60)}` : ''}`;
|
|
284
|
+
} else if (args?.resume) {
|
|
285
|
+
preview = `resume ${String(args.resume).slice(0, 8)}${args.task ? ` — ${oneLine(String(args.task))}` : ''}`;
|
|
286
|
+
} else if (args?.task) {
|
|
287
|
+
preview = oneLine(String(args.task));
|
|
288
|
+
}
|
|
289
|
+
const tag = args?.async ? ` ${theme.fg('accent', '· background')}` : '';
|
|
290
|
+
return truncateToWidth(`${title} ${theme.fg('muted', preview)}${tag}`, width);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function wrapLines(text: string, width: number): string[] {
|
|
294
|
+
const wrapped = wrapTextWithAnsi(text, Math.max(10, width));
|
|
295
|
+
return Array.isArray(wrapped) ? wrapped : String(wrapped).split('\n');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
type ThemeColor = Parameters<Theme['fg']>[0];
|
|
299
|
+
|
|
300
|
+
function terminalTaskLine(theme: Theme, task: InlineTaskView): { text: string; color: ThemeColor } | undefined {
|
|
301
|
+
switch (task.state) {
|
|
302
|
+
case 'failed':
|
|
303
|
+
case 'lost':
|
|
304
|
+
return { text: `${formatState(task.state)} — ${oneLine(task.errorMessage ?? task.stopReason ?? 'unknown error')}`, color: 'error' };
|
|
305
|
+
case 'cancelled':
|
|
306
|
+
return { text: 'cancelled', color: 'muted' };
|
|
307
|
+
case 'timeout':
|
|
308
|
+
return { text: `timed out${task.timeoutPhase ? ` (${task.timeoutPhase})` : ''}`, color: 'warning' };
|
|
309
|
+
case 'partial': {
|
|
310
|
+
if (task.wrappedUp) {
|
|
311
|
+
const first = pickLine(task.finalOutput, 'first');
|
|
312
|
+
return { text: `wrapped up (${(task.stopReason ?? 'budget').replace('_', ' ')})${first ? ` — ${oneLine(first, 80)}` : ''}`, color: 'warning' };
|
|
313
|
+
}
|
|
314
|
+
if (task.stopReason === 'stalled') {
|
|
315
|
+
return { text: `stalled — ${oneLine(task.errorMessage ?? 'no activity', 80)}`, color: 'warning' };
|
|
316
|
+
}
|
|
317
|
+
const first = pickLine(task.finalOutput, 'first');
|
|
318
|
+
return first ? { text: oneLine(first), color: 'toolOutput' } : undefined;
|
|
319
|
+
}
|
|
320
|
+
default: {
|
|
321
|
+
const first = pickLine(task.finalOutput, 'first');
|
|
322
|
+
return first ? { text: oneLine(first), color: 'toolOutput' } : undefined;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function pointerText(task: InlineTaskView, expanded: boolean): string | undefined {
|
|
328
|
+
const parts: string[] = [];
|
|
329
|
+
if (task.outputFile) parts.push(`→ ${formatPath(task.outputFile)}`);
|
|
330
|
+
if (task.worktree) parts.push(`⎇ ${task.worktree.branch}`);
|
|
331
|
+
if (expanded && task.sessionId) parts.push(`session ${task.sessionId.slice(0, 8)}`);
|
|
332
|
+
return parts.length ? parts.join(' · ') : undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function expandedOutputLines(theme: Theme, task: InlineTaskView, width: number, cap: number): string[] {
|
|
336
|
+
if (!task.finalOutput) return [];
|
|
337
|
+
const lines: string[] = [''];
|
|
338
|
+
const wrapped = wrapLines(task.finalOutput, width - 2);
|
|
339
|
+
for (const line of wrapped.slice(0, cap)) lines.push(` ${theme.fg('toolOutput', line)}`);
|
|
340
|
+
if (wrapped.length > cap) {
|
|
341
|
+
lines.push(theme.fg('dim', ` … +${wrapped.length - cap} lines (full output in ${task.outputFile ? formatPath(task.outputFile) : 'the child session'})`));
|
|
342
|
+
}
|
|
343
|
+
return lines;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Compact run block. Fixed shape while streaming:
|
|
348
|
+
* ⠹ ↻3 · 12.4k tok · 8s
|
|
349
|
+
* ⎿ reading src/auth/middleware.ts…
|
|
350
|
+
* Terminal:
|
|
351
|
+
* ↻8 · 33.8k tok · $0.012 · 12s
|
|
352
|
+
* ⎿ Found 5 middleware call sites…
|
|
353
|
+
* Parallel collapsed: one line per task.
|
|
354
|
+
*/
|
|
355
|
+
export function renderRunLines(run: InlineRunView, opts: InlineRenderOptions): string[] {
|
|
356
|
+
const { theme, width } = opts;
|
|
357
|
+
const now = opts.now ?? Date.now();
|
|
358
|
+
const frame = opts.spinnerFrame ?? 0;
|
|
359
|
+
const running = opts.isPartial ?? isActiveState(run.state);
|
|
360
|
+
const durationMs = run.startedAt ? (run.endedAt ?? now) - run.startedAt : undefined;
|
|
361
|
+
const agg = usageAggregate(run.results);
|
|
362
|
+
const stats = statsText(agg, durationMs);
|
|
363
|
+
const spin = theme.fg('accent', SPINNERS[frame % SPINNERS.length]!);
|
|
364
|
+
const lines: string[] = [];
|
|
365
|
+
|
|
366
|
+
if (run.mode === 'parallel' && run.results.length > 1) {
|
|
367
|
+
const total = run.results.length;
|
|
368
|
+
const done = run.results.filter((r) => r.state && !isActiveState(r.state)).length;
|
|
369
|
+
lines.push(running
|
|
370
|
+
? `${spin} ${theme.fg('dim', `${done}/${total} done${stats ? ` · ${stats}` : ''}`)}`
|
|
371
|
+
: theme.fg('dim', `${total} tasks${stats ? ` · ${stats}` : ''}`));
|
|
372
|
+
|
|
373
|
+
const shown = opts.expanded ? run.results : run.results.slice(0, 6);
|
|
374
|
+
for (const task of shown) {
|
|
375
|
+
const glyph = stateGlyph(task.state, theme, frame);
|
|
376
|
+
const mini = statsText(usageAggregate([task]));
|
|
377
|
+
const active = isActiveState(task.state);
|
|
378
|
+
// The state glyph already communicates the outcome; parallel rows show
|
|
379
|
+
// just the message/preview without repeating the state word.
|
|
380
|
+
const tail = active
|
|
381
|
+
? pickLine(task.finalOutput, 'last')
|
|
382
|
+
: ['failed', 'lost'].includes(task.state ?? '')
|
|
383
|
+
? (task.errorMessage ?? task.stopReason ?? formatState(task.state!))
|
|
384
|
+
: task.state === 'timeout'
|
|
385
|
+
? `timed out${task.timeoutPhase ? ` (${task.timeoutPhase})` : ''}`
|
|
386
|
+
: task.state === 'cancelled'
|
|
387
|
+
? undefined
|
|
388
|
+
: pickLine(task.finalOutput, 'first');
|
|
389
|
+
const tailColor: ThemeColor = !active && ['failed', 'lost'].includes(task.state ?? '') ? 'error' : 'muted';
|
|
390
|
+
let line = ` ${glyph} ${theme.fg('dim', task.model ?? 'model unknown')} · ${theme.fg('text', task.label ?? 'task')}`;
|
|
391
|
+
if (mini) line += theme.fg('dim', ` · ${mini}`);
|
|
392
|
+
const notes = taskAnnotations(task, now);
|
|
393
|
+
if (notes.length) line += ` ${theme.fg('warning', `[${notes.join(' · ')}]`)}`;
|
|
394
|
+
if (task.wrappedUp && !active) line += ` ${theme.fg('warning', '◐ wrapped up')}`;
|
|
395
|
+
if (tail) line += ` ${theme.fg(tailColor, `— ${oneLine(tail, 80)}`)}`;
|
|
396
|
+
lines.push(line);
|
|
397
|
+
if (opts.expanded) {
|
|
398
|
+
const pointers = pointerText(task, true);
|
|
399
|
+
if (pointers) lines.push(theme.fg('dim', ` ${pointers}`));
|
|
400
|
+
const route = formatRouteLine(task.routing, Math.max(10, width - 6));
|
|
401
|
+
if (route) lines.push(theme.fg('dim', ` ${route}`));
|
|
402
|
+
lines.push(...expandedOutputLines(theme, task, width, 12).map((l) => l ? ` ${l}` : l));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (!opts.expanded && total > shown.length) {
|
|
406
|
+
lines.push(theme.fg('dim', ` … +${total - shown.length} more`));
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
const task = run.results[0] ?? {};
|
|
410
|
+
if (running) {
|
|
411
|
+
const notes = taskAnnotations(task, now);
|
|
412
|
+
const noteText = notes.length ? ` ${theme.fg('warning', `[${notes.join(' · ')}]`)}` : '';
|
|
413
|
+
const modelText = theme.fg('dim', task.model ?? 'model unknown');
|
|
414
|
+
lines.push(`${spin} ${modelText} · ${theme.fg('dim', stats || 'starting…')}${noteText}`);
|
|
415
|
+
const activity = pickLine(task.finalOutput, 'last');
|
|
416
|
+
if (activity) lines.push(` ${theme.fg('dim', '⎿')} ${theme.fg('muted', oneLine(activity, width))}`);
|
|
417
|
+
} else {
|
|
418
|
+
const modelText = task.model ?? 'model unknown';
|
|
419
|
+
lines.push(theme.fg('dim', `${modelText} · ${stats || formatState(run.state ?? task.state ?? 'completed')}`));
|
|
420
|
+
const summary = terminalTaskLine(theme, task);
|
|
421
|
+
if (summary && !opts.expanded) lines.push(` ${theme.fg('dim', '⎿')} ${theme.fg(summary.color, oneLine(summary.text, width))}`);
|
|
422
|
+
const pointers = pointerText(task, opts.expanded ?? false);
|
|
423
|
+
if (pointers) lines.push(theme.fg('dim', ` ${pointers}`));
|
|
424
|
+
if (opts.expanded) {
|
|
425
|
+
if (summary && ['error', 'warning', 'muted'].includes(summary.color)) {
|
|
426
|
+
lines.push(` ${theme.fg('dim', '⎿')} ${theme.fg(summary.color, oneLine(summary.text, width))}`);
|
|
427
|
+
}
|
|
428
|
+
const route = formatRouteLine(task.routing, Math.max(10, width - 4));
|
|
429
|
+
if (route) lines.push(theme.fg('dim', ` ${route}`));
|
|
430
|
+
lines.push(...expandedOutputLines(theme, task, width, 40));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
436
|
+
}
|