@howarewoo/pi-subagents 0.0.0-stage → 0.2.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 +63 -0
- package/LICENSE +21 -0
- package/README.md +499 -2
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/docs/advanced-policies.md +78 -0
- package/docs/delegation-contract.md +297 -0
- package/docs/images/subagent-hub.svg +1 -0
- package/docs/images/subagent-profiles.svg +1 -0
- package/docs/images/subagent-widget.svg +1 -0
- package/docs/parity-inventory.md +330 -0
- package/docs/provenance.md +13 -0
- package/docs/releases.md +236 -0
- package/docs/verification.md +430 -0
- package/package.json +64 -4
- package/src/capabilities.ts +39 -0
- package/src/child-execution.ts +459 -0
- package/src/coordinator.ts +1376 -0
- package/src/delegation-types.ts +111 -0
- package/src/hub.ts +612 -0
- package/src/model-adapter.ts +77 -0
- package/src/model-policy.ts +100 -0
- package/src/outcomes.ts +110 -0
- package/src/pi-extended-subagents.ts +673 -0
- package/src/profile-viewer.ts +246 -0
- package/src/profiles.ts +225 -0
- package/src/scheduling.ts +73 -0
- package/src/subagent-widget.ts +115 -0
- package/src/supported-host.ts +26 -0
- package/src/task-input.ts +74 -0
- package/src/task-presentation.ts +165 -0
- package/src/thread.ts +268 -0
- package/src/tool-schemas.ts +165 -0
- package/src/workspace.ts +46 -0
package/src/hub.ts
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import {
|
|
2
|
+
colorToOklch,
|
|
3
|
+
Input,
|
|
4
|
+
matchesKey,
|
|
5
|
+
oklchColor,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
wrapTextWithAnsi,
|
|
9
|
+
type Component,
|
|
10
|
+
type Focusable,
|
|
11
|
+
type KeybindingsManager,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { ChildRef, ChildSnapshot, DelegationCoordinator } from "./coordinator.ts";
|
|
15
|
+
import { createNativeThread, safe, type LiveTool, type ThreadDependencies } from "./thread.ts";
|
|
16
|
+
export { safe } from "./thread.ts";
|
|
17
|
+
export { createChildWidget } from "./subagent-widget.ts";
|
|
18
|
+
export {
|
|
19
|
+
agentLabel,
|
|
20
|
+
fleetSummary,
|
|
21
|
+
taskCard,
|
|
22
|
+
transcriptLines,
|
|
23
|
+
writerWarning,
|
|
24
|
+
} from "./task-presentation.ts";
|
|
25
|
+
import {
|
|
26
|
+
agentLabel,
|
|
27
|
+
assignmentHeadline,
|
|
28
|
+
lineText,
|
|
29
|
+
taskDetails,
|
|
30
|
+
transcriptLines,
|
|
31
|
+
} from "./task-presentation.ts";
|
|
32
|
+
|
|
33
|
+
type Palette = Pick<Theme, "fg" | "style"> & { colors: Pick<Theme["colors"], "customMessageBg"> };
|
|
34
|
+
/** Observation only: closing or disposing never touches child execution. */
|
|
35
|
+
export function createHub(
|
|
36
|
+
coordinator: DelegationCoordinator,
|
|
37
|
+
theme: Palette,
|
|
38
|
+
keybindings: Pick<KeybindingsManager, "matches" | "getKeys">,
|
|
39
|
+
requestRender: () => void,
|
|
40
|
+
done: () => void,
|
|
41
|
+
dependencies: ThreadDependencies,
|
|
42
|
+
availableRows = () => 24,
|
|
43
|
+
): Component & Focusable & { dispose(): void } {
|
|
44
|
+
let selected: string | undefined;
|
|
45
|
+
let query = "";
|
|
46
|
+
let interactionTarget: ChildRef | undefined;
|
|
47
|
+
let interactionVisibleAtHeight: number | undefined;
|
|
48
|
+
let mode: "browse" | "search" | "steer" | "message" | "stop" = "browse";
|
|
49
|
+
let tab: "thread" | "details" | "tools" = "thread";
|
|
50
|
+
let offset = 0;
|
|
51
|
+
let following = true;
|
|
52
|
+
let maxOffset = 0;
|
|
53
|
+
let threadBounds: { x: number; y: number; width: number; height: number } | undefined;
|
|
54
|
+
let viewport = 1;
|
|
55
|
+
let notice = "";
|
|
56
|
+
let disposed = false;
|
|
57
|
+
const input = new Input();
|
|
58
|
+
const nativeThread = createNativeThread(dependencies);
|
|
59
|
+
const refresh = () => {
|
|
60
|
+
if (!disposed) requestRender();
|
|
61
|
+
};
|
|
62
|
+
// In-flight projections only; finalized messages remain in the authoritative registry.
|
|
63
|
+
const streaming = new Map<string, ReturnType<DelegationCoordinator["messages"]>[number]>();
|
|
64
|
+
const liveTools = new Map<string, LiveTool>();
|
|
65
|
+
const unsubscribe = coordinator.subscribe((event) => {
|
|
66
|
+
if (event.event?.type === "message_update")
|
|
67
|
+
streaming.set(identity(event.child), event.event.message);
|
|
68
|
+
if (event.event?.type === "message_end" || event.child.disposed)
|
|
69
|
+
streaming.delete(identity(event.child));
|
|
70
|
+
const sdk = event.event;
|
|
71
|
+
if (sdk?.type === "tool_execution_start" || sdk?.type === "tool_execution_update") {
|
|
72
|
+
liveTools.set(identity(event.child), {
|
|
73
|
+
toolName: sdk.toolName,
|
|
74
|
+
toolCallId: sdk.toolCallId,
|
|
75
|
+
args: sdk.args,
|
|
76
|
+
...(sdk.type === "tool_execution_update" ? { content: sdk.partialResult.content } : {}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (sdk?.type === "tool_execution_end" || event.child.disposed)
|
|
80
|
+
liveTools.delete(identity(event.child));
|
|
81
|
+
refresh();
|
|
82
|
+
});
|
|
83
|
+
const identity = (ref: ChildRef) => `${ref.id}/${ref.generation}`;
|
|
84
|
+
const roster = () =>
|
|
85
|
+
coordinator
|
|
86
|
+
.list()
|
|
87
|
+
.flatMap((child) => coordinator.generations(child.id))
|
|
88
|
+
.filter((child) =>
|
|
89
|
+
lineText(
|
|
90
|
+
[
|
|
91
|
+
child.id,
|
|
92
|
+
child.parentId,
|
|
93
|
+
child.profile,
|
|
94
|
+
child.status,
|
|
95
|
+
child.assignment,
|
|
96
|
+
child.progress,
|
|
97
|
+
child.result,
|
|
98
|
+
child.error,
|
|
99
|
+
child.model.id,
|
|
100
|
+
].join(" "),
|
|
101
|
+
)
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
.includes(query.toLowerCase()),
|
|
104
|
+
);
|
|
105
|
+
const selection = () => {
|
|
106
|
+
const children = roster();
|
|
107
|
+
if (!children.some((child) => identity(child) === selected)) {
|
|
108
|
+
selected = children[0] && identity(children[0]);
|
|
109
|
+
offset = 0;
|
|
110
|
+
following = tab === "thread";
|
|
111
|
+
}
|
|
112
|
+
return children.find((child) => identity(child) === selected);
|
|
113
|
+
};
|
|
114
|
+
const scrollBy = (delta: number) => {
|
|
115
|
+
offset = Math.max(0, Math.min(maxOffset, offset + delta));
|
|
116
|
+
if (tab === "thread") following = delta > 0 && offset === maxOffset;
|
|
117
|
+
refresh();
|
|
118
|
+
};
|
|
119
|
+
const control = async (target: ChildRef, text?: string, message = false) => {
|
|
120
|
+
notice =
|
|
121
|
+
text === undefined
|
|
122
|
+
? "Stopping and draining…"
|
|
123
|
+
: message
|
|
124
|
+
? "Sending inbox message…"
|
|
125
|
+
: "Sending steering…";
|
|
126
|
+
refresh();
|
|
127
|
+
try {
|
|
128
|
+
const child = coordinator.snapshot(target);
|
|
129
|
+
if (
|
|
130
|
+
!child ||
|
|
131
|
+
child.disposed ||
|
|
132
|
+
child.status === "draining" ||
|
|
133
|
+
child.status === "pausing" ||
|
|
134
|
+
(text !== undefined && child.status !== "running")
|
|
135
|
+
)
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Target ${target.id} #${target.generation} is no longer running; no action sent`,
|
|
138
|
+
);
|
|
139
|
+
if (text === undefined) {
|
|
140
|
+
await coordinator.stop(target);
|
|
141
|
+
notice = "Stopped and drained";
|
|
142
|
+
} else if (message) {
|
|
143
|
+
const receipt = await coordinator.message(undefined, target, text);
|
|
144
|
+
notice = `Message ${receipt.id} · ${receipt.status} · NOT model acknowledgment${receipt.error ? ` · ${receipt.error}` : ""}`;
|
|
145
|
+
} else notice = `Steering ${await coordinator.steer(target, text)}`;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
notice = String(error);
|
|
148
|
+
}
|
|
149
|
+
refresh();
|
|
150
|
+
};
|
|
151
|
+
const observeControl = async (target: ChildRef, action: "wait" | "park") => {
|
|
152
|
+
try {
|
|
153
|
+
if (action === "park") {
|
|
154
|
+
coordinator.park(target);
|
|
155
|
+
notice = "Parked (session already disposed)";
|
|
156
|
+
} else {
|
|
157
|
+
notice = "Waiting up to 1000ms (not model acknowledgment)…";
|
|
158
|
+
refresh();
|
|
159
|
+
notice = JSON.stringify(await coordinator.coordinateWait(undefined, target, 1000));
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
notice = String(error);
|
|
163
|
+
}
|
|
164
|
+
refresh();
|
|
165
|
+
};
|
|
166
|
+
const canSubmitInteraction = () => {
|
|
167
|
+
if (interactionVisibleAtHeight === Math.max(1, availableRows())) return true;
|
|
168
|
+
notice = "Resize for controls · Esc cancels; no action sent";
|
|
169
|
+
refresh();
|
|
170
|
+
return false;
|
|
171
|
+
};
|
|
172
|
+
const finishInput = () => {
|
|
173
|
+
if (interactionTarget && !canSubmitInteraction()) return;
|
|
174
|
+
if (mode === "search") {
|
|
175
|
+
query = lineText(input.getValue());
|
|
176
|
+
offset = 0;
|
|
177
|
+
following = tab === "thread";
|
|
178
|
+
} else if ((mode === "steer" || mode === "message") && interactionTarget)
|
|
179
|
+
void control(interactionTarget, input.getValue(), mode === "message");
|
|
180
|
+
interactionTarget = undefined;
|
|
181
|
+
mode = "browse";
|
|
182
|
+
input.setValue("");
|
|
183
|
+
refresh();
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
focused: false,
|
|
187
|
+
render(width) {
|
|
188
|
+
interactionVisibleAtHeight = undefined;
|
|
189
|
+
threadBounds = undefined;
|
|
190
|
+
width = Math.max(0, width);
|
|
191
|
+
const height = Math.max(1, availableRows());
|
|
192
|
+
const child = selection();
|
|
193
|
+
const panelBg = oklchColor(colorToOklch(theme.colors.customMessageBg).l, 0, 0);
|
|
194
|
+
const paint = (text: string) => {
|
|
195
|
+
const clipped = truncateToWidth(text, width);
|
|
196
|
+
const padded = clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
197
|
+
const restoreBg = theme.style("", { bg: panelBg }).replace(/\x1b\[49m$/, "");
|
|
198
|
+
return padded
|
|
199
|
+
.split("\x1b[0m")
|
|
200
|
+
.map((part) => theme.style(part.replaceAll("\x1b[49m", restoreBg), { bg: panelBg }))
|
|
201
|
+
.join("\x1b[0m");
|
|
202
|
+
};
|
|
203
|
+
if (width < 4 || height < 3)
|
|
204
|
+
return [
|
|
205
|
+
paint(
|
|
206
|
+
interactionTarget
|
|
207
|
+
? "Resize · Esc cancels"
|
|
208
|
+
: "Subagent Hub " + coordinator.list().length,
|
|
209
|
+
),
|
|
210
|
+
];
|
|
211
|
+
const inner = Math.max(0, width - 4);
|
|
212
|
+
const row = (text: string) => {
|
|
213
|
+
const clipped = truncateToWidth(text, inner);
|
|
214
|
+
return paint(
|
|
215
|
+
theme.fg("border", "│") +
|
|
216
|
+
" " +
|
|
217
|
+
clipped +
|
|
218
|
+
" ".repeat(Math.max(1, width - 3 - visibleWidth(clipped))) +
|
|
219
|
+
theme.fg("border", "│"),
|
|
220
|
+
);
|
|
221
|
+
};
|
|
222
|
+
const frame = (left: string, title: string, right: string) => {
|
|
223
|
+
const text = truncateToWidth(title, width - 2);
|
|
224
|
+
return paint(
|
|
225
|
+
theme.fg("border", left) +
|
|
226
|
+
theme.fg("accent", text) +
|
|
227
|
+
theme.fg("border", "─".repeat(Math.max(0, width - 2 - visibleWidth(text))) + right),
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
const children = roster();
|
|
231
|
+
const current = coordinator.list();
|
|
232
|
+
const historical = (item: ChildRef) =>
|
|
233
|
+
!current.some((live) => identity(live) === identity(item));
|
|
234
|
+
const index = children.findIndex((item) => identity(item) === selected);
|
|
235
|
+
const wide = width >= 76;
|
|
236
|
+
const leftWidth = wide ? Math.min(32, Math.max(24, Math.floor(inner / 3))) : 0;
|
|
237
|
+
const threadWidth = wide ? inner - leftWidth - 3 : inner;
|
|
238
|
+
const close = [
|
|
239
|
+
...new Set([
|
|
240
|
+
...keybindings.getKeys("tui.select.cancel"),
|
|
241
|
+
...keybindings.getKeys("tui.select.confirm"),
|
|
242
|
+
]),
|
|
243
|
+
].join("/");
|
|
244
|
+
const footer: string[] = [];
|
|
245
|
+
if (query)
|
|
246
|
+
footer.push(
|
|
247
|
+
theme.fg("muted", `Filter: ${lineText(query)} · ${children.length} generations`),
|
|
248
|
+
);
|
|
249
|
+
if (notice) {
|
|
250
|
+
const lines = wrapTextWithAnsi(lineText(notice), Math.max(1, inner));
|
|
251
|
+
footer.push(
|
|
252
|
+
...lines
|
|
253
|
+
.slice(0, 3)
|
|
254
|
+
.map((line, index) =>
|
|
255
|
+
theme.fg(
|
|
256
|
+
"warning",
|
|
257
|
+
index === 2 && lines.length > 3
|
|
258
|
+
? truncateToWidth(line, Math.max(0, inner - 19)) + " [notice truncated]"
|
|
259
|
+
: line,
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const targetLines = interactionTarget
|
|
265
|
+
? wrapTextWithAnsi(
|
|
266
|
+
`Target: ${interactionTarget.id} #${interactionTarget.generation}`,
|
|
267
|
+
Math.max(1, inner),
|
|
268
|
+
)
|
|
269
|
+
: [];
|
|
270
|
+
if (interactionTarget && (inner < 26 || height < targetLines.length + 6)) {
|
|
271
|
+
return [
|
|
272
|
+
frame("╭", " Resize / Esc cancel ", "╮"),
|
|
273
|
+
...targetLines.slice(0, height - 3).map(row),
|
|
274
|
+
row("Resize for controls · Esc cancels"),
|
|
275
|
+
frame("╰", "", "╯"),
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
footer.push(...targetLines.map((line) => theme.fg("accent", line)));
|
|
279
|
+
if (mode === "search" || mode === "steer" || mode === "message") {
|
|
280
|
+
input.focused = this.focused;
|
|
281
|
+
const label = mode === "search" ? "Filter: " : mode === "message" ? "Inbox: " : "Steer: ";
|
|
282
|
+
footer.push(
|
|
283
|
+
label + input.render(Math.max(1, inner - visibleWidth(label)))[0],
|
|
284
|
+
"Enter submit · Esc back (no action)",
|
|
285
|
+
);
|
|
286
|
+
} else if (mode === "stop")
|
|
287
|
+
footer.push("Stop target? y confirms · Esc back", "Enter does not confirm stop");
|
|
288
|
+
else
|
|
289
|
+
footer.push(
|
|
290
|
+
...(wide
|
|
291
|
+
? [
|
|
292
|
+
"↑↓ agents · j/k line · Ctrl+u/d or PgUp/Dn page · Home/End",
|
|
293
|
+
`${close}: close · / filter Tab view · s steer m inbox x stop · w wait p park`,
|
|
294
|
+
]
|
|
295
|
+
: [
|
|
296
|
+
"↑↓ agent · j/k scroll · / Tab",
|
|
297
|
+
`End live · s/m/x · ${(keybindings.getKeys("tui.select.cancel")[0] ?? "Esc").replaceAll("escape", "Esc")}: close`,
|
|
298
|
+
]),
|
|
299
|
+
);
|
|
300
|
+
// A pinned target and its controls are indivisible; optional hints must yield first.
|
|
301
|
+
const requiredFooter = interactionTarget ? targetLines.length + 2 : 0;
|
|
302
|
+
const shownFooter =
|
|
303
|
+
requiredFooter || height >= 8 ? footer.slice(-Math.max(2, height - 7, requiredFooter)) : [];
|
|
304
|
+
if (interactionTarget) interactionVisibleAtHeight = height;
|
|
305
|
+
const separatorRows = shownFooter.length ? 1 : 0;
|
|
306
|
+
const paneHeight = Math.max(1, height - 2 - shownFooter.length - separatorRows);
|
|
307
|
+
viewport = Math.max(1, paneHeight - 3);
|
|
308
|
+
let detail: string[] = [];
|
|
309
|
+
let nativeLines: string[] | undefined;
|
|
310
|
+
if (child) {
|
|
311
|
+
if (tab === "details") {
|
|
312
|
+
detail = taskDetails(
|
|
313
|
+
child,
|
|
314
|
+
coordinator
|
|
315
|
+
.agents()
|
|
316
|
+
.find((agent) => agent.id === child.id && agent.generation === child.generation)
|
|
317
|
+
?.presence,
|
|
318
|
+
);
|
|
319
|
+
} else {
|
|
320
|
+
try {
|
|
321
|
+
const messages = coordinator.messages(child);
|
|
322
|
+
const partial = streaming.get(identity(child));
|
|
323
|
+
if (
|
|
324
|
+
tab === "thread" &&
|
|
325
|
+
(messages.length || partial || liveTools.has(identity(child)))
|
|
326
|
+
) {
|
|
327
|
+
nativeLines = nativeThread.render(
|
|
328
|
+
identity(child),
|
|
329
|
+
messages,
|
|
330
|
+
partial,
|
|
331
|
+
liveTools.get(identity(child)),
|
|
332
|
+
threadWidth,
|
|
333
|
+
);
|
|
334
|
+
} else
|
|
335
|
+
detail =
|
|
336
|
+
messages.length || partial
|
|
337
|
+
? [
|
|
338
|
+
...transcriptLines(partial ? [...messages, partial] : messages, true),
|
|
339
|
+
...(liveTools.get(identity(child))
|
|
340
|
+
? [
|
|
341
|
+
"Live tool: " + liveTools.get(identity(child))!.toolName,
|
|
342
|
+
"Arguments: " +
|
|
343
|
+
JSON.stringify(liveTools.get(identity(child))!.args, null, 2),
|
|
344
|
+
...(liveTools.get(identity(child))!.content ?? []).flatMap((block) =>
|
|
345
|
+
block.type === "text" ? [block.text ?? ""] : [],
|
|
346
|
+
),
|
|
347
|
+
]
|
|
348
|
+
: []),
|
|
349
|
+
]
|
|
350
|
+
: [
|
|
351
|
+
child.error ||
|
|
352
|
+
child.result ||
|
|
353
|
+
(child.status === "queued"
|
|
354
|
+
? "Queued · no SDK messages yet"
|
|
355
|
+
: "Waiting for SDK messages"),
|
|
356
|
+
];
|
|
357
|
+
} catch (error) {
|
|
358
|
+
detail = ["Transcript unavailable: " + safe(String(error))];
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} else
|
|
362
|
+
detail = [
|
|
363
|
+
query
|
|
364
|
+
? "No matching tasks · / to change filter"
|
|
365
|
+
: "No tasks · ask the model to delegate_task",
|
|
366
|
+
];
|
|
367
|
+
const wrapped =
|
|
368
|
+
nativeLines ??
|
|
369
|
+
detail.flatMap((text) =>
|
|
370
|
+
safe(text)
|
|
371
|
+
.split("\n")
|
|
372
|
+
.flatMap((line) => wrapTextWithAnsi(line, Math.max(1, threadWidth))),
|
|
373
|
+
);
|
|
374
|
+
maxOffset = Math.max(0, wrapped.length - viewport);
|
|
375
|
+
offset = tab === "thread" && following ? maxOffset : Math.max(0, Math.min(offset, maxOffset));
|
|
376
|
+
threadBounds = {
|
|
377
|
+
x: wide ? 2 + leftWidth + 3 : 2,
|
|
378
|
+
y: 3,
|
|
379
|
+
width: threadWidth,
|
|
380
|
+
height: Math.max(0, paneHeight - 3),
|
|
381
|
+
};
|
|
382
|
+
const content = wrapped
|
|
383
|
+
.slice(offset, offset + viewport)
|
|
384
|
+
.map((line) => (nativeLines ? line : theme.fg("text", line)));
|
|
385
|
+
const statusColor = (item: ChildSnapshot): "error" | "warning" | "success" | "accent" =>
|
|
386
|
+
item.status === "failed"
|
|
387
|
+
? "error"
|
|
388
|
+
: item.status === "cancelled"
|
|
389
|
+
? "warning"
|
|
390
|
+
: item.status === "settled"
|
|
391
|
+
? "success"
|
|
392
|
+
: "accent";
|
|
393
|
+
const selectedLabel = child
|
|
394
|
+
? `${agentLabel(child)} · ${child.status}${historical(child) ? " · history" : ""}`
|
|
395
|
+
: "No selection";
|
|
396
|
+
const viewLabel = `${tab.toUpperCase()} · Tab: ${tab === "thread" ? "details" : tab === "details" ? "tools" : "thread"}`;
|
|
397
|
+
const position = `${tab === "thread" ? (following ? "LIVE · " : "READING · ") : ""}${offset + 1}–${Math.min(wrapped.length, offset + viewport)}/${wrapped.length} lines${tab === "thread" ? (following ? " · k/PgUp read" : " · End: live") : offset < maxOffset ? " · more ↓" : ""}`;
|
|
398
|
+
const pane: string[] = [];
|
|
399
|
+
if (wide) {
|
|
400
|
+
const slots = Math.max(1, Math.floor((paneHeight - 2) / 3));
|
|
401
|
+
const start = Math.max(0, index - slots + 1);
|
|
402
|
+
const left = [
|
|
403
|
+
theme.fg("accent", `SUBAGENTS · ${children.length} generations`),
|
|
404
|
+
...children.slice(start, start + slots).flatMap((item) => {
|
|
405
|
+
const indent = " ".repeat(Math.min(4, Math.max(0, item.depth - 1)));
|
|
406
|
+
const prefix = `${identity(item) === selected ? ">" : " "} ${indent}└ `;
|
|
407
|
+
return [
|
|
408
|
+
theme.fg(
|
|
409
|
+
identity(item) === selected ? "accent" : "text",
|
|
410
|
+
`${prefix}${assignmentHeadline(item)}${historical(item) ? " · history" : ""}`,
|
|
411
|
+
),
|
|
412
|
+
theme.fg(
|
|
413
|
+
statusColor(item),
|
|
414
|
+
`${" ".repeat(visibleWidth(prefix))}${lineText(item.profile)} · ${item.status}`,
|
|
415
|
+
),
|
|
416
|
+
"", // Separate agents without exposing internal generation numbers.
|
|
417
|
+
];
|
|
418
|
+
}),
|
|
419
|
+
];
|
|
420
|
+
const right = [
|
|
421
|
+
theme.fg("accent", lineText(selectedLabel)),
|
|
422
|
+
theme.fg("muted", viewLabel),
|
|
423
|
+
...content,
|
|
424
|
+
];
|
|
425
|
+
const cell = (text: string, columns: number) => {
|
|
426
|
+
const clipped = truncateToWidth(text, columns);
|
|
427
|
+
return clipped + " ".repeat(Math.max(0, columns - visibleWidth(clipped)));
|
|
428
|
+
};
|
|
429
|
+
for (let index = 0; index < paneHeight; index++)
|
|
430
|
+
pane.push(
|
|
431
|
+
cell(
|
|
432
|
+
index === paneHeight - 1
|
|
433
|
+
? theme.fg(
|
|
434
|
+
"muted",
|
|
435
|
+
`${children.length ? start + 1 : 0}–${Math.min(children.length, start + slots)}/${children.length} · ↑↓ select`,
|
|
436
|
+
)
|
|
437
|
+
: (left[index] ?? ""),
|
|
438
|
+
leftWidth,
|
|
439
|
+
) +
|
|
440
|
+
theme.fg("border", " │ ") +
|
|
441
|
+
cell(
|
|
442
|
+
index === paneHeight - 1 ? theme.fg("muted", position) : (right[index] ?? ""),
|
|
443
|
+
threadWidth,
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
} else {
|
|
447
|
+
const suffix = child ? ` · ${index + 1}/${children.length}` : "";
|
|
448
|
+
pane.push(
|
|
449
|
+
theme.fg(
|
|
450
|
+
"accent",
|
|
451
|
+
child
|
|
452
|
+
? `> └ ${truncateToWidth(agentLabel(child), Math.max(0, inner - 4 - visibleWidth(suffix)))}${suffix}`
|
|
453
|
+
: "No selection",
|
|
454
|
+
),
|
|
455
|
+
);
|
|
456
|
+
if (paneHeight > 1)
|
|
457
|
+
pane.push(
|
|
458
|
+
theme.fg(
|
|
459
|
+
child ? statusColor(child) : "muted",
|
|
460
|
+
`${tab.toUpperCase()}${child ? ` · ${child.profile} · ${child.status}` : ""}`,
|
|
461
|
+
),
|
|
462
|
+
);
|
|
463
|
+
if (paneHeight > 2)
|
|
464
|
+
pane.push(
|
|
465
|
+
...content.slice(0, paneHeight - 3),
|
|
466
|
+
...Array(Math.max(0, paneHeight - 3 - content.length)).fill(""),
|
|
467
|
+
theme.fg("muted", position),
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return [
|
|
471
|
+
frame("╭", " Subagent Hub ", "╮"),
|
|
472
|
+
...pane.slice(0, height - 2).map(row),
|
|
473
|
+
...(separatorRows ? [paint(theme.fg("border", "├" + "─".repeat(width - 2) + "┤"))] : []),
|
|
474
|
+
...shownFooter.map(row),
|
|
475
|
+
frame("╰", "", "╯"),
|
|
476
|
+
];
|
|
477
|
+
},
|
|
478
|
+
handleInput(data) {
|
|
479
|
+
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
480
|
+
if (mode !== "browse") {
|
|
481
|
+
mode = "browse";
|
|
482
|
+
interactionTarget = undefined;
|
|
483
|
+
input.setValue("");
|
|
484
|
+
refresh();
|
|
485
|
+
} else done();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (mode === "search" || mode === "steer" || mode === "message") {
|
|
489
|
+
if (keybindings.matches(data, "tui.select.confirm")) finishInput();
|
|
490
|
+
else {
|
|
491
|
+
input.handleInput(data);
|
|
492
|
+
const cleaned = lineText(input.getValue());
|
|
493
|
+
if (cleaned !== input.getValue()) input.setValue(cleaned);
|
|
494
|
+
refresh();
|
|
495
|
+
}
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (mode === "stop") {
|
|
499
|
+
if (data === "y") {
|
|
500
|
+
if (interactionTarget && !canSubmitInteraction()) return;
|
|
501
|
+
if (interactionTarget) void control(interactionTarget);
|
|
502
|
+
interactionTarget = undefined;
|
|
503
|
+
mode = "browse";
|
|
504
|
+
}
|
|
505
|
+
refresh();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (keybindings.matches(data, "tui.select.confirm")) {
|
|
509
|
+
done();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (
|
|
513
|
+
keybindings.matches(data, "tui.select.up") ||
|
|
514
|
+
keybindings.matches(data, "tui.select.down")
|
|
515
|
+
) {
|
|
516
|
+
const children = roster();
|
|
517
|
+
const index = children.findIndex((child) => identity(child) === selected);
|
|
518
|
+
const next =
|
|
519
|
+
children[
|
|
520
|
+
Math.max(
|
|
521
|
+
0,
|
|
522
|
+
Math.min(
|
|
523
|
+
children.length - 1,
|
|
524
|
+
index + (keybindings.matches(data, "tui.select.up") ? -1 : 1),
|
|
525
|
+
),
|
|
526
|
+
)
|
|
527
|
+
];
|
|
528
|
+
selected = next && identity(next);
|
|
529
|
+
offset = 0;
|
|
530
|
+
following = tab === "thread";
|
|
531
|
+
notice = "";
|
|
532
|
+
} else if (keybindings.matches(data, "tui.select.pageUp") || matchesKey(data, "ctrl+u"))
|
|
533
|
+
scrollBy(-viewport);
|
|
534
|
+
else if (keybindings.matches(data, "tui.select.pageDown") || matchesKey(data, "ctrl+d"))
|
|
535
|
+
scrollBy(viewport);
|
|
536
|
+
else if (data === "k") scrollBy(-1);
|
|
537
|
+
else if (data === "j") scrollBy(1);
|
|
538
|
+
else if (matchesKey(data, "home")) {
|
|
539
|
+
offset = 0;
|
|
540
|
+
following = false;
|
|
541
|
+
} else if (matchesKey(data, "end")) {
|
|
542
|
+
offset = maxOffset;
|
|
543
|
+
following = tab === "thread";
|
|
544
|
+
} else if (matchesKey(data, "tab")) {
|
|
545
|
+
tab = tab === "thread" ? "details" : tab === "details" ? "tools" : "thread";
|
|
546
|
+
offset = 0;
|
|
547
|
+
following = tab === "thread";
|
|
548
|
+
} else if (data === "/") {
|
|
549
|
+
mode = "search";
|
|
550
|
+
input.setValue(query);
|
|
551
|
+
// Native Input.setValue preserves its old cursor; a saved filter starts editing at its end.
|
|
552
|
+
input.handleInput("\x1b[F");
|
|
553
|
+
} else if (["w", "p", "f", "r"].includes(data)) {
|
|
554
|
+
const child = selection();
|
|
555
|
+
if (!child) notice = "Select an identity first";
|
|
556
|
+
else if (data === "w" || data === "p")
|
|
557
|
+
void observeControl(
|
|
558
|
+
{ id: child.id, generation: child.generation },
|
|
559
|
+
data === "w" ? "wait" : "park",
|
|
560
|
+
);
|
|
561
|
+
else {
|
|
562
|
+
notice =
|
|
563
|
+
"Followup/revive: native model-issued delegate_coordinate only. Hub command context has no permission-preserving tool dispatcher; no generation started.";
|
|
564
|
+
}
|
|
565
|
+
} else if (data === "s" || data === "x" || data === "m") {
|
|
566
|
+
const child = selection();
|
|
567
|
+
if (
|
|
568
|
+
child &&
|
|
569
|
+
!child.disposed &&
|
|
570
|
+
child.status !== "draining" &&
|
|
571
|
+
child.status !== "pausing" &&
|
|
572
|
+
(data === "x" || child.status === "running")
|
|
573
|
+
) {
|
|
574
|
+
mode = data === "s" ? "steer" : data === "m" ? "message" : "stop";
|
|
575
|
+
interactionTarget = { id: child.id, generation: child.generation };
|
|
576
|
+
interactionVisibleAtHeight = undefined;
|
|
577
|
+
input.setValue("");
|
|
578
|
+
} else
|
|
579
|
+
notice =
|
|
580
|
+
"Controls unavailable: historical/disposed target, or task not running (stop accepts queued/setup/running)";
|
|
581
|
+
}
|
|
582
|
+
refresh();
|
|
583
|
+
},
|
|
584
|
+
handleMouse(event) {
|
|
585
|
+
if (event.type !== "wheel" || disposed) return;
|
|
586
|
+
// Overlay wheels outside the reading viewport must not fall through to the main chat.
|
|
587
|
+
if (
|
|
588
|
+
mode === "browse" &&
|
|
589
|
+
threadBounds &&
|
|
590
|
+
event.x >= threadBounds.x &&
|
|
591
|
+
event.x < threadBounds.x + threadBounds.width &&
|
|
592
|
+
event.y >= threadBounds.y &&
|
|
593
|
+
event.y < threadBounds.y + threadBounds.height &&
|
|
594
|
+
event.wheelDelta
|
|
595
|
+
)
|
|
596
|
+
scrollBy(event.wheelDelta);
|
|
597
|
+
return { handled: true };
|
|
598
|
+
},
|
|
599
|
+
invalidate() {
|
|
600
|
+
input.invalidate();
|
|
601
|
+
nativeThread.invalidate();
|
|
602
|
+
},
|
|
603
|
+
dispose() {
|
|
604
|
+
disposed = true;
|
|
605
|
+
interactionTarget = undefined;
|
|
606
|
+
unsubscribe();
|
|
607
|
+
streaming.clear();
|
|
608
|
+
liveTools.clear();
|
|
609
|
+
nativeThread.clear();
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ModelRuntime, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
InMemoryCredentialStore,
|
|
4
|
+
type Provider,
|
|
5
|
+
type Model,
|
|
6
|
+
type Api,
|
|
7
|
+
} from "@earendil-works/pi-ai";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
/** Resolve auth only through the parent's public registry; never copy credentials to child storage. */
|
|
11
|
+
export async function parentModelRuntime(
|
|
12
|
+
ctx: Pick<ExtensionContext, "model" | "modelRegistry" | "scopedModels">,
|
|
13
|
+
agentDir: string,
|
|
14
|
+
onRequest: () => void = () => {},
|
|
15
|
+
additionalModels: Model<Api>[] = [],
|
|
16
|
+
): Promise<ModelRuntime> {
|
|
17
|
+
const { model, modelRegistry: registry } = ctx;
|
|
18
|
+
if (!model) throw new Error("Delegation requires a selected parent model");
|
|
19
|
+
if (model.api === "pi-virtual")
|
|
20
|
+
throw new Error(
|
|
21
|
+
"Selected virtual model cannot be faithfully routed through the public child adapter",
|
|
22
|
+
);
|
|
23
|
+
if (
|
|
24
|
+
ctx.scopedModels.length &&
|
|
25
|
+
!ctx.scopedModels.some(
|
|
26
|
+
(entry) => entry.model.provider === model.provider && entry.model.id === model.id,
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
throw new Error("Selected model is outside the parent's scoped models");
|
|
30
|
+
if (!registry.getProvider(model.provider) || !registry.find(model.provider, model.id))
|
|
31
|
+
throw new Error(
|
|
32
|
+
"Selected model has no public provider adapter (virtual-only models are not supported)",
|
|
33
|
+
);
|
|
34
|
+
if (!registry.hasConfiguredAuth(model))
|
|
35
|
+
throw new Error(`No configured parent authentication for ${model.provider}`);
|
|
36
|
+
const auth = await registry.getApiKeyAndHeaders(model);
|
|
37
|
+
if (!auth.ok) throw new Error(auth.error);
|
|
38
|
+
const runtime = await ModelRuntime.create({
|
|
39
|
+
credentials: new InMemoryCredentialStore(),
|
|
40
|
+
modelsPath: null,
|
|
41
|
+
modelsStorePath: join(agentDir, "models-cache.json"),
|
|
42
|
+
refreshOnCreate: false,
|
|
43
|
+
allowModelNetwork: false,
|
|
44
|
+
});
|
|
45
|
+
// Child runtime performs no independent auth discovery. Each request is forwarded to the
|
|
46
|
+
// parent registry, which owns configured providers, OAuth refresh and request-time auth.
|
|
47
|
+
const models = [model, ...additionalModels];
|
|
48
|
+
for (const providerId of new Set(models.map((item) => item.provider))) {
|
|
49
|
+
const provider: Provider = {
|
|
50
|
+
id: providerId,
|
|
51
|
+
name: `Parent selected ${providerId}`,
|
|
52
|
+
auth: {
|
|
53
|
+
apiKey: {
|
|
54
|
+
name: "Parent registry",
|
|
55
|
+
async resolve() {
|
|
56
|
+
return { auth: {}, source: "parent registry delegation" };
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
getModels: () => models.filter((item) => item.provider === providerId),
|
|
61
|
+
stream: (selected, context, options) => {
|
|
62
|
+
onRequest();
|
|
63
|
+
return registry.stream(registry.find(selected.provider, selected.id)!, context, options);
|
|
64
|
+
},
|
|
65
|
+
streamSimple: (selected, context, options) => {
|
|
66
|
+
onRequest();
|
|
67
|
+
return registry.streamSimple(
|
|
68
|
+
registry.find(selected.provider, selected.id)!,
|
|
69
|
+
context,
|
|
70
|
+
options,
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
runtime.registerNativeProvider(provider);
|
|
75
|
+
}
|
|
76
|
+
return runtime;
|
|
77
|
+
}
|