@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.
- package/CHANGELOG.md +36 -1
- package/dist/cli.js +643 -627
- package/dist/types/config/settings-schema.d.ts +36 -0
- package/dist/types/extensibility/custom-commands/types.d.ts +6 -3
- package/dist/types/extensibility/custom-tools/loader.d.ts +2 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +8 -4
- package/dist/types/extensibility/extensions/types.d.ts +2 -2
- package/dist/types/extensibility/hooks/types.d.ts +8 -4
- package/dist/types/irc/bus.d.ts +15 -2
- package/dist/types/lsp/format-options.d.ts +32 -0
- package/dist/types/mnemopi/state.d.ts +29 -1
- package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +30 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +17 -3
- package/dist/types/slash-commands/available-commands.d.ts +34 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/browser/attach.d.ts +4 -4
- package/dist/types/tools/browser/registry.d.ts +1 -0
- package/dist/types/tools/irc.d.ts +3 -2
- package/dist/types/tools/path-utils.d.ts +5 -5
- package/dist/types/utils/git.d.ts +1 -1
- package/package.json +11 -11
- package/src/config/settings-schema.ts +40 -0
- package/src/exec/bash-executor.ts +21 -6
- package/src/extensibility/custom-commands/loader.ts +3 -1
- package/src/extensibility/custom-commands/types.ts +6 -3
- package/src/extensibility/custom-tools/loader.ts +4 -7
- package/src/extensibility/custom-tools/types.ts +8 -4
- package/src/extensibility/extensions/loader.ts +2 -1
- package/src/extensibility/extensions/types.ts +2 -2
- package/src/extensibility/hooks/loader.ts +3 -1
- package/src/extensibility/hooks/types.ts +8 -4
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/irc/bus.ts +14 -3
- package/src/lsp/clients/lsp-linter-client.ts +2 -10
- package/src/lsp/defaults.json +6 -0
- package/src/lsp/format-options.ts +119 -0
- package/src/lsp/index.ts +2 -10
- package/src/lsp/render.ts +2 -28
- package/src/memories/index.ts +2 -0
- package/src/mnemopi/backend.ts +4 -8
- package/src/mnemopi/state.ts +42 -3
- package/src/modes/acp/acp-agent.ts +4 -67
- package/src/modes/components/plan-review-overlay.ts +32 -3
- package/src/modes/controllers/streaming-reveal.ts +16 -8
- package/src/modes/interactive-mode.ts +54 -2
- package/src/modes/rpc/rpc-client.ts +32 -0
- package/src/modes/rpc/rpc-mode.ts +82 -7
- package/src/modes/rpc/rpc-types.ts +23 -0
- package/src/modes/theme/theme.ts +7 -7
- package/src/modes/utils/ui-helpers.ts +13 -4
- package/src/prompts/memories/consolidation_system.md +4 -0
- package/src/prompts/system/irc-autoreply.md +6 -0
- package/src/prompts/system/irc-incoming.md +1 -1
- package/src/prompts/tools/bash.md +1 -0
- package/src/prompts/tools/irc.md +1 -1
- package/src/session/agent-session.ts +96 -7
- package/src/slash-commands/available-commands.ts +105 -0
- package/src/tools/bash.ts +5 -1
- package/src/tools/browser/attach.ts +26 -7
- package/src/tools/browser/registry.ts +11 -1
- package/src/tools/irc.ts +16 -4
- package/src/tools/job.ts +7 -3
- package/src/tools/path-utils.ts +56 -25
- package/src/tools/search.ts +11 -0
- 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
|
|
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
|
-
|
|
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
|
|
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.
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
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 =>
|
|
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
|
|
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
|
-
:
|
|
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
|
-
?
|
|
462
|
-
|
|
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
|
{
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -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
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
597
|
-
|
|
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
|
|
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:
|
|
652
|
+
return { basePath: normalizedPattern, globPattern: "**/*", hasGlob: false };
|
|
646
653
|
}
|
|
647
654
|
|
|
648
655
|
if (firstGlobIndex === 0) {
|
|
649
|
-
const needsRecursive = !
|
|
656
|
+
const needsRecursive = !normalizedPattern.startsWith("**/");
|
|
650
657
|
return {
|
|
651
658
|
basePath: ".",
|
|
652
|
-
globPattern: needsRecursive ? `**/${
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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(
|
|
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
|
}
|