@vzlentin/harness 0.2.3 → 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.
@@ -0,0 +1,303 @@
1
+ /**
2
+ * The mirror of one Pi session: the frame of its entry in Pi's chat, which holds the transcript, the status above the
3
+ * editor, and an editor whose interrupt and follow-up keys act on the agent while it works. The connection to the host
4
+ * is the `Mirror`, and the model and thinking level of the session are the `ModelSync`.
5
+ */
6
+ import type {
7
+ ExtensionAPI,
8
+ ExtensionContext,
9
+ KeybindingsManager,
10
+ Theme,
11
+ ThemeColor,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import * as coding from "@earendil-works/pi-coding-agent";
14
+ import type { ConversationView } from "@earendil-works/pi-durable";
15
+ import type { Component, EditorTheme, TUI } from "@earendil-works/pi-tui";
16
+ import * as tui from "@earendil-works/pi-tui";
17
+ import { type AgentReporter, agentReporter } from "../dist/herdr.js";
18
+ import { Mirror, type MirrorStatus, type MirrorTarget, targetTitle } from "../dist/mirror.js";
19
+ import { type ChildInfo, childLabel, childTitle, errorMessage } from "../dist/protocol.js";
20
+ import { inboxOf, liveOf, queued, statusText } from "../dist/view-state.js";
21
+ import { ChatView } from "./chat.ts";
22
+ import { ModelSync } from "./model-sync.ts";
23
+ import { ProgramStatusReport } from "./program-status.ts";
24
+ import { usageLine } from "./usage-line.ts";
25
+
26
+ /** The key of the status widget above the editor. */
27
+ const WIDGET = "harness-mirror";
28
+
29
+ export class Attachment {
30
+ readonly entryId: string;
31
+ readonly models: ModelSync;
32
+ // After a session switch, `#pi` and `#ctx` are stale and throw, so only code that checks `#closed` uses them. The
33
+ // components that Pi draws use `#theme` instead.
34
+ readonly #pi: ExtensionAPI;
35
+ readonly #ctx: ExtensionContext;
36
+ #theme: Theme;
37
+ readonly #frame: tui.Container;
38
+ readonly #status = new StatusArea();
39
+ readonly #mirror: Mirror;
40
+ #ui: TUI | undefined;
41
+ #chat: ChatView | undefined;
42
+ /** The state of the agent for Herdr, made when the agent is first known. */
43
+ #reporter: AgentReporter | undefined;
44
+ /** The state of the agent for the terminal, made with the TUI. */
45
+ #programStatus: ProgramStatusReport | undefined;
46
+ #expanded = false;
47
+ #closed = false;
48
+
49
+ constructor(pi: ExtensionAPI, ctx: ExtensionContext, entryId: string, target: MirrorTarget, frame: tui.Container) {
50
+ this.entryId = entryId;
51
+ this.#pi = pi;
52
+ this.#ctx = ctx;
53
+ this.#theme = ctx.ui.theme;
54
+ this.#frame = frame;
55
+ this.#frame.clear();
56
+ this.#frame.addChild(this.#line("muted", () => `Connecting to the ${targetTitle(target)}...`));
57
+ // Pi calls the factory at once, which gives the TUI that the tool cards need.
58
+ ctx.ui.setWidget(
59
+ WIDGET,
60
+ (ui, theme) => {
61
+ this.#ui = ui;
62
+ this.#theme = theme;
63
+ this.#programStatus ??= new ProgramStatusReport(ui.terminal);
64
+
65
+ return this.#status;
66
+ },
67
+ { placement: "aboveEditor" },
68
+ );
69
+ ctx.ui.setEditorComponent((ui, theme, keybindings) => new MirrorEditor(ui, theme, keybindings, this));
70
+ this.#mirror = new Mirror(target, (previous) => this.#update(previous));
71
+ this.#programStatus?.update(this.#mirror);
72
+ this.models = new ModelSync(pi, ctx, this.#mirror, (cause) => this.#notify(cause));
73
+ }
74
+
75
+ get busy(): boolean {
76
+ return this.#mirror.busy;
77
+ }
78
+
79
+ /**
80
+ * Sends `text` to the agent: a new turn when it is idle, else a steer, or a follow-up with `followUp`. A text that
81
+ * does not reach the agent goes back to the editor, after the text there, so it is not lost.
82
+ */
83
+ send(text: string, followUp: boolean): void {
84
+ this.#mirror.send(text, followUp).catch((cause: unknown) => {
85
+ if (this.#closed) return;
86
+ const editor = this.#ctx.ui.getEditorText();
87
+ this.#ctx.ui.setEditorText([editor, text].filter((part) => part.trim() !== "").join("\n\n"));
88
+ this.#ctx.ui.notify(`harness: not sent: ${errorMessage(cause)}`, "error");
89
+ });
90
+ }
91
+
92
+ /** Aborts the agent's turn. The queued messages that the host withdraws go back to `editor`, steers first. */
93
+ abort(editor: Pick<tui.Editor, "getText" | "setText">): void {
94
+ this.#mirror.abort().then(
95
+ (texts) => {
96
+ if (this.#closed) return;
97
+ const text = [...texts, editor.getText()].filter((part) => part.trim() !== "").join("\n\n");
98
+
99
+ if (text !== editor.getText()) editor.setText(text);
100
+ },
101
+ (cause: unknown) => this.#notify(cause),
102
+ );
103
+ }
104
+
105
+ /** Takes the theme and the expanded state of tool output that Pi gives when it draws the entry again. */
106
+ restyle(theme: Theme, expanded: boolean): void {
107
+ this.#theme = theme;
108
+ this.#expanded = expanded;
109
+ this.#chat?.setExpanded(expanded);
110
+ this.showThinkingSetting();
111
+ }
112
+
113
+ /** Shows or hides the thinking of the answers, as Pi's `hideThinkingBlock` setting says. */
114
+ showThinkingSetting(): void {
115
+ if (!this.#closed) this.#chat?.setHideThinking(this.#pi.getSettings().hideThinkingBlock ?? false);
116
+ }
117
+
118
+ /** Closes the connection and gives Pi back its editor. The agent keeps running in the host. */
119
+ close(): void {
120
+ if (this.#closed) return;
121
+ this.#closed = true;
122
+ this.models.close();
123
+ this.#mirror.close();
124
+ this.#chat?.stopTimers();
125
+ this.#status.dispose();
126
+ this.#ctx.ui.setWidget(WIDGET, undefined);
127
+ this.#ctx.ui.setEditorComponent(undefined);
128
+ void this.#reporter?.release();
129
+ this.#programStatus?.leave(this.#mirror);
130
+ }
131
+
132
+ /** Shows each change of the mirror, whose status was `previous` before it. */
133
+ #update(previous: MirrorStatus): void {
134
+ const ui = this.#ui;
135
+
136
+ if (ui === undefined || this.#closed) return;
137
+ const { status, view, child } = this.#mirror;
138
+
139
+ if (view !== undefined && child !== undefined) this.#showTranscript(ui, view, child, previous);
140
+ this.#showStatus(ui, view, child);
141
+
142
+ if (status.kind === "failed" && previous.kind !== "failed") this.#showFailure(status.reason);
143
+ this.#reporter?.report(this.#mirror.busy);
144
+ this.#programStatus?.update(this.#mirror);
145
+
146
+ if (child !== undefined) this.#followAgent(child);
147
+ ui.requestRender();
148
+ }
149
+
150
+ /**
151
+ * Shows the transcript under its header, and makes the Herdr reporter at the first connection. It applies `view` only
152
+ * on an open connection.
153
+ */
154
+ #showTranscript(ui: TUI, view: ConversationView, child: ChildInfo, previous: MirrorStatus): void {
155
+ const chat = this.#chat ?? this.#startTranscript(ui, child);
156
+
157
+ if (this.#mirror.status.kind === "open") chat.apply(view);
158
+ // A running bash card keeps its timer until it gets a final result, and no connection gives one now.
159
+ else if (previous.kind === "open") chat.stopTimers();
160
+ }
161
+
162
+ #startTranscript(ui: TUI, first: ChildInfo): ChatView {
163
+ const chat = new ChatView(ui, first.cwd, (name, text) => this.#theme.fg(name, text));
164
+ this.#chat = chat;
165
+ chat.setExpanded(this.#expanded);
166
+ this.showThinkingSetting();
167
+ this.#frame.clear();
168
+ this.#frame.addChild(this.#line("muted", () => this.#header(this.#mirror.child ?? first)));
169
+ this.#frame.addChild(chat.transcript);
170
+ this.#reporter = agentReporter(process.env, childLabel(first), this.#mirror.target);
171
+
172
+ return chat;
173
+ }
174
+
175
+ /** Shows above the editor what the agent does, its queued messages and its usage line. */
176
+ #showStatus(ui: TUI, view: ConversationView | undefined, child: ChildInfo | undefined): void {
177
+ const { status, busy } = this.#mirror;
178
+ const lines: string[] = [];
179
+
180
+ if (view !== undefined && status.kind === "open") {
181
+ for (const { mode, text } of queued(inboxOf(view))) {
182
+ lines.push(this.#theme.fg("dim", `${mode === "steer" ? "Steer" : "Follow-up"}: ${text.split("\n")[0] ?? ""}`));
183
+ }
184
+ }
185
+
186
+ if (view !== undefined && child !== undefined) {
187
+ lines.push(usageLine(this.#theme, this.#ctx.modelRegistry, child, view));
188
+ }
189
+
190
+ const working =
191
+ status.kind === "reconnecting"
192
+ ? "Connecting again to the harness host..."
193
+ : busy && view !== undefined
194
+ ? statusText(liveOf(view)) || "Working..."
195
+ : "";
196
+
197
+ this.#status.show(ui, this.#theme, working, lines);
198
+ }
199
+
200
+ #showFailure(reason: string): void {
201
+ // Without a transcript, the error takes the place of the connecting line.
202
+ if (this.#chat === undefined) this.#frame.clear();
203
+ this.#frame.addChild(this.#line("error", () => reason));
204
+ this.#ctx.ui.notify(`harness: ${reason}`, "error");
205
+ }
206
+
207
+ /** Gives Pi the agent's title, and on an open connection its model and thinking level. */
208
+ #followAgent(child: ChildInfo): void {
209
+ if (this.#mirror.status.kind === "open") this.models.fromAgent(child);
210
+ const title = childTitle(child);
211
+
212
+ if (this.#pi.getSessionName() !== title) this.#pi.setSessionName(title);
213
+ }
214
+
215
+ #notify(cause: unknown): void {
216
+ if (!this.#closed) this.#ctx.ui.notify(`harness: ${errorMessage(cause)}`, "error");
217
+ }
218
+
219
+ #header(child: ChildInfo): string {
220
+ const theme = this.#theme;
221
+
222
+ return [theme.bold(theme.fg("accent", "harness")), theme.fg("text", childTitle(child)), child.cwd].join(" │ ");
223
+ }
224
+
225
+ /** One line in the theme color `name`, colored at each render so that it follows a change of theme. */
226
+ #line(name: ThemeColor, text: () => string): Component {
227
+ return {
228
+ render: (width) => [tui.truncateToWidth(` ${this.#theme.fg(name, text())}`, width)],
229
+ invalidate: () => {},
230
+ };
231
+ }
232
+ }
233
+
234
+ /**
235
+ * The editor of a mirror session. While the agent works, the interrupt key (Esc) aborts its turn and the follow-up key
236
+ * (Alt+Enter) queues the text as a follow-up. Pi's editor handles every other key. After the key that hides thinking
237
+ * (Ctrl+T), which Pi applies only to its own messages, the transcript of the agent follows the setting. After the key
238
+ * that cycles the thinking level (Shift+Tab), the agent gets the level of Pi.
239
+ */
240
+ class MirrorEditor extends coding.CustomEditor {
241
+ readonly #keys: KeybindingsManager;
242
+ readonly #attachment: Attachment;
243
+
244
+ constructor(ui: TUI, theme: EditorTheme, keys: KeybindingsManager, attachment: Attachment) {
245
+ super(ui, theme, keys);
246
+ this.#keys = keys;
247
+ this.#attachment = attachment;
248
+ }
249
+
250
+ handleInput(data: string): void {
251
+ if (this.#attachment.busy && this.#keys.matches(data, "app.interrupt") && !this.isShowingAutocomplete()) {
252
+ this.#attachment.abort(this);
253
+
254
+ return;
255
+ }
256
+
257
+ if (this.#attachment.busy && this.#keys.matches(data, "app.message.followUp")) {
258
+ const text = this.getExpandedText().trim();
259
+
260
+ if (text !== "") {
261
+ this.addToHistory(text);
262
+ this.setText("");
263
+ this.#attachment.send(text, true);
264
+ }
265
+
266
+ return;
267
+ }
268
+
269
+ super.handleInput(data);
270
+
271
+ if (this.#keys.matches(data, "app.thinking.toggle")) this.#attachment.showThinkingSetting();
272
+
273
+ if (this.#keys.matches(data, "app.thinking.cycle")) this.#attachment.models.piCycled();
274
+ }
275
+ }
276
+
277
+ /** The status of the agent above the editor: Pi's working indicator while it works, then some lines. */
278
+ class StatusArea extends tui.Container {
279
+ #loader: tui.Loader | undefined;
280
+
281
+ /** Shows `status` with a spinner, or nothing when it is empty, then each of `lines`, cut to the width. */
282
+ show(ui: TUI, theme: Theme, status: string, lines: readonly string[]): void {
283
+ if (status === "") {
284
+ this.dispose();
285
+ } else if (this.#loader === undefined) {
286
+ const spinner = (text: string) => theme.fg("accent", text);
287
+ this.#loader = new tui.Loader(ui, spinner, (text) => theme.fg("muted", text), status);
288
+ } else {
289
+ this.#loader.setMessage(status);
290
+ }
291
+
292
+ this.clear();
293
+
294
+ if (this.#loader !== undefined) this.addChild(this.#loader);
295
+
296
+ for (const line of lines) this.addChild(new tui.TruncatedText(line, 1, 0));
297
+ }
298
+
299
+ dispose(): void {
300
+ this.#loader?.stop();
301
+ this.#loader = undefined;
302
+ }
303
+ }
package/pi/chat.ts ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * A durable conversation drawn with Pi's own message and tool components.
3
+ *
4
+ * Adapted from `ChatView` in mavam/pi-agents, src/ui/attach.ts. MIT License, Copyright (c) 2026 Matthias Vallentin.
5
+ */
6
+ import type { AssistantMessage, JsonObject, ToolResultMessage } from "@earendil-works/pi-ai";
7
+ import {
8
+ AssistantMessageComponent,
9
+ createBashToolDefinition,
10
+ createEditToolDefinition,
11
+ createFindToolDefinition,
12
+ createGrepToolDefinition,
13
+ createLsToolDefinition,
14
+ createReadToolDefinition,
15
+ createWriteToolDefinition,
16
+ getMarkdownTheme,
17
+ type ThemeColor,
18
+ type ToolDefinition,
19
+ ToolExecutionComponent,
20
+ UserMessageComponent,
21
+ } from "@earendil-works/pi-coding-agent";
22
+ import type { ConversationView, EntryRecord } from "@earendil-works/pi-durable";
23
+ import { Container, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
24
+ import { liveOf, userText } from "../dist/view-state.js";
25
+
26
+ // Pi's tool definitions differ in their schemas.
27
+ type AnyDefinition = ToolDefinition<any, any>;
28
+
29
+ /** Pi's renderers of the tools that children have under the same names. */
30
+ const RENDERERS = new Map<string, (cwd: string) => AnyDefinition>([
31
+ ["read", createReadToolDefinition],
32
+ ["bash", createBashToolDefinition],
33
+ ["edit", createEditToolDefinition],
34
+ ["write", createWriteToolDefinition],
35
+ ["grep", createGrepToolDefinition],
36
+ ["find", createFindToolDefinition],
37
+ ["ls", createLsToolDefinition],
38
+ ]);
39
+
40
+ const DIAGNOSTICS = /^<harness>\n([\s\S]*)\n<\/harness>$/;
41
+
42
+ /**
43
+ * A tool result as Pi shows its own. pi-durable adds the diagnostics of a call for the model as a `<harness>` block, such
44
+ * as `Command exited with code 3`, and the card shows them as plain lines.
45
+ */
46
+ export function displayedResult(result: ToolResultMessage): ToolResultMessage {
47
+ let changed = false;
48
+
49
+ const content = result.content.map((block) => {
50
+ const match = block.type === "text" ? DIAGNOSTICS.exec(block.text) : null;
51
+
52
+ if (!match) return block;
53
+ changed = true;
54
+ const lines = (match[1] ?? "").split("\n").map((line) => line.replace(/^\[(error|warning|info)\] /, ""));
55
+
56
+ return { type: "text" as const, text: `\n${lines.join("\n")}` };
57
+ });
58
+
59
+ return changed ? { ...result, content } : result;
60
+ }
61
+
62
+ /** The transcript of one conversation view, updated in place as the view changes. */
63
+ export class ChatView {
64
+ readonly transcript = new Container();
65
+ /** The newest card for each call ID: providers can use a call ID again in a later turn. */
66
+ private readonly tools = new Map<string, ToolExecutionComponent>();
67
+ /** Every card shown, also the older ones whose call ID a later turn used again. */
68
+ private readonly cards: ToolExecutionComponent[] = [];
69
+ /** The cards that have no final result yet. */
70
+ private readonly running = new Set<ToolExecutionComponent>();
71
+ /** The call IDs whose cards the streamed answer made; its entry takes them over. */
72
+ private readonly streamingCalls = new Set<string>();
73
+ private readonly renderers = new Map<string, AnyDefinition>();
74
+ private renderedEntryIds: EntryRecord["id"][] = [];
75
+ private streaming: AssistantMessageComponent | undefined;
76
+ /** Ctrl+O shows tool output in full, as in Pi. */
77
+ private expanded = false;
78
+ /** Pi's `hideThinkingBlock` setting: the answers show a label in place of their thinking. */
79
+ private hideThinking = false;
80
+ private readonly ui: TUI;
81
+ private readonly cwd: string;
82
+ /** Colors the lines that the transcript adds itself, such as `[compaction]`, in Pi's current theme. */
83
+ private readonly color: (name: ThemeColor, text: string) => string;
84
+
85
+ constructor(ui: TUI, cwd: string, color: (name: ThemeColor, text: string) => string) {
86
+ this.ui = ui;
87
+ this.cwd = cwd;
88
+ this.color = color;
89
+ }
90
+
91
+ apply(view: ConversationView): void {
92
+ const live = liveOf(view);
93
+ this.syncTranscript(view.entries);
94
+ const message = live.generation?.message;
95
+
96
+ // A streamed answer without its entry was dropped, for example by a retry.
97
+ if (message === undefined && this.streaming !== undefined) this.rebuild(view.entries);
98
+
99
+ if (message !== undefined) this.syncStreaming(message);
100
+
101
+ for (const slot of live.tools ?? []) {
102
+ if (slot.status === "pending") continue;
103
+ const component = this.card(slot.name, slot.callId);
104
+ component.setArgsComplete();
105
+
106
+ if (slot.status !== "running") continue;
107
+ component.markExecutionStarted();
108
+
109
+ if (slot.output !== undefined || slot.details !== undefined) {
110
+ component.updateResult(
111
+ { content: [{ type: "text", text: slot.output ?? "" }], details: slot.details, isError: false },
112
+ true,
113
+ );
114
+ }
115
+ }
116
+
117
+ this.transcript.invalidate();
118
+ }
119
+
120
+ /** Shows tool output in full when `expanded` is true, else collapsed. */
121
+ setExpanded(expanded: boolean): void {
122
+ this.expanded = expanded;
123
+
124
+ for (const card of this.cards) card.setExpanded(expanded);
125
+ this.transcript.invalidate();
126
+ }
127
+
128
+ /** Hides the thinking of the answers when `hide` is true, else shows it. */
129
+ setHideThinking(hide: boolean): void {
130
+ this.hideThinking = hide;
131
+
132
+ for (const child of this.transcript.children) {
133
+ if (child instanceof AssistantMessageComponent) child.setHideThinkingBlock(hide);
134
+ }
135
+
136
+ this.transcript.invalidate();
137
+ }
138
+
139
+ /** Finishes the running cards: a running bash card keeps a timer until it gets a final result. */
140
+ stopTimers(): void {
141
+ for (const component of this.running) component.updateResult({ content: [], isError: false }, false);
142
+ this.running.clear();
143
+ }
144
+
145
+ private syncTranscript(entries: readonly EntryRecord[]): void {
146
+ // A compaction or a reset replaces the head of the active transcript.
147
+ if (this.renderedEntryIds.some((id, index) => entries[index]?.id !== id)) this.rebuild(entries);
148
+
149
+ for (const entry of entries.slice(this.renderedEntryIds.length)) {
150
+ this.addEntry(entry);
151
+ this.renderedEntryIds.push(entry.id);
152
+ }
153
+ }
154
+
155
+ private rebuild(entries: readonly EntryRecord[]): void {
156
+ this.stopTimers();
157
+ this.transcript.clear();
158
+ this.cards.length = 0;
159
+ this.tools.clear();
160
+ this.streamingCalls.clear();
161
+ this.renderedEntryIds = [];
162
+ this.streaming = undefined;
163
+
164
+ for (const entry of entries) {
165
+ this.addEntry(entry);
166
+ this.renderedEntryIds.push(entry.id);
167
+ }
168
+ }
169
+
170
+ private addEntry(entry: EntryRecord): void {
171
+ const message = entry.model?.[0];
172
+
173
+ if (entry.kind === "pi.user" && message?.role === "user") {
174
+ this.transcript.addChild(new Spacer(1));
175
+ this.transcript.addChild(new UserMessageComponent(userText(message.content), getMarkdownTheme()));
176
+ } else if (entry.kind === "pi.assistant" && message?.role === "assistant") {
177
+ const component =
178
+ this.streaming ?? new AssistantMessageComponent(undefined, this.hideThinking, getMarkdownTheme());
179
+
180
+ if (this.streaming === undefined) this.transcript.addChild(component);
181
+ this.streaming = undefined;
182
+ component.updateContent(message, false);
183
+ // Only an answer that asks for tools runs its calls; an aborted, failed or cut answer never does.
184
+ const ran = message.stopReason === "toolUse";
185
+
186
+ for (const content of message.content) {
187
+ if (content.type !== "toolCall") continue;
188
+ const streamed = this.streamingCalls.has(content.id);
189
+
190
+ if (!ran && !streamed) continue;
191
+
192
+ const card = streamed
193
+ ? this.card(content.name, content.id, content.arguments)
194
+ : this.newCard(content.name, content.id, content.arguments);
195
+
196
+ card.setArgsComplete();
197
+
198
+ if (!ran) {
199
+ const text = "Not run: the answer was interrupted.";
200
+ card.updateResult({ content: [{ type: "text", text }], isError: true }, false);
201
+ this.running.delete(card);
202
+ }
203
+ }
204
+
205
+ this.streamingCalls.clear();
206
+ } else if (entry.kind === "pi.tool-result" && message?.role === "toolResult") {
207
+ const card = this.card(message.toolName, message.toolCallId);
208
+ card.updateResult(displayedResult(message));
209
+ this.running.delete(card);
210
+ } else if (entry.kind === "pi.compaction") {
211
+ this.addText(this.color("muted", "[compaction]"));
212
+
213
+ if (message?.role === "user") this.addText(userText(message.content));
214
+ } else if (entry.kind === "pi.reset") {
215
+ this.addText(this.color("muted", "[new context]"));
216
+ }
217
+ }
218
+
219
+ private syncStreaming(message: AssistantMessage): void {
220
+ if (this.streaming === undefined) {
221
+ this.streaming = new AssistantMessageComponent(undefined, this.hideThinking, getMarkdownTheme());
222
+ this.transcript.addChild(this.streaming);
223
+ }
224
+
225
+ this.streaming.updateContent(message, true);
226
+
227
+ for (const content of message.content) {
228
+ if (content.type !== "toolCall") continue;
229
+
230
+ if (this.streamingCalls.has(content.id)) this.card(content.name, content.id, content.arguments);
231
+ else this.newCard(content.name, content.id, content.arguments);
232
+ this.streamingCalls.add(content.id);
233
+ }
234
+ }
235
+
236
+ private renderer(toolName: string): AnyDefinition | undefined {
237
+ let definition = this.renderers.get(toolName);
238
+
239
+ if (definition === undefined) {
240
+ const create = RENDERERS.get(toolName);
241
+
242
+ if (create === undefined) return undefined;
243
+ definition = create(this.cwd);
244
+ this.renderers.set(toolName, definition);
245
+ }
246
+
247
+ return definition;
248
+ }
249
+
250
+ /** The newest card of a call, or a new card when the call has none. */
251
+ private card(toolName: string, toolCallId: string, args?: JsonObject): ToolExecutionComponent {
252
+ const existing = this.tools.get(toolCallId);
253
+
254
+ if (existing === undefined) return this.newCard(toolName, toolCallId, args ?? {});
255
+
256
+ if (args !== undefined) existing.updateArgs(args);
257
+
258
+ return existing;
259
+ }
260
+
261
+ /** A new card of a call, also for a call ID that an earlier turn used. */
262
+ private newCard(toolName: string, toolCallId: string, args: JsonObject): ToolExecutionComponent {
263
+ const component = new ToolExecutionComponent(
264
+ toolName,
265
+ toolCallId,
266
+ args,
267
+ {},
268
+ this.renderer(toolName),
269
+ this.ui,
270
+ this.cwd,
271
+ );
272
+
273
+ component.setExpanded(this.expanded);
274
+ this.transcript.addChild(component);
275
+ this.cards.push(component);
276
+ this.running.add(component);
277
+ this.tools.set(toolCallId, component);
278
+
279
+ return component;
280
+ }
281
+
282
+ private addText(text: string): void {
283
+ this.transcript.addChild(new Spacer(1));
284
+ this.transcript.addChild(new Text(text, 1, 0));
285
+ }
286
+ }
package/pi/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The `subagent` tool, `/subagent` and the subagents widget. They use the client of the harness package that holds
3
- * this folder, from its built `dist/`, and start the host of that package.
2
+ * The `subagent` tool, `/subagent`, the subagents widget and the mirror sessions of `/harness`. They use the client of
3
+ * the harness package that holds this folder, from its built `dist/`, and start the host of that package.
4
4
  */
5
5
  import { resolve } from "node:path";
6
6
  import { Type } from "@earendil-works/pi-ai";
@@ -13,14 +13,16 @@ import {
13
13
  SettingsManager,
14
14
  } from "@earendil-works/pi-coding-agent";
15
15
  import { request } from "../dist/client.js";
16
- import { openViewerTab } from "../dist/herdr.js";
17
- import { childLine, runDisplayName } from "../dist/protocol.js";
16
+ import { inHerdr, openChildTab } from "../dist/herdr.js";
17
+ import { childLine, errorMessage, runDisplayName } from "../dist/protocol.js";
18
18
  import * as tool from "../dist/tool.js";
19
+ import { registerMirror } from "./mirror.ts";
19
20
 
20
21
  const SESSION_TRUST_HINT =
21
22
  "The child gets no project skills (.pi/skills, .agents/skills): this project is trusted for this Pi session only, and children see only the trust saved for future sessions. To give children project skills, run /trust and choose Trust.";
22
23
 
23
24
  export default function subagentExtension(pi: ExtensionAPI) {
25
+ registerMirror(pi);
24
26
  const { parameters, output } = tool.toolSchemas(Type);
25
27
  pi.registerTool({
26
28
  name: "subagent",
@@ -55,7 +57,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
55
57
  });
56
58
 
57
59
  pi.registerCommand("subagent", {
58
- description: "Open the viewer of a subagent spawned by this session",
60
+ description: "Open a subagent spawned by this session in Pi, in a new Herdr tab",
59
61
  handler: async (_args, ctx) => {
60
62
  const children = await request({ type: "list", caller: ctx.sessionManager.getSessionId() }, { start: true });
61
63
 
@@ -78,16 +80,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
78
80
  if (child === undefined) return;
79
81
  const command = `harness attach ${child.handle}`;
80
82
 
81
- if (!process.env.HERDR_ENV) {
83
+ if (!inHerdr(process.env)) {
82
84
  ctx.ui.notify(`Run in another terminal: ${command}`, "info");
83
85
 
84
86
  return;
85
87
  }
86
88
 
87
89
  try {
88
- await openViewerTab(child, process.env);
90
+ await openChildTab(child, process.env);
89
91
  } catch (error) {
90
- const message = error instanceof Error ? error.message : String(error);
92
+ const message = errorMessage(error);
91
93
  ctx.ui.notify(`Could not open a Herdr tab: ${message}. Run in another terminal: ${command}`, "error");
92
94
  }
93
95
  },