@dreki-gg/pi-subagent 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +99 -49
  3. package/agents/advisor.md +37 -0
  4. package/agents/bug-prover.md +42 -0
  5. package/agents/docs-scout.md +2 -2
  6. package/agents/manager.md +52 -0
  7. package/agents/planner.md +16 -1
  8. package/agents/reviewer.md +65 -8
  9. package/agents/scout.md +5 -1
  10. package/agents/validator.md +42 -0
  11. package/agents/worker.md +39 -3
  12. package/docs/orchestration-principles.md +132 -0
  13. package/docs/plans/01_structured-handoffs-and-synthesis.plan.md +376 -0
  14. package/docs/plans/02_clean-context-review-loop.plan.md +240 -0
  15. package/docs/plans/03_parallel-recon-single-writer-docs.plan.md +199 -0
  16. package/docs/plans/04_manager-workflow.plan.md +248 -0
  17. package/docs/plans/05_advisor-routing.plan.md +244 -0
  18. package/extensions/subagent/agent-result-utils.ts +12 -0
  19. package/extensions/subagent/agent-runner-types.ts +23 -0
  20. package/extensions/subagent/{delegate-executor.ts → agent-runner.ts} +10 -29
  21. package/extensions/subagent/agents.ts +11 -0
  22. package/extensions/subagent/handoffs.ts +273 -0
  23. package/extensions/subagent/index.ts +494 -196
  24. package/extensions/subagent/{delegate-args.ts → run-agent-args.ts} +35 -29
  25. package/package.json +3 -6
  26. package/skills/spawn-subagents/SKILL.md +80 -8
  27. package/extensions/subagent/delegate-types.ts +0 -62
  28. package/extensions/subagent/delegate-ui.ts +0 -132
  29. package/extensions/subagent/workflows.ts +0 -150
  30. package/prompts/implement-and-review.md +0 -10
  31. package/prompts/implement.md +0 -10
  32. package/prompts/scout-and-plan.md +0 -9
@@ -25,43 +25,30 @@ import { StringEnum } from '@mariozechner/pi-ai';
25
25
  import {
26
26
  type ExtensionAPI,
27
27
  type ExtensionCommandContext,
28
+ AuthStorage,
28
29
  DefaultPackageManager,
29
30
  getAgentDir,
30
31
  getMarkdownTheme,
32
+ ModelRegistry,
31
33
  SettingsManager,
32
34
  withFileMutationQueue,
33
35
  } from '@mariozechner/pi-coding-agent';
34
36
  import type { ResolvedPaths } from '@mariozechner/pi-coding-agent';
35
- import { Container, Markdown, Spacer, Text } from '@mariozechner/pi-tui';
36
- import { Type } from '@sinclair/typebox';
37
+ import { Box, Container, Markdown, Spacer, Text, type AutocompleteItem } from '@mariozechner/pi-tui';
38
+ import { Type } from 'typebox';
37
39
  import {
38
40
  type AgentConfig,
39
41
  type AgentScope,
40
42
  type AgentSource,
41
43
  bundledAgentsDir,
42
- discoverAgents,
43
44
  discoverAgentsWithPackages,
44
45
  } from './agents.js';
45
- import { formatDelegateUsage, parseDelegateArgs } from './delegate-args.js';
46
- import { runAgent, runParallel } from './delegate-executor.js';
47
- import { buildSynthesisPrompt, extractRecentConversation } from './synthesis.js';
48
- import type {
49
- AgentResult,
50
- DelegateState,
51
- PhaseDefinition,
52
- PhaseResult,
53
- UsageStats as DelegateUsageStats,
54
- } from './delegate-types.js';
55
- import {
56
- aggregateUsage,
57
- confirmPlan,
58
- confirmSynthesis,
59
- formatFullSummary,
60
- formatPhaseHeader,
61
- getFinalText,
62
- pickWorkflow,
63
- } from './delegate-ui.js';
64
- import { getWorkflow, suggestWorkflow } from './workflows.js';
46
+ import { formatRunAgentUsage, parseRunAgentArgs } from './run-agent-args.js';
47
+ import { runAgent } from './agent-runner.js';
48
+ import { extractRecentConversation } from './synthesis.js';
49
+ import type { AgentResult } from './agent-runner-types.js';
50
+ import { getFinalText } from './agent-result-utils.js';
51
+ import { buildHandoffFromResult, renderHandoffForPrompt } from './handoffs.js';
65
52
 
66
53
  const MAX_PARALLEL_TASKS = 8;
67
54
  const MAX_CONCURRENCY = 4;
@@ -182,6 +169,20 @@ interface UsageStats {
182
169
  turns: number;
183
170
  }
184
171
 
172
+ interface RunAgentSummaryDetails {
173
+ agent: string;
174
+ agentSource: AgentSource;
175
+ agentScope: AgentScope;
176
+ sessionStrategy: string;
177
+ forked: boolean;
178
+ exitCode: number;
179
+ stopReason?: string;
180
+ errorMessage?: string;
181
+ usage: UsageStats;
182
+ task: string;
183
+ output: string;
184
+ }
185
+
185
186
  interface SingleResult {
186
187
  agent: string;
187
188
  agentSource: AgentSource | 'unknown';
@@ -295,6 +296,8 @@ async function runSingleAgent(
295
296
  agentName: string,
296
297
  task: string,
297
298
  cwd: string | undefined,
299
+ modelOverride: string | undefined,
300
+ thinkingOverride: string | undefined,
298
301
  step: number | undefined,
299
302
  signal: AbortSignal | undefined,
300
303
  onUpdate: OnUpdateCallback | undefined,
@@ -324,9 +327,12 @@ async function runSingleAgent(
324
327
  };
325
328
  }
326
329
 
330
+ const selectedModel = modelOverride ?? agent.model;
331
+ const selectedThinking = thinkingOverride ?? agent.thinking;
332
+
327
333
  const args: string[] = ['--mode', 'json', '-p', '--no-session'];
328
- if (agent.model) args.push('--model', agent.model);
329
- if (agent.thinking) args.push('--thinking', agent.thinking);
334
+ if (selectedModel) args.push('--model', selectedModel);
335
+ if (selectedThinking) args.push('--thinking', selectedThinking);
330
336
  if (agent.tools && agent.tools.length > 0) args.push('--tools', agent.tools.join(','));
331
337
 
332
338
  let tmpPromptDir: string | null = null;
@@ -348,7 +354,7 @@ async function runSingleAgent(
348
354
  contextTokens: 0,
349
355
  turns: 0,
350
356
  },
351
- model: agent.model,
357
+ model: selectedModel,
352
358
  step,
353
359
  };
354
360
 
@@ -473,12 +479,20 @@ async function runSingleAgent(
473
479
  const TaskItem = Type.Object({
474
480
  agent: Type.String({ description: 'Name of the agent to invoke' }),
475
481
  task: Type.String({ description: 'Task to delegate to the agent' }),
482
+ model: Type.Optional(Type.String({ description: 'Optional model override for this task' })),
483
+ thinking: Type.Optional(
484
+ Type.String({ description: 'Optional reasoning level override for this task' }),
485
+ ),
476
486
  cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process' })),
477
487
  });
478
488
 
479
489
  const ChainItem = Type.Object({
480
490
  agent: Type.String({ description: 'Name of the agent to invoke' }),
481
491
  task: Type.String({ description: 'Task with optional {previous} placeholder for prior output' }),
492
+ model: Type.Optional(Type.String({ description: 'Optional model override for this step' })),
493
+ thinking: Type.Optional(
494
+ Type.String({ description: 'Optional reasoning level override for this step' }),
495
+ ),
482
496
  cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process' })),
483
497
  });
484
498
 
@@ -493,11 +507,17 @@ const SubagentParams = Type.Object({
493
507
  Type.String({ description: 'Name of the agent to invoke (for single mode)' }),
494
508
  ),
495
509
  task: Type.Optional(Type.String({ description: 'Task to delegate (for single mode)' })),
510
+ model: Type.Optional(
511
+ Type.String({ description: 'Optional default model override for the run or all steps/tasks' }),
512
+ ),
513
+ thinking: Type.Optional(
514
+ Type.String({ description: 'Optional default reasoning level override for the run or all steps/tasks' }),
515
+ ),
496
516
  tasks: Type.Optional(
497
- Type.Array(TaskItem, { description: 'Array of {agent, task} for parallel execution' }),
517
+ Type.Array(TaskItem, { description: 'Array of {agent, task, model?, thinking?} for parallel execution' }),
498
518
  ),
499
519
  chain: Type.Optional(
500
- Type.Array(ChainItem, { description: 'Array of {agent, task} for sequential execution' }),
520
+ Type.Array(ChainItem, { description: 'Array of {agent, task, model?, thinking?} for sequential execution' }),
501
521
  ),
502
522
  agentScope: Type.Optional(AgentScopeSchema),
503
523
  confirmProjectAgents: Type.Optional(
@@ -532,12 +552,99 @@ async function tryResolvePackagePaths(cwd: string): Promise<ResolvedPaths | unde
532
552
  }
533
553
 
534
554
  export default function (pi: ExtensionAPI) {
555
+ let autocompleteCwd = process.cwd();
556
+
557
+ function parseArgumentText(argumentText: string): {
558
+ completedTokens: string[];
559
+ currentToken: string;
560
+ } {
561
+ const endsWithSpace = argumentText.length > 0 && /\s$/.test(argumentText);
562
+ const tokens = argumentText.trim().length > 0 ? argumentText.trim().split(/\s+/) : [];
563
+ return {
564
+ completedTokens: endsWithSpace ? tokens : tokens.slice(0, -1),
565
+ currentToken: endsWithSpace ? '' : (tokens.at(-1) ?? ''),
566
+ };
567
+ }
568
+
569
+ function replaceCurrentToken(argumentText: string, replacement: string): string {
570
+ const trimmedEnd = argumentText.replace(/\s+$/, '');
571
+ if (!trimmedEnd) return replacement;
572
+ if (/\s$/.test(argumentText)) return `${trimmedEnd} ${replacement}`;
573
+ const lastSpace = trimmedEnd.lastIndexOf(' ');
574
+ return lastSpace === -1
575
+ ? replacement
576
+ : `${trimmedEnd.slice(0, lastSpace + 1)}${replacement}`;
577
+ }
578
+
579
+ function buildArgumentCompletions(
580
+ argumentText: string,
581
+ items: Array<{ value: string; label?: string; description?: string }>,
582
+ ): AutocompleteItem[] | null {
583
+ const { currentToken } = parseArgumentText(argumentText);
584
+ const query = currentToken.toLowerCase();
585
+ const filtered = items.filter((item) => {
586
+ if (!query) return true;
587
+ return (
588
+ item.value.toLowerCase().startsWith(query) ||
589
+ item.label?.toLowerCase().includes(query) ||
590
+ item.description?.toLowerCase().includes(query)
591
+ );
592
+ });
593
+ if (filtered.length === 0) return null;
594
+ return filtered.map((item) => ({
595
+ value: replaceCurrentToken(argumentText, item.value),
596
+ label: item.label ?? item.value,
597
+ description: item.description,
598
+ }));
599
+ }
600
+
601
+ async function getAutocompleteAgents(scope: AgentScope): Promise<AgentConfig[]> {
602
+ const resolvedPaths = await tryResolvePackagePaths(autocompleteCwd);
603
+ return discoverAgentsWithPackages(autocompleteCwd, scope, resolvedPaths).agents;
604
+ }
605
+
606
+ async function getAutocompleteModels(scope: AgentScope): Promise<
607
+ Array<{ value: string; label?: string; description?: string }>
608
+ > {
609
+ const items = new Map<string, { value: string; label?: string; description?: string }>();
610
+
611
+ try {
612
+ const authStorage = AuthStorage.create();
613
+ const modelRegistry = ModelRegistry.create(authStorage);
614
+ const available = await modelRegistry.getAvailable();
615
+ for (const model of available) {
616
+ const value = `${model.provider}/${model.id}`;
617
+ items.set(value, {
618
+ value,
619
+ label: value,
620
+ description: 'Configured model with available credentials',
621
+ });
622
+ }
623
+ } catch {
624
+ // Fall back to models referenced by discovered agents.
625
+ }
626
+
627
+ const agents = await getAutocompleteAgents(scope);
628
+ for (const agent of agents) {
629
+ if (!agent.model) continue;
630
+ if (items.has(agent.model)) continue;
631
+ items.set(agent.model, {
632
+ value: agent.model,
633
+ label: agent.model,
634
+ description: `Default model on agent ${agent.name}`,
635
+ });
636
+ }
637
+
638
+ return [...items.values()];
639
+ }
640
+
535
641
  pi.registerTool({
536
642
  name: 'subagent',
537
643
  label: 'Subagent',
538
644
  description: [
539
645
  'Delegate tasks to specialized subagents with isolated context.',
540
646
  'Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).',
647
+ 'Optional per-run or per-step model and reasoning-level overrides are supported via model/thinking.',
541
648
  'Default agent scope is "user" (from ~/.pi/agent/agents).',
542
649
  'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
543
650
  ].join(' '),
@@ -635,6 +742,8 @@ export default function (pi: ExtensionAPI) {
635
742
  step.agent,
636
743
  taskWithContext,
637
744
  step.cwd,
745
+ step.model ?? params.model,
746
+ step.thinking ?? params.thinking,
638
747
  i + 1,
639
748
  signal,
640
749
  chainUpdate,
@@ -663,7 +772,15 @@ export default function (pi: ExtensionAPI) {
663
772
  isError: true,
664
773
  };
665
774
  }
666
- previousOutput = getFinalOutput(result.messages);
775
+ const finalOutput = getFinalOutput(result.messages) || '(no output)';
776
+ previousOutput = renderHandoffForPrompt(
777
+ buildHandoffFromResult({
778
+ agent: step.agent,
779
+ step: i + 1,
780
+ task: step.task,
781
+ output: finalOutput,
782
+ }),
783
+ );
667
784
  }
668
785
  return {
669
786
  content: [
@@ -738,6 +855,8 @@ export default function (pi: ExtensionAPI) {
738
855
  t.agent,
739
856
  t.task,
740
857
  t.cwd,
858
+ t.model ?? params.model,
859
+ t.thinking ?? params.thinking,
741
860
  undefined,
742
861
  signal,
743
862
  // Per-task update callback
@@ -778,6 +897,8 @@ export default function (pi: ExtensionAPI) {
778
897
  params.agent,
779
898
  params.task,
780
899
  params.cwd,
900
+ params.model,
901
+ params.thinking,
781
902
  undefined,
782
903
  signal,
783
904
  onUpdate,
@@ -1145,84 +1266,132 @@ export default function (pi: ExtensionAPI) {
1145
1266
  },
1146
1267
  });
1147
1268
 
1148
- // ── Delegate commands ──────────────────────────────────────────────────────
1149
-
1150
- function emptyDelegateUsage(): DelegateUsageStats {
1151
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
1152
- }
1269
+ // ── Command helpers ───────────────────────────────────────────────────────
1153
1270
 
1154
- function combineParallelOutputs(results: AgentResult[]): string {
1155
- return results
1156
- .map((r) => {
1157
- const output = getFinalText(r);
1158
- return `## ${r.agent}\n${output}`;
1159
- })
1160
- .join('\n\n');
1161
- }
1162
-
1163
- async function executePhase(
1164
- cwd: string,
1165
- phase: PhaseDefinition,
1166
- synthesis: string,
1167
- previousOutput: string,
1271
+ async function confirmProjectAgentsIfNeeded(
1168
1272
  ctx: ExtensionCommandContext,
1169
- agentScope: AgentScope,
1170
- resolvedPaths?: ResolvedPaths,
1171
- ): Promise<{ results: AgentResult[]; output: string; aborted: boolean }> {
1172
- const task = phase.taskTemplate
1173
- .replace(/\{synthesis\}/g, synthesis)
1174
- .replace(/\{previous\}/g, previousOutput);
1175
-
1176
- ctx.ui.notify(
1177
- `${formatPhaseHeader(phase.name, 'running')} ${phase.kind === 'parallel' ? `(${phase.agents.length} agents)` : phase.agents[0]}`,
1178
- 'info',
1273
+ agents: AgentConfig[],
1274
+ projectAgentsDir: string | null,
1275
+ confirmProjectAgents: boolean,
1276
+ ): Promise<boolean> {
1277
+ if (!ctx.hasUI || !confirmProjectAgents || agents.length === 0) return true;
1278
+
1279
+ const names = Array.from(new Set(agents.map((agent) => agent.name))).join(', ');
1280
+ const dir = projectAgentsDir ?? '(unknown)';
1281
+ return ctx.ui.confirm(
1282
+ 'Run project-local agents?',
1283
+ `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
1179
1284
  );
1285
+ }
1180
1286
 
1181
- let results: AgentResult[];
1182
- if (phase.kind === 'parallel') {
1183
- results = await runParallel(cwd, phase.agents, task, {
1184
- agentScope,
1185
- onUpdate: (_phaseName, _agentName, _result) => {
1186
- // Streaming updates happen per-message
1187
- },
1188
- phaseName: phase.name,
1189
- resolvedPaths,
1190
- });
1191
- } else {
1192
- const result = await runAgent(cwd, phase.agents[0], task, {
1193
- agentScope,
1194
- phaseName: phase.name,
1195
- resolvedPaths,
1196
- });
1197
- results = [result];
1198
- }
1287
+ function buildRunAgentTask(conversation: string, explicitTask?: string): string | undefined {
1288
+ const trimmedConversation = conversation.trim();
1289
+ const trimmedTask = explicitTask?.trim();
1199
1290
 
1200
- const hasError = results.some((r) => r.exitCode !== 0 || r.stopReason === 'error');
1201
- if (hasError) {
1202
- const failedAgents = results
1203
- .filter((r) => r.exitCode !== 0 || r.stopReason === 'error')
1204
- .map((r) => `${r.agent}: ${r.errorMessage || r.stderr || '(unknown error)'}`)
1205
- .join('\n');
1291
+ if (trimmedTask && trimmedConversation) {
1292
+ return `Assigned task:\n${trimmedTask}\n\nRelevant main-session context:\n${trimmedConversation}`;
1293
+ }
1206
1294
 
1207
- ctx.ui.notify(`${formatPhaseHeader(phase.name, 'failed')}\n${failedAgents}`, 'error');
1208
- return { results, output: combineParallelOutputs(results), aborted: true };
1295
+ if (trimmedTask) return trimmedTask;
1296
+ if (trimmedConversation) {
1297
+ return `Use the following main-session context to determine and complete the task:\n\n${trimmedConversation}`;
1209
1298
  }
1210
1299
 
1211
- const output = combineParallelOutputs(results);
1212
- ctx.ui.notify(formatPhaseHeader(phase.name, 'done'), 'info');
1300
+ return undefined;
1301
+ }
1213
1302
 
1214
- if (phase.requiresConfirmation && ctx.hasUI) {
1215
- const planText = output;
1216
- const proceed = await confirmPlan(ctx, planText);
1217
- if (!proceed) {
1218
- ctx.ui.notify('Delegate aborted by user after plan review.', 'warning');
1219
- return { results, output, aborted: true };
1303
+ function formatRunAgentSummary(options: {
1304
+ agent: AgentConfig;
1305
+ agentScope: AgentScope;
1306
+ task: string;
1307
+ result: AgentResult;
1308
+ forked: boolean;
1309
+ }): string {
1310
+ const { agent, agentScope, task, result, forked } = options;
1311
+ const output = getFinalText(result).trim();
1312
+ const usage = formatUsageStats(result.usage, result.model);
1313
+ const failed = result.exitCode !== 0 || result.stopReason === 'error';
1314
+ const status = failed ? 'failed' : 'completed';
1315
+ const lines = [
1316
+ '## Agent Run',
1317
+ `- Agent: \`${agent.name}\``,
1318
+ `- Source: ${agent.source}`,
1319
+ `- Scope: ${agentScope}`,
1320
+ `- Session strategy: ${agent.sessionStrategy ?? 'inline'}`,
1321
+ `- Session handling: ${forked ? 'forked current branch at active entry' : 'inline in current session'}`,
1322
+ `- Status: ${status}`,
1323
+ '',
1324
+ '## Task',
1325
+ task,
1326
+ '',
1327
+ '## Output',
1328
+ output || '_No output returned._',
1329
+ ];
1330
+
1331
+ if (failed) {
1332
+ const errorText = result.errorMessage?.trim() || result.stderr.trim();
1333
+ if (errorText) {
1334
+ lines.push('', '## Error', errorText);
1220
1335
  }
1221
1336
  }
1222
1337
 
1223
- return { results, output, aborted: false };
1338
+ if (usage) {
1339
+ lines.push('', '## Usage', usage);
1340
+ }
1341
+
1342
+ return lines.join('\n');
1224
1343
  }
1225
1344
 
1345
+ pi.registerMessageRenderer('run-agent-summary', (message, { expanded }, theme) => {
1346
+ const details = message.details as RunAgentSummaryDetails | undefined;
1347
+ if (!details) return new Text(typeof message.content === 'string' ? message.content : '', 0, 0);
1348
+
1349
+ const failed = details.exitCode !== 0 || details.stopReason === 'error';
1350
+ const icon = failed ? theme.fg('error', '✗') : theme.fg('success', '✓');
1351
+ const title = `${icon} ${theme.fg('toolTitle', theme.bold(details.agent))}${theme.fg('muted', ` (${details.agentSource})`)}`;
1352
+ const modeLine = theme.fg(
1353
+ 'dim',
1354
+ `${details.forked ? 'forked branch' : 'inline'} • ${details.agentScope} • ${details.sessionStrategy}`,
1355
+ );
1356
+ const usageLine = formatUsageStats(details.usage);
1357
+ const outputPreview = details.output.trim() || '(no output)';
1358
+
1359
+ if (!expanded) {
1360
+ const previewLines = outputPreview.split('\n').slice(0, 3).join('\n');
1361
+ const text = [
1362
+ title,
1363
+ modeLine,
1364
+ theme.fg('muted', `Task: ${details.task}`),
1365
+ failed && details.errorMessage ? theme.fg('error', `Error: ${details.errorMessage}`) : previewLines,
1366
+ usageLine ? theme.fg('dim', usageLine) : undefined,
1367
+ theme.fg('muted', '(Ctrl+O to expand)'),
1368
+ ]
1369
+ .filter((line): line is string => Boolean(line))
1370
+ .join('\n');
1371
+ const box = new Box(1, 1, (text) => theme.bg('customMessageBg', text));
1372
+ box.addChild(new Text(text, 0, 0));
1373
+ return box;
1374
+ }
1375
+
1376
+ const mdTheme = getMarkdownTheme();
1377
+ const box = new Box(1, 1, (text) => theme.bg('customMessageBg', text));
1378
+ box.addChild(new Text(title, 0, 0));
1379
+ box.addChild(new Text(modeLine, 0, 0));
1380
+ if (usageLine) box.addChild(new Text(theme.fg('dim', usageLine), 0, 0));
1381
+ box.addChild(new Spacer(1));
1382
+ box.addChild(new Text(theme.fg('muted', 'Task'), 0, 0));
1383
+ box.addChild(new Text(theme.fg('text', details.task), 0, 0));
1384
+ if (failed && details.errorMessage) {
1385
+ box.addChild(new Spacer(1));
1386
+ box.addChild(new Text(theme.fg('error', 'Error'), 0, 0));
1387
+ box.addChild(new Text(theme.fg('error', details.errorMessage), 0, 0));
1388
+ }
1389
+ box.addChild(new Spacer(1));
1390
+ box.addChild(new Text(theme.fg('muted', 'Output'), 0, 0));
1391
+ box.addChild(new Markdown(outputPreview, 0, 0, mdTheme));
1392
+ return box;
1393
+ });
1394
+
1226
1395
  async function listMdFiles(dir: string): Promise<Set<string>> {
1227
1396
  if (!existsSync(dir)) return new Set();
1228
1397
  try {
@@ -1233,9 +1402,55 @@ export default function (pi: ExtensionAPI) {
1233
1402
  }
1234
1403
  }
1235
1404
 
1405
+ pi.on('session_start', async (_event, ctx) => {
1406
+ autocompleteCwd = ctx.cwd;
1407
+ });
1408
+
1236
1409
  pi.registerCommand('delegate-agents', {
1237
1410
  description: 'List, customize, or reset delegate agents',
1411
+ getArgumentCompletions: async (argumentText) => {
1412
+ const userDir = join(getAgentDir(), 'agents');
1413
+ const { completedTokens } = parseArgumentText(argumentText);
1414
+ const action = completedTokens[0];
1415
+
1416
+ if (!action) {
1417
+ return buildArgumentCompletions(argumentText, [
1418
+ { value: 'list', description: 'Show bundled and overridden agents' },
1419
+ { value: 'reset', description: 'Restore bundled agents by removing user overrides' },
1420
+ { value: 'edit', description: 'Copy a bundled agent into your user agents directory' },
1421
+ ]);
1422
+ }
1423
+
1424
+ if (action === 'reset') {
1425
+ if (completedTokens.length > 1) return null;
1426
+ const bundled = await listMdFiles(bundledAgentsDir);
1427
+ const user = await listMdFiles(userDir);
1428
+ const resettable = [...user].filter((name) => bundled.has(name)).sort();
1429
+ return buildArgumentCompletions(argumentText, [
1430
+ { value: '--all', description: 'Reset all user overrides that have bundled versions' },
1431
+ ...resettable.map((name) => ({
1432
+ value: name,
1433
+ description: 'Reset this user override to the bundled version',
1434
+ })),
1435
+ ]);
1436
+ }
1437
+
1438
+ if (action === 'edit') {
1439
+ if (completedTokens.length > 1) return null;
1440
+ const bundled = [...(await listMdFiles(bundledAgentsDir))].sort();
1441
+ return buildArgumentCompletions(
1442
+ argumentText,
1443
+ bundled.map((name) => ({
1444
+ value: name,
1445
+ description: 'Copy this bundled agent into your user agents directory',
1446
+ })),
1447
+ );
1448
+ }
1449
+
1450
+ return null;
1451
+ },
1238
1452
  handler: async (args, ctx) => {
1453
+ autocompleteCwd = ctx.cwd;
1239
1454
  const userDir = join(getAgentDir(), 'agents');
1240
1455
  const parts = args?.trim().split(/\s+/) || [];
1241
1456
  const action = parts[0] || 'list';
@@ -1328,136 +1543,219 @@ export default function (pi: ExtensionAPI) {
1328
1543
  },
1329
1544
  });
1330
1545
 
1331
- pi.registerCommand('delegate', {
1546
+ pi.registerCommand('run-agent', {
1332
1547
  description:
1333
- 'Orchestrate subagent workflowssupports --scope, --workflow, and project-local agents',
1548
+ 'Run a single agent directly honors agent sessionStrategy metadata such as fork-at',
1549
+ getArgumentCompletions: async (argumentText) => {
1550
+ const { completedTokens } = parseArgumentText(argumentText);
1551
+ let agentScope: AgentScope = 'user';
1552
+ let expectsScopeValue = false;
1553
+ let expectsThinkingValue = false;
1554
+ let expectsModelValue = false;
1555
+ let agentName: string | undefined;
1556
+
1557
+ for (const token of completedTokens) {
1558
+ if (expectsScopeValue) {
1559
+ if (token === 'user' || token === 'project' || token === 'both') {
1560
+ agentScope = token;
1561
+ }
1562
+ expectsScopeValue = false;
1563
+ continue;
1564
+ }
1565
+ if (expectsThinkingValue) {
1566
+ expectsThinkingValue = false;
1567
+ continue;
1568
+ }
1569
+ if (expectsModelValue) {
1570
+ expectsModelValue = false;
1571
+ continue;
1572
+ }
1573
+ if (token === '--scope') {
1574
+ expectsScopeValue = true;
1575
+ continue;
1576
+ }
1577
+ if (token === '--thinking' || token === '--reasoning-level') {
1578
+ expectsThinkingValue = true;
1579
+ continue;
1580
+ }
1581
+ if (token === '--model') {
1582
+ expectsModelValue = true;
1583
+ continue;
1584
+ }
1585
+ if (token === '--yes-project-agents') continue;
1586
+ if (token.startsWith('--')) continue;
1587
+ if (!agentName) agentName = token;
1588
+ }
1589
+
1590
+ if (expectsScopeValue) {
1591
+ return buildArgumentCompletions(argumentText, [
1592
+ { value: 'user', description: 'Only user/global agents from ~/.pi/agent/agents' },
1593
+ { value: 'project', description: 'Only project-local agents from .pi/agents' },
1594
+ { value: 'both', description: 'User/global agents plus project-local agents' },
1595
+ ]);
1596
+ }
1597
+
1598
+ if (expectsThinkingValue) {
1599
+ return buildArgumentCompletions(argumentText, [
1600
+ { value: 'off', description: 'Disable reasoning if the model supports it' },
1601
+ { value: 'minimal', description: 'Minimal reasoning effort' },
1602
+ { value: 'low', description: 'Lower reasoning effort' },
1603
+ { value: 'medium', description: 'Balanced reasoning effort' },
1604
+ { value: 'high', description: 'Higher reasoning effort' },
1605
+ { value: 'xhigh', description: 'Maximum reasoning effort on supported models' },
1606
+ ]);
1607
+ }
1608
+
1609
+ if (expectsModelValue) {
1610
+ const models = await getAutocompleteModels(agentScope);
1611
+ return buildArgumentCompletions(argumentText, models);
1612
+ }
1613
+ if (agentName) return null;
1614
+
1615
+ const agents = await getAutocompleteAgents(agentScope);
1616
+ return buildArgumentCompletions(argumentText, [
1617
+ { value: '--scope', description: 'Choose which agent directories are searched' },
1618
+ { value: '--model', description: 'Override the agent model for this run' },
1619
+ { value: '--thinking', description: 'Override the reasoning level for this run' },
1620
+ { value: '--reasoning-level', description: 'Alias for --thinking' },
1621
+ { value: '--yes-project-agents', description: 'Skip the project-agent trust confirmation prompt' },
1622
+ ...agents.map((agent) => ({
1623
+ value: agent.name,
1624
+ label: agent.name,
1625
+ description: `${agent.source} — ${agent.description}`,
1626
+ })),
1627
+ ]);
1628
+ },
1334
1629
  handler: async (args, ctx) => {
1335
- const parsed = parseDelegateArgs(args);
1630
+ autocompleteCwd = ctx.cwd;
1631
+ const parsed = parseRunAgentArgs(args);
1336
1632
  if (!parsed.ok) {
1337
1633
  ctx.ui.notify(parsed.error, 'warning');
1338
1634
  return;
1339
1635
  }
1340
1636
 
1341
- const { explicitTask, agentScope, workflowId, confirmProjectAgents } = parsed.options;
1342
- const resolvedPaths = await tryResolvePackagePaths(ctx.cwd);
1343
-
1344
- // Step 1: Synthesize
1345
- const conversation = extractRecentConversation(ctx);
1346
- if (!conversation.trim() && !explicitTask) {
1347
- ctx.ui.notify(`No conversation context and no task specified.\n\n${formatDelegateUsage()}`, 'warning');
1637
+ const { agentName, explicitTask, agentScope, confirmProjectAgents, model, thinking } = parsed.options;
1638
+ if (!agentName) {
1639
+ ctx.ui.notify(formatRunAgentUsage(), 'warning');
1348
1640
  return;
1349
1641
  }
1350
1642
 
1351
- const prompt = buildSynthesisPrompt(conversation, explicitTask);
1352
- ctx.ui.notify('⏳ Synthesizing task from conversation...', 'info');
1353
-
1354
- const synthResult = await runAgent(ctx.cwd, 'planner', prompt, { agentScope, resolvedPaths });
1355
- const synthesis = getFinalText(synthResult);
1643
+ const resolvedPaths = await tryResolvePackagePaths(ctx.cwd);
1644
+ const discovery = discoverAgentsWithPackages(ctx.cwd, agentScope, resolvedPaths);
1645
+ const agent = discovery.agents.find((candidate) => candidate.name === agentName);
1356
1646
 
1357
- if (!synthesis.trim()) {
1358
- ctx.ui.notify('Failed to synthesize task from conversation.', 'error');
1647
+ if (!agent) {
1648
+ const available = discovery.agents.map((candidate) => candidate.name).join(', ') || 'none';
1649
+ ctx.ui.notify(`Unknown agent: "${agentName}". Available: ${available}`, 'warning');
1359
1650
  return;
1360
1651
  }
1361
1652
 
1362
- // Step 2: Confirm synthesis
1363
- if (ctx.hasUI) {
1364
- const synthOk = await confirmSynthesis(ctx, synthesis);
1365
- if (!synthOk) {
1366
- ctx.ui.notify('Delegate cancelled.', 'info');
1367
- return;
1368
- }
1369
- }
1370
-
1371
- // Step 3: Pick workflow
1372
- const workflow = workflowId
1373
- ? getWorkflow(workflowId)
1374
- : ctx.hasUI
1375
- ? await pickWorkflow(ctx, synthesis)
1376
- : getWorkflow(suggestWorkflow(synthesis));
1377
- if (!workflow) {
1378
- ctx.ui.notify('Delegate cancelled — no workflow selected.', 'info');
1653
+ const requestedProjectAgents = agent.source === 'project' ? [agent] : [];
1654
+ const approved = await confirmProjectAgentsIfNeeded(
1655
+ ctx,
1656
+ requestedProjectAgents,
1657
+ discovery.projectAgentsDir,
1658
+ confirmProjectAgents,
1659
+ );
1660
+ if (!approved) {
1661
+ ctx.ui.notify('Run cancelled — project-local agents not approved.', 'info');
1379
1662
  return;
1380
1663
  }
1381
1664
 
1382
- if ((agentScope === 'project' || agentScope === 'both') && confirmProjectAgents && ctx.hasUI) {
1383
- const discovery = discoverAgentsWithPackages(ctx.cwd, agentScope, resolvedPaths);
1384
- const requestedProjectAgents = workflow.phases
1385
- .flatMap((phase) => phase.agents)
1386
- .map((name) => discovery.agents.find((agent) => agent.name === name))
1387
- .filter((agent): agent is AgentConfig => agent?.source === 'project');
1388
-
1389
- if (requestedProjectAgents.length > 0) {
1390
- const names = Array.from(new Set(requestedProjectAgents.map((agent) => agent.name))).join(', ');
1391
- const dir = discovery.projectAgentsDir ?? '(unknown)';
1392
- const ok = await ctx.ui.confirm(
1393
- 'Run project-local agents?',
1394
- `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
1395
- );
1396
- if (!ok) {
1397
- ctx.ui.notify('Delegate cancelled — project-local agents not approved.', 'info');
1398
- return;
1399
- }
1400
- }
1665
+ const conversation = extractRecentConversation(ctx);
1666
+ const task = buildRunAgentTask(conversation, explicitTask);
1667
+ if (!task) {
1668
+ ctx.ui.notify(`No conversation context and no task specified.\n\n${formatRunAgentUsage()}`, 'warning');
1669
+ return;
1401
1670
  }
1402
1671
 
1403
- // Step 4: Execute phases
1404
- const state: DelegateState = {
1405
- synthesis,
1406
- workflow,
1407
- phases: [],
1408
- totalUsage: emptyDelegateUsage(),
1409
- };
1410
-
1411
- let previousOutput = '';
1412
-
1413
- ctx.ui.notify(`Starting workflow: ${workflow.label} [${agentScope}]`, 'info');
1672
+ const runAndReport = async (
1673
+ commandCwd: string,
1674
+ commandUi: typeof ctx.ui,
1675
+ sendSummary: (message: {
1676
+ customType: 'run-agent-summary';
1677
+ content: string;
1678
+ display: true;
1679
+ details: RunAgentSummaryDetails;
1680
+ }) => Promise<void> | void,
1681
+ forked: boolean,
1682
+ ) => {
1683
+ const selectedModel = model ?? agent.model;
1684
+ const selectedThinking = thinking ?? agent.thinking;
1685
+
1686
+ commandUi.notify(
1687
+ `Running agent: ${agent.name} [${agent.source}${forked ? ', forked' : ', inline'}${selectedModel ? `, model=${selectedModel}` : ''}${selectedThinking ? `, thinking=${selectedThinking}` : ''}]`,
1688
+ 'info',
1689
+ );
1414
1690
 
1415
- for (const phaseDef of workflow.phases) {
1416
- const phaseResult = await executePhase(
1417
- ctx.cwd,
1418
- phaseDef,
1419
- synthesis,
1420
- previousOutput,
1421
- ctx,
1691
+ const result = await runAgent(commandCwd, agent.name, task, {
1422
1692
  agentScope,
1423
1693
  resolvedPaths,
1424
- );
1425
-
1426
- const phase: PhaseResult = {
1427
- name: phaseDef.name,
1428
- kind: phaseDef.kind,
1429
- agents: phaseResult.results,
1430
- };
1694
+ model,
1695
+ thinking,
1696
+ });
1697
+ const failed = result.exitCode !== 0 || result.stopReason === 'error';
1698
+ const output = getFinalText(result).trim();
1431
1699
 
1432
- state.phases.push(phase);
1433
- const phaseUsage = aggregateUsage(phaseResult.results);
1434
- state.totalUsage.input += phaseUsage.input;
1435
- state.totalUsage.output += phaseUsage.output;
1436
- state.totalUsage.cacheRead += phaseUsage.cacheRead;
1437
- state.totalUsage.cacheWrite += phaseUsage.cacheWrite;
1438
- state.totalUsage.cost += phaseUsage.cost;
1439
- state.totalUsage.turns += phaseUsage.turns;
1700
+ commandUi.notify(
1701
+ failed ? `Agent "${agent.name}" failed.` : `Agent "${agent.name}" complete.`,
1702
+ failed ? 'error' : 'info',
1703
+ );
1440
1704
 
1441
- if (phaseResult.aborted) break;
1705
+ await sendSummary({
1706
+ customType: 'run-agent-summary',
1707
+ content: formatRunAgentSummary({
1708
+ agent,
1709
+ agentScope,
1710
+ task,
1711
+ result,
1712
+ forked,
1713
+ }),
1714
+ display: true,
1715
+ details: {
1716
+ agent: agent.name,
1717
+ agentSource: agent.source,
1718
+ agentScope,
1719
+ sessionStrategy: agent.sessionStrategy ?? 'inline',
1720
+ forked,
1721
+ exitCode: result.exitCode,
1722
+ stopReason: result.stopReason,
1723
+ errorMessage: result.errorMessage,
1724
+ usage: result.usage,
1725
+ task,
1726
+ output,
1727
+ } satisfies RunAgentSummaryDetails,
1728
+ });
1729
+ };
1442
1730
 
1443
- previousOutput = phaseResult.output;
1731
+ if (agent.sessionStrategy === 'fork-at') {
1732
+ const leafId = ctx.sessionManager.getLeafId?.();
1733
+ if (!leafId) {
1734
+ ctx.ui.notify(
1735
+ `Agent "${agent.name}" prefers fork-at, but the current session cannot be forked. Running inline instead.`,
1736
+ 'warning',
1737
+ );
1738
+ } else {
1739
+ const forkResult = await ctx.fork(leafId, {
1740
+ position: 'at',
1741
+ withSession: async (replacementCtx) => {
1742
+ await runAndReport(
1743
+ replacementCtx.cwd,
1744
+ replacementCtx.ui,
1745
+ (message) => replacementCtx.sendMessage(message),
1746
+ true,
1747
+ );
1748
+ },
1749
+ });
1750
+ if (forkResult.cancelled) {
1751
+ ctx.ui.notify(`Run cancelled — unable to fork session for "${agent.name}".`, 'info');
1752
+ }
1753
+ return;
1754
+ }
1444
1755
  }
1445
1756
 
1446
- // Step 5: Show full summary
1447
- const summary = formatFullSummary(state);
1448
- ctx.ui.notify(`Delegate complete — ${workflow.label}`, 'info');
1449
-
1450
- pi.sendMessage({
1451
- customType: 'delegate-summary',
1452
- content: summary,
1453
- display: true,
1454
- details: {
1455
- workflow: workflow.id,
1456
- agentScope,
1457
- phaseCount: state.phases.length,
1458
- totalUsage: state.totalUsage,
1459
- },
1460
- });
1757
+ await runAndReport(ctx.cwd, ctx.ui, (message) => pi.sendMessage(message), false);
1461
1758
  },
1462
1759
  });
1760
+
1463
1761
  }