@vzlentin/harness 0.3.0 → 0.4.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/pi/chat.ts DELETED
@@ -1,286 +0,0 @@
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 DELETED
@@ -1,170 +0,0 @@
1
- /**
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
- */
5
- import { resolve } from "node:path";
6
- import { Type } from "@earendil-works/pi-ai";
7
- import {
8
- type ExtensionAPI,
9
- type ExtensionContext,
10
- getAgentDir,
11
- hasTrustRequiringProjectResources,
12
- ProjectTrustStore,
13
- SettingsManager,
14
- } from "@earendil-works/pi-coding-agent";
15
- import { request } from "../dist/client.js";
16
- import { inHerdr, openChildTab } from "../dist/herdr.js";
17
- import { childLine, errorMessage, runDisplayName } from "../dist/protocol.js";
18
- import * as tool from "../dist/tool.js";
19
- import { registerMirror } from "./mirror.ts";
20
-
21
- const SESSION_TRUST_HINT =
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.";
23
-
24
- export default function subagentExtension(pi: ExtensionAPI) {
25
- registerMirror(pi);
26
- const { parameters, output } = tool.toolSchemas(Type);
27
- pi.registerTool({
28
- name: "subagent",
29
- label: "Subagent",
30
- description: tool.TOOL_DESCRIPTION,
31
- parameters,
32
- outputSchema: output,
33
- executionMode: "sequential",
34
- async execute(toolCallId, args, signal, _onUpdate, ctx) {
35
- const { text, data } = await tool.runTool(args, {
36
- caller: ctx.sessionManager.getSessionId(),
37
- callKey: toolCallId,
38
- provider: ctx.model?.provider,
39
- model: ctx.model?.id,
40
- thinking: pi.getThinkingLevel(),
41
- cwd: ctx.cwd,
42
- send: tool.sendToHost,
43
- signal,
44
- });
45
-
46
- // Children follow only the saved trust, so a session that trusts its project for itself says what the child misses.
47
- const sessionOnly =
48
- args.action === "spawn" &&
49
- ctx.isProjectTrusted() &&
50
- !savedTrust(ctx.cwd) &&
51
- !savedTrust(resolve(ctx.cwd, args.cwd ?? "."));
52
-
53
- const hint = sessionOnly ? `\n${SESSION_TRUST_HINT}\n` : "";
54
-
55
- return { content: [{ type: "text", text: text + hint }], details: undefined, structuredContent: data };
56
- },
57
- });
58
-
59
- pi.registerCommand("subagent", {
60
- description: "Open a subagent spawned by this session in Pi, in a new Herdr tab",
61
- handler: async (_args, ctx) => {
62
- const children = await request({ type: "list", caller: ctx.sessionManager.getSessionId() }, { start: true });
63
-
64
- if (children.length === 0) {
65
- ctx.ui.notify("No subagents spawned by this session", "info");
66
-
67
- return;
68
- }
69
-
70
- const chosen = await ctx.ui.select("Attach to subagent", children.map(childLine));
71
-
72
- // A name can start with the display name of another child, so the longest match is the chosen child.
73
- const child = children
74
- .filter((c: { handle: string; name?: string }) => chosen?.startsWith(`${runDisplayName(c)} `))
75
- .sort(
76
- (a: { handle: string; name?: string }, b: { handle: string; name?: string }) =>
77
- runDisplayName(b).length - runDisplayName(a).length,
78
- )[0];
79
-
80
- if (child === undefined) return;
81
- const command = `harness attach ${child.handle}`;
82
-
83
- if (!inHerdr(process.env)) {
84
- ctx.ui.notify(`Run in another terminal: ${command}`, "info");
85
-
86
- return;
87
- }
88
-
89
- try {
90
- await openChildTab(child, process.env);
91
- } catch (error) {
92
- const message = errorMessage(error);
93
- ctx.ui.notify(`Could not open a Herdr tab: ${message}. Run in another terminal: ${command}`, "error");
94
- }
95
- },
96
- });
97
-
98
- let widgetTimer: ReturnType<typeof setInterval> | undefined;
99
- let widgetContext: ExtensionContext | undefined;
100
- let refreshing = false;
101
-
102
- // The widget does not start the host: when none runs, it shows nothing.
103
- const refreshWidget = async (): Promise<void> => {
104
- if (!widgetContext || refreshing) return;
105
- refreshing = true;
106
-
107
- try {
108
- const context = widgetContext;
109
-
110
- const children = await request(
111
- { type: "list", caller: context.sessionManager.getSessionId() },
112
- { start: false },
113
- ).catch(() => undefined);
114
-
115
- if (context !== widgetContext) return;
116
-
117
- if (children === undefined || children.length === 0) {
118
- context.ui.setWidget("subagents", undefined);
119
-
120
- return;
121
- }
122
-
123
- const theme = context.ui.theme;
124
-
125
- const visible = children
126
- .slice(0, 5)
127
- .map(({ name, handle, state }: { name?: string; handle: string; state: string }) => {
128
- const color = state === "busy" ? "warning" : state === "idle" ? "success" : "error";
129
-
130
- return theme.fg(color, `${name ?? handle}:${state}`);
131
- });
132
-
133
- if (children.length > visible.length) visible.push(theme.fg("muted", `+${children.length - visible.length}`));
134
- context.ui.setWidget("subagents", [theme.fg("dim", "subagents: ") + visible.join(theme.fg("dim", " | "))], {
135
- placement: "belowEditor",
136
- });
137
- } finally {
138
- refreshing = false;
139
- }
140
- };
141
-
142
- pi.on("session_start", (_event, ctx) => {
143
- if (!ctx.hasUI) return;
144
- widgetContext = ctx;
145
- void refreshWidget();
146
- widgetTimer = setInterval(() => void refreshWidget(), 1000);
147
- widgetTimer.unref();
148
- });
149
-
150
- pi.on("session_shutdown", (_event, ctx) => {
151
- if (widgetTimer) clearInterval(widgetTimer);
152
- widgetTimer = undefined;
153
- widgetContext = undefined;
154
- ctx.ui.setWidget("subagents", undefined);
155
- });
156
- }
157
-
158
- /**
159
- * Whether Pi trusts the project in `cwd` with no one to ask, as the host does in `src/trust.ts` of harness. It is here
160
- * with Pi's own modules, so Pi does not load the copy of pi-coding-agent that harness installs.
161
- */
162
- function savedTrust(cwd: string): boolean {
163
- if (!hasTrustRequiringProjectResources(cwd)) return true;
164
- const agentDir = getAgentDir();
165
- const decision = new ProjectTrustStore(agentDir).get(cwd);
166
-
167
- if (decision !== null) return decision;
168
-
169
- return SettingsManager.create(cwd, agentDir, { projectTrusted: false }).getDefaultProjectTrust() === "always";
170
- }
package/pi/mirror.ts DELETED
@@ -1,206 +0,0 @@
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
- }