@oh-my-pi/pi-coding-agent 15.11.1 → 15.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -1
- package/dist/cli.js +629 -614
- 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/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 +0 -4
- 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/defaults.json +6 -0
- package/src/lsp/render.ts +2 -28
- package/src/memories/index.ts +2 -0
- 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 +32 -0
- 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 +95 -6
- 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 +22 -15
|
@@ -11,6 +11,7 @@ import type { BashResult } from "../../exec/bash-executor";
|
|
|
11
11
|
import type { ContextUsage } from "../../extensibility/extensions/types";
|
|
12
12
|
import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
|
|
13
13
|
import type { FileEntry } from "../../session/session-manager";
|
|
14
|
+
import type { AvailableSlashCommandSource } from "../../slash-commands/available-commands";
|
|
14
15
|
import type {
|
|
15
16
|
AgentProgress,
|
|
16
17
|
SubagentEventPayload,
|
|
@@ -34,6 +35,7 @@ export type RpcCommand =
|
|
|
34
35
|
|
|
35
36
|
// State
|
|
36
37
|
| { id?: string; type: "get_state" }
|
|
38
|
+
| { id?: string; type: "get_available_commands" }
|
|
37
39
|
| { id?: string; type: "set_todos"; phases: TodoPhase[] }
|
|
38
40
|
| { id?: string; type: "set_host_tools"; tools: RpcHostToolDefinition[] }
|
|
39
41
|
| { id?: string; type: "set_host_uri_schemes"; schemes: RpcHostUriSchemeDefinition[] }
|
|
@@ -110,6 +112,20 @@ export interface RpcSessionState {
|
|
|
110
112
|
contextUsage?: ContextUsage;
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
export interface RpcAvailableSlashCommand {
|
|
116
|
+
name: string;
|
|
117
|
+
aliases?: string[];
|
|
118
|
+
description?: string;
|
|
119
|
+
input?: { hint?: string };
|
|
120
|
+
subcommands?: Array<{ name: string; description?: string; usage?: string }>;
|
|
121
|
+
source: AvailableSlashCommandSource;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface RpcAvailableCommandsUpdateFrame {
|
|
125
|
+
type: "available_commands_update";
|
|
126
|
+
commands: RpcAvailableSlashCommand[];
|
|
127
|
+
}
|
|
128
|
+
|
|
113
129
|
export interface RpcHandoffResult {
|
|
114
130
|
savedPath?: string;
|
|
115
131
|
}
|
|
@@ -156,6 +172,13 @@ export type RpcResponse =
|
|
|
156
172
|
|
|
157
173
|
// State
|
|
158
174
|
| { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
|
|
175
|
+
| {
|
|
176
|
+
id?: string;
|
|
177
|
+
type: "response";
|
|
178
|
+
command: "get_available_commands";
|
|
179
|
+
success: true;
|
|
180
|
+
data: { commands: RpcAvailableSlashCommand[] };
|
|
181
|
+
}
|
|
159
182
|
| { id?: string; type: "response"; command: "set_todos"; success: true; data: { todoPhases: TodoPhase[] } }
|
|
160
183
|
| { id?: string; type: "response"; command: "set_host_tools"; success: true; data: { toolNames: string[] } }
|
|
161
184
|
| { id?: string; type: "response"; command: "set_host_uri_schemes"; success: true; data: { schemes: string[] } }
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -2565,10 +2565,10 @@ const HIGHLIGHT_CACHE_MAX = 256;
|
|
|
2565
2565
|
const highlightCache = new LRUCache<string, string>({ max: HIGHLIGHT_CACHE_MAX });
|
|
2566
2566
|
let highlightCacheTheme: Theme | undefined;
|
|
2567
2567
|
|
|
2568
|
-
function highlightCached(code: string, validLang: string | undefined): string | null {
|
|
2569
|
-
if (highlightCacheTheme !==
|
|
2568
|
+
function highlightCached(code: string, validLang: string | undefined, highlightTheme: Theme): string | null {
|
|
2569
|
+
if (highlightCacheTheme !== highlightTheme) {
|
|
2570
2570
|
highlightCache.clear();
|
|
2571
|
-
highlightCacheTheme =
|
|
2571
|
+
highlightCacheTheme = highlightTheme;
|
|
2572
2572
|
}
|
|
2573
2573
|
const key = `${validLang ?? ""}\x00${code}`;
|
|
2574
2574
|
const hit = highlightCache.get(key);
|
|
@@ -2577,7 +2577,7 @@ function highlightCached(code: string, validLang: string | undefined): string |
|
|
|
2577
2577
|
}
|
|
2578
2578
|
let highlighted: string;
|
|
2579
2579
|
try {
|
|
2580
|
-
highlighted = nativeHighlightCode(code, validLang, getHighlightColors(
|
|
2580
|
+
highlighted = nativeHighlightCode(code, validLang, getHighlightColors(highlightTheme));
|
|
2581
2581
|
} catch {
|
|
2582
2582
|
return null;
|
|
2583
2583
|
}
|
|
@@ -2589,9 +2589,9 @@ function highlightCached(code: string, validLang: string | undefined): string |
|
|
|
2589
2589
|
* Highlight code with syntax coloring based on file extension or language.
|
|
2590
2590
|
* Returns array of highlighted lines.
|
|
2591
2591
|
*/
|
|
2592
|
-
export function highlightCode(code: string, lang?: string): string[] {
|
|
2592
|
+
export function highlightCode(code: string, lang?: string, highlightTheme: Theme = theme): string[] {
|
|
2593
2593
|
const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
|
|
2594
|
-
const highlighted = highlightCached(code, validLang);
|
|
2594
|
+
const highlighted = highlightCached(code, validLang, highlightTheme);
|
|
2595
2595
|
// Always return a fresh array: callers (e.g. renderCodeCell) push extra lines
|
|
2596
2596
|
// onto the result, which would corrupt the cached string otherwise.
|
|
2597
2597
|
return (highlighted ?? code).split("\n");
|
|
@@ -2639,7 +2639,7 @@ export function getMarkdownTheme(): MarkdownTheme {
|
|
|
2639
2639
|
resolveMermaidAscii,
|
|
2640
2640
|
highlightCode: (code: string, lang?: string): string[] => {
|
|
2641
2641
|
const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
|
|
2642
|
-
const highlighted = highlightCached(code, validLang);
|
|
2642
|
+
const highlighted = highlightCached(code, validLang, theme);
|
|
2643
2643
|
if (highlighted !== null) return highlighted.split("\n");
|
|
2644
2644
|
return code.split("\n").map(line => theme.fg("mdCodeBlock", line));
|
|
2645
2645
|
},
|
|
@@ -191,7 +191,11 @@ export class UiHelpers {
|
|
|
191
191
|
this.ctx.chatContainer.addChild(component);
|
|
192
192
|
break;
|
|
193
193
|
}
|
|
194
|
-
if (
|
|
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
|
|
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
|
|
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,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.
|
package/src/prompts/tools/irc.md
CHANGED
|
@@ -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
|
|
12
|
+
- Nobody answers on a peer's behalf — a reply normally arrives only when the recipient sends one — with one exception: `send` with `await: true` to a peer that is mid-turn and cannot reach a step boundary (async execution disabled, e.g. blocked in a synchronous task spawn) gets a side-channel auto-reply generated from that peer's context. For background on what a peer has been doing, `read` `history://<id>` instead of interrogating them.
|
|
13
13
|
</instruction>
|
|
14
14
|
|
|
15
15
|
<when_to_use>
|
|
@@ -171,7 +171,7 @@ import { GoalRuntime } from "../goals/runtime";
|
|
|
171
171
|
import type { Goal, GoalModeState } from "../goals/state";
|
|
172
172
|
import type { HindsightSessionState } from "../hindsight/state";
|
|
173
173
|
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
|
|
174
|
-
import type
|
|
174
|
+
import { IrcBus, type IrcMessage } from "../irc/bus";
|
|
175
175
|
import { resolveMemoryBackend } from "../memory-backend";
|
|
176
176
|
import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state";
|
|
177
177
|
import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
|
|
@@ -185,6 +185,7 @@ import type { PlanModeState } from "../plan-mode/state";
|
|
|
185
185
|
import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
|
|
186
186
|
import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
|
|
187
187
|
import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
|
|
188
|
+
import ircAutoReplyTemplate from "../prompts/system/irc-autoreply.md" with { type: "text" };
|
|
188
189
|
import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
|
|
189
190
|
import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
|
|
190
191
|
import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
|
|
@@ -298,6 +299,7 @@ export type AgentSessionEvent =
|
|
|
298
299
|
|
|
299
300
|
/** Listener function for agent session events */
|
|
300
301
|
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
|
302
|
+
export type CommandMetadataChangedListener = () => void | Promise<void>;
|
|
301
303
|
export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
|
|
302
304
|
|
|
303
305
|
const EMPTY_STOP_MAX_RETRIES = 3;
|
|
@@ -884,6 +886,7 @@ export class AgentSession {
|
|
|
884
886
|
/** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
|
|
885
887
|
#lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
|
|
886
888
|
#eventListeners: AgentSessionEventListener[] = [];
|
|
889
|
+
#commandMetadataChangedListeners: CommandMetadataChangedListener[] = [];
|
|
887
890
|
|
|
888
891
|
/** Tracks pending steering messages for UI display. Removed when delivered.
|
|
889
892
|
* Entry shape: `{ text }` for plain-text steers (user-message dequeue
|
|
@@ -3034,6 +3037,27 @@ export class AgentSession {
|
|
|
3034
3037
|
};
|
|
3035
3038
|
}
|
|
3036
3039
|
|
|
3040
|
+
subscribeCommandMetadataChanged(listener: CommandMetadataChangedListener): () => void {
|
|
3041
|
+
this.#commandMetadataChangedListeners.push(listener);
|
|
3042
|
+
return () => {
|
|
3043
|
+
const index = this.#commandMetadataChangedListeners.indexOf(listener);
|
|
3044
|
+
if (index !== -1) {
|
|
3045
|
+
this.#commandMetadataChangedListeners.splice(index, 1);
|
|
3046
|
+
}
|
|
3047
|
+
};
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
#notifyCommandMetadataChanged(): void {
|
|
3051
|
+
const listeners = [...this.#commandMetadataChangedListeners];
|
|
3052
|
+
for (const listener of listeners) {
|
|
3053
|
+
try {
|
|
3054
|
+
void listener();
|
|
3055
|
+
} catch (err) {
|
|
3056
|
+
logger.error("Command metadata listener threw", { err });
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3037
3061
|
/**
|
|
3038
3062
|
* Temporarily disconnect from agent events.
|
|
3039
3063
|
* User listeners are preserved and will receive events again after resubscribe().
|
|
@@ -4350,9 +4374,15 @@ export class AgentSession {
|
|
|
4350
4374
|
return [...this.#customCommands, ...this.#mcpPromptCommands];
|
|
4351
4375
|
}
|
|
4352
4376
|
|
|
4377
|
+
/** MCP prompt commands only, for command-list metadata. */
|
|
4378
|
+
get mcpPromptCommands(): ReadonlyArray<LoadedCustomCommand> {
|
|
4379
|
+
return this.#mcpPromptCommands;
|
|
4380
|
+
}
|
|
4381
|
+
|
|
4353
4382
|
/** Update the MCP prompt commands list. Called when server prompts are (re)loaded. */
|
|
4354
4383
|
setMCPPromptCommands(commands: LoadedCustomCommand[]): void {
|
|
4355
4384
|
this.#mcpPromptCommands = commands;
|
|
4385
|
+
this.#notifyCommandMetadataChanged();
|
|
4356
4386
|
}
|
|
4357
4387
|
|
|
4358
4388
|
// =========================================================================
|
|
@@ -4465,12 +4495,16 @@ export class AgentSession {
|
|
|
4465
4495
|
return { ...message, content: normalized } as T;
|
|
4466
4496
|
}
|
|
4467
4497
|
|
|
4498
|
+
#magicKeywordEnabled(keyword: "orchestrate" | "ultrathink" | "workflow"): boolean {
|
|
4499
|
+
return this.settings.get("magicKeywords.enabled") && this.settings.get(`magicKeywords.${keyword}`);
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4468
4502
|
#createMagicKeywordNotices(text: string): CustomMessage[] {
|
|
4469
4503
|
const timestamp = Date.now();
|
|
4470
4504
|
const turnBudget = parseTurnBudget(text);
|
|
4471
4505
|
this.sessionManager.beginTurnBudget(turnBudget?.total ?? null, turnBudget?.hard ?? false);
|
|
4472
4506
|
const keywordNotices: CustomMessage[] = [];
|
|
4473
|
-
if (containsUltrathink(text)) {
|
|
4507
|
+
if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(text)) {
|
|
4474
4508
|
keywordNotices.push({
|
|
4475
4509
|
role: "custom",
|
|
4476
4510
|
customType: "ultrathink-notice",
|
|
@@ -4480,7 +4514,7 @@ export class AgentSession {
|
|
|
4480
4514
|
timestamp,
|
|
4481
4515
|
});
|
|
4482
4516
|
}
|
|
4483
|
-
if (containsOrchestrate(text)) {
|
|
4517
|
+
if (this.#magicKeywordEnabled("orchestrate") && containsOrchestrate(text)) {
|
|
4484
4518
|
keywordNotices.push({
|
|
4485
4519
|
role: "custom",
|
|
4486
4520
|
customType: "orchestrate-notice",
|
|
@@ -4490,7 +4524,7 @@ export class AgentSession {
|
|
|
4490
4524
|
timestamp,
|
|
4491
4525
|
});
|
|
4492
4526
|
}
|
|
4493
|
-
if (containsWorkflow(text)) {
|
|
4527
|
+
if (this.#magicKeywordEnabled("workflow") && containsWorkflow(text)) {
|
|
4494
4528
|
keywordNotices.push({
|
|
4495
4529
|
role: "custom",
|
|
4496
4530
|
customType: "workflow-notice",
|
|
@@ -5921,7 +5955,7 @@ export class AgentSession {
|
|
|
5921
5955
|
if (!model?.reasoning) return;
|
|
5922
5956
|
|
|
5923
5957
|
let resolved: Effort | undefined;
|
|
5924
|
-
if (containsUltrathink(promptText)) {
|
|
5958
|
+
if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(promptText)) {
|
|
5925
5959
|
// The user explicitly asked for maximum thinking; bypass the classifier
|
|
5926
5960
|
// and jump straight to the highest auto-supported level for this model.
|
|
5927
5961
|
resolved = clampAutoThinkingEffort(model, Effort.XHigh);
|
|
@@ -9118,11 +9152,20 @@ export class AgentSession {
|
|
|
9118
9152
|
* → "woken".
|
|
9119
9153
|
*
|
|
9120
9154
|
* Never blocks on the recipient's turn: the wake turn is fire-and-forget.
|
|
9155
|
+
*
|
|
9156
|
+
* When the sender expects a reply (`send await:true`) and this session is
|
|
9157
|
+
* mid-turn with async execution disabled, the next step boundary may be
|
|
9158
|
+
* gated on the sender's own batch finishing (blocking task spawns), so a
|
|
9159
|
+
* real reply turn can never happen in time. In that case an ephemeral
|
|
9160
|
+
* side-channel auto-reply is generated from the current context (the old
|
|
9161
|
+
* `respondAsBackground` path) and sent back over the bus on this agent's
|
|
9162
|
+
* behalf.
|
|
9121
9163
|
*/
|
|
9122
|
-
async deliverIrcMessage(msg: IrcMessage): Promise<"injected" | "woken"> {
|
|
9164
|
+
async deliverIrcMessage(msg: IrcMessage, opts?: { expectsReply?: boolean }): Promise<"injected" | "woken"> {
|
|
9123
9165
|
if (this.#isDisposed) {
|
|
9124
9166
|
throw new Error("Recipient session is disposed.");
|
|
9125
9167
|
}
|
|
9168
|
+
const autoReply = (opts?.expectsReply ?? false) && this.isStreaming && !this.settings.get("async.enabled");
|
|
9126
9169
|
const record: CustomMessage = {
|
|
9127
9170
|
role: "custom",
|
|
9128
9171
|
customType: "irc:incoming",
|
|
@@ -9130,6 +9173,7 @@ export class AgentSession {
|
|
|
9130
9173
|
from: msg.from,
|
|
9131
9174
|
message: msg.body,
|
|
9132
9175
|
replyTo: msg.replyTo ?? "",
|
|
9176
|
+
autoReplied: autoReply,
|
|
9133
9177
|
}),
|
|
9134
9178
|
display: true,
|
|
9135
9179
|
details: { id: msg.id, from: msg.from, message: msg.body, ...(msg.replyTo ? { replyTo: msg.replyTo } : {}) },
|
|
@@ -9139,6 +9183,7 @@ export class AgentSession {
|
|
|
9139
9183
|
void this.#emitSessionEvent({ type: "irc_message", message: record });
|
|
9140
9184
|
if (this.isStreaming) {
|
|
9141
9185
|
this.#pendingIrcAsides.push(record);
|
|
9186
|
+
if (autoReply) void this.#runIrcAutoReply(msg);
|
|
9142
9187
|
return "injected";
|
|
9143
9188
|
}
|
|
9144
9189
|
// Idle: same wake primitive the yield queue uses for async-result
|
|
@@ -9149,6 +9194,50 @@ export class AgentSession {
|
|
|
9149
9194
|
return "woken";
|
|
9150
9195
|
}
|
|
9151
9196
|
|
|
9197
|
+
/**
|
|
9198
|
+
* Generate and deliver an ephemeral auto-reply to `msg` on this agent's
|
|
9199
|
+
* behalf: a no-tools side-channel turn over the current history (same
|
|
9200
|
+
* pipeline as `/btw`), recorded into this session as an `irc:autoreply`
|
|
9201
|
+
* aside so the model knows what was said for it, and sent back to the
|
|
9202
|
+
* sender as a regular bus message (`replyTo: msg.id`) so their parked
|
|
9203
|
+
* `wait`/`await:true` resolves. Failures only log — the sender then hits
|
|
9204
|
+
* its normal wait timeout.
|
|
9205
|
+
*/
|
|
9206
|
+
async #runIrcAutoReply(msg: IrcMessage): Promise<void> {
|
|
9207
|
+
try {
|
|
9208
|
+
const { replyText } = await this.runEphemeralTurn({
|
|
9209
|
+
promptText: prompt.render(ircAutoReplyTemplate, {
|
|
9210
|
+
from: msg.from,
|
|
9211
|
+
message: msg.body,
|
|
9212
|
+
replyTo: msg.replyTo ?? "",
|
|
9213
|
+
}),
|
|
9214
|
+
});
|
|
9215
|
+
const body = replyText.trim();
|
|
9216
|
+
if (!body || this.#isDisposed) return;
|
|
9217
|
+
const record: CustomMessage = {
|
|
9218
|
+
role: "custom",
|
|
9219
|
+
customType: "irc:autoreply",
|
|
9220
|
+
content: `[IRC you → \`${msg.from}\` (auto)]\n\n${body}`,
|
|
9221
|
+
display: true,
|
|
9222
|
+
details: { to: msg.from, body, replyTo: msg.id },
|
|
9223
|
+
attribution: "agent",
|
|
9224
|
+
timestamp: Date.now(),
|
|
9225
|
+
};
|
|
9226
|
+
void this.#emitSessionEvent({ type: "irc_message", message: record });
|
|
9227
|
+
// Asides drain at the next step boundary; anything left over is
|
|
9228
|
+
// flushed at the start of the next prompt (#flushPendingIrcAsides).
|
|
9229
|
+
this.#pendingIrcAsides.push(record);
|
|
9230
|
+
// `from` must be the id the sender addressed (msg.to) so their
|
|
9231
|
+
// from-filtered waiter matches.
|
|
9232
|
+
const receipt = await IrcBus.global().send({ from: msg.to, to: msg.from, body, replyTo: msg.id });
|
|
9233
|
+
if (receipt.outcome === "failed") {
|
|
9234
|
+
logger.warn("IRC auto-reply delivery failed", { to: msg.from, error: receipt.error });
|
|
9235
|
+
}
|
|
9236
|
+
} catch (error) {
|
|
9237
|
+
logger.warn("IRC auto-reply turn failed", { from: msg.from, error: String(error) });
|
|
9238
|
+
}
|
|
9239
|
+
}
|
|
9240
|
+
|
|
9152
9241
|
/**
|
|
9153
9242
|
* Emit an IRC relay observation event on this session for UI rendering only.
|
|
9154
9243
|
* Does not persist the record to history. Called by the IrcBus to surface
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { AvailableCommand } from "@agentclientprotocol/sdk";
|
|
2
|
+
import type { SkillsSettings } from "../config/settings";
|
|
3
|
+
import type { LoadedCustomCommand } from "../extensibility/custom-commands";
|
|
4
|
+
import type { ExtensionRunner } from "../extensibility/extensions";
|
|
5
|
+
import { getSkillSlashCommandName, type Skill } from "../extensibility/skills";
|
|
6
|
+
import { type FileSlashCommand, loadSlashCommands } from "../extensibility/slash-commands";
|
|
7
|
+
import { ACP_BUILTIN_RESERVED_NAMES, isAcpBuiltinShadowedName } from "./acp-builtins";
|
|
8
|
+
import { BUILTIN_SLASH_COMMANDS_INTERNAL } from "./builtin-registry";
|
|
9
|
+
|
|
10
|
+
export type AvailableSlashCommandSource = "builtin" | "skill" | "extension" | "custom" | "mcp_prompt" | "file";
|
|
11
|
+
|
|
12
|
+
export interface InternalAvailableSlashCommand {
|
|
13
|
+
name: string;
|
|
14
|
+
aliases?: string[];
|
|
15
|
+
description?: string;
|
|
16
|
+
input?: { hint: string };
|
|
17
|
+
subcommands?: Array<{ name: string; description?: string; usage?: string }>;
|
|
18
|
+
source: AvailableSlashCommandSource;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AvailableCommandsSession {
|
|
22
|
+
readonly extensionRunner?: ExtensionRunner;
|
|
23
|
+
readonly customCommands: ReadonlyArray<LoadedCustomCommand>;
|
|
24
|
+
readonly mcpPromptCommands?: ReadonlyArray<LoadedCustomCommand>;
|
|
25
|
+
readonly skills: ReadonlyArray<Skill>;
|
|
26
|
+
readonly skillsSettings?: SkillsSettings;
|
|
27
|
+
setSlashCommands(slashCommands: FileSlashCommand[]): void;
|
|
28
|
+
sessionManager: { getCwd(): string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function buildAvailableSlashCommands(
|
|
32
|
+
session: AvailableCommandsSession,
|
|
33
|
+
loadFileCommands: (cwd: string) => Promise<FileSlashCommand[]> = cwd => loadSlashCommands({ cwd }),
|
|
34
|
+
): Promise<InternalAvailableSlashCommand[]> {
|
|
35
|
+
const commands: InternalAvailableSlashCommand[] = [];
|
|
36
|
+
const seenNames = new Set<string>();
|
|
37
|
+
const appendCommand = (command: InternalAvailableSlashCommand): void => {
|
|
38
|
+
if (seenNames.has(command.name)) return;
|
|
39
|
+
seenNames.add(command.name);
|
|
40
|
+
commands.push(command);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
for (const command of BUILTIN_SLASH_COMMANDS_INTERNAL) {
|
|
44
|
+
if (!command.handle) continue;
|
|
45
|
+
const hint = command.acpInputHint ?? command.inlineHint;
|
|
46
|
+
appendCommand({
|
|
47
|
+
name: command.name,
|
|
48
|
+
aliases: command.aliases,
|
|
49
|
+
description: command.acpDescription ?? command.description,
|
|
50
|
+
input: hint ? { hint } : undefined,
|
|
51
|
+
subcommands: command.subcommands,
|
|
52
|
+
source: "builtin",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (session.skillsSettings?.enableSkillCommands) {
|
|
57
|
+
for (const skill of session.skills) {
|
|
58
|
+
appendCommand({
|
|
59
|
+
name: getSkillSlashCommandName(skill),
|
|
60
|
+
description: skill.description || `Run ${skill.name} skill`,
|
|
61
|
+
input: { hint: "arguments" },
|
|
62
|
+
source: "skill",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const runner = session.extensionRunner;
|
|
68
|
+
if (runner) {
|
|
69
|
+
for (const command of runner.getRegisteredCommands(ACP_BUILTIN_RESERVED_NAMES)) {
|
|
70
|
+
if (isAcpBuiltinShadowedName(command.name)) continue;
|
|
71
|
+
appendCommand({
|
|
72
|
+
name: command.name,
|
|
73
|
+
description: command.description ?? "(extension command)",
|
|
74
|
+
input: { hint: "arguments" },
|
|
75
|
+
source: "extension",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const command of session.customCommands) {
|
|
81
|
+
const source: AvailableSlashCommandSource = command.path?.startsWith("mcp:") ? "mcp_prompt" : "custom";
|
|
82
|
+
appendCommand({
|
|
83
|
+
name: command.command.name,
|
|
84
|
+
description: command.command.description,
|
|
85
|
+
input: { hint: "arguments" },
|
|
86
|
+
source,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const fileCommands = await loadFileCommands(session.sessionManager.getCwd());
|
|
91
|
+
session.setSlashCommands(fileCommands);
|
|
92
|
+
for (const command of fileCommands) {
|
|
93
|
+
appendCommand({ name: command.name, description: command.description, source: "file" });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return commands;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function toAcpAvailableCommands(commands: readonly InternalAvailableSlashCommand[]): AvailableCommand[] {
|
|
100
|
+
return commands.map(command => ({
|
|
101
|
+
name: command.name,
|
|
102
|
+
description: command.description ?? "",
|
|
103
|
+
input: command.input,
|
|
104
|
+
}));
|
|
105
|
+
}
|
package/src/tools/bash.ts
CHANGED
|
@@ -368,7 +368,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
368
368
|
readonly loadMode = "essential";
|
|
369
369
|
readonly description: string;
|
|
370
370
|
readonly parameters: BashToolSchema;
|
|
371
|
-
|
|
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
|
{
|