takomi 2.1.2 → 2.1.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/.pi/README.md +124 -124
- package/.pi/agents/architect.md +15 -15
- package/.pi/agents/coder.md +14 -14
- package/.pi/agents/designer.md +17 -17
- package/.pi/agents/orchestrator.md +22 -22
- package/.pi/agents/reviewer.md +16 -16
- package/.pi/extensions/oauth-router/README.md +125 -125
- package/.pi/extensions/oauth-router/commands.ts +380 -380
- package/.pi/extensions/oauth-router/config.ts +200 -200
- package/.pi/extensions/oauth-router/index.ts +41 -41
- package/.pi/extensions/oauth-router/oauth-flow.ts +154 -154
- package/.pi/extensions/oauth-router/oauth-store.ts +121 -121
- package/.pi/extensions/oauth-router/package.json +14 -14
- package/.pi/extensions/oauth-router/policies.ts +27 -27
- package/.pi/extensions/oauth-router/provider.ts +492 -492
- package/.pi/extensions/oauth-router/scripts/vibe-verify.py +98 -98
- package/.pi/extensions/oauth-router/state.ts +174 -174
- package/.pi/extensions/oauth-router/types.ts +153 -153
- package/.pi/extensions/takomi-runtime/command-text.ts +130 -130
- package/.pi/extensions/takomi-runtime/commands.ts +179 -179
- package/.pi/extensions/takomi-runtime/context-panel.ts +282 -282
- package/.pi/extensions/takomi-runtime/index.ts +1288 -1288
- package/.pi/extensions/takomi-runtime/profile.ts +114 -114
- package/.pi/extensions/takomi-runtime/routing-policy.ts +105 -105
- package/.pi/extensions/takomi-runtime/shared.ts +492 -492
- package/.pi/extensions/takomi-runtime/subagent-controller.ts +364 -364
- package/.pi/extensions/takomi-runtime/subagent-render.ts +501 -501
- package/.pi/extensions/takomi-runtime/subagent-types.ts +83 -83
- package/.pi/extensions/takomi-runtime/ui.ts +133 -133
- package/.pi/extensions/takomi-subagents/agent-aliases.ts +18 -18
- package/.pi/extensions/takomi-subagents/agents.ts +113 -113
- package/.pi/extensions/takomi-subagents/delegation-plan.ts +95 -95
- package/.pi/extensions/takomi-subagents/dispatch-helpers.ts +26 -26
- package/.pi/extensions/takomi-subagents/dispatch.ts +215 -215
- package/.pi/extensions/takomi-subagents/index.ts +75 -75
- package/.pi/extensions/takomi-subagents/live-updates.ts +83 -83
- package/.pi/extensions/takomi-subagents/native-render.ts +174 -174
- package/.pi/extensions/takomi-subagents/tool-runner.ts +209 -209
- package/.pi/themes/takomi-noir.json +81 -81
- package/package.json +59 -59
- package/src/doctor.js +87 -84
- package/src/pi-harness.js +355 -351
- package/src/pi-installer.js +193 -171
- package/src/pi-takomi-core/index.ts +4 -4
- package/src/pi-takomi-core/orchestration.ts +402 -402
- package/src/pi-takomi-core/routing.ts +93 -93
- package/src/pi-takomi-core/types.ts +173 -173
- package/src/pi-takomi-core/workflows.ts +299 -299
- package/src/skills-installer.js +101 -101
|
@@ -1,492 +1,492 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared utilities for Takomi Pi extensions.
|
|
3
|
-
*
|
|
4
|
-
* Consolidates functions that were duplicated across
|
|
5
|
-
* takomi-runtime/index.ts, takomi-runtime/ui.ts, and
|
|
6
|
-
* takomi-subagents/index.ts.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
10
|
-
import { spawn } from "node:child_process";
|
|
11
|
-
import os from "node:os";
|
|
12
|
-
import path from "node:path";
|
|
13
|
-
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
14
|
-
|
|
15
|
-
// ─── ANSI / String Utilities ────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
export function stripAnsi(value: string): string {
|
|
18
|
-
return value
|
|
19
|
-
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
|
20
|
-
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
21
|
-
.replace(/[\u0000-\u001f\u007f]/g, " ");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function visibleWidth(value: string): number {
|
|
25
|
-
return stripAnsi(value).length;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function truncateToWidth(value: string, width: number, ellipsis = "..."): string {
|
|
29
|
-
const plain = stripAnsi(value);
|
|
30
|
-
if (plain.length <= width) return plain;
|
|
31
|
-
if (width <= ellipsis.length) return plain.slice(0, width);
|
|
32
|
-
return `${plain.slice(0, width - ellipsis.length)}${ellipsis}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function ellipsizeMiddle(value: string, max = 22): string {
|
|
36
|
-
if (value.length <= max) return value;
|
|
37
|
-
const left = Math.max(6, Math.floor((max - 1) / 2));
|
|
38
|
-
const right = Math.max(6, max - left - 1);
|
|
39
|
-
return `${value.slice(0, left)}…${value.slice(-right)}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function firstMeaningfulLine(text?: string): string | undefined {
|
|
43
|
-
if (!text) return undefined;
|
|
44
|
-
return text
|
|
45
|
-
.split(/\r?\n/)
|
|
46
|
-
.map((line) => line.trim())
|
|
47
|
-
.find(Boolean);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function sanitizeLogChunk(chunk: string): string[] {
|
|
51
|
-
return stripAnsi(chunk)
|
|
52
|
-
.split(/\r?\n/)
|
|
53
|
-
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
54
|
-
.filter(Boolean)
|
|
55
|
-
.slice(-8);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const STREAMED_LOG_PUNCTUATION = /^[,.;:!?%)\]}]+$/;
|
|
59
|
-
const STRUCTURED_LOG_PREFIX = /^(Tool (start|complete|failed):|Checklist\b|Model preflight\b|Requested:\b|Selected model:\b|Warning:\b|Subagent\b|Redispatch\b|Waiting for first live event)/i;
|
|
60
|
-
|
|
61
|
-
function normalizeLogLine(value: string): string {
|
|
62
|
-
return stripAnsi(value).replace(/\s+/g, " ").trim();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function shouldMergeStreamedLog(previous: string, next: string): boolean {
|
|
66
|
-
const prior = normalizeLogLine(previous);
|
|
67
|
-
const incoming = normalizeLogLine(next);
|
|
68
|
-
if (!prior || !incoming) return false;
|
|
69
|
-
if (prior.length >= 280) return false;
|
|
70
|
-
if (STRUCTURED_LOG_PREFIX.test(prior) || STRUCTURED_LOG_PREFIX.test(incoming)) return false;
|
|
71
|
-
if (STREAMED_LOG_PUNCTUATION.test(incoming)) return true;
|
|
72
|
-
if (incoming.includes(":")) return false;
|
|
73
|
-
return /^[a-z0-9("'`\[]/.test(incoming) && incoming.length <= 40;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function joinStreamedLog(previous: string, next: string): string {
|
|
77
|
-
const prior = normalizeLogLine(previous);
|
|
78
|
-
const incoming = normalizeLogLine(next);
|
|
79
|
-
if (!prior) return incoming;
|
|
80
|
-
if (!incoming) return prior;
|
|
81
|
-
if (STREAMED_LOG_PUNCTUATION.test(incoming) || /^[,.;:!?%)\]}]/.test(incoming)) return `${prior}${incoming}`;
|
|
82
|
-
if (/^['’]/.test(incoming)) return `${prior}${incoming}`;
|
|
83
|
-
if (/[([{]$/.test(prior)) return `${prior}${incoming}`;
|
|
84
|
-
return `${prior} ${incoming}`;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function appendLiveLogChunk(existing: string[], chunk: string, limit = 60): string[] {
|
|
88
|
-
const lines = sanitizeLogChunk(chunk);
|
|
89
|
-
if (!lines.length) return existing;
|
|
90
|
-
|
|
91
|
-
const nextLogs = [...existing];
|
|
92
|
-
for (const line of lines) {
|
|
93
|
-
const previous = nextLogs.at(-1);
|
|
94
|
-
if (previous && shouldMergeStreamedLog(previous, line)) {
|
|
95
|
-
nextLogs[nextLogs.length - 1] = joinStreamedLog(previous, line);
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
nextLogs.push(line);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return nextLogs.slice(-limit);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function formatDuration(ms: number): string {
|
|
105
|
-
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
106
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
107
|
-
const seconds = totalSeconds % 60;
|
|
108
|
-
if (minutes >= 60) {
|
|
109
|
-
const hours = Math.floor(minutes / 60);
|
|
110
|
-
const remMinutes = minutes % 60;
|
|
111
|
-
return `${hours}h ${remMinutes}m`;
|
|
112
|
-
}
|
|
113
|
-
if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
114
|
-
return `${seconds}s`;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function formatFooterNumber(value: number): string {
|
|
118
|
-
if (value < 1000) return `${value}`;
|
|
119
|
-
return `${(value / 1000).toFixed(1)}k`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function wrapLabel(value: string, width: number): string[] {
|
|
123
|
-
const plain = stripAnsi(value);
|
|
124
|
-
const target = Math.max(8, width);
|
|
125
|
-
const words = plain.split(/\s+/).filter(Boolean);
|
|
126
|
-
const lines: string[] = [];
|
|
127
|
-
let current = "";
|
|
128
|
-
|
|
129
|
-
for (const word of words) {
|
|
130
|
-
const next = current ? `${current} ${word}` : word;
|
|
131
|
-
if (next.length <= target) {
|
|
132
|
-
current = next;
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (current) lines.push(current);
|
|
136
|
-
if (word.length <= target) {
|
|
137
|
-
current = word;
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
let remainder = word;
|
|
141
|
-
while (remainder.length > target) {
|
|
142
|
-
lines.push(remainder.slice(0, target));
|
|
143
|
-
remainder = remainder.slice(target);
|
|
144
|
-
}
|
|
145
|
-
current = remainder;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (current) lines.push(current);
|
|
149
|
-
return lines.length ? lines : [plain];
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ─── Checklist Utilities ────────────────────────────────────────────
|
|
153
|
-
|
|
154
|
-
export type ChecklistInput = Array<string | { text: string; done?: boolean }> | undefined;
|
|
155
|
-
|
|
156
|
-
export function formatChecklist(checklist?: Array<string | { text: string; done?: boolean }>): string {
|
|
157
|
-
if (!checklist?.length) return "";
|
|
158
|
-
return [
|
|
159
|
-
"Checklist:",
|
|
160
|
-
...checklist.map((item) => typeof item === "string" ? `- [ ] ${item}` : `- [${item.done ? "x" : " "}] ${item.text}`),
|
|
161
|
-
].join("\n");
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function checklistProgress(checklist?: ChecklistInput): string | undefined {
|
|
165
|
-
if (!checklist?.length) return undefined;
|
|
166
|
-
const normalized = checklist.map((item) => (typeof item === "string" ? { text: item, done: false } : item));
|
|
167
|
-
const done = normalized.filter((item) => item.done).length;
|
|
168
|
-
return `${done}/${normalized.length}`;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ─── Pi Process Invocation ──────────────────────────────────────────
|
|
172
|
-
|
|
173
|
-
export function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
174
|
-
const currentScript = process.argv[1];
|
|
175
|
-
if (currentScript) return { command: process.execPath, args: [currentScript, ...args] };
|
|
176
|
-
return { command: "pi", args };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export async function writeTempPrompt(agentName: string, prompt: string): Promise<string> {
|
|
180
|
-
const tmpDir = path.join(os.tmpdir(), `takomi-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
181
|
-
await mkdir(tmpDir, { recursive: true });
|
|
182
|
-
const filePath = path.join(tmpDir, `${agentName}.md`);
|
|
183
|
-
await writeFile(filePath, prompt, "utf8");
|
|
184
|
-
return filePath;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// ─── Subagent Process Runners ───────────────────────────────────────
|
|
188
|
-
|
|
189
|
-
export type RunHooks = {
|
|
190
|
-
onStdout?: (chunk: string) => void;
|
|
191
|
-
onStderr?: (chunk: string) => void;
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
export type JsonRunHooks = {
|
|
195
|
-
onAssistantText?: (text: string) => void;
|
|
196
|
-
onEventText?: (line: string) => void;
|
|
197
|
-
onStderr?: (chunk: string) => void;
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
const JSON_OUTPUT_TAIL_LIMIT = 64_000;
|
|
201
|
-
|
|
202
|
-
function appendCappedTail(current: string, next: string, limit = JSON_OUTPUT_TAIL_LIMIT): string {
|
|
203
|
-
if (!next) return current;
|
|
204
|
-
const boundedNext = next.length > limit ? next.slice(-limit) : next;
|
|
205
|
-
const remaining = limit - boundedNext.length;
|
|
206
|
-
if (remaining <= 0) return boundedNext;
|
|
207
|
-
if (current.length <= remaining) return current + boundedNext;
|
|
208
|
-
return current.slice(-remaining) + boundedNext;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export function extractAssistantText(message: unknown): string {
|
|
212
|
-
if (!message || typeof message !== "object") return "";
|
|
213
|
-
const content = (message as { content?: unknown }).content;
|
|
214
|
-
if (!Array.isArray(content)) return "";
|
|
215
|
-
return content
|
|
216
|
-
.filter((block): block is { type?: string; text?: string } => Boolean(block) && typeof block === "object")
|
|
217
|
-
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
218
|
-
.map((block) => block.text ?? "")
|
|
219
|
-
.join("\n")
|
|
220
|
-
.trim();
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function normalizeAssistantOutputText(value: string): string {
|
|
224
|
-
return stripAnsi(value)
|
|
225
|
-
.replace(/\r/g, "")
|
|
226
|
-
.replace(/[ \t]+\n/g, "\n")
|
|
227
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
228
|
-
.trim();
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function extractAssistantSnapshot(event: Record<string, unknown>, currentText: string): string | undefined {
|
|
232
|
-
const type = typeof event.type === "string" ? event.type : "";
|
|
233
|
-
|
|
234
|
-
if (type === "message_update") {
|
|
235
|
-
const assistantEvent = event.assistantMessageEvent;
|
|
236
|
-
if (assistantEvent && typeof assistantEvent === "object") {
|
|
237
|
-
const delta = (assistantEvent as { delta?: unknown }).delta;
|
|
238
|
-
if (typeof delta === "string" && delta) {
|
|
239
|
-
const next = normalizeAssistantOutputText(`${currentText}${delta}`);
|
|
240
|
-
return next && next !== currentText ? next : undefined;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const messageText = normalizeAssistantOutputText(extractAssistantText(event.message));
|
|
245
|
-
return messageText && messageText !== currentText ? messageText : undefined;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (type === "message_end") {
|
|
249
|
-
const message = event.message;
|
|
250
|
-
if (message && typeof message === "object" && (message as { role?: string }).role === "assistant") {
|
|
251
|
-
const messageText = normalizeAssistantOutputText(extractAssistantText(message));
|
|
252
|
-
return messageText && messageText !== currentText ? messageText : undefined;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
if (type === "agent_end") {
|
|
257
|
-
const messages = (event.messages as unknown[] | undefined) ?? [];
|
|
258
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
259
|
-
const message = messages[i];
|
|
260
|
-
if (message && typeof message === "object" && (message as { role?: string }).role === "assistant") {
|
|
261
|
-
const messageText = normalizeAssistantOutputText(extractAssistantText(message));
|
|
262
|
-
return messageText && messageText !== currentText ? messageText : undefined;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return undefined;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export function summarizeJsonEvent(event: Record<string, unknown>): string | undefined {
|
|
271
|
-
const type = typeof event.type === "string" ? event.type : "";
|
|
272
|
-
if (type === "tool_execution_start") {
|
|
273
|
-
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
|
274
|
-
return `Tool start: ${toolName}`;
|
|
275
|
-
}
|
|
276
|
-
if (type === "tool_execution_update") {
|
|
277
|
-
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
|
278
|
-
const partial = event.partialResult;
|
|
279
|
-
if (partial && typeof partial === "object") {
|
|
280
|
-
const partialText = extractAssistantText(partial);
|
|
281
|
-
if (partialText) return `${toolName}: ${partialText.split(/\r?\n/).find(Boolean)?.trim()}`;
|
|
282
|
-
const details = (partial as { details?: { output?: string; stdout?: string } }).details;
|
|
283
|
-
const output = details?.output ?? details?.stdout;
|
|
284
|
-
if (typeof output === "string" && output.trim()) {
|
|
285
|
-
return `${toolName}: ${output.split(/\r?\n/).find(Boolean)?.trim()}`;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
return `${toolName}: update`;
|
|
289
|
-
}
|
|
290
|
-
if (type === "tool_execution_end") {
|
|
291
|
-
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
|
292
|
-
const isError = event.isError === true;
|
|
293
|
-
return isError ? `Tool failed: ${toolName}` : `Tool complete: ${toolName}`;
|
|
294
|
-
}
|
|
295
|
-
return undefined;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
export async function runPiAgent(cwd: string, args: string[], signal?: AbortSignal, hooks?: RunHooks): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
299
|
-
return new Promise((resolve) => {
|
|
300
|
-
const invocation = getPiInvocation(args);
|
|
301
|
-
const proc = spawn(invocation.command, invocation.args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: false });
|
|
302
|
-
let stdout = "";
|
|
303
|
-
let stderr = "";
|
|
304
|
-
proc.stdout.on("data", (d) => {
|
|
305
|
-
const chunk = d.toString();
|
|
306
|
-
stdout += chunk;
|
|
307
|
-
hooks?.onStdout?.(chunk);
|
|
308
|
-
});
|
|
309
|
-
proc.stderr.on("data", (d) => {
|
|
310
|
-
const chunk = d.toString();
|
|
311
|
-
stderr += chunk;
|
|
312
|
-
hooks?.onStderr?.(chunk);
|
|
313
|
-
});
|
|
314
|
-
proc.on("close", (code) => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
315
|
-
proc.on("error", () => resolve({ stdout, stderr, code: 1 }));
|
|
316
|
-
if (signal) {
|
|
317
|
-
const abort = () => proc.kill("SIGTERM");
|
|
318
|
-
if (signal.aborted) abort();
|
|
319
|
-
else signal.addEventListener("abort", abort, { once: true });
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export async function runPiAgentJson(cwd: string, args: string[], signal?: AbortSignal, hooks?: JsonRunHooks): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
325
|
-
return new Promise((resolve) => {
|
|
326
|
-
const invocation = getPiInvocation(args);
|
|
327
|
-
const proc = spawn(invocation.command, invocation.args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: false });
|
|
328
|
-
let stdoutTail = "";
|
|
329
|
-
let stderrTail = "";
|
|
330
|
-
let lineBuffer = "";
|
|
331
|
-
let finalAssistantText = "";
|
|
332
|
-
let assistantOutputText = "";
|
|
333
|
-
|
|
334
|
-
const consumeLine = (line: string) => {
|
|
335
|
-
const trimmed = line.trim();
|
|
336
|
-
if (!trimmed) return;
|
|
337
|
-
stdoutTail = appendCappedTail(stdoutTail, `${line}\n`);
|
|
338
|
-
try {
|
|
339
|
-
const event = JSON.parse(trimmed) as Record<string, unknown>;
|
|
340
|
-
const assistantText = extractAssistantSnapshot(event, assistantOutputText);
|
|
341
|
-
if (assistantText !== undefined) {
|
|
342
|
-
assistantOutputText = assistantText;
|
|
343
|
-
hooks?.onAssistantText?.(assistantText);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const messageText = summarizeJsonEvent(event);
|
|
347
|
-
if (messageText) hooks?.onEventText?.(messageText);
|
|
348
|
-
|
|
349
|
-
if (event.type === "message_end") {
|
|
350
|
-
const message = event.message;
|
|
351
|
-
if (message && typeof message === "object" && (message as { role?: string }).role === "assistant") {
|
|
352
|
-
const text = extractAssistantText(message);
|
|
353
|
-
if (text) finalAssistantText = normalizeAssistantOutputText(text);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
if (event.type === "agent_end") {
|
|
357
|
-
const messages = (event.messages as unknown[] | undefined) ?? [];
|
|
358
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
359
|
-
const message = messages[i];
|
|
360
|
-
if (message && typeof message === "object" && (message as { role?: string }).role === "assistant") {
|
|
361
|
-
const text = extractAssistantText(message);
|
|
362
|
-
if (text) {
|
|
363
|
-
finalAssistantText = normalizeAssistantOutputText(text);
|
|
364
|
-
break;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
} catch {
|
|
370
|
-
hooks?.onEventText?.(trimmed);
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
proc.stdout.on("data", (d) => {
|
|
375
|
-
lineBuffer += d.toString();
|
|
376
|
-
let newlineIndex = lineBuffer.indexOf("\n");
|
|
377
|
-
while (newlineIndex >= 0) {
|
|
378
|
-
const line = lineBuffer.slice(0, newlineIndex);
|
|
379
|
-
lineBuffer = lineBuffer.slice(newlineIndex + 1);
|
|
380
|
-
consumeLine(line);
|
|
381
|
-
newlineIndex = lineBuffer.indexOf("\n");
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
proc.stderr.on("data", (d) => {
|
|
385
|
-
const chunk = d.toString();
|
|
386
|
-
stderrTail = appendCappedTail(stderrTail, chunk);
|
|
387
|
-
hooks?.onStderr?.(chunk);
|
|
388
|
-
});
|
|
389
|
-
proc.on("close", (code) => {
|
|
390
|
-
if (lineBuffer.trim()) consumeLine(lineBuffer);
|
|
391
|
-
resolve({ stdout: finalAssistantText || assistantOutputText || stdoutTail.trim(), stderr: stderrTail, code: code ?? 0 });
|
|
392
|
-
});
|
|
393
|
-
proc.on("error", () => resolve({ stdout: finalAssistantText || assistantOutputText || stdoutTail.trim(), stderr: stderrTail, code: 1 }));
|
|
394
|
-
if (signal) {
|
|
395
|
-
const abort = () => proc.kill("SIGTERM");
|
|
396
|
-
if (signal.aborted) abort();
|
|
397
|
-
else signal.addEventListener("abort", abort, { once: true });
|
|
398
|
-
}
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// ─── Model Resolution ───────────────────────────────────────────────
|
|
403
|
-
|
|
404
|
-
export async function getAvailableModelKeys(ctx: ExtensionContext): Promise<string[]> {
|
|
405
|
-
try {
|
|
406
|
-
const available = await Promise.resolve(ctx.modelRegistry.getAvailable());
|
|
407
|
-
return available.flatMap((model) => [`${model.provider}/${model.id}`, model.id]);
|
|
408
|
-
} catch {
|
|
409
|
-
return [];
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function modelCandidates(requested?: string, fallback?: string | string[]): string[] {
|
|
414
|
-
const fallbackList = Array.isArray(fallback) ? fallback : fallback ? [fallback] : [];
|
|
415
|
-
return [requested, ...fallbackList].filter(Boolean) as string[];
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
export async function resolvePreferredModel(ctx: ExtensionContext, requested?: string, fallback?: string | string[]): Promise<{ model?: string; warning?: string }> {
|
|
419
|
-
const candidates = modelCandidates(requested, fallback);
|
|
420
|
-
if (candidates.length === 0) return {};
|
|
421
|
-
const keys = await getAvailableModelKeys(ctx);
|
|
422
|
-
const exact = candidates.find((candidate) => keys.includes(candidate));
|
|
423
|
-
if (exact) return { model: exact };
|
|
424
|
-
|
|
425
|
-
const loweredKeys = keys.map((key) => key.toLowerCase());
|
|
426
|
-
for (const candidate of candidates) {
|
|
427
|
-
const idx = loweredKeys.findIndex((key) => key === candidate.toLowerCase());
|
|
428
|
-
if (idx >= 0) return { model: keys[idx] };
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
for (const candidate of candidates) {
|
|
432
|
-
const idx = loweredKeys.findIndex((key) => key.includes(candidate.toLowerCase()) || candidate.toLowerCase().includes(key));
|
|
433
|
-
if (idx >= 0) {
|
|
434
|
-
return {
|
|
435
|
-
model: keys[idx],
|
|
436
|
-
warning: `Requested model '${candidate}' was unavailable; using '${keys[idx]}' instead.`,
|
|
437
|
-
};
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const firstAvailable = keys.find((key) => key.includes("/"));
|
|
442
|
-
if (firstAvailable) {
|
|
443
|
-
return {
|
|
444
|
-
model: firstAvailable,
|
|
445
|
-
warning: `Requested models '${candidates.join("', '")}' were unavailable; using '${firstAvailable}' instead.`,
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
return { warning: `No available signed-in model matched '${candidates.join("', '")}'.` };
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
export async function listModelsViaPi(cwd: string, signal?: AbortSignal): Promise<{ ok: boolean; output: string }> {
|
|
453
|
-
const result = await runPiAgent(cwd, ["--list-models"], signal);
|
|
454
|
-
const output = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n").trim();
|
|
455
|
-
return { ok: result.code === 0 && Boolean(output), output: output || "No model list output returned." };
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
export async function runModelPreflight(ctx: ExtensionContext, _cwd: string, requested?: string, fallback?: string[], _signal?: AbortSignal): Promise<{ model?: string; warning?: string; report: string; cliOk: boolean }> {
|
|
459
|
-
const resolved = await resolvePreferredModel(ctx, requested, fallback);
|
|
460
|
-
const keys = await getAvailableModelKeys(ctx);
|
|
461
|
-
const requestedSummary = modelCandidates(requested, fallback).join(" -> ") || "auto";
|
|
462
|
-
const status = resolved.model
|
|
463
|
-
? `Selected model: ${resolved.model}`
|
|
464
|
-
: `No confirmed model matched request: ${requestedSummary}`;
|
|
465
|
-
const report = [
|
|
466
|
-
"Model preflight (Pi model registry)",
|
|
467
|
-
`Available providers/models: ${keys.filter((key) => key.includes("/")).slice(0, 40).join(", ") || "none reported"}`,
|
|
468
|
-
`Requested: ${requestedSummary}`,
|
|
469
|
-
status,
|
|
470
|
-
resolved.warning ? `Warning: ${resolved.warning}` : "",
|
|
471
|
-
].filter(Boolean).join("\n");
|
|
472
|
-
return { ...resolved, report, cliOk: keys.length > 0 };
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// ─── Task Prompt Building ───────────────────────────────────────────
|
|
476
|
-
|
|
477
|
-
export function buildTaskPrompt(task: {
|
|
478
|
-
task: string;
|
|
479
|
-
stage?: string;
|
|
480
|
-
workflow?: string;
|
|
481
|
-
skills?: string[];
|
|
482
|
-
checklist?: Array<string | { text: string; done?: boolean }>;
|
|
483
|
-
}): string {
|
|
484
|
-
return [
|
|
485
|
-
task.stage ? `Stage: ${task.stage}` : "",
|
|
486
|
-
task.workflow ? `Workflow: ${task.workflow}` : "",
|
|
487
|
-
task.skills?.length ? `Skills: ${task.skills.join(", ")}` : "",
|
|
488
|
-
formatChecklist(task.checklist),
|
|
489
|
-
"Task:",
|
|
490
|
-
task.task,
|
|
491
|
-
].filter(Boolean).join("\n\n");
|
|
492
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for Takomi Pi extensions.
|
|
3
|
+
*
|
|
4
|
+
* Consolidates functions that were duplicated across
|
|
5
|
+
* takomi-runtime/index.ts, takomi-runtime/ui.ts, and
|
|
6
|
+
* takomi-subagents/index.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
// ─── ANSI / String Utilities ────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export function stripAnsi(value: string): string {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
|
20
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
21
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function visibleWidth(value: string): number {
|
|
25
|
+
return stripAnsi(value).length;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function truncateToWidth(value: string, width: number, ellipsis = "..."): string {
|
|
29
|
+
const plain = stripAnsi(value);
|
|
30
|
+
if (plain.length <= width) return plain;
|
|
31
|
+
if (width <= ellipsis.length) return plain.slice(0, width);
|
|
32
|
+
return `${plain.slice(0, width - ellipsis.length)}${ellipsis}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ellipsizeMiddle(value: string, max = 22): string {
|
|
36
|
+
if (value.length <= max) return value;
|
|
37
|
+
const left = Math.max(6, Math.floor((max - 1) / 2));
|
|
38
|
+
const right = Math.max(6, max - left - 1);
|
|
39
|
+
return `${value.slice(0, left)}…${value.slice(-right)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function firstMeaningfulLine(text?: string): string | undefined {
|
|
43
|
+
if (!text) return undefined;
|
|
44
|
+
return text
|
|
45
|
+
.split(/\r?\n/)
|
|
46
|
+
.map((line) => line.trim())
|
|
47
|
+
.find(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sanitizeLogChunk(chunk: string): string[] {
|
|
51
|
+
return stripAnsi(chunk)
|
|
52
|
+
.split(/\r?\n/)
|
|
53
|
+
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.slice(-8);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const STREAMED_LOG_PUNCTUATION = /^[,.;:!?%)\]}]+$/;
|
|
59
|
+
const STRUCTURED_LOG_PREFIX = /^(Tool (start|complete|failed):|Checklist\b|Model preflight\b|Requested:\b|Selected model:\b|Warning:\b|Subagent\b|Redispatch\b|Waiting for first live event)/i;
|
|
60
|
+
|
|
61
|
+
function normalizeLogLine(value: string): string {
|
|
62
|
+
return stripAnsi(value).replace(/\s+/g, " ").trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function shouldMergeStreamedLog(previous: string, next: string): boolean {
|
|
66
|
+
const prior = normalizeLogLine(previous);
|
|
67
|
+
const incoming = normalizeLogLine(next);
|
|
68
|
+
if (!prior || !incoming) return false;
|
|
69
|
+
if (prior.length >= 280) return false;
|
|
70
|
+
if (STRUCTURED_LOG_PREFIX.test(prior) || STRUCTURED_LOG_PREFIX.test(incoming)) return false;
|
|
71
|
+
if (STREAMED_LOG_PUNCTUATION.test(incoming)) return true;
|
|
72
|
+
if (incoming.includes(":")) return false;
|
|
73
|
+
return /^[a-z0-9("'`\[]/.test(incoming) && incoming.length <= 40;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function joinStreamedLog(previous: string, next: string): string {
|
|
77
|
+
const prior = normalizeLogLine(previous);
|
|
78
|
+
const incoming = normalizeLogLine(next);
|
|
79
|
+
if (!prior) return incoming;
|
|
80
|
+
if (!incoming) return prior;
|
|
81
|
+
if (STREAMED_LOG_PUNCTUATION.test(incoming) || /^[,.;:!?%)\]}]/.test(incoming)) return `${prior}${incoming}`;
|
|
82
|
+
if (/^['’]/.test(incoming)) return `${prior}${incoming}`;
|
|
83
|
+
if (/[([{]$/.test(prior)) return `${prior}${incoming}`;
|
|
84
|
+
return `${prior} ${incoming}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function appendLiveLogChunk(existing: string[], chunk: string, limit = 60): string[] {
|
|
88
|
+
const lines = sanitizeLogChunk(chunk);
|
|
89
|
+
if (!lines.length) return existing;
|
|
90
|
+
|
|
91
|
+
const nextLogs = [...existing];
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
const previous = nextLogs.at(-1);
|
|
94
|
+
if (previous && shouldMergeStreamedLog(previous, line)) {
|
|
95
|
+
nextLogs[nextLogs.length - 1] = joinStreamedLog(previous, line);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
nextLogs.push(line);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return nextLogs.slice(-limit);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function formatDuration(ms: number): string {
|
|
105
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
106
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
107
|
+
const seconds = totalSeconds % 60;
|
|
108
|
+
if (minutes >= 60) {
|
|
109
|
+
const hours = Math.floor(minutes / 60);
|
|
110
|
+
const remMinutes = minutes % 60;
|
|
111
|
+
return `${hours}h ${remMinutes}m`;
|
|
112
|
+
}
|
|
113
|
+
if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
114
|
+
return `${seconds}s`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function formatFooterNumber(value: number): string {
|
|
118
|
+
if (value < 1000) return `${value}`;
|
|
119
|
+
return `${(value / 1000).toFixed(1)}k`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function wrapLabel(value: string, width: number): string[] {
|
|
123
|
+
const plain = stripAnsi(value);
|
|
124
|
+
const target = Math.max(8, width);
|
|
125
|
+
const words = plain.split(/\s+/).filter(Boolean);
|
|
126
|
+
const lines: string[] = [];
|
|
127
|
+
let current = "";
|
|
128
|
+
|
|
129
|
+
for (const word of words) {
|
|
130
|
+
const next = current ? `${current} ${word}` : word;
|
|
131
|
+
if (next.length <= target) {
|
|
132
|
+
current = next;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (current) lines.push(current);
|
|
136
|
+
if (word.length <= target) {
|
|
137
|
+
current = word;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
let remainder = word;
|
|
141
|
+
while (remainder.length > target) {
|
|
142
|
+
lines.push(remainder.slice(0, target));
|
|
143
|
+
remainder = remainder.slice(target);
|
|
144
|
+
}
|
|
145
|
+
current = remainder;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (current) lines.push(current);
|
|
149
|
+
return lines.length ? lines : [plain];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Checklist Utilities ────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
export type ChecklistInput = Array<string | { text: string; done?: boolean }> | undefined;
|
|
155
|
+
|
|
156
|
+
export function formatChecklist(checklist?: Array<string | { text: string; done?: boolean }>): string {
|
|
157
|
+
if (!checklist?.length) return "";
|
|
158
|
+
return [
|
|
159
|
+
"Checklist:",
|
|
160
|
+
...checklist.map((item) => typeof item === "string" ? `- [ ] ${item}` : `- [${item.done ? "x" : " "}] ${item.text}`),
|
|
161
|
+
].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function checklistProgress(checklist?: ChecklistInput): string | undefined {
|
|
165
|
+
if (!checklist?.length) return undefined;
|
|
166
|
+
const normalized = checklist.map((item) => (typeof item === "string" ? { text: item, done: false } : item));
|
|
167
|
+
const done = normalized.filter((item) => item.done).length;
|
|
168
|
+
return `${done}/${normalized.length}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Pi Process Invocation ──────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
export function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
174
|
+
const currentScript = process.argv[1];
|
|
175
|
+
if (currentScript) return { command: process.execPath, args: [currentScript, ...args] };
|
|
176
|
+
return { command: "pi", args };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function writeTempPrompt(agentName: string, prompt: string): Promise<string> {
|
|
180
|
+
const tmpDir = path.join(os.tmpdir(), `takomi-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
181
|
+
await mkdir(tmpDir, { recursive: true });
|
|
182
|
+
const filePath = path.join(tmpDir, `${agentName}.md`);
|
|
183
|
+
await writeFile(filePath, prompt, "utf8");
|
|
184
|
+
return filePath;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Subagent Process Runners ───────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
export type RunHooks = {
|
|
190
|
+
onStdout?: (chunk: string) => void;
|
|
191
|
+
onStderr?: (chunk: string) => void;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export type JsonRunHooks = {
|
|
195
|
+
onAssistantText?: (text: string) => void;
|
|
196
|
+
onEventText?: (line: string) => void;
|
|
197
|
+
onStderr?: (chunk: string) => void;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const JSON_OUTPUT_TAIL_LIMIT = 64_000;
|
|
201
|
+
|
|
202
|
+
function appendCappedTail(current: string, next: string, limit = JSON_OUTPUT_TAIL_LIMIT): string {
|
|
203
|
+
if (!next) return current;
|
|
204
|
+
const boundedNext = next.length > limit ? next.slice(-limit) : next;
|
|
205
|
+
const remaining = limit - boundedNext.length;
|
|
206
|
+
if (remaining <= 0) return boundedNext;
|
|
207
|
+
if (current.length <= remaining) return current + boundedNext;
|
|
208
|
+
return current.slice(-remaining) + boundedNext;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function extractAssistantText(message: unknown): string {
|
|
212
|
+
if (!message || typeof message !== "object") return "";
|
|
213
|
+
const content = (message as { content?: unknown }).content;
|
|
214
|
+
if (!Array.isArray(content)) return "";
|
|
215
|
+
return content
|
|
216
|
+
.filter((block): block is { type?: string; text?: string } => Boolean(block) && typeof block === "object")
|
|
217
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
218
|
+
.map((block) => block.text ?? "")
|
|
219
|
+
.join("\n")
|
|
220
|
+
.trim();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeAssistantOutputText(value: string): string {
|
|
224
|
+
return stripAnsi(value)
|
|
225
|
+
.replace(/\r/g, "")
|
|
226
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
227
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
228
|
+
.trim();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function extractAssistantSnapshot(event: Record<string, unknown>, currentText: string): string | undefined {
|
|
232
|
+
const type = typeof event.type === "string" ? event.type : "";
|
|
233
|
+
|
|
234
|
+
if (type === "message_update") {
|
|
235
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
236
|
+
if (assistantEvent && typeof assistantEvent === "object") {
|
|
237
|
+
const delta = (assistantEvent as { delta?: unknown }).delta;
|
|
238
|
+
if (typeof delta === "string" && delta) {
|
|
239
|
+
const next = normalizeAssistantOutputText(`${currentText}${delta}`);
|
|
240
|
+
return next && next !== currentText ? next : undefined;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const messageText = normalizeAssistantOutputText(extractAssistantText(event.message));
|
|
245
|
+
return messageText && messageText !== currentText ? messageText : undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (type === "message_end") {
|
|
249
|
+
const message = event.message;
|
|
250
|
+
if (message && typeof message === "object" && (message as { role?: string }).role === "assistant") {
|
|
251
|
+
const messageText = normalizeAssistantOutputText(extractAssistantText(message));
|
|
252
|
+
return messageText && messageText !== currentText ? messageText : undefined;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (type === "agent_end") {
|
|
257
|
+
const messages = (event.messages as unknown[] | undefined) ?? [];
|
|
258
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
259
|
+
const message = messages[i];
|
|
260
|
+
if (message && typeof message === "object" && (message as { role?: string }).role === "assistant") {
|
|
261
|
+
const messageText = normalizeAssistantOutputText(extractAssistantText(message));
|
|
262
|
+
return messageText && messageText !== currentText ? messageText : undefined;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function summarizeJsonEvent(event: Record<string, unknown>): string | undefined {
|
|
271
|
+
const type = typeof event.type === "string" ? event.type : "";
|
|
272
|
+
if (type === "tool_execution_start") {
|
|
273
|
+
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
|
274
|
+
return `Tool start: ${toolName}`;
|
|
275
|
+
}
|
|
276
|
+
if (type === "tool_execution_update") {
|
|
277
|
+
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
|
278
|
+
const partial = event.partialResult;
|
|
279
|
+
if (partial && typeof partial === "object") {
|
|
280
|
+
const partialText = extractAssistantText(partial);
|
|
281
|
+
if (partialText) return `${toolName}: ${partialText.split(/\r?\n/).find(Boolean)?.trim()}`;
|
|
282
|
+
const details = (partial as { details?: { output?: string; stdout?: string } }).details;
|
|
283
|
+
const output = details?.output ?? details?.stdout;
|
|
284
|
+
if (typeof output === "string" && output.trim()) {
|
|
285
|
+
return `${toolName}: ${output.split(/\r?\n/).find(Boolean)?.trim()}`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return `${toolName}: update`;
|
|
289
|
+
}
|
|
290
|
+
if (type === "tool_execution_end") {
|
|
291
|
+
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
|
292
|
+
const isError = event.isError === true;
|
|
293
|
+
return isError ? `Tool failed: ${toolName}` : `Tool complete: ${toolName}`;
|
|
294
|
+
}
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function runPiAgent(cwd: string, args: string[], signal?: AbortSignal, hooks?: RunHooks): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
299
|
+
return new Promise((resolve) => {
|
|
300
|
+
const invocation = getPiInvocation(args);
|
|
301
|
+
const proc = spawn(invocation.command, invocation.args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: false });
|
|
302
|
+
let stdout = "";
|
|
303
|
+
let stderr = "";
|
|
304
|
+
proc.stdout.on("data", (d) => {
|
|
305
|
+
const chunk = d.toString();
|
|
306
|
+
stdout += chunk;
|
|
307
|
+
hooks?.onStdout?.(chunk);
|
|
308
|
+
});
|
|
309
|
+
proc.stderr.on("data", (d) => {
|
|
310
|
+
const chunk = d.toString();
|
|
311
|
+
stderr += chunk;
|
|
312
|
+
hooks?.onStderr?.(chunk);
|
|
313
|
+
});
|
|
314
|
+
proc.on("close", (code) => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
315
|
+
proc.on("error", () => resolve({ stdout, stderr, code: 1 }));
|
|
316
|
+
if (signal) {
|
|
317
|
+
const abort = () => proc.kill("SIGTERM");
|
|
318
|
+
if (signal.aborted) abort();
|
|
319
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function runPiAgentJson(cwd: string, args: string[], signal?: AbortSignal, hooks?: JsonRunHooks): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
325
|
+
return new Promise((resolve) => {
|
|
326
|
+
const invocation = getPiInvocation(args);
|
|
327
|
+
const proc = spawn(invocation.command, invocation.args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: false });
|
|
328
|
+
let stdoutTail = "";
|
|
329
|
+
let stderrTail = "";
|
|
330
|
+
let lineBuffer = "";
|
|
331
|
+
let finalAssistantText = "";
|
|
332
|
+
let assistantOutputText = "";
|
|
333
|
+
|
|
334
|
+
const consumeLine = (line: string) => {
|
|
335
|
+
const trimmed = line.trim();
|
|
336
|
+
if (!trimmed) return;
|
|
337
|
+
stdoutTail = appendCappedTail(stdoutTail, `${line}\n`);
|
|
338
|
+
try {
|
|
339
|
+
const event = JSON.parse(trimmed) as Record<string, unknown>;
|
|
340
|
+
const assistantText = extractAssistantSnapshot(event, assistantOutputText);
|
|
341
|
+
if (assistantText !== undefined) {
|
|
342
|
+
assistantOutputText = assistantText;
|
|
343
|
+
hooks?.onAssistantText?.(assistantText);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const messageText = summarizeJsonEvent(event);
|
|
347
|
+
if (messageText) hooks?.onEventText?.(messageText);
|
|
348
|
+
|
|
349
|
+
if (event.type === "message_end") {
|
|
350
|
+
const message = event.message;
|
|
351
|
+
if (message && typeof message === "object" && (message as { role?: string }).role === "assistant") {
|
|
352
|
+
const text = extractAssistantText(message);
|
|
353
|
+
if (text) finalAssistantText = normalizeAssistantOutputText(text);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (event.type === "agent_end") {
|
|
357
|
+
const messages = (event.messages as unknown[] | undefined) ?? [];
|
|
358
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
359
|
+
const message = messages[i];
|
|
360
|
+
if (message && typeof message === "object" && (message as { role?: string }).role === "assistant") {
|
|
361
|
+
const text = extractAssistantText(message);
|
|
362
|
+
if (text) {
|
|
363
|
+
finalAssistantText = normalizeAssistantOutputText(text);
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
hooks?.onEventText?.(trimmed);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
proc.stdout.on("data", (d) => {
|
|
375
|
+
lineBuffer += d.toString();
|
|
376
|
+
let newlineIndex = lineBuffer.indexOf("\n");
|
|
377
|
+
while (newlineIndex >= 0) {
|
|
378
|
+
const line = lineBuffer.slice(0, newlineIndex);
|
|
379
|
+
lineBuffer = lineBuffer.slice(newlineIndex + 1);
|
|
380
|
+
consumeLine(line);
|
|
381
|
+
newlineIndex = lineBuffer.indexOf("\n");
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
proc.stderr.on("data", (d) => {
|
|
385
|
+
const chunk = d.toString();
|
|
386
|
+
stderrTail = appendCappedTail(stderrTail, chunk);
|
|
387
|
+
hooks?.onStderr?.(chunk);
|
|
388
|
+
});
|
|
389
|
+
proc.on("close", (code) => {
|
|
390
|
+
if (lineBuffer.trim()) consumeLine(lineBuffer);
|
|
391
|
+
resolve({ stdout: finalAssistantText || assistantOutputText || stdoutTail.trim(), stderr: stderrTail, code: code ?? 0 });
|
|
392
|
+
});
|
|
393
|
+
proc.on("error", () => resolve({ stdout: finalAssistantText || assistantOutputText || stdoutTail.trim(), stderr: stderrTail, code: 1 }));
|
|
394
|
+
if (signal) {
|
|
395
|
+
const abort = () => proc.kill("SIGTERM");
|
|
396
|
+
if (signal.aborted) abort();
|
|
397
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ─── Model Resolution ───────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
export async function getAvailableModelKeys(ctx: ExtensionContext): Promise<string[]> {
|
|
405
|
+
try {
|
|
406
|
+
const available = await Promise.resolve(ctx.modelRegistry.getAvailable());
|
|
407
|
+
return available.flatMap((model) => [`${model.provider}/${model.id}`, model.id]);
|
|
408
|
+
} catch {
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function modelCandidates(requested?: string, fallback?: string | string[]): string[] {
|
|
414
|
+
const fallbackList = Array.isArray(fallback) ? fallback : fallback ? [fallback] : [];
|
|
415
|
+
return [requested, ...fallbackList].filter(Boolean) as string[];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export async function resolvePreferredModel(ctx: ExtensionContext, requested?: string, fallback?: string | string[]): Promise<{ model?: string; warning?: string }> {
|
|
419
|
+
const candidates = modelCandidates(requested, fallback);
|
|
420
|
+
if (candidates.length === 0) return {};
|
|
421
|
+
const keys = await getAvailableModelKeys(ctx);
|
|
422
|
+
const exact = candidates.find((candidate) => keys.includes(candidate));
|
|
423
|
+
if (exact) return { model: exact };
|
|
424
|
+
|
|
425
|
+
const loweredKeys = keys.map((key) => key.toLowerCase());
|
|
426
|
+
for (const candidate of candidates) {
|
|
427
|
+
const idx = loweredKeys.findIndex((key) => key === candidate.toLowerCase());
|
|
428
|
+
if (idx >= 0) return { model: keys[idx] };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const candidate of candidates) {
|
|
432
|
+
const idx = loweredKeys.findIndex((key) => key.includes(candidate.toLowerCase()) || candidate.toLowerCase().includes(key));
|
|
433
|
+
if (idx >= 0) {
|
|
434
|
+
return {
|
|
435
|
+
model: keys[idx],
|
|
436
|
+
warning: `Requested model '${candidate}' was unavailable; using '${keys[idx]}' instead.`,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const firstAvailable = keys.find((key) => key.includes("/"));
|
|
442
|
+
if (firstAvailable) {
|
|
443
|
+
return {
|
|
444
|
+
model: firstAvailable,
|
|
445
|
+
warning: `Requested models '${candidates.join("', '")}' were unavailable; using '${firstAvailable}' instead.`,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return { warning: `No available signed-in model matched '${candidates.join("', '")}'.` };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export async function listModelsViaPi(cwd: string, signal?: AbortSignal): Promise<{ ok: boolean; output: string }> {
|
|
453
|
+
const result = await runPiAgent(cwd, ["--list-models"], signal);
|
|
454
|
+
const output = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n").trim();
|
|
455
|
+
return { ok: result.code === 0 && Boolean(output), output: output || "No model list output returned." };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function runModelPreflight(ctx: ExtensionContext, _cwd: string, requested?: string, fallback?: string[], _signal?: AbortSignal): Promise<{ model?: string; warning?: string; report: string; cliOk: boolean }> {
|
|
459
|
+
const resolved = await resolvePreferredModel(ctx, requested, fallback);
|
|
460
|
+
const keys = await getAvailableModelKeys(ctx);
|
|
461
|
+
const requestedSummary = modelCandidates(requested, fallback).join(" -> ") || "auto";
|
|
462
|
+
const status = resolved.model
|
|
463
|
+
? `Selected model: ${resolved.model}`
|
|
464
|
+
: `No confirmed model matched request: ${requestedSummary}`;
|
|
465
|
+
const report = [
|
|
466
|
+
"Model preflight (Pi model registry)",
|
|
467
|
+
`Available providers/models: ${keys.filter((key) => key.includes("/")).slice(0, 40).join(", ") || "none reported"}`,
|
|
468
|
+
`Requested: ${requestedSummary}`,
|
|
469
|
+
status,
|
|
470
|
+
resolved.warning ? `Warning: ${resolved.warning}` : "",
|
|
471
|
+
].filter(Boolean).join("\n");
|
|
472
|
+
return { ...resolved, report, cliOk: keys.length > 0 };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ─── Task Prompt Building ───────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
export function buildTaskPrompt(task: {
|
|
478
|
+
task: string;
|
|
479
|
+
stage?: string;
|
|
480
|
+
workflow?: string;
|
|
481
|
+
skills?: string[];
|
|
482
|
+
checklist?: Array<string | { text: string; done?: boolean }>;
|
|
483
|
+
}): string {
|
|
484
|
+
return [
|
|
485
|
+
task.stage ? `Stage: ${task.stage}` : "",
|
|
486
|
+
task.workflow ? `Workflow: ${task.workflow}` : "",
|
|
487
|
+
task.skills?.length ? `Skills: ${task.skills.join(", ")}` : "",
|
|
488
|
+
formatChecklist(task.checklist),
|
|
489
|
+
"Task:",
|
|
490
|
+
task.task,
|
|
491
|
+
].filter(Boolean).join("\n\n");
|
|
492
|
+
}
|