@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
@@ -11,6 +11,7 @@ import type { BashResult } from "../../exec/bash-executor";
11
11
  import type { ContextUsage } from "../../extensibility/extensions/types";
12
12
  import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
13
13
  import type { FileEntry } from "../../session/session-manager";
14
+ import type { AvailableSlashCommandSource } from "../../slash-commands/available-commands";
14
15
  import type {
15
16
  AgentProgress,
16
17
  SubagentEventPayload,
@@ -34,6 +35,7 @@ export type RpcCommand =
34
35
 
35
36
  // State
36
37
  | { id?: string; type: "get_state" }
38
+ | { id?: string; type: "get_available_commands" }
37
39
  | { id?: string; type: "set_todos"; phases: TodoPhase[] }
38
40
  | { id?: string; type: "set_host_tools"; tools: RpcHostToolDefinition[] }
39
41
  | { id?: string; type: "set_host_uri_schemes"; schemes: RpcHostUriSchemeDefinition[] }
@@ -110,6 +112,20 @@ export interface RpcSessionState {
110
112
  contextUsage?: ContextUsage;
111
113
  }
112
114
 
115
+ export interface RpcAvailableSlashCommand {
116
+ name: string;
117
+ aliases?: string[];
118
+ description?: string;
119
+ input?: { hint?: string };
120
+ subcommands?: Array<{ name: string; description?: string; usage?: string }>;
121
+ source: AvailableSlashCommandSource;
122
+ }
123
+
124
+ export interface RpcAvailableCommandsUpdateFrame {
125
+ type: "available_commands_update";
126
+ commands: RpcAvailableSlashCommand[];
127
+ }
128
+
113
129
  export interface RpcHandoffResult {
114
130
  savedPath?: string;
115
131
  }
@@ -156,6 +172,13 @@ export type RpcResponse =
156
172
 
157
173
  // State
158
174
  | { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
175
+ | {
176
+ id?: string;
177
+ type: "response";
178
+ command: "get_available_commands";
179
+ success: true;
180
+ data: { commands: RpcAvailableSlashCommand[] };
181
+ }
159
182
  | { id?: string; type: "response"; command: "set_todos"; success: true; data: { todoPhases: TodoPhase[] } }
160
183
  | { id?: string; type: "response"; command: "set_host_tools"; success: true; data: { toolNames: string[] } }
161
184
  | { id?: string; type: "response"; command: "set_host_uri_schemes"; success: true; data: { schemes: string[] } }
@@ -2565,10 +2565,10 @@ const HIGHLIGHT_CACHE_MAX = 256;
2565
2565
  const highlightCache = new LRUCache<string, string>({ max: HIGHLIGHT_CACHE_MAX });
2566
2566
  let highlightCacheTheme: Theme | undefined;
2567
2567
 
2568
- function highlightCached(code: string, validLang: string | undefined): string | null {
2569
- if (highlightCacheTheme !== theme) {
2568
+ function highlightCached(code: string, validLang: string | undefined, highlightTheme: Theme): string | null {
2569
+ if (highlightCacheTheme !== highlightTheme) {
2570
2570
  highlightCache.clear();
2571
- highlightCacheTheme = theme;
2571
+ highlightCacheTheme = highlightTheme;
2572
2572
  }
2573
2573
  const key = `${validLang ?? ""}\x00${code}`;
2574
2574
  const hit = highlightCache.get(key);
@@ -2577,7 +2577,7 @@ function highlightCached(code: string, validLang: string | undefined): string |
2577
2577
  }
2578
2578
  let highlighted: string;
2579
2579
  try {
2580
- highlighted = nativeHighlightCode(code, validLang, getHighlightColors(theme));
2580
+ highlighted = nativeHighlightCode(code, validLang, getHighlightColors(highlightTheme));
2581
2581
  } catch {
2582
2582
  return null;
2583
2583
  }
@@ -2589,9 +2589,9 @@ function highlightCached(code: string, validLang: string | undefined): string |
2589
2589
  * Highlight code with syntax coloring based on file extension or language.
2590
2590
  * Returns array of highlighted lines.
2591
2591
  */
2592
- export function highlightCode(code: string, lang?: string): string[] {
2592
+ export function highlightCode(code: string, lang?: string, highlightTheme: Theme = theme): string[] {
2593
2593
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2594
- const highlighted = highlightCached(code, validLang);
2594
+ const highlighted = highlightCached(code, validLang, highlightTheme);
2595
2595
  // Always return a fresh array: callers (e.g. renderCodeCell) push extra lines
2596
2596
  // onto the result, which would corrupt the cached string otherwise.
2597
2597
  return (highlighted ?? code).split("\n");
@@ -2639,7 +2639,7 @@ export function getMarkdownTheme(): MarkdownTheme {
2639
2639
  resolveMermaidAscii,
2640
2640
  highlightCode: (code: string, lang?: string): string[] => {
2641
2641
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2642
- const highlighted = highlightCached(code, validLang);
2642
+ const highlighted = highlightCached(code, validLang, theme);
2643
2643
  if (highlighted !== null) return highlighted.split("\n");
2644
2644
  return code.split("\n").map(line => theme.fg("mdCodeBlock", line));
2645
2645
  },
@@ -191,7 +191,11 @@ export class UiHelpers {
191
191
  this.ctx.chatContainer.addChild(component);
192
192
  break;
193
193
  }
194
- if (message.customType === "irc:incoming" || message.customType === "irc:relay") {
194
+ if (
195
+ message.customType === "irc:incoming" ||
196
+ message.customType === "irc:autoreply" ||
197
+ message.customType === "irc:relay"
198
+ ) {
195
199
  const details = (
196
200
  message as CustomMessage<{
197
201
  from?: string;
@@ -201,13 +205,18 @@ export class UiHelpers {
201
205
  replyTo?: string;
202
206
  }>
203
207
  ).details;
204
- const incoming = message.customType === "irc:incoming";
208
+ const kind =
209
+ message.customType === "irc:incoming"
210
+ ? ("incoming" as const)
211
+ : message.customType === "irc:autoreply"
212
+ ? ("autoreply" as const)
213
+ : ("relay" as const);
205
214
  const card = createIrcMessageCard(
206
215
  {
207
- kind: incoming ? "incoming" : "relay",
216
+ kind,
208
217
  from: details?.from,
209
218
  to: details?.to,
210
- body: incoming ? details?.message : details?.body,
219
+ body: kind === "incoming" ? details?.message : details?.body,
211
220
  replyTo: details?.replyTo,
212
221
  timestamp: message.timestamp,
213
222
  },
@@ -0,0 +1,4 @@
1
+ You are the memory-stage-two consolidator.
2
+
3
+ Follow the user-provided consolidation task exactly.
4
+ Return strict JSON only — no markdown, no commentary.
@@ -0,0 +1,6 @@
1
+ <irc>
2
+ You received an IRC message from agent `{{from}}`{{#if replyTo}} (replying to {{replyTo}}){{/if}} while you are busy mid-task. This is a side-channel turn: reply briefly and directly using the conversation context already available to you. NEVER call tools. The text you write is delivered back to `{{from}}` as your answer.
3
+
4
+ Message:
5
+ {{message}}
6
+ </irc>
@@ -3,5 +3,5 @@ Incoming IRC message from agent `{{from}}`{{#if replyTo}} (replying to {{replyTo
3
3
 
4
4
  {{message}}
5
5
 
6
- If a response is expected, reply with the `irc` tool (`op: "send"`, `to: "{{from}}"`) — you may finish your current step first. Nobody replies on your behalf.
6
+ {{#if autoReplied}}You are mid-task, so a side-channel auto-reply was generated from your context and delivered to `{{from}}` on your behalf (recorded after this message). Follow up with the `irc` tool (`op: "send"`, `to: "{{from}}"`) only if that auto-reply needs correcting.{{else}}If a response is expected, reply with the `irc` tool (`op: "send"`, `to: "{{from}}"`) — you may finish your current step first. Nobody replies on your behalf.{{/if}}
7
7
  </irc>
@@ -6,6 +6,7 @@ Executes bash command in shell session for terminal operations like git, bun, ca
6
6
  - Quote variable expansions like `"$NAME"` to preserve exact content
7
7
  - PTY mode is opt-in: set `pty: true` only when the command needs a real terminal (e.g. `sudo`, `ssh` requiring user input); default is `false`
8
8
  - Use `;` only when later commands should run regardless of earlier failures
9
+ - Multiple bash calls in one message run concurrently. NEVER split order-dependent commands across parallel calls — chain them with `&&` in a single call.
9
10
  - Internal URIs (`skill://`, `agent://`, etc.) are auto-resolved to filesystem paths
10
11
  {{#if asyncEnabled}}
11
12
  - Use `async: true` for long-running commands when you don't need immediate output; the call returns a background job ID and the result is delivered automatically as a follow-up.
@@ -9,7 +9,7 @@ Sends short text messages to other agents in this process and receives theirs.
9
9
  - `op: "wait"` — block until a message arrives (optionally only `from` a specific peer); consumes and returns it. A timeout is a clean "no message" result, not an error.
10
10
  - `op: "inbox"` — drain pending messages without blocking (`peek: true` to leave them unread).
11
11
  - `replyTo` — set it to the id of the message you are answering so the sender can correlate.
12
- - Nobody answers on a peer's behalf anymore: a reply only arrives when the recipient actually sends one. For background on what a peer has been doing, `read` `history://<id>` instead of interrogating them.
12
+ - Nobody answers on a peer's behalf a reply normally arrives only when the recipient sends one — with one exception: `send` with `await: true` to a peer that is mid-turn and cannot reach a step boundary (async execution disabled, e.g. blocked in a synchronous task spawn) gets a side-channel auto-reply generated from that peer's context. For background on what a peer has been doing, `read` `history://<id>` instead of interrogating them.
13
13
  </instruction>
14
14
 
15
15
  <when_to_use>
@@ -171,7 +171,7 @@ import { GoalRuntime } from "../goals/runtime";
171
171
  import type { Goal, GoalModeState } from "../goals/state";
172
172
  import type { HindsightSessionState } from "../hindsight/state";
173
173
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
174
- import type { IrcMessage } from "../irc/bus";
174
+ import { IrcBus, type IrcMessage } from "../irc/bus";
175
175
  import { resolveMemoryBackend } from "../memory-backend";
176
176
  import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state";
177
177
  import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
@@ -185,6 +185,7 @@ import type { PlanModeState } from "../plan-mode/state";
185
185
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
186
186
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
187
187
  import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
188
+ import ircAutoReplyTemplate from "../prompts/system/irc-autoreply.md" with { type: "text" };
188
189
  import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
189
190
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
190
191
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
@@ -298,6 +299,7 @@ export type AgentSessionEvent =
298
299
 
299
300
  /** Listener function for agent session events */
300
301
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
302
+ export type CommandMetadataChangedListener = () => void | Promise<void>;
301
303
  export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
302
304
 
303
305
  const EMPTY_STOP_MAX_RETRIES = 3;
@@ -884,6 +886,7 @@ export class AgentSession {
884
886
  /** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
885
887
  #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
886
888
  #eventListeners: AgentSessionEventListener[] = [];
889
+ #commandMetadataChangedListeners: CommandMetadataChangedListener[] = [];
887
890
 
888
891
  /** Tracks pending steering messages for UI display. Removed when delivered.
889
892
  * Entry shape: `{ text }` for plain-text steers (user-message dequeue
@@ -3034,6 +3037,27 @@ export class AgentSession {
3034
3037
  };
3035
3038
  }
3036
3039
 
3040
+ subscribeCommandMetadataChanged(listener: CommandMetadataChangedListener): () => void {
3041
+ this.#commandMetadataChangedListeners.push(listener);
3042
+ return () => {
3043
+ const index = this.#commandMetadataChangedListeners.indexOf(listener);
3044
+ if (index !== -1) {
3045
+ this.#commandMetadataChangedListeners.splice(index, 1);
3046
+ }
3047
+ };
3048
+ }
3049
+
3050
+ #notifyCommandMetadataChanged(): void {
3051
+ const listeners = [...this.#commandMetadataChangedListeners];
3052
+ for (const listener of listeners) {
3053
+ try {
3054
+ void listener();
3055
+ } catch (err) {
3056
+ logger.error("Command metadata listener threw", { err });
3057
+ }
3058
+ }
3059
+ }
3060
+
3037
3061
  /**
3038
3062
  * Temporarily disconnect from agent events.
3039
3063
  * User listeners are preserved and will receive events again after resubscribe().
@@ -4350,9 +4374,15 @@ export class AgentSession {
4350
4374
  return [...this.#customCommands, ...this.#mcpPromptCommands];
4351
4375
  }
4352
4376
 
4377
+ /** MCP prompt commands only, for command-list metadata. */
4378
+ get mcpPromptCommands(): ReadonlyArray<LoadedCustomCommand> {
4379
+ return this.#mcpPromptCommands;
4380
+ }
4381
+
4353
4382
  /** Update the MCP prompt commands list. Called when server prompts are (re)loaded. */
4354
4383
  setMCPPromptCommands(commands: LoadedCustomCommand[]): void {
4355
4384
  this.#mcpPromptCommands = commands;
4385
+ this.#notifyCommandMetadataChanged();
4356
4386
  }
4357
4387
 
4358
4388
  // =========================================================================
@@ -4465,12 +4495,16 @@ export class AgentSession {
4465
4495
  return { ...message, content: normalized } as T;
4466
4496
  }
4467
4497
 
4498
+ #magicKeywordEnabled(keyword: "orchestrate" | "ultrathink" | "workflow"): boolean {
4499
+ return this.settings.get("magicKeywords.enabled") && this.settings.get(`magicKeywords.${keyword}`);
4500
+ }
4501
+
4468
4502
  #createMagicKeywordNotices(text: string): CustomMessage[] {
4469
4503
  const timestamp = Date.now();
4470
4504
  const turnBudget = parseTurnBudget(text);
4471
4505
  this.sessionManager.beginTurnBudget(turnBudget?.total ?? null, turnBudget?.hard ?? false);
4472
4506
  const keywordNotices: CustomMessage[] = [];
4473
- if (containsUltrathink(text)) {
4507
+ if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(text)) {
4474
4508
  keywordNotices.push({
4475
4509
  role: "custom",
4476
4510
  customType: "ultrathink-notice",
@@ -4480,7 +4514,7 @@ export class AgentSession {
4480
4514
  timestamp,
4481
4515
  });
4482
4516
  }
4483
- if (containsOrchestrate(text)) {
4517
+ if (this.#magicKeywordEnabled("orchestrate") && containsOrchestrate(text)) {
4484
4518
  keywordNotices.push({
4485
4519
  role: "custom",
4486
4520
  customType: "orchestrate-notice",
@@ -4490,7 +4524,7 @@ export class AgentSession {
4490
4524
  timestamp,
4491
4525
  });
4492
4526
  }
4493
- if (containsWorkflow(text)) {
4527
+ if (this.#magicKeywordEnabled("workflow") && containsWorkflow(text)) {
4494
4528
  keywordNotices.push({
4495
4529
  role: "custom",
4496
4530
  customType: "workflow-notice",
@@ -5921,7 +5955,7 @@ export class AgentSession {
5921
5955
  if (!model?.reasoning) return;
5922
5956
 
5923
5957
  let resolved: Effort | undefined;
5924
- if (containsUltrathink(promptText)) {
5958
+ if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(promptText)) {
5925
5959
  // The user explicitly asked for maximum thinking; bypass the classifier
5926
5960
  // and jump straight to the highest auto-supported level for this model.
5927
5961
  resolved = clampAutoThinkingEffort(model, Effort.XHigh);
@@ -9118,11 +9152,20 @@ export class AgentSession {
9118
9152
  * → "woken".
9119
9153
  *
9120
9154
  * Never blocks on the recipient's turn: the wake turn is fire-and-forget.
9155
+ *
9156
+ * When the sender expects a reply (`send await:true`) and this session is
9157
+ * mid-turn with async execution disabled, the next step boundary may be
9158
+ * gated on the sender's own batch finishing (blocking task spawns), so a
9159
+ * real reply turn can never happen in time. In that case an ephemeral
9160
+ * side-channel auto-reply is generated from the current context (the old
9161
+ * `respondAsBackground` path) and sent back over the bus on this agent's
9162
+ * behalf.
9121
9163
  */
9122
- async deliverIrcMessage(msg: IrcMessage): Promise<"injected" | "woken"> {
9164
+ async deliverIrcMessage(msg: IrcMessage, opts?: { expectsReply?: boolean }): Promise<"injected" | "woken"> {
9123
9165
  if (this.#isDisposed) {
9124
9166
  throw new Error("Recipient session is disposed.");
9125
9167
  }
9168
+ const autoReply = (opts?.expectsReply ?? false) && this.isStreaming && !this.settings.get("async.enabled");
9126
9169
  const record: CustomMessage = {
9127
9170
  role: "custom",
9128
9171
  customType: "irc:incoming",
@@ -9130,6 +9173,7 @@ export class AgentSession {
9130
9173
  from: msg.from,
9131
9174
  message: msg.body,
9132
9175
  replyTo: msg.replyTo ?? "",
9176
+ autoReplied: autoReply,
9133
9177
  }),
9134
9178
  display: true,
9135
9179
  details: { id: msg.id, from: msg.from, message: msg.body, ...(msg.replyTo ? { replyTo: msg.replyTo } : {}) },
@@ -9139,6 +9183,7 @@ export class AgentSession {
9139
9183
  void this.#emitSessionEvent({ type: "irc_message", message: record });
9140
9184
  if (this.isStreaming) {
9141
9185
  this.#pendingIrcAsides.push(record);
9186
+ if (autoReply) void this.#runIrcAutoReply(msg);
9142
9187
  return "injected";
9143
9188
  }
9144
9189
  // Idle: same wake primitive the yield queue uses for async-result
@@ -9149,6 +9194,50 @@ export class AgentSession {
9149
9194
  return "woken";
9150
9195
  }
9151
9196
 
9197
+ /**
9198
+ * Generate and deliver an ephemeral auto-reply to `msg` on this agent's
9199
+ * behalf: a no-tools side-channel turn over the current history (same
9200
+ * pipeline as `/btw`), recorded into this session as an `irc:autoreply`
9201
+ * aside so the model knows what was said for it, and sent back to the
9202
+ * sender as a regular bus message (`replyTo: msg.id`) so their parked
9203
+ * `wait`/`await:true` resolves. Failures only log — the sender then hits
9204
+ * its normal wait timeout.
9205
+ */
9206
+ async #runIrcAutoReply(msg: IrcMessage): Promise<void> {
9207
+ try {
9208
+ const { replyText } = await this.runEphemeralTurn({
9209
+ promptText: prompt.render(ircAutoReplyTemplate, {
9210
+ from: msg.from,
9211
+ message: msg.body,
9212
+ replyTo: msg.replyTo ?? "",
9213
+ }),
9214
+ });
9215
+ const body = replyText.trim();
9216
+ if (!body || this.#isDisposed) return;
9217
+ const record: CustomMessage = {
9218
+ role: "custom",
9219
+ customType: "irc:autoreply",
9220
+ content: `[IRC you → \`${msg.from}\` (auto)]\n\n${body}`,
9221
+ display: true,
9222
+ details: { to: msg.from, body, replyTo: msg.id },
9223
+ attribution: "agent",
9224
+ timestamp: Date.now(),
9225
+ };
9226
+ void this.#emitSessionEvent({ type: "irc_message", message: record });
9227
+ // Asides drain at the next step boundary; anything left over is
9228
+ // flushed at the start of the next prompt (#flushPendingIrcAsides).
9229
+ this.#pendingIrcAsides.push(record);
9230
+ // `from` must be the id the sender addressed (msg.to) so their
9231
+ // from-filtered waiter matches.
9232
+ const receipt = await IrcBus.global().send({ from: msg.to, to: msg.from, body, replyTo: msg.id });
9233
+ if (receipt.outcome === "failed") {
9234
+ logger.warn("IRC auto-reply delivery failed", { to: msg.from, error: receipt.error });
9235
+ }
9236
+ } catch (error) {
9237
+ logger.warn("IRC auto-reply turn failed", { from: msg.from, error: String(error) });
9238
+ }
9239
+ }
9240
+
9152
9241
  /**
9153
9242
  * Emit an IRC relay observation event on this session for UI rendering only.
9154
9243
  * Does not persist the record to history. Called by the IrcBus to surface
@@ -0,0 +1,105 @@
1
+ import type { AvailableCommand } from "@agentclientprotocol/sdk";
2
+ import type { SkillsSettings } from "../config/settings";
3
+ import type { LoadedCustomCommand } from "../extensibility/custom-commands";
4
+ import type { ExtensionRunner } from "../extensibility/extensions";
5
+ import { getSkillSlashCommandName, type Skill } from "../extensibility/skills";
6
+ import { type FileSlashCommand, loadSlashCommands } from "../extensibility/slash-commands";
7
+ import { ACP_BUILTIN_RESERVED_NAMES, isAcpBuiltinShadowedName } from "./acp-builtins";
8
+ import { BUILTIN_SLASH_COMMANDS_INTERNAL } from "./builtin-registry";
9
+
10
+ export type AvailableSlashCommandSource = "builtin" | "skill" | "extension" | "custom" | "mcp_prompt" | "file";
11
+
12
+ export interface InternalAvailableSlashCommand {
13
+ name: string;
14
+ aliases?: string[];
15
+ description?: string;
16
+ input?: { hint: string };
17
+ subcommands?: Array<{ name: string; description?: string; usage?: string }>;
18
+ source: AvailableSlashCommandSource;
19
+ }
20
+
21
+ export interface AvailableCommandsSession {
22
+ readonly extensionRunner?: ExtensionRunner;
23
+ readonly customCommands: ReadonlyArray<LoadedCustomCommand>;
24
+ readonly mcpPromptCommands?: ReadonlyArray<LoadedCustomCommand>;
25
+ readonly skills: ReadonlyArray<Skill>;
26
+ readonly skillsSettings?: SkillsSettings;
27
+ setSlashCommands(slashCommands: FileSlashCommand[]): void;
28
+ sessionManager: { getCwd(): string };
29
+ }
30
+
31
+ export async function buildAvailableSlashCommands(
32
+ session: AvailableCommandsSession,
33
+ loadFileCommands: (cwd: string) => Promise<FileSlashCommand[]> = cwd => loadSlashCommands({ cwd }),
34
+ ): Promise<InternalAvailableSlashCommand[]> {
35
+ const commands: InternalAvailableSlashCommand[] = [];
36
+ const seenNames = new Set<string>();
37
+ const appendCommand = (command: InternalAvailableSlashCommand): void => {
38
+ if (seenNames.has(command.name)) return;
39
+ seenNames.add(command.name);
40
+ commands.push(command);
41
+ };
42
+
43
+ for (const command of BUILTIN_SLASH_COMMANDS_INTERNAL) {
44
+ if (!command.handle) continue;
45
+ const hint = command.acpInputHint ?? command.inlineHint;
46
+ appendCommand({
47
+ name: command.name,
48
+ aliases: command.aliases,
49
+ description: command.acpDescription ?? command.description,
50
+ input: hint ? { hint } : undefined,
51
+ subcommands: command.subcommands,
52
+ source: "builtin",
53
+ });
54
+ }
55
+
56
+ if (session.skillsSettings?.enableSkillCommands) {
57
+ for (const skill of session.skills) {
58
+ appendCommand({
59
+ name: getSkillSlashCommandName(skill),
60
+ description: skill.description || `Run ${skill.name} skill`,
61
+ input: { hint: "arguments" },
62
+ source: "skill",
63
+ });
64
+ }
65
+ }
66
+
67
+ const runner = session.extensionRunner;
68
+ if (runner) {
69
+ for (const command of runner.getRegisteredCommands(ACP_BUILTIN_RESERVED_NAMES)) {
70
+ if (isAcpBuiltinShadowedName(command.name)) continue;
71
+ appendCommand({
72
+ name: command.name,
73
+ description: command.description ?? "(extension command)",
74
+ input: { hint: "arguments" },
75
+ source: "extension",
76
+ });
77
+ }
78
+ }
79
+
80
+ for (const command of session.customCommands) {
81
+ const source: AvailableSlashCommandSource = command.path?.startsWith("mcp:") ? "mcp_prompt" : "custom";
82
+ appendCommand({
83
+ name: command.command.name,
84
+ description: command.command.description,
85
+ input: { hint: "arguments" },
86
+ source,
87
+ });
88
+ }
89
+
90
+ const fileCommands = await loadFileCommands(session.sessionManager.getCwd());
91
+ session.setSlashCommands(fileCommands);
92
+ for (const command of fileCommands) {
93
+ appendCommand({ name: command.name, description: command.description, source: "file" });
94
+ }
95
+
96
+ return commands;
97
+ }
98
+
99
+ export function toAcpAvailableCommands(commands: readonly InternalAvailableSlashCommand[]): AvailableCommand[] {
100
+ return commands.map(command => ({
101
+ name: command.name,
102
+ description: command.description ?? "",
103
+ input: command.input,
104
+ }));
105
+ }
package/src/tools/bash.ts CHANGED
@@ -368,7 +368,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
368
368
  readonly loadMode = "essential";
369
369
  readonly description: string;
370
370
  readonly parameters: BashToolSchema;
371
- readonly concurrency = "exclusive";
371
+ // Non-pty calls run alongside each other (the executor isolates overlapping
372
+ // runs on the same shell session); pty takes over the terminal UI and must
373
+ // run alone.
374
+ readonly concurrency = (args: Partial<BashToolInput>): "shared" | "exclusive" =>
375
+ args.pty === true ? "exclusive" : "shared";
372
376
  readonly strict = true;
373
377
  readonly #asyncEnabled: boolean;
374
378
  readonly #autoBackgroundEnabled: boolean;
@@ -1,6 +1,6 @@
1
1
  import * as net from "node:net";
2
2
  import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
3
- import type { Browser, Page } from "puppeteer-core";
3
+ import { type Browser, type Page, TargetType } from "puppeteer-core";
4
4
  import { ToolError, throwIfAborted } from "../tool-errors";
5
5
 
6
6
  const ATTACH_TARGET_SKIP_PATTERN =
@@ -119,22 +119,41 @@ export async function findReusableCdp(
119
119
  }
120
120
 
121
121
  /**
122
- * Pick the best page target on an attached browser. Without a matcher, prefer
123
- * a page that doesn't look like a helper window (devtools, request handler,
124
- * background pages); with a matcher, return the first url+title substring hit.
122
+ * Pick the best page target on an attached browser. Prefer discoverable page
123
+ * targets first so Chromium/Edge attach flows that hide pages from
124
+ * `browser.pages()` can still return a usable tab.
125
125
  */
126
126
  export async function pickElectronTarget(browser: Browser, matcher?: string): Promise<Page> {
127
- const pages = await browser.pages();
128
- if (!pages.length) {
127
+ const discoveredPages = await Promise.all(
128
+ browser.targets().map(async target => {
129
+ if (target.type() !== TargetType.PAGE) return null;
130
+ return await target.page().catch(() => null);
131
+ }),
132
+ );
133
+ const usablePages = discoveredPages.filter((page): page is Page => page !== null);
134
+ if (usablePages.length > 0) {
135
+ return pickPageFromList(usablePages, matcher);
136
+ }
137
+
138
+ const fallbackPages = await browser.pages();
139
+ if (!fallbackPages.length) {
129
140
  throw new ToolError("No page targets available on the attached browser");
130
141
  }
131
- const enriched = await Promise.all(
142
+ return pickPageFromList(fallbackPages, matcher);
143
+ }
144
+
145
+ async function enrichPages(pages: Page[]): Promise<Array<{ page: Page; url: string; title: string }>> {
146
+ return await Promise.all(
132
147
  pages.map(async page => ({
133
148
  page,
134
149
  url: page.url(),
135
150
  title: ((await page.title().catch(() => "")) ?? "").trim(),
136
151
  })),
137
152
  );
153
+ }
154
+
155
+ async function pickPageFromList(pages: Page[], matcher?: string): Promise<Page> {
156
+ const enriched = await enrichPages(pages);
138
157
  if (matcher) {
139
158
  const needle = matcher.toLowerCase();
140
159
  const hit = enriched.find(p => p.url.toLowerCase().includes(needle) || p.title.toLowerCase().includes(needle));
@@ -58,6 +58,16 @@ export async function acquireBrowser(kind: BrowserKind, opts: AcquireBrowserOpti
58
58
  return handle;
59
59
  }
60
60
 
61
+ export function normalizeConnectedCdpUrl(rawCdpUrl: string): string {
62
+ const cdpUrl = rawCdpUrl.replace(/\/+$/, "");
63
+ if (/^wss?:\/\//i.test(cdpUrl)) {
64
+ throw new ToolError(
65
+ "browser app.cdp_url must be the HTTP CDP discovery endpoint (for example http://127.0.0.1:9222), not a ws:// browser websocket URL.",
66
+ );
67
+ }
68
+ return cdpUrl;
69
+ }
70
+
61
71
  async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions): Promise<BrowserHandle> {
62
72
  if (kind.kind === "headless") {
63
73
  const browser = await launchHeadlessBrowser({ headless: kind.headless, viewport: opts.viewport });
@@ -70,7 +80,7 @@ async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions)
70
80
  };
71
81
  }
72
82
  if (kind.kind === "connected") {
73
- const cdpUrl = kind.cdpUrl.replace(/\/+$/, "");
83
+ const cdpUrl = normalizeConnectedCdpUrl(kind.cdpUrl);
74
84
  await waitForCdp(cdpUrl, 5_000, opts.signal);
75
85
  const puppeteer = await loadPuppeteer();
76
86
  const browser = await puppeteer.connect({
package/src/tools/irc.ts CHANGED
@@ -234,7 +234,15 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
234
234
  // through the bus unfiltered so parked recipients are revived.
235
235
  const targets = isBroadcast ? registry.listVisibleTo(senderId).map(ref => ref.id) : [to];
236
236
  const receipts = await Promise.all(
237
- targets.map(target => bus.send({ from: senderId, to: target, body: message, replyTo: params.replyTo })),
237
+ targets.map(target =>
238
+ bus.send(
239
+ { from: senderId, to: target, body: message, replyTo: params.replyTo },
240
+ // Awaited sends mark the sender as blocked on an answer so a
241
+ // busy recipient that cannot reach a step boundary (async
242
+ // disabled) auto-replies instead of stranding the sender.
243
+ params.await ? { expectsReply: true } : undefined,
244
+ ),
245
+ ),
238
246
  );
239
247
 
240
248
  const lines: string[] = [];
@@ -457,13 +465,14 @@ function callMeta(args: IrcRenderArgs | undefined): string[] {
457
465
 
458
466
  /**
459
467
  * Display-only transcript card for live IRC traffic: `irc:incoming` DMs
460
- * delivered to this session and `irc:relay` observations of agent↔agent
468
+ * delivered to this session, `irc:autoreply` side-channel replies sent on
469
+ * this session's behalf, and `irc:relay` observations of agent↔agent
461
470
  * traffic. Shares the tool renderer's glyph + quote-border conventions so
462
471
  * cards and `irc` tool output look identical in the transcript.
463
472
  */
464
473
  export function createIrcMessageCard(
465
474
  card: {
466
- kind: "incoming" | "relay";
475
+ kind: "incoming" | "autoreply" | "relay";
467
476
  from?: string;
468
477
  to?: string;
469
478
  body?: string;
@@ -477,9 +486,12 @@ export function createIrcMessageCard(
477
486
  const title =
478
487
  card.kind === "incoming"
479
488
  ? `IRC ${uiTheme.nav.back} ${from}`
480
- : `IRC ${from} ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`;
489
+ : card.kind === "autoreply"
490
+ ? `IRC ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`
491
+ : `IRC ${from} ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`;
481
492
  const body = card.body ?? "";
482
493
  const meta: string[] = [];
494
+ if (card.kind === "autoreply") meta.push("auto");
483
495
  if (card.replyTo) meta.push("reply");
484
496
  const age = messageAge(card.timestamp);
485
497
  if (age) meta.push(age);
package/src/tools/job.ts CHANGED
@@ -449,17 +449,21 @@ export const jobToolRenderer = {
449
449
  const counts = { completed: 0, failed: 0, cancelled: 0, running: 0 };
450
450
  for (const job of jobs) counts[job.status]++;
451
451
 
452
+ // The title already carries the running count, so meta lists only the
453
+ // settled categories — "waiting on 19 of 19 · 19 running" read awkward.
452
454
  const meta: string[] = [];
453
455
  if (counts.completed > 0) meta.push(uiTheme.fg("success", `${counts.completed} done`));
454
456
  if (counts.failed > 0) meta.push(uiTheme.fg("error", `${counts.failed} failed`));
455
457
  if (counts.cancelled > 0) meta.push(uiTheme.fg("warning", `${counts.cancelled} cancelled`));
456
- if (counts.running > 0) meta.push(uiTheme.fg("accent", `${counts.running} running`));
457
458
 
458
459
  const headerIcon: ToolUIStatus = counts.failed > 0 ? "warning" : counts.running > 0 ? "info" : "success";
460
+ const jobsNoun = jobs.length === 1 ? "job" : "jobs";
459
461
  const description =
460
462
  counts.running > 0
461
- ? `waiting on ${counts.running} of ${jobs.length}`
462
- : `${jobs.length} ${jobs.length === 1 ? "job" : "jobs"} settled`;
463
+ ? counts.running === jobs.length
464
+ ? `waiting on ${jobs.length} ${jobsNoun}`
465
+ : `waiting on ${counts.running} of ${jobs.length} ${jobsNoun}`
466
+ : `${jobs.length} ${jobsNoun} settled`;
463
467
 
464
468
  const header = renderStatusLine(
465
469
  {