@oh-my-pi/pi-coding-agent 15.11.0 → 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 (102) hide show
  1. package/CHANGELOG.md +57 -2
  2. package/dist/cli.js +678 -657
  3. package/dist/types/capability/mcp.d.ts +1 -0
  4. package/dist/types/config/settings-schema.d.ts +49 -4
  5. package/dist/types/export/html/template.generated.d.ts +1 -1
  6. package/dist/types/extensibility/custom-commands/types.d.ts +6 -3
  7. package/dist/types/extensibility/custom-tools/loader.d.ts +2 -1
  8. package/dist/types/extensibility/custom-tools/types.d.ts +8 -4
  9. package/dist/types/extensibility/extensions/types.d.ts +2 -2
  10. package/dist/types/extensibility/hooks/types.d.ts +8 -4
  11. package/dist/types/irc/bus.d.ts +15 -2
  12. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  13. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  14. package/dist/types/mcp/transports/stdio.d.ts +1 -0
  15. package/dist/types/mcp/types.d.ts +2 -0
  16. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  17. package/dist/types/modes/components/mcp-add-wizard.d.ts +2 -1
  18. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  19. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  20. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  21. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  22. package/dist/types/modes/controllers/tool-args-reveal.d.ts +43 -0
  23. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  25. package/dist/types/modes/rpc/rpc-types.d.ts +30 -0
  26. package/dist/types/modes/theme/theme.d.ts +3 -2
  27. package/dist/types/session/agent-session.d.ts +17 -3
  28. package/dist/types/slash-commands/available-commands.d.ts +34 -0
  29. package/dist/types/task/index.d.ts +3 -3
  30. package/dist/types/tools/bash.d.ts +1 -1
  31. package/dist/types/tools/browser/attach.d.ts +4 -4
  32. package/dist/types/tools/browser/registry.d.ts +1 -0
  33. package/dist/types/tools/irc.d.ts +3 -2
  34. package/dist/types/tools/path-utils.d.ts +0 -4
  35. package/dist/types/tools/render-utils.d.ts +22 -0
  36. package/package.json +11 -11
  37. package/src/capability/mcp.ts +1 -0
  38. package/src/cli/gallery-cli.ts +5 -4
  39. package/src/config/mcp-schema.json +4 -0
  40. package/src/config/settings-schema.ts +55 -4
  41. package/src/edit/renderer.ts +96 -46
  42. package/src/exec/bash-executor.ts +21 -6
  43. package/src/export/html/template.generated.ts +1 -1
  44. package/src/export/html/template.js +6 -1
  45. package/src/extensibility/custom-commands/loader.ts +3 -1
  46. package/src/extensibility/custom-commands/types.ts +6 -3
  47. package/src/extensibility/custom-tools/loader.ts +4 -7
  48. package/src/extensibility/custom-tools/types.ts +8 -4
  49. package/src/extensibility/extensions/loader.ts +2 -1
  50. package/src/extensibility/extensions/types.ts +2 -2
  51. package/src/extensibility/hooks/loader.ts +3 -1
  52. package/src/extensibility/hooks/types.ts +8 -4
  53. package/src/internal-urls/docs-index.generated.ts +8 -8
  54. package/src/irc/bus.ts +14 -3
  55. package/src/lsp/defaults.json +6 -0
  56. package/src/lsp/render.ts +2 -28
  57. package/src/mcp/manager.ts +3 -0
  58. package/src/mcp/oauth-discovery.ts +27 -2
  59. package/src/mcp/oauth-flow.ts +47 -1
  60. package/src/mcp/transports/stdio.ts +3 -0
  61. package/src/mcp/types.ts +2 -0
  62. package/src/memories/index.ts +2 -0
  63. package/src/modes/acp/acp-agent.ts +4 -67
  64. package/src/modes/components/assistant-message.ts +15 -0
  65. package/src/modes/components/btw-panel.ts +5 -1
  66. package/src/modes/components/mcp-add-wizard.ts +13 -0
  67. package/src/modes/components/plan-review-overlay.ts +32 -3
  68. package/src/modes/components/settings-selector.ts +2 -0
  69. package/src/modes/components/status-line/component.ts +22 -12
  70. package/src/modes/components/status-line/types.ts +3 -0
  71. package/src/modes/components/transcript-container.ts +99 -18
  72. package/src/modes/components/tree-selector.ts +6 -1
  73. package/src/modes/controllers/event-controller.ts +28 -4
  74. package/src/modes/controllers/mcp-command-controller.ts +34 -2
  75. package/src/modes/controllers/selector-controller.ts +4 -0
  76. package/src/modes/controllers/streaming-reveal.ts +16 -8
  77. package/src/modes/controllers/tool-args-reveal.ts +174 -0
  78. package/src/modes/interactive-mode.ts +41 -2
  79. package/src/modes/rpc/rpc-client.ts +32 -0
  80. package/src/modes/rpc/rpc-mode.ts +82 -7
  81. package/src/modes/rpc/rpc-types.ts +23 -0
  82. package/src/modes/theme/theme.ts +13 -7
  83. package/src/modes/utils/ui-helpers.ts +13 -4
  84. package/src/prompts/memories/consolidation_system.md +4 -0
  85. package/src/prompts/system/irc-autoreply.md +6 -0
  86. package/src/prompts/system/irc-incoming.md +1 -1
  87. package/src/prompts/tools/bash.md +1 -0
  88. package/src/prompts/tools/irc.md +1 -1
  89. package/src/prompts/tools/task.md +7 -2
  90. package/src/session/agent-session.ts +120 -10
  91. package/src/slash-commands/available-commands.ts +105 -0
  92. package/src/task/index.ts +15 -10
  93. package/src/task/render.ts +10 -4
  94. package/src/tools/bash.ts +5 -1
  95. package/src/tools/browser/attach.ts +26 -7
  96. package/src/tools/browser/registry.ts +11 -1
  97. package/src/tools/irc.ts +16 -4
  98. package/src/tools/job.ts +7 -3
  99. package/src/tools/path-utils.ts +22 -15
  100. package/src/tools/render-utils.ts +56 -0
  101. package/src/tools/write.ts +65 -47
  102. package/src/web/search/providers/anthropic.ts +29 -4
@@ -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>
@@ -1,10 +1,15 @@
1
- {{#if batchEnabled}}Spawns subagents to work in the background — one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Spawns ONE subagent per call to work in the background.{{/if}}
1
+ {{#if asyncEnabled}}{{#if batchEnabled}}Spawns subagents to work in the background — one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Spawns ONE subagent per call to work in the background.{{/if}}
2
2
 
3
3
  - Spawning is non-blocking: the call returns immediately with the agent id{{#if batchEnabled}}s{{/if}} and job id{{#if batchEnabled}}s{{/if}}; each result is delivered automatically when that agent yields.
4
4
  - Parallelism = {{#if batchEnabled}}`tasks[]` items in one call, and/or multiple `task` calls in one assistant message{{else}}multiple `task` calls in one assistant message{{/if}}. Concurrency is bounded at {{MAX_CONCURRENCY}} running subagents per session.
5
5
  - If genuinely blocked on a result, wait with `job poll`; otherwise keep working. `job cancel` terminates a task and **cannot carry a message** — only for stalled/abandoned work.
6
+ {{else}}{{#if batchEnabled}}Runs subagents synchronously — one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Runs ONE subagent synchronously per call.{{/if}}
7
+
8
+ - Spawning is blocking: the call returns only after the agent{{#if batchEnabled}}s{{/if}} finish; results arrive inline.
9
+ - Parallelism = {{#if batchEnabled}}`tasks[]` items in one call, and/or multiple `task` calls in one assistant message{{else}}multiple `task` calls in one assistant message{{/if}}. Concurrency is bounded at {{MAX_CONCURRENCY}} running subagents per session.
10
+ {{/if}}
6
11
  {{#if ircEnabled}}
7
- - Coordinate with running agents via `irc` using their ids. Agents reach you and their siblings live the same way.
12
+ - Coordinate with agents via `irc` using their ids. Agents reach you and their siblings live the same way.
8
13
  {{/if}}
9
14
 
10
15
  <lifecycle>
@@ -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;
@@ -546,6 +548,7 @@ interface ActiveRetryFallbackState {
546
548
  originalSelector: string;
547
549
  originalThinkingLevel: ConfiguredThinkingLevel | undefined;
548
550
  lastAppliedFallbackThinkingLevel: ConfiguredThinkingLevel | undefined;
551
+ pinned: boolean;
549
552
  }
550
553
 
551
554
  function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined {
@@ -883,6 +886,7 @@ export class AgentSession {
883
886
  /** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
884
887
  #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
885
888
  #eventListeners: AgentSessionEventListener[] = [];
889
+ #commandMetadataChangedListeners: CommandMetadataChangedListener[] = [];
886
890
 
887
891
  /** Tracks pending steering messages for UI display. Removed when delivered.
888
892
  * Entry shape: `{ text }` for plain-text steers (user-message dequeue
@@ -3033,6 +3037,27 @@ export class AgentSession {
3033
3037
  };
3034
3038
  }
3035
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
+
3036
3061
  /**
3037
3062
  * Temporarily disconnect from agent events.
3038
3063
  * User listeners are preserved and will receive events again after resubscribe().
@@ -4349,9 +4374,15 @@ export class AgentSession {
4349
4374
  return [...this.#customCommands, ...this.#mcpPromptCommands];
4350
4375
  }
4351
4376
 
4377
+ /** MCP prompt commands only, for command-list metadata. */
4378
+ get mcpPromptCommands(): ReadonlyArray<LoadedCustomCommand> {
4379
+ return this.#mcpPromptCommands;
4380
+ }
4381
+
4352
4382
  /** Update the MCP prompt commands list. Called when server prompts are (re)loaded. */
4353
4383
  setMCPPromptCommands(commands: LoadedCustomCommand[]): void {
4354
4384
  this.#mcpPromptCommands = commands;
4385
+ this.#notifyCommandMetadataChanged();
4355
4386
  }
4356
4387
 
4357
4388
  // =========================================================================
@@ -4464,12 +4495,16 @@ export class AgentSession {
4464
4495
  return { ...message, content: normalized } as T;
4465
4496
  }
4466
4497
 
4498
+ #magicKeywordEnabled(keyword: "orchestrate" | "ultrathink" | "workflow"): boolean {
4499
+ return this.settings.get("magicKeywords.enabled") && this.settings.get(`magicKeywords.${keyword}`);
4500
+ }
4501
+
4467
4502
  #createMagicKeywordNotices(text: string): CustomMessage[] {
4468
4503
  const timestamp = Date.now();
4469
4504
  const turnBudget = parseTurnBudget(text);
4470
4505
  this.sessionManager.beginTurnBudget(turnBudget?.total ?? null, turnBudget?.hard ?? false);
4471
4506
  const keywordNotices: CustomMessage[] = [];
4472
- if (containsUltrathink(text)) {
4507
+ if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(text)) {
4473
4508
  keywordNotices.push({
4474
4509
  role: "custom",
4475
4510
  customType: "ultrathink-notice",
@@ -4479,7 +4514,7 @@ export class AgentSession {
4479
4514
  timestamp,
4480
4515
  });
4481
4516
  }
4482
- if (containsOrchestrate(text)) {
4517
+ if (this.#magicKeywordEnabled("orchestrate") && containsOrchestrate(text)) {
4483
4518
  keywordNotices.push({
4484
4519
  role: "custom",
4485
4520
  customType: "orchestrate-notice",
@@ -4489,7 +4524,7 @@ export class AgentSession {
4489
4524
  timestamp,
4490
4525
  });
4491
4526
  }
4492
- if (containsWorkflow(text)) {
4527
+ if (this.#magicKeywordEnabled("workflow") && containsWorkflow(text)) {
4493
4528
  keywordNotices.push({
4494
4529
  role: "custom",
4495
4530
  customType: "workflow-notice",
@@ -5920,7 +5955,7 @@ export class AgentSession {
5920
5955
  if (!model?.reasoning) return;
5921
5956
 
5922
5957
  let resolved: Effort | undefined;
5923
- if (containsUltrathink(promptText)) {
5958
+ if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(promptText)) {
5924
5959
  // The user explicitly asked for maximum thinking; bypass the classifier
5925
5960
  // and jump straight to the highest auto-supported level for this model.
5926
5961
  resolved = clampAutoThinkingEffort(model, Effort.XHigh);
@@ -8257,10 +8292,18 @@ export class AgentSession {
8257
8292
  const contextWindow = this.model?.contextWindow ?? 0;
8258
8293
  if (isContextOverflow(message, contextWindow)) return false;
8259
8294
 
8295
+ if (this.#isClassifierRefusal(message)) return true;
8296
+
8260
8297
  const err = message.errorMessage;
8261
8298
  return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
8262
8299
  }
8263
8300
 
8301
+ #isClassifierRefusal(message: AssistantMessage): boolean {
8302
+ if (message.stopReason !== "error") return false;
8303
+ const stopType = message.stopDetails?.type;
8304
+ return stopType === "refusal" || stopType === "sensitive";
8305
+ }
8306
+
8264
8307
  #isTransientErrorMessage(errorMessage: string): boolean {
8265
8308
  return (
8266
8309
  this.#isTransientEnvelopeErrorMessage(errorMessage) || this.#isTransientTransportErrorMessage(errorMessage)
@@ -8404,6 +8447,7 @@ export class AgentSession {
8404
8447
  role: string,
8405
8448
  selector: RetryFallbackSelector,
8406
8449
  currentSelector: string,
8450
+ options?: { pinFallback?: boolean },
8407
8451
  ): Promise<void> {
8408
8452
  const candidate = this.#modelRegistry.find(selector.provider, selector.id);
8409
8453
  if (!candidate) {
@@ -8429,9 +8473,11 @@ export class AgentSession {
8429
8473
  originalSelector: currentSelector,
8430
8474
  originalThinkingLevel: currentThinkingLevel,
8431
8475
  lastAppliedFallbackThinkingLevel: nextThinkingLevel,
8476
+ pinned: options?.pinFallback === true,
8432
8477
  };
8433
8478
  } else {
8434
8479
  this.#activeRetryFallback.lastAppliedFallbackThinkingLevel = nextThinkingLevel;
8480
+ this.#activeRetryFallback.pinned = this.#activeRetryFallback.pinned || options?.pinFallback === true;
8435
8481
  }
8436
8482
  await this.#emitSessionEvent({
8437
8483
  type: "retry_fallback_applied",
@@ -8441,7 +8487,7 @@ export class AgentSession {
8441
8487
  });
8442
8488
  }
8443
8489
 
8444
- async #tryRetryModelFallback(currentSelector: string): Promise<boolean> {
8490
+ async #tryRetryModelFallback(currentSelector: string, options?: { pinFallback?: boolean }): Promise<boolean> {
8445
8491
  const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
8446
8492
  if (!role) return false;
8447
8493
 
@@ -8451,7 +8497,7 @@ export class AgentSession {
8451
8497
  if (!candidate) continue;
8452
8498
  const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
8453
8499
  if (!apiKey) continue;
8454
- await this.#applyRetryFallbackCandidate(role, selector, currentSelector);
8500
+ await this.#applyRetryFallbackCandidate(role, selector, currentSelector, options);
8455
8501
  return true;
8456
8502
  }
8457
8503
 
@@ -8460,6 +8506,7 @@ export class AgentSession {
8460
8506
 
8461
8507
  async #maybeRestoreRetryFallbackPrimary(): Promise<void> {
8462
8508
  if (!this.#activeRetryFallback) return;
8509
+ if (this.#activeRetryFallback.pinned) return;
8463
8510
  if (this.#getRetryFallbackRevertPolicy() !== "cooldown-expiry") return;
8464
8511
 
8465
8512
  const {
@@ -8557,6 +8604,7 @@ export class AgentSession {
8557
8604
  async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
8558
8605
  const retrySettings = this.settings.getGroup("retry");
8559
8606
  if (!retrySettings.enabled) return false;
8607
+ const classifierRefusal = this.#isClassifierRefusal(message);
8560
8608
 
8561
8609
  const generation = this.#promptGeneration;
8562
8610
  this.#retryAttempt++;
@@ -8630,8 +8678,10 @@ export class AgentSession {
8630
8678
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
8631
8679
  if (!switchedCredential && currentSelector) {
8632
8680
  if (retrySettings.modelFallback) {
8633
- this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
8634
- switchedModel = await this.#tryRetryModelFallback(currentSelector);
8681
+ if (!classifierRefusal) {
8682
+ this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
8683
+ }
8684
+ switchedModel = await this.#tryRetryModelFallback(currentSelector, { pinFallback: classifierRefusal });
8635
8685
  }
8636
8686
  if (switchedModel) {
8637
8687
  delayMs = 0;
@@ -8639,6 +8689,11 @@ export class AgentSession {
8639
8689
  delayMs = parsedRetryAfterMs;
8640
8690
  }
8641
8691
  }
8692
+ if (classifierRefusal && !switchedModel) {
8693
+ this.#retryAttempt = 0;
8694
+ this.#resolveRetry();
8695
+ return false;
8696
+ }
8642
8697
 
8643
8698
  // Fail-fast cap: if the provider asks us to wait longer than
8644
8699
  // retry.maxDelayMs and we have no fallback credential or model to
@@ -9097,11 +9152,20 @@ export class AgentSession {
9097
9152
  * → "woken".
9098
9153
  *
9099
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.
9100
9163
  */
9101
- async deliverIrcMessage(msg: IrcMessage): Promise<"injected" | "woken"> {
9164
+ async deliverIrcMessage(msg: IrcMessage, opts?: { expectsReply?: boolean }): Promise<"injected" | "woken"> {
9102
9165
  if (this.#isDisposed) {
9103
9166
  throw new Error("Recipient session is disposed.");
9104
9167
  }
9168
+ const autoReply = (opts?.expectsReply ?? false) && this.isStreaming && !this.settings.get("async.enabled");
9105
9169
  const record: CustomMessage = {
9106
9170
  role: "custom",
9107
9171
  customType: "irc:incoming",
@@ -9109,6 +9173,7 @@ export class AgentSession {
9109
9173
  from: msg.from,
9110
9174
  message: msg.body,
9111
9175
  replyTo: msg.replyTo ?? "",
9176
+ autoReplied: autoReply,
9112
9177
  }),
9113
9178
  display: true,
9114
9179
  details: { id: msg.id, from: msg.from, message: msg.body, ...(msg.replyTo ? { replyTo: msg.replyTo } : {}) },
@@ -9118,6 +9183,7 @@ export class AgentSession {
9118
9183
  void this.#emitSessionEvent({ type: "irc_message", message: record });
9119
9184
  if (this.isStreaming) {
9120
9185
  this.#pendingIrcAsides.push(record);
9186
+ if (autoReply) void this.#runIrcAutoReply(msg);
9121
9187
  return "injected";
9122
9188
  }
9123
9189
  // Idle: same wake primitive the yield queue uses for async-result
@@ -9128,6 +9194,50 @@ export class AgentSession {
9128
9194
  return "woken";
9129
9195
  }
9130
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
+
9131
9241
  /**
9132
9242
  * Emit an IRC relay observation event on this session for UI rendering only.
9133
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/task/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * Supports:
10
10
  * - Single agent spawn per call (parallelism = parallel task calls)
11
11
  * - Batch spawning + shared context per call when `task.batch` is enabled
12
- * - Non-blocking execution via the session's AsyncJobManager
12
+ * - Background execution through AsyncJobManager when `async.enabled` is enabled
13
13
  * - Progress tracking via JSON events
14
14
  * - Session artifacts for debugging
15
15
  */
@@ -190,6 +190,7 @@ function renderDescription(
190
190
  isolationEnabled: boolean,
191
191
  disabledAgents: string[],
192
192
  batchEnabled: boolean,
193
+ asyncEnabled: boolean,
193
194
  ircEnabled: boolean,
194
195
  parentSpawns: string,
195
196
  ): string {
@@ -217,6 +218,7 @@ function renderDescription(
217
218
  MAX_CONCURRENCY: maxConcurrency,
218
219
  isolationEnabled,
219
220
  batchEnabled,
221
+ asyncEnabled,
220
222
  ircEnabled,
221
223
  });
222
224
  }
@@ -374,8 +376,8 @@ function discoverAgentsForCreate(cwd: string): Promise<DiscoveryResult> {
374
376
  * Task tool - Delegate tasks to specialized agents.
375
377
  *
376
378
  * Each call spawns one subagent — or, with `task.batch`, one per `tasks[]`
377
- * item. Spawning is non-blocking: the call registers AsyncJobManager jobs and
378
- * returns immediately; each result is delivered when that agent yields.
379
+ * item. When `async.enabled` is on, spawns run as AsyncJobManager jobs; when
380
+ * disabled, the tool blocks until every spawn finishes.
379
381
  */
380
382
  export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetails, Theme> {
381
383
  readonly name = "task";
@@ -411,7 +413,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
411
413
  return lines;
412
414
  };
413
415
  readonly label = "Task";
414
- readonly summary = "Spawn a subagent to complete a task in the background";
416
+ readonly summary = "Spawn subagents to complete delegated tasks";
415
417
  readonly strict = true;
416
418
  readonly loadMode = "discoverable";
417
419
  readonly renderResult = renderResult;
@@ -448,6 +450,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
448
450
  isolationMode !== "none",
449
451
  disabledAgents,
450
452
  this.#isBatchEnabled(),
453
+ this.session.settings.get("async.enabled"),
451
454
  isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0),
452
455
  this.session.getSessionSpawns() ?? "*",
453
456
  );
@@ -492,12 +495,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
492
495
 
493
496
  const spawnItems = resolveSpawnItems(params);
494
497
  const selectedAgent = this.#discoveredAgents.find(agent => agent.name === params.agent);
495
- const manager = this.session.asyncJobManager;
496
- if (!manager || selectedAgent?.blocking === true) {
497
- // Sync fallback: orphaned host that never wired a job manager, or an
498
- // agent definition that declares `blocking: true`. The session-scoped
499
- // semaphore still bounds fan-out across parallel task calls.
500
- if (!manager) {
498
+ const asyncEnabled = this.session.settings.get("async.enabled");
499
+ const manager = asyncEnabled ? this.session.asyncJobManager : undefined;
500
+ if (!asyncEnabled || !manager || selectedAgent?.blocking === true) {
501
+ // Sync fallback: async execution disabled, orphaned host that never
502
+ // wired a job manager, or an agent definition that declares
503
+ // `blocking: true`. The session-scoped semaphore still bounds fan-out
504
+ // across parallel task calls.
505
+ if (asyncEnabled && !manager) {
501
506
  logger.warn("task: no AsyncJobManager registered; falling back to sync execution");
502
507
  }
503
508
  return this.#executeSyncFanout(toolCallId, params, spawnItems, signal, onUpdate);
@@ -631,12 +631,18 @@ export function renderCall(
631
631
  // same agent (and the assignment brief) itself, so showing it here would
632
632
  // repeat what the result frame already shows.
633
633
  if (!options.renderContext?.hasResult) {
634
- sections.push({
635
- separator: true,
636
- lines: renderTaskCallLines(args, theme),
637
- });
634
+ // Mirror renderResult's layout — context, assignment, then the
635
+ // per-agent list — so the agent rows do not jump from above the
636
+ // brief to below it when the first progress snapshot replaces the
637
+ // call view. This also matches the schema's field order (`context`
638
+ // streams before `tasks`), so the streaming preview grows
639
+ // append-only instead of inserting agent rows above the
640
+ // already-rendered markdown and pushing it down on every item.
638
641
  if (contextSection) sections.push(contextSection(width));
639
642
  if (assignmentSection) sections.push(assignmentSection(width));
643
+ const callLines = renderTaskCallLines(args, theme);
644
+ // Guarded: an empty trailing section would still draw its divider.
645
+ if (callLines.length > 0) sections.push({ separator: true, lines: callLines });
640
646
  }
641
647
 
642
648
  return {
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;