pi-subagents 0.21.2 → 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
+ }
@@ -0,0 +1,92 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { controlNotificationKey, formatControlNoticeMessage } from "../runs/shared/subagent-control.ts";
3
+ import type { ControlEvent, SubagentState } from "../shared/types.ts";
4
+
5
+ export const SUBAGENT_CONTROL_MESSAGE_TYPE = "subagent_control_notice";
6
+
7
+ export interface SubagentControlMessageDetails {
8
+ event: ControlEvent;
9
+ source?: "foreground" | "async";
10
+ asyncDir?: string;
11
+ childIntercomTarget?: string;
12
+ noticeText?: string;
13
+ }
14
+
15
+ export function controlNoticeTarget(details: SubagentControlMessageDetails): string | undefined {
16
+ return details.childIntercomTarget;
17
+ }
18
+
19
+ export function formatSubagentControlNotice(details: SubagentControlMessageDetails, content?: string): string {
20
+ return details.noticeText ?? content ?? formatControlNoticeMessage(details.event, controlNoticeTarget(details));
21
+ }
22
+
23
+ function noticeTimerKey(details: SubagentControlMessageDetails): string {
24
+ const childIntercomTarget = controlNoticeTarget(details);
25
+ return `${details.event.runId}:${controlNotificationKey(details.event, childIntercomTarget)}`;
26
+ }
27
+
28
+ export function clearPendingForegroundControlNotices(state: SubagentState, runId?: string): void {
29
+ const pending = state.pendingForegroundControlNotices;
30
+ if (!pending) return;
31
+ for (const [key, timer] of pending) {
32
+ if (runId !== undefined && !key.startsWith(`${runId}:`)) continue;
33
+ clearTimeout(timer);
34
+ pending.delete(key);
35
+ }
36
+ }
37
+
38
+ function deliverControlNotice(input: {
39
+ pi: Pick<ExtensionAPI, "sendMessage">;
40
+ visibleControlNotices: Set<string>;
41
+ details: SubagentControlMessageDetails;
42
+ }): void {
43
+ const childIntercomTarget = controlNoticeTarget(input.details);
44
+ const key = controlNotificationKey(input.details.event, childIntercomTarget);
45
+ if (input.visibleControlNotices.has(key)) return;
46
+ input.visibleControlNotices.add(key);
47
+ const noticeText = input.details.noticeText ?? formatControlNoticeMessage(input.details.event, childIntercomTarget);
48
+ input.pi.sendMessage(
49
+ {
50
+ customType: SUBAGENT_CONTROL_MESSAGE_TYPE,
51
+ content: noticeText,
52
+ display: true,
53
+ details: { ...input.details, childIntercomTarget, noticeText },
54
+ },
55
+ { triggerTurn: input.details.source !== "foreground" },
56
+ );
57
+ }
58
+
59
+ function isForegroundNoticeStillActionable(state: SubagentState, details: SubagentControlMessageDetails): boolean {
60
+ const control = state.foregroundControls.get(details.event.runId);
61
+ if (!control) return false;
62
+ if (control.currentAgent && control.currentAgent !== details.event.agent) return false;
63
+ if (details.event.index !== undefined && control.currentIndex !== details.event.index) return false;
64
+ return control.currentActivityState === "needs_attention";
65
+ }
66
+
67
+ export function handleSubagentControlNotice(input: {
68
+ pi: Pick<ExtensionAPI, "sendMessage">;
69
+ state: SubagentState;
70
+ visibleControlNotices: Set<string>;
71
+ details: SubagentControlMessageDetails;
72
+ foregroundDelayMs?: number;
73
+ }): void {
74
+ if (!input.details?.event || input.details.event.type === "active_long_running") return;
75
+ if (input.details.source !== "foreground") {
76
+ deliverControlNotice(input);
77
+ return;
78
+ }
79
+
80
+ const pending = input.state.pendingForegroundControlNotices ?? new Map<string, ReturnType<typeof setTimeout>>();
81
+ input.state.pendingForegroundControlNotices = pending;
82
+ const timerKey = noticeTimerKey(input.details);
83
+ const existing = pending.get(timerKey);
84
+ if (existing) clearTimeout(existing);
85
+ const timer = setTimeout(() => {
86
+ pending.delete(timerKey);
87
+ if (!isForegroundNoticeStillActionable(input.state, input.details)) return;
88
+ deliverControlNotice(input);
89
+ }, input.foregroundDelayMs ?? 1000);
90
+ timer.unref?.();
91
+ pending.set(timerKey, timer);
92
+ }
@@ -25,7 +25,6 @@ import { renderWidget, renderSubagentResult, stopResultAnimations, stopWidgetAni
25
25
  import { SubagentParams } from "./schemas.ts";
26
26
  import { createSubagentExecutor } from "../runs/foreground/subagent-executor.ts";
27
27
  import { createAsyncJobTracker } from "../runs/background/async-job-tracker.ts";
28
- import { controlNotificationKey, formatControlNoticeMessage } from "../runs/shared/subagent-control.ts";
29
28
  import { createResultWatcher } from "../runs/background/result-watcher.ts";
30
29
  import { registerSlashCommands } from "../slash/slash-commands.ts";
31
30
  import { registerPromptTemplateDelegationBridge } from "../slash/prompt-template-bridge.ts";
@@ -36,7 +35,6 @@ import registerSubagentNotify, { type SubagentNotifyDetails } from "../runs/back
36
35
  import { SUBAGENT_CHILD_ENV } from "../runs/shared/pi-args.ts";
37
36
  import { formatDuration, shortenPath } from "../shared/formatters.ts";
38
37
  import {
39
- type ControlEvent,
40
38
  type Details,
41
39
  type ExtensionConfig,
42
40
  type SubagentState,
@@ -49,6 +47,13 @@ import {
49
47
  SUBAGENT_CONTROL_EVENT,
50
48
  WIDGET_KEY,
51
49
  } from "../shared/types.ts";
50
+ import {
51
+ clearPendingForegroundControlNotices,
52
+ formatSubagentControlNotice,
53
+ handleSubagentControlNotice,
54
+ SUBAGENT_CONTROL_MESSAGE_TYPE,
55
+ type SubagentControlMessageDetails,
56
+ } from "./control-notices.ts";
52
57
 
53
58
  /**
54
59
  * Derive subagent session base directory from parent session file.
@@ -153,24 +158,6 @@ function createSlashResultComponent(
153
158
  return container;
154
159
  }
155
160
 
156
- const SUBAGENT_CONTROL_MESSAGE_TYPE = "subagent_control_notice";
157
-
158
- interface SubagentControlMessageDetails {
159
- event: ControlEvent;
160
- source?: "foreground" | "async";
161
- asyncDir?: string;
162
- childIntercomTarget?: string;
163
- noticeText?: string;
164
- }
165
-
166
- function controlNoticeTarget(details: SubagentControlMessageDetails): string | undefined {
167
- return details.childIntercomTarget;
168
- }
169
-
170
- function formatSubagentControlNotice(details: SubagentControlMessageDetails, content?: string): string {
171
- return details.noticeText ?? content ?? formatControlNoticeMessage(details.event, controlNoticeTarget(details));
172
- }
173
-
174
161
  function parseSubagentNotifyContent(content: string): SubagentNotifyDetails | undefined {
175
162
  const lines = content.split("\n");
176
163
  const header = lines[0] ?? "";
@@ -259,6 +246,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
259
246
  asyncJobs: new Map(),
260
247
  foregroundControls: new Map(),
261
248
  lastForegroundControlId: null,
249
+ pendingForegroundControlNotices: new Map(),
262
250
  cleanupTimers: new Map(),
263
251
  lastUiContext: null,
264
252
  poller: null,
@@ -283,6 +271,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
283
271
  const runtimeCleanup = () => {
284
272
  stopWidgetAnimation();
285
273
  stopResultAnimations();
274
+ clearPendingForegroundControlNotices(state);
286
275
  if (state.poller) {
287
276
  clearInterval(state.poller);
288
277
  state.poller = null;
@@ -419,11 +408,11 @@ Example: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {agent:"agent-b", t
419
408
 
420
409
  MANAGEMENT (use action field, omit agent/task/chain/tasks):
421
410
  • { action: "list" } - discover executable agents/chains
422
- • { action: "get", agent: "name" } - full detail
423
- • { action: "create", config: { name, systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, ... } }
424
- • { action: "update", agent: "name", config: { ... } } - merge
425
- • { action: "delete", agent: "name" }
426
- • 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
427
416
 
428
417
  CONTROL:
429
418
  • { action: "status", id: "..." } - inspect an async/background run by id or prefix
@@ -497,22 +486,12 @@ DIAGNOSTICS:
497
486
  const visibleControlNotices = existingVisibleControlNotices instanceof Set ? existingVisibleControlNotices as Set<string> : new Set<string>();
498
487
  globalStore[controlNoticeSeenStoreKey] = visibleControlNotices;
499
488
  const controlEventHandler = (payload: unknown) => {
500
- const details = payload as SubagentControlMessageDetails;
501
- if (!details?.event || details.event.type === "active_long_running") return;
502
- const childIntercomTarget = controlNoticeTarget(details);
503
- const key = controlNotificationKey(details.event, childIntercomTarget);
504
- if (visibleControlNotices.has(key)) return;
505
- visibleControlNotices.add(key);
506
- const noticeText = details.noticeText ?? formatControlNoticeMessage(details.event, childIntercomTarget);
507
- pi.sendMessage(
508
- {
509
- customType: SUBAGENT_CONTROL_MESSAGE_TYPE,
510
- content: noticeText,
511
- display: true,
512
- details: { ...details, childIntercomTarget, noticeText },
513
- },
514
- { triggerTurn: true },
515
- );
489
+ handleSubagentControlNotice({
490
+ pi,
491
+ state,
492
+ visibleControlNotices,
493
+ details: payload as SubagentControlMessageDetails,
494
+ });
516
495
  };
517
496
  const eventUnsubscribes = [
518
497
  pi.events.on(SUBAGENT_ASYNC_STARTED_EVENT, handleStarted),
@@ -547,6 +526,7 @@ DIAGNOSTICS:
547
526
  state.currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
548
527
  state.lastUiContext = ctx;
549
528
  cleanupSessionArtifacts(ctx);
529
+ clearPendingForegroundControlNotices(state);
550
530
  resetJobs(ctx);
551
531
  restoreSlashFinalSnapshots(ctx.sessionManager.getEntries());
552
532
  };
@@ -569,6 +549,7 @@ DIAGNOSTICS:
569
549
  stopResultWatcher();
570
550
  if (state.poller) clearInterval(state.poller);
571
551
  state.poller = null;
552
+ clearPendingForegroundControlNotices(state);
572
553
  for (const timer of state.cleanupTimers.values()) {
573
554
  clearTimeout(timer);
574
555
  }
@@ -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
  },