@vzlentin/harness 0.2.3 → 0.3.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/README.md +10 -7
- package/dist/answer.js +2 -2
- package/dist/cli.js +29 -14
- package/dist/client.d.ts +7 -3
- package/dist/client.js +9 -5
- package/dist/context.js +2 -1
- package/dist/herdr.d.ts +11 -23
- package/dist/herdr.js +30 -28
- package/dist/host.d.ts +1 -1
- package/dist/host.js +28 -111
- package/dist/load-entries.d.ts +18 -0
- package/dist/load-entries.js +91 -0
- package/dist/mirror.d.ts +80 -0
- package/dist/mirror.js +263 -0
- package/dist/protocol.d.ts +18 -27
- package/dist/protocol.js +9 -2
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +3 -2
- package/dist/tool.js +1 -1
- package/dist/view-change.d.ts +38 -0
- package/dist/view-change.js +55 -0
- package/dist/view-state.d.ts +22 -6
- package/dist/view-state.js +45 -7
- package/package.json +2 -1
- package/pi/attachment.ts +296 -0
- package/pi/chat.ts +286 -0
- package/pi/index.ts +10 -8
- package/pi/mirror.ts +206 -0
- package/pi/model-sync.ts +102 -0
- package/pi/package.json +3 -2
- package/pi/usage-line.ts +59 -0
- package/dist/chat.d.ts +0 -48
- package/dist/chat.js +0 -208
- package/dist/chrome.d.ts +0 -18
- package/dist/chrome.js +0 -74
- package/dist/picker.d.ts +0 -22
- package/dist/picker.js +0 -70
- package/dist/theme.d.ts +0 -3
- package/dist/theme.js +0 -19
- package/dist/viewer.d.ts +0 -20
- package/dist/viewer.js +0 -307
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
90
|
+
await openChildTab(child, process.env);
|
|
89
91
|
} catch (error) {
|
|
90
|
-
const message =
|
|
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
|
},
|
package/pi/mirror.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror sessions: a Pi session that shows a child or the agent of a harness project in Pi's own chat, and sends what
|
|
3
|
+
* the user types to it. The agent runs in the host of the project; the session keeps only its target, in one custom
|
|
4
|
+
* entry. `/harness [project]` opens one in a new session, and `pi --harness <project> [--harness-child <handle>]` in the
|
|
5
|
+
* session that Pi starts with.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import type {
|
|
10
|
+
ExtensionAPI,
|
|
11
|
+
ExtensionCommandContext,
|
|
12
|
+
ExtensionContext,
|
|
13
|
+
SessionEntry,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
16
|
+
import * as tui from "@earendil-works/pi-tui";
|
|
17
|
+
import { isMirrorTarget, MIRROR_ENTRY, type MirrorTarget, PI_FLAGS } from "../dist/mirror.js";
|
|
18
|
+
import { checkProject, MAIN_PROJECT, projectDir, readProjects, resolveProject } from "../dist/projects.js";
|
|
19
|
+
import { AGENT_HANDLE, errorMessage } from "../dist/protocol.js";
|
|
20
|
+
import { Attachment } from "./attachment.ts";
|
|
21
|
+
|
|
22
|
+
/** The mirror entry of a session: its ID and its target. */
|
|
23
|
+
type MirrorEntry = { readonly id: string; readonly target: MirrorTarget };
|
|
24
|
+
|
|
25
|
+
/** Registers `/harness`, `--harness` and the handlers that turn a session with a mirror entry into a mirror session. */
|
|
26
|
+
export function registerMirror(pi: ExtensionAPI): void {
|
|
27
|
+
// The frame of each mirror entry in Pi's chat. Pi can draw the entry before or after `session_start`.
|
|
28
|
+
const frames = new Map<string, tui.Container>();
|
|
29
|
+
let attached: Attachment | undefined;
|
|
30
|
+
|
|
31
|
+
const frameOf = (entryId: string): tui.Container => {
|
|
32
|
+
const frame = frames.get(entryId) ?? new tui.Container();
|
|
33
|
+
frames.set(entryId, frame);
|
|
34
|
+
|
|
35
|
+
return frame;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
pi.registerFlag(PI_FLAGS.project, {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "Show the agent of this harness project (main or a project name) in the new session",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
pi.registerFlag(PI_FLAGS.child, {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "Show this subagent of the harness project in the new session, in place of the project agent",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
pi.registerEntryRenderer(MIRROR_ENTRY, (entry, options, theme) => {
|
|
49
|
+
if (attached?.entryId === entry.id) attached.restyle(theme, options.expanded);
|
|
50
|
+
|
|
51
|
+
return frameOf(entry.id);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
pi.registerCommand("harness", {
|
|
55
|
+
description: "Show the agent of a harness project in a new session: /harness [project]",
|
|
56
|
+
getArgumentCompletions: projectItems,
|
|
57
|
+
handler: async (args, ctx) => {
|
|
58
|
+
let project: string | undefined;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
project = args.trim() === "" ? await pickProject(ctx) : checkProject(args.trim());
|
|
62
|
+
} catch (error) {
|
|
63
|
+
ctx.ui.notify(`harness: ${errorMessage(error)}`, "error");
|
|
64
|
+
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (project === undefined) return;
|
|
69
|
+
const target: MirrorTarget = { project, handle: AGENT_HANDLE };
|
|
70
|
+
await ctx.newSession({
|
|
71
|
+
setup: async (sessionManager) => {
|
|
72
|
+
sessionManager.appendCustomEntry(MIRROR_ENTRY, target);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
pi.on("session_start", (event, ctx) => {
|
|
79
|
+
if (ctx.mode !== "tui") return;
|
|
80
|
+
const branch = ctx.sessionManager.getBranch();
|
|
81
|
+
|
|
82
|
+
// Pi draws only the entries of this branch from now on. A /reload keeps the session, and its frames.
|
|
83
|
+
for (const id of frames.keys()) {
|
|
84
|
+
if (!branch.some((entry) => entry.id === id)) frames.delete(id);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const entry = mirrorEntry(branch) ?? (event.reason === "startup" ? flagEntry(pi, ctx) : undefined);
|
|
88
|
+
|
|
89
|
+
if (entry !== undefined) attached = new Attachment(pi, ctx, entry.id, entry.target, frameOf(entry.id));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
pi.on("session_shutdown", () => {
|
|
93
|
+
attached?.close();
|
|
94
|
+
attached = undefined;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// The agent of the host answers in a mirror session, so the input goes there and not to Pi's own agent.
|
|
98
|
+
pi.on("input", (event, ctx) => {
|
|
99
|
+
if (attached === undefined) return undefined;
|
|
100
|
+
|
|
101
|
+
if (event.images !== undefined && event.images.length > 0) {
|
|
102
|
+
ctx.ui.notify("harness: the images are not sent; the agent gets only the text", "warning");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
attached.send(event.text, false);
|
|
106
|
+
|
|
107
|
+
return { action: "handled" };
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// A restored model is the one of the Pi session, not a choice of the user.
|
|
111
|
+
pi.on("model_select", (event) => {
|
|
112
|
+
if (event.source !== "restore") attached?.models.piSelected(event.model.provider, event.model.id);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The newest mirror entry of `entries` with a valid target. */
|
|
117
|
+
function mirrorEntry(entries: readonly SessionEntry[]): MirrorEntry | undefined {
|
|
118
|
+
for (const entry of entries.toReversed()) {
|
|
119
|
+
if (entry.type === "custom" && entry.customType === MIRROR_ENTRY && isMirrorTarget(entry.data)) {
|
|
120
|
+
return { id: entry.id, target: entry.data };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The mirror entry for `--harness <project>` and `--harness-child <handle>`, written in the session that Pi starts
|
|
129
|
+
* with. Without a project, it is the project of `HARNESS_PROJECT` and Pi's working directory; without a child, the
|
|
130
|
+
* project agent. It refuses a session with messages, such as one of `--continue`, because Pi's own agent answered there.
|
|
131
|
+
*/
|
|
132
|
+
function flagEntry(pi: ExtensionAPI, ctx: ExtensionContext): MirrorEntry | undefined {
|
|
133
|
+
const project = stringFlag(pi, PI_FLAGS.project);
|
|
134
|
+
const child = stringFlag(pi, PI_FLAGS.child);
|
|
135
|
+
|
|
136
|
+
if (project === undefined && child === undefined) return undefined;
|
|
137
|
+
|
|
138
|
+
if (ctx.sessionManager.getBranch().some((entry) => entry.type === "message")) {
|
|
139
|
+
ctx.ui.notify("harness: --harness needs a new session, not one with messages", "error");
|
|
140
|
+
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let target: MirrorTarget;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
target = {
|
|
148
|
+
project: project ? checkProject(project) : resolveProject(process.env, ctx.cwd),
|
|
149
|
+
handle: child || AGENT_HANDLE,
|
|
150
|
+
};
|
|
151
|
+
} catch (error) {
|
|
152
|
+
ctx.ui.notify(`harness: ${errorMessage(error)}`, "error");
|
|
153
|
+
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// `appendEntry` gives no entry ID, so the entry comes back from the branch.
|
|
158
|
+
pi.appendEntry(MIRROR_ENTRY, target);
|
|
159
|
+
|
|
160
|
+
return mirrorEntry(ctx.sessionManager.getBranch());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The value of the string flag `name`, or undefined when it is not given. */
|
|
164
|
+
function stringFlag(pi: ExtensionAPI, name: string): string | undefined {
|
|
165
|
+
const flag = pi.getFlag(name);
|
|
166
|
+
|
|
167
|
+
return flag === undefined || flag === true || flag === false ? undefined : flag;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The project that the user selects for `/harness` without a project: the project of Pi's working directory first,
|
|
172
|
+
* then the other projects and `main`. It is undefined when the user cancels.
|
|
173
|
+
*/
|
|
174
|
+
async function pickProject(ctx: ExtensionCommandContext): Promise<string | undefined> {
|
|
175
|
+
const projects = readProjects();
|
|
176
|
+
const current = resolveProject(process.env, ctx.cwd, projects);
|
|
177
|
+
const names = [current, ...[...Object.keys(projects), MAIN_PROJECT].filter((name) => name !== current)];
|
|
178
|
+
|
|
179
|
+
if (names.length === 1) return current;
|
|
180
|
+
const labels = names.map((name) => `${name} ${tildePath(projectDir(name, projects))}`);
|
|
181
|
+
const label = await ctx.ui.select("Harness project", labels);
|
|
182
|
+
|
|
183
|
+
return label === undefined ? undefined : names[labels.indexOf(label)];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `path` with the home directory as `~`. */
|
|
187
|
+
function tildePath(path: string): string {
|
|
188
|
+
const home = homedir();
|
|
189
|
+
|
|
190
|
+
return home !== "" && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The projects whose names start with `prefix`, for the argument of `/harness`. */
|
|
194
|
+
function projectItems(prefix: string): AutocompleteItem[] | null {
|
|
195
|
+
let names: string[];
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
names = [...Object.keys(readProjects()), MAIN_PROJECT];
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const items = names.flatMap((name) => (name.startsWith(prefix) ? [{ value: name, label: name }] : []));
|
|
204
|
+
|
|
205
|
+
return items.length > 0 ? items : null;
|
|
206
|
+
}
|
package/pi/model-sync.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model and the thinking level of a mirror session. The agent in the host is the truth: the Pi session shows the
|
|
3
|
+
* agent's model and level, so Pi's footer and `/model` show them, and a change that the user makes in Pi is a request
|
|
4
|
+
* to the host. When the host refuses it, Pi goes back to the agent's model and level.
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { Mirror, MirrorConfigure } from "../dist/mirror.js";
|
|
8
|
+
import type { ChildInfo } from "../dist/protocol.js";
|
|
9
|
+
|
|
10
|
+
/** What the sync uses of Pi's extension API. */
|
|
11
|
+
export type PiModels = Pick<ExtensionAPI, "setModel" | "getThinkingLevel" | "setThinkingLevel">;
|
|
12
|
+
|
|
13
|
+
/** What the sync uses of the context of the Pi session. */
|
|
14
|
+
export type PiSession = Pick<ExtensionContext, "model"> & {
|
|
15
|
+
readonly modelRegistry: Pick<ExtensionContext["modelRegistry"], "find">;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** What the sync uses of the mirror: the agent, and the requests that change it. */
|
|
19
|
+
export type MirroredAgent = Pick<Mirror, "child" | "configure">;
|
|
20
|
+
|
|
21
|
+
export class ModelSync {
|
|
22
|
+
// After a session switch, `#pi` and `#ctx` are stale and throw, so nothing uses them after `close()`.
|
|
23
|
+
readonly #pi: PiModels;
|
|
24
|
+
readonly #ctx: PiSession;
|
|
25
|
+
readonly #mirror: MirroredAgent;
|
|
26
|
+
readonly #notify: (cause: unknown) => void;
|
|
27
|
+
/** True while Pi sets the agent's model: `setModel` is async, and the agent changes often while it works. */
|
|
28
|
+
#settingModel = false;
|
|
29
|
+
#closed = false;
|
|
30
|
+
|
|
31
|
+
constructor(pi: PiModels, ctx: PiSession, mirror: MirroredAgent, notify: (cause: unknown) => void) {
|
|
32
|
+
this.#pi = pi;
|
|
33
|
+
this.#ctx = ctx;
|
|
34
|
+
this.#mirror = mirror;
|
|
35
|
+
this.#notify = notify;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Gives the Pi session the model and the thinking level of `child`, the agent as the host gives it. */
|
|
39
|
+
fromAgent(child: ChildInfo): void {
|
|
40
|
+
if (this.#closed) return;
|
|
41
|
+
const model = this.#ctx.modelRegistry.find(child.provider, child.model);
|
|
42
|
+
|
|
43
|
+
if (model !== undefined && !this.#piHasModel(child) && !this.#settingModel) {
|
|
44
|
+
this.#settingModel = true;
|
|
45
|
+
// Pi sets its own thinking level when it takes a model, so the agent's level follows once Pi has the model.
|
|
46
|
+
void this.#pi.setModel(model).finally(() => {
|
|
47
|
+
this.#settingModel = false;
|
|
48
|
+
const current = this.#mirror.child;
|
|
49
|
+
|
|
50
|
+
if (current !== undefined) this.#thinkingFromAgent(current);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.#thinkingFromAgent(child);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Asks the host for the model that the user selected in Pi. */
|
|
58
|
+
piSelected(provider: string, model: string): void {
|
|
59
|
+
const child = this.#mirror.child;
|
|
60
|
+
|
|
61
|
+
if (child === undefined || (child.provider === provider && child.model === model)) return;
|
|
62
|
+
this.#configure({ provider, model });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Asks the host for the thinking level that the user cycled to in Pi, once Pi has the agent's model. Pi also changes
|
|
67
|
+
* its level when it takes a model, so only the cycle key calls this, not each change of Pi's level.
|
|
68
|
+
*/
|
|
69
|
+
piCycled(): void {
|
|
70
|
+
const child = this.#mirror.child;
|
|
71
|
+
|
|
72
|
+
if (this.#closed || child === undefined || !this.#piHasModel(child)) return;
|
|
73
|
+
const thinking = this.#pi.getThinkingLevel();
|
|
74
|
+
|
|
75
|
+
if (child.thinking !== thinking) this.#configure({ thinking });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
close(): void {
|
|
79
|
+
this.#closed = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#configure(change: MirrorConfigure): void {
|
|
83
|
+
this.#mirror.configure(change).catch((cause: unknown) => {
|
|
84
|
+
this.#notify(cause);
|
|
85
|
+
const child = this.#mirror.child;
|
|
86
|
+
|
|
87
|
+
if (child !== undefined) this.fromAgent(child);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#thinkingFromAgent(child: ChildInfo): void {
|
|
92
|
+
if (!this.#closed && this.#piHasModel(child) && this.#pi.getThinkingLevel() !== child.thinking) {
|
|
93
|
+
this.#pi.setThinkingLevel(child.thinking);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#piHasModel(child: ChildInfo): boolean {
|
|
98
|
+
const model = this.#ctx.model;
|
|
99
|
+
|
|
100
|
+
return model?.provider === child.provider && model.id === child.model;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/pi/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"private": true,
|
|
3
|
-
"description": "The Pi extension of harness: the subagent tool, the /subagent command and the
|
|
3
|
+
"description": "The Pi extension of harness: the subagent tool, the /subagent command, the subagents widget and the /harness mirror sessions",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"pi": {
|
|
6
6
|
"extensions": [
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
11
|
"@earendil-works/pi-ai": "*",
|
|
12
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
12
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
13
|
+
"@earendil-works/pi-tui": "*"
|
|
13
14
|
}
|
|
14
15
|
}
|