@matyah00/openpi 0.1.4 → 0.2.0

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.
@@ -0,0 +1,29 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export type AuditLogEntry = {
5
+ timestamp: string;
6
+ type: "workflow" | "team_agent" | "chain_step" | "chain_run";
7
+ name: string;
8
+ task: string;
9
+ input: string;
10
+ output: string;
11
+ exitCode: number;
12
+ elapsedMs: number;
13
+ metadata?: Record<string, any>;
14
+ };
15
+
16
+ export function writeAuditLog(cwd: string, entry: Omit<AuditLogEntry, "timestamp">) {
17
+ try {
18
+ const logDir = path.join(cwd, ".pi", "logs");
19
+ fs.mkdirSync(logDir, { recursive: true });
20
+ const logFile = path.join(logDir, "openpi-audit.jsonl");
21
+ const line = JSON.stringify({
22
+ timestamp: new Date().toISOString(),
23
+ ...entry
24
+ });
25
+ fs.appendFileSync(logFile, `${line}\n`, "utf-8");
26
+ } catch {
27
+ // Silent fail to ensure logger never disrupts runtime in case of file locks
28
+ }
29
+ }
@@ -6,9 +6,9 @@
6
6
  * Usage: pi -e extensions/minimal.ts
7
7
  */
8
8
 
9
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import { applyExtensionDefaults } from "./themeMap.ts";
11
- import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
12
12
 
13
13
  export default function (pi: ExtensionAPI) {
14
14
  pi.on("session_start", async (_event, ctx) => {
@@ -31,4 +31,4 @@ export default function (pi: ExtensionAPI) {
31
31
  },
32
32
  }));
33
33
  });
34
- }
34
+ }
@@ -1,8 +1,9 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { extensionDir } from "./lib/packagePaths.ts";
5
+ import { extensionDir, bundledPromptsDir, bundledAgentsDir, bundledPiPiAgentsDir } from "./lib/packagePaths.ts";
6
+ import { parseMarkdownFrontmatter, stringField, arrayField } from "./lib/markdown.ts";
6
7
 
7
8
  type Profile = {
8
9
  name: string;
@@ -130,26 +131,31 @@ function resolveSettingsPath(cwd: string, global: boolean): string {
130
131
  return global ? path.join(os.homedir(), ".pi", "agent", "settings.json") : path.join(cwd, ".pi", "settings.json");
131
132
  }
132
133
 
133
- function applyProfileToSettings(settingsPath: string, profile: Profile): { added: string[]; removed: number } {
134
+ function applyProfileToSettings(settingsPath: string, profile: Profile, dryRun = false): { added: string[]; removed: number } {
134
135
  const settingsDir = path.dirname(settingsPath);
135
136
  const settings = readJsonFile(settingsPath);
136
137
  const currentExtensions = stringArray(settings.extensions);
137
138
  const keptExtensions = currentExtensions.filter((entry) => !isManagedExtensionEntry(entry, settingsDir));
138
139
  const added = profile.extensions.map(extensionPath);
139
140
 
140
- settings.extensions = addUnique(keptExtensions, added);
141
- writeJsonFile(settingsPath, settings);
141
+ if (!dryRun) {
142
+ settings.extensions = addUnique(keptExtensions, added);
143
+ writeJsonFile(settingsPath, settings);
144
+ }
142
145
 
143
146
  return { added, removed: currentExtensions.length - keptExtensions.length };
144
147
  }
145
148
 
146
- function clearProfileFromSettings(settingsPath: string): { removed: number } {
149
+ function clearProfileFromSettings(settingsPath: string, dryRun = false): { removed: number } {
147
150
  const settingsDir = path.dirname(settingsPath);
148
151
  const settings = readJsonFile(settingsPath);
149
152
  const currentExtensions = stringArray(settings.extensions);
150
- settings.extensions = currentExtensions.filter((entry) => !isManagedExtensionEntry(entry, settingsDir));
151
- writeJsonFile(settingsPath, settings);
152
- return { removed: currentExtensions.length - stringArray(settings.extensions).length };
153
+ const keptExtensions = currentExtensions.filter((entry) => !isManagedExtensionEntry(entry, settingsDir));
154
+ if (!dryRun) {
155
+ settings.extensions = keptExtensions;
156
+ writeJsonFile(settingsPath, settings);
157
+ }
158
+ return { removed: currentExtensions.length - keptExtensions.length };
153
159
  }
154
160
 
155
161
  function profileListMarkdown(): string {
@@ -157,6 +163,7 @@ function profileListMarkdown(): string {
157
163
  "# openpi profiles",
158
164
  "",
159
165
  "Use `/openpi use <profile>` to activate a profile for this project.",
166
+ "Use `/openpi use <profile1>+<profile2>` to combine multiple profiles (e.g. `commands+guard`).",
160
167
  "Use `/openpi use <profile> --global` to activate it globally.",
161
168
  "Run `/reload` or restart Pi after changing profiles.",
162
169
  "",
@@ -172,6 +179,110 @@ function emit(pi: ExtensionAPI, content: string) {
172
179
  pi.sendMessage({ customType: "openpi", content, display: true });
173
180
  }
174
181
 
182
+ const NATIVE_TOOLS_BY_EXTENSION: Record<string, string[]> = {
183
+ "search-tools.ts": ["project_tree", "code_search_batch"],
184
+ "audit-tools.ts": ["env_scan", "secret_scan", "ghost_test_scan", "dependency_inventory", "sast_scan"],
185
+ "state-tools.ts": ["state_snapshot", "state_restore", "state_diff", "state_clean"],
186
+ "agent-team.ts": ["dispatch_agent"],
187
+ "agent-chain.ts": ["run_chain"],
188
+ "workflow.ts": ["spawn_agents"],
189
+ };
190
+
191
+ const NATIVE_COMMANDS_BY_EXTENSION: Record<string, string[]> = {
192
+ "workflow.ts": ["add", "fix", "review", "openpi-agents"],
193
+ "theme-cycler.ts": ["theme"],
194
+ "system-select.ts": ["system"],
195
+ "agent-team.ts": ["agents-team", "agents-list"],
196
+ "agent-chain.ts": ["chain", "chain-list"],
197
+ "commands.ts": ["commands", "command:status"],
198
+ };
199
+
200
+ function scanDirForFiles(dir: string, extension: string): string[] {
201
+ if (!fs.existsSync(dir)) return [];
202
+ try {
203
+ return fs.readdirSync(dir)
204
+ .filter((file) => file.endsWith(extension))
205
+ .map((file) => path.basename(file, extension).toLowerCase());
206
+ } catch {
207
+ return [];
208
+ }
209
+ }
210
+
211
+ function getProfileSummary(extensions: string[], cwd: string) {
212
+ const tools = new Set<string>();
213
+ const commands = new Set<string>();
214
+
215
+ for (const ext of extensions) {
216
+ const extTools = NATIVE_TOOLS_BY_EXTENSION[ext] || [];
217
+ for (const t of extTools) {
218
+ tools.add(t);
219
+ }
220
+ const extCmds = NATIVE_COMMANDS_BY_EXTENSION[ext] || [];
221
+ for (const c of extCmds) {
222
+ commands.add(c);
223
+ }
224
+ }
225
+
226
+ // If commands.ts is active, also scan prompts
227
+ if (extensions.includes("commands.ts")) {
228
+ const promptDirs = [
229
+ bundledPromptsDir,
230
+ path.join(cwd, ".pi", "prompts")
231
+ ];
232
+ for (const dir of promptDirs) {
233
+ if (fs.existsSync(dir)) {
234
+ try {
235
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
236
+ for (const file of files) {
237
+ const filePath = path.join(dir, file);
238
+ const raw = fs.readFileSync(filePath, "utf-8");
239
+ const { frontmatter } = parseMarkdownFrontmatter(raw);
240
+ const name = stringField(frontmatter.name) || path.basename(file, ".md");
241
+ commands.add(name.toLowerCase());
242
+ const aliases = arrayField(frontmatter.aliases);
243
+ for (const alias of aliases) {
244
+ commands.add(alias.toLowerCase());
245
+ }
246
+ }
247
+ } catch {
248
+ // ignore
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ // Scan agents
255
+ const agents = new Set<string>();
256
+ const agentDirs = [
257
+ bundledAgentsDir,
258
+ bundledPiPiAgentsDir,
259
+ path.join(cwd, ".pi", "agents"),
260
+ path.join(cwd, "agents")
261
+ ];
262
+ for (const dir of agentDirs) {
263
+ if (fs.existsSync(dir)) {
264
+ try {
265
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
266
+ for (const file of files) {
267
+ const agentName = path.basename(file, ".md").toLowerCase();
268
+ agents.add(agentName);
269
+ }
270
+ } catch {
271
+ // ignore
272
+ }
273
+ }
274
+ }
275
+
276
+ return {
277
+ commandsCount: commands.size,
278
+ commandsList: Array.from(commands),
279
+ toolsCount: tools.size,
280
+ toolsList: Array.from(tools),
281
+ agentsCount: agents.size,
282
+ agentsList: Array.from(agents),
283
+ };
284
+ }
285
+
175
286
  function registerProfileCommand(pi: ExtensionAPI, name: string, description: string) {
176
287
  pi.registerCommand(name, {
177
288
  description,
@@ -185,6 +296,16 @@ function registerProfileCommand(pi: ExtensionAPI, name: string, description: str
185
296
  }
186
297
  if (words[0] === "use") {
187
298
  const partial = words[1] || "";
299
+ if (partial.includes("+")) {
300
+ const parts = partial.split("+");
301
+ const last = parts[parts.length - 1];
302
+ const base = parts.slice(0, -1).join("+");
303
+ return PROFILES.filter((profile) => profile.name.startsWith(last)).map((profile) => ({
304
+ value: `${base}+${profile.name}`,
305
+ label: `${base}+${profile.name}`,
306
+ description: `Combine with ${profile.name}`,
307
+ }));
308
+ }
188
309
  return PROFILES.filter((profile) => profile.name.startsWith(partial)).map((profile) => ({
189
310
  value: profile.name,
190
311
  label: profile.name,
@@ -202,36 +323,62 @@ function registerProfileCommand(pi: ExtensionAPI, name: string, description: str
202
323
  return;
203
324
  }
204
325
 
205
- if (PROFILE_BY_NAME.has(action)) tokens.unshift("use");
326
+ const isComposition = action.includes("+") && action.split("+").every(name => PROFILE_BY_NAME.has(name));
327
+ if (PROFILE_BY_NAME.has(action) || isComposition) tokens.unshift("use");
206
328
 
207
329
  if (tokens[0] === "use") {
208
- const profile = PROFILE_BY_NAME.get(tokens[1] || "");
209
- if (!profile) {
210
- ctx.ui.notify("Usage: /openpi use <profile> [--global]", "error");
330
+ const profileArg = tokens[1] || "";
331
+ const profileNames = profileArg.split("+").filter(Boolean);
332
+ if (!profileNames.length) {
333
+ ctx.ui.notify("Usage: /openpi use <profile1>+<profile2> [--global] [--dry-run]", "error");
211
334
  return;
212
335
  }
336
+
337
+ const invalidNames = profileNames.filter(name => !PROFILE_BY_NAME.has(name));
338
+ if (invalidNames.length > 0) {
339
+ ctx.ui.notify(`Unknown profile(s): ${invalidNames.join(", ")}`, "error");
340
+ return;
341
+ }
342
+
343
+ const profiles = profileNames.map(name => PROFILE_BY_NAME.get(name)!);
344
+ const composedProfile: Profile = {
345
+ name: profileNames.join("+"),
346
+ description: profiles.map(p => p.description).join(" | "),
347
+ extensions: Array.from(new Set(profiles.flatMap(p => p.extensions))),
348
+ notes: Array.from(new Set(profiles.flatMap(p => p.notes || [])))
349
+ };
350
+
213
351
  const global = tokens.includes("--global");
352
+ const dryRun = tokens.includes("--dry-run");
214
353
  const settingsPath = resolveSettingsPath(ctx.cwd, global);
215
- const result = applyProfileToSettings(settingsPath, profile);
354
+ const result = applyProfileToSettings(settingsPath, composedProfile, dryRun);
355
+
356
+ const summary = getProfileSummary(composedProfile.extensions, ctx.cwd);
216
357
  emit(pi, [
217
- `Activated **${profile.name}** in ${global ? "global" : "project"} Pi settings.`,
358
+ `${dryRun ? "**[DRY-RUN]** " : ""}Activated **${composedProfile.name}** in ${global ? "global" : "project"} Pi settings.`,
218
359
  "",
219
360
  `Settings: \`${settingsPath}\``,
220
- `Removed previous openpi profile entries: ${result.removed}`,
221
- "Added:",
222
- ...result.added.map((entry) => `- \`${entry}\``),
361
+ `${dryRun ? "Would remove" : "Removed"} previous openpi profile entries: ${result.removed}`,
362
+ `${dryRun ? "Would add" : "Added"} extensions: ${result.added.length}`,
363
+ ...result.added.map((entry) => `- \`${path.basename(entry)}\``),
223
364
  "",
224
- "Run `/reload` or restart Pi.",
225
- ...(profile.notes?.length ? ["", ...profile.notes.map((note) => `- ${note}`)] : []),
365
+ "### Profile Capability Summary",
366
+ `- **Commands Enabled**: ${summary.commandsCount} (${summary.commandsList.join(", ")})`,
367
+ `- **Tools Enabled**: ${summary.toolsCount} (${summary.toolsList.join(", ")})`,
368
+ `- **Agents Available**: ${summary.agentsCount} (${summary.agentsList.join(", ")})`,
369
+ "",
370
+ dryRun ? "This was a dry run. No settings files were modified." : "Run `/reload` or restart Pi to apply changes.",
371
+ ...(composedProfile.notes?.length ? ["", ...composedProfile.notes.map((note) => `- ${note}`)] : []),
226
372
  ].join("\n"));
227
373
  return;
228
374
  }
229
375
 
230
376
  if (tokens[0] === "clear") {
231
377
  const global = tokens.includes("--global");
378
+ const dryRun = tokens.includes("--dry-run");
232
379
  const settingsPath = resolveSettingsPath(ctx.cwd, global);
233
- const result = clearProfileFromSettings(settingsPath);
234
- emit(pi, `Cleared ${result.removed} openpi extension entr${result.removed === 1 ? "y" : "ies"} from \`${settingsPath}\`. Run \`/reload\` or restart Pi.`);
380
+ const result = clearProfileFromSettings(settingsPath, dryRun);
381
+ emit(pi, `${dryRun ? "**[DRY-RUN]** Would clear" : "Cleared"} ${result.removed} openpi extension entr${result.removed === 1 ? "y" : "ies"} from \`${settingsPath}\`.${dryRun ? "" : " Run `/reload` or restart Pi."}`);
235
382
  return;
236
383
  }
237
384
 
@@ -240,6 +387,7 @@ function registerProfileCommand(pi: ExtensionAPI, name: string, description: str
240
387
  });
241
388
  }
242
389
 
390
+
243
391
  export default function (pi: ExtensionAPI) {
244
392
  registerProfileCommand(pi, "openpi", "Manage openpi native profiles");
245
393
  registerProfileCommand(pi, "azpi", "Deprecated alias for /openpi");
@@ -7,7 +7,7 @@
7
7
  * Usage: pi -e examples/extensions/pure-focus.ts
8
8
  */
9
9
 
10
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { applyExtensionDefaults } from "./themeMap.ts";
12
12
 
13
13
  export default function (pi: ExtensionAPI) {
@@ -8,8 +8,8 @@
8
8
  * Usage: pi -e extensions/purpose-gate.ts
9
9
  */
10
10
 
11
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
- import { Text, truncateToWidth } from "@mariozechner/pi-tui";
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
13
13
  import { applyExtensionDefaults } from "./themeMap.ts";
14
14
 
15
15
  // synthwave: bgWarm #4a1e6a → rgb(74,30,106)
@@ -31,6 +31,7 @@ type TreeNode = {
31
31
  name: string;
32
32
  relativePath: string;
33
33
  isDirectory: boolean;
34
+ sizeBytes?: number;
34
35
  children?: TreeNode[];
35
36
  };
36
37
 
@@ -39,6 +40,7 @@ type SearchQuery = {
39
40
  cwd?: string;
40
41
  globs?: string[];
41
42
  caseInsensitive?: boolean;
43
+ isRegex?: boolean;
42
44
  before?: number;
43
45
  after?: number;
44
46
  maxResults?: number;
@@ -83,6 +85,7 @@ function buildTree(params: {
83
85
  maxDepth: number;
84
86
  maxEntries: number;
85
87
  includeHidden: boolean;
88
+ showSizes: boolean;
86
89
  }): { nodes: TreeNode[]; omitted: number; visited: number } {
87
90
  const ignoreNames = readIgnoreNames(params.projectRoot);
88
91
  let visited = 0;
@@ -120,7 +123,11 @@ function buildTree(params: {
120
123
  children: walk(absolutePath, depth + 1),
121
124
  });
122
125
  } else {
123
- nodes.push({ name: entry.name, relativePath, isDirectory: false });
126
+ let sizeBytes: number | undefined;
127
+ if (params.showSizes) {
128
+ try { sizeBytes = fs.statSync(absolutePath).size; } catch { /* skip */ }
129
+ }
130
+ nodes.push({ name: entry.name, relativePath, isDirectory: false, sizeBytes });
124
131
  }
125
132
  }
126
133
  return nodes;
@@ -129,12 +136,19 @@ function buildTree(params: {
129
136
  return { nodes: walk(params.root, 0), omitted, visited };
130
137
  }
131
138
 
139
+ function formatSize(bytes: number): string {
140
+ if (bytes < 1024) return `${bytes}B`;
141
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
142
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
143
+ }
144
+
132
145
  function renderTree(nodes: TreeNode[], prefix = ""): string[] {
133
146
  const lines: string[] = [];
134
147
  nodes.forEach((node, index) => {
135
148
  const isLast = index === nodes.length - 1;
136
149
  const connector = isLast ? "`-- " : "|-- ";
137
- lines.push(`${prefix}${connector}${node.name}${node.isDirectory ? "/" : ""}`);
150
+ const sizeSuffix = node.sizeBytes !== undefined ? ` (${formatSize(node.sizeBytes)})` : "";
151
+ lines.push(`${prefix}${connector}${node.name}${node.isDirectory ? "/" : ""}${sizeSuffix}`);
138
152
  if (node.children?.length) {
139
153
  lines.push(...renderTree(node.children, `${prefix}${isLast ? " " : "| "}`));
140
154
  }
@@ -145,6 +159,7 @@ function renderTree(nodes: TreeNode[], prefix = ""): string[] {
145
159
  function runRipgrep(projectRoot: string, query: SearchQuery): Promise<string> {
146
160
  const cwd = resolveProjectPath(projectRoot, query.cwd);
147
161
  const args = ["--line-number", "--column", "--no-heading", "--color", "never"];
162
+ if (!query.isRegex) args.push("-F");
148
163
  if (query.caseInsensitive) args.push("-i");
149
164
  if (query.before) args.push("-B", String(query.before));
150
165
  if (query.after) args.push("-A", String(query.after));
@@ -193,13 +208,15 @@ export default function searchToolsExtension(pi: ExtensionAPI) {
193
208
  maxDepth: Type.Optional(Type.Number({ description: "Maximum directory depth. Default 3." })),
194
209
  maxEntries: Type.Optional(Type.Number({ description: "Maximum entries to include. Default 400." })),
195
210
  includeHidden: Type.Optional(Type.Boolean({ description: "Include hidden files and directories. Default false." })),
211
+ showSizes: Type.Optional(Type.Boolean({ description: "Show file sizes next to filenames. Default false." })),
196
212
  }),
197
213
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
198
214
  const root = resolveProjectPath(ctx.cwd, params.root as string | undefined);
199
215
  const maxDepth = Math.max(0, Math.min(Number(params.maxDepth ?? 3), 8));
200
216
  const maxEntries = Math.max(20, Math.min(Number(params.maxEntries ?? 400), 4000));
201
217
  const includeHidden = Boolean(params.includeHidden);
202
- const result = buildTree({ projectRoot: ctx.cwd, root, maxDepth, maxEntries, includeHidden });
218
+ const showSizes = Boolean(params.showSizes);
219
+ const result = buildTree({ projectRoot: ctx.cwd, root, maxDepth, maxEntries, includeHidden, showSizes });
203
220
  const header = [
204
221
  `root: ${normalizeRelative(ctx.cwd, root)}`,
205
222
  `depth: ${maxDepth}`,
@@ -236,6 +253,7 @@ export default function searchToolsExtension(pi: ExtensionAPI) {
236
253
  cwd: Type.Optional(Type.String({ description: "Project-relative directory to search in." })),
237
254
  globs: Type.Optional(Type.Array(Type.String(), { description: "Optional ripgrep -g patterns." })),
238
255
  caseInsensitive: Type.Optional(Type.Boolean({ description: "Use case-insensitive search." })),
256
+ isRegex: Type.Optional(Type.Boolean({ description: "Treat pattern as regex. Default false (literal/fixed-string search)." })),
239
257
  before: Type.Optional(Type.Number({ description: "Context lines before each match." })),
240
258
  after: Type.Optional(Type.Number({ description: "Context lines after each match." })),
241
259
  maxResults: Type.Optional(Type.Number({ description: "Maximum matches per file from rg -m." })),
@@ -17,8 +17,8 @@
17
17
  * Usage: pi -e extensions/theme-cycler.ts -e extensions/minimal.ts
18
18
  */
19
19
 
20
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
21
- import { truncateToWidth } from "@mariozechner/pi-tui";
20
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+ import { truncateToWidth } from "@earendil-works/pi-tui";
22
22
  import { applyExtensionDefaults } from "./themeMap.ts";
23
23
 
24
24
  export default function (pi: ExtensionAPI) {
@@ -11,7 +11,7 @@
11
11
  * synthwave · tokyo-night
12
12
  */
13
13
 
14
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
14
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
15
15
  import { basename } from "path";
16
16
  import { fileURLToPath } from "url";
17
17
 
@@ -7,8 +7,8 @@
7
7
  * Usage: pi -e extensions/tool-counter-widget.ts
8
8
  */
9
9
 
10
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
- import { Box, Text } from "@mariozechner/pi-tui";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { Box, Text } from "@earendil-works/pi-tui";
12
12
  import { applyExtensionDefaults } from "./themeMap.ts";
13
13
 
14
14
  const palette = [
@@ -10,9 +10,9 @@
10
10
  * Usage: pi -e extensions/tool-counter.ts
11
11
  */
12
12
 
13
- import type { AssistantMessage } from "@mariozechner/pi-ai";
14
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15
- import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
13
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
14
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
16
  import { basename } from "node:path";
17
17
  import { applyExtensionDefaults } from "./themeMap.ts";
18
18
 
@@ -20,6 +20,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
20
20
  import { type ExtensionAPI, getAgentDir, parseFrontmatter, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
21
21
  import { Text } from "@earendil-works/pi-tui";
22
22
  import { Type } from "typebox";
23
+ import { writeAuditLog } from "./lib/auditLogger.ts";
23
24
 
24
25
  type AgentScope = "user" | "project" | "both";
25
26
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
@@ -28,6 +29,8 @@ type ExecutionMode = "parallel" | "sequential";
28
29
  const MAX_AGENTS_PER_CALL = 8;
29
30
  const MAX_PARALLEL_CONCURRENCY = 4;
30
31
  const OUTPUT_PREVIEW_CHARS = 6_000;
32
+ const DEFAULT_AGENT_TIMEOUT_MS = 120_000;
33
+ const RETRY_DELAY_MS = 2_000;
31
34
  const ROLE_ALIASES: Record<string, string> = {
32
35
  "file_picker": "file-picker",
33
36
  "file-picker": "file-picker",
@@ -83,6 +86,8 @@ interface AgentRunRequest {
83
86
  model?: string;
84
87
  thinking?: ThinkingLevel;
85
88
  tools?: string[];
89
+ timeout?: number;
90
+ retry?: boolean;
86
91
  }
87
92
 
88
93
  interface UsageStats {
@@ -108,6 +113,7 @@ interface AgentRunResult {
108
113
  thinking?: ThinkingLevel;
109
114
  stopReason?: string;
110
115
  errorMessage?: string;
116
+ retryCount?: number;
111
117
  }
112
118
 
113
119
  interface SpawnAgentsDetails {
@@ -271,6 +277,7 @@ async function runSingleAgent(
271
277
  availableAgents: AgentConfig[],
272
278
  request: AgentRunRequest,
273
279
  signal: AbortSignal | undefined,
280
+ onProgress?: (agentName: string, text: string) => void,
274
281
  ): Promise<AgentRunResult> {
275
282
  const requestedAgent = normalizeRoleName(request.agent ?? request.agent_type);
276
283
  const prompt = (request.prompt ?? request.task ?? "").trim();
@@ -330,6 +337,7 @@ async function runSingleAgent(
330
337
  stdio: ["ignore", "pipe", "pipe"],
331
338
  });
332
339
  let buffer = "";
340
+ let progressText = "";
333
341
 
334
342
  const processLine = (line: string) => {
335
343
  if (!line.trim()) return;
@@ -340,6 +348,13 @@ async function runSingleAgent(
340
348
  return;
341
349
  }
342
350
 
351
+ // Stream text deltas for live progress
352
+ if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
353
+ const delta = event.assistantMessageEvent.delta || "";
354
+ progressText += delta;
355
+ if (onProgress) onProgress(requestedAgent, progressText);
356
+ }
357
+
343
358
  if (event.type === "message_end" && event.message) {
344
359
  const message = event.message as Message;
345
360
  messages.push(message);
@@ -397,17 +412,42 @@ async function runSingleAgent(
397
412
  if (signal.aborted) killProc();
398
413
  else signal.addEventListener("abort", killProc, { once: true });
399
414
  }
415
+
416
+ // Agent timeout
417
+ const timeoutMs = request.timeout ?? DEFAULT_AGENT_TIMEOUT_MS;
418
+ if (timeoutMs > 0) {
419
+ setTimeout(() => {
420
+ if (!proc.killed) {
421
+ wasAborted = true;
422
+ baseResult.stopReason = "timeout";
423
+ proc.kill("SIGTERM");
424
+ setTimeout(() => {
425
+ if (!proc.killed) proc.kill("SIGKILL");
426
+ }, 5_000);
427
+ }
428
+ }, timeoutMs);
429
+ }
400
430
  });
401
431
 
402
432
  if (wasAborted) {
403
- return { ...baseResult, exitCode, errorMessage: "Subagent was aborted" };
433
+ return { ...baseResult, exitCode, errorMessage: baseResult.stopReason === "timeout" ? `Agent timed out after ${(request.timeout ?? DEFAULT_AGENT_TIMEOUT_MS) / 1000}s` : "Subagent was aborted" };
404
434
  }
405
435
 
406
- return {
436
+ const result: AgentRunResult = {
407
437
  ...baseResult,
408
438
  exitCode,
409
439
  output: getFinalAssistantText(messages),
410
440
  };
441
+
442
+ // Retry logic: retry once if failed with empty output (transient failure)
443
+ if (request.retry && exitCode !== 0 && !result.output.trim() && !(result.retryCount ?? 0)) {
444
+ await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
445
+ const retryResult = await runSingleAgent(projectCwd, availableAgents, { ...request, retry: false }, signal, onProgress);
446
+ retryResult.retryCount = 1;
447
+ return retryResult;
448
+ }
449
+
450
+ return result;
411
451
  } finally {
412
452
  if (tmpPromptPath) await fs.promises.rm(tmpPromptPath, { force: true }).catch(() => undefined);
413
453
  if (tmpPromptDir) await fs.promises.rm(tmpPromptDir, { recursive: true, force: true }).catch(() => undefined);
@@ -439,12 +479,13 @@ function summarizeSpawnResults(results: AgentRunResult[]): string {
439
479
  .map((result, index) => {
440
480
  const ok = result.exitCode === 0 && !result.errorMessage;
441
481
  const usage = formatUsage(result.usage);
482
+ const retryNote = result.retryCount ? ` (retried ${result.retryCount}x)` : "";
442
483
  const meta = [result.model, result.thinking ? `thinking:${result.thinking}` : undefined, usage]
443
484
  .filter(Boolean)
444
485
  .join(" | ");
445
486
  const body = result.output || result.errorMessage || result.stderr || "(no output)";
446
487
  return [
447
- `## ${index + 1}. ${ok ? "OK" : "FAIL"} ${result.agent}`,
488
+ `## ${index + 1}. ${ok ? "OK" : "FAIL"} ${result.agent}${retryNote}`,
448
489
  meta ? `_${meta}_` : undefined,
449
490
  "",
450
491
  truncateText(body),
@@ -478,6 +519,8 @@ const AgentRequestSchema = Type.Object({
478
519
  }),
479
520
  ),
480
521
  tools: Type.Optional(Type.Array(Type.String(), { description: "Optional tool override for this agent process" })),
522
+ timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds for this agent. Default 120000 (2 minutes)." })),
523
+ retry: Type.Optional(Type.Boolean({ description: "Retry once on transient failure (empty output + non-zero exit). Default false." })),
481
524
  });
482
525
 
483
526
  export default function workflowExtension(pi: ExtensionAPI) {
@@ -543,7 +586,14 @@ export default function workflowExtension(pi: ExtensionAPI) {
543
586
 
544
587
  if (mode === "sequential") {
545
588
  for (const request of requests) {
546
- const result = await runSingleAgent(ctx.cwd, discovery.agents, request, signal);
589
+ const runStart = Date.now();
590
+ const result = await runSingleAgent(ctx.cwd, discovery.agents, request, signal, (agentName, text) => {
591
+ onUpdate?.({
592
+ content: [{ type: "text", text: `spawn_agents [${agentName}]: ${text.split("\n").filter(Boolean).pop() || "working..."}` }],
593
+ details: makeDetails([...results]),
594
+ });
595
+ });
596
+ (result as any).elapsedMs = Date.now() - runStart;
547
597
  results.push(result);
548
598
  emitProgress();
549
599
  if (result.exitCode !== 0 || result.errorMessage) break;
@@ -553,7 +603,14 @@ export default function workflowExtension(pi: ExtensionAPI) {
553
603
  requests,
554
604
  MAX_PARALLEL_CONCURRENCY,
555
605
  async (request) => {
556
- const result = await runSingleAgent(ctx.cwd, discovery.agents, request, signal);
606
+ const runStart = Date.now();
607
+ const result = await runSingleAgent(ctx.cwd, discovery.agents, request, signal, (agentName, text) => {
608
+ onUpdate?.({
609
+ content: [{ type: "text", text: `spawn_agents [${agentName}]: ${text.split("\n").filter(Boolean).pop() || "working..."}` }],
610
+ details: makeDetails([...results]),
611
+ });
612
+ });
613
+ (result as any).elapsedMs = Date.now() - runStart;
557
614
  results.push(result);
558
615
  emitProgress();
559
616
  return result;
@@ -563,6 +620,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
563
620
  }
564
621
 
565
622
  const successCount = results.filter((result) => result.exitCode === 0 && !result.errorMessage).length;
623
+
624
+ for (const res of results) {
625
+ writeAuditLog(ctx.cwd, {
626
+ type: "workflow",
627
+ name: `spawn_agents:${res.agent}`,
628
+ task: res.prompt,
629
+ input: res.prompt,
630
+ output: res.output || res.errorMessage || "",
631
+ exitCode: res.exitCode,
632
+ elapsedMs: (res as any).elapsedMs || 0,
633
+
634
+ metadata: { executionMode: mode, success: res.exitCode === 0 && !res.errorMessage }
635
+ });
636
+ }
637
+
566
638
  return {
567
639
  content: [
568
640
  {