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.
Files changed (50) hide show
  1. package/README.md +48 -5
  2. package/extensions/api-server/README.md +21 -1
  3. package/extensions/api-server/application-runtime.ts +65 -4
  4. package/extensions/api-server/application-store.ts +5 -5
  5. package/extensions/api-server/index.ts +6 -2
  6. package/extensions/api-server/profile-store.ts +81 -34
  7. package/extensions/api-server/server.ts +179 -42
  8. package/extensions/cli-resources.ts +125 -94
  9. package/extensions/cli-tui-mode.ts +7 -0
  10. package/extensions/context-memory/index.ts +6 -1
  11. package/extensions/guardrails/index.ts +2 -1
  12. package/extensions/lib/application-context.ts +2 -0
  13. package/extensions/lib/cli-tui-mode.ts +72 -0
  14. package/extensions/lib/context-memory.ts +6 -1
  15. package/extensions/lib/package-resources.ts +58 -0
  16. package/extensions/lib/pi-command.ts +11 -0
  17. package/extensions/lib/profile-sandbox.ts +11 -3
  18. package/extensions/lib/remote-hosts.ts +7 -5
  19. package/extensions/lib/workflow-evaluator.ts +113 -0
  20. package/extensions/lib/workflow-harness.ts +409 -43
  21. package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
  22. package/extensions/pi-console-webui/app/globals.css +3 -0
  23. package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
  24. package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
  25. package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
  26. package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
  27. package/extensions/pi-console-webui/components/console-page.tsx +1 -0
  28. package/extensions/pi-console-webui/components/console-state.tsx +1 -1
  29. package/extensions/pi-console-webui/components/console.tsx +26 -9
  30. package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
  31. package/extensions/pi-console-webui/components/profile-model-form.tsx +2 -2
  32. package/extensions/pi-console-webui/components/pulse-resources.tsx +4 -3
  33. package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
  34. package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
  35. package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
  36. package/extensions/pi-console-webui/index.ts +2 -1
  37. package/extensions/pi-console-webui/lib/admin-models.ts +6 -4
  38. package/extensions/pi-console-webui/lib/page-metadata.ts +1 -0
  39. package/extensions/profile-ssh.ts +2 -2
  40. package/extensions/profiles.ts +17 -9
  41. package/extensions/pulse/index.ts +35 -17
  42. package/extensions/pulse/store.ts +29 -7
  43. package/extensions/sequential-workflow.ts +121 -79
  44. package/extensions/skill-sources/store.ts +29 -10
  45. package/package.json +19 -9
  46. package/scripts/build-console-webui.mjs +7 -0
  47. package/scripts/console-webui-cache.mjs +79 -0
  48. package/scripts/ensure-console-build.mjs +43 -0
  49. package/skills/sequential-workflow/SKILL.md +4 -3
  50. 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, highlightStatus = false }: { title: string; headers: Row; rows: Row[]; highlightStatus?: boolean }) {
67
- const terminalWidth = Math.max(80, process.stdout.columns ?? 80);
68
- const available = terminalWidth - 10;
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
- const renderedRow = (values: SourceRow, index: number) => React.createElement(Box, { flexDirection: "row", key: `${values.join("\0")}-${index}` }, React.createElement(Text, { color: "gray" }, "│ "), React.createElement(Text, { color: "white" }, cell(values[0], widths[0])), React.createElement(Text, { color: "gray" }, " │ "), React.createElement(Text, { color: values[1] === "enabled" ? "green" : "#f5c2d7" }, cell(values[1], widths[1])), React.createElement(Text, { color: "gray" }, " │ "), React.createElement(Text, { color: "white" }, cell(values[2], widths[2])), React.createElement(Text, { color: "gray" }, " │ "), React.createElement(Text, { color: "white" }, cell(values[3], widths[3])), React.createElement(Text, { color: "gray" }, " │"));
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 React.createElement(
142
- Box,
143
- { flexDirection: "column" },
144
- React.createElement(Text, { color: "cyan", bold: true }, "SESSIONS"),
145
- React.createElement(Text, { color: "gray" }, top),
146
- React.createElement(Text, { color: "cyan", bold: true }, row(["ID", "SESSION", "LAST ACTIVITY", "PATH"])),
147
- React.createElement(Text, { color: "gray" }, line),
148
- ...sessions.map((session) => React.createElement(
149
- Text,
150
- { color: "white", key: session.path },
151
- row([session.id, session.name ?? session.firstMessage, formatActivity(session.modified), session.path]),
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
- async function extensionRows(paths: string[], exclusions: string[], enabledExtensions?: string[]): Promise<Row[]> {
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 = (name: string, path: string) => !enabledExtensions || enabledExtensions.includes("*") || enabledExtensions.includes(name) ? !isExcluded(path, exclusions) : false;
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
- rows.push([basename(path, extname(path)), enabled(basename(path, extname(path)), path) ? "enabled" : "disabled", path]);
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
- rows.push([basename(path), enabled(basename(path), path) ? "enabled" : "disabled", path]);
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
- rows.push([basename(entry.name, extname(entry.name)), enabled(basename(entry.name, extname(entry.name)), resourcePath) ? "enabled" : "disabled", resourcePath]);
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
- rows.push([entry.name, enabled(entry.name, resourcePath) ? "enabled" : "disabled", resourcePath]);
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; base: string; name: string; manifest?: { version?: string; description?: string; pi?: { extensions?: unknown; skills?: unknown } } };
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 configuredPackageResources(resourceRoot, settings, "extensions");
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 renderAndClose(element: React.ReactElement) {
322
- const app = render(element, { stdout: process.stdout, stdin: process.stdin, exitOnCtrlC: false, patchConsole: false });
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
- const exclusion = `!${resourcePath}`;
329
- if (disable) return entries.includes(exclusion) ? entries : [...entries, exclusion];
330
- return entries.filter((entry) => entry !== exclusion);
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
- for (const entry of await configuredPackageExtensions(resourceRoot, settings)) {
348
- const extensionName = basename(entry.path, extname(entry.path));
349
- if (extensionName === name) return entry.path;
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 args = process.argv.slice(2);
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 sessionRequested = args.some((arg) => arg === "--list-sessions" || arg === "--list-sessions=true")
486
- || args.some((arg, index) => arg === "sessions" && args[index + 1] === "list");
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 child = spawn(process.execPath, [process.argv[1], "--no-session", ...args], {
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 (sessionRequested) {
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 renderAndClose(React.createElement(ResourceTable, { title: "PACKAGES", headers: ["NAME", "STATUS", "VERSION / DESCRIPTION"], rows: await packageRows(resourceRoot, runtimeSettings), highlightStatus: true }));
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 renderAndClose(React.createElement(SourceTable, { title: "TOOLS", detailHeader: "DESCRIPTION", rows }));
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 = await configuredPackageResources(resourceRoot, loaded.runtimeSettings, kind === "extensions" ? "extensions" : "skills");
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 rows: SourceRow[] = (kind === "skills" ? await skillRows(paths, catalogRoot, profileSkillRoot, settings, exclusions) : await extensionRows(paths, exclusions, (loaded.runtimeSettings.profile as { enabledExtensions?: string[] } | undefined)?.enabledExtensions)).map((row) => {
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 renderAndClose(React.createElement(SourceTable, { title: kind.toUpperCase(), detailHeader: "PATH", rows }));
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
- 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 };
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
+ }