pum-agent 0.1.0-beta.3
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/LICENSE +21 -0
- package/README.md +196 -0
- package/package.json +69 -0
- package/src/agent-selector.tsx +217 -0
- package/src/agent-usage.ts +93 -0
- package/src/animation.tsx +476 -0
- package/src/app.tsx +1953 -0
- package/src/apply-patch.ts +583 -0
- package/src/cancel-confirmation.ts +14 -0
- package/src/check-mode.ts +630 -0
- package/src/commands.ts +45 -0
- package/src/config.ts +24 -0
- package/src/explanation-strength.ts +47 -0
- package/src/git-branch.ts +54 -0
- package/src/help-popup.tsx +279 -0
- package/src/history.ts +57 -0
- package/src/image-paste.ts +204 -0
- package/src/index.tsx +133 -0
- package/src/login-controller.ts +267 -0
- package/src/login-flow.ts +170 -0
- package/src/login-popup.tsx +154 -0
- package/src/platform.ts +94 -0
- package/src/prompt-stash.ts +130 -0
- package/src/replay.ts +188 -0
- package/src/session-history-popup.tsx +68 -0
- package/src/settings-popup.tsx +283 -0
- package/src/settings.ts +81 -0
- package/src/shutdown.ts +23 -0
- package/src/stash-batch.ts +28 -0
- package/src/status-bar.tsx +143 -0
- package/src/status-metadata.ts +110 -0
- package/src/subagents/manager.ts +1196 -0
- package/src/subagents/types.ts +86 -0
- package/src/syntax.ts +60 -0
- package/src/theme.ts +346 -0
- package/src/tool-line.ts +72 -0
- package/src/transcript.tsx +393 -0
- package/src/web-search.ts +157 -0
- package/src/worktree-command.ts +39 -0
- package/src/worktree.ts +219 -0
- package/src/writing-style.ts +54 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { StyledText, fg, type SyntaxStyle } from "@opentui/core";
|
|
2
|
+
import {
|
|
3
|
+
useBlinkingText,
|
|
4
|
+
useMarkdownCaret,
|
|
5
|
+
useShimmerText,
|
|
6
|
+
useSpinner,
|
|
7
|
+
} from "./animation";
|
|
8
|
+
import type { Theme } from "./theme";
|
|
9
|
+
import type { ToolCall } from "./tool-line";
|
|
10
|
+
|
|
11
|
+
export type Role = "user" | "assistant" | "thinking" | "system" | "error";
|
|
12
|
+
|
|
13
|
+
export type Line =
|
|
14
|
+
| { kind: "text"; role: Role; text: string }
|
|
15
|
+
| { kind: "tool"; call: ToolCall }
|
|
16
|
+
| { kind: "agent-message"; sender: string; recipient: string; text: string };
|
|
17
|
+
|
|
18
|
+
export type PendingLine = {
|
|
19
|
+
id: string;
|
|
20
|
+
line: Extract<Line, { kind: "text" | "agent-message" }>;
|
|
21
|
+
/** Text used to match pi's message_start event. */
|
|
22
|
+
deliveryText?: string;
|
|
23
|
+
/** Pi inserted the message, but the active streamed message must finish first. */
|
|
24
|
+
delivered?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type PendingTranscriptState = {
|
|
28
|
+
lines: Line[];
|
|
29
|
+
stream: { kind: "assistant" | "thinking"; text: string } | null;
|
|
30
|
+
pending: PendingLine[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Resolve a delivered message without splitting the active streamed output. */
|
|
34
|
+
export function resolvePendingDelivery<T extends PendingTranscriptState>(value: T, id: string): T {
|
|
35
|
+
const pending = value.pending.find((item) => item.id === id);
|
|
36
|
+
if (!pending) return value;
|
|
37
|
+
if (value.stream) {
|
|
38
|
+
return {
|
|
39
|
+
...value,
|
|
40
|
+
pending: value.pending.map((item) => item.id === id ? { ...item, delivered: true } : item),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
...value,
|
|
45
|
+
lines: [...value.lines, pending.line],
|
|
46
|
+
pending: value.pending.filter((item) => item.id !== id),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Finish the stream, then insert messages that arrived while it was active. */
|
|
51
|
+
export function settleTranscriptMessage<T extends PendingTranscriptState>(value: T): T {
|
|
52
|
+
const lines = [...value.lines];
|
|
53
|
+
if (value.stream?.text.trim()) {
|
|
54
|
+
lines.push({ kind: "text", role: value.stream.kind, text: value.stream.text.trim() });
|
|
55
|
+
}
|
|
56
|
+
const delivered = value.pending.filter((item) => item.delivered);
|
|
57
|
+
return {
|
|
58
|
+
...value,
|
|
59
|
+
lines: [...lines, ...delivered.map((item) => item.line)],
|
|
60
|
+
stream: null,
|
|
61
|
+
pending: value.pending.filter((item) => !item.delivered),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type LineGroup = "tool" | "thinking" | "other";
|
|
66
|
+
|
|
67
|
+
const lineGroup = (line: Line): LineGroup => {
|
|
68
|
+
if (line.kind === "tool") return "tool";
|
|
69
|
+
return line.kind === "text" && line.role === "thinking" ? "thinking" : "other";
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Exactly one gap around tool/thinking groups, but none inside either group. */
|
|
73
|
+
export function needsTranscriptGap(prev: Line | undefined, line: Line): boolean {
|
|
74
|
+
if (!prev) return false;
|
|
75
|
+
const prevGroup = lineGroup(prev);
|
|
76
|
+
const group = lineGroup(line);
|
|
77
|
+
|
|
78
|
+
if (prevGroup !== group && (prevGroup !== "other" || group !== "other")) return true;
|
|
79
|
+
if (group !== "other") return false;
|
|
80
|
+
|
|
81
|
+
// Preserve the normal turn layout for rows outside tool/thinking groups.
|
|
82
|
+
const isUser = line.kind === "text" && line.role === "user";
|
|
83
|
+
const prevIsUser = prev.kind === "text" && prev.role === "user";
|
|
84
|
+
const isAnswer = line.kind === "text" && line.role === "assistant";
|
|
85
|
+
const isAgentMessage = line.kind === "agent-message" || prev.kind === "agent-message";
|
|
86
|
+
return isUser || prevIsUser || isAnswer || isAgentMessage;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const GUTTER = " ";
|
|
90
|
+
const PROMPT = "❯ ";
|
|
91
|
+
|
|
92
|
+
/** Remove provider trace wrappers and compact adjacent thinking entries. */
|
|
93
|
+
export function normalizeThinkingText(text: string): string {
|
|
94
|
+
return text
|
|
95
|
+
.replaceAll("**", "")
|
|
96
|
+
.replace(/\n[ \t]*\n+/g, "\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function roleColor(theme: Theme, role: Role): string {
|
|
100
|
+
switch (role) {
|
|
101
|
+
case "user":
|
|
102
|
+
return theme.user;
|
|
103
|
+
case "assistant":
|
|
104
|
+
return theme.assistant;
|
|
105
|
+
case "thinking":
|
|
106
|
+
return theme.thinking;
|
|
107
|
+
case "system":
|
|
108
|
+
return theme.dim;
|
|
109
|
+
case "error":
|
|
110
|
+
return theme.error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A one-row glyph cell beside a growing text cell. The text wraps inside its
|
|
116
|
+
* own narrower column, so every wrapped row lines up under the first and the
|
|
117
|
+
* glyph appears only once. The background comes from the box, because a text's
|
|
118
|
+
* own `bg` paints glyph cells only.
|
|
119
|
+
*/
|
|
120
|
+
function Row({
|
|
121
|
+
glyph,
|
|
122
|
+
glyphColor,
|
|
123
|
+
background,
|
|
124
|
+
children,
|
|
125
|
+
}: {
|
|
126
|
+
glyph: string;
|
|
127
|
+
glyphColor: string;
|
|
128
|
+
background?: string;
|
|
129
|
+
children: React.ReactNode;
|
|
130
|
+
}) {
|
|
131
|
+
return (
|
|
132
|
+
<box
|
|
133
|
+
style={{
|
|
134
|
+
flexDirection: "row",
|
|
135
|
+
width: "100%",
|
|
136
|
+
backgroundColor: background ?? "transparent",
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
{/* A numeric width pins the gutter: a whitespace-only <text> measures
|
|
140
|
+
inconsistently once the message column wraps, losing a column. */}
|
|
141
|
+
<box style={{ width: 2, flexShrink: 0 }}>
|
|
142
|
+
{glyph.trim() ? <text content={glyph} fg={glyphColor} /> : null}
|
|
143
|
+
</box>
|
|
144
|
+
{/* The nested flex item gives every transcript type the same measured
|
|
145
|
+
remaining-width column as the tool-row body. */}
|
|
146
|
+
<box style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
|
|
147
|
+
{children}
|
|
148
|
+
</box>
|
|
149
|
+
</box>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function TextLine({
|
|
154
|
+
theme,
|
|
155
|
+
syntaxStyle,
|
|
156
|
+
role,
|
|
157
|
+
text,
|
|
158
|
+
workingCaret = false,
|
|
159
|
+
}: {
|
|
160
|
+
theme: Theme;
|
|
161
|
+
syntaxStyle: SyntaxStyle;
|
|
162
|
+
role: Role;
|
|
163
|
+
text: string;
|
|
164
|
+
workingCaret?: boolean;
|
|
165
|
+
}) {
|
|
166
|
+
const color = roleColor(theme, role);
|
|
167
|
+
const isUser = role === "user";
|
|
168
|
+
const isAssistant = role === "assistant";
|
|
169
|
+
const displayText = role === "thinking" ? normalizeThinkingText(text) : text;
|
|
170
|
+
const textCaret = useBlinkingText({
|
|
171
|
+
chunks: [fg(color)(displayText)],
|
|
172
|
+
contentKey: `${role}:${displayText}`,
|
|
173
|
+
caretColor: color,
|
|
174
|
+
active: workingCaret && !isAssistant,
|
|
175
|
+
});
|
|
176
|
+
const markdownCaret = useMarkdownCaret(text, workingCaret && isAssistant);
|
|
177
|
+
|
|
178
|
+
// Assistant text is already styled while streaming, so settlement only
|
|
179
|
+
// finalizes Markdown parsing and does not swap through a plain-text phase.
|
|
180
|
+
// User messages are static Markdown. They never get a streaming caret.
|
|
181
|
+
if (isAssistant || isUser) {
|
|
182
|
+
return (
|
|
183
|
+
<Row
|
|
184
|
+
glyph={isUser ? PROMPT : GUTTER}
|
|
185
|
+
glyphColor={color}
|
|
186
|
+
background={isUser ? theme.userBg : undefined}
|
|
187
|
+
>
|
|
188
|
+
<markdown
|
|
189
|
+
ref={isAssistant && workingCaret ? markdownCaret : undefined}
|
|
190
|
+
content={isAssistant && workingCaret ? undefined : text}
|
|
191
|
+
streaming={false}
|
|
192
|
+
syntaxStyle={syntaxStyle}
|
|
193
|
+
fg={color}
|
|
194
|
+
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}
|
|
195
|
+
/>
|
|
196
|
+
</Row>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<Row glyph={GUTTER} glyphColor={color}>
|
|
202
|
+
<text
|
|
203
|
+
ref={workingCaret ? textCaret : undefined}
|
|
204
|
+
content={workingCaret ? undefined : displayText}
|
|
205
|
+
fg={color}
|
|
206
|
+
wrapMode="word"
|
|
207
|
+
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}
|
|
208
|
+
/>
|
|
209
|
+
</Row>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The line currently streaming in: shimmered, with a caret riding the end. */
|
|
214
|
+
export function StreamLine({
|
|
215
|
+
theme,
|
|
216
|
+
syntaxStyle,
|
|
217
|
+
role,
|
|
218
|
+
text,
|
|
219
|
+
}: {
|
|
220
|
+
theme: Theme;
|
|
221
|
+
syntaxStyle: SyntaxStyle;
|
|
222
|
+
role: "assistant" | "thinking";
|
|
223
|
+
text: string;
|
|
224
|
+
}) {
|
|
225
|
+
const color = roleColor(theme, role);
|
|
226
|
+
const displayText = role === "thinking" ? normalizeThinkingText(text) : text;
|
|
227
|
+
const shimmer = useShimmerText({
|
|
228
|
+
text: displayText,
|
|
229
|
+
color,
|
|
230
|
+
highlight: theme.highlight,
|
|
231
|
+
active: role === "thinking",
|
|
232
|
+
caret: true,
|
|
233
|
+
});
|
|
234
|
+
const markdown = useMarkdownCaret(text, role === "assistant");
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<Row glyph={GUTTER} glyphColor={color}>
|
|
238
|
+
{role === "assistant" ? (
|
|
239
|
+
<markdown
|
|
240
|
+
ref={markdown}
|
|
241
|
+
streaming
|
|
242
|
+
syntaxStyle={syntaxStyle}
|
|
243
|
+
fg={color}
|
|
244
|
+
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}
|
|
245
|
+
/>
|
|
246
|
+
) : (
|
|
247
|
+
<text
|
|
248
|
+
ref={shimmer}
|
|
249
|
+
wrapMode="word"
|
|
250
|
+
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}
|
|
251
|
+
/>
|
|
252
|
+
)}
|
|
253
|
+
</Row>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function PendingMessageLine({
|
|
258
|
+
theme,
|
|
259
|
+
syntaxStyle,
|
|
260
|
+
pending,
|
|
261
|
+
}: {
|
|
262
|
+
theme: Theme;
|
|
263
|
+
syntaxStyle: SyntaxStyle;
|
|
264
|
+
pending: PendingLine;
|
|
265
|
+
}) {
|
|
266
|
+
const line = pending.line;
|
|
267
|
+
if (line.kind === "agent-message") {
|
|
268
|
+
return (
|
|
269
|
+
<Row glyph="◇ " glyphColor={theme.dim} background={theme.agentMessageBg}>
|
|
270
|
+
<box style={{ flexDirection: "column", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
|
|
271
|
+
<text
|
|
272
|
+
content={`${line.sender} → ${line.recipient} · queued`}
|
|
273
|
+
fg={theme.dim}
|
|
274
|
+
wrapMode="word"
|
|
275
|
+
style={{ width: "100%", flexShrink: 1, minWidth: 0 }}
|
|
276
|
+
/>
|
|
277
|
+
<markdown
|
|
278
|
+
content={line.text}
|
|
279
|
+
streaming={false}
|
|
280
|
+
syntaxStyle={syntaxStyle}
|
|
281
|
+
fg={theme.dim}
|
|
282
|
+
style={{ width: "100%", flexGrow: 1, flexShrink: 1, minWidth: 0 }}
|
|
283
|
+
/>
|
|
284
|
+
</box>
|
|
285
|
+
</Row>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<Row glyph="○ " glyphColor={theme.dim} background={theme.userBg}>
|
|
291
|
+
<markdown
|
|
292
|
+
content={line.text}
|
|
293
|
+
streaming={false}
|
|
294
|
+
syntaxStyle={syntaxStyle}
|
|
295
|
+
fg={theme.dim}
|
|
296
|
+
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}
|
|
297
|
+
/>
|
|
298
|
+
</Row>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function AgentMessageLine({
|
|
303
|
+
theme,
|
|
304
|
+
syntaxStyle,
|
|
305
|
+
line,
|
|
306
|
+
}: {
|
|
307
|
+
theme: Theme;
|
|
308
|
+
syntaxStyle: SyntaxStyle;
|
|
309
|
+
line: Extract<Line, { kind: "agent-message" }>;
|
|
310
|
+
}) {
|
|
311
|
+
return (
|
|
312
|
+
<Row glyph="◇ " glyphColor={theme.agentMessage} background={theme.agentMessageBg}>
|
|
313
|
+
<box style={{ flexDirection: "column", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
|
|
314
|
+
<text
|
|
315
|
+
content={`${line.sender} → ${line.recipient}`}
|
|
316
|
+
fg={theme.agentMessage}
|
|
317
|
+
wrapMode="word"
|
|
318
|
+
style={{ width: "100%", flexShrink: 1, minWidth: 0 }}
|
|
319
|
+
/>
|
|
320
|
+
<markdown
|
|
321
|
+
content={line.text}
|
|
322
|
+
streaming={false}
|
|
323
|
+
syntaxStyle={syntaxStyle}
|
|
324
|
+
fg={theme.agentMessage}
|
|
325
|
+
style={{ width: "100%", flexGrow: 1, flexShrink: 1, minWidth: 0 }}
|
|
326
|
+
/>
|
|
327
|
+
</box>
|
|
328
|
+
</Row>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function toolStateGlyph(state: ToolCall["state"]): string {
|
|
333
|
+
if (state === "ok") return "✓";
|
|
334
|
+
if (state === "rejected") return "!";
|
|
335
|
+
return "✗";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function ToolLine({
|
|
339
|
+
theme,
|
|
340
|
+
call,
|
|
341
|
+
workingCaret = false,
|
|
342
|
+
}: {
|
|
343
|
+
theme: Theme;
|
|
344
|
+
call: ToolCall;
|
|
345
|
+
workingCaret?: boolean;
|
|
346
|
+
}) {
|
|
347
|
+
const spinner = useSpinner(call.state === "running");
|
|
348
|
+
const failed = call.state === "error";
|
|
349
|
+
const rejected = call.state === "rejected";
|
|
350
|
+
const toolColor = failed ? theme.error : rejected ? theme.warn : theme.tool;
|
|
351
|
+
const argColor = failed ? theme.error : rejected ? theme.warn : theme.toolArg;
|
|
352
|
+
const detailColor = failed ? theme.error : rejected ? theme.warn : theme.dim;
|
|
353
|
+
|
|
354
|
+
const prefix = call.arg
|
|
355
|
+
? new StyledText([fg(toolColor)(call.name), fg(detailColor)(" · ")])
|
|
356
|
+
: null;
|
|
357
|
+
const bodyChunks = call.arg
|
|
358
|
+
? [fg(argColor)(call.arg)]
|
|
359
|
+
: [fg(toolColor)(call.name)];
|
|
360
|
+
if (call.detail) bodyChunks.push(fg(detailColor)(` ${call.detail}`));
|
|
361
|
+
|
|
362
|
+
const caret = useBlinkingText({
|
|
363
|
+
chunks: bodyChunks,
|
|
364
|
+
contentKey: `${call.name}:${call.arg}:${call.state}:${call.detail ?? ""}`,
|
|
365
|
+
caretColor: failed ? theme.error : rejected ? theme.warn : theme.accent,
|
|
366
|
+
active: workingCaret,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<Row glyph={GUTTER} glyphColor={toolColor}>
|
|
371
|
+
<box style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
|
|
372
|
+
{prefix ? <text content={prefix} style={{ flexShrink: 0 }} /> : null}
|
|
373
|
+
<text
|
|
374
|
+
ref={workingCaret ? caret : undefined}
|
|
375
|
+
content={workingCaret ? undefined : new StyledText(bodyChunks)}
|
|
376
|
+
wrapMode="word"
|
|
377
|
+
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}
|
|
378
|
+
/>
|
|
379
|
+
</box>
|
|
380
|
+
<box style={{ width: 1, flexShrink: 0 }} />
|
|
381
|
+
<box style={{ width: 1, flexShrink: 0 }}>
|
|
382
|
+
{call.state === "running" ? (
|
|
383
|
+
<text ref={spinner} fg={theme.accent} />
|
|
384
|
+
) : (
|
|
385
|
+
<text
|
|
386
|
+
content={toolStateGlyph(call.state)}
|
|
387
|
+
fg={call.state === "ok" ? theme.success : call.state === "rejected" ? theme.warn : theme.error}
|
|
388
|
+
/>
|
|
389
|
+
)}
|
|
390
|
+
</box>
|
|
391
|
+
</Row>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { Provider } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Web search through the Codex subscription.
|
|
7
|
+
*
|
|
8
|
+
* OpenAI's `web_search` is a hosted tool. The model calls it, OpenAI runs it,
|
|
9
|
+
* and the answer comes back already informed by the results.
|
|
10
|
+
*/
|
|
11
|
+
const HOSTED_SEARCH_PROVIDERS = ["openai-codex"];
|
|
12
|
+
|
|
13
|
+
/** Session entry type used for searches, which pi does not represent as tools. */
|
|
14
|
+
export const WEB_SEARCH_CUSTOM_TYPE = "pum.web_search";
|
|
15
|
+
|
|
16
|
+
/** Mutable so the Ctrl+P toggle takes effect without rebuilding the provider. */
|
|
17
|
+
export const webSearch = { enabled: false };
|
|
18
|
+
|
|
19
|
+
const searchRoute = new AsyncLocalStorage<string>();
|
|
20
|
+
let socketObserverInstalled = false;
|
|
21
|
+
|
|
22
|
+
function addSearchTool(payload: unknown): unknown | undefined {
|
|
23
|
+
if (!webSearch.enabled || !payload || typeof payload !== "object") return undefined;
|
|
24
|
+
const body = payload as { tools?: unknown[] };
|
|
25
|
+
const tools = Array.isArray(body.tools) ? body.tools : [];
|
|
26
|
+
if (tools.some((t) => (t as { type?: string })?.type === "web_search")) return undefined;
|
|
27
|
+
return { ...body, tools: [...tools, { type: "web_search" }] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Delegates through the prototype so the provider's other members survive. */
|
|
31
|
+
function wrapProvider(base: Provider): Provider {
|
|
32
|
+
const wrapped: Provider = Object.create(base);
|
|
33
|
+
wrapped.stream = ((model: any, context: any, options: any) =>
|
|
34
|
+
base.stream(model, context, { ...options, onPayload: addSearchTool })) as Provider["stream"];
|
|
35
|
+
wrapped.streamSimple = ((model: any, context: any, options: any) =>
|
|
36
|
+
base.streamSimple(model, context, {
|
|
37
|
+
...options,
|
|
38
|
+
onPayload: addSearchTool,
|
|
39
|
+
})) as Provider["streamSimple"];
|
|
40
|
+
return wrapped;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type SearchCall =
|
|
44
|
+
| { phase: "start"; id: string; query: string }
|
|
45
|
+
| { phase: "end"; id: string; query: string; ok: boolean };
|
|
46
|
+
|
|
47
|
+
export type SearchCallRecord = {
|
|
48
|
+
id: string;
|
|
49
|
+
query: string;
|
|
50
|
+
state: "running" | "ok" | "error";
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export class SearchCallRouter {
|
|
54
|
+
private readonly listeners = new Map<string, Set<(call: SearchCall) => void>>();
|
|
55
|
+
|
|
56
|
+
subscribe(route: string, listener: (call: SearchCall) => void): () => void {
|
|
57
|
+
const listeners = this.listeners.get(route) ?? new Set();
|
|
58
|
+
listeners.add(listener);
|
|
59
|
+
this.listeners.set(route, listeners);
|
|
60
|
+
return () => {
|
|
61
|
+
listeners.delete(listener);
|
|
62
|
+
if (listeners.size === 0) this.listeners.delete(route);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
emit(route: string, call: SearchCall): void {
|
|
67
|
+
for (const listener of this.listeners.get(route) ?? []) listener(call);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const searchCalls = new SearchCallRouter();
|
|
72
|
+
|
|
73
|
+
/** Keep the route active through the asynchronous agent and provider call chain. */
|
|
74
|
+
export function withSearchRoute<T>(route: string, operation: () => T): T {
|
|
75
|
+
return searchRoute.run(route, operation);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Subscribe one transcript to searches from only its own agent session. */
|
|
79
|
+
export function observeSearchCalls(
|
|
80
|
+
route: string,
|
|
81
|
+
onCall: (call: SearchCall) => void,
|
|
82
|
+
): () => void {
|
|
83
|
+
installSocketObserver();
|
|
84
|
+
return searchCalls.subscribe(route, onCall);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Persist an out-of-band search as session metadata, not LLM context. */
|
|
88
|
+
export function persistSearchCall(
|
|
89
|
+
sessionManager: Pick<SessionManager, "appendCustomEntry">,
|
|
90
|
+
call: SearchCall,
|
|
91
|
+
): void {
|
|
92
|
+
const record: SearchCallRecord = {
|
|
93
|
+
id: call.id,
|
|
94
|
+
query: call.query,
|
|
95
|
+
state: call.phase === "start" ? "running" : call.ok ? "ok" : "error",
|
|
96
|
+
};
|
|
97
|
+
sessionManager.appendCustomEntry(WEB_SEARCH_CUSTOM_TYPE, record);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* pi drops `web_search_call` items. Observe WebSocket frames without changing
|
|
102
|
+
* the cached WebSocket transport. Each socket captures its current session route.
|
|
103
|
+
*/
|
|
104
|
+
function installSocketObserver(): void {
|
|
105
|
+
if (socketObserverInstalled) return;
|
|
106
|
+
const Original = globalThis.WebSocket;
|
|
107
|
+
if (!Original || (Original as { __pumPatched?: boolean }).__pumPatched) return;
|
|
108
|
+
socketObserverInstalled = true;
|
|
109
|
+
|
|
110
|
+
const Patched = new Proxy(Original, {
|
|
111
|
+
construct(target, args: any[]) {
|
|
112
|
+
const route = searchRoute.getStore();
|
|
113
|
+
const socket = new (target as any)(...args);
|
|
114
|
+
const seen = new Map<string, string>();
|
|
115
|
+
socket.addEventListener?.("message", (ev: any) => {
|
|
116
|
+
try {
|
|
117
|
+
const raw = ev?.data;
|
|
118
|
+
if (!route || typeof raw !== "string" || !raw.includes("web_search_call")) return;
|
|
119
|
+
const event = JSON.parse(raw);
|
|
120
|
+
const item = event?.item;
|
|
121
|
+
if (item?.type !== "web_search_call") return;
|
|
122
|
+
const id = String(item.id ?? "");
|
|
123
|
+
const query = String(item.action?.query ?? seen.get(id) ?? "");
|
|
124
|
+
if (query) seen.set(id, query);
|
|
125
|
+
if (event.type === "response.output_item.added") {
|
|
126
|
+
searchCalls.emit(route, { phase: "start", id, query });
|
|
127
|
+
} else if (event.type === "response.output_item.done") {
|
|
128
|
+
searchCalls.emit(route, {
|
|
129
|
+
phase: "end",
|
|
130
|
+
id,
|
|
131
|
+
query,
|
|
132
|
+
ok: item.status !== "failed",
|
|
133
|
+
});
|
|
134
|
+
seen.delete(id);
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Search observation must never break an agent turn.
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
return socket;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
(Patched as unknown as { __pumPatched: boolean }).__pumPatched = true;
|
|
144
|
+
globalThis.WebSocket = Patched as typeof WebSocket;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Returns the provider ids that now carry the hosted search tool. */
|
|
148
|
+
export function installWebSearch(runtime: ModelRuntime): string[] {
|
|
149
|
+
const installed: string[] = [];
|
|
150
|
+
for (const id of HOSTED_SEARCH_PROVIDERS) {
|
|
151
|
+
const base = runtime.getProvider(id);
|
|
152
|
+
if (!base) continue;
|
|
153
|
+
runtime.registerNativeProvider(wrapProvider(base));
|
|
154
|
+
installed.push(id);
|
|
155
|
+
}
|
|
156
|
+
return installed;
|
|
157
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ToolCall } from "./tool-line";
|
|
2
|
+
import type { SubagentManager } from "./subagents/manager";
|
|
3
|
+
|
|
4
|
+
export function runWorktreeCommand({
|
|
5
|
+
name,
|
|
6
|
+
manager,
|
|
7
|
+
append,
|
|
8
|
+
patch,
|
|
9
|
+
settled,
|
|
10
|
+
}: {
|
|
11
|
+
name?: string;
|
|
12
|
+
manager: SubagentManager;
|
|
13
|
+
append: (call: ToolCall) => void;
|
|
14
|
+
patch: (id: string, patch: Partial<ToolCall>) => void;
|
|
15
|
+
settled: () => void;
|
|
16
|
+
}): void {
|
|
17
|
+
const id = `worktree-command-${Date.now()}`;
|
|
18
|
+
const call: ToolCall = {
|
|
19
|
+
id,
|
|
20
|
+
name: "worktree",
|
|
21
|
+
arg: name ? `create ${name}` : "create",
|
|
22
|
+
state: "running",
|
|
23
|
+
};
|
|
24
|
+
append(call);
|
|
25
|
+
manager.persistToolEvent(call);
|
|
26
|
+
manager
|
|
27
|
+
.createStandaloneWorktree(name)
|
|
28
|
+
.then((record) => {
|
|
29
|
+
const update: Partial<ToolCall> = { state: "ok", detail: record.branch };
|
|
30
|
+
patch(id, update);
|
|
31
|
+
manager.persistToolEvent({ ...call, ...update });
|
|
32
|
+
})
|
|
33
|
+
.catch((error) => {
|
|
34
|
+
const update: Partial<ToolCall> = { state: "error", detail: String(error) };
|
|
35
|
+
patch(id, update);
|
|
36
|
+
manager.persistToolEvent({ ...call, ...update });
|
|
37
|
+
})
|
|
38
|
+
.finally(settled);
|
|
39
|
+
}
|