@howarewoo/pi-subagents 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/thread.ts ADDED
@@ -0,0 +1,268 @@
1
+ import {
2
+ AssistantMessageComponent,
3
+ UserMessageComponent,
4
+ ToolExecutionComponent,
5
+ getMarkdownTheme,
6
+ createReadToolDefinition,
7
+ createBashToolDefinition,
8
+ createEditToolDefinition,
9
+ createWriteToolDefinition,
10
+ createGrepToolDefinition,
11
+ createFindToolDefinition,
12
+ createLsToolDefinition,
13
+ type ToolRenderers,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import { Text, wrapTextWithAnsi, type Component, type TUI } from "@earendil-works/pi-tui";
16
+ import type { DelegationCoordinator } from "./coordinator.ts";
17
+
18
+ export function safe(text: string): string {
19
+ return text
20
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
21
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
22
+ .replace(
23
+ /[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g,
24
+ " ",
25
+ )
26
+ .replaceAll("\t", " ");
27
+ }
28
+
29
+ // Inputs are sanitized before native rendering. Only native SGR survives the output boundary,
30
+ // not OSC links/shell zones, image protocols or cursor-control sequences.
31
+ export function nativeText(text: string): string {
32
+ return text
33
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
34
+ .split(/(\x1b\[[0-9;:]*m)/g)
35
+ .map((part, index) => (index % 2 ? part : safe(part)))
36
+ .join("");
37
+ }
38
+ function safeValue(value: unknown): unknown {
39
+ if (typeof value === "string") return safe(value);
40
+ if (Array.isArray(value)) return value.map(safeValue);
41
+ if (value && typeof value === "object")
42
+ return Object.fromEntries(
43
+ Object.entries(value).map(([key, item]) => [safe(key), safeValue(item)]),
44
+ );
45
+ return value;
46
+ }
47
+
48
+ type Messages = ReturnType<DelegationCoordinator["messages"]>;
49
+ export type ThreadDependencies = Readonly<{
50
+ tui: TUI;
51
+ cwd: string;
52
+ toolRenderers?: Readonly<Record<string, ToolRenderers>>;
53
+ }>;
54
+ export type LiveTool = {
55
+ toolCallId: string;
56
+ toolName: string;
57
+ args: unknown;
58
+ content?: readonly { type: string; text?: string; mimeType?: string }[];
59
+ };
60
+ const textContent = (content: readonly { type: string; text?: string; mimeType?: string }[]) =>
61
+ content.flatMap((block) =>
62
+ block.type === "text"
63
+ ? [{ type: "text", text: safe(block.text ?? "") }]
64
+ : block.type === "image"
65
+ ? [{ type: "text", text: `[image ${safe(block.mimeType ?? "unknown")}; payload omitted]` }]
66
+ : [],
67
+ );
68
+
69
+ /** Selected-generation readonly projection through the same public classes as Pi's main chat. */
70
+ export function createNativeThread({ tui, cwd, toolRenderers }: ThreadDependencies) {
71
+ const renderers = new Map<string, ToolRenderers>(
72
+ [
73
+ createReadToolDefinition(cwd),
74
+ createBashToolDefinition(cwd),
75
+ createEditToolDefinition(cwd),
76
+ createWriteToolDefinition(cwd),
77
+ createGrepToolDefinition(cwd),
78
+ createFindToolDefinition(cwd),
79
+ createLsToolDefinition(cwd),
80
+ ].map(({ name, renderShell, renderCall, renderResult }) => [
81
+ name,
82
+ { renderShell, renderCall, renderResult } as ToolRenderers,
83
+ ]),
84
+ );
85
+ for (const [name, renderersForTool] of Object.entries(toolRenderers ?? {}))
86
+ renderers.set(name, renderersForTool);
87
+ const cache = new Map<string, { signature: string; component: Component }>();
88
+ let generation: string | undefined;
89
+ return {
90
+ render(
91
+ ref: string,
92
+ messages: Messages,
93
+ partial: Messages[number] | undefined,
94
+ live: LiveTool | undefined,
95
+ width: number,
96
+ ): string[] {
97
+ if (generation !== ref) {
98
+ cache.clear();
99
+ generation = ref;
100
+ }
101
+ const used = new Set<string>();
102
+ const components: Component[] = [];
103
+ const append = (
104
+ key: string,
105
+ value: unknown,
106
+ build: () => Component,
107
+ update?: (component: Component) => void,
108
+ ) => {
109
+ const signature = JSON.stringify(value);
110
+ let entry = cache.get(key);
111
+ if (!entry || entry.signature !== signature) {
112
+ if (entry && update) {
113
+ update(entry.component);
114
+ entry.signature = signature;
115
+ } else {
116
+ entry = { signature, component: build() };
117
+ cache.set(key, entry);
118
+ }
119
+ }
120
+ used.add(key);
121
+ components.push(entry.component);
122
+ };
123
+ const all = partial ? [...messages, partial] : messages;
124
+ const results = new Map(
125
+ all.flatMap((message) =>
126
+ message.role === "toolResult" ? [[message.toolCallId, message] as const] : [],
127
+ ),
128
+ );
129
+ const toolIds = new Set<string>();
130
+ const tool = (name: string, id: string, args: unknown, failure?: string) => {
131
+ if (toolIds.has(id)) return;
132
+ toolIds.add(id);
133
+ const result = results.get(id);
134
+ const active = live?.toolCallId === id ? live : undefined;
135
+ const content = result
136
+ ? textContent(result.content)
137
+ : active?.content
138
+ ? textContent(active.content)
139
+ : failure
140
+ ? [{ type: "text", text: safe(failure) }]
141
+ : undefined;
142
+ // Only the documented edit diff is renderer input. Never pass arbitrary tool details.
143
+ const details = result?.details;
144
+ const diff =
145
+ name === "edit" &&
146
+ details &&
147
+ typeof details === "object" &&
148
+ "diff" in details &&
149
+ typeof details.diff === "string"
150
+ ? {
151
+ diff: safe(details.diff),
152
+ ...("firstChangedLine" in details && typeof details.firstChangedLine === "number"
153
+ ? { firstChangedLine: details.firstChangedLine }
154
+ : {}),
155
+ }
156
+ : undefined;
157
+ const toolArgs = safeValue(active?.args ?? args);
158
+ const output = content
159
+ ? { content, details: diff, isError: result?.isError ?? !!failure }
160
+ : undefined;
161
+ append("tool:" + id, [name, toolArgs, output, !!active], () => {
162
+ const component = new ToolExecutionComponent(
163
+ safe(name),
164
+ safe(id),
165
+ toolArgs,
166
+ { showImages: false },
167
+ renderers.get(name),
168
+ tui,
169
+ cwd,
170
+ );
171
+ component.setExpanded(true);
172
+ // Native edit's completed-args preview reads cwd files. Recorded result.diff is sufficient.
173
+ if (name !== "edit") component.setArgsComplete();
174
+ // Do not mark execution started: this observer must not invent elapsed clocks/timers.
175
+ if (output) component.updateResult(output, !!active && !result);
176
+ return component;
177
+ });
178
+ };
179
+ all.forEach((message, index) => {
180
+ if (message.role === "system") return;
181
+ if (message.role === "assistant") {
182
+ const content: typeof message.content = message.content.map((block) =>
183
+ block.type === "text"
184
+ ? { type: "text", text: safe(block.text) }
185
+ : block.type === "thinking"
186
+ ? { type: "thinking", thinking: safe(block.thinking) }
187
+ : {
188
+ type: "toolCall",
189
+ id: safe(block.id),
190
+ name: safe(block.name),
191
+ arguments: safeValue(block.arguments) as typeof block.arguments,
192
+ },
193
+ );
194
+ const projected = {
195
+ role: message.role,
196
+ api: message.api,
197
+ provider: message.provider,
198
+ model: message.model,
199
+ timestamp: message.timestamp,
200
+ usage: message.usage,
201
+ stopReason: message.stopReason,
202
+ content,
203
+ errorMessage: message.errorMessage ? safe(message.errorMessage) : undefined,
204
+ };
205
+ const streaming = message === partial;
206
+ append(
207
+ "assistant:" + index,
208
+ [projected, streaming],
209
+ () => {
210
+ const component = new AssistantMessageComponent(projected, false, getMarkdownTheme());
211
+ component.updateContent(projected, streaming);
212
+ return component;
213
+ },
214
+ (component) =>
215
+ (component as AssistantMessageComponent).updateContent(projected, streaming),
216
+ );
217
+ for (const block of message.content)
218
+ if (block.type === "toolCall")
219
+ tool(
220
+ block.name,
221
+ block.id,
222
+ block.arguments,
223
+ message.stopReason === "error" || message.stopReason === "aborted"
224
+ ? message.errorMessage || "Operation aborted"
225
+ : undefined,
226
+ );
227
+ if (
228
+ message.errorMessage &&
229
+ message.stopReason !== "error" &&
230
+ message.stopReason !== "aborted"
231
+ ) {
232
+ append(
233
+ "error:" + index,
234
+ message.errorMessage,
235
+ () => new Text("Error: " + safe(message.errorMessage!), 1, 0),
236
+ );
237
+ }
238
+ } else if (message.role === "toolResult") {
239
+ if (!toolIds.has(message.toolCallId)) tool(message.toolName, message.toolCallId, {});
240
+ } else if ("content" in message) {
241
+ const text =
242
+ typeof message.content === "string"
243
+ ? safe(message.content)
244
+ : Array.isArray(message.content)
245
+ ? textContent(message.content)
246
+ .map((block) => block.text)
247
+ .join("\n")
248
+ : "";
249
+ append("user:" + index, text, () => new UserMessageComponent(text, getMarkdownTheme()));
250
+ }
251
+ });
252
+ if (live && !toolIds.has(live.toolCallId)) tool(live.toolName, live.toolCallId, live.args);
253
+ for (const key of cache.keys()) if (!used.has(key)) cache.delete(key);
254
+ return components.flatMap((component) =>
255
+ component
256
+ .render(Math.max(12, width))
257
+ .flatMap((line) => wrapTextWithAnsi(nativeText(line), Math.max(1, width))),
258
+ );
259
+ },
260
+ invalidate() {
261
+ for (const entry of cache.values()) entry.component.invalidate();
262
+ },
263
+ clear() {
264
+ cache.clear();
265
+ generation = undefined;
266
+ },
267
+ };
268
+ }
@@ -0,0 +1,46 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { BuiltinTool } from "./profiles.ts";
4
+
5
+ function ordinaryPath(path: string): void {
6
+ if (path.includes("\0") || /^[~@]/.test(path) || /[\u00a0\u202f]/.test(path))
7
+ throw new Error(
8
+ "Workspace paths must use ordinary absolute/relative spelling, not ~, @ or nonbreaking spaces",
9
+ );
10
+ }
11
+
12
+ /** Existing directory only; never allocate, change process cwd, or infer project trust. */
13
+ export function resolveWorkspace(cwd: string | undefined, parentCwd: string): string {
14
+ const root = realpathSync(parentCwd);
15
+ if (cwd === undefined || cwd === parentCwd || cwd === root) return root;
16
+ const path = cwd;
17
+ ordinaryPath(path);
18
+ const directory = realpathSync(resolve(parentCwd, path));
19
+ ordinaryPath(directory);
20
+ if (!statSync(directory).isDirectory())
21
+ throw new Error("Delegation cwd must be an existing directory");
22
+ return directory;
23
+ }
24
+
25
+ /** Root hooks see the actual absolute paths/command, not a fictitious child dispatch cwd. */
26
+ export function workspaceArguments(
27
+ name: BuiltinTool,
28
+ args: unknown,
29
+ cwd: string,
30
+ parentCwd: string,
31
+ ): unknown {
32
+ if (cwd === realpathSync(parentCwd)) return args;
33
+ if (!args || typeof args !== "object" || Array.isArray(args))
34
+ throw new Error("Expected builtin tool arguments");
35
+ const input = args as Record<string, unknown>;
36
+ if (name === "bash") {
37
+ if (typeof input.command !== "string") throw new Error("Expected a bash command");
38
+ const quoted = "'" + cwd.replaceAll("'", "'\\''") + "'";
39
+ // A failed cd must exit before ANY user command, including a semicolon/OR list.
40
+ return { ...input, command: `cd -- ${quoted} || exit $?\n${input.command}` };
41
+ }
42
+ const path = input.path ?? (["grep", "find", "ls"].includes(name) ? "." : undefined);
43
+ if (typeof path !== "string") throw new Error("Expected a builtin path");
44
+ ordinaryPath(path);
45
+ return { ...input, path: resolve(cwd, path) };
46
+ }