pi-subagents 0.21.3 → 0.21.4

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.
@@ -1,4 +1,5 @@
1
1
  import type { ChainConfig, ChainStepConfig } from "./agents.ts";
2
+ import { buildRuntimeName, frontmatterNameForConfig, parsePackageName } from "./identity.ts";
2
3
  import { parseFrontmatter } from "./frontmatter.ts";
3
4
 
4
5
  function parseStepBody(agent: string, sectionBody: string): ChainStepConfig {
@@ -19,6 +20,10 @@ function parseStepBody(agent: string, sectionBody: string): ChainStepConfig {
19
20
  else if (rawValue) step.output = rawValue;
20
21
  continue;
21
22
  }
23
+ if (key === "outputmode") {
24
+ if (rawValue === "inline" || rawValue === "file-only") step.outputMode = rawValue;
25
+ continue;
26
+ }
22
27
  if (key === "reads") {
23
28
  if (rawValue === "false") {
24
29
  step.reads = false;
@@ -75,14 +80,20 @@ export function parseChain(content: string, source: "user" | "project", filePath
75
80
  steps.push(parseStepBody(agent, sectionBody));
76
81
  }
77
82
 
83
+ const localName = frontmatter.name;
84
+ const parsedPackage = parsePackageName(frontmatter.package, `Chain '${localName}' package`);
85
+ if (parsedPackage.error) throw new Error(parsedPackage.error);
86
+ const packageName = parsedPackage.packageName;
78
87
  const extraFields: Record<string, string> = {};
79
88
  for (const [key, value] of Object.entries(frontmatter)) {
80
- if (key === "name" || key === "description") continue;
89
+ if (key === "name" || key === "package" || key === "description") continue;
81
90
  extraFields[key] = value;
82
91
  }
83
92
 
84
93
  return {
85
- name: frontmatter.name,
94
+ name: buildRuntimeName(localName, packageName),
95
+ localName,
96
+ packageName,
86
97
  description: frontmatter.description,
87
98
  source,
88
99
  filePath,
@@ -94,7 +105,8 @@ export function parseChain(content: string, source: "user" | "project", filePath
94
105
  export function serializeChain(config: ChainConfig): string {
95
106
  const lines: string[] = [];
96
107
  lines.push("---");
97
- lines.push(`name: ${config.name}`);
108
+ lines.push(`name: ${frontmatterNameForConfig(config)}`);
109
+ if (config.packageName) lines.push(`package: ${config.packageName}`);
98
110
  lines.push(`description: ${config.description}`);
99
111
  if (config.extraFields) {
100
112
  for (const [key, value] of Object.entries(config.extraFields)) {
@@ -109,6 +121,7 @@ export function serializeChain(config: ChainConfig): string {
109
121
  lines.push(`## ${step.agent}`);
110
122
  if (step.output === false) lines.push("output: false");
111
123
  else if (step.output) lines.push(`output: ${step.output}`);
124
+ if (step.outputMode) lines.push(`outputMode: ${step.outputMode}`);
112
125
  if (step.reads === false) lines.push("reads: false");
113
126
  else if (Array.isArray(step.reads) && step.reads.length > 0) lines.push(`reads: ${step.reads.join(", ")}`);
114
127
  if (step.model) lines.push(`model: ${step.model}`);
@@ -0,0 +1,30 @@
1
+ import type { AgentConfig, ChainConfig } from "./agents.ts";
2
+
3
+ const IDENTIFIER_PATTERN = /^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)*$/;
4
+
5
+ function normalizePackageName(value: string | undefined): string | undefined {
6
+ const trimmed = value?.trim();
7
+ if (!trimmed) return undefined;
8
+ return trimmed.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9.-]/g, "").replace(/-+/g, "-").replace(/\.+/g, ".").replace(/(?:^[-.]+|[-.]+$)/g, "");
9
+ }
10
+
11
+ export function parsePackageName(value: unknown, label = "package"): { packageName?: string; error?: string } {
12
+ if (value === undefined || value === false || value === "") return { packageName: undefined };
13
+ if (typeof value !== "string") return { error: `${label} must be a string or false when provided.` };
14
+ const packageName = normalizePackageName(value);
15
+ if (!packageName || !IDENTIFIER_PATTERN.test(packageName)) return { error: `${label} is invalid after sanitization.` };
16
+ return { packageName };
17
+ }
18
+
19
+ export function buildRuntimeName(localName: string, packageName?: string): string {
20
+ const trimmedPackage = packageName?.trim();
21
+ return trimmedPackage ? `${trimmedPackage}.${localName}` : localName;
22
+ }
23
+
24
+ export function frontmatterNameForConfig(config: Pick<AgentConfig | ChainConfig, "name" | "localName" | "packageName">): string {
25
+ if (config.localName) return config.localName;
26
+ if (config.packageName && config.name.startsWith(`${config.packageName}.`)) {
27
+ return config.name.slice(config.packageName.length + 1);
28
+ }
29
+ return config.name;
30
+ }
@@ -408,11 +408,11 @@ Example: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {agent:"agent-b", t
408
408
 
409
409
  MANAGEMENT (use action field, omit agent/task/chain/tasks):
410
410
  • { action: "list" } - discover executable agents/chains
411
- • { action: "get", agent: "name" } - full detail
412
- • { action: "create", config: { name, systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, ... } }
413
- • { action: "update", agent: "name", config: { ... } } - merge
414
- • { action: "delete", agent: "name" }
415
- • Use chainName for chain operations
411
+ • { action: "get", agent: "name" } - full detail; packaged agents use dotted runtime names like "package.agent"
412
+ • { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, ... } }
413
+ • { action: "update", agent: "code-analysis.custom-agent", config: { package: "analysis", ... } } - merge
414
+ • { action: "delete", agent: "code-analysis.custom-agent" }
415
+ • Use chainName for chain operations; packaged chains also use dotted runtime names
416
416
 
417
417
  CONTROL:
418
418
  • { action: "status", id: "..." } - inspect an async/background run by id or prefix
@@ -22,6 +22,11 @@ const OutputOverride = Type.Unsafe({
22
22
  description: "Output filename/path (string), or false to disable file output",
23
23
  });
24
24
 
25
+ const OutputModeOverride = Type.String({
26
+ enum: ["inline", "file-only"],
27
+ description: "Return saved output inline (default) or only a concise file reference. file-only requires output to be a path.",
28
+ });
29
+
25
30
  const ReadsOverride = Type.Unsafe({
26
31
  anyOf: [
27
32
  { type: "array", items: { type: "string" } },
@@ -36,6 +41,7 @@ const TaskItem = Type.Object({
36
41
  cwd: Type.Optional(Type.String()),
37
42
  count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
38
43
  output: Type.Optional(OutputOverride),
44
+ outputMode: Type.Optional(OutputModeOverride),
39
45
  reads: Type.Optional(ReadsOverride),
40
46
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking for this task" })),
41
47
  model: Type.Optional(Type.String({ description: "Override model for this task (e.g. 'google/gemini-3-pro')" })),
@@ -49,6 +55,7 @@ const ParallelTaskSchema = Type.Object({
49
55
  cwd: Type.Optional(Type.String()),
50
56
  count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
51
57
  output: Type.Optional(OutputOverride),
58
+ outputMode: Type.Optional(OutputModeOverride),
52
59
  reads: Type.Optional(ReadsOverride),
53
60
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
54
61
  skill: Type.Optional(SkillOverride),
@@ -63,6 +70,7 @@ const ChainItem = Type.Object({
63
70
  })),
64
71
  cwd: Type.Optional(Type.String()),
65
72
  output: Type.Optional(OutputOverride),
73
+ outputMode: Type.Optional(OutputModeOverride),
66
74
  reads: Type.Optional(ReadsOverride),
67
75
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
68
76
  skill: Type.Optional(SkillOverride),
@@ -119,9 +127,9 @@ export const SubagentParams = Type.Object({
119
127
  { type: "object", additionalProperties: true },
120
128
  { type: "string" },
121
129
  ],
122
- description: "Agent or chain config for create/update. Agent: name, description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext ('fresh'|'fork'), model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, description, scope, steps (array of {agent, task?, output?, reads?, model?, skill?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
130
+ description: "Agent or chain config for create/update. Agent: name, package (optional namespace; runtime name becomes package.name), description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext ('fresh'|'fork'), model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, package, description, scope, steps (array of {agent, task?, output?, outputMode?, reads?, model?, skill?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
123
131
  })),
124
- tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?, output?, reads?, progress?}, ...]" })),
132
+ tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?, output?, outputMode?, reads?, progress?}, ...]" })),
125
133
  concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Top-level PARALLEL mode only: max concurrent tasks. Defaults to config.parallel.concurrency or 4." })),
126
134
  worktree: Type.Optional(Type.Boolean({
127
135
  description: "Create isolated git worktrees for each parallel task. " +
@@ -154,6 +162,7 @@ export const SubagentParams = Type.Object({
154
162
  ],
155
163
  description: "Output file for single agent (string), or false to disable. Relative paths resolve against cwd.",
156
164
  })),
165
+ outputMode: Type.Optional(OutputModeOverride),
157
166
  skill: Type.Optional(SkillOverride),
158
167
  model: Type.Optional(Type.String({ description: "Override model for single agent (e.g. 'anthropic/claude-sonnet-4')" })),
159
168
  });
@@ -50,6 +50,10 @@ function buildChainDetailLines(chain: ChainConfig, width: number): string[] {
50
50
  const steps = chain.steps as DetailChainStep[];
51
51
  const dependencyMap = buildDependencyMap(steps);
52
52
  lines.push(truncateToWidth(chain.description, contentWidth));
53
+ if (chain.packageName) {
54
+ lines.push(truncateToWidth(`Local name: ${chain.localName ?? chain.name}`, contentWidth));
55
+ lines.push(truncateToWidth(`Package: ${chain.packageName}`, contentWidth));
56
+ }
53
57
  lines.push("");
54
58
  lines.push(truncateToWidth(`File: ${formatPath(chain.filePath)}`, contentWidth));
55
59
  lines.push("");
@@ -61,6 +61,10 @@ function buildDetailLines(
61
61
  const defaultContext = agent.defaultContext ?? "auto";
62
62
  const maxSubagentDepth = agent.maxSubagentDepth !== undefined ? String(agent.maxSubagentDepth) : "(default)";
63
63
 
64
+ if (agent.packageName) {
65
+ lines.push(renderFieldLine("Local name:", agent.localName ?? agent.name, contentWidth, theme));
66
+ lines.push(renderFieldLine("Package:", agent.packageName, contentWidth, theme));
67
+ }
64
68
  lines.push(renderFieldLine("Model:", agent.model ?? "default", contentWidth, theme));
65
69
  lines.push(renderFieldLine("Prompt mode:", agent.systemPromptMode, contentWidth, theme));
66
70
  lines.push(renderFieldLine("Project ctx:", agent.inheritProjectContext ? "on" : "off", contentWidth, theme));
@@ -1,6 +1,6 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
- import { defaultSystemPromptMode, type AgentConfig, type AgentDefaultContext, type BuiltinAgentOverrideBase } from "../agents/agents.ts";
3
+ import { buildRuntimeName, defaultSystemPromptMode, frontmatterNameForConfig, parsePackageName, type AgentConfig, type AgentDefaultContext, type BuiltinAgentOverrideBase } from "../agents/agents.ts";
4
4
  import { createEditorState, ensureCursorVisible, getCursorDisplayPos, handleEditorInput, renderEditor, wrapText } from "../tui/text-editor.ts";
5
5
  import type { TextEditorState } from "../tui/text-editor.ts";
6
6
  import { pad, row, renderHeader, renderFooter, formatScrollInfo } from "../tui/render-helpers.ts";
@@ -26,7 +26,7 @@ interface CreateEditStateOptions {
26
26
  }
27
27
 
28
28
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
29
- const FIELD_ORDER = ["name", "description", "model", "fallbackModels", "thinking", "systemPromptMode", "inheritProjectContext", "inheritSkills", "defaultContext", "tools", "extensions", "skills", "output", "reads", "progress", "interactive", "prompt"] as const;
29
+ const FIELD_ORDER = ["name", "package", "description", "model", "fallbackModels", "thinking", "systemPromptMode", "inheritProjectContext", "inheritSkills", "defaultContext", "tools", "extensions", "skills", "output", "reads", "progress", "interactive", "prompt"] as const;
30
30
  type ThinkingLevel = typeof THINKING_LEVELS[number];
31
31
  const PROMPT_VIEWPORT_HEIGHT = 16;
32
32
  const MODEL_SELECTOR_HEIGHT = 10;
@@ -94,12 +94,13 @@ export function createEditState(draft: AgentConfig, isNew: boolean, models: Mode
94
94
  function renderFieldValue(field: EditField, state: EditState): string {
95
95
  const draft = state.draft;
96
96
  switch (field) {
97
- case "name": return draft.name;
97
+ case "name": return frontmatterNameForConfig(draft);
98
+ case "package": return draft.packageName ?? "";
98
99
  case "description": return draft.description;
99
100
  case "model": return draft.model ?? "default";
100
101
  case "fallbackModels": return draft.fallbackModels && draft.fallbackModels.length > 0 ? draft.fallbackModels.join(", ") : "";
101
102
  case "thinking": return draft.thinking ?? "off";
102
- case "systemPromptMode": return draft.systemPromptMode ?? defaultSystemPromptMode(draft.name);
103
+ case "systemPromptMode": return draft.systemPromptMode ?? defaultSystemPromptMode(frontmatterNameForConfig(draft));
103
104
  case "inheritProjectContext": return draft.inheritProjectContext ? "on" : "off";
104
105
  case "inheritSkills": return draft.inheritSkills ? "on" : "off";
105
106
  case "defaultContext": return draft.defaultContext ?? "auto";
@@ -118,14 +119,32 @@ function renderFieldValue(field: EditField, state: EditState): string {
118
119
  function applyFieldValue(field: EditField, state: EditState, value: string): void {
119
120
  const draft = state.draft;
120
121
  switch (field) {
121
- case "name": draft.name = value.trim(); break;
122
+ case "name": {
123
+ const localName = value.trim();
124
+ draft.localName = localName || undefined;
125
+ draft.name = localName ? buildRuntimeName(localName, draft.packageName) : "";
126
+ break;
127
+ }
128
+ case "package": {
129
+ const parsed = parsePackageName(value, "package");
130
+ if (parsed.error) {
131
+ state.error = parsed.error;
132
+ break;
133
+ }
134
+ const packageName = parsed.packageName;
135
+ draft.packageName = packageName;
136
+ const localName = frontmatterNameForConfig(draft).trim();
137
+ draft.name = localName ? buildRuntimeName(localName, packageName) : "";
138
+ state.error = undefined;
139
+ break;
140
+ }
122
141
  case "description": draft.description = value.trim(); break;
123
142
  case "model": draft.model = value.trim() || undefined; break;
124
143
  case "fallbackModels": draft.fallbackModels = parseCommaList(value); break;
125
144
  case "systemPromptMode": {
126
145
  const trimmed = value.trim();
127
146
  if (trimmed === "") {
128
- draft.systemPromptMode = defaultSystemPromptMode(draft.name);
147
+ draft.systemPromptMode = defaultSystemPromptMode(frontmatterNameForConfig(draft));
129
148
  break;
130
149
  }
131
150
  if (trimmed === "append" || trimmed === "replace") {
@@ -8,7 +8,9 @@ import {
8
8
  defaultInheritProjectContext,
9
9
  defaultInheritSkills,
10
10
  defaultSystemPromptMode,
11
+ buildRuntimeName,
11
12
  discoverAgentsAll,
13
+ frontmatterNameForConfig,
12
14
  removeBuiltinAgentOverride,
13
15
  saveBuiltinAgentOverride,
14
16
  type AgentConfig,
@@ -238,10 +240,19 @@ export class AgentManagerComponent implements Component {
238
240
  catch (err) { this.statusMessage = { text: err instanceof Error ? err.message : "Failed to load chain file.", type: "error" }; this.screen = "list"; }
239
241
  }
240
242
 
243
+ private runtimeNameExistsInScope(kind: "agent" | "chain", scope: "user" | "project", name: string, excludePath?: string): boolean {
244
+ const discovered = discoverAgentsAll(this.agentData.cwd);
245
+ if (kind === "agent") {
246
+ const agents = scope === "user" ? discovered.user : discovered.project;
247
+ return agents.some((agent) => agent.name === name && agent.filePath !== excludePath);
248
+ }
249
+ return discovered.chains.some((chain) => chain.source === scope && chain.name === name && chain.filePath !== excludePath);
250
+ }
251
+
241
252
  private enterNameInput(mode: NameInputState["mode"], sourceId?: string, template?: AgentTemplate): void {
242
253
  const allowProject = Boolean(this.agentData.projectDir); let initial = ""; let scope: "user" | "project" = "user";
243
- if (mode === "clone-agent" && sourceId) { const entry = this.getAgentEntry(sourceId); if (entry) { initial = `${entry.config.name}-copy`; scope = entry.config.source === "project" ? "project" : "user"; } }
244
- if (mode === "clone-chain" && sourceId) { const entry = this.getChainEntry(sourceId); if (entry) { initial = `${entry.config.name}-copy`; scope = entry.config.source === "project" ? "project" : "user"; } }
254
+ if (mode === "clone-agent" && sourceId) { const entry = this.getAgentEntry(sourceId); if (entry) { initial = `${frontmatterNameForConfig(entry.config)}-copy`; scope = entry.config.source === "project" ? "project" : "user"; } }
255
+ if (mode === "clone-chain" && sourceId) { const entry = this.getChainEntry(sourceId); if (entry) { initial = `${frontmatterNameForConfig(entry.config)}-copy`; scope = entry.config.source === "project" ? "project" : "user"; } }
245
256
  if (mode === "new-agent" && template && template.name !== "Blank") initial = slugTemplateName(template.name);
246
257
  this.nameInputState = { mode, editor: createEditorState(initial), scope, allowProject, sourceId, template }; this.screen = "name-input";
247
258
  }
@@ -269,7 +280,16 @@ export class AgentManagerComponent implements Component {
269
280
  return false;
270
281
  }
271
282
  }
272
- if (!edit.draft.name || !edit.draft.description) { edit.error = "Name and description are required."; return false; }
283
+ let localName = frontmatterNameForConfig(edit.draft).trim();
284
+ if (!edit.draft.packageName && edit.draft.name !== entry.config.name) localName = edit.draft.name.trim();
285
+ if (!localName || !edit.draft.description) { edit.error = "Name and description are required."; return false; }
286
+ edit.draft.localName = localName;
287
+ edit.draft.name = buildRuntimeName(localName, edit.draft.packageName);
288
+ const draftScope = edit.draft.source === "project" ? "project" : "user";
289
+ if (this.runtimeNameExistsInScope("agent", draftScope, edit.draft.name, entry.config.filePath)) {
290
+ edit.error = `An agent named '${edit.draft.name}' already exists in ${draftScope} scope.`;
291
+ return false;
292
+ }
273
293
  let filePath = entry.config.filePath;
274
294
  if (entry.isNew) {
275
295
  const dir = edit.draft.source === "project" ? this.agentData.projectDir : this.agentData.userDir;
@@ -404,15 +424,15 @@ export class AgentManagerComponent implements Component {
404
424
  const sourceEntry = this.getChainEntry(state.sourceId); if (!sourceEntry) { this.screen = "list"; this.tui.requestRender(); return; }
405
425
  const dir = state.scope === "project" ? this.agentData.projectDir : this.agentData.userDir;
406
426
  if (!dir) { state.error = "Project agents directory not found."; this.tui.requestRender(); return; }
407
- const filePath = path.join(dir, `${name}.chain.md`); if (fs.existsSync(filePath)) { state.error = "A chain with that name already exists."; this.tui.requestRender(); return; }
408
- try { const cloned = cloneChainConfig({ ...sourceEntry.config, name, source: state.scope, filePath }); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, serializeChain(cloned), "utf-8"); const added: ChainEntry = { id: `c${this.nextId++}`, kind: "chain", config: cloned }; this.chains.push(added); this.nameInputState = null; this.enterChainDetail(added); this.tui.requestRender(); return; }
427
+ const filePath = path.join(dir, `${name}.chain.md`); if (fs.existsSync(filePath) || this.runtimeNameExistsInScope("chain", state.scope, name)) { state.error = "A chain with that name already exists."; this.tui.requestRender(); return; }
428
+ try { const cloned = cloneChainConfig({ ...sourceEntry.config, name, localName: name, packageName: undefined, source: state.scope, filePath }); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, serializeChain(cloned), "utf-8"); const added: ChainEntry = { id: `c${this.nextId++}`, kind: "chain", config: cloned }; this.chains.push(added); this.nameInputState = null; this.enterChainDetail(added); this.tui.requestRender(); return; }
409
429
  catch (err) { state.error = err instanceof Error ? err.message : "Failed to clone chain."; this.tui.requestRender(); return; }
410
430
  }
411
431
  if (state.mode === "new-chain") {
412
432
  const dir = state.scope === "project" ? this.agentData.projectDir : this.agentData.userDir;
413
433
  if (!dir) { state.error = "Directory not found."; this.tui.requestRender(); return; }
414
- const filePath = path.join(dir, `${name}.chain.md`); if (fs.existsSync(filePath)) { state.error = "A chain with that name already exists."; this.tui.requestRender(); return; }
415
- const config: ChainConfig = { name, description: "Describe this chain", source: state.scope, filePath, steps: [{ agent: "agent-name", task: "{task}" }] };
434
+ const filePath = path.join(dir, `${name}.chain.md`); if (fs.existsSync(filePath) || this.runtimeNameExistsInScope("chain", state.scope, name)) { state.error = "A chain with that name already exists."; this.tui.requestRender(); return; }
435
+ const config: ChainConfig = { name, localName: name, description: "Describe this chain", source: state.scope, filePath, steps: [{ agent: "agent-name", task: "{task}" }] };
416
436
  try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, serializeChain(config), "utf-8"); const entry: ChainEntry = { id: `c${this.nextId++}`, kind: "chain", config }; this.chains.push(entry); this.nameInputState = null; this.enterChainEdit(entry); }
417
437
  catch (err) { state.error = err instanceof Error ? err.message : "Failed to create chain."; }
418
438
  this.tui.requestRender(); return;
@@ -438,7 +458,7 @@ export class AgentManagerComponent implements Component {
438
458
  }
439
459
  const dir = state.scope === "project" ? this.agentData.projectDir : this.agentData.userDir;
440
460
  if (!dir) { state.error = "Project agents directory not found."; this.tui.requestRender(); return; }
441
- const filePath = path.join(dir, `${name}.md`); const config: AgentConfig = { ...baseConfig, name, source: state.scope, filePath };
461
+ const filePath = path.join(dir, `${name}.md`); const config: AgentConfig = { ...baseConfig, name, localName: name, packageName: undefined, source: state.scope, filePath };
442
462
  const entry: AgentEntry = { id: `a${this.nextId++}`, kind: "agent", config, isNew: true };
443
463
  this.agents.push(entry); this.nameInputState = null; this.enterEdit(entry); this.tui.requestRender();
444
464
  }
@@ -11,7 +11,7 @@ import { createRequire } from "node:module";
11
11
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
12
  import type { AgentConfig } from "../../agents/agents.ts";
13
13
  import { applyThinkingSuffix } from "../shared/pi-args.ts";
14
- import { injectSingleOutputInstruction, resolveSingleOutputPath } from "../shared/single-output.ts";
14
+ import { injectSingleOutputInstruction, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
15
15
  import { buildChainInstructions, isParallelStep, resolveStepBehavior, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
16
16
  import type { RunnerStep } from "../shared/parallel-utils.ts";
17
17
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
@@ -98,6 +98,7 @@ interface AsyncSingleParams {
98
98
  sessionFile?: string;
99
99
  skills?: string[];
100
100
  output?: string | false;
101
+ outputMode?: "inline" | "file-only";
101
102
  modelOverride?: string;
102
103
  availableModels?: AvailableModelInfo[];
103
104
  maxSubagentDepth: number;
@@ -170,6 +171,7 @@ function formatAsyncStartError(mode: "single" | "chain", message: string): Async
170
171
  const UNAVAILABLE_SUBAGENT_SKILL_ERROR = "Skills not found: pi-subagents";
171
172
 
172
173
  class UnavailableSubagentSkillError extends Error {}
174
+ class AsyncStartValidationError extends Error {}
173
175
 
174
176
  /**
175
177
  * Execute a chain asynchronously
@@ -232,6 +234,7 @@ export function executeAsyncChain(
232
234
  const stepSkillInput = normalizeSkillInput(s.skill);
233
235
  return {
234
236
  ...(s.output !== undefined ? { output: s.output } : {}),
237
+ ...(s.outputMode !== undefined ? { outputMode: s.outputMode } : {}),
235
238
  ...(s.reads !== undefined ? { reads: s.reads } : {}),
236
239
  ...(s.progress !== undefined ? { progress: s.progress } : {}),
237
240
  ...(stepSkillInput !== undefined ? { skills: stepSkillInput } : {}),
@@ -258,6 +261,8 @@ export function executeAsyncChain(
258
261
  if (behavior.progress) progressInstructionCreated = true;
259
262
  const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, runnerCwd, isFirstProgressAgent);
260
263
  const outputPath = resolveSingleOutputPath(behavior.output, ctx.cwd, instructionCwd);
264
+ const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Async step (${s.agent})`);
265
+ if (validationError) throw new AsyncStartValidationError(validationError);
261
266
  const task = injectSingleOutputInstruction(`${readInstructions.prefix}${s.task ?? "{previous}"}${progressInstructions.suffix}`, outputPath);
262
267
 
263
268
  const primaryModel = resolveModelCandidate(behavior.model ?? a.model, availableModels, ctx.currentModelProvider);
@@ -278,6 +283,7 @@ export function executeAsyncChain(
278
283
  inheritSkills: a.inheritSkills,
279
284
  skills: resolvedSkills.map((r) => r.name),
280
285
  outputPath,
286
+ outputMode: behavior.outputMode,
281
287
  sessionFile,
282
288
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, a.maxSubagentDepth),
283
289
  };
@@ -323,7 +329,7 @@ export function executeAsyncChain(
323
329
  return buildSeqStep(s as SequentialStep, nextSessionFile());
324
330
  });
325
331
  } catch (error) {
326
- if (error instanceof UnavailableSubagentSkillError) return formatAsyncStartError("chain", error.message);
332
+ if (error instanceof UnavailableSubagentSkillError || error instanceof AsyncStartValidationError) return formatAsyncStartError("chain", error.message);
327
333
  throw error;
328
334
  }
329
335
  let childTargetIndex = 0;
@@ -470,6 +476,9 @@ export function executeAsyncSingle(
470
476
  }
471
477
 
472
478
  const outputPath = resolveSingleOutputPath(params.output, ctx.cwd, runnerCwd);
479
+ const outputMode = params.outputMode ?? "inline";
480
+ const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
481
+ if (validationError) return formatAsyncStartError("single", validationError);
473
482
  const taskWithOutputInstruction = injectSingleOutputInstruction(task, outputPath);
474
483
  let spawnResult: { pid?: number; error?: string } = {};
475
484
  try {
@@ -494,6 +503,7 @@ export function executeAsyncSingle(
494
503
  inheritSkills: agentConfig.inheritSkills,
495
504
  skills: resolvedSkills.map((r) => r.name),
496
505
  outputPath,
506
+ outputMode,
497
507
  sessionFile,
498
508
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth),
499
509
  },
@@ -7,7 +7,7 @@ import type { Message } from "@mariozechner/pi-ai";
7
7
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
8
8
  import { appendJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
9
9
  import { getPiSpawnCommand } from "../shared/pi-spawn.ts";
10
- import { captureSingleOutputSnapshot, resolveSingleOutput } from "../shared/single-output.ts";
10
+ import { captureSingleOutputSnapshot, finalizeSingleOutput, formatSavedOutputReference, resolveSingleOutput } from "../shared/single-output.ts";
11
11
  import {
12
12
  type ActivityState,
13
13
  type ArtifactConfig,
@@ -662,19 +662,21 @@ async function runSingleStep(
662
662
  ? resolveSingleOutput(step.outputPath, rawOutput, outputSnapshot)
663
663
  : { fullOutput: rawOutput };
664
664
  const output = resolvedOutput.fullOutput;
665
+ const outputReference = resolvedOutput.savedPath ? formatSavedOutputReference(resolvedOutput.savedPath, output) : undefined;
665
666
  let outputForSummary = output;
666
667
  if (attemptNotes.length > 0) {
667
668
  outputForSummary = `${attemptNotes.join("\n")}\n\n${outputForSummary}`.trim();
668
669
  }
669
- if (resolvedOutput.savedPath) {
670
- outputForSummary = outputForSummary
671
- ? `${outputForSummary}\n\nOutput saved to: ${resolvedOutput.savedPath}`
672
- : `Output saved to: ${resolvedOutput.savedPath}`;
673
- } else if (resolvedOutput.saveError && step.outputPath && finalResult?.exitCode === 0) {
674
- outputForSummary = outputForSummary
675
- ? `${outputForSummary}\n\nFailed to save output to: ${step.outputPath}\n${resolvedOutput.saveError}`
676
- : `Failed to save output to: ${step.outputPath}\n${resolvedOutput.saveError}`;
677
- }
670
+ const finalizedOutput = finalizeSingleOutput({
671
+ fullOutput: outputForSummary,
672
+ outputPath: step.outputPath,
673
+ outputMode: step.outputMode,
674
+ exitCode: finalResult?.exitCode ?? 1,
675
+ savedPath: resolvedOutput.savedPath,
676
+ outputReference,
677
+ saveError: resolvedOutput.saveError,
678
+ });
679
+ outputForSummary = finalizedOutput.displayOutput;
678
680
 
679
681
  if (artifactPaths && ctx.artifactConfig?.enabled !== false) {
680
682
  if (ctx.artifactConfig?.includeOutput !== false) {
@@ -240,6 +240,7 @@ export class ChainClarifyComponent implements Component {
240
240
 
241
241
  return {
242
242
  output: override.output !== undefined ? override.output : base.output,
243
+ outputMode: base.outputMode,
243
244
  reads: override.reads !== undefined ? override.reads : base.reads,
244
245
  progress: override.progress !== undefined ? override.progress : base.progress,
245
246
  skills: override.skills !== undefined ? override.skills : base.skills,
@@ -277,6 +278,7 @@ export class ChainClarifyComponent implements Component {
277
278
  const template = this.templates[i] ?? "";
278
279
  const step: ChainStepConfig = { agent: agent.name, task: template };
279
280
  if (override?.output !== undefined) step.output = behavior.output;
281
+ if (behavior.outputMode !== "inline") step.outputMode = behavior.outputMode;
280
282
  if (override?.reads !== undefined) step.reads = behavior.reads;
281
283
  if (override?.model !== undefined) step.model = behavior.model;
282
284
  if (override?.skills !== undefined) step.skills = behavior.skills;
@@ -55,6 +55,7 @@ import {
55
55
  resolveChildMaxSubagentDepth,
56
56
  } from "../../shared/types.ts";
57
57
  import { resolveModelCandidate } from "../shared/model-fallback.ts";
58
+ import { validateFileOnlyOutputMode } from "../shared/single-output.ts";
58
59
 
59
60
  interface ChainExecutionDetailsInput {
60
61
  results: SingleResult[];
@@ -234,6 +235,7 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
234
235
  artifactsDir: input.artifactConfig.enabled ? input.artifactsDir : undefined,
235
236
  artifactConfig: input.artifactConfig,
236
237
  outputPath,
238
+ outputMode: behavior.outputMode,
237
239
  maxSubagentDepth,
238
240
  controlConfig: input.controlConfig,
239
241
  onControlEvent: input.onControlEvent,
@@ -412,6 +414,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
412
414
 
413
415
  const stepOverrides: StepOverrides[] = seqSteps.map((step) => ({
414
416
  output: step.output,
417
+ outputMode: step.outputMode,
415
418
  reads: step.reads,
416
419
  progress: step.progress,
417
420
  skills: normalizeSkillInput(step.skill),
@@ -462,6 +465,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
462
465
  task: result.templates[i]!,
463
466
  ...(override?.model ? { model: override.model } : {}),
464
467
  ...(override?.output !== undefined ? { output: override.output } : {}),
468
+ ...("outputMode" in step && step.outputMode !== undefined ? { outputMode: step.outputMode } : {}),
465
469
  ...(override?.reads !== undefined ? { reads: override.reads } : {}),
466
470
  ...(override?.progress !== undefined ? { progress: override.progress } : {}),
467
471
  ...(override?.skills !== undefined ? { skill: override.skills } : {}),
@@ -533,6 +537,23 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
533
537
  try {
534
538
  const agentNames = step.parallel.map((task) => task.agent);
535
539
  const parallelBehaviors = resolveParallelBehaviors(step.parallel, agents, stepIndex, chainSkills);
540
+ for (let taskIndex = 0; taskIndex < step.parallel.length; taskIndex++) {
541
+ const behavior = parallelBehaviors[taskIndex]!;
542
+ const outputPath = typeof behavior.output === "string"
543
+ ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(chainDir, behavior.output))
544
+ : undefined;
545
+ const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Parallel chain step ${stepIndex + 1} task ${taskIndex + 1} (${step.parallel[taskIndex]!.agent})`);
546
+ if (validationError) return buildChainExecutionErrorResult(validationError, {
547
+ results,
548
+ includeProgress,
549
+ allProgress,
550
+ allArtifactPaths,
551
+ artifactsDir,
552
+ chainAgents,
553
+ totalSteps,
554
+ currentStepIndex: stepIndex,
555
+ });
556
+ }
536
557
  progressCreated = ensureParallelProgressFile(chainDir, progressCreated, parallelBehaviors);
537
558
  createParallelDirs(chainDir, stepIndex, step.parallel.length, agentNames);
538
559
 
@@ -664,6 +685,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
664
685
  const tuiOverride = tuiBehaviorOverrides?.[stepIndex];
665
686
  const stepOverride: StepOverrides = {
666
687
  output: tuiOverride?.output !== undefined ? tuiOverride.output : seqStep.output,
688
+ outputMode: seqStep.outputMode,
667
689
  reads: tuiOverride?.reads !== undefined ? tuiOverride.reads : seqStep.reads,
668
690
  progress: tuiOverride?.progress !== undefined ? tuiOverride.progress : seqStep.progress,
669
691
  skills:
@@ -701,6 +723,19 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
701
723
  const outputPath = typeof behavior.output === "string"
702
724
  ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(chainDir, behavior.output))
703
725
  : undefined;
726
+ const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Chain step ${stepIndex + 1} (${seqStep.agent})`);
727
+ if (validationError) {
728
+ return buildChainExecutionErrorResult(validationError, {
729
+ results,
730
+ includeProgress,
731
+ allProgress,
732
+ allArtifactPaths,
733
+ artifactsDir,
734
+ chainAgents,
735
+ totalSteps,
736
+ currentStepIndex: stepIndex,
737
+ });
738
+ }
704
739
  const maxSubagentDepth = resolveChildMaxSubagentDepth(params.maxSubagentDepth, agentConfig.maxSubagentDepth);
705
740
  const interruptController = new AbortController();
706
741
  if (foregroundControl) {
@@ -731,6 +766,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
731
766
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
732
767
  artifactConfig,
733
768
  outputPath,
769
+ outputMode: behavior.outputMode,
734
770
  maxSubagentDepth,
735
771
  controlConfig,
736
772
  onControlEvent,
@@ -46,7 +46,7 @@ import { getPiSpawnCommand } from "../shared/pi-spawn.ts";
46
46
  import { createJsonlWriter } from "../../shared/jsonl-writer.ts";
47
47
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
48
48
  import { applyThinkingSuffix, buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
49
- import { captureSingleOutputSnapshot, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
49
+ import { captureSingleOutputSnapshot, formatSavedOutputReference, resolveSingleOutput, validateFileOnlyOutputMode, type SingleOutputSnapshot } from "../shared/single-output.ts";
50
50
  import {
51
51
  buildModelCandidates,
52
52
  formatModelAttemptNote,
@@ -64,6 +64,8 @@ import {
64
64
  summarizeRecentMutatingFailures,
65
65
  } from "../shared/long-running-guard.ts";
66
66
 
67
+ const artifactOutputByResult = new WeakMap<SingleResult, string>();
68
+
67
69
  function emptyUsage(): Usage {
68
70
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
69
71
  }
@@ -97,7 +99,7 @@ function snapshotProgress(progress: AgentProgress): AgentProgress {
97
99
  function snapshotResult(result: SingleResult, progress: AgentProgress): SingleResult {
98
100
  return {
99
101
  ...result,
100
- messages: result.messages ? [...result.messages] : undefined,
102
+ messages: result.outputMode === "file-only" && result.savedOutputPath ? undefined : result.messages ? [...result.messages] : undefined,
101
103
  usage: { ...result.usage },
102
104
  skills: result.skills ? [...result.skills] : undefined,
103
105
  attemptedModels: result.attemptedModels ? [...result.attemptedModels] : undefined,
@@ -112,6 +114,7 @@ function snapshotResult(result: SingleResult, progress: AgentProgress): SingleRe
112
114
  progressSummary: result.progressSummary ? { ...result.progressSummary } : undefined,
113
115
  artifactPaths: result.artifactPaths ? { ...result.artifactPaths } : undefined,
114
116
  truncation: result.truncation ? { ...result.truncation } : undefined,
117
+ outputReference: result.outputReference ? { ...result.outputReference } : undefined,
115
118
  };
116
119
  }
117
120
 
@@ -676,8 +679,15 @@ async function runSingleAttempt(
676
679
  fullOutput = resolvedOutput.fullOutput;
677
680
  result.savedOutputPath = resolvedOutput.savedPath;
678
681
  result.outputSaveError = resolvedOutput.saveError;
682
+ if (resolvedOutput.savedPath) {
683
+ result.outputReference = formatSavedOutputReference(resolvedOutput.savedPath, fullOutput);
684
+ }
679
685
  }
680
- result.finalOutput = fullOutput;
686
+ artifactOutputByResult.set(result, fullOutput);
687
+ result.outputMode = options.outputMode ?? "inline";
688
+ result.finalOutput = options.outputMode === "file-only" && result.savedOutputPath && result.outputReference
689
+ ? result.outputReference.message
690
+ : fullOutput;
681
691
  result.controlEvents = allControlEvents.length ? allControlEvents : undefined;
682
692
  if (options.onUpdate) {
683
693
  const finalText = result.finalOutput || result.error || "(no output)";
@@ -717,6 +727,18 @@ export async function runSync(
717
727
  error: `Unknown agent: ${agentName}`,
718
728
  };
719
729
  }
730
+ const outputModeValidationError = validateFileOnlyOutputMode(options.outputMode, options.outputPath, `Single run (${agentName})`);
731
+ if (outputModeValidationError) {
732
+ return {
733
+ agent: agentName,
734
+ task,
735
+ exitCode: 1,
736
+ messages: [],
737
+ usage: emptyUsage(),
738
+ outputMode: options.outputMode,
739
+ error: outputModeValidationError,
740
+ };
741
+ }
720
742
 
721
743
  const shareEnabled = options.share === true;
722
744
  const sessionEnabled = Boolean(options.sessionFile || options.sessionDir) || shareEnabled;
@@ -829,7 +851,7 @@ export async function runSync(
829
851
  if (artifactPathsResult && options.artifactConfig?.enabled !== false) {
830
852
  result.artifactPaths = artifactPathsResult;
831
853
  if (options.artifactConfig?.includeOutput !== false) {
832
- writeArtifact(artifactPathsResult.outputPath, result.finalOutput ?? "");
854
+ writeArtifact(artifactPathsResult.outputPath, artifactOutputByResult.get(result) ?? result.finalOutput ?? "");
833
855
  }
834
856
  if (options.artifactConfig?.includeMetadata !== false) {
835
857
  writeMetadata(artifactPathsResult.metadataPath, {