@tt-a1i/openpi 0.2.0 → 0.3.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/README.md +28 -6
- package/SETUP.md +1 -1
- package/extensions/capabilities/index.ts +4 -1
- package/extensions/plan-mode/bash-policy.ts +219 -42
- package/extensions/plan-mode/index.ts +44 -19
- package/extensions/setup/index.ts +3 -3
- package/extensions/shared/editor-layers.ts +150 -0
- package/extensions/shared/setup-config.ts +4 -15
- package/extensions/subagents/index.ts +174 -124
- package/extensions/subagents/src/prompt.ts +6 -6
- package/extensions/subagents/src/result-delivery.ts +50 -5
- package/extensions/subagents/src/ui/takeover.ts +231 -133
- package/extensions/subagents/src/ui/transcript.ts +252 -37
- package/extensions/subagents/src/ui/wait-result.ts +6 -19
- package/extensions/suggestions/index.ts +27 -18
- package/extensions/tasks/index.ts +24 -6
- package/extensions/ui-customization/footer.ts +59 -10
- package/extensions/workflows/index.ts +28 -20
- package/package.json +1 -1
- package/skills/subagents/SKILL.md +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CustomEditor,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
type KeybindingsManager,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { EditorComponent, EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
8
|
+
|
|
9
|
+
const CLAIM_CHANNEL = "openpi:editor-layers:claim";
|
|
10
|
+
const REGISTER_CHANNEL = "openpi:editor-layers:register";
|
|
11
|
+
const REMOVE_CHANNEL = "openpi:editor-layers:remove";
|
|
12
|
+
|
|
13
|
+
type EditorFactory = NonNullable<
|
|
14
|
+
ReturnType<ExtensionContext["ui"]["getEditorComponent"]>
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
export interface EditorLayer {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly order: number;
|
|
20
|
+
readonly wrap: (
|
|
21
|
+
base: EditorComponent,
|
|
22
|
+
tui: TUI,
|
|
23
|
+
theme: EditorTheme,
|
|
24
|
+
keybindings: KeybindingsManager,
|
|
25
|
+
) => EditorComponent;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface EditorLayerRegistration {
|
|
29
|
+
readonly ctx: ExtensionContext;
|
|
30
|
+
readonly layer: EditorLayer;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
34
|
+
return typeof value === "object" && value !== null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readRegistration(value: unknown) {
|
|
38
|
+
if (!isRecord(value) || !isRecord(value.layer)) return undefined;
|
|
39
|
+
const { ctx, layer } = value;
|
|
40
|
+
if (
|
|
41
|
+
!isRecord(ctx) ||
|
|
42
|
+
typeof layer.id !== "string" ||
|
|
43
|
+
typeof layer.order !== "number" ||
|
|
44
|
+
typeof layer.wrap !== "function"
|
|
45
|
+
) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
ctx: ctx as unknown as ExtensionContext,
|
|
50
|
+
layer: layer as unknown as EditorLayer,
|
|
51
|
+
} satisfies EditorLayerRegistration;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readLayerId(value: unknown) {
|
|
55
|
+
if (!isRecord(value) || typeof value.id !== "string") return undefined;
|
|
56
|
+
return value.id;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function composeEditorFactory(
|
|
60
|
+
previous: EditorFactory | undefined,
|
|
61
|
+
layers: readonly EditorLayer[],
|
|
62
|
+
) {
|
|
63
|
+
return ((tui, theme, keybindings) => {
|
|
64
|
+
let editor =
|
|
65
|
+
previous?.(tui, theme, keybindings) ??
|
|
66
|
+
new CustomEditor(tui, theme, keybindings);
|
|
67
|
+
for (const layer of layers) {
|
|
68
|
+
editor = layer.wrap(editor, tui, theme, keybindings);
|
|
69
|
+
}
|
|
70
|
+
return editor;
|
|
71
|
+
}) satisfies EditorFactory;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Each extension is evaluated in its own jiti module graph, so ordinary module
|
|
76
|
+
* singletons are not shared. The first OpenPI editor contributor claims the
|
|
77
|
+
* runtime EventBus and coordinates the rest through that host-owned boundary.
|
|
78
|
+
*/
|
|
79
|
+
function ensureCoordinator(pi: ExtensionAPI) {
|
|
80
|
+
const claim = { claimed: false };
|
|
81
|
+
pi.events.emit(CLAIM_CHANNEL, claim);
|
|
82
|
+
if (claim.claimed) return;
|
|
83
|
+
|
|
84
|
+
let ctx: ExtensionContext | undefined;
|
|
85
|
+
let installTimer: ReturnType<typeof setTimeout> | undefined;
|
|
86
|
+
const layers = new Map<string, EditorLayer>();
|
|
87
|
+
|
|
88
|
+
const cancelInstall = () => {
|
|
89
|
+
if (installTimer) clearTimeout(installTimer);
|
|
90
|
+
installTimer = undefined;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const install = () => {
|
|
94
|
+
installTimer = undefined;
|
|
95
|
+
const current = ctx;
|
|
96
|
+
if (!current || current.mode !== "tui" || layers.size === 0) return;
|
|
97
|
+
const ordered = [...layers.values()].sort(
|
|
98
|
+
(left, right) =>
|
|
99
|
+
left.order - right.order || left.id.localeCompare(right.id),
|
|
100
|
+
);
|
|
101
|
+
current.ui.setEditorComponent(
|
|
102
|
+
composeEditorFactory(current.ui.getEditorComponent(), ordered),
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const scheduleInstall = () => {
|
|
107
|
+
if (installTimer) return;
|
|
108
|
+
installTimer = setTimeout(install, 0);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
pi.events.on(CLAIM_CHANNEL, (value) => {
|
|
112
|
+
if (isRecord(value) && value.claimed === false) value.claimed = true;
|
|
113
|
+
});
|
|
114
|
+
pi.events.on(REGISTER_CHANNEL, (value) => {
|
|
115
|
+
const registration = readRegistration(value);
|
|
116
|
+
if (!registration || registration.ctx.mode !== "tui") return;
|
|
117
|
+
if (ctx !== registration.ctx) {
|
|
118
|
+
cancelInstall();
|
|
119
|
+
layers.clear();
|
|
120
|
+
ctx = registration.ctx;
|
|
121
|
+
}
|
|
122
|
+
layers.set(registration.layer.id, registration.layer);
|
|
123
|
+
scheduleInstall();
|
|
124
|
+
});
|
|
125
|
+
pi.events.on(REMOVE_CHANNEL, (value) => {
|
|
126
|
+
const id = readLayerId(value);
|
|
127
|
+
if (!id) return;
|
|
128
|
+
layers.delete(id);
|
|
129
|
+
if (layers.size > 0) return;
|
|
130
|
+
cancelInstall();
|
|
131
|
+
ctx = undefined;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function registerEditorLayer(
|
|
136
|
+
pi: ExtensionAPI,
|
|
137
|
+
ctx: ExtensionContext,
|
|
138
|
+
layer: EditorLayer,
|
|
139
|
+
) {
|
|
140
|
+
if (ctx.mode !== "tui") return;
|
|
141
|
+
ensureCoordinator(pi);
|
|
142
|
+
pi.events.emit(REGISTER_CHANNEL, {
|
|
143
|
+
ctx,
|
|
144
|
+
layer,
|
|
145
|
+
} satisfies EditorLayerRegistration);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function removeEditorLayer(pi: ExtensionAPI, id: string) {
|
|
149
|
+
pi.events.emit(REMOVE_CHANNEL, { id });
|
|
150
|
+
}
|
|
@@ -67,23 +67,12 @@ export const CAPABILITY_DISCOVERY_MODES = ["explicit", "adaptive"] as const;
|
|
|
67
67
|
export type CapabilityDiscoveryMode =
|
|
68
68
|
(typeof CAPABILITY_DISCOVERY_MODES)[number];
|
|
69
69
|
|
|
70
|
-
/** Canonical default layout: one-line
|
|
70
|
+
/** Canonical default layout: one-line plain footer with flex alignment. */
|
|
71
71
|
export const DEFAULT_FOOTER_LINES: FooterLines = [
|
|
72
|
-
[
|
|
73
|
-
"cwd",
|
|
74
|
-
"model",
|
|
75
|
-
"thinking",
|
|
76
|
-
"context",
|
|
77
|
-
"cache",
|
|
78
|
-
"cost",
|
|
79
|
-
"throughput",
|
|
80
|
-
"flex",
|
|
81
|
-
"git",
|
|
82
|
-
"pr",
|
|
83
|
-
],
|
|
72
|
+
["cwd", "git", "pr", "flex", "model", "context", "cost"],
|
|
84
73
|
];
|
|
85
74
|
|
|
86
|
-
export const DEFAULT_FOOTER_STYLE: FooterStyle = "
|
|
75
|
+
export const DEFAULT_FOOTER_STYLE: FooterStyle = "plain";
|
|
87
76
|
|
|
88
77
|
export const DEFAULT_FOOTER_ITEMS: readonly FooterItem[] =
|
|
89
78
|
flattenFooterItems(DEFAULT_FOOTER_LINES);
|
|
@@ -967,7 +956,7 @@ export function formatSetupConfig(
|
|
|
967
956
|
suggestions,
|
|
968
957
|
`Workflows: ${config.workflows.concurrency} concurrent agents · ${config.workflows.maxAgentCalls} total calls`,
|
|
969
958
|
`UI: large header ${config.ui.showHeader ? "on" : "off"} · custom footer ${footer}`,
|
|
970
|
-
`Subagent results: ${config.ui.subagentResultDisplay === "full" ? "full by default" : "compact
|
|
959
|
+
`Subagent results: ${config.ui.subagentResultDisplay === "full" ? "full by default" : "compact status summary (Ctrl+O expands full output)"}`,
|
|
971
960
|
`Bash operations: ${config.ui.bashToolDisplay === "full" ? "expanded by default" : "folded preview (Ctrl+O expands all)"}`,
|
|
972
961
|
`Write/Edit operations: ${config.ui.fileMutationDisplay === "full" ? "expanded by default" : "folded preview (Ctrl+O expands all)"}`,
|
|
973
962
|
`Post-edit command: ${config.postEdit.command ? config.postEdit.command : "off"}`,
|
|
@@ -31,9 +31,9 @@ import type {
|
|
|
31
31
|
ExtensionCommandContext,
|
|
32
32
|
ExtensionContext,
|
|
33
33
|
ExtensionUIContext,
|
|
34
|
+
MessageRenderer,
|
|
34
35
|
} from "@earendil-works/pi-coding-agent";
|
|
35
36
|
import {
|
|
36
|
-
CustomEditor,
|
|
37
37
|
DEFAULT_MAX_BYTES,
|
|
38
38
|
DEFAULT_MAX_LINES,
|
|
39
39
|
defineTool,
|
|
@@ -69,6 +69,10 @@ import {
|
|
|
69
69
|
OPENPI_TOOL_SURFACE,
|
|
70
70
|
patchOwnedTools,
|
|
71
71
|
} from "../shared/tool-surface.ts";
|
|
72
|
+
import {
|
|
73
|
+
registerEditorLayer,
|
|
74
|
+
removeEditorLayer,
|
|
75
|
+
} from "../shared/editor-layers.ts";
|
|
72
76
|
import { formatContextUtilization } from "./src/format.ts";
|
|
73
77
|
import { SubagentManager, type SubagentManagerShape } from "./src/manager.ts";
|
|
74
78
|
import {
|
|
@@ -91,8 +95,7 @@ import {
|
|
|
91
95
|
SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS,
|
|
92
96
|
SUBAGENT_WAIT_TOOL_DESCRIPTION,
|
|
93
97
|
} from "./src/prompt.ts";
|
|
94
|
-
import {
|
|
95
|
-
import { resultDeliveryOptions } from "../background-terminals/src/result-delivery.ts";
|
|
98
|
+
import { createSubagentResultDelivery } from "./src/result-delivery.ts";
|
|
96
99
|
import {
|
|
97
100
|
effectiveChildToolAllowlist,
|
|
98
101
|
resolveStandaloneChildProjectTrust,
|
|
@@ -125,6 +128,7 @@ import {
|
|
|
125
128
|
} from "./navigation.ts";
|
|
126
129
|
import { openSubagentPicker, openSubagentTakeover } from "./src/ui/takeover.ts";
|
|
127
130
|
import {
|
|
131
|
+
buildWaitResultPreview,
|
|
128
132
|
renderWaitResult,
|
|
129
133
|
type WaitResultDetails,
|
|
130
134
|
} from "./src/ui/wait-result.ts";
|
|
@@ -148,6 +152,23 @@ interface SubagentFinishedData {
|
|
|
148
152
|
readonly elapsed: string;
|
|
149
153
|
}
|
|
150
154
|
|
|
155
|
+
interface SubagentResultDetails {
|
|
156
|
+
readonly id?: string;
|
|
157
|
+
readonly title?: string;
|
|
158
|
+
readonly status?: SubagentSnapshot["status"];
|
|
159
|
+
readonly count?: number;
|
|
160
|
+
readonly results?: ReadonlyArray<{
|
|
161
|
+
readonly id: string;
|
|
162
|
+
readonly title: string;
|
|
163
|
+
readonly status: SubagentSnapshot["status"];
|
|
164
|
+
}>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface SubagentResultEntryData {
|
|
168
|
+
readonly content: string;
|
|
169
|
+
readonly details: SubagentResultDetails;
|
|
170
|
+
}
|
|
171
|
+
|
|
151
172
|
interface BtwResultData {
|
|
152
173
|
readonly id: string;
|
|
153
174
|
readonly title: string;
|
|
@@ -184,6 +205,104 @@ function truncatedOutput(
|
|
|
184
205
|
return text;
|
|
185
206
|
}
|
|
186
207
|
|
|
208
|
+
export function createSubagentResultDispatcher(
|
|
209
|
+
pi: ExtensionAPI,
|
|
210
|
+
outputFor: (snap: SubagentSnapshot) => string = truncatedOutput,
|
|
211
|
+
) {
|
|
212
|
+
return (snaps: readonly SubagentSnapshot[]) => {
|
|
213
|
+
if (snaps.length === 0) return;
|
|
214
|
+
const content = snaps
|
|
215
|
+
.map((snap) =>
|
|
216
|
+
buildSubagentResultMessage({
|
|
217
|
+
id: snap.id,
|
|
218
|
+
title: snap.title,
|
|
219
|
+
status: snap.status,
|
|
220
|
+
errorText: snap.errorText,
|
|
221
|
+
output: outputFor(snap),
|
|
222
|
+
}),
|
|
223
|
+
)
|
|
224
|
+
.join("\n\n");
|
|
225
|
+
const details: SubagentResultDetails =
|
|
226
|
+
snaps.length === 1
|
|
227
|
+
? {
|
|
228
|
+
id: snaps[0]!.id,
|
|
229
|
+
title: snaps[0]!.title,
|
|
230
|
+
status: snaps[0]!.status,
|
|
231
|
+
}
|
|
232
|
+
: {
|
|
233
|
+
count: snaps.length,
|
|
234
|
+
results: snaps.map((snap) => ({
|
|
235
|
+
id: snap.id,
|
|
236
|
+
title: snap.title,
|
|
237
|
+
status: snap.status,
|
|
238
|
+
})),
|
|
239
|
+
};
|
|
240
|
+
pi.appendEntry<SubagentResultEntryData>("subagent-result", {
|
|
241
|
+
content,
|
|
242
|
+
details,
|
|
243
|
+
});
|
|
244
|
+
pi.sendMessage(
|
|
245
|
+
{
|
|
246
|
+
customType: "subagent-result",
|
|
247
|
+
content,
|
|
248
|
+
display: false,
|
|
249
|
+
details,
|
|
250
|
+
},
|
|
251
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
252
|
+
);
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
type SubagentResultTheme = Parameters<MessageRenderer>[2];
|
|
257
|
+
|
|
258
|
+
function renderSubagentResult(
|
|
259
|
+
content: string,
|
|
260
|
+
details: SubagentResultDetails,
|
|
261
|
+
expanded: boolean,
|
|
262
|
+
theme: SubagentResultTheme,
|
|
263
|
+
) {
|
|
264
|
+
if (!expanded && loadSetupConfig().ui.subagentResultDisplay === "compact") {
|
|
265
|
+
const results = details.results?.length
|
|
266
|
+
? details.results
|
|
267
|
+
: details.id
|
|
268
|
+
? [
|
|
269
|
+
{
|
|
270
|
+
id: details.id,
|
|
271
|
+
title: details.title,
|
|
272
|
+
status: details.status,
|
|
273
|
+
},
|
|
274
|
+
]
|
|
275
|
+
: [];
|
|
276
|
+
return new Text(buildWaitResultPreview(content, { results }, theme), 0, 0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const failed = details.status === "error";
|
|
280
|
+
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■");
|
|
281
|
+
const header =
|
|
282
|
+
`${icon} ` +
|
|
283
|
+
theme.fg("accent", theme.bold(`subagent ${details.id ?? "?"}`)) +
|
|
284
|
+
theme.fg(
|
|
285
|
+
"muted",
|
|
286
|
+
` · ${details.title ?? ""} · ${failed ? "failed" : "finished"}`,
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Remove only the summary line. The following Error line (when present)
|
|
290
|
+
// is part of the actual result and must remain visible.
|
|
291
|
+
const body = content.split("\n").slice(1).join("\n").trim();
|
|
292
|
+
const md = new Markdown(body, 0, 0, getMarkdownTheme());
|
|
293
|
+
const container = new Text(header, 0, 0);
|
|
294
|
+
return {
|
|
295
|
+
render: (width: number) => [
|
|
296
|
+
...container.render(width),
|
|
297
|
+
...md.render(width),
|
|
298
|
+
],
|
|
299
|
+
invalidate: () => {
|
|
300
|
+
container.invalidate();
|
|
301
|
+
md.invalidate();
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
187
306
|
export default function (pi: ExtensionAPI) {
|
|
188
307
|
let runtime: SubagentRuntime | undefined;
|
|
189
308
|
let managerPromise: Promise<SubagentManagerShape> | undefined;
|
|
@@ -200,8 +319,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
200
319
|
let navigationManager: SubagentManagerShape | undefined;
|
|
201
320
|
let widgetVisible = false;
|
|
202
321
|
let requestWidgetRender: (() => void) | undefined;
|
|
322
|
+
let navigationLayerRegistered = false;
|
|
203
323
|
let dashboardOpen = false;
|
|
204
|
-
const
|
|
324
|
+
const dispatchResults = createSubagentResultDispatcher(pi);
|
|
325
|
+
const resultDelivery = createSubagentResultDelivery<SubagentSnapshot>({
|
|
326
|
+
isIdle: () => sessionContext?.isIdle() === true,
|
|
327
|
+
// Every unconsumed fire-and-forget result must reach the parent. The
|
|
328
|
+
// delivery coordinator batches results that settled while it was busy.
|
|
329
|
+
deliver: dispatchResults,
|
|
330
|
+
});
|
|
331
|
+
pi.on("agent_settled", () => resultDelivery.parentSettled());
|
|
205
332
|
const hideLifecycleTools = () =>
|
|
206
333
|
patchOwnedTools(pi, "subagents", {
|
|
207
334
|
disable: OPENPI_TOOL_SURFACE.subagents.deferred,
|
|
@@ -292,79 +419,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
292
419
|
|
|
293
420
|
const installSubagentNavigation = (ctx: ExtensionContext) => {
|
|
294
421
|
if (ctx.mode !== "tui") return;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
new
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
);
|
|
422
|
+
registerEditorLayer(pi, ctx, {
|
|
423
|
+
id: "subagents",
|
|
424
|
+
order: 100,
|
|
425
|
+
wrap: (base, tui, _theme, keybindings) =>
|
|
426
|
+
new BelowEditorNavigationEditor(
|
|
427
|
+
base,
|
|
428
|
+
keybindings,
|
|
429
|
+
stripState,
|
|
430
|
+
() => Boolean(stripEntry()),
|
|
431
|
+
() => {
|
|
432
|
+
const entry = stripEntry();
|
|
433
|
+
if (entry) void openDashboard(ctx, entry.snapshot.id);
|
|
434
|
+
},
|
|
435
|
+
() => {
|
|
436
|
+
requestWidgetRender?.();
|
|
437
|
+
tui.requestRender();
|
|
438
|
+
},
|
|
439
|
+
),
|
|
314
440
|
});
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* `wake` decides whether this costs the model a turn. A subagent that
|
|
319
|
-
* settled while the model sits idle is the result it is waiting on. A
|
|
320
|
-
* backlog that piled up while it worked is not: waking once per stale
|
|
321
|
-
* subagent forces a turn each, and the model can only answer "that one
|
|
322
|
-
* already finished". `nextTurn` still enters context with the user's next
|
|
323
|
-
* message, without demanding a reply.
|
|
324
|
-
*/
|
|
325
|
-
const deliverResults = (
|
|
326
|
-
snaps: readonly SubagentSnapshot[],
|
|
327
|
-
wake: boolean,
|
|
328
|
-
) => {
|
|
329
|
-
if (snaps.length === 0) return;
|
|
330
|
-
pi.sendMessage(
|
|
331
|
-
{
|
|
332
|
-
customType: "subagent-result",
|
|
333
|
-
// One message per flush, not per subagent.
|
|
334
|
-
content: snaps
|
|
335
|
-
.map((snap) =>
|
|
336
|
-
buildSubagentResultMessage({
|
|
337
|
-
id: snap.id,
|
|
338
|
-
title: snap.title,
|
|
339
|
-
status: snap.status,
|
|
340
|
-
errorText: snap.errorText,
|
|
341
|
-
output: truncatedOutput(snap),
|
|
342
|
-
}),
|
|
343
|
-
)
|
|
344
|
-
.join("\n\n"),
|
|
345
|
-
display: true,
|
|
346
|
-
details:
|
|
347
|
-
snaps.length === 1
|
|
348
|
-
? {
|
|
349
|
-
id: snaps[0]!.id,
|
|
350
|
-
title: snaps[0]!.title,
|
|
351
|
-
status: snaps[0]!.status,
|
|
352
|
-
}
|
|
353
|
-
: {
|
|
354
|
-
count: snaps.length,
|
|
355
|
-
results: snaps.map((snap) => ({
|
|
356
|
-
id: snap.id,
|
|
357
|
-
title: snap.title,
|
|
358
|
-
status: snap.status,
|
|
359
|
-
})),
|
|
360
|
-
},
|
|
361
|
-
},
|
|
362
|
-
resultDeliveryOptions(wake),
|
|
363
|
-
);
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
const flushResults = (wake: boolean) => {
|
|
367
|
-
deliverResults(resultDelivery.drain(), wake);
|
|
441
|
+
navigationLayerRegistered = true;
|
|
368
442
|
};
|
|
369
443
|
|
|
370
444
|
const deliverBtwResult = (snap: SubagentSnapshot) => {
|
|
@@ -412,10 +486,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
412
486
|
// subagent_wait can consume it before agent_settled flushes follow-ups.
|
|
413
487
|
// Defer a copy: the live snapshot keeps mutating if the subagent is
|
|
414
488
|
// restarted before the deferred result flushes.
|
|
489
|
+
// The delivery coordinator closes both sides of the wake-up race: it
|
|
490
|
+
// flushes now if the parent is already idle, otherwise the parent's next
|
|
491
|
+
// agent_settled edge rechecks this same pending Map.
|
|
415
492
|
resultDelivery.defer({ ...snap, meta: { ...snap.meta } });
|
|
416
|
-
// Settled while the model sits idle: it has nothing else in flight, so
|
|
417
|
-
// this is the result it is waiting on — wake it.
|
|
418
|
-
if (sessionContext?.isIdle()) flushResults(true);
|
|
419
493
|
};
|
|
420
494
|
|
|
421
495
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -442,11 +516,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
442
516
|
managerPromise?.then(updateStatus).catch(() => undefined);
|
|
443
517
|
});
|
|
444
518
|
|
|
445
|
-
// These settled while the model was working on something else, so they go
|
|
446
|
-
// into context without forcing a turn per stale subagent.
|
|
447
|
-
pi.on("agent_settled", () => flushResults(false));
|
|
448
|
-
|
|
449
519
|
pi.on("session_shutdown", async () => {
|
|
520
|
+
if (navigationLayerRegistered) {
|
|
521
|
+
removeEditorLayer(pi, "subagents");
|
|
522
|
+
navigationLayerRegistered = false;
|
|
523
|
+
}
|
|
450
524
|
resultDelivery.clear();
|
|
451
525
|
unsubStatus?.();
|
|
452
526
|
unsubStatus = undefined;
|
|
@@ -1051,52 +1125,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
1051
1125
|
pi.registerMessageRenderer(
|
|
1052
1126
|
"subagent-result",
|
|
1053
1127
|
(message, { expanded }, theme) => {
|
|
1054
|
-
const details = (message.details ?? {}) as {
|
|
1055
|
-
id?: string;
|
|
1056
|
-
title?: string;
|
|
1057
|
-
status?: string;
|
|
1058
|
-
};
|
|
1059
|
-
const failed = details.status === "error";
|
|
1060
|
-
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■");
|
|
1061
|
-
const header =
|
|
1062
|
-
`${icon} ` +
|
|
1063
|
-
theme.fg("accent", theme.bold(`subagent ${details.id ?? "?"}`)) +
|
|
1064
|
-
theme.fg(
|
|
1065
|
-
"muted",
|
|
1066
|
-
` · ${details.title ?? ""} · ${failed ? "failed" : "finished"}`,
|
|
1067
|
-
);
|
|
1068
|
-
|
|
1069
1128
|
const content =
|
|
1070
1129
|
typeof message.content === "string" ? message.content : "";
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
const container = new Text(header, 0, 0);
|
|
1078
|
-
return {
|
|
1079
|
-
render: (width: number) => [
|
|
1080
|
-
...container.render(width),
|
|
1081
|
-
...md.render(width),
|
|
1082
|
-
],
|
|
1083
|
-
invalidate: () => {
|
|
1084
|
-
container.invalidate();
|
|
1085
|
-
md.invalidate();
|
|
1086
|
-
},
|
|
1087
|
-
};
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
const previewLines = body.split("\n").slice(0, 8);
|
|
1091
|
-
let text = header;
|
|
1092
|
-
for (const line of previewLines)
|
|
1093
|
-
text += `\n${theme.fg("toolOutput", line)}`;
|
|
1094
|
-
if (body.split("\n").length > 8)
|
|
1095
|
-
text += `\n${theme.fg("dim", `... (${keyHint("app.tools.expand", "to expand")})`)}`;
|
|
1096
|
-
return new Text(text, 0, 0);
|
|
1130
|
+
return renderSubagentResult(
|
|
1131
|
+
content,
|
|
1132
|
+
(message.details ?? {}) as SubagentResultDetails,
|
|
1133
|
+
expanded,
|
|
1134
|
+
theme,
|
|
1135
|
+
);
|
|
1097
1136
|
},
|
|
1098
1137
|
);
|
|
1099
1138
|
|
|
1139
|
+
pi.registerEntryRenderer<SubagentResultEntryData>(
|
|
1140
|
+
"subagent-result",
|
|
1141
|
+
(entry, { expanded }, theme) =>
|
|
1142
|
+
renderSubagentResult(
|
|
1143
|
+
entry.data?.content ?? "",
|
|
1144
|
+
entry.data?.details ?? {},
|
|
1145
|
+
expanded,
|
|
1146
|
+
theme,
|
|
1147
|
+
),
|
|
1148
|
+
);
|
|
1149
|
+
|
|
1100
1150
|
pi.registerEntryRenderer<SubagentFinishedData>(
|
|
1101
1151
|
"subagent-finished",
|
|
1102
1152
|
(entry, _options, theme) => {
|
|
@@ -8,7 +8,7 @@ import { MAX_RUNNING } from "./manager.ts";
|
|
|
8
8
|
|
|
9
9
|
/** Describes subagent_spawn, including the fixed concurrency cap. */
|
|
10
10
|
export const SUBAGENT_SPAWN_TOOL_DESCRIPTION =
|
|
11
|
-
"Spawn a background subagent: a fully autonomous, headless pi session with its own context window, this environment's tools and config, and normal host permissions. Fire-and-forget: this returns immediately with an id
|
|
11
|
+
"Spawn a background subagent: a fully autonomous, headless pi session with its own context window, this environment's tools and config, and normal host permissions. Fire-and-forget: this returns immediately with an id, and the subagent's final output is automatically queued back to you as a message when it settles. In an interactive session, keep working or end your turn so the user remains able to interact; do not block merely because a later step depends on the result. Children cannot orchestrate more agents/workflows or ask the user, and cannot see this conversation, so the prompt must be self-contained. Only use trusted working directories. " +
|
|
12
12
|
`Max ${MAX_RUNNING} subagents can be running at once.`;
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -62,7 +62,7 @@ export const SUBAGENT_SPAWN_PROMPT_SNIPPET =
|
|
|
62
62
|
/** Guides the parent model to delegate standalone tasks and avoid unnecessary blocking waits. */
|
|
63
63
|
export const SUBAGENT_SPAWN_PROMPT_GUIDELINES = [
|
|
64
64
|
"Reserve subagent_spawn for substantial, self-contained work; give it a complete, standalone prompt. For a single lookup or edit you can do inline, just do it — each subagent spends a fresh context window and cannot see this conversation.",
|
|
65
|
-
"After subagent_spawn, keep working on
|
|
65
|
+
"After subagent_spawn, keep working on independent work. If none remains in an interactive session, briefly tell the user the subagent is running in the background and end your turn; its result arrives automatically and you are re-invoked when it settles. Do not poll with subagent_check. Do not call subagent_wait merely because your next step depends on the result or because you have nothing else to do. Block only when the user explicitly asks you to keep the current response open for these results, or when a non-interactive automation must return them in the same invocation. Never answer from a guessed result before it arrives.",
|
|
66
66
|
];
|
|
67
67
|
|
|
68
68
|
/** Model-facing schema descriptions for subagent_spawn task and execution options. */
|
|
@@ -113,14 +113,14 @@ export function buildSubagentSpawnResult(options: {
|
|
|
113
113
|
: "";
|
|
114
114
|
return (
|
|
115
115
|
`Spawned subagent ${options.id} "${options.title}" (${options.harness}: ${options.modelLabel}, ${options.cwd}).${typeNote}${toolNote}${worktreeNote}\n` +
|
|
116
|
-
`It runs in the background — keep working on
|
|
117
|
-
`
|
|
116
|
+
`It runs in the background — keep working on independent work. If none remains in an interactive session, briefly tell the user it is still running and end your turn; its result is delivered automatically and you are automatically re-invoked when it finishes. Do not poll or call subagent_wait merely because a later step depends on it. ` +
|
|
117
|
+
`Use subagent_wait(ids: ["${options.id}"]) only if the user explicitly asked you to keep the current response open for this result, or a non-interactive automation must return it in the same invocation; subagent_cancel stops it, subagent_check peeks at a running one, subagent_list shows all.`
|
|
118
118
|
);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/** Describes explicit blocking collection of one or more subagent results. */
|
|
122
122
|
export const SUBAGENT_WAIT_TOOL_DESCRIPTION =
|
|
123
|
-
"Block until all listed subagents have settled, then return their final outputs. This is
|
|
123
|
+
"Block until all listed subagents have settled, then return their final outputs. This is an explicit synchronous barrier, not the default. In an interactive session, call it only when the user explicitly asks you to keep the current response open for these results. A dependent next step or having nothing else to do is not sufficient: end your turn and let automatic result delivery re-invoke you while the user remains free to interact. In a non-interactive automation, use it only when the same invocation must return the completed results. Never poll for completion and never answer from a guessed result before it arrives.";
|
|
124
124
|
|
|
125
125
|
/** Model-facing schema description for the subagent ids to await. */
|
|
126
126
|
export const SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS = {
|
|
@@ -154,7 +154,7 @@ export function buildSubagentSendResult(options: {
|
|
|
154
154
|
}) {
|
|
155
155
|
return options.wasRunning
|
|
156
156
|
? `Steered ${options.id} "${options.title}". It is queued into the active run; the result is delivered when it settles.`
|
|
157
|
-
: `Restarted ${options.id} "${options.title}" for another turn on its existing transcript. The result is delivered when it settles
|
|
157
|
+
: `Restarted ${options.id} "${options.title}" for another turn on its existing transcript. The result is delivered automatically when it settles.`;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
/** Describes nonblocking inspection of a subagent without consuming its result. */
|
|
@@ -1,17 +1,62 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface SubagentResultDeliveryOptions<T> {
|
|
2
|
+
/** True only when the parent has no run or queued continuation in flight. */
|
|
3
|
+
readonly isIdle: () => boolean;
|
|
4
|
+
/** Deliver one drained batch and wake the parent. */
|
|
5
|
+
readonly deliver: (results: readonly T[]) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One-shot result delivery for fire-and-forget subagents.
|
|
10
|
+
*
|
|
11
|
+
* The tool contract promises that a settled child re-invokes the parent. A
|
|
12
|
+
* child that settles while the parent is busy therefore remains retractable
|
|
13
|
+
* until the parent's `agent_settled` event, but it must never be downgraded to
|
|
14
|
+
* a `nextTurn` message that needs another user prompt. There are two symmetric
|
|
15
|
+
* wake-up edges so no ordering can lose the notification:
|
|
16
|
+
*
|
|
17
|
+
* 1. child settles after the parent became idle -> `defer` flushes now;
|
|
18
|
+
* 2. parent settles after the child -> `parentSettled` flushes the batch.
|
|
19
|
+
*
|
|
20
|
+
* The parent boundary wakes even if an earlier extension handler has already
|
|
21
|
+
* started another turn: Pi queues the follow-up into that active run.
|
|
22
|
+
*
|
|
23
|
+
* The Map is the one-shot gate: `subagent_wait` may consume a result before it
|
|
24
|
+
* is delivered, and whichever path drains first prevents duplicate delivery.
|
|
25
|
+
*/
|
|
26
|
+
export function createSubagentResultDelivery<T extends { id: string }>(
|
|
27
|
+
options: SubagentResultDeliveryOptions<T>,
|
|
28
|
+
) {
|
|
2
29
|
const pending = new Map<string, T>();
|
|
3
30
|
|
|
31
|
+
const flush = () => {
|
|
32
|
+
if (pending.size === 0) return;
|
|
33
|
+
const results = [...pending.values()];
|
|
34
|
+
pending.clear();
|
|
35
|
+
try {
|
|
36
|
+
options.deliver(results);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
// A synchronous session teardown may reject append/send. Preserve the
|
|
39
|
+
// original batch ahead of anything deferred re-entrantly while delivery
|
|
40
|
+
// ran, so a later boundary can retry without loss or reordering.
|
|
41
|
+
const current = [...pending.values()];
|
|
42
|
+
pending.clear();
|
|
43
|
+
for (const result of results) pending.set(result.id, result);
|
|
44
|
+
for (const result of current) pending.set(result.id, result);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
4
49
|
return {
|
|
5
50
|
defer(result: T) {
|
|
6
51
|
pending.set(result.id, result);
|
|
52
|
+
if (options.isIdle()) flush();
|
|
7
53
|
},
|
|
8
54
|
consume(ids: Iterable<string>) {
|
|
9
55
|
for (const id of ids) pending.delete(id);
|
|
10
56
|
},
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return results;
|
|
57
|
+
/** Flush at the authoritative parent boundary. */
|
|
58
|
+
parentSettled() {
|
|
59
|
+
flush();
|
|
15
60
|
},
|
|
16
61
|
clear() {
|
|
17
62
|
pending.clear();
|