@abelxiaoxing/cadence 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -22
- package/package.json +3 -1
- package/prompts/abel-design.md +1 -0
- package/prompts/abel-diagnose.md +1 -0
- package/prompts/abel-implement.md +14 -2
- package/skills/abel-workflow/SKILL.md +7 -1
- package/src/candidate-preflight.ts +1163 -0
- package/src/child-session.ts +98 -10
- package/src/contracts.ts +379 -48
- package/src/file-snapshot.ts +62 -8
- package/src/index.ts +191 -14
- package/src/parent-payload-bridge.ts +442 -0
- package/src/parent-provider.ts +124 -16
- package/src/patch.ts +73 -3
- package/src/result-store.ts +40 -3
- package/src/runtime.ts +1013 -60
- package/src/scheduler.ts +47 -10
- package/src/scoped-tools.ts +27 -6
- package/src/subagent-activity.ts +747 -0
- package/src/submit-tool.ts +3 -1
- package/src/worker.ts +136 -26
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionUIContext,
|
|
3
|
+
ToolRenderResultOptions,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
6
|
+
import {
|
|
7
|
+
stripTerminalSequences,
|
|
8
|
+
Text,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
visibleWidth,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import type {
|
|
13
|
+
DispatchResult,
|
|
14
|
+
RuntimeActivityEvent,
|
|
15
|
+
RuntimeActivityObserver,
|
|
16
|
+
RuntimeActivityState,
|
|
17
|
+
} from "./runtime.ts";
|
|
18
|
+
|
|
19
|
+
export const ACTIVITY_DETAILS_KEY = "activityDisplay" as const;
|
|
20
|
+
export const ACTIVITY_WIDGET_KEY = "abel-subagents" as const;
|
|
21
|
+
export const ACTIVITY_STATUS_KEY = "abel-subagents" as const;
|
|
22
|
+
export const ACTIVITY_REFRESH_MS = 100;
|
|
23
|
+
export const ACTIVITY_WIDGET_MAX_LINES = 12;
|
|
24
|
+
export const ACTIVITY_WIDGET_MIN_WIDTH = 3;
|
|
25
|
+
|
|
26
|
+
export type TerminalActivityState = Exclude<
|
|
27
|
+
RuntimeActivityState,
|
|
28
|
+
"queued" | "running"
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
export interface EvidenceActivitySummary {
|
|
32
|
+
kind: "evidence";
|
|
33
|
+
conclusions: number;
|
|
34
|
+
citations: number;
|
|
35
|
+
risks: number;
|
|
36
|
+
blockingQuestions: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DiffActivitySummary {
|
|
40
|
+
kind: "diff";
|
|
41
|
+
summary: string;
|
|
42
|
+
nextStep: string;
|
|
43
|
+
riskCount: number;
|
|
44
|
+
retained: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ActivitySummary = EvidenceActivitySummary | DiffActivitySummary;
|
|
48
|
+
|
|
49
|
+
export interface ActivityDisplay {
|
|
50
|
+
version: 1;
|
|
51
|
+
kind: "activityDisplay";
|
|
52
|
+
requestId: string;
|
|
53
|
+
role: string;
|
|
54
|
+
phase: string;
|
|
55
|
+
objective: string;
|
|
56
|
+
state: TerminalActivityState;
|
|
57
|
+
elapsedMs: number;
|
|
58
|
+
summary?: ActivitySummary;
|
|
59
|
+
reason?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ActivitySnapshot {
|
|
63
|
+
toolCallId: string;
|
|
64
|
+
requestId: string;
|
|
65
|
+
role: string;
|
|
66
|
+
phase: string;
|
|
67
|
+
objective: string;
|
|
68
|
+
state: "queued" | "running";
|
|
69
|
+
sequence: number;
|
|
70
|
+
startedAt: number;
|
|
71
|
+
elapsedMs: number;
|
|
72
|
+
spinnerFrame?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface ActivityEntry extends ActivitySnapshot {
|
|
76
|
+
onUpdate?: (result: unknown) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type ActivityColor =
|
|
80
|
+
| "accent"
|
|
81
|
+
| "success"
|
|
82
|
+
| "error"
|
|
83
|
+
| "warning"
|
|
84
|
+
| "muted"
|
|
85
|
+
| "text";
|
|
86
|
+
type ActivityTheme = {
|
|
87
|
+
fg?: (color: ActivityColor, text: string) => string;
|
|
88
|
+
};
|
|
89
|
+
type ActivityUi = Pick<ExtensionUIContext, "setWidget" | "setStatus">;
|
|
90
|
+
type TimerHandle = ReturnType<typeof setInterval>;
|
|
91
|
+
|
|
92
|
+
export interface ActivityControllerOptions {
|
|
93
|
+
now?: () => number;
|
|
94
|
+
setInterval?: (callback: () => void, delay: number) => TimerHandle | unknown;
|
|
95
|
+
clearInterval?: (handle: TimerHandle | unknown) => void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function boundedWidth(width: number): number {
|
|
99
|
+
return Math.max(0, Number.isFinite(width) ? Math.floor(width) : 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeWhitespace(value: string): string {
|
|
103
|
+
return stripTerminalSequences(value).replace(/\s+/g, " ").trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function redactPaths(value: string): string {
|
|
107
|
+
return value
|
|
108
|
+
.replace(
|
|
109
|
+
/(?:[A-Za-z]:[\\/]|\.{1,2}[\\/]|\/)(?:[^\s,;:()[\]{}]+[\\/])*[^\s,;:()[\]{}]+/g,
|
|
110
|
+
"[path]",
|
|
111
|
+
)
|
|
112
|
+
.replace(/(?:\b[\w.-]+[\\/])+[\w.-]+/g, "[path]")
|
|
113
|
+
.replace(/\b[\w.-]+\.[A-Za-z0-9]{1,12}\b/g, "[path]");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function redactProviderAndModel(value: string): string {
|
|
117
|
+
return value.replace(
|
|
118
|
+
/\b(?:provider|model)(?:\s+identity)?\s*(?:[:=]|\/)?\s*[^\s,;()[\]]+/gi,
|
|
119
|
+
"[redacted]",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function sanitizeDisplayText(value: unknown, maxLength = 240): string {
|
|
124
|
+
const text = typeof value === "string" ? value : String(value ?? "");
|
|
125
|
+
const safe = redactProviderAndModel(redactPaths(normalizeWhitespace(text)));
|
|
126
|
+
return safe.slice(0, Math.max(0, maxLength));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const SAFE_FAILURE_REASONS = new Set([
|
|
130
|
+
"subagent failed",
|
|
131
|
+
"subagent cancelled",
|
|
132
|
+
"phase timed out",
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
export function sanitizeFailureReason(value: unknown, maxLength = 240): string {
|
|
136
|
+
const normalized = normalizeWhitespace(String(value ?? ""));
|
|
137
|
+
const safe = SAFE_FAILURE_REASONS.has(normalized)
|
|
138
|
+
? normalized
|
|
139
|
+
: "subagent failed";
|
|
140
|
+
return safe.slice(0, Math.max(0, maxLength));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function formatElapsed(milliseconds: number): string {
|
|
144
|
+
const value = Math.max(
|
|
145
|
+
0,
|
|
146
|
+
Math.floor(Number.isFinite(milliseconds) ? milliseconds : 0),
|
|
147
|
+
);
|
|
148
|
+
if (value < 1_000) return `${value}ms`;
|
|
149
|
+
const seconds = Math.floor(value / 1_000);
|
|
150
|
+
if (seconds < 60) return `${seconds}s`;
|
|
151
|
+
const minutes = Math.floor(seconds / 60);
|
|
152
|
+
return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
156
|
+
|
|
157
|
+
function stateGlyph(state: RuntimeActivityState, spinnerFrame = 0): string {
|
|
158
|
+
switch (state) {
|
|
159
|
+
case "queued":
|
|
160
|
+
return "…";
|
|
161
|
+
case "running":
|
|
162
|
+
return SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? "⠋";
|
|
163
|
+
case "completed":
|
|
164
|
+
return "✓";
|
|
165
|
+
case "failed":
|
|
166
|
+
return "✗";
|
|
167
|
+
case "cancelled":
|
|
168
|
+
return "⊘";
|
|
169
|
+
case "timed-out":
|
|
170
|
+
return "⌛";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function line(value: string, width: number): string {
|
|
175
|
+
const bounded = truncateToWidth(value, boundedWidth(width), "");
|
|
176
|
+
return visibleWidth(bounded) <= boundedWidth(width)
|
|
177
|
+
? bounded
|
|
178
|
+
: truncateToWidth(bounded, boundedWidth(width), "");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function stateText(state: RuntimeActivityState): string {
|
|
182
|
+
return state;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function styled(
|
|
186
|
+
theme: ActivityTheme | undefined,
|
|
187
|
+
color: ActivityColor,
|
|
188
|
+
value: string,
|
|
189
|
+
): string {
|
|
190
|
+
try {
|
|
191
|
+
return theme?.fg?.(color, value) ?? value;
|
|
192
|
+
} catch {
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderActivityLines(
|
|
198
|
+
snapshot: ActivitySnapshot | ActivityDisplay,
|
|
199
|
+
width: number,
|
|
200
|
+
theme?: ActivityTheme,
|
|
201
|
+
now = Date.now(),
|
|
202
|
+
): string[] {
|
|
203
|
+
const elapsed =
|
|
204
|
+
"kind" in snapshot
|
|
205
|
+
? snapshot.elapsedMs
|
|
206
|
+
: Math.max(0, now - snapshot.startedAt);
|
|
207
|
+
const first = [
|
|
208
|
+
stateGlyph(
|
|
209
|
+
snapshot.state,
|
|
210
|
+
"spinnerFrame" in snapshot ? snapshot.spinnerFrame : undefined,
|
|
211
|
+
),
|
|
212
|
+
"Subagent",
|
|
213
|
+
sanitizeDisplayText(snapshot.role, 80),
|
|
214
|
+
`#${sanitizeDisplayText(snapshot.requestId, 128)}`,
|
|
215
|
+
sanitizeDisplayText(snapshot.phase, 40),
|
|
216
|
+
stateText(snapshot.state),
|
|
217
|
+
formatElapsed(elapsed),
|
|
218
|
+
].join(" · ");
|
|
219
|
+
const second = ` ${sanitizeDisplayText(snapshot.objective, 240)}`;
|
|
220
|
+
const color =
|
|
221
|
+
snapshot.state === "completed"
|
|
222
|
+
? "success"
|
|
223
|
+
: snapshot.state === "failed" || snapshot.state === "timed-out"
|
|
224
|
+
? "error"
|
|
225
|
+
: snapshot.state === "cancelled"
|
|
226
|
+
? "warning"
|
|
227
|
+
: "accent";
|
|
228
|
+
return [
|
|
229
|
+
line(styled(theme, color, first), width),
|
|
230
|
+
line(styled(theme, "muted", second), width),
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function renderActivityWidgetLines(
|
|
235
|
+
entries: readonly ActivitySnapshot[],
|
|
236
|
+
width: number,
|
|
237
|
+
_now = Date.now(),
|
|
238
|
+
): string[] {
|
|
239
|
+
const ordered = [...entries].sort(
|
|
240
|
+
(left, right) =>
|
|
241
|
+
left.sequence - right.sequence ||
|
|
242
|
+
left.toolCallId.localeCompare(right.toolCallId),
|
|
243
|
+
);
|
|
244
|
+
const visible = ordered.slice(0, 5);
|
|
245
|
+
const hidden = ordered.slice(visible.length);
|
|
246
|
+
const lines = [line(`Agents · ${ordered.length} active`, width)];
|
|
247
|
+
for (const entry of visible) {
|
|
248
|
+
lines.push(...renderActivityLines(entry, width, undefined, _now));
|
|
249
|
+
}
|
|
250
|
+
if (hidden.length > 0) {
|
|
251
|
+
const running = hidden.filter((entry) => entry.state === "running").length;
|
|
252
|
+
const queued = hidden.filter((entry) => entry.state === "queued").length;
|
|
253
|
+
const overflow = [
|
|
254
|
+
`+${hidden.length} more (${running} running, ${queued} queued)`,
|
|
255
|
+
`+${hidden.length} (${running}r, ${queued}q)`,
|
|
256
|
+
`+${hidden.length} ${running}r ${queued}q`,
|
|
257
|
+
`+${hidden.length} ${running}/${queued}`,
|
|
258
|
+
`${hidden.length}:${running}/${queued}`,
|
|
259
|
+
`${running}/${queued}`,
|
|
260
|
+
].find((candidate) => visibleWidth(candidate) <= boundedWidth(width));
|
|
261
|
+
lines.push(line(overflow ?? `${running}/${queued}`, width));
|
|
262
|
+
}
|
|
263
|
+
return lines
|
|
264
|
+
.slice(0, ACTIVITY_WIDGET_MAX_LINES)
|
|
265
|
+
.map((entry) => line(entry, width));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export class ActivityWidget implements Component {
|
|
269
|
+
constructor(
|
|
270
|
+
private readonly entries: () => readonly ActivitySnapshot[],
|
|
271
|
+
private readonly now: () => number = Date.now,
|
|
272
|
+
private readonly theme?: ActivityTheme,
|
|
273
|
+
) {}
|
|
274
|
+
|
|
275
|
+
render(width: number): string[] {
|
|
276
|
+
return renderActivityWidgetLines(this.entries(), width, this.now()).map(
|
|
277
|
+
(entry, index) =>
|
|
278
|
+
line(
|
|
279
|
+
index === 0
|
|
280
|
+
? styled(this.theme, "accent", entry)
|
|
281
|
+
: styled(this.theme, "text", entry),
|
|
282
|
+
width,
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
invalidate(): void {}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export class ActivityInlineComponent implements Component {
|
|
291
|
+
constructor(
|
|
292
|
+
private readonly display: ActivityDisplay | ActivitySnapshot,
|
|
293
|
+
private readonly theme?: ActivityTheme,
|
|
294
|
+
private readonly expanded = false,
|
|
295
|
+
private readonly now: () => number = Date.now,
|
|
296
|
+
) {}
|
|
297
|
+
|
|
298
|
+
render(width: number): string[] {
|
|
299
|
+
const lines = renderActivityLines(
|
|
300
|
+
this.display,
|
|
301
|
+
width,
|
|
302
|
+
this.theme,
|
|
303
|
+
this.now(),
|
|
304
|
+
);
|
|
305
|
+
if (
|
|
306
|
+
!this.expanded ||
|
|
307
|
+
!("summary" in this.display) ||
|
|
308
|
+
!this.display.summary
|
|
309
|
+
) {
|
|
310
|
+
if ("reason" in this.display && this.display.reason) {
|
|
311
|
+
lines[1] = line(
|
|
312
|
+
styled(
|
|
313
|
+
this.theme,
|
|
314
|
+
"error",
|
|
315
|
+
` reason: ${sanitizeFailureReason(this.display.reason)}`,
|
|
316
|
+
),
|
|
317
|
+
width,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return lines;
|
|
321
|
+
}
|
|
322
|
+
const summary = this.display.summary;
|
|
323
|
+
const detail =
|
|
324
|
+
summary.kind === "evidence"
|
|
325
|
+
? ` evidence: ${summary.conclusions} conclusions · ${summary.citations} citations · ${summary.risks} risks · ${summary.blockingQuestions} blocking`
|
|
326
|
+
: ` diff: ${sanitizeDisplayText(summary.summary)} · next: ${sanitizeDisplayText(summary.nextStep)} · ${summary.riskCount} risks · retained: ${summary.retained ? "yes" : "no"}`;
|
|
327
|
+
return [lines[0] ?? "", line(styled(this.theme, "muted", detail), width)];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
invalidate(): void {}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
334
|
+
return typeof value === "object" && value !== null
|
|
335
|
+
? (value as Record<string, unknown>)
|
|
336
|
+
: undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function summarizeDispatchResult(
|
|
340
|
+
result: DispatchResult,
|
|
341
|
+
): ActivitySummary | undefined {
|
|
342
|
+
if (!result.ok) return undefined;
|
|
343
|
+
const value = asRecord(result.result);
|
|
344
|
+
if (!value) return undefined;
|
|
345
|
+
if (
|
|
346
|
+
value.kind === "evidence" &&
|
|
347
|
+
Array.isArray(value.conclusions) &&
|
|
348
|
+
Array.isArray(value.citations) &&
|
|
349
|
+
Array.isArray(value.risks) &&
|
|
350
|
+
Array.isArray(value.blockingQuestions)
|
|
351
|
+
) {
|
|
352
|
+
return {
|
|
353
|
+
kind: "evidence",
|
|
354
|
+
conclusions: value.conclusions.length,
|
|
355
|
+
citations: value.citations.length,
|
|
356
|
+
risks: value.risks.length,
|
|
357
|
+
blockingQuestions: value.blockingQuestions.length,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (
|
|
361
|
+
value.kind === "diff" &&
|
|
362
|
+
typeof value.summary === "string" &&
|
|
363
|
+
typeof value.nextStep === "string" &&
|
|
364
|
+
Array.isArray(value.risks)
|
|
365
|
+
) {
|
|
366
|
+
return {
|
|
367
|
+
kind: "diff",
|
|
368
|
+
summary: sanitizeDisplayText(value.summary),
|
|
369
|
+
nextStep: sanitizeDisplayText(value.nextStep),
|
|
370
|
+
riskCount: value.risks.length,
|
|
371
|
+
retained: typeof result.resultId === "string",
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function terminalState(
|
|
378
|
+
event: RuntimeActivityEvent,
|
|
379
|
+
): event is RuntimeActivityEvent & {
|
|
380
|
+
state: TerminalActivityState;
|
|
381
|
+
} {
|
|
382
|
+
return !["queued", "running"].includes(event.state);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function displayFromEvent(
|
|
386
|
+
entry: ActivityEntry,
|
|
387
|
+
event: RuntimeActivityEvent,
|
|
388
|
+
elapsedMs: number,
|
|
389
|
+
): ActivityDisplay {
|
|
390
|
+
const reason = event.failureReason;
|
|
391
|
+
return {
|
|
392
|
+
version: 1,
|
|
393
|
+
kind: "activityDisplay",
|
|
394
|
+
requestId: entry.requestId,
|
|
395
|
+
role: entry.role,
|
|
396
|
+
phase: entry.phase,
|
|
397
|
+
objective: sanitizeDisplayText(entry.objective),
|
|
398
|
+
state: event.state as TerminalActivityState,
|
|
399
|
+
elapsedMs,
|
|
400
|
+
...(reason === undefined ? {} : { reason: sanitizeFailureReason(reason) }),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function createActivityDisplay(
|
|
405
|
+
event: RuntimeActivityEvent,
|
|
406
|
+
elapsedMs: number,
|
|
407
|
+
result?: DispatchResult,
|
|
408
|
+
): ActivityDisplay {
|
|
409
|
+
const entry: ActivityEntry = {
|
|
410
|
+
toolCallId: "",
|
|
411
|
+
requestId: event.requestId,
|
|
412
|
+
role: event.role,
|
|
413
|
+
phase: event.phase,
|
|
414
|
+
objective: event.objective,
|
|
415
|
+
state: event.state === "queued" ? "queued" : "running",
|
|
416
|
+
sequence: event.sequence,
|
|
417
|
+
startedAt: 0,
|
|
418
|
+
elapsedMs,
|
|
419
|
+
};
|
|
420
|
+
const display = displayFromEvent(entry, event, elapsedMs);
|
|
421
|
+
const summary = result ? summarizeDispatchResult(result) : undefined;
|
|
422
|
+
return summary ? { ...display, summary } : display;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function activityPartial(display: ActivityDisplay | ActivitySnapshot): unknown {
|
|
426
|
+
const safeDisplay =
|
|
427
|
+
"onUpdate" in display
|
|
428
|
+
? (({ onUpdate: _onUpdate, ...snapshot }) => snapshot)(display)
|
|
429
|
+
: display;
|
|
430
|
+
return {
|
|
431
|
+
content: [
|
|
432
|
+
{
|
|
433
|
+
type: "text",
|
|
434
|
+
text: renderActivityLines(safeDisplay, 120).join("\n"),
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
details: { [ACTIVITY_DETAILS_KEY]: safeDisplay },
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export class ActivityController {
|
|
442
|
+
private ui?: ActivityUi;
|
|
443
|
+
private accepting = false;
|
|
444
|
+
private readonly entries = new Map<string, ActivityEntry>();
|
|
445
|
+
private readonly terminals = new Map<string, ActivityDisplay>();
|
|
446
|
+
private spinnerFrame = 0;
|
|
447
|
+
private timer?: TimerHandle | unknown;
|
|
448
|
+
private requestRender?: () => void;
|
|
449
|
+
private widgetInstalled = false;
|
|
450
|
+
private readonly now: () => number;
|
|
451
|
+
private readonly setIntervalFn: (
|
|
452
|
+
callback: () => void,
|
|
453
|
+
delay: number,
|
|
454
|
+
) => TimerHandle | unknown;
|
|
455
|
+
private readonly clearIntervalFn: (handle: TimerHandle | unknown) => void;
|
|
456
|
+
|
|
457
|
+
constructor(options: ActivityControllerOptions = {}) {
|
|
458
|
+
this.now = options.now ?? Date.now;
|
|
459
|
+
this.setIntervalFn =
|
|
460
|
+
options.setInterval ??
|
|
461
|
+
((callback, delay) => setInterval(callback, delay));
|
|
462
|
+
this.clearIntervalFn =
|
|
463
|
+
options.clearInterval ??
|
|
464
|
+
((handle) => clearInterval(handle as TimerHandle));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
attach(ui: ActivityUi): void {
|
|
468
|
+
this.detachUi();
|
|
469
|
+
this.ui = ui;
|
|
470
|
+
this.accepting = true;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
detach(): void {
|
|
474
|
+
this.accepting = false;
|
|
475
|
+
this.entries.clear();
|
|
476
|
+
this.terminals.clear();
|
|
477
|
+
this.stopTimer();
|
|
478
|
+
this.detachUi();
|
|
479
|
+
this.ui = undefined;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
clear(): void {
|
|
483
|
+
this.entries.clear();
|
|
484
|
+
this.terminals.clear();
|
|
485
|
+
this.stopTimer();
|
|
486
|
+
this.clearUi();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
observe(
|
|
490
|
+
toolCallId: string,
|
|
491
|
+
onUpdate?: (result: unknown) => void,
|
|
492
|
+
): RuntimeActivityObserver {
|
|
493
|
+
return (event) => this.accept(toolCallId, onUpdate, event);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
accept(
|
|
497
|
+
toolCallId: string,
|
|
498
|
+
onUpdate: ((result: unknown) => void) | undefined,
|
|
499
|
+
event: RuntimeActivityEvent,
|
|
500
|
+
): void {
|
|
501
|
+
if (!this.accepting || this.terminals.has(toolCallId)) return;
|
|
502
|
+
let entry = this.entries.get(toolCallId);
|
|
503
|
+
if (!entry) {
|
|
504
|
+
entry = {
|
|
505
|
+
toolCallId,
|
|
506
|
+
requestId: event.requestId,
|
|
507
|
+
role: event.role,
|
|
508
|
+
phase: event.phase,
|
|
509
|
+
objective: sanitizeDisplayText(event.objective),
|
|
510
|
+
state: event.state === "queued" ? "queued" : "running",
|
|
511
|
+
sequence: event.sequence,
|
|
512
|
+
startedAt: this.now(),
|
|
513
|
+
elapsedMs: 0,
|
|
514
|
+
spinnerFrame: this.spinnerFrame,
|
|
515
|
+
onUpdate,
|
|
516
|
+
};
|
|
517
|
+
this.entries.set(toolCallId, entry);
|
|
518
|
+
} else {
|
|
519
|
+
entry.onUpdate = onUpdate ?? entry.onUpdate;
|
|
520
|
+
if (event.state === "running") entry.state = "running";
|
|
521
|
+
}
|
|
522
|
+
if (terminalState(event)) {
|
|
523
|
+
const display = displayFromEvent(
|
|
524
|
+
entry,
|
|
525
|
+
event,
|
|
526
|
+
Math.max(0, this.now() - entry.startedAt),
|
|
527
|
+
);
|
|
528
|
+
this.terminals.set(toolCallId, display);
|
|
529
|
+
this.emit(entry, display);
|
|
530
|
+
this.entries.delete(toolCallId);
|
|
531
|
+
this.syncUi();
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this.emit(entry, {
|
|
535
|
+
...entry,
|
|
536
|
+
elapsedMs: Math.max(0, this.now() - entry.startedAt),
|
|
537
|
+
});
|
|
538
|
+
this.syncUi();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
finalize(
|
|
542
|
+
toolCallId: string,
|
|
543
|
+
result: DispatchResult,
|
|
544
|
+
): ActivityDisplay | undefined {
|
|
545
|
+
const display = this.terminals.get(toolCallId);
|
|
546
|
+
if (!display) return undefined;
|
|
547
|
+
this.terminals.delete(toolCallId);
|
|
548
|
+
const summary = summarizeDispatchResult(result);
|
|
549
|
+
return summary ? { ...display, summary } : display;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
getActiveEntries(): readonly ActivitySnapshot[] {
|
|
553
|
+
return [...this.entries.values()].map(
|
|
554
|
+
({ onUpdate: _onUpdate, ...entry }) => ({
|
|
555
|
+
...entry,
|
|
556
|
+
elapsedMs: Math.max(0, this.now() - entry.startedAt),
|
|
557
|
+
spinnerFrame: this.spinnerFrame,
|
|
558
|
+
}),
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
isTimerActive(): boolean {
|
|
563
|
+
return this.timer !== undefined;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private emit(
|
|
567
|
+
entry: ActivityEntry,
|
|
568
|
+
display: ActivityDisplay | ActivitySnapshot,
|
|
569
|
+
): void {
|
|
570
|
+
try {
|
|
571
|
+
entry.onUpdate?.(activityPartial(display));
|
|
572
|
+
} catch {
|
|
573
|
+
// A renderer callback is not part of orchestration.
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private ensureWidget(): void {
|
|
578
|
+
if (!this.ui || this.widgetInstalled) return;
|
|
579
|
+
try {
|
|
580
|
+
this.ui.setWidget(
|
|
581
|
+
ACTIVITY_WIDGET_KEY,
|
|
582
|
+
(tui, theme) => {
|
|
583
|
+
this.requestRender = () => tui.requestRender();
|
|
584
|
+
return new ActivityWidget(
|
|
585
|
+
() => this.getActiveEntries(),
|
|
586
|
+
this.now,
|
|
587
|
+
theme,
|
|
588
|
+
);
|
|
589
|
+
},
|
|
590
|
+
{ placement: "aboveEditor" },
|
|
591
|
+
);
|
|
592
|
+
this.widgetInstalled = true;
|
|
593
|
+
} catch {
|
|
594
|
+
// TUI failures are deliberately isolated from the run.
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private startTimer(): void {
|
|
599
|
+
if (this.timer !== undefined) return;
|
|
600
|
+
try {
|
|
601
|
+
this.timer = this.setIntervalFn(
|
|
602
|
+
() => this.tick(),
|
|
603
|
+
ACTIVITY_REFRESH_MS,
|
|
604
|
+
) as TimerHandle;
|
|
605
|
+
} catch {
|
|
606
|
+
this.timer = undefined;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private stopTimer(): void {
|
|
611
|
+
if (this.timer === undefined) return;
|
|
612
|
+
try {
|
|
613
|
+
this.clearIntervalFn(this.timer);
|
|
614
|
+
} catch {
|
|
615
|
+
// The timer is best-effort presentation state.
|
|
616
|
+
}
|
|
617
|
+
this.timer = undefined;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private tick(): void {
|
|
621
|
+
if (this.entries.size === 0) {
|
|
622
|
+
this.stopTimer();
|
|
623
|
+
this.clearUi();
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
this.spinnerFrame++;
|
|
627
|
+
for (const entry of this.entries.values()) {
|
|
628
|
+
this.emit(entry, {
|
|
629
|
+
...entry,
|
|
630
|
+
elapsedMs: Math.max(0, this.now() - entry.startedAt),
|
|
631
|
+
spinnerFrame: this.spinnerFrame,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
try {
|
|
635
|
+
this.requestRender?.();
|
|
636
|
+
} catch {
|
|
637
|
+
// Widget refresh is best-effort.
|
|
638
|
+
}
|
|
639
|
+
this.syncUi();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private syncUi(): void {
|
|
643
|
+
if (!this.ui) return;
|
|
644
|
+
if (this.entries.size === 0) {
|
|
645
|
+
this.stopTimer();
|
|
646
|
+
this.clearUi();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
this.ensureWidget();
|
|
650
|
+
this.startTimer();
|
|
651
|
+
try {
|
|
652
|
+
const running = [...this.entries.values()].filter(
|
|
653
|
+
(entry) => entry.state === "running",
|
|
654
|
+
).length;
|
|
655
|
+
const queued = this.entries.size - running;
|
|
656
|
+
this.ui.setStatus(
|
|
657
|
+
ACTIVITY_STATUS_KEY,
|
|
658
|
+
`Agents: ${running} running, ${queued} queued`,
|
|
659
|
+
);
|
|
660
|
+
} catch {
|
|
661
|
+
// Status rendering is optional.
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private clearUi(): void {
|
|
666
|
+
if (!this.ui) return;
|
|
667
|
+
try {
|
|
668
|
+
if (this.widgetInstalled)
|
|
669
|
+
this.ui.setWidget(ACTIVITY_WIDGET_KEY, undefined);
|
|
670
|
+
} catch {
|
|
671
|
+
// Best-effort cleanup.
|
|
672
|
+
}
|
|
673
|
+
try {
|
|
674
|
+
this.ui.setStatus(ACTIVITY_STATUS_KEY, undefined);
|
|
675
|
+
} catch {
|
|
676
|
+
// Best-effort cleanup.
|
|
677
|
+
}
|
|
678
|
+
this.widgetInstalled = false;
|
|
679
|
+
this.requestRender = undefined;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private detachUi(): void {
|
|
683
|
+
this.clearUi();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export function renderActivityResult(
|
|
688
|
+
result: unknown,
|
|
689
|
+
options: ToolRenderResultOptions,
|
|
690
|
+
theme?: ActivityTheme,
|
|
691
|
+
): Component {
|
|
692
|
+
const details = asRecord(asRecord(result)?.details);
|
|
693
|
+
const activity = details?.[ACTIVITY_DETAILS_KEY];
|
|
694
|
+
if (isActivityDisplay(activity)) {
|
|
695
|
+
return new ActivityInlineComponent(activity, theme, options.expanded);
|
|
696
|
+
}
|
|
697
|
+
const content = asRecord(result)?.content;
|
|
698
|
+
const text = Array.isArray(content)
|
|
699
|
+
? content
|
|
700
|
+
.map((item) => asRecord(item)?.text)
|
|
701
|
+
.filter((item): item is string => typeof item === "string")
|
|
702
|
+
.join("\n")
|
|
703
|
+
: "";
|
|
704
|
+
return new Text(text, 0, 0);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function isActivityDisplay(value: unknown): value is ActivityDisplay {
|
|
708
|
+
const record = asRecord(value);
|
|
709
|
+
return (
|
|
710
|
+
record?.kind === "activityDisplay" &&
|
|
711
|
+
record.version === 1 &&
|
|
712
|
+
typeof record.requestId === "string" &&
|
|
713
|
+
typeof record.role === "string" &&
|
|
714
|
+
typeof record.phase === "string" &&
|
|
715
|
+
typeof record.objective === "string" &&
|
|
716
|
+
typeof record.state === "string" &&
|
|
717
|
+
typeof record.elapsedMs === "number"
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function renderActivityCall(
|
|
722
|
+
args: unknown,
|
|
723
|
+
theme?: ActivityTheme,
|
|
724
|
+
): Component {
|
|
725
|
+
const value = asRecord(args);
|
|
726
|
+
if (value?.action !== "run") {
|
|
727
|
+
return new Text(
|
|
728
|
+
`Abel Dispatch ${sanitizeDisplayText(value?.action ?? "")}`,
|
|
729
|
+
0,
|
|
730
|
+
0,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
const request = asRecord(value.request);
|
|
734
|
+
if (!request) return new Text("Subagent", 0, 0);
|
|
735
|
+
const display: ActivitySnapshot = {
|
|
736
|
+
toolCallId: "call",
|
|
737
|
+
requestId: sanitizeDisplayText(request.id ?? "unknown", 128),
|
|
738
|
+
role: sanitizeDisplayText(request.role ?? "unknown", 80),
|
|
739
|
+
phase: sanitizeDisplayText(request.phase ?? "unknown", 40),
|
|
740
|
+
objective: sanitizeDisplayText(request.objective ?? "", 240),
|
|
741
|
+
state: "queued",
|
|
742
|
+
sequence: 0,
|
|
743
|
+
startedAt: Date.now(),
|
|
744
|
+
elapsedMs: 0,
|
|
745
|
+
};
|
|
746
|
+
return new ActivityInlineComponent(display, theme);
|
|
747
|
+
}
|