pi-subagents 0.8.2 → 0.8.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.8.3] - 2026-02-11
6
+
7
+ ### Added
8
+ - Agent `extensions` frontmatter support for extension sandboxing: absent field keeps default extension discovery, empty value disables all extensions, and comma-separated values create an explicit extension allowlist.
9
+
10
+ ### Fixed
11
+ - Parallel chain aggregation now surfaces step failures and warnings in `{previous}` instead of silently passing empty output.
12
+ - Empty-output warnings are now context-aware: runs that intentionally write to explicit output paths are not flagged as warning-only successes in the renderer.
13
+ - Async execution now respects agent `extensions` sandbox settings, matching sync behavior.
14
+ - Single-mode `output` now resolves explicit paths correctly: absolute paths are used directly, and relative paths resolve against `cwd`.
15
+ - Single-mode output persistence is now caller-side in both sync and async execution, so output files are still written when agents run with read-only tools.
16
+ - Pi process spawning now uses a shared cross-platform helper in sync and async paths; on Windows it prefers direct Node + CLI invocation to avoid `ENOENT` and argument fragmentation.
17
+ - Sync JSONL artifact capture now streams lines directly to disk with backpressure handling, preventing unbounded memory growth in long or parallel runs.
18
+ - Execution now defaults `agentScope` to `both`, aligning run behavior with management `list` so project agents shown in discovery execute without explicit scope overrides.
19
+ - Async completion notifications now dedupe at source and notify layers, eliminating duplicate/triple "Background task completed" messages.
20
+ - Async notifications now standardize on canonical `subagent:started` and `subagent:complete` events (legacy enhanced event emissions removed).
21
+
22
+ ### Changed
23
+ - Reworked `skills.ts` to resolve skills through Pi core skill loading with explicit project-first precedence and support for project/user package and settings skill paths.
24
+ - Skill discovery now normalizes and prioritizes collisions by source so project-scoped skills consistently win over user-scoped skills.
25
+ - Documentation now references `<tmpdir>` instead of hardcoded `/tmp` paths for cross-platform clarity.
26
+
5
27
  ## [0.8.2] - 2026-02-11
6
28
 
7
29
  ### Added
@@ -10,7 +32,7 @@
10
32
  ## [0.8.1] - 2026-02-10
11
33
 
12
34
  ### Added
13
- - **`chainDir` param** for persistent chain artifacts — specify a directory to keep artifacts beyond the default 24-hour `/tmp/` cleanup. Relative paths are resolved to absolute via `path.resolve()` for safe use in `{chain_dir}` template substitutions.
35
+ - **`chainDir` param** for persistent chain artifacts — specify a directory to keep artifacts beyond the default 24-hour temp-directory cleanup. Relative paths are resolved to absolute via `path.resolve()` for safe use in `{chain_dir}` template substitutions.
14
36
 
15
37
  ## [0.8.0] - 2026-02-09
16
38
 
package/README.md CHANGED
@@ -31,7 +31,7 @@ Agents are markdown files with YAML frontmatter that define specialized subagent
31
31
  | User | `~/.pi/agent/agents/{name}.md` |
32
32
  | Project | `.pi/agents/{name}.md` (searches up directory tree) |
33
33
 
34
- Use `agentScope` parameter to control discovery: `"user"` (default), `"project"`, or `"both"` (project takes priority).
34
+ Use `agentScope` parameter to control discovery: `"user"`, `"project"`, or `"both"` (default; project takes priority).
35
35
 
36
36
  **Agent frontmatter:**
37
37
 
@@ -40,6 +40,7 @@ Use `agentScope` parameter to control discovery: `"user"` (default), `"project"`
40
40
  name: scout
41
41
  description: Fast codebase recon
42
42
  tools: read, grep, find, ls, bash, mcp:chrome-devtools # mcp: requires pi-mcp-adapter
43
+ extensions: # absent=all, empty=none, csv=allowlist
43
44
  model: claude-haiku-4-5
44
45
  thinking: high # off, minimal, low, medium, high, xhigh
45
46
  skill: safe-bash, chrome-devtools # comma-separated skills to inject
@@ -54,6 +55,27 @@ Your system prompt goes here (the markdown body after frontmatter).
54
55
 
55
56
  The `thinking` field sets a default extended thinking level for the agent. At runtime it's appended as a `:level` suffix to the model string (e.g., `claude-sonnet-4-5:high`). If the model already has a thinking suffix (from a chain-clarify override), the agent's default is not double-applied.
56
57
 
58
+ **Extension sandboxing**
59
+
60
+ Use `extensions` in frontmatter to control which extensions a subagent can access:
61
+
62
+ ```yaml
63
+ # Field absent: all extensions load (default behavior)
64
+
65
+ # Empty field: no extensions
66
+ extensions:
67
+
68
+ # Allowlist specific extensions
69
+ extensions: /abs/path/to/ext-a.ts, /abs/path/to/ext-b.ts
70
+ ```
71
+
72
+ Semantics:
73
+ - `extensions` absent → all extensions load
74
+ - `extensions:` empty → `--no-extensions`
75
+ - `extensions: a,b` → `--no-extensions --extension a --extension b`
76
+
77
+ When `extensions` is present, it takes precedence over extension paths implied by `tools` entries.
78
+
57
79
  **MCP Tools**
58
80
 
59
81
  Agents can use MCP server tools directly (requires the [pi-mcp-adapter](https://github.com/nicobailon/pi-mcp-adapter) extension). Add `mcp:` prefixed entries to the `tools` field:
@@ -127,7 +149,7 @@ Append `[key=value,...]` to any agent name to override its defaults:
127
149
 
128
150
  | Key | Example | Description |
129
151
  |-----|---------|-------------|
130
- | `output` | `output=context.md` | Write results to file (relative to chain dir for `/chain`/`/parallel`, temp dir for `/run`) |
152
+ | `output` | `output=context.md` | Write results to file (relative to chain dir for `/chain`/`/parallel`; for `/run`, absolute paths are used as-is and relative paths resolve against cwd) |
131
153
  | `reads` | `reads=a.md+b.md` | Read files before executing (`+` separates multiple) |
132
154
  | `model` | `model=anthropic/claude-sonnet-4` | Override model for this step |
133
155
  | `skills` | `skills=planning+review` | Override skills (`+` separates multiple) |
@@ -228,7 +250,7 @@ Chains can be created from the Agents Manager template picker ("Blank Chain"), o
228
250
  - **Parallel-in-Chain**: Fan-out/fan-in patterns with `{ parallel: [...] }` steps within chains
229
251
  - **Chain Clarification TUI**: Interactive preview/edit of chain templates and behaviors before execution
230
252
  - **Agent Frontmatter Extensions**: Agents declare default chain behavior (`output`, `defaultReads`, `defaultProgress`, `skill`)
231
- - **Chain Artifacts**: Shared directory at `/tmp/pi-chain-runs/{runId}/` for inter-step files
253
+ - **Chain Artifacts**: Shared directory at `<tmpdir>/pi-chain-runs/{runId}/` for inter-step files
232
254
  - **Solo Agent Output**: Agents with `output` write to temp dir and return path to caller
233
255
  - **Live Progress Display**: Real-time visibility during sync execution showing current tool, recent output, tokens, and duration
234
256
  - **Output Truncation**: Configurable byte/line limits via `maxOutput`
@@ -310,9 +332,13 @@ Single and parallel modes also support the clarify TUI for previewing/editing pa
310
332
 
311
333
  Skills are specialized instructions loaded from SKILL.md files and injected into the agent's system prompt.
312
334
 
313
- **Skill locations:**
314
- - Project: `.pi/skills/{name}/SKILL.md` (higher priority)
335
+ **Skill locations (project-first precedence):**
336
+ - Project: `.pi/skills/{name}/SKILL.md`
337
+ - Project packages: `.pi/npm/node_modules/*` via `package.json -> pi.skills`
338
+ - Project settings: `.pi/settings.json -> skills`
315
339
  - User: `~/.pi/agent/skills/{name}/SKILL.md`
340
+ - User packages: `~/.pi/agent/npm/node_modules/*` via `package.json -> pi.skills`
341
+ - User settings: `~/.pi/agent/settings.json -> skills`
316
342
 
317
343
  **Usage:**
318
344
  ```typescript
@@ -397,7 +423,7 @@ Skills are specialized instructions loaded from SKILL.md files and injected into
397
423
  **subagent_status tool:**
398
424
  ```typescript
399
425
  { id: "a53ebe46" }
400
- { dir: "/tmp/pi-async-subagent-runs/a53ebe46-..." }
426
+ { dir: "<tmpdir>/pi-async-subagent-runs/a53ebe46-..." }
401
427
  ```
402
428
 
403
429
  ## Management Actions
@@ -421,6 +447,7 @@ Agent definitions are not loaded into LLM context by default. Management actions
421
447
  systemPrompt: "You are a code scout...",
422
448
  model: "anthropic/claude-sonnet-4",
423
449
  tools: "read, bash, mcp:github/search_repositories",
450
+ extensions: "", // empty = no extensions
424
451
  skills: "parallel-scout",
425
452
  thinking: "high",
426
453
  output: "context.md",
@@ -457,7 +484,7 @@ Agent definitions are not loaded into LLM context by default. Management actions
457
484
  Notes:
458
485
  - `create` uses `config.scope` (`"user"` or `"project"`), not `agentScope`.
459
486
  - `update`/`delete` use `agentScope` only for scope disambiguation when the same name exists in both scopes.
460
- - Agent config mapping: `reads -> defaultReads`, `progress -> defaultProgress`, and `tools` supports `mcp:` entries that map to direct MCP tools.
487
+ - Agent config mapping: `reads -> defaultReads`, `progress -> defaultProgress`, `extensions` controls extension sandboxing, and `tools` supports `mcp:` entries that map to direct MCP tools.
461
488
  - To clear any optional field, set it to `false` or `""` (e.g., `{ model: false }` or `{ skills: "" }`). Both work for all string-typed fields.
462
489
 
463
490
  ## Parameters
@@ -469,14 +496,14 @@ Notes:
469
496
  | `action` | string | - | Management action: `list`, `get`, `create`, `update`, `delete` |
470
497
  | `chainName` | string | - | Chain name for management get/update/delete |
471
498
  | `config` | object | - | Agent or chain config for management create/update |
472
- | `output` | `string \| false` | agent default | Override output file for single agent |
499
+ | `output` | `string \| false` | agent default | Override output file for single agent (absolute path as-is, relative path resolved against cwd) |
473
500
  | `skill` | `string \| string[] \| false` | agent default | Override skills (comma-separated string, array, or false to disable) |
474
501
  | `model` | string | agent default | Override model for single agent |
475
502
  | `tasks` | `{agent, task, cwd?, skill?}[]` | - | Parallel tasks (sync only) |
476
503
  | `chain` | ChainItem[] | - | Sequential steps with behavior overrides (see below) |
477
- | `chainDir` | string | `/tmp/pi-chain-runs/` | Persistent directory for chain artifacts (default auto-cleaned after 24h) |
504
+ | `chainDir` | string | `<tmpdir>/pi-chain-runs/` | Persistent directory for chain artifacts (default auto-cleaned after 24h) |
478
505
  | `clarify` | boolean | true (chains) | Show TUI to preview/edit chain; implies sync mode |
479
- | `agentScope` | `"user" \| "project" \| "both"` | `user` | Agent discovery scope |
506
+ | `agentScope` | `"user" \| "project" \| "both"` | `both` | Agent discovery scope (project wins on name collisions) |
480
507
  | `async` | boolean | false | Background execution (requires `clarify: false` for chains) |
481
508
  | `cwd` | string | - | Override working directory |
482
509
  | `maxOutput` | `{bytes?, lines?}` | 200KB, 5000 lines | Truncation limits for final output |
@@ -551,7 +578,7 @@ This aggregated output becomes `{previous}` for the next step.
551
578
 
552
579
  ## Chain Directory
553
580
 
554
- Each chain run creates `/tmp/pi-chain-runs/{runId}/` containing:
581
+ Each chain run creates `<tmpdir>/pi-chain-runs/{runId}/` containing:
555
582
  - `context.md` - Scout/context-builder output
556
583
  - `plan.md` - Planner output
557
584
  - `progress.md` - Worker/reviewer shared progress
@@ -564,7 +591,7 @@ Directories older than 24 hours are cleaned up on extension startup.
564
591
 
565
592
  ## Artifacts
566
593
 
567
- Location: `{sessionDir}/subagent-artifacts/` or `/tmp/pi-subagent-artifacts/`
594
+ Location: `{sessionDir}/subagent-artifacts/` or `<tmpdir>/pi-subagent-artifacts/`
568
595
 
569
596
  Files per task:
570
597
  - `{runId}_{agent}_input.md` - Task prompt
@@ -574,7 +601,7 @@ Files per task:
574
601
 
575
602
  ## Session Logs
576
603
 
577
- Session files (JSONL) are stored under a per-run session dir (temp by default). The session file path is shown in output. Set `sessionDir` to keep session logs outside `/tmp`.
604
+ Session files (JSONL) are stored under a per-run session dir (temp by default). The session file path is shown in output. Set `sessionDir` to keep session logs outside `<tmpdir>`.
578
605
 
579
606
  ## Live progress (sync mode)
580
607
 
@@ -616,7 +643,7 @@ export PI_SUBAGENT_MAX_DEPTH=0 # disable the subagent tool entirely
616
643
  Async runs write a dedicated observability folder:
617
644
 
618
645
  ```
619
- /tmp/pi-async-subagent-runs/<id>/
646
+ <tmpdir>/pi-async-subagent-runs/<id>/
620
647
  status.json
621
648
  events.jsonl
622
649
  subagent-log-<id>.md
@@ -627,7 +654,7 @@ Async runs write a dedicated observability folder:
627
654
 
628
655
  ```typescript
629
656
  subagent_status({ id: "<id>" })
630
- subagent_status({ dir: "/tmp/pi-async-subagent-runs/<id>" })
657
+ subagent_status({ dir: "<tmpdir>/pi-async-subagent-runs/<id>" })
631
658
  ```
632
659
 
633
660
  ## Events
@@ -636,9 +663,7 @@ Async events:
636
663
  - `subagent:started`
637
664
  - `subagent:complete`
638
665
 
639
- Legacy events (still emitted):
640
- - `subagent_enhanced:started`
641
- - `subagent_enhanced:complete`
666
+ `notify.ts` consumes `subagent:complete` as the canonical completion channel.
642
667
 
643
668
  ## Files
644
669
 
@@ -203,6 +203,12 @@ function applyAgentConfig(target: AgentConfig, cfg: Record<string, unknown>): st
203
203
  else if (typeof cfg.skills === "string") { const skills = parseCsv(cfg.skills); target.skills = skills.length ? skills : undefined; }
204
204
  else return "config.skills must be a comma-separated string or false when provided.";
205
205
  }
206
+ if (hasKey(cfg, "extensions")) {
207
+ if (cfg.extensions === false) target.extensions = undefined;
208
+ else if (cfg.extensions === "") target.extensions = [];
209
+ else if (typeof cfg.extensions === "string") target.extensions = parseCsv(cfg.extensions);
210
+ else return "config.extensions must be a comma-separated string, empty string, or false when provided.";
211
+ }
206
212
  if (hasKey(cfg, "thinking")) {
207
213
  if (cfg.thinking === false || cfg.thinking === "") target.thinking = undefined;
208
214
  else if (typeof cfg.thinking === "string") target.thinking = cfg.thinking.trim() || undefined;
@@ -273,6 +279,7 @@ export function formatAgentDetail(agent: AgentConfig): string {
273
279
  if (agent.model) lines.push(`Model: ${agent.model}`);
274
280
  if (tools.length) lines.push(`Tools: ${tools.join(", ")}`);
275
281
  if (agent.skills?.length) lines.push(`Skills: ${agent.skills.join(", ")}`);
282
+ if (agent.extensions !== undefined) lines.push(`Extensions: ${agent.extensions.length ? agent.extensions.join(", ") : "(none)"}`);
276
283
  if (agent.thinking) lines.push(`Thinking: ${agent.thinking}`);
277
284
  if (agent.output) lines.push(`Output: ${agent.output}`);
278
285
  if (agent.defaultReads?.length) lines.push(`Reads: ${agent.defaultReads.join(", ")}`);
@@ -64,6 +64,10 @@ function buildDetailLines(
64
64
  lines.push(renderFieldLine("Tools:", tools, contentWidth, theme));
65
65
  lines.push(renderFieldLine("MCP:", mcp, contentWidth, theme));
66
66
  lines.push(renderFieldLine("Skills:", skillsList, contentWidth, theme));
67
+ const extensionsList = agent.extensions !== undefined
68
+ ? (agent.extensions.length > 0 ? agent.extensions.join(", ") : "(none)")
69
+ : "(all)";
70
+ lines.push(renderFieldLine("Extensions:", extensionsList, contentWidth, theme));
67
71
  lines.push(renderFieldLine("Output:", output, contentWidth, theme));
68
72
  lines.push(renderFieldLine("Reads:", reads, contentWidth, theme));
69
73
  lines.push(renderFieldLine("Progress:", progress, contentWidth, theme));
@@ -16,7 +16,7 @@ export interface EditState {
16
16
  export interface EditInputResult { action?: "save" | "discard"; nextScreen?: EditScreen; }
17
17
 
18
18
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
19
- const FIELD_ORDER = ["name", "description", "model", "thinking", "tools", "skills", "output", "reads", "progress", "interactive", "prompt"] as const;
19
+ const FIELD_ORDER = ["name", "description", "model", "thinking", "tools", "extensions", "skills", "output", "reads", "progress", "interactive", "prompt"] as const;
20
20
  type EditField = typeof FIELD_ORDER[number];
21
21
  type ThinkingLevel = typeof THINKING_LEVELS[number];
22
22
  const PROMPT_VIEWPORT_HEIGHT = 16;
@@ -29,7 +29,7 @@ function parseCommaList(value: string): string[] | undefined { const items = val
29
29
 
30
30
  export function createEditState(draft: AgentConfig, isNew: boolean, models: ModelInfo[], skills: SkillInfo[]): EditState {
31
31
  return {
32
- draft: { ...draft, tools: draft.tools ? [...draft.tools] : undefined, mcpDirectTools: draft.mcpDirectTools ? [...draft.mcpDirectTools] : undefined, skills: draft.skills ? [...draft.skills] : undefined, defaultReads: draft.defaultReads ? [...draft.defaultReads] : undefined, extraFields: draft.extraFields ? { ...draft.extraFields } : undefined },
32
+ draft: { ...draft, tools: draft.tools ? [...draft.tools] : undefined, mcpDirectTools: draft.mcpDirectTools ? [...draft.mcpDirectTools] : undefined, skills: draft.skills ? [...draft.skills] : undefined, extensions: draft.extensions ? [...draft.extensions] : draft.extensions, defaultReads: draft.defaultReads ? [...draft.defaultReads] : undefined, extraFields: draft.extraFields ? { ...draft.extraFields } : undefined },
33
33
  isNew, fieldIndex: 0, fieldMode: null, fieldEditor: createEditorState(), promptEditor: createEditorState(draft.systemPrompt ?? ""),
34
34
  modelSearchQuery: "", modelCursor: 0, filteredModels: [...models], thinkingCursor: 0, skillSearchQuery: "", skillCursor: 0, filteredSkills: [...skills], skillSelected: new Set(draft.skills ?? []),
35
35
  };
@@ -43,6 +43,7 @@ function renderFieldValue(field: EditField, state: EditState): string {
43
43
  case "model": return draft.model ?? "default";
44
44
  case "thinking": return draft.thinking ?? "off";
45
45
  case "tools": return formatTools(draft);
46
+ case "extensions": return draft.extensions !== undefined ? (draft.extensions.length > 0 ? draft.extensions.join(", ") : "") : "(all)";
46
47
  case "skills": return draft.skills && draft.skills.length > 0 ? draft.skills.join(", ") : "";
47
48
  case "output": return draft.output ?? "";
48
49
  case "reads": return draft.defaultReads && draft.defaultReads.length > 0 ? draft.defaultReads.join(", ") : "";
@@ -59,6 +60,7 @@ function applyFieldValue(field: EditField, state: EditState, value: string): voi
59
60
  case "description": draft.description = value.trim(); break;
60
61
  case "model": draft.model = value.trim() || undefined; break;
61
62
  case "tools": { const parsed = parseTools(value); draft.tools = parsed.tools; draft.mcpDirectTools = parsed.mcp; break; }
63
+ case "extensions": { const trimmed = value.trim(); draft.extensions = trimmed === "(all)" ? undefined : parseCommaList(trimmed) ?? []; break; }
62
64
  case "skills": draft.skills = parseCommaList(value); break;
63
65
  case "output": { const trimmed = value.trim(); draft.output = trimmed.length > 0 ? trimmed : undefined; break; }
64
66
  case "reads": draft.defaultReads = parseCommaList(value); break;
package/agent-scope.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { AgentScope } from "./agents.js";
2
+
3
+ export function resolveExecutionAgentScope(scope: unknown): AgentScope {
4
+ if (scope === "user" || scope === "project" || scope === "both") return scope;
5
+ return "both";
6
+ }
@@ -0,0 +1,20 @@
1
+ import type { AgentScope, AgentConfig } from "./agents.js";
2
+
3
+ export function mergeAgentsForScope(
4
+ scope: AgentScope,
5
+ userAgents: AgentConfig[],
6
+ projectAgents: AgentConfig[],
7
+ ): AgentConfig[] {
8
+ const agentMap = new Map<string, AgentConfig>();
9
+
10
+ if (scope === "both") {
11
+ for (const agent of userAgents) agentMap.set(agent.name, agent);
12
+ for (const agent of projectAgents) agentMap.set(agent.name, agent);
13
+ } else if (scope === "user") {
14
+ for (const agent of userAgents) agentMap.set(agent.name, agent);
15
+ } else {
16
+ for (const agent of projectAgents) agentMap.set(agent.name, agent);
17
+ }
18
+
19
+ return Array.from(agentMap.values());
20
+ }
@@ -9,6 +9,7 @@ export const KNOWN_FIELDS = new Set([
9
9
  "thinking",
10
10
  "skill",
11
11
  "skills",
12
+ "extensions",
12
13
  "output",
13
14
  "defaultReads",
14
15
  "defaultProgress",
@@ -39,6 +40,11 @@ export function serializeAgent(config: AgentConfig): string {
39
40
  const skillsValue = joinComma(config.skills);
40
41
  if (skillsValue) lines.push(`skills: ${skillsValue}`);
41
42
 
43
+ if (config.extensions !== undefined) {
44
+ const extensionsValue = joinComma(config.extensions);
45
+ lines.push(`extensions: ${extensionsValue ?? ""}`);
46
+ }
47
+
42
48
  if (config.output) lines.push(`output: ${config.output}`);
43
49
 
44
50
  const readsValue = joinComma(config.defaultReads);
package/agents.ts CHANGED
@@ -7,6 +7,7 @@ import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import { KNOWN_FIELDS } from "./agent-serializer.js";
9
9
  import { parseChain } from "./chain-serializer.js";
10
+ import { mergeAgentsForScope } from "./agent-selection.js";
10
11
 
11
12
  export type AgentScope = "user" | "project" | "both";
12
13
 
@@ -21,6 +22,7 @@ export interface AgentConfig {
21
22
  source: "user" | "project";
22
23
  filePath: string;
23
24
  skills?: string[];
25
+ extensions?: string[];
24
26
  // Chain behavior fields
25
27
  output?: string;
26
28
  defaultReads?: string[];
@@ -145,6 +147,14 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
145
147
  .map((s) => s.trim())
146
148
  .filter(Boolean);
147
149
 
150
+ let extensions: string[] | undefined;
151
+ if (frontmatter.extensions !== undefined) {
152
+ extensions = frontmatter.extensions
153
+ .split(",")
154
+ .map((e) => e.trim())
155
+ .filter(Boolean);
156
+ }
157
+
148
158
  const extraFields: Record<string, string> = {};
149
159
  for (const [key, value] of Object.entries(frontmatter)) {
150
160
  if (!KNOWN_FIELDS.has(key)) extraFields[key] = value;
@@ -161,6 +171,7 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
161
171
  source,
162
172
  filePath,
163
173
  skills: skills && skills.length > 0 ? skills : undefined,
174
+ extensions,
164
175
  // Chain behavior fields
165
176
  output: frontmatter.output,
166
177
  defaultReads: defaultReads && defaultReads.length > 0 ? defaultReads : undefined,
@@ -235,19 +246,9 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
235
246
 
236
247
  const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
237
248
  const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
249
+ const agents = mergeAgentsForScope(scope, userAgents, projectAgents);
238
250
 
239
- const agentMap = new Map<string, AgentConfig>();
240
-
241
- if (scope === "both") {
242
- for (const agent of userAgents) agentMap.set(agent.name, agent);
243
- for (const agent of projectAgents) agentMap.set(agent.name, agent);
244
- } else if (scope === "user") {
245
- for (const agent of userAgents) agentMap.set(agent.name, agent);
246
- } else {
247
- for (const agent of projectAgents) agentMap.set(agent.name, agent);
248
- }
249
-
250
- return { agents: Array.from(agentMap.values()), projectAgentsDir };
251
+ return { agents, projectAgentsDir };
251
252
  }
252
253
 
253
254
  export function discoverAgentsAll(cwd: string): {
package/artifacts.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import type { ArtifactPaths } from "./types.js";
4
5
 
5
- const TEMP_ARTIFACTS_DIR = "/tmp/pi-subagent-artifacts";
6
+ const TEMP_ARTIFACTS_DIR = path.join(os.tmpdir(), "pi-subagent-artifacts");
6
7
  const CLEANUP_MARKER_FILE = ".last-cleanup";
7
8
 
8
9
  export function getArtifactsDir(sessionFile: string | null): string {
@@ -11,6 +11,7 @@ import { createRequire } from "node:module";
11
11
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
12
  import type { AgentConfig } from "./agents.js";
13
13
  import { applyThinkingSuffix } from "./execution.js";
14
+ import { injectSingleOutputInstruction, resolveSingleOutputPath } from "./single-output.js";
14
15
  import { isParallelStep, resolveStepBehavior, type ChainStep, type SequentialStep, type StepOverrides } from "./settings.js";
15
16
  import { buildSkillInjection, normalizeSkillInput, resolveSkills } from "./skills.js";
16
17
  import {
@@ -61,6 +62,7 @@ export interface AsyncSingleParams {
61
62
  shareEnabled: boolean;
62
63
  sessionRoot?: string;
63
64
  skills?: string[];
65
+ output?: string | false;
64
66
  }
65
67
 
66
68
  export interface AsyncExecutionResult {
@@ -153,6 +155,7 @@ export function executeAsyncChain(
153
155
  cwd: s.cwd,
154
156
  model: applyThinkingSuffix(s.model ?? a.model, a.thinking),
155
157
  tools: a.tools,
158
+ extensions: a.extensions,
156
159
  mcpDirectTools: a.mcpDirectTools,
157
160
  systemPrompt,
158
161
  skills: resolvedSkills.map((r) => r.name),
@@ -181,15 +184,6 @@ export function executeAsyncChain(
181
184
 
182
185
  if (pid) {
183
186
  const firstAgent = chain[0] as SequentialStep;
184
- ctx.pi.events.emit("subagent_enhanced:started", {
185
- id,
186
- pid,
187
- agent: firstAgent.agent,
188
- task: firstAgent.task?.slice(0, 50),
189
- chain: chain.map((s) => (s as SequentialStep).agent),
190
- cwd: runnerCwd,
191
- asyncDir,
192
- });
193
187
  ctx.pi.events.emit("subagent:started", {
194
188
  id,
195
189
  pid,
@@ -231,19 +225,23 @@ export function executeAsyncSingle(
231
225
  } catch {}
232
226
 
233
227
  const runnerCwd = cwd ?? ctx.cwd;
228
+ const outputPath = resolveSingleOutputPath(params.output, ctx.cwd, cwd);
229
+ const taskWithOutputInstruction = injectSingleOutputInstruction(task, outputPath);
234
230
  const pid = spawnRunner(
235
231
  {
236
232
  id,
237
233
  steps: [
238
234
  {
239
235
  agent,
240
- task,
236
+ task: taskWithOutputInstruction,
241
237
  cwd,
242
238
  model: applyThinkingSuffix(agentConfig.model, agentConfig.thinking),
243
239
  tools: agentConfig.tools,
240
+ extensions: agentConfig.extensions,
244
241
  mcpDirectTools: agentConfig.mcpDirectTools,
245
242
  systemPrompt,
246
243
  skills: resolvedSkills.map((r) => r.name),
244
+ outputPath,
247
245
  },
248
246
  ],
249
247
  resultPath: path.join(RESULTS_DIR, `${id}.json`),
@@ -262,14 +260,6 @@ export function executeAsyncSingle(
262
260
  );
263
261
 
264
262
  if (pid) {
265
- ctx.pi.events.emit("subagent_enhanced:started", {
266
- id,
267
- pid,
268
- agent,
269
- task: task?.slice(0, 50),
270
- cwd: runnerCwd,
271
- asyncDir,
272
- });
273
263
  ctx.pi.events.emit("subagent:started", {
274
264
  id,
275
265
  pid,
@@ -376,13 +376,21 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
376
376
  }
377
377
 
378
378
  // Aggregate outputs for {previous}
379
- const taskResults: ParallelTaskResult[] = parallelResults.map((r, i) => ({
380
- agent: r.agent,
381
- taskIndex: i,
382
- output: getFinalOutput(r.messages),
383
- exitCode: r.exitCode,
384
- error: r.error,
385
- }));
379
+ const taskResults: ParallelTaskResult[] = parallelResults.map((r, i) => {
380
+ const outputTarget = parallelBehaviors[i]?.output;
381
+ const outputTargetPath = typeof outputTarget === "string"
382
+ ? (path.isAbsolute(outputTarget) ? outputTarget : path.join(chainDir, outputTarget))
383
+ : undefined;
384
+ return {
385
+ agent: r.agent,
386
+ taskIndex: i,
387
+ output: getFinalOutput(r.messages),
388
+ exitCode: r.exitCode,
389
+ error: r.error,
390
+ outputTargetPath,
391
+ outputTargetExists: outputTargetPath ? fs.existsSync(outputTargetPath) : undefined,
392
+ };
393
+ });
386
394
  prev = aggregateParallelOutputs(taskResults);
387
395
  } else {
388
396
  // === SEQUENTIAL STEP EXECUTION ===
@@ -0,0 +1,63 @@
1
+ interface CompletionDataLike {
2
+ id?: unknown;
3
+ agent?: unknown;
4
+ timestamp?: unknown;
5
+ sessionId?: unknown;
6
+ taskIndex?: unknown;
7
+ totalTasks?: unknown;
8
+ success?: unknown;
9
+ }
10
+
11
+ function asNonEmptyString(value: unknown): string | undefined {
12
+ if (typeof value !== "string") return undefined;
13
+ const trimmed = value.trim();
14
+ return trimmed.length > 0 ? trimmed : undefined;
15
+ }
16
+
17
+ function asFiniteNumber(value: unknown): number | undefined {
18
+ if (typeof value !== "number") return undefined;
19
+ return Number.isFinite(value) ? value : undefined;
20
+ }
21
+
22
+ export function buildCompletionKey(data: CompletionDataLike, fallback: string): string {
23
+ const id = asNonEmptyString(data.id);
24
+ if (id) return `id:${id}`;
25
+ const sessionId = asNonEmptyString(data.sessionId) ?? "no-session";
26
+ const agent = asNonEmptyString(data.agent) ?? "unknown";
27
+ const timestamp = asFiniteNumber(data.timestamp);
28
+ const taskIndex = asFiniteNumber(data.taskIndex);
29
+ const totalTasks = asFiniteNumber(data.totalTasks);
30
+ const success = typeof data.success === "boolean" ? (data.success ? "1" : "0") : "?";
31
+ return [
32
+ "meta",
33
+ sessionId,
34
+ agent,
35
+ timestamp !== undefined ? String(timestamp) : "no-ts",
36
+ taskIndex !== undefined ? String(taskIndex) : "-",
37
+ totalTasks !== undefined ? String(totalTasks) : "-",
38
+ success,
39
+ fallback,
40
+ ].join(":");
41
+ }
42
+
43
+ export function pruneSeenMap(seen: Map<string, number>, now: number, ttlMs: number): void {
44
+ for (const [key, ts] of seen.entries()) {
45
+ if (now - ts > ttlMs) seen.delete(key);
46
+ }
47
+ }
48
+
49
+ export function markSeenWithTtl(seen: Map<string, number>, key: string, now: number, ttlMs: number): boolean {
50
+ pruneSeenMap(seen, now, ttlMs);
51
+ if (seen.has(key)) return true;
52
+ seen.set(key, now);
53
+ return false;
54
+ }
55
+
56
+ export function getGlobalSeenMap(storeKey: string): Map<string, number> {
57
+ const globalStore = globalThis as Record<string, unknown>;
58
+ const existing = globalStore[storeKey];
59
+ if (existing instanceof Map) return existing as Map<string, number>;
60
+ const map = new Map<string, number>();
61
+ globalStore[storeKey] = map;
62
+ return map;
63
+ }