@smithers-orchestrator/pi-plugin 0.16.9
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/package.json +64 -0
- package/src/SmithersPiRunContext.ts +7 -0
- package/src/api/SmithersPiHttpClient.ts +86 -0
- package/src/api/approve.ts +23 -0
- package/src/api/cancel.ts +14 -0
- package/src/api/deny.ts +23 -0
- package/src/api/getFrames.ts +14 -0
- package/src/api/getStatus.ts +11 -0
- package/src/api/listRuns.ts +20 -0
- package/src/api/resume.ts +19 -0
- package/src/api/runWorkflow.ts +20 -0
- package/src/api/streamEvents.ts +11 -0
- package/src/buildSmithersPiSystemPrompt.ts +120 -0
- package/src/extension.ts +571 -0
- package/src/index.d.ts +443 -0
- package/src/index.ts +18 -0
- package/src/runtime/DevToolsClient.ts +528 -0
- package/src/runtime/DevToolsStore.ts +927 -0
- package/src/views/FrameScrubber.ts +72 -0
- package/src/views/Header.ts +144 -0
- package/src/views/NodeInspector.ts +221 -0
- package/src/views/RunInspector.ts +232 -0
- package/src/views/RunTree.ts +404 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
|
+
import type { ExtensionAPI as PiExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
import { Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
9
|
+
import { Type } from "@sinclair/typebox";
|
|
10
|
+
import {
|
|
11
|
+
createSmithersAgentContract,
|
|
12
|
+
type SmithersAgentContract,
|
|
13
|
+
} from "@smithers-orchestrator/agents/agent-contract";
|
|
14
|
+
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
15
|
+
import { buildSmithersPiSystemPrompt } from "./buildSmithersPiSystemPrompt.js";
|
|
16
|
+
import { DevToolsClient } from "./runtime/DevToolsClient.js";
|
|
17
|
+
import { DevToolsStore } from "./runtime/DevToolsStore.js";
|
|
18
|
+
import { RunInspector } from "./views/RunInspector.js";
|
|
19
|
+
|
|
20
|
+
type ExtensionAPI = PiExtensionAPI & {
|
|
21
|
+
registerFlag: (name: string, config: Record<string, unknown>) => void;
|
|
22
|
+
getFlag: (name: string) => string | undefined;
|
|
23
|
+
on: (event: string, handler: (...args: any[]) => unknown) => void;
|
|
24
|
+
registerTool: (tool: Record<string, unknown>) => void;
|
|
25
|
+
registerCommand: (name: string, command: Record<string, unknown>) => void;
|
|
26
|
+
registerMessageRenderer: (name: string, renderer: (...args: any[]) => unknown) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type ExtensionContext = {
|
|
30
|
+
hasUI?: boolean;
|
|
31
|
+
ui: {
|
|
32
|
+
notify: (message: string, level?: "info" | "warning" | "error") => void;
|
|
33
|
+
custom: (factory: (...args: any[]) => unknown) => Promise<void>;
|
|
34
|
+
input: (title: string, placeholder?: string) => Promise<string | undefined>;
|
|
35
|
+
select: (title: string, options: string[]) => Promise<string | undefined>;
|
|
36
|
+
confirm: (title: string, message?: string) => Promise<boolean>;
|
|
37
|
+
setHeader?: (factory: (...args: any[]) => unknown) => void;
|
|
38
|
+
setFooter?: (factory: (...args: any[]) => unknown) => void;
|
|
39
|
+
setStatus?: (name: string, status: string | undefined) => void;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type TrackedRun = {
|
|
44
|
+
runId: string;
|
|
45
|
+
workflowName: string;
|
|
46
|
+
client: DevToolsClient;
|
|
47
|
+
store: DevToolsStore;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const DEFAULT_BASE = "http://127.0.0.1:7331";
|
|
51
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
52
|
+
|
|
53
|
+
let piRef: ExtensionAPI | undefined;
|
|
54
|
+
let smithersDocs: string | undefined;
|
|
55
|
+
let mcpClient: Client | undefined;
|
|
56
|
+
let mcpTransport: StdioClientTransport | undefined;
|
|
57
|
+
let smithersToolContract: SmithersAgentContract | undefined;
|
|
58
|
+
let pollInterval: ReturnType<typeof setInterval> | undefined;
|
|
59
|
+
let activeRunId: string | undefined;
|
|
60
|
+
|
|
61
|
+
const runs = new Map<string, TrackedRun>();
|
|
62
|
+
|
|
63
|
+
function getBase() {
|
|
64
|
+
const value = piRef?.getFlag("smithers-url");
|
|
65
|
+
return typeof value === "string" && value.length > 0 ? value : DEFAULT_BASE;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getApiKey() {
|
|
69
|
+
const value = piRef?.getFlag("smithers-key");
|
|
70
|
+
return typeof value === "string" && value.length > 0 ? value : process.env.SMITHERS_API_KEY || undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function loadSmithersDocs() {
|
|
74
|
+
if (smithersDocs) {
|
|
75
|
+
return smithersDocs;
|
|
76
|
+
}
|
|
77
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
78
|
+
const candidates = [
|
|
79
|
+
resolve(thisDir, "../../../docs/llms-full.txt"),
|
|
80
|
+
resolve(process.cwd(), "docs/llms-full.txt"),
|
|
81
|
+
resolve(process.cwd(), "node_modules/smithers-orchestrator/docs/llms-full.txt"),
|
|
82
|
+
];
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
try {
|
|
85
|
+
smithersDocs = readFileSync(candidate, "utf8");
|
|
86
|
+
return smithersDocs;
|
|
87
|
+
} catch {
|
|
88
|
+
// try next
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const fallbacks = [
|
|
92
|
+
resolve(thisDir, "../../../docs/llms.txt"),
|
|
93
|
+
resolve(process.cwd(), "docs/llms.txt"),
|
|
94
|
+
resolve(process.cwd(), "node_modules/smithers-orchestrator/docs/llms.txt"),
|
|
95
|
+
];
|
|
96
|
+
for (const candidate of fallbacks) {
|
|
97
|
+
try {
|
|
98
|
+
smithersDocs = readFileSync(candidate, "utf8");
|
|
99
|
+
return smithersDocs;
|
|
100
|
+
} catch {
|
|
101
|
+
// try next
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
smithersDocs = "(Smithers docs not found - check that docs/llms-full.txt exists)";
|
|
105
|
+
return smithersDocs;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveCliPath() {
|
|
109
|
+
try {
|
|
110
|
+
return requireFromHere.resolve("@smithers-orchestrator/cli");
|
|
111
|
+
} catch {
|
|
112
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "../../../apps/cli/src/index.js");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function ensureMcpClient() {
|
|
117
|
+
if (mcpClient) {
|
|
118
|
+
return mcpClient;
|
|
119
|
+
}
|
|
120
|
+
mcpTransport = new StdioClientTransport({
|
|
121
|
+
command: "bun",
|
|
122
|
+
args: ["run", resolveCliPath(), "--mcp"],
|
|
123
|
+
cwd: process.cwd(),
|
|
124
|
+
stderr: "pipe",
|
|
125
|
+
});
|
|
126
|
+
mcpClient = new Client({ name: "smithers-pi-extension", version: "1.0.0" });
|
|
127
|
+
await mcpClient.connect(mcpTransport);
|
|
128
|
+
return mcpClient;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function ensureSmithersToolContract() {
|
|
132
|
+
if (smithersToolContract) {
|
|
133
|
+
return smithersToolContract;
|
|
134
|
+
}
|
|
135
|
+
const client = await ensureMcpClient();
|
|
136
|
+
const { tools } = await client.listTools();
|
|
137
|
+
smithersToolContract = createSmithersAgentContract({
|
|
138
|
+
serverName: "smithers",
|
|
139
|
+
toolSurface: "semantic",
|
|
140
|
+
tools: tools
|
|
141
|
+
.filter((tool) => tool.name !== "tui")
|
|
142
|
+
.map((tool) => ({
|
|
143
|
+
name: tool.name,
|
|
144
|
+
description: tool.description,
|
|
145
|
+
})),
|
|
146
|
+
});
|
|
147
|
+
return smithersToolContract;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function callMcpTool(name: string, args: Record<string, unknown>) {
|
|
151
|
+
const client = await ensureMcpClient();
|
|
152
|
+
const result = await client.callTool({ name, arguments: args });
|
|
153
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
154
|
+
const text = content
|
|
155
|
+
.filter((item): item is { type: "text"; text: string } => item.type === "text" && typeof item.text === "string")
|
|
156
|
+
.map((item) => item.text)
|
|
157
|
+
.join("\n");
|
|
158
|
+
return { text, isError: result.isError === true };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function jsonSchemaToTypebox(schema: Record<string, any>) {
|
|
162
|
+
const properties = schema.properties ?? {};
|
|
163
|
+
const required = new Set<string>(schema.required ?? []);
|
|
164
|
+
const result: Record<string, any> = {};
|
|
165
|
+
for (const [key, prop] of Object.entries<any>(properties)) {
|
|
166
|
+
const opts = prop.description ? { description: prop.description } : {};
|
|
167
|
+
let field;
|
|
168
|
+
switch (prop.type) {
|
|
169
|
+
case "number":
|
|
170
|
+
case "integer":
|
|
171
|
+
field = Type.Number(opts);
|
|
172
|
+
break;
|
|
173
|
+
case "boolean":
|
|
174
|
+
field = Type.Boolean(opts);
|
|
175
|
+
break;
|
|
176
|
+
case "array":
|
|
177
|
+
field = Type.Array(Type.String(), opts);
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
180
|
+
field = Type.String(opts);
|
|
181
|
+
}
|
|
182
|
+
result[key] = required.has(key) ? field : Type.Optional(field);
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function statusIcon(status: string) {
|
|
188
|
+
switch (status) {
|
|
189
|
+
case "running":
|
|
190
|
+
return ">";
|
|
191
|
+
case "finished":
|
|
192
|
+
return "v";
|
|
193
|
+
case "failed":
|
|
194
|
+
return "x";
|
|
195
|
+
case "cancelled":
|
|
196
|
+
return "-";
|
|
197
|
+
case "waiting-approval":
|
|
198
|
+
return "!";
|
|
199
|
+
default:
|
|
200
|
+
return "o";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function stripAnsi(value: string) {
|
|
205
|
+
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), "");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function collectNodeStates(run: TrackedRun) {
|
|
209
|
+
const states: Array<{ nodeId: string; state: string }> = [];
|
|
210
|
+
const walk = (node: any) => {
|
|
211
|
+
if (node?.task?.nodeId) {
|
|
212
|
+
states.push({
|
|
213
|
+
nodeId: node.task.nodeId,
|
|
214
|
+
state: typeof node.props?.state === "string" ? node.props.state : "unknown",
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
for (const child of node?.children ?? []) {
|
|
218
|
+
walk(child);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
walk(run.store.tree);
|
|
222
|
+
return states;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function collectErrors(run: TrackedRun) {
|
|
226
|
+
const errors: string[] = [];
|
|
227
|
+
const walk = (node: any) => {
|
|
228
|
+
if (node?.props?.error !== undefined) {
|
|
229
|
+
errors.push(`${node.task?.nodeId ?? node.name}: ${String(node.props.error)}`);
|
|
230
|
+
}
|
|
231
|
+
for (const child of node?.children ?? []) {
|
|
232
|
+
walk(child);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
walk(run.store.tree);
|
|
236
|
+
return errors;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function trackRun(runId: string, workflowName = "workflow") {
|
|
240
|
+
const existing = runs.get(runId);
|
|
241
|
+
if (existing) {
|
|
242
|
+
activeRunId = runId;
|
|
243
|
+
return existing;
|
|
244
|
+
}
|
|
245
|
+
const client = new DevToolsClient({ baseUrl: getBase(), apiKey: getApiKey() });
|
|
246
|
+
const store = new DevToolsStore({ client });
|
|
247
|
+
const run = { runId, workflowName, client, store };
|
|
248
|
+
runs.set(runId, run);
|
|
249
|
+
activeRunId = runId;
|
|
250
|
+
store.connect(runId);
|
|
251
|
+
return run;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function updateStatusBar(ctx: ExtensionContext) {
|
|
255
|
+
const active = [...runs.values()].filter((run) => !run.store.isRunFinished);
|
|
256
|
+
const failed = [...runs.values()].filter((run) => run.store.runStatus === "failed");
|
|
257
|
+
const parts: string[] = [];
|
|
258
|
+
if (active.length > 0) {
|
|
259
|
+
parts.push(`${active.length} active`);
|
|
260
|
+
}
|
|
261
|
+
if (failed.length > 0) {
|
|
262
|
+
parts.push(`${failed.length} failed`);
|
|
263
|
+
}
|
|
264
|
+
ctx.ui.setStatus?.("smithers", parts.length > 0 ? `smithers: ${parts.join(" ")}` : undefined);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function openInspector(ctx: ExtensionContext, run: TrackedRun) {
|
|
268
|
+
if (!ctx.hasUI) {
|
|
269
|
+
ctx.ui.notify("/smithers requires interactive mode", "error");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
await ctx.ui.custom((_tui: unknown, theme: unknown, _kb: unknown, done: () => void) =>
|
|
273
|
+
new RunInspector(run.store, run.client, {
|
|
274
|
+
workflowName: run.workflowName,
|
|
275
|
+
onClose: done,
|
|
276
|
+
onNotify: (message, level) => ctx.ui.notify(message, level),
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function registerMcpTools(pi: ExtensionAPI, ctx: ExtensionContext) {
|
|
282
|
+
try {
|
|
283
|
+
const client = await ensureMcpClient();
|
|
284
|
+
const { tools } = await client.listTools();
|
|
285
|
+
smithersToolContract = createSmithersAgentContract({
|
|
286
|
+
serverName: "smithers",
|
|
287
|
+
toolSurface: "semantic",
|
|
288
|
+
tools: tools
|
|
289
|
+
.filter((tool) => tool.name !== "tui")
|
|
290
|
+
.map((tool) => ({ name: tool.name, description: tool.description })),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
for (const tool of tools) {
|
|
294
|
+
if (tool.name === "tui") {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
pi.registerTool({
|
|
298
|
+
name: `smithers_${tool.name}`,
|
|
299
|
+
label: `Smithers ${tool.name}`,
|
|
300
|
+
description: tool.description ?? `Run smithers ${tool.name.replace(/_/g, " ")}`,
|
|
301
|
+
parameters: Type.Object(jsonSchemaToTypebox((tool.inputSchema ?? {}) as Record<string, any>)),
|
|
302
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
303
|
+
const cleanParams: Record<string, unknown> = {};
|
|
304
|
+
for (const [key, value] of Object.entries(params)) {
|
|
305
|
+
if (value !== undefined) {
|
|
306
|
+
cleanParams[key] = value;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const result = await callMcpTool(tool.name, cleanParams);
|
|
310
|
+
return {
|
|
311
|
+
content: [{ type: "text", text: result.text }],
|
|
312
|
+
details: { tool: tool.name, isError: result.isError },
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
renderCall(args: Record<string, unknown>, theme: any) {
|
|
316
|
+
const argStr = Object.entries(args)
|
|
317
|
+
.filter(([, value]) => value !== undefined)
|
|
318
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
319
|
+
.join(" ");
|
|
320
|
+
return new Text(
|
|
321
|
+
theme.fg("toolTitle", theme.bold(`smithers ${tool.name.replace(/_/g, " ")} `)) +
|
|
322
|
+
theme.fg("muted", argStr),
|
|
323
|
+
0,
|
|
324
|
+
0,
|
|
325
|
+
);
|
|
326
|
+
},
|
|
327
|
+
renderResult(result: any, _opts: unknown, theme: any) {
|
|
328
|
+
if (result.details?.isError) {
|
|
329
|
+
const text = result.content?.[0];
|
|
330
|
+
return new Text(theme.fg("error", `x ${text?.type === "text" ? text.text : "error"}`), 0, 0);
|
|
331
|
+
}
|
|
332
|
+
return new Text("", 0, 0);
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
} catch (error) {
|
|
337
|
+
ctx.ui.notify(`Smithers MCP: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function extension(pi: ExtensionAPI) {
|
|
342
|
+
piRef = pi;
|
|
343
|
+
|
|
344
|
+
pi.registerFlag("smithers-url", {
|
|
345
|
+
description: "Smithers gateway URL (default: http://127.0.0.1:7331)",
|
|
346
|
+
type: "string",
|
|
347
|
+
default: DEFAULT_BASE,
|
|
348
|
+
});
|
|
349
|
+
pi.registerFlag("smithers-key", {
|
|
350
|
+
description: "Smithers API key",
|
|
351
|
+
type: "string",
|
|
352
|
+
default: "",
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
356
|
+
pollInterval = setInterval(() => updateStatusBar(ctx), 5_000);
|
|
357
|
+
await registerMcpTools(pi, ctx);
|
|
358
|
+
if (ctx.hasUI) {
|
|
359
|
+
ctx.ui.setHeader?.((_tui: unknown, theme: any) => ({
|
|
360
|
+
render(width: number) {
|
|
361
|
+
return [truncateToWidth(` ${theme.fg("accent", theme.bold("smithers"))} ${theme.fg("muted", "PI inspector")}`, width)];
|
|
362
|
+
},
|
|
363
|
+
invalidate() {},
|
|
364
|
+
}));
|
|
365
|
+
ctx.ui.setFooter?.((_tui: unknown, theme: any, footerData: any) => ({
|
|
366
|
+
render(width: number) {
|
|
367
|
+
const statuses = footerData.getExtensionStatuses?.();
|
|
368
|
+
const smithersStatus = statuses?.get?.("smithers") ?? "smithers: idle";
|
|
369
|
+
const branch = footerData.getGitBranch?.();
|
|
370
|
+
const left = ` ${theme.fg("muted", smithersStatus)}`;
|
|
371
|
+
const right = branch ? theme.fg("dim", ` ${branch}`) : "";
|
|
372
|
+
const gap = Math.max(0, width - stripAnsi(left).length - stripAnsi(right).length);
|
|
373
|
+
return [truncateToWidth(left + " ".repeat(gap) + right, width)];
|
|
374
|
+
},
|
|
375
|
+
invalidate() {},
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
updateStatusBar(ctx);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
pi.on("session_shutdown", async () => {
|
|
382
|
+
if (pollInterval) {
|
|
383
|
+
clearInterval(pollInterval);
|
|
384
|
+
}
|
|
385
|
+
for (const run of runs.values()) {
|
|
386
|
+
run.store.disconnect();
|
|
387
|
+
}
|
|
388
|
+
runs.clear();
|
|
389
|
+
if (mcpTransport) {
|
|
390
|
+
await mcpTransport.close().catch(() => undefined);
|
|
391
|
+
}
|
|
392
|
+
mcpTransport = undefined;
|
|
393
|
+
mcpClient = undefined;
|
|
394
|
+
smithersToolContract = undefined;
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
pi.on("before_agent_start", async (event: { systemPrompt: string }) => {
|
|
398
|
+
const docs = loadSmithersDocs();
|
|
399
|
+
const contract = await ensureSmithersToolContract();
|
|
400
|
+
const active = activeRunId ? runs.get(activeRunId) : undefined;
|
|
401
|
+
return {
|
|
402
|
+
systemPrompt: buildSmithersPiSystemPrompt(
|
|
403
|
+
event.systemPrompt,
|
|
404
|
+
docs,
|
|
405
|
+
contract,
|
|
406
|
+
active
|
|
407
|
+
? {
|
|
408
|
+
runId: active.runId,
|
|
409
|
+
workflowName: active.workflowName,
|
|
410
|
+
status: active.store.runStatus,
|
|
411
|
+
nodeStates: collectNodeStates(active),
|
|
412
|
+
errors: collectErrors(active),
|
|
413
|
+
}
|
|
414
|
+
: undefined,
|
|
415
|
+
),
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
pi.registerCommand("smithers", {
|
|
420
|
+
description: "Open the Smithers live run inspector",
|
|
421
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
422
|
+
const requested = args.trim();
|
|
423
|
+
let run = requested ? trackRun(requested) : activeRunId ? runs.get(activeRunId) : undefined;
|
|
424
|
+
if (!run) {
|
|
425
|
+
const runId = await ctx.ui.input("Run ID", "Enter the Smithers run ID to inspect");
|
|
426
|
+
if (!runId) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
run = trackRun(runId);
|
|
430
|
+
}
|
|
431
|
+
await openInspector(ctx, run);
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
pi.registerCommand("smithers-watch", {
|
|
436
|
+
description: "Attach to a Smithers run devtools stream by run ID",
|
|
437
|
+
getArgumentCompletions(prefix: string) {
|
|
438
|
+
return [...runs.values()]
|
|
439
|
+
.filter((run) => run.runId.startsWith(prefix))
|
|
440
|
+
.map((run) => ({ value: run.runId, label: `${run.workflowName} (${run.runId.slice(0, 8)})` }));
|
|
441
|
+
},
|
|
442
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
443
|
+
const runId = args.trim() || (await ctx.ui.input("Run ID", "Enter the Smithers run ID to watch"));
|
|
444
|
+
if (!runId) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const run = trackRun(runId);
|
|
448
|
+
ctx.ui.notify(`Watching run ${runId.slice(0, 8)}`, "info");
|
|
449
|
+
updateStatusBar(ctx);
|
|
450
|
+
await openInspector(ctx, run);
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
pi.registerCommand("smithers-runs", {
|
|
455
|
+
description: "List tracked Smithers runs",
|
|
456
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
457
|
+
if (runs.size === 0) {
|
|
458
|
+
ctx.ui.notify("No runs tracked", "info");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const runList = [...runs.values()];
|
|
462
|
+
const options = runList.map(
|
|
463
|
+
(run) => `${statusIcon(run.store.runStatus)} ${run.workflowName} (${run.runId.slice(0, 8)}) - ${run.store.runStatus}`,
|
|
464
|
+
);
|
|
465
|
+
const selected = await ctx.ui.select("Smithers Runs", options);
|
|
466
|
+
if (!selected) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const run = runList[options.indexOf(selected)];
|
|
470
|
+
activeRunId = run.runId;
|
|
471
|
+
await openInspector(ctx, run);
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
pi.registerCommand("smithers-approve", {
|
|
476
|
+
description: "Approve or deny the selected waiting node",
|
|
477
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
478
|
+
const waiting = [...runs.values()].flatMap((run) =>
|
|
479
|
+
collectNodeStates(run)
|
|
480
|
+
.filter((node) => node.state === "waiting-approval")
|
|
481
|
+
.map((node) => ({ run, node })),
|
|
482
|
+
);
|
|
483
|
+
if (waiting.length === 0) {
|
|
484
|
+
ctx.ui.notify("No nodes waiting for approval", "info");
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const options = waiting.map((entry) => `${entry.run.workflowName} -> ${entry.node.nodeId}`);
|
|
488
|
+
const selected = await ctx.ui.select("Select node", options);
|
|
489
|
+
if (!selected) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const target = waiting[options.indexOf(selected)];
|
|
493
|
+
const action = await ctx.ui.select("Action", ["Approve", "Deny", "Cancel"]);
|
|
494
|
+
if (!action || action === "Cancel") {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (action === "Approve") {
|
|
498
|
+
await target.run.client.approve(target.run.runId, target.node.nodeId);
|
|
499
|
+
} else {
|
|
500
|
+
await target.run.client.deny(target.run.runId, target.node.nodeId);
|
|
501
|
+
}
|
|
502
|
+
ctx.ui.notify(`${action}d ${target.node.nodeId}`, action === "Approve" ? "info" : "warning");
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
pi.registerCommand("smithers-cancel", {
|
|
507
|
+
description: "Cancel the active Smithers run",
|
|
508
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
509
|
+
const runId = args.trim() || activeRunId;
|
|
510
|
+
const run = runId ? runs.get(runId) : undefined;
|
|
511
|
+
if (!run) {
|
|
512
|
+
ctx.ui.notify("No active run to cancel", "info");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const confirmed = await ctx.ui.confirm("Cancel run?", `Cancel run ${run.runId.slice(0, 8)}?`);
|
|
516
|
+
if (!confirmed) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
await run.client.cancel(run.runId);
|
|
520
|
+
ctx.ui.notify(`Cancelling ${run.runId.slice(0, 8)}`, "warning");
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
pi.registerCommand("smithers-run", {
|
|
525
|
+
description: "Start a Smithers workflow through MCP",
|
|
526
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
527
|
+
const workflow = args.trim() || (await ctx.ui.input("Workflow path", "e.g. ./workflows/deploy.tsx"));
|
|
528
|
+
if (!workflow) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const input = await ctx.ui.input("Input JSON (optional)", "{}");
|
|
532
|
+
const params: Record<string, unknown> = { workflow };
|
|
533
|
+
if (input && input !== "{}") {
|
|
534
|
+
params.input = input;
|
|
535
|
+
}
|
|
536
|
+
const result = await callMcpTool("run", params);
|
|
537
|
+
if (result.isError) {
|
|
538
|
+
throw new SmithersError("PI_MCP_ERROR", result.text);
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
const parsed = JSON.parse(result.text);
|
|
542
|
+
if (typeof parsed.runId === "string") {
|
|
543
|
+
trackRun(parsed.runId, workflow.split("/").pop() ?? workflow);
|
|
544
|
+
ctx.ui.notify(`Started ${workflow} - run ${parsed.runId.slice(0, 8)}`, "info");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
} catch {
|
|
548
|
+
// non-json tool output
|
|
549
|
+
}
|
|
550
|
+
ctx.ui.notify(`Started: ${result.text}`, "info");
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
pi.registerMessageRenderer("smithers-event", (message: any, { expanded }: any, theme: any) => {
|
|
555
|
+
const details = message.details;
|
|
556
|
+
if (!details) {
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
const content =
|
|
560
|
+
typeof message.content === "string"
|
|
561
|
+
? message.content
|
|
562
|
+
: message.content?.map?.((part: any) => (part.type === "text" ? part.text : "[image]")).join(" ");
|
|
563
|
+
let text = `${statusIcon(details.status ?? "running")} ${theme.fg("muted", content ?? "")}`;
|
|
564
|
+
if (expanded && details.runId) {
|
|
565
|
+
text += `\n${theme.fg("dim", ` run: ${details.runId}`)}`;
|
|
566
|
+
}
|
|
567
|
+
return new Text(text, 0, 0);
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export default extension;
|