pi-mainagent.ts 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470
  1. /**
  2. * Slim main-agent selector extension (spec MS-001).
  3. *
  4. * Purpose: discover prompt-persona agent definitions, switch the active persona
  5. * via the `/mainagent` command, append the persona body to Pi's system prompt
  6. * each turn, apply the persona's model/thinking/tools to the current session
  7. * (session-scoped: new sessions keep Pi's configured defaults), persist the
  8. * selection, and reflect it in the status widget. An interactive TUI menu
  9. * (bare `/mainagent`) also edits the model/effort/description of a
  10. * definition file and activates the selected agent.
  11. *
  12. * Tool filters layer across agent switches: an agent without its own tools
  13. * configuration keeps the restrictions applied by the previous one (restored
  14. * on deactivation; faithful to piagents.ts_).
  15. *
  16. * Supported frontmatter keys (anything else in the definition file is
  17. * silently ignored by this extension):
  18. * name — unique persona name; falls back to the file basename.
  19. * description — short display description (picker, list rendering).
  20. * model — provider/model applied at activation (session-scoped).
  21. * thinking — thinking level applied at activation (session-scoped).
  22. * tools — optional tool whitelist, wildcards allowed.
  23. * excludeTools — optional tool blacklist, wildcards allowed.
  24. * prompt — path to an external Markdown prompt file (absolute, or
  25. * relative to the definition file); its content becomes the
  26. * persona body, prepended to any inline body below the
  27. * frontmatter.
  28. *
  29. * Prompt semantics: the persona body is APPENDED to Pi's system prompt each
  30. * turn (append-only). The base prompt, APPEND_SYSTEM.md, project context
  31. * files and skills are never stripped. Frontmatter keys belonging to other
  32. * extensions (e.g. pi-subagents' `systemPromptMode`, `inheritProjectContext`,
  33. * `inheritSkills`, `acceptanceRole`) are not part of this contract and are
  34. * ignored here.
  35. *
  36. * Out of scope (vs piagents.ts_): the subagent orchestration tool and command.
  37. */
  38. import fs from "node:fs";
  39. import path from "node:path";
  40. import {
  41. DynamicBorder,
  42. getAgentDir,
  43. parseFrontmatter,
  44. } from "@earendil-works/pi-coding-agent";
  45. import type {
  46. ExtensionAPI,
  47. ExtensionContext,
  48. Theme,
  49. } from "@earendil-works/pi-coding-agent";
  50. import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
  51. import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
  52. import type { Api, Model } from "@earendil-works/pi-ai";
  53. import { matchesKey, SelectList, Text } from "@earendil-works/pi-tui";
  54. import type { SelectItem, SelectListTheme } from "@earendil-works/pi-tui";
  55. // ---------------------------------------------------------------------------
  56. // Types
  57. // ---------------------------------------------------------------------------
  58. /** A discovered prompt-persona agent definition. */
  59. interface AgentDef {
  60. /** Absolute path of the source definition file (display/logging only). */
  61. filePath: string;
  62. /** Unique agent name used for activation and persistence. */
  63. name: string;
  64. /** Short display description. */
  65. description?: string;
  66. /** Provider/model identifier applied to the session at activation. */
  67. model?: string;
  68. /** Thinking level applied to the session at activation. */
  69. thinking?: ThinkingLevel;
  70. /** Tool whitelist (wildcards allowed) applied to the session at activation. */
  71. tools?: string[];
  72. /** Tool blacklist (wildcards allowed) applied to the session at activation. */
  73. excludeTools?: string[];
  74. /** Persona markdown appended to Pi's system prompt. */
  75. body: string;
  76. }
  77. /** Field updates applied to an agent definition file from the interactive menu. */
  78. interface AgentDefUpdates {
  79. /** New provider/model identifier. */
  80. model?: string;
  81. /** New thinking level. */
  82. thinking?: ThinkingLevel;
  83. /** New description text (empty clears the field). */
  84. description?: string;
  85. }
  86. /** A model and effort pair chosen in the combined picker dialog. */
  87. interface ModelEffortChoice {
  88. /** Chosen provider/model identifier. */
  89. model: string;
  90. /** Chosen thinking level. */
  91. effort: ThinkingLevel;
  92. }
  93. // ---------------------------------------------------------------------------
  94. // Constants
  95. // ---------------------------------------------------------------------------
  96. const LOG_PREFIX = "[main-agent]";
  97. const COMMAND_NAME = "mainagent";
  98. const WIDGET_ID = "main-agent-status";
  99. const AGENTS_SUBDIR = "agents";
  100. const STATE_FILENAME = "agent-state.json";
  101. const PROMPT_METADATA_KEY = "prompt";
  102. const FRONTMATTER_SEPARATOR = "\n\n";
  103. const UNSUPPORTED_YAML_SCALAR_PREFIX = /^[!&*[{|>-]/;
  104. const MODEL_ID_SEPARATOR = "/";
  105. const MAX_PROMPT_FILE_BYTES = 64 * 1024;
  106. const DEACTIVATION_KEYWORD = "off";
  107. const THINKING_LEVELS: readonly ThinkingLevel[] = [
  108. "off",
  109. "minimal",
  110. "low",
  111. "medium",
  112. "high",
  113. "xhigh",
  114. "max",
  115. ];
  116. const MODEL_METADATA_KEY = "model";
  117. const THINKING_METADATA_KEY = "thinking";
  118. const DESCRIPTION_METADATA_KEY = "description";
  119. const FRONTMATTER_BOUNDARY = "---";
  120. const DESCRIPTION_BLOCK_INDENT = " ";
  121. const UTF8_BOM = "\uFEFF";
  122. const DEFAULT_EFFORT: ThinkingLevel = "medium";
  123. const MAX_VISIBLE_MENU_ITEMS = 10;
  124. const CURRENT_MODEL_LABEL = "current";
  125. const ACTION_CHANGE_MODEL = "Change model & effort";
  126. const ACTION_EDIT_DESCRIPTION = "Edit description";
  127. const ACTION_SWITCH = "Switch to this agent";
  128. const ACTION_BACK = "Back";
  129. // ---------------------------------------------------------------------------
  130. // Tool-name normalization (frontmatter metadata)
  131. // ---------------------------------------------------------------------------
  132. /**
  133. * Normalizes a supported scalar tool name for runtime tool filtering.
  134. *
  135. * Input: raw YAML scalar text. Output: normalized tool name, if supported.
  136. * Side effects: none.
  137. */
  138. function normalizeToolName(value: string): string | undefined {
  139. const trimmed = value.trim();
  140. if (!trimmed || UNSUPPORTED_YAML_SCALAR_PREFIX.test(trimmed)) return undefined;
  141. const isQuoted =
  142. (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
  143. (trimmed.startsWith("'") && trimmed.endsWith("'"));
  144. const toolName = isQuoted ? trimmed.slice(1, -1).trim() : trimmed;
  145. return toolName || undefined;
  146. }
  147. /**
  148. * Parses a tools-list frontmatter field (YAML list or CSV) for runtime tool
  149. * filtering.
  150. *
  151. * Input: raw frontmatter value. Output: normalized tool names, when any.
  152. * Side effects: none.
  153. */
  154. function parseToolList(value: unknown): string[] | undefined {
  155. // Accept either a YAML sequence or a comma-separated string form.
  156. const values = Array.isArray(value)
  157. ? value
  158. : typeof value === "string"
  159. ? value.split(",")
  160. : [];
  161. const tools = values
  162. .filter((tool): tool is string => typeof tool === "string")
  163. .map(normalizeToolName)
  164. .filter((toolName): toolName is string => Boolean(toolName));
  165. return tools.length > 0 ? tools : undefined;
  166. }
  167. /**
  168. * Validates a raw thinking-level frontmatter value against the supported levels.
  169. *
  170. * Input: raw frontmatter value. Output: validated thinking level, if supported.
  171. * Side effects: none.
  172. */
  173. function parseThinkingLevel(value: unknown): ThinkingLevel | undefined {
  174. if (typeof value !== "string") return undefined;
  175. const level = value.trim().toLowerCase() as ThinkingLevel;
  176. return (THINKING_LEVELS as readonly string[]).includes(level) ? level : undefined;
  177. }
  178. /**
  179. * Escapes regex metacharacters in one wildcard-pattern segment.
  180. *
  181. * Input: literal pattern segment. Output: escaped segment.
  182. * Side effects: none.
  183. */
  184. function escapeToolPatternSegment(segment: string): string {
  185. return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  186. }
  187. /**
  188. * Checks whether a tool name matches a pattern containing '*' wildcards.
  189. *
  190. * Input: candidate tool name and pattern (e.g. "ssh-manager_*"). Output: match result.
  191. * Side effects: none.
  192. */
  193. function matchesToolPattern(toolName: string, pattern: string): boolean {
  194. const regex = new RegExp(`^${pattern.split("*").map(escapeToolPatternSegment).join(".*")}$`);
  195. return regex.test(toolName);
  196. }
  197. /**
  198. * Expands wildcard entries ("ssh-manager_*") against the available tool names.
  199. *
  200. * Inputs: whitelist entries and registered tool names. Output: effective tool list.
  201. * Exact entries stay verbatim; patterns matching nothing are dropped. An empty
  202. * available list returns the patterns unchanged (safe fallback).
  203. * Side effects: none.
  204. */
  205. function expandToolPatterns(patterns: string[], availableToolNames: readonly string[]): string[] {
  206. if (availableToolNames.length === 0 || !patterns.some((entry) => entry.includes("*"))) {
  207. return patterns;
  208. }
  209. const resolved: string[] = [];
  210. for (const entry of patterns) {
  211. if (!entry.includes("*")) {
  212. resolved.push(entry);
  213. continue;
  214. }
  215. for (const name of availableToolNames) {
  216. if (matchesToolPattern(name, entry) && !resolved.includes(name)) resolved.push(name);
  217. }
  218. }
  219. return resolved;
  220. }
  221. /**
  222. * Lists registered tool names for wildcard expansion.
  223. *
  224. * Input: Pi extension API. Output: registered tool names; empty when unavailable.
  225. * Side effects: none.
  226. */
  227. function listRegisteredToolNames(pi: ExtensionAPI): string[] {
  228. try {
  229. return pi.getAllTools().map((tool) => tool.name);
  230. } catch {
  231. // Tool metadata may be unavailable before Pi finishes binding its actions.
  232. return [];
  233. }
  234. }
  235. // ---------------------------------------------------------------------------
  236. // Paths
  237. // ---------------------------------------------------------------------------
  238. /**
  239. * Resolves the directory containing agent definition files.
  240. *
  241. * Input: Pi's configured agent directory. Output: absolute agents directory.
  242. * Side effects: none.
  243. */
  244. function agentsDir(): string {
  245. return path.join(getAgentDir(), AGENTS_SUBDIR);
  246. }
  247. /**
  248. * Resolves the persisted active-agent state file path.
  249. *
  250. * Input: Pi's configured agent directory. Output: absolute state-file path.
  251. * Side effects: none.
  252. */
  253. function stateFile(): string {
  254. return path.join(getAgentDir(), STATE_FILENAME);
  255. }
  256. // ---------------------------------------------------------------------------
  257. // Persistence
  258. // ---------------------------------------------------------------------------
  259. /**
  260. * Reads the persisted active-agent name from agent-state.json.
  261. *
  262. * Input: state file contents. Output: active agent name, or undefined when no
  263. * selection is persisted or the file is missing, corrupt, or wrong-shaped.
  264. * Side effects: reads the state file.
  265. */
  266. function readState(): string | undefined {
  267. try {
  268. // Parse the persisted JSON state and validate the `{ active }` shape.
  269. const parsed: unknown = JSON.parse(fs.readFileSync(stateFile(), "utf-8"));
  270. if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
  271. return undefined;
  272. }
  273. const active = (parsed as { active?: unknown }).active;
  274. if (typeof active === "string" && active.length > 0) return active;
  275. return undefined;
  276. } catch {
  277. // Missing or malformed state simply means "no persisted selection".
  278. return undefined;
  279. }
  280. }
  281. /**
  282. * Persists the selected agent name or the deactivated state.
  283. *
  284. * Input: agent name or `null`. Output: whether persistence succeeded.
  285. * Side effects: writes agent-state.json; logs write failures.
  286. */
  287. function writeState(active: string | null): boolean {
  288. try {
  289. fs.writeFileSync(stateFile(), JSON.stringify({ active }, null, 2), "utf-8");
  290. return true;
  291. } catch (err) {
  292. // Report failures so the caller can show a warning notification.
  293. console.error(`${LOG_PREFIX} failed to write state:`, err);
  294. return false;
  295. }
  296. }
  297. // ---------------------------------------------------------------------------
  298. // Agent discovery
  299. // ---------------------------------------------------------------------------
  300. /**
  301. * Reads the optional Markdown prompt referenced by an agent definition.
  302. *
  303. * Inputs: parsed prompt metadata and the definition file path. Output: trimmed
  304. * Markdown content, or an empty string when no prompt file is configured.
  305. * Side effects: reads the configured prompt file.
  306. */
  307. function readPromptFile(prompt: unknown, definitionPath: string): string {
  308. // Keep inline-only agent definitions valid when they omit the prompt field.
  309. if (typeof prompt !== "string" || !prompt.trim()) return "";
  310. // Absolute prompt paths are supported by design; relative ones resolve
  311. // against the definition that declares the prompt.
  312. const configuredPath = prompt.trim();
  313. const promptPath = path.isAbsolute(configuredPath)
  314. ? configuredPath
  315. : path.resolve(path.dirname(definitionPath), configuredPath);
  316. // Refuse non-regular files (a FIFO would block the read) and oversized ones.
  317. const stat = fs.statSync(promptPath);
  318. if (!stat.isFile() || stat.size > MAX_PROMPT_FILE_BYTES) {
  319. throw new Error(
  320. `prompt file must be a regular file of at most ${MAX_PROMPT_FILE_BYTES} bytes: ${promptPath}`,
  321. );
  322. }
  323. return fs.readFileSync(promptPath, "utf-8").trim();
  324. }
  325. /**
  326. * Discovers and normalizes agent definitions from the agents directory.
  327. *
  328. * Input: none (uses the configured agent directory). Output: name-sorted agent
  329. * definitions; empty catalog when the directory is missing.
  330. * Side effects: reads agent files from the filesystem; logs per-file failures.
  331. */
  332. function loadAgents(): AgentDef[] {
  333. // Discover the optional agent-definition directory before reading its files.
  334. const dir = agentsDir();
  335. let entries: fs.Dirent[];
  336. try {
  337. if (!fs.existsSync(dir)) {
  338. console.warn(`${LOG_PREFIX} agent definitions directory is unavailable: ${dir}`);
  339. return [];
  340. }
  341. entries = fs.readdirSync(dir, { withFileTypes: true });
  342. } catch (err) {
  343. console.error(`${LOG_PREFIX} failed to discover agent definitions in "${dir}":`, err);
  344. return [];
  345. }
  346. // Read each Markdown definition and convert it into an agent record.
  347. const agents: AgentDef[] = [];
  348. const seenNames = new Set<string>();
  349. for (const entry of entries) {
  350. if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
  351. const filePath = path.join(dir, entry.name);
  352. try {
  353. if (fs.statSync(filePath).size > MAX_PROMPT_FILE_BYTES) {
  354. throw new Error(`definition file exceeds ${MAX_PROMPT_FILE_BYTES} bytes`);
  355. }
  356. const raw = fs.readFileSync(filePath, "utf-8");
  357. const { frontmatter: meta, body: inlineBody } =
  358. parseFrontmatter<Record<string, unknown>>(raw);
  359. const promptBody = readPromptFile(meta[PROMPT_METADATA_KEY], filePath);
  360. // External prompt file first, then the inline body.
  361. const body = [promptBody, inlineBody.trim()]
  362. .filter((value) => value.length > 0)
  363. .join(FRONTMATTER_SEPARATOR);
  364. const declaredName = typeof meta.name === "string" ? meta.name.trim() : "";
  365. const name = declaredName || path.basename(entry.name, ".md") || entry.name;
  366. if (seenNames.has(name)) {
  367. console.warn(`${LOG_PREFIX} duplicate agent name "${name}" in "${filePath}", skipped`);
  368. continue;
  369. }
  370. seenNames.add(name);
  371. agents.push({
  372. filePath,
  373. name,
  374. description:
  375. typeof meta.description === "string" && meta.description
  376. ? meta.description
  377. : undefined,
  378. model: typeof meta.model === "string" ? meta.model : undefined,
  379. thinking: parseThinkingLevel(meta.thinking),
  380. tools: parseToolList(meta.tools),
  381. excludeTools: parseToolList(meta.excludeTools ?? meta.exclude_tools),
  382. body,
  383. });
  384. } catch (err) {
  385. // Isolate per-file failures so one bad definition cannot break loading.
  386. console.error(`${LOG_PREFIX} failed to load agent definition "${filePath}":`, err);
  387. }
  388. }
  389. // Return definitions in a stable order for display and completion.
  390. agents.sort((first, second) => first.name.localeCompare(second.name));
  391. return agents;
  392. }
  393. // ---------------------------------------------------------------------------
  394. // Command rendering
  395. // ---------------------------------------------------------------------------
  396. /**
  397. * Builds the display-only metadata line for one agent in the list output.
  398. *
  399. * Input: agent definition. Output: bracketed metadata line, or empty string
  400. * when the agent defines no metadata.
  401. * Side effects: none.
  402. */
  403. function buildMetaLine(agent: AgentDef): string {
  404. const parts = [
  405. agent.model,
  406. agent.thinking ? `thinking:${agent.thinking}` : undefined,
  407. agent.tools ? `tools:${agent.tools.join(",")}` : undefined,
  408. agent.excludeTools ? `exclude:${agent.excludeTools.join(",")}` : undefined,
  409. ].filter((part): part is string => Boolean(part));
  410. return parts.length > 0 ? ` [${parts.join(" | ")}]` : "";
  411. }
  412. /**
  413. * Renders the `/mainagent` catalog listing.
  414. *
  415. * Inputs: discovered agents and the currently active one. Output: multi-line
  416. * listing text with active-state markers.
  417. * Side effects: none.
  418. */
  419. function renderAgentList(agents: AgentDef[], active: AgentDef | null): string {
  420. const lines = agents.map((agent) => {
  421. const marker = active && active.name === agent.name ? "●" : "○";
  422. return ` ${marker} ${agent.name}${buildMetaLine(agent)}\n ${agent.description ?? ""}`;
  423. });
  424. return `Agents (${agents.length}):\n${lines.join("\n")}`;
  425. }
  426. // ---------------------------------------------------------------------------
  427. // Definition editing (frontmatter surgery)
  428. // ---------------------------------------------------------------------------
  429. /**
  430. * Checks whether a frontmatter line continues a block-scalar value.
  431. *
  432. * Input: one frontmatter line. Output: whether it is indented content.
  433. * Side effects: none.
  434. */
  435. function isBlockValueLine(line: string): boolean {
  436. return line.length > 0 && /^\s/.test(line);
  437. }
  438. /**
  439. * Finds the extent of a frontmatter key's value: the key line plus its
  440. * continuation lines (indented content, and blank lines only when further
  441. * indented content follows before the next column-0 line). Indented `#`
  442. * comment lines are consumed too: they are indistinguishable from folded
  443. * scalar content, so replacements drop them (accepted tradeoff).
  444. *
  445. * Inputs: frontmatter lines and the key line index. Output: exclusive end
  446. * index of the value extent.
  447. * Side effects: none.
  448. */
  449. function findKeyExtent(lines: string[], keyIndex: number): number {
  450. let end = keyIndex + 1;
  451. while (end < lines.length) {
  452. if (isBlockValueLine(lines[end])) {
  453. end++;
  454. continue;
  455. }
  456. if (lines[end] === "") {
  457. // Blank lines belong to the block only when indented content follows.
  458. let lookahead = end + 1;
  459. while (lookahead < lines.length && lines[lookahead] === "") lookahead++;
  460. if (lookahead < lines.length && isBlockValueLine(lines[lookahead])) {
  461. end = lookahead + 1;
  462. continue;
  463. }
  464. }
  465. break;
  466. }
  467. return end;
  468. }
  469. /**
  470. * Replaces or inserts a single-line frontmatter key.
  471. *
  472. * Inputs: frontmatter lines, key, and scalar value. Output: none (mutates
  473. * lines). Continuation lines of a multi-line hand-authored value are consumed
  474. * so they cannot orphan and corrupt the YAML.
  475. * Side effects: none.
  476. */
  477. function setFrontmatterScalar(lines: string[], key: string, value: string): void {
  478. const index = lines.findIndex((line) => line.startsWith(`${key}:`));
  479. if (index === -1) {
  480. lines.push(`${key}: ${value}`);
  481. return;
  482. }
  483. const end = findKeyExtent(lines, index);
  484. lines.splice(index, end - index, `${key}: ${value}`);
  485. }
  486. /**
  487. * Replaces or inserts the description frontmatter key as a YAML block scalar.
  488. *
  489. * Inputs: frontmatter lines and the new description. Output: none (mutates
  490. * lines). An empty description becomes an explicit empty quoted scalar so the
  491. * loader drops the field.
  492. * Side effects: none.
  493. */
  494. function setFrontmatterDescription(lines: string[], value: string): void {
  495. const keyIndex = lines.findIndex((line) => line.startsWith(`${DESCRIPTION_METADATA_KEY}:`));
  496. const replacement = value.trim()
  497. ? [
  498. `${DESCRIPTION_METADATA_KEY}: |`,
  499. ...value.trimEnd().split("\n").map((line) => (line ? DESCRIPTION_BLOCK_INDENT + line : "")),
  500. ]
  501. : [`${DESCRIPTION_METADATA_KEY}: ""`];
  502. if (keyIndex === -1) lines.push(...replacement);
  503. else lines.splice(keyIndex, findKeyExtent(lines, keyIndex) - keyIndex, ...replacement);
  504. }
  505. /**
  506. * Builds the confirmation summary lines for pending definition updates.
  507. *
  508. * Inputs: agent definition and updates. Output: changed-field summary lines.
  509. * Side effects: none.
  510. */
  511. function buildUpdateSummary(agent: AgentDef, updates: AgentDefUpdates): string[] {
  512. const lines = [path.basename(agent.filePath)];
  513. if (updates.model !== undefined && updates.model !== agent.model) {
  514. lines.push(`model: ${agent.model ?? "—"} → ${updates.model}`);
  515. }
  516. if (updates.thinking !== undefined && updates.thinking !== agent.thinking) {
  517. lines.push(`effort: ${agent.thinking ?? "—"} → ${updates.thinking}`);
  518. }
  519. if (updates.description !== undefined) lines.push("description: updated");
  520. return lines;
  521. }
  522. /**
  523. * Applies one set of menu updates to parsed frontmatter lines.
  524. *
  525. * Inputs: frontmatter lines and updates. Output: none (mutates lines).
  526. * Side effects: none.
  527. */
  528. function applyFrontmatterUpdates(lines: string[], updates: AgentDefUpdates): void {
  529. if (updates.model !== undefined) {
  530. setFrontmatterScalar(lines, MODEL_METADATA_KEY, updates.model);
  531. }
  532. if (updates.thinking !== undefined) {
  533. setFrontmatterScalar(lines, THINKING_METADATA_KEY, updates.thinking);
  534. }
  535. if (updates.description !== undefined) {
  536. setFrontmatterDescription(lines, updates.description);
  537. }
  538. }
  539. /**
  540. * Checks whether the loader would parse frontmatter that this writer's
  541. * strict opening delimiter (`---` + line ending) cannot rewrite.
  542. *
  543. * Input: file contents without a BOM. Output: true when the loader sees a
  544. * non-empty frontmatter block this writer cannot match (e.g. a `----` or
  545. * `--- ` opener); an empty payload is body-only for the loader too.
  546. * Side effects: none.
  547. */
  548. function hasUnsupportedFrontmatter(content: string): boolean {
  549. // Mirror the loader's own detection: any "---"-prefixed first line plus a
  550. // "\n---" closer, with a non-empty YAML payload between them.
  551. const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
  552. if (!normalized.startsWith(FRONTMATTER_BOUNDARY)) return false;
  553. const closerIndex = normalized.indexOf(`\n${FRONTMATTER_BOUNDARY}`, FRONTMATTER_BOUNDARY.length);
  554. return (
  555. closerIndex !== -1 &&
  556. normalized.slice(FRONTMATTER_BOUNDARY.length + 1, closerIndex) !== ""
  557. );
  558. }
  559. /**
  560. * Applies definition updates to one agent `.md` file without touching its body.
  561. *
  562. * Inputs: raw file contents and the updates to apply. Output: rewritten file
  563. * contents with an updated frontmatter block.
  564. * Side effects: none (pure string transform).
  565. */
  566. function updateFrontmatterFields(raw: string, updates: AgentDefUpdates): string {
  567. // Keep a UTF-8 BOM out of the rewritten frontmatter and re-emit it first.
  568. const bom = raw.startsWith(UTF8_BOM) ? UTF8_BOM : "";
  569. const content = bom ? raw.slice(UTF8_BOM.length) : raw;
  570. const match = /^---(\r?\n)([\s\S]*?)\r?\n---/.exec(content);
  571. // Refuse openers this writer cannot rewrite instead of demoting the
  572. // loader-visible frontmatter into the persona body; body-only files get a
  573. // fresh block prepended instead of guessed-at repairs.
  574. if (!match) {
  575. if (hasUnsupportedFrontmatter(content)) {
  576. throw new Error("unsupported frontmatter opening delimiter");
  577. }
  578. const freshEol = content.includes("\r\n") ? "\r\n" : "\n";
  579. const fresh: string[] = [];
  580. applyFrontmatterUpdates(fresh, updates);
  581. return bom + [FRONTMATTER_BOUNDARY, ...fresh, FRONTMATTER_BOUNDARY, "", content].join(freshEol);
  582. }
  583. // Rebuild only the frontmatter using its own line ending; the body below
  584. // the closing delimiter is preserved byte-for-byte.
  585. const lines = match[2].split(/\r?\n/);
  586. applyFrontmatterUpdates(lines, updates);
  587. return (
  588. bom +
  589. [FRONTMATTER_BOUNDARY, ...lines, FRONTMATTER_BOUNDARY].join(match[1]) +
  590. content.slice(match[0].length)
  591. );
  592. }
  593. // ---------------------------------------------------------------------------
  594. // Interactive menu (TUI dialogs)
  595. // ---------------------------------------------------------------------------
  596. /** Configuration for one framed SelectList dialog. */
  597. interface SelectDialogSpec<T> {
  598. /** Dialog title rendered above the list. */
  599. title: string;
  600. /** Selectable items. */
  601. items: SelectItem[];
  602. /** Keymap hint rendered below the list. */
  603. help: string;
  604. /** Maps the selected item value to the dialog result. */
  605. resolve: (value: string) => T;
  606. /** Extra lines re-evaluated on every render (e.g. the live effort value). */
  607. dynamicLines?: (theme: Theme) => string[];
  608. /** Consumes raw input before the list sees it; returns true when handled. */
  609. onRawInput?: (data: string) => boolean;
  610. /** Item value preselected when the dialog opens. */
  611. initialSelection?: string;
  612. /** Notified whenever the highlighted item changes (arrow keys, clicks). */
  613. onSelectionChange?: (value: string) => void;
  614. }
  615. /**
  616. * Builds the `provider/id` identifier used as list label and persisted value.
  617. *
  618. * Input: model. Output: its canonical identifier. Side effects: none.
  619. */
  620. function modelIdentifier(model: Model<Api>): string {
  621. return `${model.provider}${MODEL_ID_SEPARATOR}${model.id}`;
  622. }
  623. /**
  624. * Lists the models selectable in the menu, with their metadata.
  625. *
  626. * Input: extension context. Output: deduplicated (by identifier), sorted
  627. * model objects; scoped models take precedence over the full catalogue.
  628. * Side effects: none.
  629. */
  630. function buildModelChoices(ctx: ExtensionContext): Model<Api>[] {
  631. const models: readonly Model<Api>[] =
  632. ctx.scopedModels.length > 0
  633. ? ctx.scopedModels.map((scoped) => scoped.model)
  634. : ctx.modelRegistry.getAvailable();
  635. const byIdentifier = new Map<string, Model<Api>>(
  636. models.map((model): [string, Model<Api>] => [modelIdentifier(model), model]),
  637. );
  638. return [...byIdentifier.keys()].sort().map((identifier) => byIdentifier.get(identifier)!);
  639. }
  640. /**
  641. * Builds the shared visual theme for menu SelectLists.
  642. *
  643. * Input: extension theme. Output: SelectList theme callbacks.
  644. * Side effects: none.
  645. */
  646. function buildSelectListTheme(theme: Theme): SelectListTheme {
  647. return {
  648. selectedPrefix: (text) => theme.fg("accent", text),
  649. selectedText: (text) => theme.fg("accent", text),
  650. description: (text) => theme.fg("muted", text),
  651. scrollInfo: (text) => theme.fg("dim", text),
  652. noMatch: (text) => theme.fg("warning", text),
  653. };
  654. }
  655. /**
  656. * Shows one framed SelectList dialog and resolves the selection.
  657. *
  658. * Inputs: extension context and dialog configuration. Output: resolved
  659. * selection, or null when cancelled.
  660. * Side effects: renders a modal dialog with keyboard focus.
  661. */
  662. function showSelectDialog<T>(
  663. ctx: ExtensionContext,
  664. spec: SelectDialogSpec<T>,
  665. ): Promise<T | null> {
  666. return ctx.ui.custom<T | null>((tui, theme, _keybindings, done) => {
  667. const topBorder = new DynamicBorder((line) => theme.fg("accent", line));
  668. const bottomBorder = new DynamicBorder((line) => theme.fg("accent", line));
  669. const titleText = new Text(theme.fg("accent", theme.bold(spec.title)), 1, 0);
  670. const helpText = new Text(theme.fg("dim", spec.help), 1, 0);
  671. const selectList = new SelectList(spec.items, MAX_VISIBLE_MENU_ITEMS, buildSelectListTheme(theme));
  672. selectList.onSelect = (item) => done(spec.resolve(item.value));
  673. selectList.onCancel = () => done(null);
  674. selectList.onSelectionChange = (item) => spec.onSelectionChange?.(item.value);
  675. if (spec.initialSelection !== undefined) {
  676. const initialIndex = spec.items.findIndex(
  677. (item) => item.value === spec.initialSelection,
  678. );
  679. if (initialIndex >= 0) selectList.setSelectedIndex(initialIndex);
  680. }
  681. return {
  682. render: (width) => [
  683. ...topBorder.render(width),
  684. ...titleText.render(width),
  685. ...(spec.dynamicLines ? spec.dynamicLines(theme) : []),
  686. ...selectList.render(width),
  687. ...helpText.render(width),
  688. ...bottomBorder.render(width),
  689. ],
  690. invalidate: () => {
  691. selectList.invalidate();
  692. titleText.invalidate();
  693. helpText.invalidate();
  694. },
  695. handleInput: (data: string) => {
  696. if (!spec.onRawInput?.(data)) selectList.handleInput(data);
  697. tui.requestRender();
  698. },
  699. };
  700. });
  701. }
  702. /**
  703. * Shows the agent selection dialog.
  704. *
  705. * Inputs: extension context, discovered agents, and the active one. Output:
  706. * chosen agent name, or null when cancelled.
  707. * Side effects: renders a modal dialog.
  708. */
  709. function showAgentPicker(
  710. ctx: ExtensionContext,
  711. agents: readonly AgentDef[],
  712. active: AgentDef | null,
  713. ): Promise<string | null> {
  714. const items: SelectItem[] = agents.map((agent) => ({
  715. value: agent.name,
  716. label: agent.name,
  717. description: [
  718. active === agent ? "● active" : undefined,
  719. `model: ${agent.model ?? "—"}`,
  720. `effort: ${agent.thinking ?? "—"}`,
  721. ]
  722. .filter((part): part is string => Boolean(part))
  723. .join(" · "),
  724. }));
  725. return showSelectDialog(ctx, {
  726. title: "Main agent",
  727. items,
  728. help: "↑↓ select · enter open · esc cancel",
  729. resolve: (value) => value,
  730. });
  731. }
  732. /**
  733. * Shows the per-agent action menu.
  734. *
  735. * Inputs: extension context and the agent being managed. Output: chosen action
  736. * label, or null when cancelled.
  737. * Side effects: renders a modal dialog.
  738. */
  739. function showAgentActions(ctx: ExtensionContext, agent: AgentDef): Promise<string | null> {
  740. const title = `${agent.name} — model: ${agent.model ?? "—"} · effort: ${agent.thinking ?? "—"}`;
  741. return showSelectDialog(ctx, {
  742. title,
  743. items: [
  744. {
  745. value: ACTION_CHANGE_MODEL,
  746. label: ACTION_CHANGE_MODEL,
  747. description: "←→ cycles the effort while picking the model",
  748. },
  749. { value: ACTION_EDIT_DESCRIPTION, label: ACTION_EDIT_DESCRIPTION },
  750. { value: ACTION_SWITCH, label: ACTION_SWITCH },
  751. { value: ACTION_BACK, label: ACTION_BACK },
  752. ],
  753. help: "↑↓ select · enter confirm · esc cancel",
  754. resolve: (value) => value,
  755. });
  756. }
  757. /**
  758. * Shows the combined model and effort picker: ↑↓ picks the model, ←→ cycles
  759. * the effort level shown live between the title and the list. Only the
  760. * levels supported by the highlighted model are offered, and the current
  761. * effort is clamped to them via the same SDK helpers the session runtime
  762. * uses for setThinkingLevel, so a saved pair never silently diverges at
  763. * runtime.
  764. *
  765. * Inputs: extension context and the agent being edited. Output: the chosen
  766. * model/effort pair, or null when cancelled.
  767. * Side effects: renders a modal dialog; notifies when no model is available.
  768. */
  769. function showModelEffortPicker(
  770. ctx: ExtensionContext,
  771. agent: AgentDef,
  772. ): Promise<ModelEffortChoice | null> {
  773. const models = buildModelChoices(ctx);
  774. if (models.length === 0) {
  775. ctx.ui.notify("No models available to pick from.", "warning");
  776. return Promise.resolve(null);
  777. }
  778. const initialModel =
  779. models.find((model) => modelIdentifier(model) === agent.model) ?? models[0];
  780. let currentModel: Model<Api> = initialModel;
  781. let effort: ThinkingLevel = clampThinkingLevel(
  782. initialModel,
  783. agent.thinking ?? DEFAULT_EFFORT,
  784. );
  785. return showSelectDialog<ModelEffortChoice>(ctx, {
  786. title: `Model & effort — ${agent.name}`,
  787. items: models.map((model) => {
  788. const identifier = modelIdentifier(model);
  789. return {
  790. value: identifier,
  791. label: identifier,
  792. description: identifier === agent.model ? CURRENT_MODEL_LABEL : undefined,
  793. };
  794. }),
  795. initialSelection: modelIdentifier(initialModel),
  796. help: "↑↓ model · ←→ effort · enter save · esc cancel",
  797. onSelectionChange: (identifier) => {
  798. const model = models.find((entry) => modelIdentifier(entry) === identifier);
  799. if (!model) return;
  800. currentModel = model;
  801. effort = clampThinkingLevel(model, effort);
  802. },
  803. resolve: (identifier) => {
  804. const model =
  805. models.find((entry) => modelIdentifier(entry) === identifier) ?? currentModel;
  806. return { model: identifier, effort: clampThinkingLevel(model, effort) };
  807. },
  808. dynamicLines: (theme) => [
  809. "",
  810. theme.fg("accent", ` effort: ‹ ${effort} ›`),
  811. ],
  812. onRawInput: (data) => {
  813. const isLeft = matchesKey(data, "left");
  814. const isRight = matchesKey(data, "right");
  815. if (!isLeft && !isRight) return false;
  816. const levels = getSupportedThinkingLevels(currentModel);
  817. const index = Math.max(0, levels.indexOf(effort));
  818. const direction = isRight ? 1 : -1;
  819. effort = levels[(index + direction + levels.length) % levels.length];
  820. return true;
  821. },
  822. });
  823. }
  824. // ---------------------------------------------------------------------------
  825. // Extension
  826. // ---------------------------------------------------------------------------
  827. /**
  828. * Registers the main-agent selector features with Pi.
  829. *
  830. * Input: Pi extension API. Output: none; command and lifecycle handlers register.
  831. * Side effects: reads agent files, persists selections, updates the status
  832. * widget, and appends the persona body to the system prompt each turn.
  833. */
  834. function registerMainAgentFeatures(pi: ExtensionAPI): void {
  835. // Initialize discovered definitions, the mutable active selection, and the
  836. // session runtime baseline captured before the first agent setting.
  837. const agents = loadAgents();
  838. let active: AgentDef | null = null;
  839. let runtimeBaseline: {
  840. activeTools: string[];
  841. thinkingLevel: ThinkingLevel;
  842. model: ExtensionContext["model"];
  843. } | null = null;
  844. /**
  845. * Finds a discovered agent by its configured name (exact match first, then
  846. * case-insensitive fallback for typed input).
  847. *
  848. * Input: agent name. Output: matching definition, if any.
  849. * Side effects: none.
  850. */
  851. const findAgent = (name: string): AgentDef | undefined =>
  852. agents.find((agent) => agent.name === name) ??
  853. agents.find((agent) => agent.name.toLowerCase() === name.toLowerCase());
  854. /**
  855. * Updates the TUI widget with the active agent name.
  856. *
  857. * Input: extension context. Output: none.
  858. * Side effects: replaces the status widget content.
  859. */
  860. const updateAgentStatus = (ctx: ExtensionContext): void => {
  861. ctx.ui.setWidget(
  862. WIDGET_ID,
  863. [`Main Agent: ${active?.name ?? "none"}`],
  864. { placement: "belowEditor" },
  865. );
  866. };
  867. /**
  868. * Captures runtime settings before the first agent setting is applied.
  869. *
  870. * Input: extension context. Output: whether a baseline is available.
  871. * Side effects: reads Pi runtime settings and stores them in session state.
  872. */
  873. const captureBaseline = (ctx: ExtensionContext): boolean => {
  874. // Preserve an existing baseline across agent changes in the session.
  875. if (runtimeBaseline) return true;
  876. try {
  877. // Snapshot the tools, thinking level, and model from the active runtime.
  878. runtimeBaseline = {
  879. activeTools: [...pi.getActiveTools()],
  880. thinkingLevel: pi.getThinkingLevel(),
  881. model: ctx.model,
  882. };
  883. return true;
  884. } catch (err) {
  885. console.error(`${LOG_PREFIX} failed to capture runtime baseline:`, err);
  886. return false;
  887. }
  888. };
  889. /**
  890. * Restores the runtime settings captured before agent activation.
  891. *
  892. * Input: stored runtime baseline. Output: whether all settings were restored.
  893. * Side effects: changes Pi tools, thinking level, and model.
  894. */
  895. const restoreBaseline = async (): Promise<boolean> => {
  896. // Return immediately when no agent settings altered this runtime.
  897. const baseline = runtimeBaseline;
  898. if (!baseline) return true;
  899. // Track partial restore failures while attempting every setting.
  900. let restored = true;
  901. try {
  902. pi.setActiveTools([...baseline.activeTools]);
  903. } catch (err) {
  904. restored = false;
  905. console.error(`${LOG_PREFIX} failed to restore active tools:`, err);
  906. }
  907. // Restore the original model only when a baseline model was available.
  908. if (baseline.model) {
  909. try {
  910. const ok = await pi.setModel(baseline.model);
  911. if (!ok) {
  912. restored = false;
  913. console.error(`${LOG_PREFIX} failed to restore model: setter returned false`);
  914. }
  915. } catch (err) {
  916. restored = false;
  917. console.error(`${LOG_PREFIX} failed to restore model:`, err);
  918. }
  919. }
  920. try {
  921. pi.setThinkingLevel(baseline.thinkingLevel);
  922. } catch (err) {
  923. restored = false;
  924. console.error(`${LOG_PREFIX} failed to restore thinking level:`, err);
  925. }
  926. // Clear the snapshot only after all original settings were restored.
  927. if (restored) runtimeBaseline = null;
  928. return restored;
  929. };
  930. /**
  931. * Applies an agent's tool whitelist/blacklist to the active runtime.
  932. *
  933. * Input: agent definition. Output: summary of the applied filters, if any.
  934. * Side effects: changes Pi's active tools.
  935. */
  936. const applyAgentTools = (agent: AgentDef): string | undefined => {
  937. const registeredToolNames = listRegisteredToolNames(pi);
  938. const whitelist =
  939. agent.tools && agent.tools.length > 0
  940. ? expandToolPatterns(agent.tools, registeredToolNames)
  941. : undefined;
  942. const excludedTools = agent.excludeTools
  943. ? expandToolPatterns(agent.excludeTools, registeredToolNames)
  944. : [];
  945. if (!whitelist && excludedTools.length === 0) return undefined;
  946. // With only a blacklist, start from the active tools so existing runtime
  947. // restrictions survive.
  948. const baseTools = whitelist ?? pi.getActiveTools();
  949. pi.setActiveTools(baseTools.filter((toolName) => !excludedTools.includes(toolName)));
  950. const toolsLabel =
  951. whitelist && agent.tools
  952. ? whitelist.length === agent.tools.length
  953. ? agent.tools.join(",")
  954. : `${agent.tools.join(",")} → ${whitelist.length} tools`
  955. : undefined;
  956. const excludeLabel =
  957. excludedTools.length > 0 ? `exclude: ${excludedTools.join(",")}` : undefined;
  958. return [toolsLabel, excludeLabel]
  959. .filter((label): label is string => Boolean(label))
  960. .join(" | ");
  961. };
  962. /**
  963. * Resolves and applies an agent's provider/model identifier to this session.
  964. *
  965. * Inputs: agent definition and extension context. Output: summary label when
  966. * the model was applied, otherwise undefined.
  967. * Side effects: changes Pi's model; notifies the TUI on failures.
  968. */
  969. const applyAgentModel = async (
  970. agent: AgentDef,
  971. ctx: ExtensionContext,
  972. ): Promise<string | undefined> => {
  973. const configured = agent.model;
  974. if (!configured) return undefined;
  975. const separatorIndex = configured.indexOf(MODEL_ID_SEPARATOR);
  976. if (separatorIndex === -1) {
  977. ctx.ui.notify(
  978. `Agent "${agent.name}": model "${configured}" must use the provider${MODEL_ID_SEPARATOR}model form`,
  979. "warning",
  980. );
  981. return undefined;
  982. }
  983. const provider = configured.slice(0, separatorIndex);
  984. const modelId = configured.slice(separatorIndex + MODEL_ID_SEPARATOR.length);
  985. const model = ctx.modelRegistry.find(provider, modelId);
  986. if (!model) {
  987. ctx.ui.notify(
  988. `Agent "${agent.name}": model "${configured}" not found in the registry`,
  989. "warning",
  990. );
  991. return undefined;
  992. }
  993. try {
  994. if (!(await pi.setModel(model))) {
  995. console.error(`${LOG_PREFIX} setModel returned false`);
  996. ctx.ui.notify(`Agent "${agent.name}": unable to set model ${configured}`, "warning");
  997. return undefined;
  998. }
  999. return `model: ${configured}`;
  1000. } catch (err) {
  1001. console.error(`${LOG_PREFIX} setModel failed:`, err);
  1002. ctx.ui.notify(`Agent "${agent.name}": unable to set model ${configured}`, "warning");
  1003. return undefined;
  1004. }
  1005. };
  1006. /**
  1007. * Applies an agent's model, thinking level, and tool filters to this session.
  1008. *
  1009. * Inputs: agent definition and extension context. Output: descriptions of the
  1010. * settings that were applied successfully.
  1011. * Side effects: changes Pi tools, thinking level, and model; notifies the TUI
  1012. * on partial failures.
  1013. */
  1014. const applyAgentRuntime = async (
  1015. agent: AgentDef,
  1016. ctx: ExtensionContext,
  1017. ): Promise<string[]> => {
  1018. const applied: string[] = [];
  1019. // Apply the optional tool whitelist and blacklist first.
  1020. try {
  1021. const toolsSummary = applyAgentTools(agent);
  1022. if (toolsSummary) applied.push(toolsSummary);
  1023. } catch (err) {
  1024. console.error(`${LOG_PREFIX} setActiveTools failed:`, err);
  1025. }
  1026. // Resolve and apply the optional provider/model identifier first:
  1027. // setModel overwrites the thinking level with the per-model or global
  1028. // default, so the agent's effort must be applied after the model switch.
  1029. if (agent.model) {
  1030. const modelLabel = await applyAgentModel(agent, ctx);
  1031. if (modelLabel) applied.push(modelLabel);
  1032. }
  1033. // Apply the optional thinking level (validated at load time); the runtime
  1034. // clamps it against the model set above.
  1035. if (agent.thinking) {
  1036. try {
  1037. pi.setThinkingLevel(agent.thinking);
  1038. applied.push(`thinking: ${agent.thinking}`);
  1039. } catch (err) {
  1040. console.error(`${LOG_PREFIX} setThinkingLevel failed:`, err);
  1041. }
  1042. }
  1043. return applied;
  1044. };
  1045. /**
  1046. * Applies an agent selection or deactivation end-to-end.
  1047. *
  1048. * Inputs: agent definition or null, extension context. Output: none.
  1049. * Side effects: captures/restores the runtime baseline, updates the selection,
  1050. * the widget, the state file, and notifies the TUI.
  1051. */
  1052. const applySelection = async (agent: AgentDef | null, ctx: ExtensionContext): Promise<void> => {
  1053. // Deactivation: restore the session's pre-agent runtime settings.
  1054. if (!agent) {
  1055. if (!captureBaseline(ctx)) {
  1056. ctx.ui.notify("Unable to capture the pre-agent runtime state", "error");
  1057. return;
  1058. }
  1059. active = null;
  1060. updateAgentStatus(ctx);
  1061. const persisted = writeState(null);
  1062. const restored = await restoreBaseline();
  1063. if (persisted && restored) {
  1064. ctx.ui.notify("Main agent deactivated.", "info");
  1065. } else if (!persisted && !restored) {
  1066. ctx.ui.notify(
  1067. "Main agent deactivated, but state persistence and runtime restore are incomplete.",
  1068. "warning",
  1069. );
  1070. } else if (!persisted) {
  1071. ctx.ui.notify("Main agent deactivated, but the state could not be saved.", "warning");
  1072. } else {
  1073. ctx.ui.notify("Main agent deactivated, but the runtime restore is incomplete.", "warning");
  1074. }
  1075. return;
  1076. }
  1077. // Activation: stop before changing settings if the original runtime cannot
  1078. // be preserved for a later restore.
  1079. if (!captureBaseline(ctx)) {
  1080. ctx.ui.notify(
  1081. `Agent "${agent.name}": unable to capture the pre-agent runtime state`,
  1082. "error",
  1083. );
  1084. return;
  1085. }
  1086. active = agent;
  1087. updateAgentStatus(ctx);
  1088. const persisted = writeState(agent.name);
  1089. const applied = await applyAgentRuntime(agent, ctx);
  1090. const runtimeLabel = applied.length > 0 ? ` [${applied.join(" | ")}]` : "";
  1091. if (persisted) {
  1092. ctx.ui.notify(`Active agent: ${agent.name}${runtimeLabel}`, "info");
  1093. } else {
  1094. ctx.ui.notify(
  1095. `Active agent: ${agent.name}${runtimeLabel} (state could not be saved)`,
  1096. "warning",
  1097. );
  1098. }
  1099. };
  1100. /**
  1101. * Persists definition updates end-to-end: confirms with the user, rewrites
  1102. * the `.md` frontmatter, mutates the in-memory definition, and live-applies
  1103. * runtime changes when the edited agent is the active one.
  1104. *
  1105. * Inputs: agent definition, updates, extension context. Output: none.
  1106. * Side effects: writes the definition file, updates the in-memory catalog,
  1107. * may change Pi's runtime model/thinking; notifies the TUI.
  1108. */
  1109. const saveAgentUpdates = async (
  1110. agent: AgentDef,
  1111. updates: AgentDefUpdates,
  1112. ctx: ExtensionContext,
  1113. ): Promise<void> => {
  1114. const summary = buildUpdateSummary(agent, updates).join("\n");
  1115. const confirmed = await ctx.ui.confirm(
  1116. "Write changes to the agent definition?",
  1117. summary,
  1118. );
  1119. if (!confirmed) return;
  1120. // Write the file first; only a successful write updates the session state.
  1121. try {
  1122. const raw = fs.readFileSync(agent.filePath, "utf-8");
  1123. fs.writeFileSync(agent.filePath, updateFrontmatterFields(raw, updates), "utf-8");
  1124. } catch (err) {
  1125. console.error(`${LOG_PREFIX} failed to update agent definition "${agent.filePath}":`, err);
  1126. ctx.ui.notify(
  1127. `Unable to update ${path.basename(agent.filePath)}: definition unchanged`,
  1128. "error",
  1129. );
  1130. return;
  1131. }
  1132. // Keep the in-memory catalog coherent with the file for this session.
  1133. if (updates.model !== undefined) agent.model = updates.model;
  1134. if (updates.thinking !== undefined) agent.thinking = updates.thinking;
  1135. if (updates.description !== undefined) {
  1136. agent.description = updates.description.trim() || undefined;
  1137. }
  1138. // Live-apply model/effort edits when the edited agent is the active one;
  1139. // description edits need no runtime change.
  1140. if (active === agent && (updates.model !== undefined || updates.thinking !== undefined)) {
  1141. const applied = await applyAgentRuntime(agent, ctx);
  1142. const runtimeLabel = applied.length > 0 ? ` [${applied.join(" | ")}]` : "";
  1143. ctx.ui.notify(`Updated "${agent.name}"${runtimeLabel}`, "info");
  1144. return;
  1145. }
  1146. ctx.ui.notify(`Updated "${agent.name}" (${path.basename(agent.filePath)})`, "info");
  1147. };
  1148. /**
  1149. * Runs the combined model/effort picker and saves the resulting changes.
  1150. *
  1151. * Inputs: agent definition and extension context. Output: none.
  1152. * Side effects: opens the picker dialog; may write the definition file and
  1153. * change the runtime; notifies the TUI.
  1154. */
  1155. const handleModelEffortChange = async (
  1156. agent: AgentDef,
  1157. ctx: ExtensionContext,
  1158. ): Promise<void> => {
  1159. const choice = await showModelEffortPicker(ctx, agent);
  1160. if (!choice) return;
  1161. const updates: AgentDefUpdates = {};
  1162. if (choice.model !== agent.model) updates.model = choice.model;
  1163. if (choice.effort !== agent.thinking) updates.thinking = choice.effort;
  1164. if (updates.model === undefined && updates.thinking === undefined) {
  1165. ctx.ui.notify("No changes to save.", "info");
  1166. return;
  1167. }
  1168. await saveAgentUpdates(agent, updates, ctx);
  1169. };
  1170. /**
  1171. * Runs the description editor and saves the resulting changes.
  1172. *
  1173. * Inputs: agent definition and extension context. Output: none.
  1174. * Side effects: opens the editor dialog; may write the definition file and
  1175. * notify the TUI.
  1176. */
  1177. const handleDescriptionChange = async (
  1178. agent: AgentDef,
  1179. ctx: ExtensionContext,
  1180. ): Promise<void> => {
  1181. const value = await ctx.ui.editor(
  1182. `Edit description — ${agent.name}`,
  1183. (agent.description ?? "").trim(),
  1184. );
  1185. if (value === undefined) return;
  1186. if (value.trim() === (agent.description ?? "").trim()) {
  1187. ctx.ui.notify("No changes to save.", "info");
  1188. return;
  1189. }
  1190. await saveAgentUpdates(agent, { description: value }, ctx);
  1191. };
  1192. /**
  1193. * Runs the interactive agent menu loop shown by a bare `/mainagent`
  1194. * (TUI only).
  1195. *
  1196. * Input: extension context. Output: none.
  1197. * Side effects: opens dialogs; may save definition edits and switch agents.
  1198. */
  1199. const runAgentMenu = async (ctx: ExtensionContext): Promise<void> => {
  1200. let name = await showAgentPicker(ctx, agents, active);
  1201. while (name !== null) {
  1202. const agent = findAgent(name);
  1203. if (!agent) return;
  1204. const action = await showAgentActions(ctx, agent);
  1205. if (action === null) return;
  1206. if (action === ACTION_SWITCH) {
  1207. await applySelection(agent, ctx);
  1208. return;
  1209. }
  1210. if (action === ACTION_BACK) {
  1211. name = await showAgentPicker(ctx, agents, active);
  1212. continue;
  1213. }
  1214. if (action === ACTION_CHANGE_MODEL) await handleModelEffortChange(agent, ctx);
  1215. else if (action === ACTION_EDIT_DESCRIPTION) await handleDescriptionChange(agent, ctx);
  1216. }
  1217. };
  1218. // Restore a persisted selection at registration when the agent still exists.
  1219. const persisted = readState();
  1220. if (typeof persisted === "string") {
  1221. active = findAgent(persisted) ?? null;
  1222. if (!active) {
  1223. console.warn(
  1224. `${LOG_PREFIX} state references unknown agent "${persisted}", ignoring`,
  1225. );
  1226. }
  1227. }
  1228. pi.on("session_start", async (_event, ctx) => {
  1229. // Re-apply a persisted agent to this session's runtime without
  1230. // re-persisting the selection: another Pi instance may have deactivated
  1231. // the agent since this state was written.
  1232. if (active) {
  1233. if (captureBaseline(ctx)) {
  1234. await applyAgentRuntime(active, ctx);
  1235. } else {
  1236. ctx.ui.notify("Unable to capture the pre-agent runtime state", "error");
  1237. }
  1238. }
  1239. updateAgentStatus(ctx);
  1240. });
  1241. pi.on("session_shutdown", async () => {
  1242. // Give the session back its pre-agent runtime settings.
  1243. await restoreBaseline();
  1244. });
  1245. pi.on("before_agent_start", (event, _ctx) => {
  1246. // Reapply the tool filters; other code may have changed tools mid-session.
  1247. if (
  1248. active &&
  1249. ((active.tools && active.tools.length > 0) ||
  1250. (active.excludeTools && active.excludeTools.length > 0))
  1251. ) {
  1252. try {
  1253. applyAgentTools(active);
  1254. } catch (err) {
  1255. console.error(`${LOG_PREFIX} per-turn tool reapply failed:`, err);
  1256. }
  1257. }
  1258. // Keep the unmodified Pi prompt when no persona body is active.
  1259. if (!active || !active.body) return;
  1260. return { systemPrompt: event.systemPrompt + FRONTMATTER_SEPARATOR + active.body };
  1261. });
  1262. pi.registerCommand(COMMAND_NAME, {
  1263. description:
  1264. "Show or change the active agent. No arguments: interactive menu (TUI). Usage: /mainagent [name|off]. E.g. /mainagent gv-dev",
  1265. /**
  1266. * Provides agent names that begin with the command argument prefix.
  1267. *
  1268. * Input: completion prefix. Output: matching completion items.
  1269. * Side effects: none.
  1270. */
  1271. getArgumentCompletions: (prefix: string) => {
  1272. const lowerPrefix = prefix.toLowerCase();
  1273. const agentItems = agents
  1274. .filter((agent) => agent.name.toLowerCase().startsWith(lowerPrefix))
  1275. .map((agent) => ({
  1276. value: agent.name,
  1277. label: agent.name,
  1278. description: agent.description,
  1279. }));
  1280. // Offer the deactivation keyword unless a real agent claims that name.
  1281. const offItems =
  1282. DEACTIVATION_KEYWORD.startsWith(lowerPrefix) && !findAgent(DEACTIVATION_KEYWORD)
  1283. ? [
  1284. {
  1285. value: DEACTIVATION_KEYWORD,
  1286. label: DEACTIVATION_KEYWORD,
  1287. description: "Deactivate persona",
  1288. },
  1289. ]
  1290. : [];
  1291. return [...agentItems, ...offItems];
  1292. },
  1293. /**
  1294. * Lists, activates, or deactivates the agent selected by `/mainagent`.
  1295. *
  1296. * Inputs: command arguments and extension context. Output: none.
  1297. * Side effects: changes the active persona, persists the selection, updates
  1298. * the widget, and notifies the TUI.
  1299. */
  1300. handler: async (args: string, ctx: ExtensionContext) => {
  1301. // Normalize the optional command argument before branching on intent.
  1302. const arg = args.trim();
  1303. // No argument: interactive menu in the TUI, static listing elsewhere.
  1304. if (!arg) {
  1305. if (agents.length === 0) {
  1306. ctx.ui.notify(
  1307. `No agents found. Create files in ${agentsDir()}${path.sep}*.md`,
  1308. "info",
  1309. );
  1310. return;
  1311. }
  1312. if (ctx.mode === "tui") {
  1313. await runAgentMenu(ctx);
  1314. return;
  1315. }
  1316. ctx.ui.notify(renderAgentList(agents, active), "info");
  1317. return;
  1318. }
  1319. // Resolve the requested agent definition by name; real agents win over
  1320. // the deactivation aliases.
  1321. const agent = findAgent(arg);
  1322. if (!agent && arg.toLowerCase() === DEACTIVATION_KEYWORD) {
  1323. await applySelection(null, ctx);
  1324. return;
  1325. }
  1326. if (!agent) {
  1327. ctx.ui.notify(
  1328. `Agent "${arg}" not found. Available: ${agents.map((a) => a.name).join(", ")}`,
  1329. "error",
  1330. );
  1331. return;
  1332. }
  1333. // Activate the persona and apply its runtime settings.
  1334. await applySelection(agent, ctx);
  1335. },
  1336. });
  1337. }
  1338. /** Registers every pi-mainagent capability from a single extension entry point. */
  1339. export default function piMainagentExtension(pi: ExtensionAPI): void {
  1340. registerMainAgentFeatures(pi);
  1341. }