pi-feats 0.1.5 → 0.1.7
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/README.md +48 -5
- package/extensions/api-server/README.md +21 -1
- package/extensions/api-server/application-runtime.ts +65 -4
- package/extensions/api-server/application-store.ts +5 -5
- package/extensions/api-server/index.ts +6 -2
- package/extensions/api-server/profile-store.ts +81 -34
- package/extensions/api-server/server.ts +179 -42
- package/extensions/cli-resources.ts +125 -94
- package/extensions/cli-tui-mode.ts +7 -0
- package/extensions/context-memory/index.ts +6 -1
- package/extensions/guardrails/index.ts +2 -1
- package/extensions/lib/application-context.ts +2 -0
- package/extensions/lib/cli-tui-mode.ts +72 -0
- package/extensions/lib/context-memory.ts +6 -1
- package/extensions/lib/package-resources.ts +58 -0
- package/extensions/lib/pi-command.ts +11 -0
- package/extensions/lib/profile-sandbox.ts +11 -3
- package/extensions/lib/remote-hosts.ts +7 -5
- package/extensions/lib/workflow-evaluator.ts +113 -0
- package/extensions/lib/workflow-harness.ts +409 -43
- package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
- package/extensions/pi-console-webui/app/globals.css +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
- package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
- package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
- package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
- package/extensions/pi-console-webui/components/console-page.tsx +1 -0
- package/extensions/pi-console-webui/components/console-state.tsx +1 -1
- package/extensions/pi-console-webui/components/console.tsx +26 -9
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
- package/extensions/pi-console-webui/components/profile-model-form.tsx +2 -2
- package/extensions/pi-console-webui/components/pulse-resources.tsx +4 -3
- package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
- package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
- package/extensions/pi-console-webui/index.ts +2 -1
- package/extensions/pi-console-webui/lib/admin-models.ts +6 -4
- package/extensions/pi-console-webui/lib/page-metadata.ts +1 -0
- package/extensions/profile-ssh.ts +2 -2
- package/extensions/profiles.ts +17 -9
- package/extensions/pulse/index.ts +35 -17
- package/extensions/pulse/store.ts +29 -7
- package/extensions/sequential-workflow.ts +121 -79
- package/extensions/skill-sources/store.ts +29 -10
- package/package.json +19 -9
- package/scripts/build-console-webui.mjs +7 -0
- package/scripts/console-webui-cache.mjs +79 -0
- package/scripts/ensure-console-build.mjs +43 -0
- package/skills/sequential-workflow/SKILL.md +4 -3
- package/themes/legacy-dark.json +85 -0
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { SessionManager, type ExtensionAPI, type SessionInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Box, render, Text } from "ink";
|
|
3
|
-
import React from "react";
|
|
4
2
|
import { existsSync } from "node:fs";
|
|
5
3
|
import { readFile, readdir, writeFile } from "node:fs/promises";
|
|
6
4
|
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
7
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
7
|
import { BUILTIN_TOOLS, activeBuiltinTools, updatedBuiltinTools } from "./lib/builtin-tools.ts";
|
|
8
|
+
import { packageExtensions, type InstalledPackage } from "./lib/package-resources.ts";
|
|
9
|
+
import { withoutTuiModeArgs } from "./lib/cli-tui-mode.ts";
|
|
10
|
+
import { piCommand } from "./lib/pi-command.ts";
|
|
11
|
+
import { PulseStore, type PulseExecutionSession } from "./pulse/store.ts";
|
|
9
12
|
|
|
10
13
|
type ListKind = "tools" | "skills" | "extensions";
|
|
11
14
|
type Row = [string, string, string];
|
|
@@ -63,61 +66,29 @@ const actionFromArgs = (args: string[]): ParsedAction | undefined => {
|
|
|
63
66
|
const clean = (value: string) => value.replaceAll("\n", " ").replaceAll("\t", " ");
|
|
64
67
|
const clip = (value: string, width: number) => value.length <= width ? value : `${value.slice(0, Math.max(0, width - 1))}…`;
|
|
65
68
|
|
|
66
|
-
function ResourceTable({ title, headers, rows
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const widths: [number, number, number] = [
|
|
70
|
-
Math.max(12, Math.floor(available * 0.23)),
|
|
71
|
-
Math.max(12, Math.floor(available * 0.18)),
|
|
72
|
-
0,
|
|
73
|
-
];
|
|
69
|
+
function ResourceTable({ title, headers, rows }: { title: string; headers: Row; rows: Row[]; highlightStatus?: boolean }): string {
|
|
70
|
+
const available = Math.max(80, process.stdout.columns ?? 80) - 10;
|
|
71
|
+
const widths: [number, number, number] = [Math.max(12, Math.floor(available * 0.23)), Math.max(12, Math.floor(available * 0.18)), 0];
|
|
74
72
|
widths[2] = available - widths[0] - widths[1];
|
|
75
73
|
const cell = (value: string, width: number) => clip(clean(value), width).padEnd(width);
|
|
76
74
|
const line = `┼${"─".repeat(widths[0] + 2)}┼${"─".repeat(widths[1] + 2)}┼${"─".repeat(widths[2] + 2)}┼`;
|
|
77
|
-
const top = line.replaceAll("┼", "┬").replace(/^┬/, "┌").replace(/┬$/, "┐");
|
|
78
|
-
const bottom = line.replaceAll("┼", "┴").replace(/^┴/, "└").replace(/┴$/, "┘");
|
|
75
|
+
const top = line.replaceAll("┼", "┬").replace(/^┬/, "┌").replace(/┬$/, "┐"), bottom = line.replaceAll("┼", "┴").replace(/^┴/, "└").replace(/┴$/, "┘");
|
|
79
76
|
const row = (values: Row) => `│ ${cell(values[0], widths[0])} │ ${cell(values[1], widths[1])} │ ${cell(values[2], widths[2])} │`;
|
|
80
|
-
|
|
81
|
-
const renderedRow = (values: Row, index: number) => {
|
|
82
|
-
const statusColor = values[1] === "enabled" ? "green" : "#f5c2d7";
|
|
83
|
-
return React.createElement(
|
|
84
|
-
Box,
|
|
85
|
-
{ flexDirection: "row", key: `${values.join("\0")}-${index}` },
|
|
86
|
-
React.createElement(Text, { color: "gray" }, "│ "),
|
|
87
|
-
React.createElement(Text, { color: "white" }, cell(values[0], widths[0])),
|
|
88
|
-
React.createElement(Text, { color: "gray" }, " │ "),
|
|
89
|
-
React.createElement(Text, { color: highlightStatus ? statusColor : "white" }, cell(values[1], widths[1])),
|
|
90
|
-
React.createElement(Text, { color: "gray" }, " │ "),
|
|
91
|
-
React.createElement(Text, { color: "white" }, cell(values[2], widths[2])),
|
|
92
|
-
React.createElement(Text, { color: "gray" }, " │"),
|
|
93
|
-
);
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
return React.createElement(
|
|
97
|
-
Box,
|
|
98
|
-
{ flexDirection: "column" },
|
|
99
|
-
React.createElement(Text, { color: "cyan", bold: true }, title),
|
|
100
|
-
React.createElement(Text, { color: "gray" }, top),
|
|
101
|
-
React.createElement(Text, { color: "cyan", bold: true }, row(headers)),
|
|
102
|
-
React.createElement(Text, { color: "gray" }, line),
|
|
103
|
-
...rows.map(renderedRow),
|
|
104
|
-
React.createElement(Text, { color: "gray" }, bottom),
|
|
105
|
-
);
|
|
77
|
+
return [title, top, row(headers), line, ...rows.map(row), bottom].join("\n");
|
|
106
78
|
}
|
|
107
79
|
|
|
108
80
|
type SourceRow = [string, string, string, string];
|
|
109
|
-
function SourceTable({ title, detailHeader, rows }: { title: string; detailHeader: string; rows: SourceRow[] }) {
|
|
81
|
+
function SourceTable({ title, detailHeader, rows }: { title: string; detailHeader: string; rows: SourceRow[] }): string {
|
|
110
82
|
const available = Math.max(80, process.stdout.columns ?? 80) - 13, widths: [number, number, number, number] = [Math.max(14, Math.floor(available * .20)), 10, Math.max(18, Math.floor(available * .27)), 0];
|
|
111
83
|
widths[3] = available - widths[0] - widths[1] - widths[2];
|
|
112
84
|
const cell = (value: string, width: number) => clip(clean(value), width).padEnd(width);
|
|
113
85
|
const line = `┼${"─".repeat(widths[0] + 2)}┼${"─".repeat(widths[1] + 2)}┼${"─".repeat(widths[2] + 2)}┼${"─".repeat(widths[3] + 2)}┼`;
|
|
114
86
|
const top = line.replaceAll("┼", "┬").replace(/^┬/, "┌").replace(/┬$/, "┐"), bottom = line.replaceAll("┼", "┴").replace(/^┴/, "└").replace(/┴$/, "┘");
|
|
115
87
|
const row = (values: SourceRow) => `│ ${cell(values[0], widths[0])} │ ${cell(values[1], widths[1])} │ ${cell(values[2], widths[2])} │ ${cell(values[3], widths[3])} │`;
|
|
116
|
-
|
|
117
|
-
return React.createElement(Box, { flexDirection: "column" }, React.createElement(Text, { color: "cyan", bold: true }, title), React.createElement(Text, { color: "gray" }, top), React.createElement(Text, { color: "cyan", bold: true }, row(["NAME", "STATUS", "SOURCE", detailHeader])), React.createElement(Text, { color: "gray" }, line), ...rows.map(renderedRow), React.createElement(Text, { color: "gray" }, bottom));
|
|
88
|
+
return [title, top, row(["NAME", "STATUS", "SOURCE", detailHeader]), line, ...rows.map(row), bottom].join("\n");
|
|
118
89
|
}
|
|
119
90
|
|
|
120
|
-
function SessionTable({ sessions }: { sessions: SessionInfo[] }) {
|
|
91
|
+
function SessionTable({ sessions }: { sessions: SessionInfo[] }): string {
|
|
121
92
|
const terminalWidth = Math.max(80, process.stdout.columns ?? 80);
|
|
122
93
|
const available = terminalWidth - 13;
|
|
123
94
|
const widths = {
|
|
@@ -138,20 +109,17 @@ function SessionTable({ sessions }: { sessions: SessionInfo[] }) {
|
|
|
138
109
|
hour12: false,
|
|
139
110
|
}).format(date);
|
|
140
111
|
|
|
141
|
-
return
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
)),
|
|
153
|
-
React.createElement(Text, { color: "gray" }, bottom),
|
|
154
|
-
);
|
|
112
|
+
return ["SESSIONS", top, row(["ID", "SESSION", "LAST ACTIVITY", "PATH"]), line, ...sessions.map((session) => row([session.id, session.name ?? session.firstMessage, formatActivity(session.modified), session.path])), bottom].join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type PulseListedSession = SessionInfo & { source: "execution" | "injected-result"; pulse?: PulseExecutionSession };
|
|
116
|
+
function isPulseOnlySession(session: SessionInfo, sessionDir: string | undefined): boolean { const entries = SessionManager.open(session.path, sessionDir, session.cwd).getBranch() as Array<{ type?: string }>; const relevant = entries.filter((entry) => entry.type !== "session_info"); return relevant.length > 0 && relevant.some((entry) => entry.type === "pulse") && relevant.every((entry) => entry.type === "pulse"); }
|
|
117
|
+
function PulseSessionTable(sessions: PulseListedSession[]): string { if (!sessions.length) return "PULSE SESSIONS\nNo Pulse sessions found."; return `PULSE SESSIONS\nID SOURCE TYPE PULSE STATUS LAST ACTIVITY\n${sessions.map((session) => `${session.id} ${session.source} ${session.pulse?.pulseType ?? "—"} ${session.pulse?.pulseName ?? "Pulse result"} ${session.pulse?.status ?? "—"} ${session.modified.toISOString()}`).join("\n")}`; }
|
|
118
|
+
|
|
119
|
+
function ApplicationSessionTable(root: string, profile: string): string {
|
|
120
|
+
const path = join(root, "applications.db"); if (!existsSync(path)) return "";
|
|
121
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
122
|
+
try { const rows = db.prepare("SELECT s.application_slug,s.session_prefix,s.session_id,s.session_type,s.message_count,s.updated_at FROM application_session_state st JOIN application_sessions s ON s.session_id=st.current_session_id WHERE st.profile=? ORDER BY s.updated_at DESC").all(profile) as Array<Record<string, unknown>>; if (!rows.length) return ""; const lines = rows.map((row) => `${String(row.session_id)} ${String(row.session_type)} ${Number(row.message_count)} ${String(row.session_type) === "stateless" ? "requests" : "messages"} ${String(row.application_slug)}/${String(row.session_prefix)}`); return `APPLICATION SESSIONS\nID TYPE COUNT ROUTE\n${lines.join("\n")}`; } finally { db.close(); }
|
|
155
123
|
}
|
|
156
124
|
|
|
157
125
|
function requestedProfile(): string | undefined {
|
|
@@ -177,7 +145,7 @@ async function readSettings() {
|
|
|
177
145
|
}
|
|
178
146
|
|
|
179
147
|
const isExcluded = (resourcePath: string, exclusions: string[]): boolean =>
|
|
180
|
-
exclusions.includes(`!${resourcePath}`);
|
|
148
|
+
exclusions.includes(`-${resourcePath}`) || exclusions.includes(`!${resourcePath}`);
|
|
181
149
|
|
|
182
150
|
async function findSkillFiles(path: string): Promise<string[]> {
|
|
183
151
|
if (!existsSync(path)) return [];
|
|
@@ -221,28 +189,34 @@ export async function skillRows(paths: string[], sharedSkillRoot: string, profil
|
|
|
221
189
|
return rows.sort((a, b) => a[0].localeCompare(b[0]) || a[2].localeCompare(b[2]));
|
|
222
190
|
}
|
|
223
191
|
|
|
224
|
-
|
|
192
|
+
type ExtensionIdentity = { name: string; aliases: string[] };
|
|
193
|
+
|
|
194
|
+
export async function extensionRows(paths: string[], exclusions: string[], enabledExtensions?: string[], identitiesByPath = new Map<string, ExtensionIdentity>()): Promise<Row[]> {
|
|
225
195
|
const rows: Row[] = [];
|
|
226
|
-
const enabled = (
|
|
196
|
+
const enabled = (identity: ExtensionIdentity, path: string) => !enabledExtensions || enabledExtensions.includes("*") || identity.aliases.some((name) => enabledExtensions.includes(name)) ? !isExcluded(path, exclusions) : false;
|
|
227
197
|
for (const path of paths) {
|
|
228
198
|
if (!existsSync(path)) continue;
|
|
229
199
|
if ([".ts", ".js"].includes(extname(path))) {
|
|
230
|
-
|
|
200
|
+
const identity = identitiesByPath.get(path) ?? { name: basename(path, extname(path)), aliases: [basename(path, extname(path))] };
|
|
201
|
+
rows.push([identity.name, enabled(identity, path) ? "enabled" : "disabled", path]);
|
|
231
202
|
continue;
|
|
232
203
|
}
|
|
233
204
|
// A configured directory with index.ts/index.js is one extension. Its
|
|
234
205
|
// sibling source/config files are implementation details, not extensions.
|
|
235
206
|
if (existsSync(join(path, "index.ts")) || existsSync(join(path, "index.js"))) {
|
|
236
|
-
|
|
207
|
+
const identity = identitiesByPath.get(path) ?? { name: basename(path), aliases: [basename(path)] };
|
|
208
|
+
rows.push([identity.name, enabled(identity, path) ? "enabled" : "disabled", path]);
|
|
237
209
|
continue;
|
|
238
210
|
}
|
|
239
211
|
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
240
212
|
if (entry.isFile() && [".ts", ".js"].includes(extname(entry.name))) {
|
|
241
213
|
const resourcePath = join(path, entry.name);
|
|
242
|
-
|
|
214
|
+
const identity = identitiesByPath.get(resourcePath) ?? { name: basename(entry.name, extname(entry.name)), aliases: [basename(entry.name, extname(entry.name))] };
|
|
215
|
+
rows.push([identity.name, enabled(identity, resourcePath) ? "enabled" : "disabled", resourcePath]);
|
|
243
216
|
} else if (entry.isDirectory() && (existsSync(join(path, entry.name, "index.ts")) || existsSync(join(path, entry.name, "index.js")))) {
|
|
244
217
|
const resourcePath = join(path, entry.name);
|
|
245
|
-
|
|
218
|
+
const identity = identitiesByPath.get(resourcePath) ?? { name: entry.name, aliases: [entry.name] };
|
|
219
|
+
rows.push([identity.name, enabled(identity, resourcePath) ? "enabled" : "disabled", resourcePath]);
|
|
246
220
|
}
|
|
247
221
|
}
|
|
248
222
|
}
|
|
@@ -250,7 +224,7 @@ async function extensionRows(paths: string[], exclusions: string[], enabledExten
|
|
|
250
224
|
}
|
|
251
225
|
|
|
252
226
|
type PackageResourceKind = "extensions" | "skills";
|
|
253
|
-
type ConfiguredPackage = { source: string;
|
|
227
|
+
type ConfiguredPackage = InstalledPackage & { source: string; manifest?: InstalledPackage["manifest"] & { version?: string; description?: string } };
|
|
254
228
|
|
|
255
229
|
function packageBase(source: string, resourceRoot: string): string | undefined {
|
|
256
230
|
if (source.startsWith("npm:")) return join(resourceRoot, "npm", "node_modules", source.slice(4));
|
|
@@ -279,8 +253,8 @@ async function configuredPackages(resourceRoot: string, settings: Record<string,
|
|
|
279
253
|
return packages;
|
|
280
254
|
}
|
|
281
255
|
|
|
282
|
-
async function configuredPackageResources(resourceRoot: string, settings: Record<string, unknown>, kind: PackageResourceKind): Promise<Array<{ path: string; packageName: string }>> {
|
|
283
|
-
const paths: Array<{ path: string; packageName: string }> = [];
|
|
256
|
+
async function configuredPackageResources(resourceRoot: string, settings: Record<string, unknown>, kind: PackageResourceKind): Promise<Array<{ path: string; packageName: string; name?: string }>> {
|
|
257
|
+
const paths: Array<{ path: string; packageName: string; name?: string }> = [];
|
|
284
258
|
for (const pkg of await configuredPackages(resourceRoot, settings)) {
|
|
285
259
|
const entries = pkg.manifest?.pi?.[kind];
|
|
286
260
|
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
@@ -291,7 +265,14 @@ async function configuredPackageResources(resourceRoot: string, settings: Record
|
|
|
291
265
|
}
|
|
292
266
|
|
|
293
267
|
async function configuredPackageExtensions(resourceRoot: string, settings: Record<string, unknown>) {
|
|
294
|
-
return
|
|
268
|
+
return packageExtensions(await configuredPackages(resourceRoot, settings));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function extensionIdentity(resourcePath: string, resourceRoot: string, settings: Record<string, unknown>): Promise<ExtensionIdentity> {
|
|
272
|
+
const packageExtension = (await configuredPackageExtensions(resourceRoot, settings)).find((entry) => entry.path === resourcePath);
|
|
273
|
+
if (packageExtension) return { name: packageExtension.name, aliases: packageExtension.aliases };
|
|
274
|
+
const name = basename(resourcePath, extname(resourcePath));
|
|
275
|
+
return { name, aliases: [name] };
|
|
295
276
|
}
|
|
296
277
|
|
|
297
278
|
async function packageToolSources(resourceRoot: string, settings: Record<string, unknown>, names: string[]): Promise<Map<string, string>> {
|
|
@@ -318,19 +299,41 @@ async function applyPackageAction(action: PackageAction) {
|
|
|
318
299
|
console.log(`Package "${name}" ${action.action === "enable" ? "enabled" : "disabled"} in ${join(agentDir, "settings.json")}. Use /reload to apply.`);
|
|
319
300
|
}
|
|
320
301
|
|
|
321
|
-
async function
|
|
322
|
-
|
|
323
|
-
await new Promise((resolveRender) => setTimeout(resolveRender, 25));
|
|
324
|
-
app.unmount();
|
|
302
|
+
async function writeTable(table: string) {
|
|
303
|
+
await new Promise<void>((resolveWrite, reject) => process.stdout.write(`${table}\n`, (error) => error ? reject(error) : resolveWrite()));
|
|
325
304
|
}
|
|
326
305
|
|
|
327
306
|
function setExclusion(entries: string[], resourcePath: string, disable: boolean) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
307
|
+
// Pi applies exact exclusions (`-path`) to resources discovered through a
|
|
308
|
+
// package manifest. `!path` is a glob exclusion and is retained only as a
|
|
309
|
+
// legacy value when reading existing settings.
|
|
310
|
+
const exactExclusion = `-${resourcePath}`;
|
|
311
|
+
const legacyGlobExclusion = `!${resourcePath}`;
|
|
312
|
+
if (disable) return [...entries.filter((entry) => entry !== legacyGlobExclusion && entry !== exactExclusion), exactExclusion];
|
|
313
|
+
return entries.filter((entry) => entry !== exactExclusion && entry !== legacyGlobExclusion);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function updatedExtensionSettings(settings: Record<string, unknown>, resourcePath: string, identity: ExtensionIdentity, enable: boolean): Record<string, unknown> {
|
|
317
|
+
const entries = Array.isArray(settings.extensions) ? settings.extensions.filter((entry): entry is string => typeof entry === "string") : [];
|
|
318
|
+
const extensions = setExclusion(entries, resourcePath, !enable);
|
|
319
|
+
const updated: Record<string, unknown> = { ...settings };
|
|
320
|
+
if (extensions.length) updated.extensions = extensions;
|
|
321
|
+
else delete updated.extensions;
|
|
322
|
+
|
|
323
|
+
const profile = settings.profile;
|
|
324
|
+
const enabledExtensions = profile && typeof profile === "object" && Array.isArray((profile as Record<string, unknown>).enabledExtensions)
|
|
325
|
+
? (profile as Record<string, unknown>).enabledExtensions.filter((entry): entry is string => typeof entry === "string")
|
|
326
|
+
: undefined;
|
|
327
|
+
if (enabledExtensions && !enabledExtensions.includes("*")) {
|
|
328
|
+
const selected = new Set(enabledExtensions);
|
|
329
|
+
for (const alias of identity.aliases) selected.delete(alias);
|
|
330
|
+
if (enable) selected.add(identity.name);
|
|
331
|
+
updated.profile = { ...(profile as Record<string, unknown>), enabledExtensions: [...selected].sort() };
|
|
332
|
+
}
|
|
333
|
+
return updated;
|
|
331
334
|
}
|
|
332
335
|
|
|
333
|
-
async function findResource(kind: "extensions" | "skills", target: string, settings: Record<string, unknown>, agentDir: string, resourceRoot: string) {
|
|
336
|
+
export async function findResource(kind: "extensions" | "skills", target: string, settings: Record<string, unknown>, agentDir: string, resourceRoot: string) {
|
|
334
337
|
if (existsSync(target)) {
|
|
335
338
|
if (kind === "skills" && (basename(target) === "SKILL.md" || existsSync(join(target, "SKILL.md")))) {
|
|
336
339
|
return basename(target) === "SKILL.md" ? dirname(target) : target;
|
|
@@ -344,10 +347,9 @@ async function findResource(kind: "extensions" | "skills", target: string, setti
|
|
|
344
347
|
const roots = [join(agentDir, kind), ...(kind === "skills" ? [join(resourceRoot, "skills")] : []), ...rawEntries.filter((entry) => !/^[!+\-]/.test(entry)), join(process.cwd(), ".pi", kind)];
|
|
345
348
|
const name = target.replace(/^[!+\-]+/, "");
|
|
346
349
|
if (kind === "extensions") {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
350
|
+
const matches = (await configuredPackageExtensions(resourceRoot, settings)).filter((entry) => entry.aliases.includes(name));
|
|
351
|
+
if (matches.length === 1) return matches[0].path;
|
|
352
|
+
if (matches.length > 1) throw new Error(`Extension name \"${target}\" is ambiguous; use its path.`);
|
|
351
353
|
} else {
|
|
352
354
|
for (const entry of await configuredPackageResources(resourceRoot, settings, "skills")) {
|
|
353
355
|
for (const skillFile of await findSkillFiles(entry.path)) {
|
|
@@ -451,6 +453,17 @@ async function applyAction(action: ParsedAction) {
|
|
|
451
453
|
const resourcePath = await findResource(action.kind, action.target, settings, agentDir, resourceRoot);
|
|
452
454
|
if (!resourcePath) throw new Error(`${action.kind.slice(0, -1)} \"${action.target}\" não encontrada.`);
|
|
453
455
|
if (action.kind === "skills" && await applyProfileSkillAction(action, resourcePath, settings, agentDir, resourceRoot, settingsPath)) return;
|
|
456
|
+
if (action.kind === "extensions") {
|
|
457
|
+
const identity = await extensionIdentity(resourcePath, resourceRoot, settings);
|
|
458
|
+
const updatedSettings = updatedExtensionSettings(settings, resourcePath, identity, enable);
|
|
459
|
+
if (JSON.stringify(settings) === JSON.stringify(updatedSettings)) {
|
|
460
|
+
console.log(`Extension \"${action.target}\" is already ${enable ? "enabled" : "disabled"}.`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
await writeFile(settingsPath, `${JSON.stringify(updatedSettings, null, 2)}\n`, "utf8");
|
|
464
|
+
console.log(`Extension \"${identity.name}\" ${enable ? "enabled" : "disabled"} in ${settingsPath}. Use /reload to apply.`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
454
467
|
const entries = Array.isArray(settings[action.kind])
|
|
455
468
|
? settings[action.kind].filter((entry): entry is string => typeof entry === "string")
|
|
456
469
|
: [];
|
|
@@ -472,7 +485,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
472
485
|
default: false,
|
|
473
486
|
});
|
|
474
487
|
|
|
475
|
-
const
|
|
488
|
+
const rawArgs = process.argv.slice(2);
|
|
489
|
+
const args = withoutTuiModeArgs(rawArgs);
|
|
476
490
|
// Remote commands must reach the profiles extension before this extension
|
|
477
491
|
// creates its local --no-session helper process. The remote Pi will process
|
|
478
492
|
// resource commands on its own host/runtime.
|
|
@@ -482,8 +496,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
482
496
|
const packageRequested = packageRequestedFromArgs(args);
|
|
483
497
|
const packageAction = packageActionFromArgs(args);
|
|
484
498
|
const sessionRename = sessionRenameFromArgs(args);
|
|
485
|
-
const
|
|
486
|
-
|
|
499
|
+
const sessionListIndex = args.findIndex((arg, index) => arg === "sessions" && args[index + 1] === "list");
|
|
500
|
+
const sessionFilterCandidate = sessionListIndex >= 0 ? args[sessionListIndex + 2] : undefined;
|
|
501
|
+
const sessionFilter = sessionFilterCandidate?.startsWith("--") ? undefined : sessionFilterCandidate;
|
|
502
|
+
if (sessionFilter !== undefined && sessionFilter !== "pulses") throw new Error(`Unknown sessions list filter: ${sessionFilter}`);
|
|
503
|
+
const sessionRequested = args.some((arg) => arg === "--list-sessions" || arg === "--list-sessions=true") || sessionListIndex >= 0;
|
|
487
504
|
if (!kind && !action && !packageRequested && !packageAction && !sessionRequested && !sessionRename) return;
|
|
488
505
|
|
|
489
506
|
// Run CLI operations in an ephemeral child so they never create an empty session.
|
|
@@ -491,7 +508,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
491
508
|
const profile = requestedProfile();
|
|
492
509
|
const root = process.env.PI_PROFILE_ROOT ?? process.env.PI_CODING_AGENT_DIR ?? join(process.env.HOME ?? "", ".pi", "agent");
|
|
493
510
|
const target = profile ? join(root, "profiles", profile) : process.env.PI_CODING_AGENT_DIR;
|
|
494
|
-
const
|
|
511
|
+
const pi = piCommand();
|
|
512
|
+
const child = spawn(pi.command, [...pi.args, "--no-session", ...rawArgs], {
|
|
495
513
|
stdio: "inherit",
|
|
496
514
|
env: { ...process.env, PI_CLI_RESOURCES: "1", PI_CODING_AGENT_DIR: target, PI_PROFILE_ROOT: root, ...(profile ? { PI_ACTIVE_PROFILE: profile } : {}) },
|
|
497
515
|
});
|
|
@@ -502,6 +520,18 @@ export default async function (pi: ExtensionAPI) {
|
|
|
502
520
|
process.exit(code);
|
|
503
521
|
}
|
|
504
522
|
|
|
523
|
+
if (sessionRequested) {
|
|
524
|
+
const sessionDir = process.env.PI_CODING_AGENT_SESSION_DIR;
|
|
525
|
+
const sessions = sessionDir ? await SessionManager.listAll(sessionDir) : await SessionManager.listAll();
|
|
526
|
+
const profile = process.env.PI_ACTIVE_PROFILE ?? requestedProfile() ?? "default", root = process.env.PI_PROFILE_ROOT ?? process.env.PI_CODING_AGENT_DIR ?? join(process.env.HOME ?? "", ".pi", "agent");
|
|
527
|
+
const pulseExecutions = new PulseStore(join(root, "pulse.db")).executionSessions(profile);
|
|
528
|
+
const pulseSessions: PulseListedSession[] = sessions.flatMap((session) => { const pulse = pulseExecutions.get(session.id); if (pulse) return [{ ...session, source: "execution", pulse }]; return isPulseOnlySession(session, sessionDir) ? [{ ...session, source: "injected-result" }] : []; });
|
|
529
|
+
const pulseSessionIds = new Set(pulseSessions.map((session) => session.id));
|
|
530
|
+
if (sessionFilter === "pulses") await writeTable(PulseSessionTable(pulseSessions));
|
|
531
|
+
else await writeTable([SessionTable({ sessions: sessions.filter((session) => !pulseSessionIds.has(session.id)) }), ApplicationSessionTable(root, profile)].filter(Boolean).join("\n\n"));
|
|
532
|
+
process.exit(0);
|
|
533
|
+
}
|
|
534
|
+
|
|
505
535
|
if (sessionRename) {
|
|
506
536
|
try {
|
|
507
537
|
if (!sessionRename.name) throw new Error("Session name cannot be empty.");
|
|
@@ -541,13 +571,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
541
571
|
}
|
|
542
572
|
|
|
543
573
|
pi.on("session_start", async () => {
|
|
544
|
-
if (
|
|
545
|
-
const sessionDir = process.env.PI_CODING_AGENT_SESSION_DIR;
|
|
546
|
-
const sessions = sessionDir ? await SessionManager.listAll(sessionDir) : await SessionManager.listAll();
|
|
547
|
-
await renderAndClose(React.createElement(SessionTable, { sessions }));
|
|
548
|
-
} else if (packageRequested) {
|
|
574
|
+
if (packageRequested) {
|
|
549
575
|
const { resourceRoot, runtimeSettings } = await readSettings();
|
|
550
|
-
await
|
|
576
|
+
await writeTable(ResourceTable({ title: "PACKAGES", headers: ["NAME", "STATUS", "VERSION / DESCRIPTION"], rows: await packageRows(resourceRoot, runtimeSettings), highlightStatus: true }));
|
|
551
577
|
} else if (kind === "tools") {
|
|
552
578
|
const active = new Set(pi.getActiveTools());
|
|
553
579
|
const { resourceRoot, runtimeSettings } = await readSettings();
|
|
@@ -555,7 +581,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
555
581
|
const rows: SourceRow[] = pi.getAllTools()
|
|
556
582
|
.map((tool) => [tool.name, active.has(tool.name) ? "enabled" : "disabled", (BUILTIN_TOOLS as readonly string[]).includes(tool.name) ? "Built-in" : sources.has(tool.name) ? `npm: ${sources.get(tool.name)}` : "Extension", tool.description] as SourceRow)
|
|
557
583
|
.sort((a, b) => a[0].localeCompare(b[0]));
|
|
558
|
-
await
|
|
584
|
+
await writeTable(SourceTable({ title: "TOOLS", detailHeader: "DESCRIPTION", rows }));
|
|
559
585
|
} else {
|
|
560
586
|
const loaded = await readSettings();
|
|
561
587
|
const resourceRoot = loaded.resourceRoot;
|
|
@@ -563,19 +589,24 @@ export default async function (pi: ExtensionAPI) {
|
|
|
563
589
|
const settings = kind === "extensions" ? loaded.runtimeSettings : loaded.settings;
|
|
564
590
|
const rawEntries = Array.isArray(settings[kind]) ? settings[kind].filter((value): value is string => typeof value === "string") : [];
|
|
565
591
|
const configuredPaths = rawEntries.filter((value) => !/^[!+\-]/.test(value));
|
|
566
|
-
const exclusions = rawEntries.filter((value) => value.startsWith("!"));
|
|
592
|
+
const exclusions = rawEntries.filter((value) => value.startsWith("!") || value.startsWith("-"));
|
|
567
593
|
const catalogRoot = join(resourceRoot, kind);
|
|
568
|
-
const packageResources =
|
|
594
|
+
const packageResources = kind === "extensions"
|
|
595
|
+
? await configuredPackageExtensions(resourceRoot, loaded.runtimeSettings)
|
|
596
|
+
: await configuredPackageResources(resourceRoot, loaded.runtimeSettings, "skills");
|
|
569
597
|
const paths = kind === "skills"
|
|
570
598
|
? [...new Set([catalogRoot, ...configuredPaths, ...packageResources.map(({ path }) => path)])]
|
|
571
599
|
: [...new Set([...(configuredPaths.length > 0 ? configuredPaths : [catalogRoot]), ...packageResources.map(({ path }) => path)])];
|
|
572
600
|
const profileSkillRoot = kind === "skills" && loaded.agentDir !== resourceRoot ? join(loaded.agentDir, "skills") : undefined;
|
|
573
|
-
const
|
|
601
|
+
const extensionIdentities = new Map(packageResources.flatMap((resource) => "aliases" in resource && Array.isArray(resource.aliases)
|
|
602
|
+
? [[resource.path, { name: resource.name, aliases: resource.aliases }] as const]
|
|
603
|
+
: []));
|
|
604
|
+
const rows: SourceRow[] = (kind === "skills" ? await skillRows(paths, catalogRoot, profileSkillRoot, settings, exclusions) : await extensionRows(paths, exclusions, (loaded.runtimeSettings.profile as { enabledExtensions?: string[] } | undefined)?.enabledExtensions, extensionIdentities)).map((row) => {
|
|
574
605
|
const packageName = packageResources.find(({ path }) => row[2] === path || isWithin(row[2], path))?.packageName;
|
|
575
606
|
const source = packageName ? `Package: ${packageName}` : (row[2].startsWith(join(resourceRoot, "skills")) ? "Shared" : row[2].includes("/profiles/") ? "Profile" : "Local");
|
|
576
607
|
return [row[0], row[1], source, row[2]];
|
|
577
608
|
});
|
|
578
|
-
await
|
|
609
|
+
await writeTable(SourceTable({ title: kind.toUpperCase(), detailHeader: "PATH", rows }));
|
|
579
610
|
}
|
|
580
611
|
// This command always runs in an ephemeral child process.
|
|
581
612
|
process.exit(0);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { reexecCliWithRegularTui } from "./lib/cli-tui-mode.ts";
|
|
3
|
+
|
|
4
|
+
/** Keep finite pi-feats CLI commands out of Pi's alternate-screen TUI. */
|
|
5
|
+
export default async function (_pi: ExtensionAPI) {
|
|
6
|
+
await reexecCliWithRegularTui();
|
|
7
|
+
}
|
|
@@ -10,7 +10,12 @@ const agentRoot = () => process.env.PI_PROFILE_ROOT ?? process.env.PI_CODING_AGE
|
|
|
10
10
|
const profileDirectory = () => process.env.PI_CODING_AGENT_DIR ?? agentRoot();
|
|
11
11
|
function executionContext() {
|
|
12
12
|
const application = applicationExecutionContext.getStore();
|
|
13
|
-
|
|
13
|
+
const encodedDirectMemory = process.env.PI_APPLICATION_DIRECT_CONTEXT_MEMORY;
|
|
14
|
+
let directContextMemory = application?.directContextMemory;
|
|
15
|
+
if (directContextMemory === undefined && encodedDirectMemory) {
|
|
16
|
+
try { directContextMemory = Buffer.from(encodedDirectMemory, "base64url").toString("utf8"); } catch { /* validation happens during context resolution */ }
|
|
17
|
+
}
|
|
18
|
+
return { application: application?.application ?? process.env.PI_APPLICATION_SLUG, identityKey: application?.identityKey ?? process.env.PI_APPLICATION_IDENTITY_KEY, profile: application?.profile ?? process.env.PI_ACTIVE_PROFILE ?? "default", sessionId: application?.sessionId ?? process.env.PI_APPLICATION_SESSION_ID, directContextMemory };
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export default function registerContextMemory(pi: ExtensionAPI): void {
|
|
@@ -5,6 +5,7 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import { readFile } from "node:fs/promises";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { basename, join } from "node:path";
|
|
8
|
+
import { withoutTuiModeArgs } from "../lib/cli-tui-mode.ts";
|
|
8
9
|
|
|
9
10
|
type Stage = "input" | "pre_tool" | "post_tool" | "output";
|
|
10
11
|
type Mode = "transform" | "evaluate" | "reflect";
|
|
@@ -97,7 +98,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
97
98
|
// `pi guardrails …` is implemented by profiles.ts before Pi opens a model
|
|
98
99
|
// session. Do not register a slash command for that positional CLI form:
|
|
99
100
|
// Pi would dispatch it as an interactive command and require credentials.
|
|
100
|
-
if (process.argv.slice(2)[0] === "guardrails") return;
|
|
101
|
+
if (withoutTuiModeArgs(process.argv.slice(2))[0] === "guardrails") return;
|
|
101
102
|
|
|
102
103
|
let reflectionCount = 0;
|
|
103
104
|
|
|
@@ -16,6 +16,8 @@ export type ApplicationExecutionContext = {
|
|
|
16
16
|
identityKey: string;
|
|
17
17
|
profile: string;
|
|
18
18
|
sessionId: string;
|
|
19
|
+
/** Non-persistent personal context supplied by an Application Direct Mode request. */
|
|
20
|
+
directContextMemory?: string;
|
|
19
21
|
onContextMemoryExecution?: (event: ContextMemoryExecutionLog) => void;
|
|
20
22
|
};
|
|
21
23
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { piCommand } from "./pi-command.ts";
|
|
3
|
+
|
|
4
|
+
const profileManagementActions = new Set(["add", "create", "delete", "list", "open", "remove", "resume"]);
|
|
5
|
+
const profileResourceCommands = new Set(["extensions", "packages", "sessions", "skills", "tools"]);
|
|
6
|
+
const topLevelCliCommands = new Set(["api", "console", "guardrails", "pulse", "ssh", "tools", "skills", "extensions", "packages", "sessions"]);
|
|
7
|
+
|
|
8
|
+
/** Remove the Pi TUI override before an extension dispatches positional CLI arguments. */
|
|
9
|
+
export function withoutTuiModeArgs(raw: string[]): string[] {
|
|
10
|
+
const args: string[] = [];
|
|
11
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
12
|
+
const value = raw[index];
|
|
13
|
+
if (value === "--tui-mode") { index += 1; continue; }
|
|
14
|
+
if (value.startsWith("--tui-mode=")) continue;
|
|
15
|
+
args.push(value);
|
|
16
|
+
}
|
|
17
|
+
return args;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function hasTuiModeArg(args: string[]): boolean {
|
|
21
|
+
return args.includes("--tui-mode");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Preserve an explicit user preference; otherwise make a finite CLI command render normally. */
|
|
25
|
+
export function withRegularTuiMode(args: string[]): string[] {
|
|
26
|
+
return hasTuiModeArg(args) ? args : [...args, "--tui-mode", "regular"];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function profileCommandIsNonInteractive(args: string[]): boolean {
|
|
30
|
+
const action = args[1];
|
|
31
|
+
if (!action) return false;
|
|
32
|
+
if (profileManagementActions.has(action.toLowerCase())) return true;
|
|
33
|
+
return !profileManagementActions.has(action.toLowerCase()) && Boolean(args[2]) && (
|
|
34
|
+
profileResourceCommands.has(args[2]) || ["api", "console", "guardrails", "pulse", "ssh"].includes(args[2])
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True only for pi-feats commands that produce a finite CLI result. Interactive
|
|
40
|
+
* Pi launches intentionally retain the selected profile's TUI preference.
|
|
41
|
+
*/
|
|
42
|
+
export function isPiFeatsNonInteractiveCli(raw: string[]): boolean {
|
|
43
|
+
const args = withoutTuiModeArgs(raw);
|
|
44
|
+
const command = args[0];
|
|
45
|
+
if (!command) return false;
|
|
46
|
+
if (topLevelCliCommands.has(command)) return true;
|
|
47
|
+
if (command === "profile") return profileCommandIsNonInteractive(args);
|
|
48
|
+
if (command === "remote") return ["add", "list", "delete"].includes(args[1] ?? "");
|
|
49
|
+
|
|
50
|
+
const remote = command.match(/^remote:[a-zA-Z][a-zA-Z0-9_-]{0,63}$/);
|
|
51
|
+
if (!remote) return false;
|
|
52
|
+
const nested = args.slice(1);
|
|
53
|
+
if (!nested.length || nested[0] === "bash") return false;
|
|
54
|
+
if (nested[0] === "profile") return profileCommandIsNonInteractive(nested);
|
|
55
|
+
return topLevelCliCommands.has(nested[0]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Re-exec finite pi-feats CLI commands before Pi initializes its TUI. */
|
|
59
|
+
export async function reexecCliWithRegularTui(): Promise<boolean> {
|
|
60
|
+
const args = process.argv.slice(2);
|
|
61
|
+
if (!isPiFeatsNonInteractiveCli(args) || hasTuiModeArg(args)) return false;
|
|
62
|
+
const pi = piCommand();
|
|
63
|
+
const child = spawn(pi.command, [...pi.args, ...withRegularTuiMode(args)], {
|
|
64
|
+
stdio: "inherit",
|
|
65
|
+
env: process.env,
|
|
66
|
+
});
|
|
67
|
+
const code = await new Promise<number>((resolveExit, reject) => {
|
|
68
|
+
child.once("error", reject);
|
|
69
|
+
child.once("exit", (exitCode) => resolveExit(exitCode ?? 1));
|
|
70
|
+
});
|
|
71
|
+
process.exit(code);
|
|
72
|
+
}
|
|
@@ -13,7 +13,7 @@ export type ContextMemoryConfig =
|
|
|
13
13
|
export type ContextMemoryTarget = "operational" | "profile" | "user";
|
|
14
14
|
export type ContextMemoryToolTarget = "operational" | "personal";
|
|
15
15
|
export type ContextMemoryAction = "read" | "insert" | "update" | "remove" | "replace";
|
|
16
|
-
export type MemoryExecutionContext = { application?: string; identityKey?: string; profile: string; sessionId?: string };
|
|
16
|
+
export type MemoryExecutionContext = { application?: string; identityKey?: string; profile: string; sessionId?: string; directContextMemory?: string };
|
|
17
17
|
export const CONTEXT_MEMORY_LIMITS = { operational: 2750, profile: 1375, user: 1375 } as const;
|
|
18
18
|
const MAX_FILE_BYTES = 64 * 1024;
|
|
19
19
|
const sensitive = /\b(password|passphrase|api[_ -]?key|secret|access[_ -]?token|refresh[_ -]?token|private[_ -]?key|cpf|credit[_ -]?card)\b/i;
|
|
@@ -116,6 +116,11 @@ async function handlerMemory(agentDir: string, profileDir: string, context: Memo
|
|
|
116
116
|
export async function resolveContextMemory(agentDir: string, profileDir: string, context: MemoryExecutionContext): Promise<{ operational: string; personal: string; source?: string; handlerExecution?: ContextMemoryExecutionLog }> {
|
|
117
117
|
const operational = await readMemory(memoryPath(agentDir, profileDir, "operational"));
|
|
118
118
|
if (contextMemoryCharacters(operational) > CONTEXT_MEMORY_LIMITS.operational) throw new Error("OPERATIONAL.md exceeds 2750 characters.");
|
|
119
|
+
if (context.directContextMemory !== undefined) {
|
|
120
|
+
const personal = context.directContextMemory.trim();
|
|
121
|
+
if (contextMemoryCharacters(personal) > CONTEXT_MEMORY_LIMITS.user) throw new Error("Direct Context Memory exceeds 1375 characters.");
|
|
122
|
+
return { operational, personal, source: "direct" };
|
|
123
|
+
}
|
|
119
124
|
const config = await profileMemoryConfig(profileDir);
|
|
120
125
|
if (!config) return { operational, personal: "" };
|
|
121
126
|
if (config.mode === "file" && config.target === "profile") { const personal = await readMemory(memoryPath(agentDir, profileDir, "profile")); if (contextMemoryCharacters(personal) > CONTEXT_MEMORY_LIMITS.profile) throw new Error("PROFILE.md exceeds 1375 characters."); return { operational, personal, source: "profile" }; }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { basename, extname, join } from "node:path";
|
|
2
|
+
|
|
3
|
+
export type PackageManifest = {
|
|
4
|
+
pi?: { extensions?: unknown; skills?: unknown };
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type InstalledPackage = {
|
|
8
|
+
base: string;
|
|
9
|
+
name: string;
|
|
10
|
+
manifest?: PackageManifest;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type PackageExtension = {
|
|
14
|
+
name: string;
|
|
15
|
+
aliases: string[];
|
|
16
|
+
path: string;
|
|
17
|
+
packageName: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const extensionFileName = (entry: string) => basename(entry, extname(entry));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Derive stable user-facing names for package extensions.
|
|
24
|
+
*
|
|
25
|
+
* A package whose only extension is an index entry uses the package name so
|
|
26
|
+
* users do not have to address a generic "index" extension. The technical
|
|
27
|
+
* entry name remains an alias for backwards compatibility.
|
|
28
|
+
*/
|
|
29
|
+
export function packageExtensions(packages: InstalledPackage[]): PackageExtension[] {
|
|
30
|
+
const candidates: Array<PackageExtension & { preferredName: string }> = [];
|
|
31
|
+
for (const pkg of packages) {
|
|
32
|
+
const entries = Array.isArray(pkg.manifest?.pi?.extensions)
|
|
33
|
+
? pkg.manifest.pi.extensions.filter((entry): entry is string => typeof entry === "string")
|
|
34
|
+
: [];
|
|
35
|
+
const soleIndexEntry = entries.length === 1 && extensionFileName(entries[0]) === "index";
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const technicalName = extensionFileName(entry);
|
|
38
|
+
const preferredName = soleIndexEntry ? pkg.name : technicalName;
|
|
39
|
+
candidates.push({
|
|
40
|
+
name: preferredName,
|
|
41
|
+
preferredName,
|
|
42
|
+
aliases: [...new Set([preferredName, technicalName])],
|
|
43
|
+
path: join(pkg.base, entry),
|
|
44
|
+
packageName: pkg.name,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const preferredNameCounts = new Map<string, number>();
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
preferredNameCounts.set(candidate.preferredName, (preferredNameCounts.get(candidate.preferredName) ?? 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
return candidates.map(({ preferredName, ...candidate }) => {
|
|
54
|
+
const technicalName = extensionFileName(candidate.path);
|
|
55
|
+
const name = (preferredNameCounts.get(preferredName) ?? 0) === 1 ? preferredName : technicalName;
|
|
56
|
+
return { ...candidate, name, aliases: [...new Set([name, ...candidate.aliases])] };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Return a fresh Pi CLI invocation. Managed Pi upgrades replace release files,
|
|
5
|
+
* so a long-running worker must not blindly reuse a now-deleted argv[1].
|
|
6
|
+
*/
|
|
7
|
+
export function piCommand(entry = process.argv[1]): { command: string; args: string[] } {
|
|
8
|
+
return entry && existsSync(entry)
|
|
9
|
+
? { command: process.execPath, args: [entry] }
|
|
10
|
+
: { command: "pi", args: [] };
|
|
11
|
+
}
|