479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
|
|
/**
|
||
|
|
* pi-aps: Pi Agent extension for the APS Gateway agent API.
|
||
|
|
*
|
||
|
|
* Scope of this PoC: read-only business intents through POST /api/agent/invoke.
|
||
|
|
* The extension never opens a second execution path and never fabricates APS
|
||
|
|
* data. HTTP failures are surfaced as tool errors (isError=true).
|
||
|
|
*/
|
||
|
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||
|
|
import { Type } from "typebox";
|
||
|
|
import { existsSync, readFileSync } from "node:fs";
|
||
|
|
import { homedir } from "node:os";
|
||
|
|
import { join } from "node:path";
|
||
|
|
|
||
|
|
const DEFAULT_APS_BASE_URL = "http://127.0.0.1:8000";
|
||
|
|
const REQUEST_TIMEOUT_MS = 30_000;
|
||
|
|
const CATALOG_TIMEOUT_MS = 8_000;
|
||
|
|
const BASE_TOOL_NAMES = new Set(["aps_health", "aps_summary", "aps_invoke"]);
|
||
|
|
|
||
|
|
interface ApsConfigState {
|
||
|
|
baseUrl?: string;
|
||
|
|
token?: string;
|
||
|
|
requestTimeoutMs?: number;
|
||
|
|
catalogTimeoutMs?: number;
|
||
|
|
path: string;
|
||
|
|
loaded: boolean;
|
||
|
|
error?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
function apsConfigPath(): string {
|
||
|
|
const explicit = String(process.env.APS_CONFIG_FILE || "").trim();
|
||
|
|
if (explicit) return explicit;
|
||
|
|
const piHome = String(process.env.PI_CODING_AGENT_DIR || "").trim();
|
||
|
|
if (piHome) return join(piHome, "aps.json");
|
||
|
|
return join(homedir(), ".pi", "aps.json");
|
||
|
|
}
|
||
|
|
|
||
|
|
function optionalString(value: unknown): string | undefined {
|
||
|
|
if (typeof value !== "string") return undefined;
|
||
|
|
const trimmed = value.trim();
|
||
|
|
return trimmed || undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function optionalPositiveNumber(value: unknown): number | undefined {
|
||
|
|
const parsed = Number(value);
|
||
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function loadApsConfig(): ApsConfigState {
|
||
|
|
const path = apsConfigPath();
|
||
|
|
try {
|
||
|
|
if (!existsSync(path)) return { path, loaded: false };
|
||
|
|
const raw = readFileSync(path, "utf8");
|
||
|
|
const parsed: any = JSON.parse(raw);
|
||
|
|
if (!parsed || typeof parsed !== "object") throw new Error("config root must be an object");
|
||
|
|
const section = parsed.aps && typeof parsed.aps === "object" ? parsed.aps : parsed;
|
||
|
|
return {
|
||
|
|
baseUrl: optionalString(section.baseUrl ?? section.serverUrl),
|
||
|
|
token: optionalString(section.token),
|
||
|
|
requestTimeoutMs: optionalPositiveNumber(
|
||
|
|
section.requestTimeoutMs ?? section.request_timeout_ms,
|
||
|
|
),
|
||
|
|
catalogTimeoutMs: optionalPositiveNumber(
|
||
|
|
section.catalogTimeoutMs ?? section.catalog_timeout_ms,
|
||
|
|
),
|
||
|
|
path,
|
||
|
|
loaded: true,
|
||
|
|
};
|
||
|
|
} catch (err) {
|
||
|
|
const message = err instanceof Error ? err.message : String(err);
|
||
|
|
return { path, loaded: false, error: message };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function positiveNumber(raw: string | undefined, configValue: number | undefined, fallback: number): number {
|
||
|
|
const parsed = Number(String(raw || "").trim());
|
||
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : configValue ?? fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
const APS_CONFIG = loadApsConfig();
|
||
|
|
|
||
|
|
interface IntentEntry {
|
||
|
|
name?: string;
|
||
|
|
power?: string;
|
||
|
|
description?: string;
|
||
|
|
parameters?: any;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface CatalogState {
|
||
|
|
lastAttemptAt?: string;
|
||
|
|
lastSuccessAt?: string;
|
||
|
|
available: boolean;
|
||
|
|
error?: string;
|
||
|
|
intentCount: number;
|
||
|
|
registeredTools: string[];
|
||
|
|
}
|
||
|
|
|
||
|
|
const catalogState: CatalogState = {
|
||
|
|
available: false,
|
||
|
|
intentCount: 0,
|
||
|
|
registeredTools: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
const dynamicIntentByTool = new Map<string, string>();
|
||
|
|
let lastToolError: string | undefined;
|
||
|
|
|
||
|
|
class ApsCallError extends Error {
|
||
|
|
code: string;
|
||
|
|
httpStatus?: number;
|
||
|
|
|
||
|
|
constructor(code: string, message: string, httpStatus?: number) {
|
||
|
|
super(message);
|
||
|
|
this.name = "ApsCallError";
|
||
|
|
this.code = code;
|
||
|
|
this.httpStatus = httpStatus;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function apsBaseUrl(): string {
|
||
|
|
const value = String(process.env.APS_BASE_URL || APS_CONFIG.baseUrl || DEFAULT_APS_BASE_URL);
|
||
|
|
return value.trim().replace(/\/+$/, "");
|
||
|
|
}
|
||
|
|
|
||
|
|
function apsToken(): string | undefined {
|
||
|
|
const token = String(process.env.APS_AGENT_TOKEN || APS_CONFIG.token || "").trim();
|
||
|
|
return token || undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function requestTimeoutMs(): number {
|
||
|
|
return positiveNumber(process.env.APS_REQUEST_TIMEOUT_MS, APS_CONFIG.requestTimeoutMs, REQUEST_TIMEOUT_MS);
|
||
|
|
}
|
||
|
|
|
||
|
|
function catalogTimeoutMs(): number {
|
||
|
|
return positiveNumber(process.env.APS_CATALOG_TIMEOUT_MS, APS_CONFIG.catalogTimeoutMs, CATALOG_TIMEOUT_MS);
|
||
|
|
}
|
||
|
|
|
||
|
|
function maskToken(token: string | undefined): string {
|
||
|
|
if (!token) return "not configured";
|
||
|
|
if (token.length <= 4) return "configured (masked)";
|
||
|
|
return `configured (ends ${token.slice(-4)})`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function requestId(prefix = "aps"): string {
|
||
|
|
const cryptoObj = (globalThis as any).crypto as Crypto | undefined;
|
||
|
|
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
|
||
|
|
return `${prefix}-${cryptoObj.randomUUID()}`;
|
||
|
|
}
|
||
|
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function headers(): Record<string, string> {
|
||
|
|
const result: Record<string, string> = {
|
||
|
|
Accept: "application/json",
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
};
|
||
|
|
const token = apsToken();
|
||
|
|
if (token) result.Authorization = `Bearer ${token}`;
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
function serverErrorCode(body: any, fallback: string): string {
|
||
|
|
if (body && typeof body === "object") {
|
||
|
|
if (typeof body.error?.code === "string") return body.error.code;
|
||
|
|
if (typeof body.code === "string") return body.code;
|
||
|
|
}
|
||
|
|
return fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
function serverErrorMessage(body: any, fallback: string): string {
|
||
|
|
if (body && typeof body === "object") {
|
||
|
|
if (typeof body.error?.message === "string") return body.error.message;
|
||
|
|
if (typeof body.message === "string") return body.message;
|
||
|
|
}
|
||
|
|
return fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readJsonResponse(response: Response): Promise<any> {
|
||
|
|
const text = await response.text();
|
||
|
|
let body: any = {};
|
||
|
|
if (text) {
|
||
|
|
try {
|
||
|
|
body = JSON.parse(text);
|
||
|
|
} catch {
|
||
|
|
body = { raw: text.slice(0, 500) };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!response.ok) {
|
||
|
|
const code = serverErrorCode(body, `HTTP_${response.status}`);
|
||
|
|
const message = serverErrorMessage(body, text.slice(0, 500) || response.statusText);
|
||
|
|
throw new ApsCallError(
|
||
|
|
code,
|
||
|
|
`HTTP ${response.status} (${code}): ${message}`,
|
||
|
|
response.status,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (body && typeof body === "object" && body.ok === false) {
|
||
|
|
const code = serverErrorCode(body, "APS_INVOKE_REJECTED");
|
||
|
|
const message = serverErrorMessage(body, "APS server returned ok:false");
|
||
|
|
throw new ApsCallError(
|
||
|
|
code,
|
||
|
|
`HTTP ${response.status} (${code}): ${message}`,
|
||
|
|
response.status,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return body;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function httpJson(
|
||
|
|
path: string,
|
||
|
|
method: string,
|
||
|
|
payload: unknown,
|
||
|
|
signal: AbortSignal | undefined,
|
||
|
|
timeoutMs: number,
|
||
|
|
): Promise<any> {
|
||
|
|
const controller = new AbortController();
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
controller.abort(new ApsCallError("APS_TIMEOUT", `APS request timed out after ${timeoutMs}ms`, undefined));
|
||
|
|
}, timeoutMs);
|
||
|
|
const onOuterAbort = () => controller.abort(signal?.reason ?? new Error("Aborted by pi"));
|
||
|
|
if (signal) {
|
||
|
|
if (signal.aborted) {
|
||
|
|
clearTimeout(timer);
|
||
|
|
throw new ApsCallError("APS_ABORTED", "APS request aborted by pi", undefined);
|
||
|
|
}
|
||
|
|
signal.addEventListener("abort", onOuterAbort, { once: true });
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const url = `${apsBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`;
|
||
|
|
const response = await fetch(url, {
|
||
|
|
method,
|
||
|
|
headers: headers(),
|
||
|
|
body: method === "GET" || method === "HEAD" ? undefined : JSON.stringify(payload ?? {}),
|
||
|
|
signal: controller.signal,
|
||
|
|
});
|
||
|
|
return await readJsonResponse(response);
|
||
|
|
} catch (err) {
|
||
|
|
if (err instanceof ApsCallError) throw err;
|
||
|
|
const message = err instanceof Error ? err.message : String(err);
|
||
|
|
throw new ApsCallError("APS_NETWORK_FAILED", `APS HTTP request failed: ${message}`, undefined);
|
||
|
|
} finally {
|
||
|
|
clearTimeout(timer);
|
||
|
|
if (signal) signal.removeEventListener("abort", onOuterAbort);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function responseText(payload: any, label: string): string {
|
||
|
|
if (payload && typeof payload === "object" && typeof payload.data?.text === "string") {
|
||
|
|
const parts: string[] = [payload.data.text];
|
||
|
|
const refs = Array.isArray(payload.data.evidenceRefs) && payload.data.evidenceRefs.length
|
||
|
|
? payload.data.evidenceRefs
|
||
|
|
: Array.isArray(payload.evidenceRefs) && payload.evidenceRefs.length
|
||
|
|
? payload.evidenceRefs
|
||
|
|
: [];
|
||
|
|
if (refs.length) parts.push(`\n\nevidenceRefs: ${refs.map(String).join(", ")}`);
|
||
|
|
return parts.join("");
|
||
|
|
}
|
||
|
|
if (payload && typeof payload === "object" && typeof payload.text === "string") {
|
||
|
|
return payload.text;
|
||
|
|
}
|
||
|
|
return JSON.stringify(payload ?? null, null, 2);
|
||
|
|
}
|
||
|
|
|
||
|
|
function toolResult(toolName: string, payload: any) {
|
||
|
|
return {
|
||
|
|
content: [{ type: "text", text: responseText(payload, toolName) }],
|
||
|
|
details: {
|
||
|
|
tool: toolName,
|
||
|
|
receivedAt: new Date().toISOString(),
|
||
|
|
payload,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function captureToolError(err: unknown): never {
|
||
|
|
lastToolError = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
||
|
|
if (err instanceof ApsCallError) throw err;
|
||
|
|
throw err instanceof Error ? err : new Error(String(err));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function invokeIntent(
|
||
|
|
intent: string,
|
||
|
|
params: Record<string, unknown>,
|
||
|
|
requestIdValue: unknown,
|
||
|
|
signal: AbortSignal | undefined,
|
||
|
|
): Promise<any> {
|
||
|
|
const rid = typeof requestIdValue === "string" && requestIdValue.trim()
|
||
|
|
? requestIdValue.trim()
|
||
|
|
: requestId(`aps-${intent.replace(/[^a-zA-Z0-9]+/g, "-")}`);
|
||
|
|
return httpJson("/api/agent/invoke", "POST", {
|
||
|
|
intent,
|
||
|
|
params: params && typeof params === "object" ? params : {},
|
||
|
|
requestId: rid,
|
||
|
|
}, signal, requestTimeoutMs());
|
||
|
|
}
|
||
|
|
|
||
|
|
function registerBaseTools(pi: ExtensionAPI): void {
|
||
|
|
pi.registerTool({
|
||
|
|
name: "aps_health",
|
||
|
|
label: "APS Health",
|
||
|
|
description: "Call APS read-only business capability 'health'. Returns APS Gateway health/interface metadata verbatim.",
|
||
|
|
parameters: Type.Object({}),
|
||
|
|
async execute(_toolCallId, _params, signal) {
|
||
|
|
try {
|
||
|
|
return toolResult("aps_health", await httpJson("/api/health", "GET", undefined, signal, requestTimeoutMs()));
|
||
|
|
} catch (err) {
|
||
|
|
captureToolError(err);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
pi.registerTool({
|
||
|
|
name: "aps_summary",
|
||
|
|
label: "APS World Summary",
|
||
|
|
description: "Call APS read-only business capability 'world summary'. Returns APS world/KPI summary data verbatim.",
|
||
|
|
parameters: Type.Object({}),
|
||
|
|
async execute(_toolCallId, _params, signal) {
|
||
|
|
try {
|
||
|
|
return toolResult("aps_summary", await httpJson("/api/world/summary", "GET", undefined, signal, requestTimeoutMs()));
|
||
|
|
} catch (err) {
|
||
|
|
captureToolError(err);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
pi.registerTool({
|
||
|
|
name: "aps_invoke",
|
||
|
|
label: "APS Invoke",
|
||
|
|
description:
|
||
|
|
"Call an APS read-only business capability by exact intent name through POST /api/agent/invoke. " +
|
||
|
|
"Intent names look like order.pool or plan.buckets. Write or confirmation-gated intents are rejected by the APS server.",
|
||
|
|
parameters: Type.Object({
|
||
|
|
intent: Type.String({ description: "Exact APS intent name from the /api/agent/intents catalog" }),
|
||
|
|
params: Type.Object({}, { description: "Intent parameters accepted by the catalog schema", additionalProperties: true }),
|
||
|
|
requestId: Type.Optional(Type.String({ description: "Optional client request id for auditing" })),
|
||
|
|
}),
|
||
|
|
async execute(_toolCallId, params: any, signal) {
|
||
|
|
try {
|
||
|
|
return toolResult(
|
||
|
|
"aps_invoke",
|
||
|
|
await invokeIntent(String(params.intent), params.params ?? {}, params.requestId, signal),
|
||
|
|
);
|
||
|
|
} catch (err) {
|
||
|
|
captureToolError(err);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeToolName(intentName: string): string | undefined {
|
||
|
|
const normalized = String(intentName)
|
||
|
|
.trim()
|
||
|
|
.toLowerCase()
|
||
|
|
.replace(/\./g, "_")
|
||
|
|
.replace(/[^a-z0-9_]+/g, "_")
|
||
|
|
.replace(/_+/g, "_")
|
||
|
|
.replace(/^_+|_+$/g, "");
|
||
|
|
if (!normalized) return undefined;
|
||
|
|
const toolName = `aps_${normalized}`;
|
||
|
|
return /^[a-z][a-z0-9_]*$/.test(toolName) ? toolName : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function typeFromJsonSchema(schemaJson: any, description?: string): any {
|
||
|
|
const type = String(schemaJson?.type || "").toLowerCase();
|
||
|
|
switch (type) {
|
||
|
|
case "string":
|
||
|
|
return Type.String({ description: description || schemaJson?.description });
|
||
|
|
case "number":
|
||
|
|
case "integer":
|
||
|
|
return Type.Number({ description: description || schemaJson?.description });
|
||
|
|
case "boolean":
|
||
|
|
return Type.Boolean({ description: description || schemaJson?.description });
|
||
|
|
case "array":
|
||
|
|
return Type.Array(typeFromJsonSchema(schemaJson.items), { description: description || schemaJson?.description });
|
||
|
|
case "object":
|
||
|
|
return schemaFromJsonSchema(schemaJson);
|
||
|
|
case "null":
|
||
|
|
return Type.Unknown({ description: description || schemaJson?.description });
|
||
|
|
default:
|
||
|
|
return Type.Unknown({ description: description || schemaJson?.description });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function schemaFromJsonSchema(schemaJson: any): any {
|
||
|
|
if (!schemaJson || typeof schemaJson !== "object") {
|
||
|
|
return Type.Object({}, { additionalProperties: true });
|
||
|
|
}
|
||
|
|
const requiredSet = new Set(Array.isArray(schemaJson.required) ? schemaJson.required.map(String) : []);
|
||
|
|
const properties: Record<string, any> = {};
|
||
|
|
if (schemaJson.properties && typeof schemaJson.properties === "object") {
|
||
|
|
for (const [key, prop] of Object.entries<any>(schemaJson.properties)) {
|
||
|
|
if (!/^[a-zA-Z0-9_]+$/.test(key)) continue;
|
||
|
|
const base = typeFromJsonSchema(prop);
|
||
|
|
properties[key] = requiredSet.has(key) ? base : Type.Optional(base);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return Type.Object(properties, {
|
||
|
|
additionalProperties: schemaJson.additionalProperties !== false,
|
||
|
|
description: typeof schemaJson.description === "string" ? schemaJson.description : undefined,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function registerCatalogTools(pi: ExtensionAPI): Promise<void> {
|
||
|
|
catalogState.lastAttemptAt = new Date().toISOString();
|
||
|
|
catalogState.registeredTools = [];
|
||
|
|
return httpJson("/api/agent/intents", "GET", undefined, undefined, catalogTimeoutMs())
|
||
|
|
.then((payload: any) => {
|
||
|
|
const entries: IntentEntry[] = Array.isArray(payload?.intents) ? payload.intents : [];
|
||
|
|
for (const entry of entries) {
|
||
|
|
if (!entry || typeof entry.name !== "string" || !entry.name.trim()) continue;
|
||
|
|
const toolName = normalizeToolName(entry.name);
|
||
|
|
if (!toolName || BASE_TOOL_NAMES.has(toolName) || dynamicIntentByTool.has(toolName)) continue;
|
||
|
|
const power = String(entry.power || "P0");
|
||
|
|
const description = entry.description && String(entry.description).trim()
|
||
|
|
? String(entry.description).trim()
|
||
|
|
: `Call APS intent ${entry.name}`;
|
||
|
|
dynamicIntentByTool.set(toolName, entry.name.trim());
|
||
|
|
pi.registerTool({
|
||
|
|
name: toolName,
|
||
|
|
label: `APS ${entry.name}`,
|
||
|
|
description: `Call APS read-only business capability ${entry.name} (power ${power}). ${description}`,
|
||
|
|
parameters: schemaFromJsonSchema(entry.parameters),
|
||
|
|
async execute(_toolCallId, params: any, signal) {
|
||
|
|
try {
|
||
|
|
return toolResult(
|
||
|
|
toolName,
|
||
|
|
await invokeIntent(entry.name.trim(), params ?? {}, params?.requestId, signal),
|
||
|
|
);
|
||
|
|
} catch (err) {
|
||
|
|
captureToolError(err);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
catalogState.registeredTools.push(toolName);
|
||
|
|
}
|
||
|
|
catalogState.available = true;
|
||
|
|
catalogState.intentCount = entries.length;
|
||
|
|
catalogState.lastSuccessAt = new Date().toISOString();
|
||
|
|
catalogState.error = undefined;
|
||
|
|
})
|
||
|
|
.catch((err: unknown) => {
|
||
|
|
catalogState.available = false;
|
||
|
|
catalogState.error = err instanceof Error ? err.message : String(err);
|
||
|
|
// Health/summary/invoke tools remain usable even without the catalog.
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function apsExtension(pi: ExtensionAPI): void {
|
||
|
|
registerBaseTools(pi);
|
||
|
|
|
||
|
|
pi.on("session_start", () => {
|
||
|
|
return registerCatalogTools(pi);
|
||
|
|
});
|
||
|
|
|
||
|
|
pi.registerCommand("aps-status", {
|
||
|
|
description: "Show pi-aps base URL/token state, registered APS tools, and last error",
|
||
|
|
handler: async (_args, ctx) => {
|
||
|
|
const token = apsToken();
|
||
|
|
const lines = [
|
||
|
|
`APS config file: ${APS_CONFIG.path}`,
|
||
|
|
`APS config loaded: ${APS_CONFIG.loaded ? "yes" : "no"}`,
|
||
|
|
`APS config error: ${APS_CONFIG.error || "(none)"}`,
|
||
|
|
`APS baseUrl: ${apsBaseUrl()}`,
|
||
|
|
`APS agent token: ${maskToken(token)}`,
|
||
|
|
`Catalog available: ${catalogState.available ? "yes" : "no"}`,
|
||
|
|
`Registered APS tools: ${catalogState.registeredTools.length + BASE_TOOL_NAMES.size}`,
|
||
|
|
`Dynamic tools: ${catalogState.registeredTools.join(", ") || "(none)"}`,
|
||
|
|
`Last catalog attempt: ${catalogState.lastAttemptAt || "(not attempted)"}`,
|
||
|
|
`Last catalog error: ${catalogState.error || "(none)"}`,
|
||
|
|
`Last tool error: ${lastToolError || "(none)"}`,
|
||
|
|
];
|
||
|
|
const text = lines.join("\n");
|
||
|
|
if (ctx.hasUI) {
|
||
|
|
ctx.ui.notify(text, catalogState.error ? "warning" : "info");
|
||
|
|
} else {
|
||
|
|
process.stderr.write(`[aps-status]\n${text}\n`);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|