@oh-my-pi/pi-coding-agent 14.9.9 → 15.0.0
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 +82 -0
- package/package.json +7 -7
- package/scripts/format-prompts.ts +1 -1
- package/src/cli/args.ts +2 -2
- package/src/cli.ts +1 -0
- package/src/commands/acp.ts +24 -0
- package/src/commands/launch.ts +6 -4
- package/src/commit/agentic/prompts/system.md +1 -1
- package/src/config/model-resolver.ts +30 -0
- package/src/config/settings-schema.ts +31 -0
- package/src/edit/index.ts +22 -1
- package/src/edit/modes/patch.ts +10 -0
- package/src/edit/modes/replace.ts +3 -0
- package/src/edit/renderer.ts +10 -0
- package/src/eval/js/context-manager.ts +1 -1
- package/src/eval/js/shared/rewrite-imports.ts +120 -48
- package/src/eval/js/shared/runtime.ts +31 -4
- package/src/eval/js/tool-bridge.ts +43 -21
- package/src/extensibility/extensions/runner.ts +54 -1
- package/src/extensibility/extensions/types.ts +11 -0
- package/src/extensibility/skills.ts +33 -1
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/issue-pr-protocol.ts +577 -0
- package/src/internal-urls/router.ts +6 -3
- package/src/internal-urls/types.ts +22 -1
- package/src/main.ts +13 -9
- package/src/modes/acp/acp-agent.ts +361 -54
- package/src/modes/acp/acp-client-bridge.ts +152 -0
- package/src/modes/acp/acp-event-mapper.ts +180 -15
- package/src/modes/acp/terminal-auth.ts +37 -0
- package/src/modes/components/read-tool-group.ts +29 -1
- package/src/modes/controllers/command-controller.ts +14 -6
- package/src/modes/controllers/event-controller.ts +24 -11
- package/src/modes/controllers/extension-ui-controller.ts +8 -2
- package/src/modes/controllers/input-controller.ts +72 -39
- package/src/modes/interactive-mode.ts +71 -7
- package/src/modes/rpc/rpc-mode.ts +17 -2
- package/src/modes/types.ts +6 -2
- package/src/modes/utils/ui-helpers.ts +15 -3
- package/src/prompts/agents/designer.md +5 -5
- package/src/prompts/agents/explore.md +7 -7
- package/src/prompts/agents/init.md +9 -9
- package/src/prompts/agents/librarian.md +14 -14
- package/src/prompts/agents/plan.md +4 -4
- package/src/prompts/agents/reviewer.md +5 -5
- package/src/prompts/agents/task.md +10 -10
- package/src/prompts/commands/orchestrate.md +2 -2
- package/src/prompts/compaction/branch-summary.md +3 -3
- package/src/prompts/compaction/compaction-short-summary.md +7 -7
- package/src/prompts/compaction/compaction-summary-context.md +1 -1
- package/src/prompts/compaction/compaction-summary.md +5 -5
- package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
- package/src/prompts/compaction/compaction-update-summary.md +11 -11
- package/src/prompts/memories/consolidation.md +2 -2
- package/src/prompts/memories/read-path.md +1 -1
- package/src/prompts/memories/stage_one_input.md +1 -1
- package/src/prompts/memories/stage_one_system.md +5 -5
- package/src/prompts/review-request.md +4 -4
- package/src/prompts/system/agent-creation-architect.md +17 -17
- package/src/prompts/system/agent-creation-user.md +2 -2
- package/src/prompts/system/commit-message-system.md +2 -2
- package/src/prompts/system/custom-system-prompt.md +2 -2
- package/src/prompts/system/eager-todo.md +6 -6
- package/src/prompts/system/handoff-document.md +1 -1
- package/src/prompts/system/plan-mode-active.md +22 -21
- package/src/prompts/system/plan-mode-approved.md +4 -4
- package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
- package/src/prompts/system/plan-mode-reference.md +2 -2
- package/src/prompts/system/plan-mode-subagent.md +8 -8
- package/src/prompts/system/plan-mode-tool-decision-reminder.md +2 -2
- package/src/prompts/system/project-prompt.md +4 -4
- package/src/prompts/system/subagent-system-prompt.md +7 -7
- package/src/prompts/system/subagent-yield-reminder.md +4 -4
- package/src/prompts/system/system-prompt.md +72 -71
- package/src/prompts/system/ttsr-interrupt.md +1 -1
- package/src/prompts/tools/apply-patch.md +1 -1
- package/src/prompts/tools/ast-edit.md +3 -3
- package/src/prompts/tools/ast-grep.md +3 -3
- package/src/prompts/tools/browser.md +3 -3
- package/src/prompts/tools/checkpoint.md +3 -3
- package/src/prompts/tools/exit-plan-mode.md +2 -2
- package/src/prompts/tools/find.md +3 -3
- package/src/prompts/tools/github.md +2 -5
- package/src/prompts/tools/hashline.md +6 -6
- package/src/prompts/tools/image-gen.md +3 -3
- package/src/prompts/tools/irc.md +1 -1
- package/src/prompts/tools/lsp.md +2 -2
- package/src/prompts/tools/patch.md +6 -6
- package/src/prompts/tools/read.md +7 -7
- package/src/prompts/tools/replace.md +5 -5
- package/src/prompts/tools/retain.md +1 -1
- package/src/prompts/tools/rewind.md +2 -2
- package/src/prompts/tools/search.md +2 -2
- package/src/prompts/tools/ssh.md +2 -2
- package/src/prompts/tools/task.md +12 -6
- package/src/prompts/tools/web-search.md +2 -2
- package/src/prompts/tools/write.md +3 -3
- package/src/sdk.ts +69 -12
- package/src/session/agent-session.ts +231 -22
- package/src/session/client-bridge.ts +81 -0
- package/src/session/compaction/errors.ts +31 -0
- package/src/session/compaction/index.ts +1 -0
- package/src/slash-commands/acp-builtins.ts +46 -0
- package/src/slash-commands/builtin-registry.ts +699 -116
- package/src/slash-commands/helpers/context-report.ts +39 -0
- package/src/slash-commands/helpers/format.ts +23 -0
- package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
- package/src/slash-commands/helpers/mcp.ts +532 -0
- package/src/slash-commands/helpers/parse.ts +85 -0
- package/src/slash-commands/helpers/ssh.ts +193 -0
- package/src/slash-commands/helpers/todo.ts +279 -0
- package/src/slash-commands/helpers/usage-report.ts +91 -0
- package/src/slash-commands/types.ts +126 -0
- package/src/task/executor.ts +10 -3
- package/src/task/index.ts +17 -1
- package/src/task/render.ts +6 -3
- package/src/tools/bash.ts +176 -2
- package/src/tools/conflict-detect.ts +6 -6
- package/src/tools/fetch.ts +15 -4
- package/src/tools/find.ts +19 -1
- package/src/tools/gh-renderer.ts +0 -12
- package/src/tools/gh.ts +682 -176
- package/src/tools/github-cache.ts +548 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/read.ts +110 -27
- package/src/tools/write.ts +23 -1
- package/src/tui/code-cell.ts +70 -2
|
@@ -17,8 +17,8 @@ Searches files using powerful regex matching.
|
|
|
17
17
|
</output>
|
|
18
18
|
|
|
19
19
|
<critical>
|
|
20
|
-
- You
|
|
20
|
+
- You MUST use the built-in `search` tool for any content search. NEVER shell out to `grep`, `rg`, `ripgrep`, `ag`, `ack`, `git grep`, `awk`, `sed`-for-search, or any other CLI search via Bash — even for a single match, even "just to check quickly", even piped through other commands.
|
|
21
21
|
- Bash `grep`/`rg` loses `.gitignore` semantics, bypasses result limits, and wastes tokens. The `search` tool is faster, structured, and already wired into the workspace — there is no scenario where Bash search is preferable.
|
|
22
22
|
- If you catch yourself typing `grep`, `rg`, or `| grep` in a Bash command, stop and re-issue the lookup through the `search` tool instead.
|
|
23
|
-
- If the search is open-ended, requiring multiple rounds, you
|
|
23
|
+
- If the search is open-ended, requiring multiple rounds, you MUST use the Task tool with the explore subagent instead of chaining `search` calls yourself.
|
|
24
24
|
</critical>
|
package/src/prompts/tools/ssh.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Runs commands on remote hosts.
|
|
2
2
|
|
|
3
3
|
<instruction>
|
|
4
|
-
You
|
|
4
|
+
You MUST build commands from the reference below
|
|
5
5
|
</instruction>
|
|
6
6
|
|
|
7
7
|
<commands>
|
|
@@ -22,7 +22,7 @@ You **MUST** build commands from the reference below
|
|
|
22
22
|
</commands>
|
|
23
23
|
|
|
24
24
|
<critical>
|
|
25
|
-
You
|
|
25
|
+
You MUST verify the shell type from "Available hosts" and use matching commands.
|
|
26
26
|
</critical>
|
|
27
27
|
|
|
28
28
|
<examples>
|
|
@@ -2,14 +2,20 @@ Launches subagents to parallelize workflows.
|
|
|
2
2
|
|
|
3
3
|
{{#if asyncEnabled}}
|
|
4
4
|
- Results are delivered automatically when complete.
|
|
5
|
-
-
|
|
6
|
-
|
|
5
|
+
- The tool result lists the assigned task ids (e.g. `0-AuthLoader`) — those are the live agent ids.
|
|
6
|
+
{{#if ircEnabled}}
|
|
7
|
+
- Coordinate with running tasks via `irc` using those ids. `job cancel` terminates a task and **cannot carry a message** — only use it for stalled/abandoned work.
|
|
8
|
+
- If genuinely blocked on completion, wait with `job poll`; otherwise keep working.
|
|
9
|
+
{{else}}
|
|
10
|
+
- If genuinely blocked on completion, wait with `job poll`; otherwise keep working.
|
|
11
|
+
- Use `job list` to snapshot manager state; `cancel: [id]` only to actually stop a stuck task.
|
|
12
|
+
{{/if}}
|
|
7
13
|
{{/if}}
|
|
8
14
|
|
|
9
15
|
{{#if ircEnabled}}
|
|
10
16
|
Subagents have no conversation history, but they can reach you and their siblings live via the `irc` tool. Front-load every fact, file path, and direction they need in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
|
|
11
17
|
{{else}}
|
|
12
|
-
Subagents have no conversation history. Every fact, file path, and direction they need
|
|
18
|
+
Subagents have no conversation history. Every fact, file path, and direction they need MUST be explicit in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
|
|
13
19
|
{{/if}}
|
|
14
20
|
|
|
15
21
|
<parameters>
|
|
@@ -24,8 +30,8 @@ Subagents have no conversation history. Every fact, file path, and direction the
|
|
|
24
30
|
</parameters>
|
|
25
31
|
|
|
26
32
|
<rules>
|
|
27
|
-
-
|
|
28
|
-
- **Subagents do not verify, lint, or format.** Every assignment
|
|
33
|
+
- NEVER assign tasks to run project-wide build/test/lint. Caller verifies after the batch.
|
|
34
|
+
- **Subagents do not verify, lint, or format.** Every assignment MUST instruct the subagent to skip all gates and formatters. You run them once at the end across the union of changed files — avoids redundant runs and racing formatter passes.
|
|
29
35
|
{{#if ircEnabled}}
|
|
30
36
|
- Each task: ≤3–5 explicit files. Overlapping file sets are tolerable when peers can coordinate via `irc`, but still fan out to a cluster when the scopes are cleanly separable.
|
|
31
37
|
- No globs, no "update all", no package-wide scope.
|
|
@@ -52,7 +58,7 @@ Parallel when tasks touch disjoint files or are independent refactors/tests.
|
|
|
52
58
|
{{#if contextEnabled}}
|
|
53
59
|
<context-fmt>
|
|
54
60
|
# Goal ← one sentence: what the batch accomplishes
|
|
55
|
-
# Constraints ←
|
|
61
|
+
# Constraints ← MUST/NEVER rules and session decisions
|
|
56
62
|
# Contract ← exact types/signatures if tasks share an interface
|
|
57
63
|
</context-fmt>
|
|
58
64
|
{{/if}}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Searches the web for up-to-date information beyond Claude's knowledge cutoff.
|
|
2
2
|
|
|
3
3
|
<instruction>
|
|
4
|
-
- You
|
|
5
|
-
- You
|
|
4
|
+
- You SHOULD prefer primary sources (papers, official docs) and corroborate key claims with multiple sources
|
|
5
|
+
- You MUST include links for cited sources in the final response
|
|
6
6
|
</instruction>
|
|
7
7
|
|
|
8
8
|
<caution>
|
|
@@ -8,7 +8,7 @@ Creates or overwrites file at specified path.
|
|
|
8
8
|
</conditions>
|
|
9
9
|
|
|
10
10
|
<critical>
|
|
11
|
-
- You
|
|
12
|
-
- You
|
|
13
|
-
- You
|
|
11
|
+
- You SHOULD use Edit tool for modifying existing files (more precise, preserves formatting)
|
|
12
|
+
- You NEVER create documentation files (*.md, README) unless explicitly requested
|
|
13
|
+
- You NEVER use emojis unless requested
|
|
14
14
|
</critical>
|
package/src/sdk.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
INTENT_FIELD,
|
|
7
7
|
type ThinkingLevel,
|
|
8
8
|
} from "@oh-my-pi/pi-agent-core";
|
|
9
|
-
import type { Message, Model, SimpleStreamOptions } from "@oh-my-pi/pi-ai";
|
|
9
|
+
import type { CredentialDisabledEvent, Message, Model, SimpleStreamOptions } from "@oh-my-pi/pi-ai";
|
|
10
10
|
import {
|
|
11
11
|
getOpenAICodexTransportDetails,
|
|
12
12
|
prewarmOpenAICodexResponses,
|
|
@@ -29,7 +29,13 @@ import { createAutoresearchExtension } from "./autoresearch";
|
|
|
29
29
|
import { loadCapability } from "./capability";
|
|
30
30
|
import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
|
|
31
31
|
import { ModelRegistry } from "./config/model-registry";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
formatModelString,
|
|
34
|
+
parseModelPattern,
|
|
35
|
+
parseModelString,
|
|
36
|
+
resolveAllowedModels,
|
|
37
|
+
resolveModelRoleValue,
|
|
38
|
+
} from "./config/model-resolver";
|
|
33
39
|
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates";
|
|
34
40
|
import { Settings, type SkillsSettings } from "./config/settings";
|
|
35
41
|
import { CursorExecHandlers } from "./cursor";
|
|
@@ -670,10 +676,32 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
670
676
|
registerSshCleanup();
|
|
671
677
|
registerPythonCleanup();
|
|
672
678
|
|
|
673
|
-
//
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
679
|
+
// Pin authStorage to modelRegistry.authStorage: ModelRegistry.getApiKey() routes refresh
|
|
680
|
+
// failures through that instance, so any divergent storage handed to the bridge / mcpManager
|
|
681
|
+
// / session would silently miss credential_disabled events.
|
|
682
|
+
const modelRegistry =
|
|
683
|
+
options.modelRegistry ??
|
|
684
|
+
new ModelRegistry(options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir)));
|
|
685
|
+
const authStorage = modelRegistry.authStorage;
|
|
686
|
+
if (options.authStorage && options.authStorage !== authStorage) {
|
|
687
|
+
throw new Error(
|
|
688
|
+
"options.authStorage and options.modelRegistry.authStorage must be the same instance when both are provided",
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
// Subscribe before any getApiKey() call so startup model probes can't fire a
|
|
692
|
+
// credential_disabled event past us. An embedder's constructor handler makes the
|
|
693
|
+
// listener set non-empty from construction, which defeats AuthStorage's no-listener
|
|
694
|
+
// buffer — so we can't rely on it to catch startup events for the extension runner.
|
|
695
|
+
const startupCredentialDisabledEvents: CredentialDisabledEvent[] = [];
|
|
696
|
+
let credentialDisabledTarget: ExtensionRunner | undefined;
|
|
697
|
+
let unsubscribeCredentialDisabled: (() => void) | undefined = authStorage.onCredentialDisabled(event => {
|
|
698
|
+
if (credentialDisabledTarget) {
|
|
699
|
+
// Discard return: any handler error is routed through runner.onError listeners.
|
|
700
|
+
void credentialDisabledTarget.emitCredentialDisabled(event);
|
|
701
|
+
} else {
|
|
702
|
+
startupCredentialDisabledEvents.push(event);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
677
705
|
const settings = options.settings ?? (await logger.time("settings", Settings.init, { cwd, agentDir }));
|
|
678
706
|
logger.time("initializeWithSettings", initializeWithSettings, settings);
|
|
679
707
|
if (!options.modelRegistry) {
|
|
@@ -778,8 +806,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
778
806
|
const modelMatchPreferences = {
|
|
779
807
|
usageOrder: settings.getStorage()?.getModelUsageOrder(),
|
|
780
808
|
};
|
|
809
|
+
const allowedModels = await logger.time("resolveAllowedModels", () =>
|
|
810
|
+
resolveAllowedModels(modelRegistry, settings, modelMatchPreferences),
|
|
811
|
+
);
|
|
781
812
|
const defaultRoleSpec = logger.time("resolveDefaultModelRole", () =>
|
|
782
|
-
resolveModelRoleValue(settings.getModelRole("default"),
|
|
813
|
+
resolveModelRoleValue(settings.getModelRole("default"), allowedModels, {
|
|
783
814
|
settings,
|
|
784
815
|
matchPreferences: modelMatchPreferences,
|
|
785
816
|
modelRegistry,
|
|
@@ -1014,6 +1045,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1014
1045
|
getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
|
|
1015
1046
|
getActiveModelString,
|
|
1016
1047
|
getPlanModeState: () => session.getPlanModeState(),
|
|
1048
|
+
getClientBridge: () => session?.clientBridge,
|
|
1017
1049
|
getCompactContext: () => session.formatCompactContext(),
|
|
1018
1050
|
getTodoPhases: () => session.getTodoPhases(),
|
|
1019
1051
|
setTodoPhases: phases => session.setTodoPhases(phases),
|
|
@@ -1236,11 +1268,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1236
1268
|
}
|
|
1237
1269
|
}
|
|
1238
1270
|
|
|
1239
|
-
// Fall back to first available model with a valid API key
|
|
1240
|
-
//
|
|
1271
|
+
// Fall back to first available model with a valid API key, honoring the
|
|
1272
|
+
// path-scoped `enabledModels` allow-list when configured. Skip when the
|
|
1273
|
+
// user explicitly requested a model via --model that wasn't found.
|
|
1241
1274
|
if (!model && !options.modelPattern) {
|
|
1242
|
-
|
|
1243
|
-
|
|
1275
|
+
// Re-resolve the allowed set: extension factories above may have
|
|
1276
|
+
// registered providers/models that weren't visible at startup.
|
|
1277
|
+
const fallbackCandidates = await resolveAllowedModels(modelRegistry, settings, modelMatchPreferences);
|
|
1278
|
+
for (const candidate of fallbackCandidates) {
|
|
1244
1279
|
if (await hasModelApiKey(candidate)) {
|
|
1245
1280
|
model = candidate;
|
|
1246
1281
|
break;
|
|
@@ -1251,8 +1286,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1251
1286
|
modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
|
|
1252
1287
|
}
|
|
1253
1288
|
} else {
|
|
1289
|
+
const patterns = settings.get("enabledModels");
|
|
1254
1290
|
modelFallbackMessage =
|
|
1255
|
-
|
|
1291
|
+
patterns && patterns.length > 0
|
|
1292
|
+
? `No model available matching enabledModels (${patterns.join(", ")}) with usable credentials. Configure auth for an allowed provider or adjust enabledModels.`
|
|
1293
|
+
: "No models available. Use /login or set an API key environment variable. Then use /model to select a model.";
|
|
1256
1294
|
}
|
|
1257
1295
|
}
|
|
1258
1296
|
|
|
@@ -1277,6 +1315,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1277
1315
|
);
|
|
1278
1316
|
}
|
|
1279
1317
|
|
|
1318
|
+
if (extensionRunner) {
|
|
1319
|
+
credentialDisabledTarget = extensionRunner;
|
|
1320
|
+
for (const event of startupCredentialDisabledEvents.splice(0)) {
|
|
1321
|
+
// Discard return: any handler error is routed through runner.onError listeners.
|
|
1322
|
+
void extensionRunner.emitCredentialDisabled(event);
|
|
1323
|
+
}
|
|
1324
|
+
} else {
|
|
1325
|
+
// No runner to forward to; release our subscription. The embedder's own
|
|
1326
|
+
// onCredentialDisabled (if any) keeps firing through its own subscription.
|
|
1327
|
+
startupCredentialDisabledEvents.length = 0;
|
|
1328
|
+
unsubscribeCredentialDisabled?.();
|
|
1329
|
+
unsubscribeCredentialDisabled = undefined;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1280
1332
|
const getSessionContext = () => ({
|
|
1281
1333
|
sessionManager,
|
|
1282
1334
|
modelRegistry,
|
|
@@ -1766,6 +1818,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1766
1818
|
await originalDispose();
|
|
1767
1819
|
} finally {
|
|
1768
1820
|
agentRegistry.unregister(resolvedAgentId);
|
|
1821
|
+
unsubscribeCredentialDisabled?.();
|
|
1769
1822
|
}
|
|
1770
1823
|
};
|
|
1771
1824
|
}
|
|
@@ -1901,6 +1954,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1901
1954
|
eventBus,
|
|
1902
1955
|
};
|
|
1903
1956
|
} catch (error) {
|
|
1957
|
+
// Release the subscription if the throw happened after install but before the
|
|
1958
|
+
// dispose-wrap took ownership. Idempotent with dispose() — Set.delete is a no-op
|
|
1959
|
+
// for already-removed listeners.
|
|
1960
|
+
unsubscribeCredentialDisabled?.();
|
|
1904
1961
|
try {
|
|
1905
1962
|
if (hasSession) {
|
|
1906
1963
|
await session.dispose();
|
|
@@ -143,7 +143,7 @@ import { outputMeta } from "../tools/output-meta";
|
|
|
143
143
|
import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
|
|
144
144
|
import { isAutoQaEnabled } from "../tools/report-tool-issue";
|
|
145
145
|
import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
|
|
146
|
-
import { ToolError } from "../tools/tool-errors";
|
|
146
|
+
import { ToolAbortError, ToolError } from "../tools/tool-errors";
|
|
147
147
|
import { clampTimeout } from "../tools/tool-timeouts";
|
|
148
148
|
import { parseCommandArgs } from "../utils/command-args";
|
|
149
149
|
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
|
|
@@ -151,7 +151,9 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
|
151
151
|
import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
|
|
152
152
|
import { buildNamedToolChoice } from "../utils/tool-choice";
|
|
153
153
|
import type { AuthStorage } from "./auth-storage";
|
|
154
|
+
import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
|
|
154
155
|
import {
|
|
156
|
+
CompactionCancelledError,
|
|
155
157
|
type CompactionPreparation,
|
|
156
158
|
type CompactionResult,
|
|
157
159
|
calculateContextTokens,
|
|
@@ -498,6 +500,96 @@ const noOpUIContext: ExtensionUIContext = {
|
|
|
498
500
|
setToolsExpanded: () => {},
|
|
499
501
|
};
|
|
500
502
|
|
|
503
|
+
// ============================================================================
|
|
504
|
+
// ACP Permission Gate
|
|
505
|
+
// ============================================================================
|
|
506
|
+
|
|
507
|
+
/** Tools that require user permission before execution when an ACP client is connected. */
|
|
508
|
+
const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "write", "ast_edit", "delete", "move"]);
|
|
509
|
+
|
|
510
|
+
/** Permission options presented to the client on each gated tool call. */
|
|
511
|
+
const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
|
|
512
|
+
{ optionId: "allow_once", name: "Allow once", kind: "allow_once" },
|
|
513
|
+
{ optionId: "allow_always", name: "Always allow", kind: "allow_always" },
|
|
514
|
+
{ optionId: "reject_once", name: "Reject", kind: "reject_once" },
|
|
515
|
+
{ optionId: "reject_always", name: "Always reject", kind: "reject_always" },
|
|
516
|
+
];
|
|
517
|
+
|
|
518
|
+
const PERMISSION_OPTIONS_BY_ID = new Map(PERMISSION_OPTIONS.map(option => [option.optionId, option]));
|
|
519
|
+
|
|
520
|
+
function derivePermissionTitle(toolName: string, args: unknown): string {
|
|
521
|
+
const a = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
|
522
|
+
if (toolName === "bash") {
|
|
523
|
+
const cmd = typeof a.command === "string" ? a.command.slice(0, 80) : undefined;
|
|
524
|
+
if (cmd) return cmd;
|
|
525
|
+
} else if (toolName === "edit" || toolName === "write" || toolName === "delete") {
|
|
526
|
+
const p = typeof a.path === "string" ? a.path : undefined;
|
|
527
|
+
if (p) {
|
|
528
|
+
const verb = toolName === "edit" ? "Edit" : toolName === "write" ? "Write" : "Delete";
|
|
529
|
+
return `${verb} ${p}`;
|
|
530
|
+
}
|
|
531
|
+
} else if (toolName === "move") {
|
|
532
|
+
const from =
|
|
533
|
+
typeof a.oldPath === "string"
|
|
534
|
+
? a.oldPath
|
|
535
|
+
: typeof a.path === "string"
|
|
536
|
+
? a.path
|
|
537
|
+
: typeof a.from === "string"
|
|
538
|
+
? a.from
|
|
539
|
+
: undefined;
|
|
540
|
+
const to =
|
|
541
|
+
typeof a.newPath === "string"
|
|
542
|
+
? a.newPath
|
|
543
|
+
: typeof a.to === "string"
|
|
544
|
+
? a.to
|
|
545
|
+
: typeof a.destination === "string"
|
|
546
|
+
? a.destination
|
|
547
|
+
: undefined;
|
|
548
|
+
if (from && to) return `Move ${from} to ${to}`;
|
|
549
|
+
if (from) return `Move ${from}`;
|
|
550
|
+
} else if (toolName === "ast_edit") {
|
|
551
|
+
const paths = Array.isArray(a.paths)
|
|
552
|
+
? (a.paths as unknown[]).filter(x => typeof x === "string").join(", ")
|
|
553
|
+
: undefined;
|
|
554
|
+
if (paths) return `AST edit ${paths}`;
|
|
555
|
+
}
|
|
556
|
+
return toolName;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function extractPermissionLocations(args: unknown, cwd: string): { path: string; line?: number }[] {
|
|
560
|
+
if (!args || typeof args !== "object") return [];
|
|
561
|
+
const a = args as Record<string, unknown>;
|
|
562
|
+
const out: { path: string; line?: number }[] = [];
|
|
563
|
+
const pushPath = (value: unknown) => {
|
|
564
|
+
if (typeof value !== "string" || value.length === 0) return;
|
|
565
|
+
// ACP locations carry file paths that the editor host will open or focus;
|
|
566
|
+
// they must be absolute or the client cannot resolve them. Resolve raw
|
|
567
|
+
// tool args (often cwd-relative) against the session cwd before sending.
|
|
568
|
+
let resolved: string;
|
|
569
|
+
try {
|
|
570
|
+
resolved = resolveToCwd(value, cwd);
|
|
571
|
+
} catch {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (out.some(location => location.path === resolved)) return;
|
|
575
|
+
out.push({ path: resolved });
|
|
576
|
+
};
|
|
577
|
+
pushPath(a.path);
|
|
578
|
+
pushPath(a.file);
|
|
579
|
+
if (Array.isArray(a.paths)) {
|
|
580
|
+
for (const p of a.paths) {
|
|
581
|
+
pushPath(p);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
pushPath(a.oldPath);
|
|
585
|
+
pushPath(a.newPath);
|
|
586
|
+
pushPath(a.from);
|
|
587
|
+
pushPath(a.to);
|
|
588
|
+
pushPath(a.source);
|
|
589
|
+
pushPath(a.destination);
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
|
|
501
593
|
// ============================================================================
|
|
502
594
|
// AgentSession Class
|
|
503
595
|
// ============================================================================
|
|
@@ -530,6 +622,9 @@ export class AgentSession {
|
|
|
530
622
|
#planModeState: PlanModeState | undefined;
|
|
531
623
|
#planReferenceSent = false;
|
|
532
624
|
#planReferencePath = "local://PLAN.md";
|
|
625
|
+
#clientBridge: ClientBridge | undefined;
|
|
626
|
+
/** Per-session memory of allow_always / reject_always decisions for gated tools. */
|
|
627
|
+
#acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
|
|
533
628
|
|
|
534
629
|
// Compaction state
|
|
535
630
|
#compactionAbortController: AbortController | undefined = undefined;
|
|
@@ -2547,6 +2642,85 @@ export class AgentSession {
|
|
|
2547
2642
|
return [...new Set(activated)];
|
|
2548
2643
|
}
|
|
2549
2644
|
|
|
2645
|
+
/**
|
|
2646
|
+
* Wrap a tool with a permission-gate proxy when an ACP client is connected.
|
|
2647
|
+
* Only wraps tools whose name is in PERMISSION_REQUIRED_TOOLS and only when
|
|
2648
|
+
* the bridge exposes `requestPermission`. No-ops for all other cases.
|
|
2649
|
+
*/
|
|
2650
|
+
#wrapToolForAcpPermission<T extends AgentTool>(tool: T): T {
|
|
2651
|
+
const bridge = this.#clientBridge;
|
|
2652
|
+
// Match the capability+method gating pattern used by read/write/bash.
|
|
2653
|
+
if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool;
|
|
2654
|
+
if (!PERMISSION_REQUIRED_TOOLS.has(tool.name)) return tool;
|
|
2655
|
+
return new Proxy(tool, {
|
|
2656
|
+
get: (target, prop, receiver) => {
|
|
2657
|
+
if (prop !== "execute") return Reflect.get(target, prop, receiver);
|
|
2658
|
+
return async (
|
|
2659
|
+
toolCallId: string,
|
|
2660
|
+
args: unknown,
|
|
2661
|
+
signal: AbortSignal | undefined,
|
|
2662
|
+
onUpdate: never,
|
|
2663
|
+
ctx: never,
|
|
2664
|
+
) => {
|
|
2665
|
+
// Short-circuit on persisted decisions.
|
|
2666
|
+
const persisted = this.#acpPermissionDecisions.get(target.name);
|
|
2667
|
+
if (persisted === "allow_always") {
|
|
2668
|
+
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
|
|
2669
|
+
}
|
|
2670
|
+
if (persisted === "reject_always") {
|
|
2671
|
+
throw new ToolError(`Tool call rejected by user (preference)`);
|
|
2672
|
+
}
|
|
2673
|
+
if (signal?.aborted) {
|
|
2674
|
+
throw new ToolAbortError("Permission request cancelled");
|
|
2675
|
+
}
|
|
2676
|
+
type PermissionRaceResult =
|
|
2677
|
+
| { kind: "permission"; outcome: ClientBridgePermissionOutcome }
|
|
2678
|
+
| { kind: "aborted" };
|
|
2679
|
+
const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers<PermissionRaceResult>();
|
|
2680
|
+
const onAbort = () => resolveAbort({ kind: "aborted" });
|
|
2681
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2682
|
+
let raced: PermissionRaceResult;
|
|
2683
|
+
try {
|
|
2684
|
+
const permissionPromise = bridge.requestPermission!(
|
|
2685
|
+
{
|
|
2686
|
+
toolCallId,
|
|
2687
|
+
toolName: target.name,
|
|
2688
|
+
title: derivePermissionTitle(target.name, args),
|
|
2689
|
+
rawInput: args,
|
|
2690
|
+
locations: extractPermissionLocations(args, this.sessionManager.getCwd()),
|
|
2691
|
+
},
|
|
2692
|
+
PERMISSION_OPTIONS,
|
|
2693
|
+
signal,
|
|
2694
|
+
).then(outcome => ({ kind: "permission" as const, outcome }));
|
|
2695
|
+
raced = await Promise.race([permissionPromise, abortPromise]);
|
|
2696
|
+
} finally {
|
|
2697
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2698
|
+
}
|
|
2699
|
+
if (raced.kind === "aborted" || signal?.aborted) {
|
|
2700
|
+
throw new ToolAbortError("Permission request cancelled");
|
|
2701
|
+
}
|
|
2702
|
+
const outcome = raced.outcome;
|
|
2703
|
+
if (outcome.outcome === "cancelled") {
|
|
2704
|
+
throw new ToolAbortError("Permission request cancelled");
|
|
2705
|
+
}
|
|
2706
|
+
const selectedOption = PERMISSION_OPTIONS_BY_ID.get(outcome.optionId);
|
|
2707
|
+
if (!selectedOption) {
|
|
2708
|
+
throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`);
|
|
2709
|
+
}
|
|
2710
|
+
if (selectedOption.kind === "allow_always") {
|
|
2711
|
+
this.#acpPermissionDecisions.set(target.name, "allow_always");
|
|
2712
|
+
} else if (selectedOption.kind === "reject_always") {
|
|
2713
|
+
this.#acpPermissionDecisions.set(target.name, "reject_always");
|
|
2714
|
+
}
|
|
2715
|
+
if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") {
|
|
2716
|
+
throw new ToolError(`Tool call rejected by user (${target.name})`);
|
|
2717
|
+
}
|
|
2718
|
+
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
|
|
2719
|
+
};
|
|
2720
|
+
},
|
|
2721
|
+
}) as T;
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2550
2724
|
async #applyActiveToolsByName(
|
|
2551
2725
|
toolNames: string[],
|
|
2552
2726
|
options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] },
|
|
@@ -2558,7 +2732,7 @@ export class AgentSession {
|
|
|
2558
2732
|
for (const name of toolNames) {
|
|
2559
2733
|
const tool = this.#toolRegistry.get(name);
|
|
2560
2734
|
if (tool) {
|
|
2561
|
-
tools.push(tool);
|
|
2735
|
+
tools.push(this.#wrapToolForAcpPermission(tool));
|
|
2562
2736
|
validToolNames.push(name);
|
|
2563
2737
|
}
|
|
2564
2738
|
}
|
|
@@ -2566,7 +2740,7 @@ export class AgentSession {
|
|
|
2566
2740
|
if (isAutoQaEnabled(this.settings) && !validToolNames.includes("report_tool_issue")) {
|
|
2567
2741
|
const qaTool = this.#toolRegistry.get("report_tool_issue");
|
|
2568
2742
|
if (qaTool) {
|
|
2569
|
-
tools.push(qaTool);
|
|
2743
|
+
tools.push(this.#wrapToolForAcpPermission(qaTool));
|
|
2570
2744
|
validToolNames.push("report_tool_issue");
|
|
2571
2745
|
}
|
|
2572
2746
|
}
|
|
@@ -2974,6 +3148,21 @@ export class AgentSession {
|
|
|
2974
3148
|
this.#planReferencePath = path;
|
|
2975
3149
|
}
|
|
2976
3150
|
|
|
3151
|
+
get clientBridge(): ClientBridge | undefined {
|
|
3152
|
+
return this.#clientBridge;
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
setClientBridge(bridge: ClientBridge | undefined): void {
|
|
3156
|
+
this.#clientBridge = bridge;
|
|
3157
|
+
this.#acpPermissionDecisions.clear();
|
|
3158
|
+
const activeToolNames = this.getActiveToolNames();
|
|
3159
|
+
const activeTools = activeToolNames
|
|
3160
|
+
.map(name => this.#toolRegistry.get(name))
|
|
3161
|
+
.filter((tool): tool is AgentTool => tool !== undefined)
|
|
3162
|
+
.map(tool => this.#wrapToolForAcpPermission(tool));
|
|
3163
|
+
this.agent.setTools(activeTools);
|
|
3164
|
+
}
|
|
3165
|
+
|
|
2977
3166
|
getCheckpointState(): CheckpointState | undefined {
|
|
2978
3167
|
return this.#checkpointState;
|
|
2979
3168
|
}
|
|
@@ -4503,7 +4692,7 @@ export class AgentSession {
|
|
|
4503
4692
|
})) as SessionBeforeCompactResult | undefined;
|
|
4504
4693
|
|
|
4505
4694
|
if (result?.cancel) {
|
|
4506
|
-
throw new
|
|
4695
|
+
throw new CompactionCancelledError();
|
|
4507
4696
|
}
|
|
4508
4697
|
|
|
4509
4698
|
if (result?.compaction) {
|
|
@@ -4545,27 +4734,47 @@ export class AgentSession {
|
|
|
4545
4734
|
details = hookCompaction.details;
|
|
4546
4735
|
preserveData ??= hookCompaction.preserveData;
|
|
4547
4736
|
} else {
|
|
4548
|
-
// Generate compaction result
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4737
|
+
// Generate compaction result. Only convert known abort-shaped
|
|
4738
|
+
// rejections (AbortError raised while the abort signal is set,
|
|
4739
|
+
// or an already-typed sentinel) into `CompactionCancelledError`
|
|
4740
|
+
// so downstream callers can discriminate cancel from generic
|
|
4741
|
+
// failure via `instanceof` without inspecting message strings.
|
|
4742
|
+
// Real compaction bugs (network, server, parsing, etc.) keep
|
|
4743
|
+
// their original shape — they must not be silently relabeled
|
|
4744
|
+
// as cancellations even if the signal happens to be aborted
|
|
4745
|
+
// for an unrelated reason. Assignments live inside the try
|
|
4746
|
+
// block because every catch path throws — the post-try reads
|
|
4747
|
+
// of the result-derived locals are reachable only on success.
|
|
4748
|
+
try {
|
|
4749
|
+
const result = await this.#compactWithFallbackModel(
|
|
4750
|
+
preparation,
|
|
4751
|
+
customInstructions,
|
|
4752
|
+
compactionAbortController.signal,
|
|
4753
|
+
{
|
|
4754
|
+
promptOverride: hookPrompt,
|
|
4755
|
+
extraContext: hookContext,
|
|
4756
|
+
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
|
|
4757
|
+
},
|
|
4758
|
+
);
|
|
4759
|
+
summary = result.summary;
|
|
4760
|
+
shortSummary = result.shortSummary;
|
|
4761
|
+
firstKeptEntryId = result.firstKeptEntryId;
|
|
4762
|
+
tokensBefore = result.tokensBefore;
|
|
4763
|
+
details = result.details;
|
|
4764
|
+
preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) };
|
|
4765
|
+
} catch (err) {
|
|
4766
|
+
if (err instanceof CompactionCancelledError) {
|
|
4767
|
+
throw err;
|
|
4768
|
+
}
|
|
4769
|
+
if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
|
|
4770
|
+
throw new CompactionCancelledError();
|
|
4771
|
+
}
|
|
4772
|
+
throw err;
|
|
4773
|
+
}
|
|
4565
4774
|
}
|
|
4566
4775
|
|
|
4567
4776
|
if (compactionAbortController.signal.aborted) {
|
|
4568
|
-
throw new
|
|
4777
|
+
throw new CompactionCancelledError();
|
|
4569
4778
|
}
|
|
4570
4779
|
|
|
4571
4780
|
this.sessionManager.appendCompaction(
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClientBridge — abstraction over capabilities provided by an external client
|
|
3
|
+
* (e.g. ACP editor host) that the agent can route through instead of operating
|
|
4
|
+
* directly on the local filesystem / spawning local subprocesses.
|
|
5
|
+
*
|
|
6
|
+
* When `undefined`, tools fall back to local IO. When populated (currently
|
|
7
|
+
* only by `AcpAgent`), tools route requests through the client so it can
|
|
8
|
+
* surface unsaved buffer state, render terminals in the IDE, or gate
|
|
9
|
+
* destructive operations behind user permission prompts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ClientBridgeCapabilities {
|
|
13
|
+
/** Client implements `fs/read_text_file`. */
|
|
14
|
+
readTextFile?: boolean;
|
|
15
|
+
/** Client implements `fs/write_text_file`. */
|
|
16
|
+
writeTextFile?: boolean;
|
|
17
|
+
/** Client implements the `terminal/*` family. */
|
|
18
|
+
terminal?: boolean;
|
|
19
|
+
/** Client implements `session/request_permission`. */
|
|
20
|
+
requestPermission?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ClientBridgePermissionToolCall {
|
|
24
|
+
toolCallId: string;
|
|
25
|
+
toolName: string;
|
|
26
|
+
title: string;
|
|
27
|
+
kind?: string;
|
|
28
|
+
rawInput?: unknown;
|
|
29
|
+
locations?: { path: string; line?: number }[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ClientBridgePermissionOptionKind = "allow_once" | "allow_always" | "reject_once" | "reject_always";
|
|
33
|
+
|
|
34
|
+
export interface ClientBridgePermissionOption {
|
|
35
|
+
optionId: string;
|
|
36
|
+
name: string;
|
|
37
|
+
kind: ClientBridgePermissionOptionKind;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ClientBridgePermissionOutcome =
|
|
41
|
+
| { outcome: "cancelled" }
|
|
42
|
+
| { outcome: "selected"; optionId: string; kind?: ClientBridgePermissionOptionKind };
|
|
43
|
+
|
|
44
|
+
export interface ClientBridgeTerminalExitStatus {
|
|
45
|
+
exitCode?: number | null;
|
|
46
|
+
signal?: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ClientBridgeTerminalOutput {
|
|
50
|
+
output: string;
|
|
51
|
+
truncated: boolean;
|
|
52
|
+
exitStatus?: ClientBridgeTerminalExitStatus | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ClientBridgeTerminalHandle {
|
|
56
|
+
terminalId: string;
|
|
57
|
+
waitForExit(): Promise<ClientBridgeTerminalExitStatus>;
|
|
58
|
+
currentOutput(): Promise<ClientBridgeTerminalOutput>;
|
|
59
|
+
kill(): Promise<void>;
|
|
60
|
+
release(): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ClientBridgeCreateTerminalParams {
|
|
64
|
+
command: string;
|
|
65
|
+
args?: string[];
|
|
66
|
+
env?: Array<{ name: string; value: string }>;
|
|
67
|
+
cwd?: string;
|
|
68
|
+
outputByteLimit?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ClientBridge {
|
|
72
|
+
readonly capabilities: ClientBridgeCapabilities;
|
|
73
|
+
readTextFile?(params: { path: string; line?: number; limit?: number }): Promise<string>;
|
|
74
|
+
writeTextFile?(params: { path: string; content: string }): Promise<void>;
|
|
75
|
+
createTerminal?(params: ClientBridgeCreateTerminalParams): Promise<ClientBridgeTerminalHandle>;
|
|
76
|
+
requestPermission?(
|
|
77
|
+
toolCall: ClientBridgePermissionToolCall,
|
|
78
|
+
options: ClientBridgePermissionOption[],
|
|
79
|
+
signal?: AbortSignal,
|
|
80
|
+
): Promise<ClientBridgePermissionOutcome>;
|
|
81
|
+
}
|