@oh-my-pi/pi-coding-agent 15.11.1 → 15.11.2

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 (59) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/dist/cli.js +629 -614
  3. package/dist/types/config/settings-schema.d.ts +36 -0
  4. package/dist/types/extensibility/custom-commands/types.d.ts +6 -3
  5. package/dist/types/extensibility/custom-tools/loader.d.ts +2 -1
  6. package/dist/types/extensibility/custom-tools/types.d.ts +8 -4
  7. package/dist/types/extensibility/extensions/types.d.ts +2 -2
  8. package/dist/types/extensibility/hooks/types.d.ts +8 -4
  9. package/dist/types/irc/bus.d.ts +15 -2
  10. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  11. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  12. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  13. package/dist/types/modes/rpc/rpc-types.d.ts +30 -0
  14. package/dist/types/modes/theme/theme.d.ts +1 -1
  15. package/dist/types/session/agent-session.d.ts +17 -3
  16. package/dist/types/slash-commands/available-commands.d.ts +34 -0
  17. package/dist/types/tools/bash.d.ts +1 -1
  18. package/dist/types/tools/browser/attach.d.ts +4 -4
  19. package/dist/types/tools/browser/registry.d.ts +1 -0
  20. package/dist/types/tools/irc.d.ts +3 -2
  21. package/dist/types/tools/path-utils.d.ts +0 -4
  22. package/package.json +11 -11
  23. package/src/config/settings-schema.ts +40 -0
  24. package/src/exec/bash-executor.ts +21 -6
  25. package/src/extensibility/custom-commands/loader.ts +3 -1
  26. package/src/extensibility/custom-commands/types.ts +6 -3
  27. package/src/extensibility/custom-tools/loader.ts +4 -7
  28. package/src/extensibility/custom-tools/types.ts +8 -4
  29. package/src/extensibility/extensions/loader.ts +2 -1
  30. package/src/extensibility/extensions/types.ts +2 -2
  31. package/src/extensibility/hooks/loader.ts +3 -1
  32. package/src/extensibility/hooks/types.ts +8 -4
  33. package/src/internal-urls/docs-index.generated.ts +4 -4
  34. package/src/irc/bus.ts +14 -3
  35. package/src/lsp/defaults.json +6 -0
  36. package/src/lsp/render.ts +2 -28
  37. package/src/memories/index.ts +2 -0
  38. package/src/modes/acp/acp-agent.ts +4 -67
  39. package/src/modes/components/plan-review-overlay.ts +32 -3
  40. package/src/modes/controllers/streaming-reveal.ts +16 -8
  41. package/src/modes/interactive-mode.ts +32 -0
  42. package/src/modes/rpc/rpc-client.ts +32 -0
  43. package/src/modes/rpc/rpc-mode.ts +82 -7
  44. package/src/modes/rpc/rpc-types.ts +23 -0
  45. package/src/modes/theme/theme.ts +7 -7
  46. package/src/modes/utils/ui-helpers.ts +13 -4
  47. package/src/prompts/memories/consolidation_system.md +4 -0
  48. package/src/prompts/system/irc-autoreply.md +6 -0
  49. package/src/prompts/system/irc-incoming.md +1 -1
  50. package/src/prompts/tools/bash.md +1 -0
  51. package/src/prompts/tools/irc.md +1 -1
  52. package/src/session/agent-session.ts +95 -6
  53. package/src/slash-commands/available-commands.ts +105 -0
  54. package/src/tools/bash.ts +5 -1
  55. package/src/tools/browser/attach.ts +26 -7
  56. package/src/tools/browser/registry.ts +11 -1
  57. package/src/tools/irc.ts +16 -4
  58. package/src/tools/job.ts +7 -3
  59. package/src/tools/path-utils.ts +22 -15
package/src/irc/bus.ts CHANGED
@@ -7,7 +7,11 @@
7
7
  * AgentLifecycleManager, idle agents are woken with a real turn, and busy
8
8
  * agents receive the message as a non-interrupting aside at the next step
9
9
  * boundary (see AgentSession.deliverIrcMessage). Replies are real turns by
10
- * the recipient, observed via `wait`.
10
+ * the recipient, observed via `wait` — with one exception: when the sender
11
+ * awaits a reply and the recipient is mid-turn with async execution
12
+ * disabled, the recipient session generates an ephemeral side-channel
13
+ * auto-reply (it may be blocked in a synchronous task spawn whose batch
14
+ * includes the sender, so a real turn could never happen in time).
11
15
  */
12
16
 
13
17
  import { logger, Snowflake } from "@oh-my-pi/pi-utils";
@@ -80,8 +84,15 @@ export class IrcBus {
80
84
  * context, so buffering it too would double-deliver via a later
81
85
  * `wait`/`inbox` and inflate unread counts. Only a failed live hand-off
82
86
  * is buffered for the recipient to drain later.
87
+ *
88
+ * `opts.expectsReply` marks sends whose caller is blocked on an answer
89
+ * (`send await:true`). It is forwarded to the recipient session so a
90
+ * mid-turn recipient that cannot reach a step boundary (async execution
91
+ * disabled — e.g. blocked in a synchronous task spawn awaiting the
92
+ * sender's own batch) can generate an ephemeral side-channel auto-reply
93
+ * instead of stranding the sender until timeout.
83
94
  */
84
- async send(msg: Omit<IrcMessage, "id" | "ts">): Promise<IrcDeliveryReceipt> {
95
+ async send(msg: Omit<IrcMessage, "id" | "ts">, opts?: { expectsReply?: boolean }): Promise<IrcDeliveryReceipt> {
85
96
  const message: IrcMessage = { ...msg, id: Snowflake.next(), ts: Date.now() };
86
97
  const ref = this.#registry.get(message.to);
87
98
  if (!ref || ref.status === "aborted") {
@@ -118,7 +129,7 @@ export class IrcBus {
118
129
  }
119
130
 
120
131
  try {
121
- const delivery = await session.deliverIrcMessage(message);
132
+ const delivery = await session.deliverIrcMessage(message, opts);
122
133
  this.#relayToMainUi(message);
123
134
  return { to: message.to, outcome: revived ? "revived" : delivery };
124
135
  } catch (error) {
@@ -248,6 +248,12 @@
248
248
  }
249
249
  }
250
250
  },
251
+ "expert": {
252
+ "command": "expert",
253
+ "args": ["--stdio"],
254
+ "fileTypes": [".ex", ".exs", ".heex", ".eex"],
255
+ "rootMarkers": ["mix.exs", "mix.lock"]
256
+ },
251
257
  "erlangls": {
252
258
  "command": "erlang_ls",
253
259
  "args": [],
package/src/lsp/render.ts CHANGED
@@ -8,9 +8,8 @@
8
8
  * - Collapsible/expandable views
9
9
  */
10
10
  import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core";
11
- import { type HighlightColors, highlightCode as nativeHighlightCode, supportsLanguage } from "@oh-my-pi/pi-natives";
12
11
  import { type Component, Text } from "@oh-my-pi/pi-tui";
13
- import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
12
+ import { getLanguageFromPath, highlightCode as highlightThemeCode, type Theme } from "../modes/theme/theme";
14
13
  import {
15
14
  formatExpandHint,
16
15
  formatMoreItems,
@@ -219,7 +218,7 @@ function renderHover(
219
218
  const beforeCode = fullText.slice(0, codeStart).trimEnd();
220
219
  const afterCode = fullText.slice(fullText.indexOf("```", 3) + 3).trim();
221
220
 
222
- const codeLines = highlightCode(code, lang, theme);
221
+ const codeLines = highlightThemeCode(code, lang, theme);
223
222
  const icon = theme.styledSymbol("status.info", "accent");
224
223
  const langLabel = lang ? theme.fg("mdCodeBlockBorder", ` ${lang}`) : "";
225
224
 
@@ -274,31 +273,6 @@ function renderHover(
274
273
  return output.split("\n");
275
274
  }
276
275
 
277
- /**
278
- * Syntax highlight code using native highlighter.
279
- */
280
- function highlightCode(codeText: string, language: string, theme: Theme): string[] {
281
- const validLang = language && supportsLanguage(language) ? language : undefined;
282
- try {
283
- const colors: HighlightColors = {
284
- comment: theme.getFgAnsi("syntaxComment"),
285
- keyword: theme.getFgAnsi("syntaxKeyword"),
286
- function: theme.getFgAnsi("syntaxFunction"),
287
- variable: theme.getFgAnsi("syntaxVariable"),
288
- string: theme.getFgAnsi("syntaxString"),
289
- number: theme.getFgAnsi("syntaxNumber"),
290
- type: theme.getFgAnsi("syntaxType"),
291
- operator: theme.getFgAnsi("syntaxOperator"),
292
- punctuation: theme.getFgAnsi("syntaxPunctuation"),
293
- inserted: theme.getFgAnsi("toolDiffAdded"),
294
- deleted: theme.getFgAnsi("toolDiffRemoved"),
295
- };
296
- return nativeHighlightCode(codeText, validLang, colors).split("\n");
297
- } catch {
298
- return codeText.split("\n");
299
- }
300
- }
301
-
302
276
  // =============================================================================
303
277
  // Diagnostics Rendering
304
278
  // =============================================================================
@@ -11,6 +11,7 @@ import type { ModelRegistry } from "../config/model-registry";
11
11
  import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver";
12
12
  import type { Settings } from "../config/settings";
13
13
  import consolidationTemplate from "../prompts/memories/consolidation.md" with { type: "text" };
14
+ import consolidationSystemTemplate from "../prompts/memories/consolidation_system.md" with { type: "text" };
14
15
  import readPathTemplate from "../prompts/memories/read-path.md" with { type: "text" };
15
16
  import stageOneInputTemplate from "../prompts/memories/stage_one_input.md" with { type: "text" };
16
17
  import stageOneSystemTemplate from "../prompts/memories/stage_one_system.md" with { type: "text" };
@@ -752,6 +753,7 @@ async function runConsolidationModel(options: {
752
753
  const response = await completeSimple(
753
754
  model,
754
755
  {
756
+ systemPrompt: [consolidationSystemTemplate],
755
757
  messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }],
756
758
  },
757
759
  {
@@ -56,7 +56,7 @@ import {
56
56
  } from "../../extensibility/extensions";
57
57
  import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
58
58
  import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
59
- import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills";
59
+ import { buildSkillPromptMessage } from "../../extensibility/skills";
60
60
  import { loadSlashCommands } from "../../extensibility/slash-commands";
61
61
  import { resolveLocalUrlToPath } from "../../internal-urls";
62
62
  import { MCPManager } from "../../mcp/manager";
@@ -71,12 +71,8 @@ import {
71
71
  type SessionInfo as StoredSessionInfo,
72
72
  type UsageStatistics,
73
73
  } from "../../session/session-manager";
74
- import {
75
- ACP_BUILTIN_RESERVED_NAMES,
76
- ACP_BUILTIN_SLASH_COMMANDS,
77
- executeAcpBuiltinSlashCommand,
78
- isAcpBuiltinShadowedName,
79
- } from "../../slash-commands/acp-builtins";
74
+ import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
75
+ import { buildAvailableSlashCommands, toAcpAvailableCommands } from "../../slash-commands/available-commands";
80
76
  import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking";
81
77
  import { normalizeLocalScheme } from "../../tools/path-utils";
82
78
  import { runResolveInvocation } from "../../tools/resolve";
@@ -1662,66 +1658,7 @@ export class AcpAgent implements Agent {
1662
1658
  }
1663
1659
 
1664
1660
  async #buildAvailableCommands(session: AgentSession): Promise<AvailableCommand[]> {
1665
- const commands: AvailableCommand[] = [];
1666
- const seenNames = new Set<string>();
1667
- const appendCommand = (command: AvailableCommand): void => {
1668
- if (seenNames.has(command.name)) {
1669
- return;
1670
- }
1671
- seenNames.add(command.name);
1672
- commands.push(command);
1673
- };
1674
-
1675
- // Advertise in the order dispatch resolves them (mirrors AgentSession
1676
- // dispatch: builtins → skills → extensions → custom TS → file-based).
1677
- // `appendCommand` dedupes by name so earlier entries win; extension
1678
- // commands therefore correctly shadow custom TS commands of the same
1679
- // name, matching the runtime behaviour of #tryExecuteExtensionCommand
1680
- // running before #tryExecuteCustomCommand.
1681
- for (const command of ACP_BUILTIN_SLASH_COMMANDS) {
1682
- appendCommand(command);
1683
- }
1684
-
1685
- if (session.skillsSettings?.enableSkillCommands) {
1686
- for (const skill of session.skills) {
1687
- appendCommand({
1688
- name: getSkillSlashCommandName(skill),
1689
- description: skill.description || `Run ${skill.name} skill`,
1690
- input: { hint: "arguments" },
1691
- });
1692
- }
1693
- }
1694
-
1695
- for (const command of session.extensionRunner?.getRegisteredCommands(ACP_BUILTIN_RESERVED_NAMES) ?? []) {
1696
- // Reserved-set filtering in getRegisteredCommands only covers exact
1697
- // names; colon-namespaced names whose prefix is a builtin (e.g.
1698
- // `model:foo`) would still dispatch to the builtin in ACP.
1699
- if (isAcpBuiltinShadowedName(command.name)) {
1700
- continue;
1701
- }
1702
- appendCommand({
1703
- name: command.name,
1704
- description: command.description ?? "(extension command)",
1705
- input: { hint: "arguments" },
1706
- });
1707
- }
1708
-
1709
- for (const command of session.customCommands) {
1710
- appendCommand({
1711
- name: command.command.name,
1712
- description: command.command.description,
1713
- input: { hint: "arguments" },
1714
- });
1715
- }
1716
-
1717
- for (const command of await loadSlashCommands({ cwd: session.sessionManager.getCwd() })) {
1718
- appendCommand({
1719
- name: command.name,
1720
- description: command.description,
1721
- });
1722
- }
1723
-
1724
- return commands;
1661
+ return toAcpAvailableCommands(await buildAvailableSlashCommands(session));
1725
1662
  }
1726
1663
 
1727
1664
  #toSessionInfo(session: StoredSessionInfo): SessionInfo {
@@ -83,6 +83,8 @@ export interface PlanReviewOverlayCallbacks {
83
83
  onCancel: () => void;
84
84
  /** Invoked when the external-editor key is pressed (overlay stays open). */
85
85
  onExternalEditor?: () => void;
86
+ /** Invoked when the external-editor key edits the active annotation draft. */
87
+ onAnnotationExternalEditor?: (draft: string, commit: (text: string | null) => void) => void;
86
88
  /** Invoked with the new full plan text after an in-overlay delete/undo. */
87
89
  onPlanEdited?: (content: string) => void;
88
90
  /** Invoked with the Refine feedback markdown whenever annotations change. */
@@ -282,6 +284,12 @@ export class PlanReviewOverlay implements Component {
282
284
  handleInput(keyData: string): void {
283
285
  if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) return;
284
286
  if (this.#annotating) {
287
+ if (this.callbacks.onAnnotationExternalEditor && matchesAppExternalEditor(keyData)) {
288
+ this.callbacks.onAnnotationExternalEditor(this.#input.getValue(), text => {
289
+ if (text !== null) this.#submitAnnotation(text);
290
+ });
291
+ return;
292
+ }
285
293
  this.#input.handleInput(keyData);
286
294
  return;
287
295
  }
@@ -603,11 +611,23 @@ export class PlanReviewOverlay implements Component {
603
611
  }
604
612
  for (const section of annotated) {
605
613
  feedback += `\n## ${section.title}\n`;
606
- for (const note of section.annotations) feedback += `- ${note}\n`;
614
+ for (const note of section.annotations) feedback += this.#formatAnnotationFeedback(note);
607
615
  }
608
616
  this.callbacks.onFeedbackChange?.(feedback);
609
617
  }
610
618
 
619
+ #formatAnnotationFeedback(note: string): string {
620
+ if (!note.includes("\n")) return `- ${note}\n`;
621
+ const fence = this.#markdownFenceFor(note);
622
+ return `${fence}md\n${note}\n${fence}\n`;
623
+ }
624
+
625
+ #markdownFenceFor(text: string): string {
626
+ let fence = "```";
627
+ while (text.includes(fence)) fence += "`";
628
+ return fence;
629
+ }
630
+
611
631
  #renderSliderLines(): string[] {
612
632
  const slider = this.#slider;
613
633
  if (!slider) return [];
@@ -676,7 +696,14 @@ export class PlanReviewOverlay implements Component {
676
696
  if (section.level >= 1 && section.annotations.length > 0 && rendered.length > 0) {
677
697
  lines.push(rendered[0]!);
678
698
  for (const note of section.annotations) {
679
- lines.push(`${theme.fg("warning", "▎ ")}${theme.fg("dim", "note: ")}${theme.fg("accent", note)}`);
699
+ const noteLines = note.split(/\r?\n/);
700
+ for (let j = 0; j < noteLines.length; j++) {
701
+ const prefix =
702
+ j === 0
703
+ ? `${theme.fg("warning", "▎ ")}${theme.fg("dim", "note: ")}`
704
+ : `${theme.fg("warning", "▎ ")}${theme.fg("dim", " ")}`;
705
+ lines.push(`${prefix}${theme.fg("accent", noteLines[j] ?? "")}`);
706
+ }
680
707
  }
681
708
  for (let k = 1; k < rendered.length; k++) lines.push(rendered[k]!);
682
709
  } else {
@@ -749,7 +776,9 @@ export class PlanReviewOverlay implements Component {
749
776
  const section = this.#sections[this.#toc[this.#tocCursor]!];
750
777
  const title = section?.title ?? "";
751
778
  const caption = `${theme.fg("dim", "Annotate")} ${theme.fg("accent", `‹${title}›`)}`;
752
- return [caption, this.#input.render(innerWidth)[0] ?? ""];
779
+ const hintParts = ["enter save", "esc cancel"];
780
+ if (this.#externalEditorLabel) hintParts.push(`${this.#externalEditorLabel} editor`);
781
+ return [caption, this.#input.render(innerWidth)[0] ?? "", theme.fg("dim", hintParts.join(" · "))];
753
782
  }
754
783
  return [theme.fg("dim", this.#buildHelp())];
755
784
  }
@@ -163,7 +163,7 @@ export class StreamingRevealController {
163
163
  this.#hideThinkingBlock = this.#getHideThinkingBlock();
164
164
  this.#smoothStreaming = this.#getSmoothStreaming();
165
165
  if (!this.#smoothStreaming) {
166
- component.updateContent(message);
166
+ component.updateContent(message, { transient: true });
167
167
  return;
168
168
  }
169
169
  const total = this.#visibleUnits(message);
@@ -171,10 +171,12 @@ export class StreamingRevealController {
171
171
  // A tool call is a transcript-order boundary: finish any leading
172
172
  // assistant text before EventController renders the separate tool card.
173
173
  this.#revealed = total;
174
- component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf));
174
+ component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf), {
175
+ transient: true,
176
+ });
175
177
  return;
176
178
  }
177
- this.#renderCurrent(total);
179
+ this.#renderCurrent();
178
180
  this.#syncTimer(total);
179
181
  }
180
182
 
@@ -182,7 +184,7 @@ export class StreamingRevealController {
182
184
  this.#target = message;
183
185
  if (!this.#component) return;
184
186
  if (!this.#smoothStreaming) {
185
- this.#component.updateContent(message);
187
+ this.#component.updateContent(message, { transient: true });
186
188
  return;
187
189
  }
188
190
  const total = this.#visibleUnits(message);
@@ -193,13 +195,16 @@ export class StreamingRevealController {
193
195
  this.#stopTimer();
194
196
  this.#component.updateContent(
195
197
  buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf),
198
+ {
199
+ transient: true,
200
+ },
196
201
  );
197
202
  return;
198
203
  }
199
204
  if (this.#revealed > total) {
200
205
  this.#revealed = total;
201
206
  }
202
- this.#renderCurrent(total);
207
+ this.#renderCurrent();
203
208
  this.#syncTimer(total);
204
209
  }
205
210
 
@@ -225,11 +230,14 @@ export class StreamingRevealController {
225
230
  return total;
226
231
  }
227
232
 
228
- #renderCurrent(total = this.#target ? this.#visibleUnits(this.#target) : 0): void {
233
+ #renderCurrent(): void {
229
234
  if (!this.#target || !this.#component) return;
235
+ // Every controller render is an in-flight streaming snapshot, even when
236
+ // smooth reveal has temporarily caught up to the current target. The
237
+ // message_end handler performs the only stable non-transient render.
230
238
  this.#component.updateContent(
231
239
  buildDisplayMessage(this.#target, this.#revealed, this.#hideThinkingBlock, this.#countOf),
232
- { transient: this.#revealed < total },
240
+ { transient: true },
233
241
  );
234
242
  }
235
243
 
@@ -269,7 +277,7 @@ export class StreamingRevealController {
269
277
  }
270
278
  this.#revealed = Math.min(total, this.#revealed + nextStep(total - this.#revealed));
271
279
  component.updateContent(buildDisplayMessage(target, this.#revealed, this.#hideThinkingBlock, this.#countOf), {
272
- transient: this.#revealed < total,
280
+ transient: true,
273
281
  });
274
282
  this.#requestRender();
275
283
  if (this.#revealed >= total) {
@@ -1785,6 +1785,7 @@ export class InteractiveMode implements InteractiveModeContext {
1785
1785
  onPick: choice => finish(choice),
1786
1786
  onCancel: () => finish(undefined),
1787
1787
  onExternalEditor: dialogOptions?.onExternalEditor,
1788
+ onAnnotationExternalEditor: (draft, commit) => void this.#openPlanAnnotationInExternalEditor(draft, commit),
1788
1789
  onPlanEdited: dialogOptions?.onPlanEdited,
1789
1790
  onFeedbackChange: dialogOptions?.onFeedbackChange,
1790
1791
  },
@@ -1899,6 +1900,37 @@ export class InteractiveMode implements InteractiveModeContext {
1899
1900
  }
1900
1901
  }
1901
1902
 
1903
+ async #openPlanAnnotationInExternalEditor(draft: string, commit: (text: string | null) => void): Promise<void> {
1904
+ const editorCmd = getEditorCommand();
1905
+ if (!editorCmd) {
1906
+ this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
1907
+ return;
1908
+ }
1909
+
1910
+ let ttyHandle: fs.FileHandle | null = null;
1911
+ try {
1912
+ ttyHandle = await this.#openEditorTerminalHandle();
1913
+ this.ui.stop();
1914
+
1915
+ const stdio: [number | "inherit", number | "inherit", number | "inherit"] = ttyHandle
1916
+ ? [ttyHandle.fd, ttyHandle.fd, ttyHandle.fd]
1917
+ : ["inherit", "inherit", "inherit"];
1918
+
1919
+ const result = await openInEditor(editorCmd, draft, { extension: ".md", stdio });
1920
+ if (result !== null) {
1921
+ commit(result);
1922
+ }
1923
+ } catch (error) {
1924
+ this.showWarning(`Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`);
1925
+ } finally {
1926
+ if (ttyHandle) {
1927
+ await ttyHandle.close();
1928
+ }
1929
+ this.ui.start();
1930
+ this.ui.requestRender(true);
1931
+ }
1932
+ }
1933
+
1902
1934
  async #applyPlanExecutionModel(entry: ResolvedRoleModel | undefined): Promise<void> {
1903
1935
  if (!entry) return;
1904
1936
  try {
@@ -13,6 +13,8 @@ import type { FileSink } from "bun";
13
13
  import type { BashResult } from "../../exec/bash-executor";
14
14
  import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
15
15
  import type {
16
+ RpcAvailableCommandsUpdateFrame,
17
+ RpcAvailableSlashCommand,
16
18
  RpcCommand,
17
19
  RpcExtensionUIRequest,
18
20
  RpcHandoffResult,
@@ -63,6 +65,7 @@ export type RpcSessionEventListener = (event: AgentSessionEvent) => void;
63
65
  export type RpcSubagentLifecycleListener = (payload: RpcSubagentLifecycleFrame["payload"]) => void;
64
66
  export type RpcSubagentProgressListener = (payload: RpcSubagentProgressFrame["payload"]) => void;
65
67
  export type RpcSubagentEventListener = (payload: RpcSubagentEventFrame["payload"]) => void;
68
+ export type RpcAvailableCommandsUpdateListener = (commands: RpcAvailableSlashCommand[]) => void;
66
69
 
67
70
  export interface RpcClientToolContext<TDetails = unknown> {
68
71
  toolCallId: string;
@@ -161,6 +164,11 @@ function isRpcSubagentEventFrame(value: unknown): value is RpcSubagentEventFrame
161
164
  return value.type === "subagent_event" && isRecord(value.payload);
162
165
  }
163
166
 
167
+ function isRpcAvailableCommandsUpdateFrame(value: unknown): value is RpcAvailableCommandsUpdateFrame {
168
+ if (!isRecord(value)) return false;
169
+ return value.type === "available_commands_update" && Array.isArray(value.commands);
170
+ }
171
+
164
172
  function isRpcHostToolCallRequest(value: unknown): value is RpcHostToolCallRequest {
165
173
  if (!isRecord(value)) return false;
166
174
  return (
@@ -202,6 +210,7 @@ export class RpcClient {
202
210
  #subagentLifecycleListeners = new Set<RpcSubagentLifecycleListener>();
203
211
  #subagentProgressListeners = new Set<RpcSubagentProgressListener>();
204
212
  #subagentEventListeners = new Set<RpcSubagentEventListener>();
213
+ #availableCommandsUpdateListeners = new Set<RpcAvailableCommandsUpdateListener>();
205
214
  #pendingRequests: Map<string, { resolve: (response: RpcResponse) => void; reject: (error: Error) => void }> =
206
215
  new Map();
207
216
  #customTools: RpcClientCustomTool[] = [];
@@ -377,6 +386,14 @@ export class RpcClient {
377
386
  return () => this.#subagentEventListeners.delete(listener);
378
387
  }
379
388
 
389
+ /**
390
+ * Subscribe to slash-command availability updates emitted by the RPC server.
391
+ */
392
+ onAvailableCommandsUpdate(listener: RpcAvailableCommandsUpdateListener): () => void {
393
+ this.#availableCommandsUpdateListeners.add(listener);
394
+ return () => this.#availableCommandsUpdateListeners.delete(listener);
395
+ }
396
+
380
397
  /**
381
398
  * Get collected stderr output (useful for debugging).
382
399
  */
@@ -511,6 +528,14 @@ export class RpcClient {
511
528
  return this.#getData<{ models: ModelInfo[] }>(response).models;
512
529
  }
513
530
 
531
+ /**
532
+ * Get list of available slash commands.
533
+ */
534
+ async getAvailableCommands(): Promise<RpcAvailableSlashCommand[]> {
535
+ const response = await this.#send({ type: "get_available_commands" });
536
+ return this.#getData<{ commands: RpcAvailableSlashCommand[] }>(response).commands;
537
+ }
538
+
514
539
  /**
515
540
  * Set thinking level.
516
541
  */
@@ -825,6 +850,13 @@ export class RpcClient {
825
850
  return;
826
851
  }
827
852
 
853
+ if (isRpcAvailableCommandsUpdateFrame(data)) {
854
+ for (const listener of this.#availableCommandsUpdateListeners) {
855
+ listener(data.commands);
856
+ }
857
+ return;
858
+ }
859
+
828
860
  if (!isAgentSessionEvent(data)) return;
829
861
 
830
862
  for (const listener of this.#sessionEventListeners) {
@@ -12,6 +12,8 @@
12
12
  */
13
13
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
14
14
  import { $env, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
15
+ import { reset as resetCapabilities } from "../../capability";
16
+ import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
15
17
  import {
16
18
  type ExtensionUIContext,
17
19
  type ExtensionUIDialogOptions,
@@ -19,8 +21,13 @@ import {
19
21
  type ExtensionWidgetOptions,
20
22
  getExtensionUISelectOptionLabel,
21
23
  } from "../../extensibility/extensions";
24
+ import { buildSkillPromptMessage } from "../../extensibility/skills";
25
+ import { loadSlashCommands } from "../../extensibility/slash-commands";
22
26
  import { type Theme, theme } from "../../modes/theme/theme";
23
27
  import type { AgentSession } from "../../session/agent-session";
28
+ import { SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
29
+ import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
30
+ import { buildAvailableSlashCommands } from "../../slash-commands/available-commands";
24
31
  import type { EventBus } from "../../utils/event-bus";
25
32
  import { initializeExtensions } from "../runtime-init";
26
33
  import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
@@ -70,6 +77,28 @@ export type RpcSessionChangeResult =
70
77
  | { type: "branch"; data: { text: string; cancelled: boolean } };
71
78
 
72
79
  export type RpcSessionChangeSession = Pick<AgentSession, "newSession" | "switchSession" | "branch">;
80
+
81
+ export type RpcSkillCommandSession = Pick<AgentSession, "promptCustomMessage" | "skills" | "skillsSettings">;
82
+
83
+ export async function tryRunRpcSkillCommand(session: RpcSkillCommandSession, text: string): Promise<boolean> {
84
+ if (!text.startsWith("/skill:")) return false;
85
+ if (!session.skillsSettings?.enableSkillCommands) return false;
86
+ const spaceIndex = text.indexOf(" ");
87
+ const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
88
+ const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
89
+ const skillName = commandName.slice("skill:".length);
90
+ const skill = session.skills.find(candidate => candidate.name === skillName);
91
+ if (!skill) return false;
92
+ const built = await buildSkillPromptMessage(skill, args);
93
+ await session.promptCustomMessage({
94
+ customType: SKILL_PROMPT_MESSAGE_TYPE,
95
+ content: built.message,
96
+ display: true,
97
+ details: built.details,
98
+ attribution: "user",
99
+ });
100
+ return true;
101
+ }
73
102
  export type RpcSubagentResetRegistry = Pick<RpcSubagentRegistry, "clear">;
74
103
 
75
104
  export async function handleRpcSessionChange(
@@ -511,6 +540,24 @@ export async function runRpcMode(
511
540
  output(event);
512
541
  });
513
542
 
543
+ const getAvailableCommands = async () => buildAvailableSlashCommands(session);
544
+ const reloadPluginState = async () => {
545
+ const cwd = session.sessionManager.getCwd();
546
+ const projectPath = await resolveActiveProjectRegistryPath(cwd);
547
+ clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
548
+ resetCapabilities();
549
+ session.setSlashCommands(await loadSlashCommands({ cwd }));
550
+ await session.refreshSshTool({ activateIfAvailable: true });
551
+ await emitAvailableCommandsUpdate();
552
+ };
553
+ const emitAvailableCommandsUpdate = async () => {
554
+ output({ type: "available_commands_update", commands: await getAvailableCommands() });
555
+ };
556
+ session.subscribeCommandMetadataChanged(() => {
557
+ void emitAvailableCommandsUpdate();
558
+ });
559
+ await emitAvailableCommandsUpdate();
560
+
514
561
  // Handle a single command
515
562
  const handleCommand = async (command: RpcCommand): Promise<RpcResponse> => {
516
563
  const id = command.id;
@@ -521,6 +568,33 @@ export async function runRpcMode(
521
568
  // =================================================================
522
569
 
523
570
  case "prompt": {
571
+ if (await tryRunRpcSkillCommand(session, command.message)) {
572
+ return success(id, "prompt");
573
+ }
574
+ const builtinResult = await executeAcpBuiltinSlashCommand(command.message, {
575
+ session,
576
+ sessionManager: session.sessionManager,
577
+ settings: session.settings,
578
+ cwd: session.sessionManager.getCwd(),
579
+ output: text => output({ type: "command_output", text }),
580
+ refreshCommands: emitAvailableCommandsUpdate,
581
+ reloadPlugins: reloadPluginState,
582
+ notifyTitleChanged: async () => {
583
+ output({ type: "session_info_update", title: session.sessionName, sessionId: session.sessionId });
584
+ },
585
+ notifyConfigChanged: async () => {
586
+ output({ type: "config_update", model: session.model, thinkingLevel: session.thinkingLevel });
587
+ },
588
+ });
589
+ if (builtinResult !== false) {
590
+ if ("prompt" in builtinResult) {
591
+ session
592
+ .prompt(builtinResult.prompt, { images: command.images })
593
+ .catch(e => output(error(id, "prompt", e.message)));
594
+ }
595
+ return success(id, "prompt");
596
+ }
597
+
524
598
  // Don't await - events will stream
525
599
  // Extension commands are executed immediately, file prompt templates are expanded
526
600
  // If streaming and streamingBehavior specified, queues via steer/followUp
@@ -556,8 +630,11 @@ export async function runRpcMode(
556
630
  return success(id, "abort_and_prompt");
557
631
  }
558
632
 
559
- case "new_session": {
633
+ case "new_session":
634
+ case "switch_session":
635
+ case "branch": {
560
636
  const result = await handleRpcSessionChange(session, command, subagentRegistry);
637
+ if (!result.data.cancelled) await emitAvailableCommandsUpdate();
561
638
  return success(id, result.type, result.data);
562
639
  }
563
640
 
@@ -592,6 +669,10 @@ export async function runRpcMode(
592
669
  return success(id, "get_state", state);
593
670
  }
594
671
 
672
+ case "get_available_commands": {
673
+ return success(id, "get_available_commands", { commands: await getAvailableCommands() });
674
+ }
675
+
595
676
  case "set_todos": {
596
677
  session.setTodoPhases(command.phases);
597
678
  return success(id, "set_todos", { todoPhases: session.getTodoPhases() });
@@ -770,12 +851,6 @@ export async function runRpcMode(
770
851
  return success(id, "export_html", { path });
771
852
  }
772
853
 
773
- case "switch_session":
774
- case "branch": {
775
- const result = await handleRpcSessionChange(session, command, subagentRegistry);
776
- return success(id, result.type, result.data);
777
- }
778
-
779
854
  case "get_branch_messages": {
780
855
  const messages = session.getUserMessagesForBranching();
781
856
  return success(id, "get_branch_messages", { messages });