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

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 (69) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/dist/cli.js +643 -627
  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/lsp/format-options.d.ts +32 -0
  11. package/dist/types/mnemopi/state.d.ts +29 -1
  12. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  13. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  14. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  15. package/dist/types/modes/rpc/rpc-types.d.ts +30 -0
  16. package/dist/types/modes/theme/theme.d.ts +1 -1
  17. package/dist/types/session/agent-session.d.ts +17 -3
  18. package/dist/types/slash-commands/available-commands.d.ts +34 -0
  19. package/dist/types/tools/bash.d.ts +1 -1
  20. package/dist/types/tools/browser/attach.d.ts +4 -4
  21. package/dist/types/tools/browser/registry.d.ts +1 -0
  22. package/dist/types/tools/irc.d.ts +3 -2
  23. package/dist/types/tools/path-utils.d.ts +5 -5
  24. package/dist/types/utils/git.d.ts +1 -1
  25. package/package.json +11 -11
  26. package/src/config/settings-schema.ts +40 -0
  27. package/src/exec/bash-executor.ts +21 -6
  28. package/src/extensibility/custom-commands/loader.ts +3 -1
  29. package/src/extensibility/custom-commands/types.ts +6 -3
  30. package/src/extensibility/custom-tools/loader.ts +4 -7
  31. package/src/extensibility/custom-tools/types.ts +8 -4
  32. package/src/extensibility/extensions/loader.ts +2 -1
  33. package/src/extensibility/extensions/types.ts +2 -2
  34. package/src/extensibility/hooks/loader.ts +3 -1
  35. package/src/extensibility/hooks/types.ts +8 -4
  36. package/src/internal-urls/docs-index.generated.ts +4 -4
  37. package/src/irc/bus.ts +14 -3
  38. package/src/lsp/clients/lsp-linter-client.ts +2 -10
  39. package/src/lsp/defaults.json +6 -0
  40. package/src/lsp/format-options.ts +119 -0
  41. package/src/lsp/index.ts +2 -10
  42. package/src/lsp/render.ts +2 -28
  43. package/src/memories/index.ts +2 -0
  44. package/src/mnemopi/backend.ts +4 -8
  45. package/src/mnemopi/state.ts +42 -3
  46. package/src/modes/acp/acp-agent.ts +4 -67
  47. package/src/modes/components/plan-review-overlay.ts +32 -3
  48. package/src/modes/controllers/streaming-reveal.ts +16 -8
  49. package/src/modes/interactive-mode.ts +54 -2
  50. package/src/modes/rpc/rpc-client.ts +32 -0
  51. package/src/modes/rpc/rpc-mode.ts +82 -7
  52. package/src/modes/rpc/rpc-types.ts +23 -0
  53. package/src/modes/theme/theme.ts +7 -7
  54. package/src/modes/utils/ui-helpers.ts +13 -4
  55. package/src/prompts/memories/consolidation_system.md +4 -0
  56. package/src/prompts/system/irc-autoreply.md +6 -0
  57. package/src/prompts/system/irc-incoming.md +1 -1
  58. package/src/prompts/tools/bash.md +1 -0
  59. package/src/prompts/tools/irc.md +1 -1
  60. package/src/session/agent-session.ts +96 -7
  61. package/src/slash-commands/available-commands.ts +105 -0
  62. package/src/tools/bash.ts +5 -1
  63. package/src/tools/browser/attach.ts +26 -7
  64. package/src/tools/browser/registry.ts +11 -1
  65. package/src/tools/irc.ts +16 -4
  66. package/src/tools/job.ts +7 -3
  67. package/src/tools/path-utils.ts +56 -25
  68. package/src/tools/search.ts +11 -0
  69. package/src/utils/git.ts +7 -2
@@ -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().
@@ -3189,7 +3213,7 @@ export class AgentSession {
3189
3213
  this.setHindsightSessionState(undefined);
3190
3214
  hindsightState?.dispose();
3191
3215
  const mnemopiState = setMnemopiSessionState(this, undefined);
3192
- mnemopiState?.dispose();
3216
+ await mnemopiState?.dispose();
3193
3217
  this.#disconnectFromAgent();
3194
3218
  if (this.#unsubscribeAppendOnly) {
3195
3219
  this.#unsubscribeAppendOnly();
@@ -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
  {
@@ -398,6 +398,11 @@ export function formatPathRelativeToCwd(
398
398
  export function stripOuterDoubleQuotes(input: string): string {
399
399
  return input.startsWith('"') && input.endsWith('"') && input.length > 1 ? input.slice(1, -1) : input;
400
400
  }
401
+ function normalizePathSeparators(input: string): string {
402
+ if (isInternalUrlPath(input)) return input;
403
+ if (!input.includes("\\")) return input;
404
+ return input.replace(/\\/g, "/");
405
+ }
401
406
 
402
407
  export function normalizePathLikeInput(input: string): string {
403
408
  return stripOuterDoubleQuotes(input.trim());
@@ -582,19 +587,20 @@ export interface ResolvedMultiFindPattern {
582
587
  targets: ResolvedFindTarget[];
583
588
  scopePath: string;
584
589
  }
585
-
586
- /**
587
- * Split a user path into a base path + glob pattern for tools that delegate to
588
- * APIs accepting separate `path` and `glob` arguments.
589
- */
590
590
  export function parseSearchPath(filePath: string): ParsedSearchPath {
591
- const normalizedPath = filePath.replace(/\\/g, "/");
592
- if (!hasGlobPathChars(normalizedPath)) {
593
- return { basePath: filePath };
591
+ const normalizedPath = normalizePathSeparators(filePath);
592
+ const segments = normalizedPath.split("/");
593
+ let firstGlobIndex = -1;
594
+ for (let i = 0; i < segments.length; i++) {
595
+ if (hasGlobPathChars(segments[i])) {
596
+ firstGlobIndex = i;
597
+ break;
598
+ }
594
599
  }
595
600
 
596
- const segments = normalizedPath.split("/");
597
- const firstGlobIndex = segments.findIndex(segment => hasGlobPathChars(segment));
601
+ if (firstGlobIndex === -1) {
602
+ return { basePath: normalizedPath };
603
+ }
598
604
 
599
605
  if (firstGlobIndex <= 0) {
600
606
  return { basePath: ".", glob: normalizedPath };
@@ -617,7 +623,7 @@ export async function parseSearchPathPreferringLiteral(filePath: string, cwd: st
617
623
  if (!hasGlobPathChars(filePath) || isInternalUrlPath(filePath)) return parseSearchPath(filePath);
618
624
  try {
619
625
  await fs.promises.stat(resolveToCwd(filePath, cwd));
620
- return { basePath: filePath };
626
+ return { basePath: normalizePathSeparators(filePath) };
621
627
  } catch {
622
628
  return parseSearchPath(filePath);
623
629
  }
@@ -632,7 +638,8 @@ export async function parseSearchPathPreferringLiteral(filePath: string, cwd: st
632
638
  // /abs/path/**/\*.ts -> { basePath: "/abs/path", globPattern: "**/*.ts", hasGlob: true }
633
639
  // src/app -> { basePath: "src/app", globPattern: "**/*", hasGlob: false }
634
640
  export function parseFindPattern(pattern: string): ParsedFindPattern {
635
- const segments = pattern.split("/");
641
+ const normalizedPattern = normalizePathSeparators(pattern);
642
+ const segments = normalizedPattern.split("/");
636
643
  let firstGlobIndex = -1;
637
644
  for (let i = 0; i < segments.length; i++) {
638
645
  if (hasGlobPathChars(segments[i])) {
@@ -642,14 +649,14 @@ export function parseFindPattern(pattern: string): ParsedFindPattern {
642
649
  }
643
650
 
644
651
  if (firstGlobIndex === -1) {
645
- return { basePath: pattern, globPattern: "**/*", hasGlob: false };
652
+ return { basePath: normalizedPattern, globPattern: "**/*", hasGlob: false };
646
653
  }
647
654
 
648
655
  if (firstGlobIndex === 0) {
649
- const needsRecursive = !pattern.startsWith("**/");
656
+ const needsRecursive = !normalizedPattern.startsWith("**/");
650
657
  return {
651
658
  basePath: ".",
652
- globPattern: needsRecursive ? `**/${pattern}` : pattern,
659
+ globPattern: needsRecursive ? `**/${normalizedPattern}` : normalizedPattern,
653
660
  hasGlob: true,
654
661
  };
655
662
  }
@@ -722,6 +729,7 @@ async function resolveSearchPathItems(
722
729
  pathItems: string[],
723
730
  cwd: string,
724
731
  suffixGlob?: string,
732
+ fanOutFileItems = false,
725
733
  ): Promise<ResolvedMultiSearchPath | undefined> {
726
734
  if (pathItems.length < 1) {
727
735
  return undefined;
@@ -753,14 +761,27 @@ async function resolveSearchPathItems(
753
761
  }
754
762
  return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath;
755
763
  });
756
- const rootPath = path.parse(commonBasePath).root;
757
- const isDegenerateRoot = commonBasePath === rootPath && parsedItems.length > 1;
758
- const targets = isDegenerateRoot
759
- ? parsedItems.map(item => ({
760
- basePath: item.absoluteBasePath,
761
- glob: item.parsedPath.glob ? combineSearchGlobs(item.parsedPath.glob, suffixGlob) : suffixGlob,
762
- }))
763
- : undefined;
764
+ // A single walk rooted at the common ancestor is only safe when that
765
+ // ancestor is itself one of the requested scopes (e.g. `.` + `src/foo.ts`):
766
+ // the walk then covers exactly what the caller asked for. When the common
767
+ // ancestor is an unrequested parent (`.` + `~/.gitconfig` → `$HOME`, or
768
+ // disjoint trees → `/`), a collapsed walk traverses every unrelated sibling
769
+ // under it — fan out into per-item targets so each scan stays bounded to a
770
+ // requested path.
771
+ const commonIsRequestedScope = parsedItems.some(item => item.absoluteBasePath === commonBasePath);
772
+ // Walkers prune `.git` unconditionally and honor gitignore, so a plain-file
773
+ // item folded into a directory walk's glob union (`.` + `.git/config`) can
774
+ // silently never match. Callers that dedupe overlapping results opt in via
775
+ // `fanOutFileItems` to get explicit file targets, which bypass the walker.
776
+ const demotesFileItem =
777
+ fanOutFileItems && !allExactFiles && parsedItems.some(item => !item.parsedPath.glob && item.stat.isFile());
778
+ const targets =
779
+ parsedItems.length > 1 && (!commonIsRequestedScope || demotesFileItem)
780
+ ? parsedItems.map(item => ({
781
+ basePath: item.absoluteBasePath,
782
+ glob: item.parsedPath.glob ? combineSearchGlobs(item.parsedPath.glob, suffixGlob) : suffixGlob,
783
+ }))
784
+ : undefined;
764
785
 
765
786
  return {
766
787
  basePath: commonBasePath,
@@ -775,8 +796,9 @@ export async function resolveExplicitSearchPaths(
775
796
  pathItems: string[],
776
797
  cwd: string,
777
798
  suffixGlob?: string,
799
+ fanOutFileItems = false,
778
800
  ): Promise<ResolvedMultiSearchPath | undefined> {
779
- return resolveSearchPathItems([...new Set(pathItems)], cwd, suffixGlob);
801
+ return resolveSearchPathItems([...new Set(pathItems)], cwd, suffixGlob, fanOutFileItems);
780
802
  }
781
803
 
782
804
  async function resolveFindPatternItems(
@@ -921,6 +943,10 @@ export interface ToolScopeOptions {
921
943
  trackImmutableSources?: boolean;
922
944
  /** Honor `exactFilePaths` from {@link resolveExplicitSearchPaths} (search-only). */
923
945
  surfaceExactFilePaths?: boolean;
946
+ /** Fan plain-file entries out into per-target scans instead of folding them
947
+ * into a directory walk's glob union (search-only: the caller must dedupe
948
+ * matches from overlapping targets). */
949
+ fanOutFileTargets?: boolean;
924
950
  /** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
925
951
  multipathStatHint?: string;
926
952
  /** Calling session's settings — forwarded to the internal-URL router so caller-aware handlers (issue://, pr://) honor it. */
@@ -1017,7 +1043,12 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
1017
1043
  globFilter = parsedPath.glob;
1018
1044
  scopePath = formatPathRelativeToCwd(searchPath, cwd);
1019
1045
  } else {
1020
- const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, cwd);
1046
+ const multiSearchPath = await resolveExplicitSearchPaths(
1047
+ effectivePaths,
1048
+ cwd,
1049
+ undefined,
1050
+ opts.fanOutFileTargets === true,
1051
+ );
1021
1052
  if (!multiSearchPath) {
1022
1053
  throw new ToolError("`paths` must contain at least one path or glob");
1023
1054
  }