Merge remote-tracking branch 'upstream/main' into feat/error-notify
This commit is contained in:
@@ -1294,6 +1294,151 @@ describe("advisor", () => {
|
||||
expect(failures).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("calls onTurnError with state.error before retrying the batch", async () => {
|
||||
const promptInputs: string[] = [];
|
||||
const turnErrors: unknown[] = [];
|
||||
const events: string[] = [];
|
||||
const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
|
||||
let promptCalls = 0;
|
||||
const agent: AdvisorAgent = {
|
||||
prompt: async input => {
|
||||
promptCalls++;
|
||||
promptInputs.push(input);
|
||||
events.push(`prompt:${promptCalls}`);
|
||||
state.error = promptCalls === 1 ? "provider failed" : undefined;
|
||||
},
|
||||
abort: () => {},
|
||||
reset: () => {
|
||||
state.error = undefined;
|
||||
},
|
||||
state,
|
||||
};
|
||||
const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
|
||||
const host: AdvisorRuntimeHost = {
|
||||
snapshotMessages: () => messages,
|
||||
enqueueAdvice: () => {},
|
||||
onTurnError: error => {
|
||||
turnErrors.push(error);
|
||||
events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
|
||||
},
|
||||
};
|
||||
const runtime = new AdvisorRuntime(agent, host, 1);
|
||||
|
||||
runtime.onTurnEnd(messages);
|
||||
await runtime.waitForCatchup(1000, 1);
|
||||
|
||||
expect(promptInputs).toHaveLength(2);
|
||||
expect(turnErrors).toHaveLength(1);
|
||||
const error = turnErrors[0];
|
||||
if (!(error instanceof Error)) throw new Error("expected advisor turn error");
|
||||
expect(error.message).toBe("provider failed");
|
||||
expect(events).toEqual(["prompt:1", "hook:provider failed", "prompt:2"]);
|
||||
expect(runtime.backlog).toBe(0);
|
||||
});
|
||||
|
||||
it("calls onTurnError for each consecutive failure including the dropped third turn", async () => {
|
||||
const promptInputs: string[] = [];
|
||||
const turnErrors: unknown[] = [];
|
||||
const failures: unknown[] = [];
|
||||
const events: string[] = [];
|
||||
const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
|
||||
let promptCalls = 0;
|
||||
const agent: AdvisorAgent = {
|
||||
prompt: async input => {
|
||||
promptCalls++;
|
||||
promptInputs.push(input);
|
||||
events.push(`prompt:${promptCalls}`);
|
||||
state.error = `provider failed ${promptCalls}`;
|
||||
},
|
||||
abort: () => {},
|
||||
reset: () => {
|
||||
state.error = undefined;
|
||||
},
|
||||
state,
|
||||
};
|
||||
const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
|
||||
const host: AdvisorRuntimeHost = {
|
||||
snapshotMessages: () => messages,
|
||||
enqueueAdvice: () => {},
|
||||
onTurnError: error => {
|
||||
turnErrors.push(error);
|
||||
events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
|
||||
},
|
||||
notifyFailure: error => {
|
||||
failures.push(error);
|
||||
events.push(`notify:${error instanceof Error ? error.message : String(error)}`);
|
||||
},
|
||||
};
|
||||
const runtime = new AdvisorRuntime(agent, host, 1);
|
||||
|
||||
runtime.onTurnEnd(messages);
|
||||
await runtime.waitForCatchup(1000, 1);
|
||||
|
||||
expect(promptInputs).toHaveLength(3);
|
||||
expect(turnErrors.map(error => (error instanceof Error ? error.message : String(error)))).toEqual([
|
||||
"provider failed 1",
|
||||
"provider failed 2",
|
||||
"provider failed 3",
|
||||
]);
|
||||
expect(failures).toHaveLength(1);
|
||||
const failure = failures[0];
|
||||
if (!(failure instanceof Error)) throw new Error("expected advisor failure error");
|
||||
expect(failure.message).toBe("provider failed 3");
|
||||
expect(events).toEqual([
|
||||
"prompt:1",
|
||||
"hook:provider failed 1",
|
||||
"prompt:2",
|
||||
"hook:provider failed 2",
|
||||
"prompt:3",
|
||||
"hook:provider failed 3",
|
||||
"notify:provider failed 3",
|
||||
]);
|
||||
expect(runtime.backlog).toBe(0);
|
||||
});
|
||||
|
||||
it("continues retrying when onTurnError rejects", async () => {
|
||||
const promptInputs: string[] = [];
|
||||
const turnErrors: unknown[] = [];
|
||||
const events: string[] = [];
|
||||
const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
|
||||
let promptCalls = 0;
|
||||
const agent: AdvisorAgent = {
|
||||
prompt: async input => {
|
||||
promptCalls++;
|
||||
promptInputs.push(input);
|
||||
events.push(`prompt:${promptCalls}`);
|
||||
state.error = promptCalls === 1 ? "provider failed" : undefined;
|
||||
},
|
||||
abort: () => {},
|
||||
reset: () => {
|
||||
state.error = undefined;
|
||||
},
|
||||
state,
|
||||
};
|
||||
const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
|
||||
const host: AdvisorRuntimeHost = {
|
||||
snapshotMessages: () => messages,
|
||||
enqueueAdvice: () => {},
|
||||
onTurnError: async error => {
|
||||
turnErrors.push(error);
|
||||
events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
|
||||
throw new Error("hook failed");
|
||||
},
|
||||
};
|
||||
const runtime = new AdvisorRuntime(agent, host, 1);
|
||||
|
||||
runtime.onTurnEnd(messages);
|
||||
await runtime.waitForCatchup(1000, 1);
|
||||
|
||||
expect(promptInputs).toHaveLength(2);
|
||||
expect(turnErrors).toHaveLength(1);
|
||||
const error = turnErrors[0];
|
||||
if (!(error instanceof Error)) throw new Error("expected advisor turn error");
|
||||
expect(error.message).toBe("provider failed");
|
||||
expect(events).toEqual(["prompt:1", "hook:provider failed", "prompt:2"]);
|
||||
expect(runtime.backlog).toBe(0);
|
||||
});
|
||||
|
||||
it("rolls advisor state back after each failed prompt so retries don't replay duplicate turns", async () => {
|
||||
// The real `Agent` appends the user batch + a synthetic `stopReason: "error"`
|
||||
// assistant turn before `state.error` is read. Without rollback, the runtime's
|
||||
|
||||
@@ -48,6 +48,17 @@ export interface AdvisorRuntimeHost {
|
||||
* one that routes `advise()` results back to the primary.
|
||||
*/
|
||||
beginAdvisorUpdate?(): void;
|
||||
/**
|
||||
* Called with the error of every failed advisor turn, before the retry sleep
|
||||
* or the dropped-after-3 path. Lets the host apply credential-level remedies
|
||||
* the advisor loop lacks: the in-stream a/b/c auth retry rotates through
|
||||
* sibling credentials within one request but never blocks the LAST failing
|
||||
* one — the primary agent's retry pipeline does that via
|
||||
* `markUsageLimitReached`, so without this hook the advisor re-picks the
|
||||
* same usage-limited account on every retry. Errors thrown here are logged
|
||||
* and swallowed.
|
||||
*/
|
||||
onTurnError?(error: unknown): Promise<void> | void;
|
||||
/** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
|
||||
notifyFailure?(error: unknown): void;
|
||||
}
|
||||
@@ -352,6 +363,14 @@ export class AdvisorRuntime {
|
||||
if (this.#epoch !== epoch) continue;
|
||||
this.#rollbackFailedTurn(messageSnapshot);
|
||||
logger.debug("advisor turn failed", { err: String(err) });
|
||||
try {
|
||||
await this.host.onTurnError?.(err);
|
||||
} catch (hookErr) {
|
||||
logger.debug("advisor onTurnError hook failed", { err: String(hookErr) });
|
||||
}
|
||||
// The hook awaits; a reset during it invalidates this batch like the
|
||||
// prompt await above — drop it instead of requeueing stale content.
|
||||
if (this.#epoch !== epoch) continue;
|
||||
this.#consecutiveFailures++;
|
||||
if (this.#consecutiveFailures >= 3) {
|
||||
logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
|
||||
|
||||
@@ -49,7 +49,7 @@ export function createApiKeyResolver(
|
||||
options: ApiKeyResolverOptions = {},
|
||||
): ApiKeyResolver {
|
||||
const { sessionId, baseUrl, modelId } = options;
|
||||
return async ({ lastChance, error, signal }) => {
|
||||
return async ({ lastChance, error, signal, previousKey }) => {
|
||||
if (error === undefined) {
|
||||
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, modelId });
|
||||
}
|
||||
@@ -59,7 +59,12 @@ export function createApiKeyResolver(
|
||||
// sibling exists we switch immediately; the precise no-sibling backoff
|
||||
// is owned by `markUsageLimitReached` (default + server usage-report
|
||||
// reset) and the outer whole-turn retry layer.
|
||||
await registry.authStorage.rotateSessionCredential(provider, sessionId, { error, modelId, signal });
|
||||
await registry.authStorage.rotateSessionCredential(provider, sessionId, {
|
||||
error,
|
||||
modelId,
|
||||
signal,
|
||||
apiKey: previousKey,
|
||||
});
|
||||
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, modelId });
|
||||
}
|
||||
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, modelId, forceRefresh: true, signal });
|
||||
|
||||
@@ -2266,6 +2266,15 @@ export class ModelRegistry {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cooldown suppression for one selector after an explicit user selection.
|
||||
*/
|
||||
clearSuppressedSelector(selector: string): void {
|
||||
this.#suppressedSelectors.delete(
|
||||
normalizeSuppressedSelector(selector, (provider, id) => this.find(provider, id) !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cooldown suppressions recorded via {@link suppressSelector}.
|
||||
* Used to reset retry-fallback cooldown state without a full {@link refresh}.
|
||||
|
||||
@@ -1367,7 +1367,17 @@ export const SETTINGS_SCHEMA = {
|
||||
description: "Allow retry recovery to switch to configured fallback models",
|
||||
},
|
||||
},
|
||||
"retry.fallbackChains": { type: "record", default: {} as Record<string, string[]> },
|
||||
"retry.fallbackChains": {
|
||||
type: "record",
|
||||
default: {} as Record<string, string[]>,
|
||||
ui: {
|
||||
tab: "model",
|
||||
group: "Retry & Fallback",
|
||||
label: "Retry Fallback Chains",
|
||||
description:
|
||||
'JSON object mapping model roles to ordered fallback model selectors, e.g. {"default":["openai/gpt-4o-mini"]}.',
|
||||
},
|
||||
},
|
||||
"retry.fallbackRevertPolicy": {
|
||||
type: "enum",
|
||||
values: ["cooldown-expiry", "never"] as const,
|
||||
|
||||
@@ -429,6 +429,9 @@ export class Settings {
|
||||
if (path === "statusLine.sessionAccent") {
|
||||
statusLineSessionAccentSignal.fire();
|
||||
}
|
||||
if (path === "modelRoles") {
|
||||
modelRolesSignal.fire();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -480,11 +483,13 @@ export class Settings {
|
||||
async reloadForCwd(cwd: string): Promise<void> {
|
||||
const normalized = path.normalize(cwd);
|
||||
if (normalized === this.#cwd) return;
|
||||
const prevModelRoles = this.get("modelRoles");
|
||||
this.#cwd = normalized;
|
||||
if (this.#persist) {
|
||||
this.#project = await this.#loadProjectSettings();
|
||||
}
|
||||
this.#rebuildMerged();
|
||||
this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prevModelRoles);
|
||||
this.#fireAllHooks();
|
||||
}
|
||||
|
||||
@@ -1477,6 +1482,12 @@ const appendOnlyModeSignal = new SettingSignal<[value: string]>("provider.append
|
||||
*/
|
||||
export const onAppendOnlyModeChanged = (cb: (value: string) => void) => appendOnlyModeSignal.on(cb);
|
||||
|
||||
/** Fires when any model role changes at runtime. */
|
||||
const modelRolesSignal = new SettingSignal("modelRoles");
|
||||
|
||||
/** Subscribe to model role changes. Returns an unsubscribe function. */
|
||||
export const onModelRolesChanged: (cb: () => void) => () => void = modelRolesSignal.on.bind(modelRolesSignal);
|
||||
|
||||
/** Fires when `statusLine.sessionAccent` changes at runtime. */
|
||||
const statusLineSessionAccentSignal = new SettingSignal("statusLine.sessionAccent");
|
||||
|
||||
|
||||
@@ -401,7 +401,8 @@ async function loadStickyRulesFile(filePath: string, level: "user" | "project"):
|
||||
const content = await readFile(filePath);
|
||||
if (!content) return null;
|
||||
const source = createSourceMeta(PROVIDER_ID, filePath, level);
|
||||
const rule = buildRuleFromMarkdown("RULES.md", content, filePath, source, { ruleName: "RULES" });
|
||||
const ruleName = level === "project" ? "RULES@project" : "RULES";
|
||||
const rule = buildRuleFromMarkdown("RULES.md", content, filePath, source, { ruleName });
|
||||
// Force alwaysApply regardless of frontmatter — the whole point of RULES.md
|
||||
// is to be reattached every turn.
|
||||
return { ...rule, alwaysApply: true };
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Loads configuration from ~/.claude/plugins/cache/ based on installed_plugins.json registry.
|
||||
* Priority: 70 (below claude.ts at 80, so user overrides in .claude/ take precedence)
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import { registerProvider } from "../capability";
|
||||
@@ -30,14 +31,14 @@ const DISPLAY_NAME = "Claude Code Marketplace";
|
||||
const PRIORITY = 70; // Below claude.ts (80) so user .claude/ overrides win
|
||||
|
||||
interface ClaudePluginManifest {
|
||||
skills?: string;
|
||||
"slash-commands"?: string;
|
||||
commands?: string;
|
||||
skills?: string | string[];
|
||||
"slash-commands"?: string | string[];
|
||||
commands?: string | string[];
|
||||
}
|
||||
|
||||
interface ResolvedPluginDir {
|
||||
dir: string;
|
||||
warning?: string;
|
||||
dirs: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
async function readPluginManifest(root: ClaudePluginRoot): Promise<ClaudePluginManifest | null> {
|
||||
@@ -54,43 +55,116 @@ async function readPluginManifest(root: ClaudePluginRoot): Promise<ClaudePluginM
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function skillsManifestReplacesFallback(root: ClaudePluginRoot): Promise<boolean> {
|
||||
const raw = await readFile(path.join(root.path, "marketplace.json"));
|
||||
if (raw === null) return false;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isRecord(parsed)) return false;
|
||||
const plugins = parsed.plugins;
|
||||
return (
|
||||
Array.isArray(plugins) &&
|
||||
plugins.some(entry => isRecord(entry) && entry.name === root.plugin && entry.source === "./")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isWithinPluginRoot(rootPath: string, targetPath: string): boolean {
|
||||
const relative = path.relative(rootPath, targetPath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a manifest-declared directory field to absolute paths within the
|
||||
* plugin root.
|
||||
*
|
||||
* Manifest path fields may be `string` or `string[]`
|
||||
* (https://code.claude.com/docs/en/plugins-reference#path-behavior-rules);
|
||||
* both shapes are normalized here. The first `manifestKeys` entry that
|
||||
* supplies at least one non-empty path wins (later keys are ignored — used for
|
||||
* the `commands` > `slash-commands` legacy fallback).
|
||||
*
|
||||
* `fallback` is the default subdirectory (e.g. `skills/`, `commands/`) and
|
||||
* `includeFallback` controls the Claude-documented merge semantic per field:
|
||||
*
|
||||
* - `skills` **adds to** the default: `fallback` is always scanned, and any
|
||||
* manifest entries load alongside it. Callers pass `includeFallback: true`.
|
||||
* - `commands` / `slash-commands` **replace** the default: an explicit
|
||||
* manifest key means the default `commands/` directory is not scanned.
|
||||
* Callers pass `includeFallback: false` (the manifest itself may still
|
||||
* list `./commands` explicitly to keep it).
|
||||
*
|
||||
* When no matching key is set, the fallback is used regardless. Entries that
|
||||
* resolve outside the plugin root are dropped with a warning so misconfigured
|
||||
* manifests remain observable and cannot escape via traversal.
|
||||
*/
|
||||
async function resolvePluginDir(
|
||||
root: ClaudePluginRoot,
|
||||
manifestKeys: ReadonlyArray<keyof ClaudePluginManifest>,
|
||||
fallback: string,
|
||||
includeFallback: boolean,
|
||||
): Promise<ResolvedPluginDir> {
|
||||
const manifest = await readPluginManifest(root);
|
||||
const fallbackDir = path.join(root.path, fallback);
|
||||
|
||||
let configured: string | undefined;
|
||||
let configured: string[] | undefined;
|
||||
let matchedKey: keyof ClaudePluginManifest | undefined;
|
||||
for (const key of manifestKeys) {
|
||||
const val = manifest?.[key];
|
||||
if (typeof val === "string" && val.trim()) {
|
||||
configured = val.trim();
|
||||
const candidates: string[] = [];
|
||||
if (typeof val === "string") {
|
||||
const trimmed = val.trim();
|
||||
if (trimmed) candidates.push(trimmed);
|
||||
} else if (Array.isArray(val)) {
|
||||
for (const entry of val) {
|
||||
if (typeof entry !== "string") continue;
|
||||
const trimmed = entry.trim();
|
||||
if (trimmed) candidates.push(trimmed);
|
||||
}
|
||||
}
|
||||
if (candidates.length > 0) {
|
||||
configured = candidates;
|
||||
matchedKey = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (configured === undefined) {
|
||||
return { dir: fallbackDir };
|
||||
return { dirs: [fallbackDir], warnings: [] };
|
||||
}
|
||||
|
||||
const resolved = path.resolve(root.path, configured);
|
||||
if (isWithinPluginRoot(root.path, resolved)) {
|
||||
return { dir: resolved };
|
||||
// Dedup preserves order: default entry (when included) first, then declared
|
||||
// entries in manifest order. Deduping the paths themselves means a plugin
|
||||
// author can still list `./commands` explicitly when they want the default
|
||||
// alongside extras without producing double-loads.
|
||||
const seen = new Set<string>();
|
||||
const dirs: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
if (includeFallback) {
|
||||
seen.add(fallbackDir);
|
||||
dirs.push(fallbackDir);
|
||||
}
|
||||
for (const entry of configured) {
|
||||
const resolved = path.resolve(root.path, entry);
|
||||
if (!isWithinPluginRoot(root.path, resolved)) {
|
||||
warnings.push(
|
||||
`[claude-plugins] Ignoring ${String(matchedKey)} path outside plugin root for ${root.id}: ${entry}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(resolved)) continue;
|
||||
seen.add(resolved);
|
||||
dirs.push(resolved);
|
||||
}
|
||||
|
||||
return {
|
||||
dir: fallbackDir,
|
||||
warning: `[claude-plugins] Ignoring ${String(matchedKey)} path outside plugin root for ${root.id}: ${configured}`,
|
||||
};
|
||||
return { dirs, warnings };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -104,24 +178,37 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
warnings.push(...rootWarnings);
|
||||
const results = await Promise.all(
|
||||
roots.map(async root => {
|
||||
const { dir: skillsDir, warning } = await resolvePluginDir(root, ["skills"], "skills");
|
||||
const result = await scanSkillsFromDir(ctx, {
|
||||
dir: skillsDir,
|
||||
providerId: PROVIDER_ID,
|
||||
level: root.scope,
|
||||
});
|
||||
return { root, result, warning };
|
||||
const includeFallback = !(await skillsManifestReplacesFallback(root));
|
||||
const { dirs: skillsDirs, warnings: resolveWarnings } = await resolvePluginDir(
|
||||
root,
|
||||
["skills"],
|
||||
"skills",
|
||||
includeFallback,
|
||||
);
|
||||
const scanResults = await Promise.all(
|
||||
skillsDirs.map(dir =>
|
||||
scanSkillsFromDir(ctx, {
|
||||
dir,
|
||||
providerId: PROVIDER_ID,
|
||||
level: root.scope,
|
||||
includeSelf: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { scanResults, resolveWarnings };
|
||||
}),
|
||||
);
|
||||
for (const { result, warning } of results) {
|
||||
if (warning) warnings.push(warning);
|
||||
for (const { scanResults, resolveWarnings } of results) {
|
||||
warnings.push(...resolveWarnings);
|
||||
// Intentionally do NOT prefix skill names with `root.plugin`.
|
||||
// The `plugin:name` format breaks skill:// URL parsing (colons are
|
||||
// ambiguous with port separators) and is unintuitive for callers.
|
||||
// Dedup-by-key in the capability layer already handles name collisions
|
||||
// across providers using priority ordering.
|
||||
items.push(...result.items);
|
||||
if (result.warnings) warnings.push(...result.warnings);
|
||||
for (const result of scanResults) {
|
||||
items.push(...result.items);
|
||||
if (result.warnings) warnings.push(...result.warnings);
|
||||
}
|
||||
}
|
||||
return { items, warnings };
|
||||
}
|
||||
@@ -139,28 +226,62 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
|
||||
|
||||
const results = await Promise.all(
|
||||
roots.map(async root => {
|
||||
const { dir: commandsDir, warning } = await resolvePluginDir(root, ["commands", "slash-commands"], "commands");
|
||||
const commandResult = await loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, root.scope, {
|
||||
extensions: ["md"],
|
||||
transform: (name, content, filePath, source) => {
|
||||
const cmdName = name.replace(/\.md$/, "");
|
||||
return {
|
||||
name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName,
|
||||
path: filePath,
|
||||
content,
|
||||
level: root.scope,
|
||||
_source: source,
|
||||
};
|
||||
},
|
||||
});
|
||||
return { commandResult, warning };
|
||||
const { dirs: commandsDirs, warnings: resolveWarnings } = await resolvePluginDir(
|
||||
root,
|
||||
["commands", "slash-commands"],
|
||||
"commands",
|
||||
false,
|
||||
);
|
||||
const commandResults = await Promise.all(
|
||||
commandsDirs.map(async dir => {
|
||||
try {
|
||||
const stats = await fs.stat(dir);
|
||||
if (stats.isFile()) {
|
||||
if (path.extname(dir) !== ".md") return { items: [], warnings: [] };
|
||||
const content = await readFile(dir);
|
||||
if (content === null) return { items: [], warnings: [`Failed to read file: ${dir}`] };
|
||||
const cmdName = path.basename(dir).replace(/\.md$/, "");
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName,
|
||||
path: dir,
|
||||
content,
|
||||
level: root.scope,
|
||||
_source: createSourceMeta(PROVIDER_ID, dir, root.scope),
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Missing entries behave like missing directories: no items, no warning.
|
||||
}
|
||||
return loadFilesFromDir<SlashCommand>(ctx, dir, PROVIDER_ID, root.scope, {
|
||||
extensions: ["md"],
|
||||
transform: (name, content, filePath, source) => {
|
||||
const cmdName = name.replace(/\.md$/, "");
|
||||
return {
|
||||
name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName,
|
||||
path: filePath,
|
||||
content,
|
||||
level: root.scope,
|
||||
_source: source,
|
||||
};
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
return { commandResults, resolveWarnings };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { commandResult, warning } of results) {
|
||||
if (warning) warnings.push(warning);
|
||||
items.push(...commandResult.items);
|
||||
if (commandResult.warnings) warnings.push(...commandResult.warnings);
|
||||
for (const { commandResults, resolveWarnings } of results) {
|
||||
warnings.push(...resolveWarnings);
|
||||
for (const commandResult of commandResults) {
|
||||
items.push(...commandResult.items);
|
||||
if (commandResult.warnings) warnings.push(...commandResult.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
return { items, warnings };
|
||||
|
||||
@@ -312,6 +312,15 @@ export interface ScanSkillsFromDirOptions {
|
||||
providerId: string;
|
||||
level: "user" | "project";
|
||||
requireDescription?: boolean;
|
||||
/**
|
||||
* When true, treat a `SKILL.md` sitting directly under `dir` as a single skill in addition to
|
||||
* scanning `<dir>/<name>/SKILL.md` children. Matches the Claude plugin manifest convention
|
||||
* that lets a skill path point at a directory containing `SKILL.md` directly (e.g.
|
||||
* `"skills": ["./"]`), where the frontmatter `name` determines the invocation name and the
|
||||
* directory basename is the fallback. Default `false` preserves the strict child-scan
|
||||
* semantic every non-Claude provider relies on.
|
||||
*/
|
||||
includeSelf?: boolean;
|
||||
}
|
||||
|
||||
// Stable ordering used for skill lists in prompts: name (case-insensitive), then name, then path.
|
||||
@@ -368,7 +377,13 @@ export async function scanSkillsFromDir(
|
||||
}
|
||||
};
|
||||
|
||||
const work = [];
|
||||
const work: Promise<void>[] = [];
|
||||
if (options.includeSelf) {
|
||||
const selfSkillPath = path.join(dir, "SKILL.md");
|
||||
if (fs.existsSync(selfSkillPath)) {
|
||||
work.push(loadSkill(selfSkillPath));
|
||||
}
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue;
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
||||
|
||||
@@ -679,21 +679,35 @@ function wrapEditRendererLine(line: string, width: number): string[] {
|
||||
const startAnsi = line.match(/^((?:\x1b\[[0-9;]*m)*)/)?.[1] ?? "";
|
||||
const bodyWithReset = line.slice(startAnsi.length);
|
||||
const body = bodyWithReset.endsWith("\x1b[39m") ? bodyWithReset.slice(0, -"\x1b[39m".length) : bodyWithReset;
|
||||
const diffMatch = /^([+\-\s])(\s*\d+)([|│])(.*)$/s.exec(body);
|
||||
// Gutter shapes produced by formatCodeFrameLine: "-315│", " 313│", "+322│",
|
||||
// plus the deduplicated forms " +│" and " │" whose repeated line number
|
||||
// renderDiff blanked (single-line replacement pairs and insert-then-context
|
||||
// runs) — all │-separated. ASCII "|" gutters exist only in raw canonical
|
||||
// diff rows passed through by the plain fallback ("-42|old", " 42|ctx"),
|
||||
// which always carry a marker column ("+"/"-"/space) and a line number. So
|
||||
// the number is optional for "│", while "|" requires the full canonical
|
||||
// shape; anything else (a body line merely starting with "|", error text
|
||||
// like "123|…") is not a diff row and wraps generically.
|
||||
const diffMatch = /^(\s*[+-]?\s*\d*)([|│])(.*)$/s.exec(body);
|
||||
|
||||
if (!diffMatch) {
|
||||
if (!diffMatch || diffMatch[1].length === 0 || (diffMatch[2] === "|" && !/^[+\-\s]\s*\d+$/.test(diffMatch[1]))) {
|
||||
return wrapTextWithAnsi(line, width);
|
||||
}
|
||||
|
||||
const [, marker, lineNum, separator, content] = diffMatch;
|
||||
const prefix = `${marker}${lineNum}${separator}`;
|
||||
const [, gutter, separator, content] = diffMatch;
|
||||
const prefix = `${gutter}${separator}`;
|
||||
const prefixWidth = visibleWidth(prefix);
|
||||
const contentWidth = Math.max(1, width - prefixWidth);
|
||||
const continuationPrefix = `${" ".repeat(Math.max(0, prefixWidth - 1))}${separator}`;
|
||||
const wrappedContent = wrapTextWithAnsi(content ?? "", contentWidth);
|
||||
|
||||
// Each visual row is a standalone terminal line: wrapTextWithAnsi re-opens
|
||||
// active SGR state at the next row's start, so a row that breaks inside an
|
||||
// intra-line diff highlight still ends with inverse video active. Close it
|
||||
// alongside the foreground reset — otherwise the frame padding appended
|
||||
// after the row is painted as an inverse block (default-foreground cells).
|
||||
return wrappedContent.map(
|
||||
(segment, index) => `${startAnsi}${index === 0 ? prefix : continuationPrefix}${segment}\x1b[39m`,
|
||||
(segment, index) => `${startAnsi}${index === 0 ? prefix : continuationPrefix}${segment}\x1b[27m\x1b[39m`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { isMainThread } from "node:worker_threads";
|
||||
import { postmortem } from "@oh-my-pi/pi-utils";
|
||||
import { ToolError } from "../../tools/tool-errors";
|
||||
import { JsRuntime, type RuntimeHooks } from "./shared/runtime";
|
||||
import type { RunErrorPayload, SessionSnapshot, ToolReply, Transport, WorkerInbound } from "./worker-protocol";
|
||||
import type {
|
||||
RunErrorPayload,
|
||||
SessionSnapshot,
|
||||
ToolReply,
|
||||
Transport,
|
||||
WorkerInbound,
|
||||
WorkerOutbound,
|
||||
} from "./worker-protocol";
|
||||
|
||||
interface PendingTool {
|
||||
runId: string;
|
||||
@@ -10,9 +19,17 @@ interface PendingTool {
|
||||
|
||||
interface ActiveRun {
|
||||
runId: string;
|
||||
filename: string;
|
||||
pendingTools: Map<string, PendingTool>;
|
||||
/** Rejections floated by this run's cell code, captured before its result was sent. */
|
||||
floatingRejections: unknown[];
|
||||
}
|
||||
|
||||
type RunResult = Extract<WorkerOutbound, { type: "result" }>;
|
||||
|
||||
/** Finished-cell filenames retained for attributing rejections that surface after the run settled. */
|
||||
const RECENT_CELL_FILES_MAX = 256;
|
||||
|
||||
function errorPayload(error: unknown): RunErrorPayload {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
@@ -34,15 +51,134 @@ function errorFromPayload(payload: RunErrorPayload): Error {
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold rejections floated by cell code into the run result: an otherwise
|
||||
* successful run fails with the first floating rejection (an unawaited promise
|
||||
* failing is a cell failure, not a success with noise); the rest surface as
|
||||
* output text so nothing is silently dropped.
|
||||
*/
|
||||
function foldFloatingRejections(active: ActiveRun, result: RunResult, hooks: RuntimeHooks): RunResult {
|
||||
const rejections = active.floatingRejections;
|
||||
if (rejections.length === 0) return result;
|
||||
let folded = result;
|
||||
let reported = rejections;
|
||||
if (result.ok) {
|
||||
const error = errorPayload(rejections[0]);
|
||||
error.message = `Unhandled rejection (missing await?): ${error.message}`;
|
||||
folded = { type: "result", runId: active.runId, ok: false, error };
|
||||
reported = rejections.slice(1);
|
||||
}
|
||||
for (const reason of reported) {
|
||||
const payload = errorPayload(reason);
|
||||
hooks.onText(`[unhandled rejection] ${payload.name ?? "Error"}: ${payload.message}\n`);
|
||||
}
|
||||
return folded;
|
||||
}
|
||||
|
||||
export class WorkerCore {
|
||||
#transport: Transport;
|
||||
#runtime: JsRuntime | null = null;
|
||||
#runs = new Map<string, ActiveRun>();
|
||||
#recentCellFiles = new Set<string>();
|
||||
#unsubscribe: () => void;
|
||||
#uninstallRejectionGuard: () => void;
|
||||
|
||||
constructor(transport: Transport) {
|
||||
this.#transport = transport;
|
||||
this.#unsubscribe = transport.onMessage(msg => this.#handle(msg));
|
||||
this.#uninstallRejectionGuard = this.#installRejectionGuard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture unhandled rejections floated by eval-cell code (unawaited async
|
||||
* calls) so they fail the owning run instead of tearing down the worker or —
|
||||
* via the global postmortem handler — the whole session. On the main thread
|
||||
* (inline fallback) only cell-attributable rejections are consumed; in the
|
||||
* dedicated worker realm a rejection during a live run is cell activity even
|
||||
* without a usable stack, while anything else keeps its default fatality.
|
||||
*/
|
||||
#installRejectionGuard(): () => void {
|
||||
if (isMainThread) {
|
||||
return postmortem.interceptUnhandledRejections(reason => this.#consumeRejection(reason));
|
||||
}
|
||||
const onRejection = (reason: unknown): void => {
|
||||
if (this.#consumeRejection(reason)) return;
|
||||
// Not cell-attributable: restore default fatality. Rethrowing from a
|
||||
// timer surfaces it as an uncaught exception, which reaches the host
|
||||
// as a worker `error` event exactly like an unhandled rejection did
|
||||
// before this listener existed.
|
||||
setTimeout(() => {
|
||||
throw reason;
|
||||
}, 0);
|
||||
};
|
||||
process.on("unhandledRejection", onRejection);
|
||||
return () => {
|
||||
process.off("unhandledRejection", onRejection);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute an unhandled rejection to eval-cell code. Live runs are stashed
|
||||
* on the run (folded into its result after the settle drain); finished cells
|
||||
* downgrade to a host-side warn log. Returns false when the rejection is not
|
||||
* cell activity and must keep the default fatal path.
|
||||
*/
|
||||
#consumeRejection(reason: unknown): boolean {
|
||||
const stack = reason instanceof Error && typeof reason.stack === "string" ? reason.stack : undefined;
|
||||
if (stack) {
|
||||
// The stack can name several cells (helper defined by an earlier cell,
|
||||
// called from the live one); the outermost matching frame is the caller
|
||||
// that owns the floating promise.
|
||||
let owner: ActiveRun | undefined;
|
||||
let ownerIndex = -1;
|
||||
for (const run of this.#runs.values()) {
|
||||
const index = stack.lastIndexOf(run.filename);
|
||||
if (index > ownerIndex) {
|
||||
ownerIndex = index;
|
||||
owner = run;
|
||||
}
|
||||
}
|
||||
if (owner) {
|
||||
owner.floatingRejections.push(reason);
|
||||
return true;
|
||||
}
|
||||
let recent: string | undefined;
|
||||
let recentIndex = -1;
|
||||
for (const filename of this.#recentCellFiles) {
|
||||
const index = stack.lastIndexOf(filename);
|
||||
if (index > recentIndex) {
|
||||
recentIndex = index;
|
||||
recent = filename;
|
||||
}
|
||||
}
|
||||
if (recent) {
|
||||
this.#transport.send({
|
||||
type: "log",
|
||||
level: "warn",
|
||||
msg: "Unhandled rejection from a finished eval cell (missing await?)",
|
||||
meta: { filename: recent, error: errorPayload(reason) },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!isMainThread && this.#runs.size > 0) {
|
||||
// Dedicated eval worker: during a live run, a rejection without a cell
|
||||
// frame (e.g. `Promise.reject("msg")` or a library-created reason) is
|
||||
// still cell activity — nothing else runs user code in this realm.
|
||||
if (this.#runs.size === 1) {
|
||||
const only = this.#runs.values().next().value;
|
||||
only?.floatingRejections.push(reason);
|
||||
return true;
|
||||
}
|
||||
this.#transport.send({
|
||||
type: "log",
|
||||
level: "warn",
|
||||
msg: "Unhandled rejection during concurrent eval runs; cannot attribute to a cell",
|
||||
meta: { error: errorPayload(reason) },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#handle(msg: WorkerInbound): void {
|
||||
@@ -77,23 +213,42 @@ export class WorkerCore {
|
||||
}
|
||||
|
||||
async #runOne(runId: string, code: string, filename: string, snapshot: SessionSnapshot): Promise<void> {
|
||||
const runtime = this.#ensureRuntime(snapshot);
|
||||
runtime.setCwd(snapshot.cwd);
|
||||
const active: ActiveRun = { runId, pendingTools: new Map() };
|
||||
const active: ActiveRun = { runId, filename, pendingTools: new Map(), floatingRejections: [] };
|
||||
this.#runs.set(runId, active);
|
||||
const hooks: RuntimeHooks = {
|
||||
onText: chunk => this.#transport.send({ type: "text", runId, chunk }),
|
||||
onDisplay: output => this.#transport.send({ type: "display", runId, output }),
|
||||
callTool: (name, args) => this.#callTool(active, name, args),
|
||||
};
|
||||
let result: RunResult;
|
||||
try {
|
||||
const runtime = this.#ensureRuntime(snapshot);
|
||||
runtime.setCwd(snapshot.cwd);
|
||||
const value = await runtime.run(code, filename, hooks, { runId, cwd: snapshot.cwd });
|
||||
runtime.displayValue(value, hooks);
|
||||
this.#transport.send({ type: "result", runId, ok: true });
|
||||
result = { type: "result", runId, ok: true };
|
||||
} catch (error) {
|
||||
this.#transport.send({ type: "result", runId, ok: false, error: errorPayload(error) });
|
||||
result = { type: "result", runId, ok: false, error: errorPayload(error) };
|
||||
}
|
||||
try {
|
||||
// One event-loop turn so rejections the cell already floated surface
|
||||
// while this run can still own them (rejection callbacks run before
|
||||
// timers fire).
|
||||
await Bun.sleep(0);
|
||||
result = foldFloatingRejections(active, result, hooks);
|
||||
} finally {
|
||||
this.#runs.delete(runId);
|
||||
this.#rememberCellFile(filename);
|
||||
this.#transport.send(result);
|
||||
}
|
||||
}
|
||||
|
||||
#rememberCellFile(filename: string): void {
|
||||
this.#recentCellFiles.delete(filename);
|
||||
this.#recentCellFiles.add(filename);
|
||||
if (this.#recentCellFiles.size > RECENT_CELL_FILES_MAX) {
|
||||
const oldest = this.#recentCellFiles.values().next().value;
|
||||
if (oldest !== undefined) this.#recentCellFiles.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +282,7 @@ export class WorkerCore {
|
||||
this.#runtime?.dispose?.();
|
||||
this.#runtime = null;
|
||||
this.#transport.send({ type: "closed" });
|
||||
this.#uninstallRejectionGuard();
|
||||
this.#unsubscribe();
|
||||
this.#transport.close();
|
||||
}
|
||||
@@ -141,6 +297,7 @@ export class WorkerCore {
|
||||
this.#runs.clear();
|
||||
this.#runtime?.dispose?.();
|
||||
this.#runtime = null;
|
||||
this.#uninstallRejectionGuard();
|
||||
this.#unsubscribe();
|
||||
try {
|
||||
this.#transport.close();
|
||||
|
||||
@@ -14,6 +14,7 @@ import { buildNonInteractiveEnv } from "./non-interactive-env";
|
||||
|
||||
export interface BashExecutorOptions {
|
||||
cwd?: string;
|
||||
/** Milliseconds before aborting the command; 0 disables the executor deadline. */
|
||||
timeout?: number;
|
||||
onChunk?: (chunk: string) => void;
|
||||
chunkThrottleMs?: number;
|
||||
@@ -296,11 +297,15 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
|
||||
let timeoutTimer: NodeJS.Timeout | undefined;
|
||||
const timeoutDeferred = Promise.withResolvers<"timeout">();
|
||||
const baseTimeoutMs = Math.max(1_000, options?.timeout ?? 300_000);
|
||||
timeoutTimer = setTimeout(() => {
|
||||
abortCurrentExecution();
|
||||
timeoutDeferred.resolve("timeout");
|
||||
}, baseTimeoutMs);
|
||||
const requestedTimeoutMs = options?.timeout;
|
||||
const deadlineTimeoutMs = requestedTimeoutMs === 0 ? undefined : Math.max(1_000, requestedTimeoutMs ?? 300_000);
|
||||
const nativeTimeoutMs = requestedTimeoutMs !== undefined && requestedTimeoutMs > 0 ? requestedTimeoutMs : undefined;
|
||||
if (deadlineTimeoutMs !== undefined) {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
abortCurrentExecution();
|
||||
timeoutDeferred.resolve("timeout");
|
||||
}, deadlineTimeoutMs);
|
||||
}
|
||||
|
||||
let resetSession = false;
|
||||
|
||||
@@ -311,7 +316,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
command: finalCommand,
|
||||
cwd: commandCwd,
|
||||
env: commandEnv,
|
||||
timeoutMs: options?.timeout,
|
||||
timeoutMs: nativeTimeoutMs,
|
||||
signal: runAbortController.signal,
|
||||
},
|
||||
(err, chunk) => {
|
||||
@@ -328,7 +333,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
sessionEnv: shellEnv,
|
||||
snapshotPath: snapshotPath ?? undefined,
|
||||
minimizer,
|
||||
timeoutMs: options?.timeout,
|
||||
timeoutMs: nativeTimeoutMs,
|
||||
signal: runAbortController.signal,
|
||||
},
|
||||
(err, chunk) => {
|
||||
@@ -359,8 +364,8 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
...(await sink.dump(
|
||||
winner.kind === "timeout"
|
||||
? `Command timed out after ${Math.round(baseTimeoutMs / 1000)} seconds`
|
||||
winner.kind === "timeout" && deadlineTimeoutMs !== undefined
|
||||
? `Command timed out after ${Math.round(deadlineTimeoutMs / 1000)} seconds`
|
||||
: "Command cancelled",
|
||||
)),
|
||||
};
|
||||
|
||||
@@ -1059,7 +1059,8 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
|
||||
let resolved: string | null = null;
|
||||
let nextFollowsBareDependencies = followBareDependencies;
|
||||
if (specifier.startsWith(".")) {
|
||||
resolved = await realpathOrSelf(Bun.resolveSync(specifier, dir));
|
||||
const candidate = Bun.resolveSync(specifier, dir);
|
||||
resolved = hasSourceModuleExtension(candidate) ? await realpathOrSelf(candidate) : null;
|
||||
} else if (specifier.startsWith("#")) {
|
||||
resolved = await resolvePackageImportSpecifier(specifier, file);
|
||||
} else if (
|
||||
@@ -1078,7 +1079,10 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
|
||||
dependencyExtension === ".cjs" ||
|
||||
dependencyExtension === ".cts" ||
|
||||
((dependencyExtension === ".js" || dependencyExtension === ".jsx") && manifest?.type !== "module");
|
||||
resolved = dependencyEntry && !isCommonJsEntry ? await realpathOrSelf(dependencyEntry) : null;
|
||||
resolved =
|
||||
dependencyEntry && hasSourceModuleExtension(dependencyEntry) && !isCommonJsEntry
|
||||
? await realpathOrSelf(dependencyEntry)
|
||||
: null;
|
||||
nextFollowsBareDependencies = false;
|
||||
}
|
||||
if (resolved && !modules.has(resolved)) {
|
||||
|
||||
@@ -196,19 +196,20 @@ export function parseMarketplaceCatalog(content: string, filePath: string): Mark
|
||||
* Catalog paths tried in priority order: omp-namespaced override first, then
|
||||
* the Claude Code-compatible fallback so existing marketplaces keep loading.
|
||||
*/
|
||||
const CATALOG_RELATIVE_PATHS: readonly string[] = [
|
||||
path.join(".omp-plugin", "marketplace.json"),
|
||||
path.join(".claude-plugin", "marketplace.json"),
|
||||
];
|
||||
const CATALOG_RELATIVE_PATHS: readonly string[] = [".omp-plugin/marketplace.json", ".claude-plugin/marketplace.json"];
|
||||
|
||||
async function readMarketplaceCatalog(root: string): Promise<{ catalogPath: string; content: string }> {
|
||||
async function readMarketplaceCatalog(
|
||||
root: string,
|
||||
options: { relativeDisplayPaths?: boolean } = {},
|
||||
): Promise<{ catalogPath: string; displayPath: string; content: string }> {
|
||||
const tried: string[] = [];
|
||||
for (const rel of CATALOG_RELATIVE_PATHS) {
|
||||
const catalogPath = path.join(root, rel);
|
||||
tried.push(catalogPath);
|
||||
const catalogPath = path.join(root, ...rel.split("/"));
|
||||
const displayPath = options.relativeDisplayPaths ? rel : catalogPath;
|
||||
tried.push(displayPath);
|
||||
try {
|
||||
const content = await Bun.file(catalogPath).text();
|
||||
return { catalogPath, content };
|
||||
return { catalogPath, displayPath, content };
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) continue;
|
||||
throw err;
|
||||
@@ -252,11 +253,11 @@ export async function fetchMarketplace(source: string, cacheDir: string): Promis
|
||||
|
||||
if (type === "github") {
|
||||
const url = `https://github.com/${source}.git`;
|
||||
return cloneAndReadCatalog(url, cacheDir);
|
||||
return cloneAndReadCatalog(url, source, cacheDir);
|
||||
}
|
||||
|
||||
if (type === "git") {
|
||||
return cloneAndReadCatalog(source, cacheDir);
|
||||
return cloneAndReadCatalog(source, source, cacheDir);
|
||||
}
|
||||
|
||||
// type === "url"
|
||||
@@ -284,7 +285,7 @@ export async function fetchMarketplace(source: string, cacheDir: string): Promis
|
||||
* responsible for promoting the clone to its final cache location via
|
||||
* `promoteCloneToCache` after any duplicate/drift checks pass.
|
||||
*/
|
||||
async function cloneAndReadCatalog(url: string, cacheDir: string): Promise<FetchResult> {
|
||||
async function cloneAndReadCatalog(url: string, source: string, cacheDir: string): Promise<FetchResult> {
|
||||
const tmpDir = path.join(cacheDir, `.tmp-clone-${Date.now()}`);
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
|
||||
@@ -292,12 +293,12 @@ async function cloneAndReadCatalog(url: string, cacheDir: string): Promise<Fetch
|
||||
await git.clone(url, tmpDir);
|
||||
|
||||
try {
|
||||
const { catalogPath, content } = await readMarketplaceCatalog(tmpDir);
|
||||
const catalog = parseMarketplaceCatalog(content, catalogPath);
|
||||
const { displayPath, content } = await readMarketplaceCatalog(tmpDir, { relativeDisplayPaths: true });
|
||||
const catalog = parseMarketplaceCatalog(content, displayPath);
|
||||
return { catalog, clonePath: tmpDir };
|
||||
} catch (err) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
throw new Error(`Cloned repository ${url}: ${(err as Error).message}`, { cause: err });
|
||||
throw new Error(`Cloned repository ${url}: ${(err as Error).message} (source: ${source})`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface SessionStartEvent {
|
||||
export interface SessionBeforeSwitchEvent {
|
||||
type: "session_before_switch";
|
||||
/** Reason for the switch */
|
||||
reason: "new" | "resume" | "fork";
|
||||
reason: "new" | "resume" | "fork" | "handoff";
|
||||
/** Session file we're switching to (only for "resume") */
|
||||
targetSessionFile?: string;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export interface SessionBeforeSwitchEvent {
|
||||
export interface SessionSwitchEvent {
|
||||
type: "session_switch";
|
||||
/** Reason for the switch */
|
||||
reason: "new" | "resume" | "fork";
|
||||
reason: "new" | "resume" | "fork" | "handoff";
|
||||
/** Session file we came from */
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterAll, afterEach, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { AgentRegistry } from "../../registry/agent-registry";
|
||||
import type { AgentSession } from "../../session/agent-session";
|
||||
import { ArtifactManager } from "../../session/artifacts";
|
||||
import { AgentProtocolHandler } from "../agent-protocol";
|
||||
import { resetRegisteredArtifactDirsForTests } from "../registry-helpers";
|
||||
|
||||
const tempDir = TempDir.createSync("omp-nested-agent-repro-");
|
||||
afterEach(() => {
|
||||
AgentRegistry.resetGlobalForTests();
|
||||
resetRegisteredArtifactDirsForTests();
|
||||
});
|
||||
afterAll(() => {
|
||||
tempDir.removeSync();
|
||||
});
|
||||
|
||||
it("agent:// resolves a depth-2 subagent's .md output while its session is live and artifact-manager-adopted", async () => {
|
||||
const root = tempDir.path();
|
||||
const rootSessionFile = path.join(root, "session.jsonl");
|
||||
const rootArtifactsDir = rootSessionFile.slice(0, -6);
|
||||
await fs.mkdir(rootArtifactsDir, { recursive: true });
|
||||
// Every subagent adopts the root ArtifactManager and reports its dir.
|
||||
const sharedArtifactManager = new ArtifactManager(rootArtifactsDir);
|
||||
|
||||
// A depth-1 subagent's OWN children are written under its own
|
||||
// sessionFile.slice(0, -6) (task/index.ts), i.e. one level deeper.
|
||||
const midSessionFile = path.join(rootArtifactsDir, "CodexDeepDive.jsonl");
|
||||
const midOwnArtifactsDir = midSessionFile.slice(0, -6);
|
||||
await fs.mkdir(midOwnArtifactsDir, { recursive: true });
|
||||
|
||||
const grandchildId = "CodexDeepDive.GraphStore";
|
||||
const grandchildSessionFile = path.join(midOwnArtifactsDir, `${grandchildId}.jsonl`);
|
||||
await fs.writeFile(path.join(midOwnArtifactsDir, `${grandchildId}.md`), "full report content");
|
||||
|
||||
const fakeSession = {
|
||||
sessionManager: { getArtifactsDir: () => sharedArtifactManager.dir },
|
||||
} as unknown as AgentSession;
|
||||
const registry = AgentRegistry.global();
|
||||
registry.register({
|
||||
id: "Main",
|
||||
displayName: "main",
|
||||
kind: "main",
|
||||
session: fakeSession,
|
||||
sessionFile: rootSessionFile,
|
||||
});
|
||||
registry.register({
|
||||
id: "CodexDeepDive",
|
||||
displayName: "sub",
|
||||
kind: "sub",
|
||||
parentId: "Main",
|
||||
session: fakeSession,
|
||||
sessionFile: midSessionFile,
|
||||
});
|
||||
registry.register({
|
||||
id: grandchildId,
|
||||
displayName: "sub",
|
||||
kind: "sub",
|
||||
parentId: "CodexDeepDive",
|
||||
session: fakeSession,
|
||||
sessionFile: grandchildSessionFile,
|
||||
});
|
||||
|
||||
const resource = await new AgentProtocolHandler().resolve(new URL(`agent://${grandchildId}`) as never);
|
||||
expect(resource.content).toBe("full report content");
|
||||
});
|
||||
@@ -20,11 +20,13 @@ export function resetRegisteredArtifactDirsForTests(): void {
|
||||
/**
|
||||
* Snapshot of artifacts dirs for every registered session, deduped.
|
||||
*
|
||||
* Prefers `sessionManager.getArtifactsDir()` because subagents adopt their
|
||||
* parent's `ArtifactManager` and report the parent's dir there; dedup then
|
||||
* collapses parent + N subagents (the whole agent tree) to one entry. Falls
|
||||
* back to the raw session file (with the `.jsonl` suffix stripped) when no
|
||||
* live session reference is attached.
|
||||
* Collects TWO candidate dirs per ref, because a subagent reads from its
|
||||
* adopted (root-wide) `ArtifactManager.dir` but its own children are written
|
||||
* one level deeper, under `sessionFile.slice(0, -6)` (`task/index.ts`). A
|
||||
* depth-2+ subagent's output therefore lives in the write-time dir, not the
|
||||
* adopted one, so `agent://` must scan both or it 404s a live nested peer.
|
||||
* `addDir` dedup collapses the depth-0 case (both formulas agree) back to a
|
||||
* single entry.
|
||||
*/
|
||||
export function artifactsDirsFromRegistry(): string[] {
|
||||
const dirs: string[] = [];
|
||||
@@ -33,7 +35,8 @@ export function artifactsDirsFromRegistry(): string[] {
|
||||
if (!dirs.includes(dir)) dirs.push(dir);
|
||||
};
|
||||
for (const ref of AgentRegistry.global().list()) {
|
||||
addDir(ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null));
|
||||
addDir(ref.session?.sessionManager.getArtifactsDir());
|
||||
if (ref.sessionFile) addDir(ref.sessionFile.slice(0, -6));
|
||||
}
|
||||
for (const dir of extraArtifactsDirs) addDir(dir);
|
||||
return dirs;
|
||||
|
||||
@@ -90,16 +90,20 @@ interface RoleAssignment {
|
||||
autoSelected: boolean;
|
||||
}
|
||||
|
||||
type ModelSelectorAction = "modelRole" | "retryFallback";
|
||||
|
||||
type RoleSelectCallback = (
|
||||
model: Model,
|
||||
role: string | null,
|
||||
thinkingLevel?: ConfiguredThinkingLevel,
|
||||
selector?: string,
|
||||
action?: ModelSelectorAction,
|
||||
) => void;
|
||||
type CancelCallback = () => void;
|
||||
interface MenuRoleAction {
|
||||
label: string;
|
||||
role: string; // now accepts custom role strings
|
||||
role: string;
|
||||
action: ModelSelectorAction;
|
||||
}
|
||||
|
||||
interface ProviderTabState {
|
||||
@@ -284,14 +288,19 @@ export class ModelSelectorComponent extends Container {
|
||||
}
|
||||
|
||||
#buildMenuRoleActions(): void {
|
||||
this.#menuRoleActions = getKnownRoleIds(this.#settings).map(role => {
|
||||
const roleActions = getKnownRoleIds(this.#settings).map(role => {
|
||||
const roleInfo = getRoleInfo(role, this.#settings);
|
||||
const roleLabel = roleInfo.tag ? `${roleInfo.tag} (${roleInfo.name})` : roleInfo.name;
|
||||
return {
|
||||
label: `Set as ${roleLabel}`,
|
||||
role,
|
||||
action: "modelRole" as const,
|
||||
};
|
||||
});
|
||||
this.#menuRoleActions = [
|
||||
...roleActions,
|
||||
{ label: "Set as DEFAULT retry fallback", role: "default", action: "retryFallback" },
|
||||
];
|
||||
}
|
||||
|
||||
#loadRoleModels(autoCandidateModels?: ReadonlyArray<Model>): void {
|
||||
@@ -1195,6 +1204,11 @@ export class ModelSelectorComponent extends Container {
|
||||
if (this.#menuStep === "role") {
|
||||
const action = this.#menuRoleActions[this.#menuSelectedIndex];
|
||||
if (!action) return;
|
||||
if (action.action === "retryFallback") {
|
||||
this.#handleSelect(selectedItem, action.role, undefined, action.action);
|
||||
this.#closeMenu();
|
||||
return;
|
||||
}
|
||||
this.#menuSelectedRole = action.role;
|
||||
this.#menuStep = "thinking";
|
||||
this.#menuSelectedIndex = this.#getThinkingPreselectIndex(action.role, selectedItem.model);
|
||||
@@ -1206,7 +1220,7 @@ export class ModelSelectorComponent extends Container {
|
||||
const thinkingOptions = this.#getThinkingLevelsForModel(selectedItem.model);
|
||||
const thinkingLevel = thinkingOptions[this.#menuSelectedIndex];
|
||||
if (!thinkingLevel) return;
|
||||
this.#handleSelect(selectedItem, this.#menuSelectedRole, thinkingLevel);
|
||||
this.#handleSelect(selectedItem, this.#menuSelectedRole, thinkingLevel, "modelRole");
|
||||
this.#closeMenu();
|
||||
return;
|
||||
}
|
||||
@@ -1225,13 +1239,23 @@ export class ModelSelectorComponent extends Container {
|
||||
}
|
||||
}
|
||||
|
||||
#handleSelect(item: ModelItem, role: string | null, thinkingLevel?: ConfiguredThinkingLevel): void {
|
||||
#handleSelect(
|
||||
item: ModelItem,
|
||||
role: string | null,
|
||||
thinkingLevel?: ConfiguredThinkingLevel,
|
||||
action: ModelSelectorAction = "modelRole",
|
||||
): void {
|
||||
if (this.#isItemDisabled(item)) {
|
||||
return;
|
||||
}
|
||||
// For temporary role, don't save to settings - just notify caller
|
||||
if (role === null) {
|
||||
this.#onSelectCallback(item.model, null, undefined, item.selector);
|
||||
this.#onSelectCallback(item.model, null, undefined, item.selector, action);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "retryFallback") {
|
||||
this.#onSelectCallback(item.model, role, undefined, item.selector, action);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1241,7 +1265,7 @@ export class ModelSelectorComponent extends Container {
|
||||
this.#roles[role] = { model: item.model, thinkingLevel: selectedThinkingLevel, autoSelected: false };
|
||||
|
||||
// Notify caller (for updating agent state if needed)
|
||||
this.#onSelectCallback(item.model, role, selectedThinkingLevel, item.selector);
|
||||
this.#onSelectCallback(item.model, role, selectedThinkingLevel, item.selector, action);
|
||||
|
||||
// Update list to show new badges
|
||||
this.#updateList();
|
||||
|
||||
@@ -180,7 +180,7 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
|
||||
}
|
||||
|
||||
if (schemaType === "record") {
|
||||
return path === "providers.maxInFlightRequests" ? { ...base, type: "providerLimits" } : null;
|
||||
return path === "providers.maxInFlightRequests" ? { ...base, type: "providerLimits" } : { ...base, type: "text" };
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1235,9 +1235,21 @@ export class StatusLineComponent implements Component {
|
||||
}
|
||||
}
|
||||
}
|
||||
const leftOverflowDropIndex = (): number => {
|
||||
// Preserve the current working directory as long as possible. The
|
||||
// previous right-to-left pop could collapse a normal-width bar to
|
||||
// just the model segment, hiding the path before less-critical left
|
||||
// segments such as model/mode/collab were removed.
|
||||
for (let i = leftSegIds.length - 1; i >= 0; i--) {
|
||||
if (leftSegIds[i] !== "path") return i;
|
||||
}
|
||||
return left.length - 1;
|
||||
};
|
||||
|
||||
while (totalWidth() > topFillWidth && left.length > 0) {
|
||||
left.pop();
|
||||
leftSegIds.pop();
|
||||
const dropIdx = leftOverflowDropIndex();
|
||||
left.splice(dropIdx, 1);
|
||||
leftSegIds.splice(dropIdx, 1);
|
||||
leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -849,11 +849,7 @@ export class CommandController {
|
||||
}
|
||||
|
||||
async #runNewSessionFlow(options?: NewSessionOptions, label: string = "New session started"): Promise<void> {
|
||||
if (this.ctx.loadingAnimation) {
|
||||
this.ctx.loadingAnimation.stop();
|
||||
this.ctx.loadingAnimation = undefined;
|
||||
}
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.clearTransientSessionUi();
|
||||
|
||||
if (this.ctx.session.isCompacting) {
|
||||
this.ctx.session.abortCompaction();
|
||||
@@ -867,14 +863,9 @@ export class CommandController {
|
||||
|
||||
this.ctx.statusLine.invalidate();
|
||||
this.ctx.statusLine.resetActiveTime();
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.updateEditorBorderColor();
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.pendingMessagesContainer.clear();
|
||||
this.ctx.compactionQueuedMessages = [];
|
||||
this.ctx.streamingComponent = undefined;
|
||||
this.ctx.streamingMessage = undefined;
|
||||
this.ctx.pendingTools.clear();
|
||||
this.ctx.clearTransientSessionUi();
|
||||
this.ctx.resetTranscript();
|
||||
|
||||
this.ctx.present([new Spacer(1), new Text(`${theme.fg("accent", `${theme.status.success} ${label}`)}`, 1, 1)]);
|
||||
await this.ctx.reloadTodos();
|
||||
@@ -914,7 +905,7 @@ export class CommandController {
|
||||
this.ctx.loadingAnimation.stop();
|
||||
this.ctx.loadingAnimation = undefined;
|
||||
}
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
|
||||
const success = await this.ctx.session.fork();
|
||||
if (!success) {
|
||||
@@ -1177,7 +1168,7 @@ export class CommandController {
|
||||
this.ctx.loadingAnimation.stop();
|
||||
this.ctx.loadingAnimation = undefined;
|
||||
}
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
|
||||
const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
|
||||
const compactingLoader = new Loader(
|
||||
@@ -1207,7 +1198,7 @@ export class CommandController {
|
||||
await this.ctx.session.compact(instructions, options);
|
||||
|
||||
compactingLoader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
this.ctx.rebuildChatFromMessages();
|
||||
|
||||
this.ctx.statusLine.invalidate();
|
||||
@@ -1223,7 +1214,7 @@ export class CommandController {
|
||||
}
|
||||
} finally {
|
||||
compactingLoader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
}
|
||||
// Run the caller's pre-flush hook (e.g. the plan-approval model transition)
|
||||
// before queued user input is dispatched, so any turn queued during
|
||||
@@ -1252,7 +1243,7 @@ export class CommandController {
|
||||
this.ctx.loadingAnimation.stop();
|
||||
this.ctx.loadingAnimation = undefined;
|
||||
}
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
|
||||
const handoffLoader = new Loader(
|
||||
this.ctx.ui,
|
||||
@@ -1273,11 +1264,10 @@ export class CommandController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild chat from the new session (which now contains the handoff document)
|
||||
this.ctx.rebuildChatFromMessages();
|
||||
|
||||
// Rebuild chat from the new session (which now contains the handoff document).
|
||||
this.ctx.clearTransientSessionUi();
|
||||
this.ctx.renderInitialMessages();
|
||||
this.ctx.statusLine.invalidate();
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.updateEditorBorderColor();
|
||||
await this.ctx.reloadTodos();
|
||||
|
||||
@@ -1297,9 +1287,9 @@ export class CommandController {
|
||||
}
|
||||
} finally {
|
||||
handoffLoader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
}
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.ui.requestRender(true, { clearScrollback: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -379,7 +379,7 @@ export class EventController {
|
||||
if (this.ctx.retryLoader) {
|
||||
this.ctx.retryLoader.stop();
|
||||
this.ctx.retryLoader = undefined;
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
}
|
||||
this.#cancelIdleCompaction();
|
||||
this.#cancelIdleRecap();
|
||||
@@ -1083,7 +1083,7 @@ export class EventController {
|
||||
if (this.ctx.loadingAnimation) {
|
||||
this.ctx.loadingAnimation.stop();
|
||||
this.ctx.loadingAnimation = undefined;
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
}
|
||||
if (this.ctx.streamingComponent) {
|
||||
this.ctx.chatContainer.removeChild(this.ctx.streamingComponent);
|
||||
@@ -1126,9 +1126,9 @@ export class EventController {
|
||||
|
||||
/**
|
||||
* Tear down the live "Working…" loader: stop its animation timer AND clear the
|
||||
* reference. A transient overlay (auto-compaction / auto-retry) that only ran
|
||||
* `statusContainer.clear()` detached the loader from the container but left
|
||||
* `ctx.loadingAnimation` set, so the resumed turn's `agent_start` →
|
||||
* reference. A transient overlay (auto-compaction / auto-retry) can remove the
|
||||
* loader from the container while leaving `ctx.loadingAnimation` set, so the
|
||||
* resumed turn's `agent_start` →
|
||||
* `ensureLoadingAnimation()` (guarded by `if (!this.loadingAnimation)`) skipped
|
||||
* re-adding it and the spinner vanished while the agent kept streaming. Nulling
|
||||
* the reference here lets the next `agent_start` recreate and re-attach it.
|
||||
@@ -1169,7 +1169,7 @@ export class EventController {
|
||||
this.#cancelIdleRecap();
|
||||
this.#setTerminalProgress(true);
|
||||
this.#stopWorkingLoader();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
const reasonText =
|
||||
event.reason === "overflow"
|
||||
? "Context overflow detected, "
|
||||
@@ -1204,7 +1204,7 @@ export class EventController {
|
||||
if (this.ctx.autoCompactionLoader) {
|
||||
this.ctx.autoCompactionLoader.stop();
|
||||
this.ctx.autoCompactionLoader = undefined;
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
}
|
||||
const isHandoffAction = event.action === "handoff";
|
||||
const isShakeAction = event.action === "shake";
|
||||
@@ -1246,12 +1246,12 @@ export class EventController {
|
||||
} else if (event.errorMessage) {
|
||||
this.ctx.showWarning(event.errorMessage);
|
||||
} else if (isHandoffAction) {
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.clearTransientSessionUi();
|
||||
this.ctx.lastAssistantUsage = undefined;
|
||||
this.ctx.rebuildChatFromMessages();
|
||||
this.ctx.renderInitialMessages();
|
||||
this.ctx.statusLine.invalidate();
|
||||
this.ctx.ui.requestRender();
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.ui.requestRender(true, { clearScrollback: true });
|
||||
this.ctx.showStatus("Auto-handoff completed");
|
||||
} else if (event.skipped) {
|
||||
// Benign skip: no model selected, no candidate models available, or nothing
|
||||
@@ -1269,7 +1269,7 @@ export class EventController {
|
||||
async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
|
||||
this.#trackRetrySupersededAssistantComponent(this.#lastAssistantComponent);
|
||||
this.#stopWorkingLoader();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
if (AIError.is(event.errorId, AIError.Flag.ThinkingLoop)) {
|
||||
// The retry path drops the failed assistant from runtime context. Do not
|
||||
// restore its inline Error row; just unpin the fixed-region banner so the
|
||||
@@ -1293,7 +1293,7 @@ export class EventController {
|
||||
if (this.ctx.retryLoader) {
|
||||
this.ctx.retryLoader.stop();
|
||||
this.ctx.retryLoader = undefined;
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
}
|
||||
if (event.success) {
|
||||
let appliedRecovered = false;
|
||||
|
||||
@@ -162,18 +162,12 @@ export class ExtensionUiController {
|
||||
waitForIdle: () => this.ctx.session.agent.waitForIdle(),
|
||||
reload: async () => {
|
||||
await this.ctx.session.reload();
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.showStatus("Reloaded session");
|
||||
},
|
||||
newSession: async options => {
|
||||
// Stop any loading animation
|
||||
if (this.ctx.loadingAnimation) {
|
||||
this.ctx.loadingAnimation.stop();
|
||||
this.ctx.loadingAnimation = undefined;
|
||||
}
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.clearTransientSessionUi();
|
||||
|
||||
// Create new session
|
||||
this.clearExtensionTerminalInputListeners();
|
||||
@@ -192,15 +186,8 @@ export class ExtensionUiController {
|
||||
// Reset and update status line
|
||||
this.ctx.statusLine.invalidate();
|
||||
this.ctx.statusLine.resetActiveTime();
|
||||
this.ctx.ui.requestRender();
|
||||
|
||||
// Clear UI state
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.pendingMessagesContainer.clear();
|
||||
this.ctx.compactionQueuedMessages = [];
|
||||
this.ctx.streamingComponent = undefined;
|
||||
this.ctx.streamingMessage = undefined;
|
||||
this.ctx.pendingTools.clear();
|
||||
this.ctx.clearTransientSessionUi();
|
||||
this.ctx.resetTranscript();
|
||||
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
@@ -218,7 +205,6 @@ export class ExtensionUiController {
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.editor.setText(result.selectedText);
|
||||
@@ -233,7 +219,6 @@ export class ExtensionUiController {
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
||||
await this.ctx.reloadTodos();
|
||||
if (result.editorText && !this.ctx.editor.getText().trim()) {
|
||||
@@ -251,7 +236,6 @@ export class ExtensionUiController {
|
||||
return { cancelled: true };
|
||||
}
|
||||
setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
||||
await this.ctx.reloadTodos();
|
||||
return { cancelled: false };
|
||||
@@ -398,18 +382,12 @@ export class ExtensionUiController {
|
||||
waitForIdle: () => this.ctx.session.agent.waitForIdle(),
|
||||
reload: async () => {
|
||||
await this.ctx.session.reload();
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.showStatus("Reloaded session");
|
||||
},
|
||||
newSession: async options => {
|
||||
// Stop any loading animation
|
||||
if (this.ctx.loadingAnimation) {
|
||||
this.ctx.loadingAnimation.stop();
|
||||
this.ctx.loadingAnimation = undefined;
|
||||
}
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.clearTransientSessionUi();
|
||||
|
||||
// Create new session
|
||||
this.clearExtensionTerminalInputListeners();
|
||||
@@ -425,12 +403,8 @@ export class ExtensionUiController {
|
||||
}
|
||||
|
||||
// Clear UI state
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.pendingMessagesContainer.clear();
|
||||
this.ctx.compactionQueuedMessages = [];
|
||||
this.ctx.streamingComponent = undefined;
|
||||
this.ctx.streamingMessage = undefined;
|
||||
this.ctx.pendingTools.clear();
|
||||
this.ctx.clearTransientSessionUi();
|
||||
this.ctx.resetTranscript();
|
||||
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
@@ -448,7 +422,6 @@ export class ExtensionUiController {
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.editor.setText(result.selectedText);
|
||||
@@ -463,7 +436,6 @@ export class ExtensionUiController {
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
||||
await this.ctx.reloadTodos();
|
||||
if (result.editorText && !this.ctx.editor.getText().trim()) {
|
||||
@@ -480,7 +452,6 @@ export class ExtensionUiController {
|
||||
if (!result) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
||||
await this.ctx.reloadTodos();
|
||||
return { cancelled: false };
|
||||
|
||||
@@ -86,6 +86,20 @@ function hasPasteText(value: unknown): value is PasteTarget {
|
||||
return typeof value === "object" && value !== null && typeof (value as PasteTarget).pasteText === "function";
|
||||
}
|
||||
|
||||
const SHELL_PROMPT_COMMAND_RE =
|
||||
/^(?:\.{0,2}\/|~\/|cd(?:\s|$)|sudo(?:\s|$)|git(?:\s|$)|bun(?:\s|$)|npm(?:\s|$)|pnpm(?:\s|$)|yarn(?:\s|$)|node(?:\s|$)|python\d*(?:\s|$)|cargo(?:\s|$)|go(?:\s|$)|make(?:\s|$)|docker(?:\s|$)|kubectl(?:\s|$))/;
|
||||
const SHELL_PROMPT_OPERATOR_RE = /(?:^|\s)(?:&&|\|\||\||2>&1|[<>]{1,2})(?:\s|$)/;
|
||||
const OMP_STATUS_LINE_RE = /^\s*in:\s+\d+\s+out:\s+\d+(?:\s+cache\s+\S+)?\s+t:\s+\S+\s+tok\/s:\s+\S+/m;
|
||||
|
||||
function looksLikePastedShellPrompt(code: string): boolean {
|
||||
const firstLine = code.split("\n", 1)[0]?.trimStart() ?? "";
|
||||
return (
|
||||
SHELL_PROMPT_COMMAND_RE.test(firstLine) ||
|
||||
SHELL_PROMPT_OPERATOR_RE.test(firstLine) ||
|
||||
OMP_STATUS_LINE_RE.test(code)
|
||||
);
|
||||
}
|
||||
|
||||
function pythonCommandPrefixLength(trimmedText: string): 0 | 1 | 2 {
|
||||
if (trimmedText.charCodeAt(0) !== 36 /* $ */) return 0;
|
||||
if (trimmedText.charCodeAt(1) === 123 /* { */) return 0;
|
||||
@@ -100,8 +114,10 @@ function parsePythonCommandInput(text: string): { code: string; isExcluded: bool
|
||||
const trimmed = text.trimStart();
|
||||
const prefixLength = pythonCommandPrefixLength(trimmed);
|
||||
if (prefixLength === 0) return undefined;
|
||||
const code = trimmed.slice(prefixLength).trim();
|
||||
if (prefixLength === 1 && looksLikePastedShellPrompt(code)) return undefined;
|
||||
return {
|
||||
code: trimmed.slice(prefixLength).trim(),
|
||||
code,
|
||||
isExcluded: prefixLength === 2,
|
||||
};
|
||||
}
|
||||
@@ -536,7 +552,7 @@ export class InputController {
|
||||
const wasPythonMode = this.ctx.isPythonMode;
|
||||
const trimmed = text.trimStart();
|
||||
this.ctx.isBashMode = trimmed.startsWith("!");
|
||||
this.ctx.isPythonMode = pythonCommandPrefixLength(trimmed) > 0;
|
||||
this.ctx.isPythonMode = parsePythonCommandInput(trimmed) !== undefined;
|
||||
if (wasBashMode !== this.ctx.isBashMode || wasPythonMode !== this.ctx.isPythonMode) {
|
||||
this.ctx.updateEditorBorderColor();
|
||||
}
|
||||
|
||||
@@ -88,12 +88,14 @@ function raceAbortSignal<T>(promise: Promise<T>, signal: AbortSignal, createErro
|
||||
const MCP_AUTH_MIN_WRAP_WIDTH = 16;
|
||||
|
||||
/**
|
||||
* Wrap `url` into rows that each fit inside `width`, prefixed by a shared
|
||||
* single-column indent so nested composition doesn't touch column 0. When the
|
||||
* label + URL fit on one line, returns a single row; otherwise puts the label
|
||||
* on its own row and slices the URL into fixed-width chunks. URL chunks are
|
||||
* plain code points — browsers strip whitespace when pasted into the address
|
||||
* bar, so a multi-row selection copies back to the intact URL.
|
||||
* Wrap `url` into rows that each fit inside `width`. When the label + URL fit
|
||||
* on one line, returns a single indented row; otherwise puts the label on its
|
||||
* own indented row and slices the URL into fixed-width chunks that start at
|
||||
* column 0. Continuation chunks carry ZERO leading bytes on purpose: a
|
||||
* multi-row terminal selection includes the newline plus any leading indent,
|
||||
* and while address bars strip newlines they preserve or percent-encode
|
||||
* embedded spaces — an indent would corrupt the URL at every chunk boundary
|
||||
* (silently, when the damage lands inside a query value).
|
||||
*/
|
||||
function wrapUrlRows(label: string, url: string, width: number): string[] {
|
||||
const indent = " ";
|
||||
@@ -103,10 +105,9 @@ function wrapUrlRows(label: string, url: string, width: number): string[] {
|
||||
if (inlineWidth <= effective) {
|
||||
return [`${indent}${theme.fg("muted", `${label} ${sanitized}`)}`];
|
||||
}
|
||||
const chunkWidth = Math.max(1, effective - indent.length);
|
||||
const rows: string[] = [`${indent}${theme.fg("muted", label)}`];
|
||||
for (let i = 0; i < sanitized.length; i += chunkWidth) {
|
||||
rows.push(`${indent}${theme.fg("muted", sanitized.slice(i, i + chunkWidth))}`);
|
||||
for (let i = 0; i < sanitized.length; i += effective) {
|
||||
rows.push(theme.fg("muted", sanitized.slice(i, i + effective)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -593,13 +593,27 @@ export class SelectorController {
|
||||
this.ctx.settings,
|
||||
this.ctx.session.modelRegistry,
|
||||
this.ctx.session.scopedModels,
|
||||
async (model, role, thinkingLevel, selector) => {
|
||||
async (model, role, thinkingLevel, selector, action) => {
|
||||
// `auto` is session-global: never baked into a per-role model value
|
||||
// (it can't round-trip through `model:<level>`). Apply it to the session
|
||||
// separately and persist via `defaultThinkingLevel`.
|
||||
const isAuto = thinkingLevel === AUTO_THINKING;
|
||||
const concreteThinking = isAuto ? undefined : thinkingLevel;
|
||||
const selectorValue = selector ?? `${model.provider}/${model.id}`;
|
||||
try {
|
||||
if (action === "retryFallback" && role !== null) {
|
||||
const fallbackSelector = formatModelSelectorValue(selectorValue, concreteThinking);
|
||||
const fallbackChains = this.ctx.settings.get("retry.fallbackChains");
|
||||
const chain = Array.isArray(fallbackChains[role]) ? fallbackChains[role] : [];
|
||||
this.ctx.settings.set("retry.fallbackChains", {
|
||||
...fallbackChains,
|
||||
[role]: [fallbackSelector, ...chain.filter(existing => existing !== fallbackSelector)],
|
||||
});
|
||||
const roleInfo = getRoleInfo(role, settings);
|
||||
const roleLabel = roleInfo?.name ?? role;
|
||||
this.ctx.showStatus(`${roleLabel} fallback model: ${fallbackSelector}`);
|
||||
return;
|
||||
}
|
||||
if (role === null) {
|
||||
// Temporary: update agent state but don't persist the model to settings
|
||||
await this.ctx.session.setModelTemporary(model);
|
||||
@@ -771,7 +785,6 @@ export class SelectorController {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
||||
this.ctx.editor.setText(result.selectedText);
|
||||
done();
|
||||
@@ -915,7 +928,6 @@ export class SelectorController {
|
||||
|
||||
// Update UI — rebuild the display transcript for the new leaf (the
|
||||
// context from navigateTree is the LLM context, not the transcript).
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
||||
await this.ctx.reloadTodos();
|
||||
if (result.editorText && !this.ctx.editor.getText().trim()) {
|
||||
@@ -927,7 +939,7 @@ export class SelectorController {
|
||||
} finally {
|
||||
if (summaryLoader) {
|
||||
summaryLoader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.statusContainer.disposeChildren();
|
||||
}
|
||||
this.ctx.editor.onEscape = originalOnEscape;
|
||||
}
|
||||
@@ -1067,7 +1079,6 @@ export class SelectorController {
|
||||
this.ctx.updateEditorBorderColor();
|
||||
|
||||
// Clear and re-render the chat
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session");
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Autocomplete for GitHub issue/PR references typed as `#<number>` (e.g. `#3164`).
|
||||
*
|
||||
* Mirrors the `@` file-reference and `scheme://` internal-url conventions: the
|
||||
* token is rewritten to an internal URL (`pr://3164` or `issue://3164`) plus a
|
||||
* trailing space, and the existing tool-mediated pipeline (the `read` tool →
|
||||
* InternalUrlRouter → `gh`) resolves it from the session cwd's git remote.
|
||||
*
|
||||
* No network at suggestion time — candidates are generated locally. GitHub
|
||||
* shares the issue/PR number space and there is no cheap way to tell which a
|
||||
* given number is while typing, so both a PR and an Issue candidate are offered
|
||||
* by default. Naming the type first (`pr #3164` / `issue #3164`) constrains the
|
||||
* candidates to that kind. Anything that is not a standalone `#<number>` token
|
||||
* keeps falling through to the existing prompt-action menu.
|
||||
*/
|
||||
import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
|
||||
|
||||
/** Candidate kinds, in default display order. */
|
||||
const GITHUB_REF_KINDS = [
|
||||
{ qualifier: "pr", scheme: "pr", label: "PR", description: "GitHub pull request" },
|
||||
{ qualifier: "issue", scheme: "issue", label: "Issue", description: "GitHub issue" },
|
||||
] as const;
|
||||
|
||||
export interface GithubRefContext {
|
||||
/** Text to replace on accept: `#3164`, or `pr #3164` when a qualifier precedes it. */
|
||||
prefix: string;
|
||||
/** Type the user named (`pr`/`pull` → `pr`, `issue` → `issue`), or null to offer both. */
|
||||
qualifier: "pr" | "issue" | null;
|
||||
/** The numeric reference, e.g. `3164`. */
|
||||
number: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A standalone `#<positive-number>` token ending at the cursor. The `#` must be
|
||||
* preceded by a token boundary (start, whitespace, or an opening quote/paren/`<`/`=`,
|
||||
* matching the internal-URL boundary set) so embedded hashes like `owner/repo#N`,
|
||||
* `foo#N`, `C#12`, or a URL fragment do not match. An optional `pr`/`pull`/`issue`
|
||||
* qualifier word (case-insensitive) immediately before the `#` constrains the kind.
|
||||
*/
|
||||
const GITHUB_REF_TOKEN_RE = /(?:^|[\s"'`(<=])(?:(pr|pull|issue)(\s+))?#([1-9]\d*)$/i;
|
||||
|
||||
export function getGithubRefContext(textBeforeCursor: string): GithubRefContext | null {
|
||||
const match = textBeforeCursor.match(GITHUB_REF_TOKEN_RE);
|
||||
if (!match) return null;
|
||||
const qualifierWord = match[1];
|
||||
const whitespace = match[2] ?? "";
|
||||
const number = match[3] ?? "";
|
||||
return {
|
||||
prefix: qualifierWord ? `${qualifierWord}${whitespace}#${number}` : `#${number}`,
|
||||
qualifier: !qualifierWord ? null : qualifierWord.toLowerCase() === "issue" ? "issue" : "pr",
|
||||
number,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggestions for a `#<number>` token. Both kinds are offered unless the user
|
||||
* named a type (`pr #3164` / `issue #3164`), in which case only that kind is
|
||||
* offered. Returns `null` when the text before the cursor is not a standalone
|
||||
* `#<number>` token.
|
||||
*/
|
||||
export function getGithubRefSuggestions(
|
||||
textBeforeCursor: string,
|
||||
): { items: AutocompleteItem[]; prefix: string } | null {
|
||||
const context = getGithubRefContext(textBeforeCursor);
|
||||
if (!context) return null;
|
||||
const kinds = context.qualifier
|
||||
? GITHUB_REF_KINDS.filter(kind => kind.qualifier === context.qualifier)
|
||||
: GITHUB_REF_KINDS;
|
||||
const items: AutocompleteItem[] = kinds.map(kind => ({
|
||||
value: `${kind.scheme}://${context.number}`,
|
||||
label: `${kind.label} #${context.number}`,
|
||||
description: kind.description,
|
||||
}));
|
||||
return { items, prefix: context.prefix };
|
||||
}
|
||||
@@ -579,10 +579,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.retryLoader.stop();
|
||||
this.retryLoader = undefined;
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
this.pendingMessagesContainer.clear();
|
||||
this.statusContainer.disposeChildren();
|
||||
this.pendingMessagesContainer.disposeChildren();
|
||||
this.#cancelModelCycleClearTimer();
|
||||
this.modelCycleContainer.clear();
|
||||
this.modelCycleContainer.disposeChildren();
|
||||
this.compactionQueuedMessages = [];
|
||||
this.streamingComponent = undefined;
|
||||
this.streamingMessage = undefined;
|
||||
@@ -2602,6 +2602,49 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
}
|
||||
|
||||
#resolveLocalRoot(): string {
|
||||
return resolveLocalUrlToPath("local://", {
|
||||
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
|
||||
getSessionId: () => this.sessionManager.getSessionId(),
|
||||
});
|
||||
}
|
||||
|
||||
async #copyLocalArtifactsForFreshSession(sourceRoot: string, destinationRoot: string): Promise<void> {
|
||||
if (sourceRoot === destinationRoot) return;
|
||||
|
||||
let sourceRootStat: { isDirectory(): boolean };
|
||||
try {
|
||||
sourceRootStat = await fs.lstat(sourceRoot);
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!sourceRootStat.isDirectory()) return;
|
||||
|
||||
await fs.mkdir(destinationRoot, { recursive: true });
|
||||
await this.#copyLocalArtifactEntries(sourceRoot, destinationRoot);
|
||||
}
|
||||
|
||||
async #copyLocalArtifactEntries(sourceDir: string, destinationDir: string): Promise<void> {
|
||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(sourceDir, entry.name);
|
||||
const destinationPath = path.join(destinationDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await fs.mkdir(destinationPath, { recursive: true });
|
||||
await this.#copyLocalArtifactEntries(sourcePath, destinationPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
await fs.copyFile(sourcePath, destinationPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #approvePlan(
|
||||
planContent: string,
|
||||
options: {
|
||||
@@ -2632,14 +2675,16 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
});
|
||||
|
||||
if (!options.preserveContext) {
|
||||
const oldLocalRoot = this.#resolveLocalRoot();
|
||||
await this.handleClearCommand();
|
||||
// The new session has a fresh local:// root — persist the approved plan there
|
||||
// so `local://<slug>-plan.md` resolves correctly in the execution session.
|
||||
const newLocalRoot = this.#resolveLocalRoot();
|
||||
await this.#copyLocalArtifactsForFreshSession(oldLocalRoot, newLocalRoot);
|
||||
const newLocalPath = resolveLocalUrlToPath(options.planFilePath, {
|
||||
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
|
||||
getSessionId: () => this.sessionManager.getSessionId(),
|
||||
});
|
||||
await Bun.write(newLocalPath, planContent);
|
||||
await fs.mkdir(path.dirname(newLocalPath), { recursive: true });
|
||||
await fs.writeFile(newLocalPath, planContent);
|
||||
} else if (options.compactBeforeExecute) {
|
||||
// Distill the plan-mode transcript before the execution turn is queued so
|
||||
// the plan-approved synthetic prompt lands as a fresh cache anchor.
|
||||
@@ -3626,7 +3671,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
ensureLoadingAnimation(): void {
|
||||
if (!this.loadingAnimation) {
|
||||
this.#clearWorkingMessageAccentCache();
|
||||
this.statusContainer.clear();
|
||||
this.statusContainer.disposeChildren();
|
||||
const messageColorFn = ((message: string) =>
|
||||
renderWorkingMessage(message, this.#getWorkingMessageAccent())) as LoaderMessageColorFn & {
|
||||
animated?: true;
|
||||
@@ -3647,7 +3692,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
);
|
||||
this.statusContainer.addChild(this.loadingAnimation);
|
||||
} else if (!this.statusContainer.children.includes(this.loadingAnimation)) {
|
||||
this.statusContainer.clear();
|
||||
this.statusContainer.disposeChildren();
|
||||
this.statusContainer.addChild(this.loadingAnimation);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
@@ -3660,7 +3705,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.loadingAnimation = undefined;
|
||||
this.#clearWorkingMessageAccentCache();
|
||||
if (clearStatusContainer) {
|
||||
this.statusContainer.clear();
|
||||
this.statusContainer.disposeChildren();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4123,7 +4168,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
this.#btwController.dispose();
|
||||
this.#omfgController.dispose();
|
||||
this.chatContainer.clear();
|
||||
this.renderInitialMessages({ clearTerminalHistory: true });
|
||||
this.updateEditorBorderColor();
|
||||
this.showStatus(
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { formatKeyHints, type KeybindingsManager } from "../config/keybindings";
|
||||
import { isSettingsInitialized, settings } from "../config/settings";
|
||||
import { applyEmojiCompletion, getEmojiSuggestions, isEmojiPrefix, tryEmojiInlineReplace } from "./emoji-autocomplete";
|
||||
import { getGithubRefContext, getGithubRefSuggestions } from "./github-ref-autocomplete";
|
||||
import {
|
||||
applyInternalUrlCompletion,
|
||||
getInternalUrlSuggestions,
|
||||
@@ -94,6 +95,36 @@ function getPromptActionPrefix(textBeforeCursor: string): string | null {
|
||||
return textBeforeCursor.slice(hashIndex);
|
||||
}
|
||||
|
||||
function applyGithubRefCompletion(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
item: AutocompleteItem,
|
||||
prefix: string,
|
||||
): { lines: string[]; cursorLine: number; cursorCol: number } | null {
|
||||
if (!getGithubRefContext(prefix)) return null;
|
||||
const scheme: "pr" | "issue" | null = item.value.startsWith("pr://")
|
||||
? "pr"
|
||||
: item.value.startsWith("issue://")
|
||||
? "issue"
|
||||
: null;
|
||||
if (!scheme) return { lines, cursorLine, cursorCol };
|
||||
|
||||
const currentLine = lines[cursorLine] || "";
|
||||
const liveContext = getGithubRefContext(currentLine.slice(0, cursorCol));
|
||||
if (!liveContext || (liveContext.qualifier && liveContext.qualifier !== scheme)) {
|
||||
return { lines, cursorLine, cursorCol };
|
||||
}
|
||||
|
||||
return applyInternalUrlCompletion(
|
||||
lines,
|
||||
cursorLine,
|
||||
cursorCol,
|
||||
{ ...item, value: `${scheme}://${liveContext.number}` },
|
||||
liveContext.prefix,
|
||||
);
|
||||
}
|
||||
|
||||
export class PromptActionAutocompleteProvider implements AutocompleteProvider {
|
||||
#commands: SlashCommand[];
|
||||
#baseProvider: CombinedAutocompleteProvider;
|
||||
@@ -129,6 +160,8 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
|
||||
}
|
||||
}
|
||||
|
||||
const githubRefSuggestions = getGithubRefSuggestions(textBeforeCursor);
|
||||
if (githubRefSuggestions) return githubRefSuggestions;
|
||||
const promptActionPrefix = getPromptActionPrefix(textBeforeCursor);
|
||||
if (promptActionPrefix) {
|
||||
const query = promptActionPrefix.slice(1).toLowerCase();
|
||||
@@ -176,6 +209,8 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
|
||||
cursorCol: number;
|
||||
onApplied?: () => void;
|
||||
} {
|
||||
const githubRefCompletion = applyGithubRefCompletion(lines, cursorLine, cursorCol, item, prefix);
|
||||
if (githubRefCompletion) return githubRefCompletion;
|
||||
if (prefix.startsWith("#") && isPromptActionItem(item)) {
|
||||
if (item.actionId === "undo") {
|
||||
return {
|
||||
|
||||
@@ -52,7 +52,8 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
|
||||
`| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image or text from clipboard |`,
|
||||
"| Hold `Space` | Speech-to-text (push-to-talk): hold to record, release to transcribe |",
|
||||
`| \`${appKey(bindings, "app.agents.hub")}\` / \`${appKey(bindings, "app.session.observe")}\` / double-tap \`←\` (empty editor) | Open the agent hub |`,
|
||||
"| `#` | Open prompt actions |",
|
||||
"| `#<number>` | GitHub issue/PR reference (e.g. `#3164` → `pr://`/`issue://`) |",
|
||||
"| `#` / `#<text>` | Prompt actions (copy / undo / move cursor) |",
|
||||
"| `/` | Slash commands |",
|
||||
"| `!` | Run bash command |",
|
||||
"| `!!` | Run bash command (excluded from context) |",
|
||||
|
||||
@@ -581,7 +581,7 @@ export class UiHelpers {
|
||||
} else {
|
||||
this.ctx.resetTranscript();
|
||||
}
|
||||
this.ctx.pendingMessagesContainer.clear();
|
||||
this.ctx.pendingMessagesContainer.disposeChildren();
|
||||
this.ctx.pendingBashComponents = [];
|
||||
this.ctx.pendingPythonComponents = [];
|
||||
|
||||
@@ -647,7 +647,7 @@ export class UiHelpers {
|
||||
}
|
||||
|
||||
updatePendingMessagesDisplay(): void {
|
||||
this.ctx.pendingMessagesContainer.clear();
|
||||
this.ctx.pendingMessagesContainer.disposeChildren();
|
||||
const queuedMessages = this.ctx.viewSession.getQueuedMessages() as QueuedMessages;
|
||||
|
||||
const steeringMessages: Array<{ message: string; label: string }> = [];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import workflowNotice from "../prompts/system/workflow-notice.md" with { type: "text" };
|
||||
import { prompt } from "@oh-my-pi/pi-utils";
|
||||
import workflowNoticeTemplate from "../prompts/system/workflow-notice.md" with { type: "text" };
|
||||
import { createGradientHighlighter, type KeywordHighlighter } from "./gradient-highlight";
|
||||
import { keywordInProse } from "./markdown-prose";
|
||||
|
||||
@@ -7,18 +8,23 @@ import { keywordInProse } from "./markdown-prose";
|
||||
*
|
||||
* Typing the standalone word in the input editor paints it with a warm
|
||||
* amber→green gradient ({@link highlightWorkflow}); submitting a message that
|
||||
* mentions it appends a hidden {@link WORKFLOW_NOTICE} that steers the model to
|
||||
* author a deterministic multi-subagent workflow in eval cells (agent/parallel/
|
||||
* pipeline). Matching is whitespace-delimited and case-sensitive (lowercase
|
||||
* only) — "workflowz" triggers, but "workflowzed", "Workflowz", and
|
||||
* "workflowz.ts" never do.
|
||||
* mentions it appends a hidden workflow notice that steers the model to author
|
||||
* a deterministic multi-subagent workflow through the active task schema.
|
||||
* Matching is whitespace-delimited and case-sensitive (lowercase only) —
|
||||
* "workflowz" triggers, but "workflowzed", "Workflowz", and "workflowz.ts"
|
||||
* never do.
|
||||
*/
|
||||
|
||||
// Detection: lowercase keyword flanked by whitespace or a string edge. Non-global so `.test` stays stateless.
|
||||
const WORKFLOW_WORD = /(?<!\S)workflowz(?!\S)/;
|
||||
|
||||
/** Hidden system notice appended after a user message that mentions "workflowz". */
|
||||
export const WORKFLOW_NOTICE: string = workflowNotice.trim();
|
||||
/** WORKFLOW_NOTICE is the default hidden notice for sessions with batched task calls enabled. */
|
||||
export const WORKFLOW_NOTICE: string = renderWorkflowNotice({ taskBatch: true });
|
||||
|
||||
/** renderWorkflowNotice renders the workflow notice for the active task schema. */
|
||||
export function renderWorkflowNotice({ taskBatch }: { taskBatch: boolean }): string {
|
||||
return prompt.render(workflowNoticeTemplate, { taskBatch }).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `text` contains the standalone keyword "workflowz"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<critical>
|
||||
Plan mode is active. You MUST perform READ-ONLY work only:
|
||||
- You NEVER create, edit, or delete files — except the single plan file named below.
|
||||
Plan mode is active. You MUST preserve read-only working-tree and system semantics:
|
||||
- You NEVER create, edit, delete, or rename working-tree files.
|
||||
- You NEVER run state-changing commands (`git commit`, `npm install`, migrations) or make any other system change.
|
||||
- `local://` artifacts are session-local planning artifacts. You MAY create or update them when explicitly requested or needed for the plan.
|
||||
- You NEVER delete or rename `local://` artifacts.
|
||||
- You MUST write the canonical plan to `local://<slug>-plan.md`.
|
||||
|
||||
To leave plan mode and implement: call `resolve` with `action: "apply"`, a `reason`, and `extra: { title: "<slug>" }`, where `<slug>` matches your `local://<slug>-plan.md`. The user then picks an execution option and full write access is restored. `<slug>` may contain only letters, numbers, underscores, and hyphens.
|
||||
|
||||
|
||||
@@ -110,9 +110,8 @@ You MUST use the specialized tool over its shell equivalent:
|
||||
{{#has tools "lsp"}}- Code intelligence → `{{toolRefs.lsp}}`.{{/has}}
|
||||
{{#has tools "grep"}}- Regex search → `{{toolRefs.grep}}`, not `grep`, `rg`, or `awk`.{{/has}}
|
||||
{{#has tools "glob"}}- Globbing → `{{toolRefs.glob}}`, not `ls **/*.ext` or `fd`.{{/has}}
|
||||
{{#has tools "eval"}}- Default for any compute: `{{toolRefs.eval}}` cells. Bash is the EXCEPTION — only single binary calls or short fact-computing pipelines (`wc -l`, `sort | uniq -c`, `diff`, checksums). The moment a command grows a loop, conditional, heredoc, `-e`/`-c` script, `$(…)` nesting, or >2 pipe stages, it's a program → `{{toolRefs.eval}}`. NEVER write multiline or inline-script bash.{{/has}}
|
||||
{{#has tools "bash"}}- `{{toolRefs.bash}}`: real binaries and short fact pipelines only. Commands shadowing the specialized tools above are blocked.{{/has}}
|
||||
{{#has tools "bash"}}- Litmus: one external-CLI call or short pipeline returning a count, frequency, set difference, or checksum → bash.{{#has tools "eval"}} Needs control flow, state, or fights shell quoting → `{{toolRefs.eval}}`.{{/has}} Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}}
|
||||
{{#has tools "bash"}}- Litmus: one external-CLI call or short pipeline returning a count, frequency, set difference, or checksum → bash. Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}}
|
||||
|
||||
{{#has tools "report_tool_issue"}}
|
||||
<critical>
|
||||
|
||||
@@ -1,70 +1,89 @@
|
||||
<system-notice>
|
||||
The user's message above contains the **workflowz** keyword: drive this task as a deterministic multi-subagent workflow. Author the orchestration as Python in the `eval` tool and fan out subagents — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before you commit), or to take on scale one context can't hold (audits, migrations, broad sweeps). This overrides any default tendency to do the whole task inline when fanning out would be more thorough.
|
||||
The user's message above contains the **workflowz** keyword: drive this task as a deterministic multi-subagent workflow. Use the `task` tool {{#if taskBatch}}for batched fan-out{{else}}once per independent subagent{{/if}} — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before you commit), or to take on scale one context can't hold (audits, migrations, broad sweeps). This overrides any default tendency to do the whole task inline when fanning out would be more thorough.
|
||||
|
||||
<when>
|
||||
Worth it when the task benefits from decomposition + parallel coverage, or from independent/adversarial cross-checking before you commit. For a quick lookup or single edit, just do it directly — don't spin up agents. Scout inline FIRST (list the files, scope the diff, find the call sites) to discover the work-list, then fan out over it — you don't need to know the shape before the *task*, only before the *fan-out*. Common shapes, each a well-scoped `eval` call you can chain across turns:
|
||||
- **Understand** — parallel readers over subsystems → structured map
|
||||
- **Design** — judge panel of N independent approaches → scored synthesis
|
||||
- **Review** — split into dimensions → find per dimension → adversarially verify each finding
|
||||
- **Research** — multi-modal sweep → deep-read the hits → synthesize
|
||||
- **Migrate** — discover sites → transform each → verify
|
||||
Worth it when the task benefits from decomposition + parallel coverage, or from independent/adversarial cross-checking before you commit. For a quick lookup or single edit, just do it directly — don't spin up agents. Scout inline first (list the files, scope the diff, find the call sites) to discover the work list, then fan out over it. Common shapes:
|
||||
- **Understand** — parallel readers over subsystems → structured map.
|
||||
- **Design** — independent approaches → scored synthesis.
|
||||
- **Review** — split dimensions → find per dimension → adversarially verify each finding.
|
||||
- **Research** — multi-modal sweep → deep-read the hits → synthesize.
|
||||
- **Migrate** — discover sites → transform each → verify.
|
||||
</when>
|
||||
|
||||
<helpers>
|
||||
State persists across eval calls, so scout in one call and fan out in the next. Every eval call has:
|
||||
<task-contract>
|
||||
{{#if taskBatch}}
|
||||
Call `task` once per independent fan-out batch. Put shared background in `context`, and put each independent work item in `tasks[]`. Do not emulate batching with shell loops or eval helper APIs.
|
||||
|
||||
- `agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("explore", "reviewer", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes. Recursion follows `task.maxRecursionDepth` (default 2; `-1` uses eval's hard cap 3): main agent depth = 0, each `agent()` child increments depth by 1, and a spawner may call `agent()` only while its current `taskDepth < effective cap`. Pass `isolated=True` to run the spawn in a copy-on-write worktree so parallel `agent()` calls can edit overlapping files safely — strict opt-in, mirrors the `task` tool, defaults off regardless of `task.isolation.mode`; `isolated=True` while the setting is `"none"` errors out instead of silently downgrading. With isolation, `apply=False` keeps changes in the worktree, and `merge=False` forces patch mode even when the setting is `"branch"`. Captured root patch path, branch name, nested repo patches, and apply summary reach the workflow through `handle=True` — combine it with `apply=False` (or `apply=False, schema=…`) and read `node["patch_path"]`, `node["branch_name"]`, `node["nested_patches"]`, `node["changes_applied"]`, `node["isolation_summary"]` (JS: same keys camelCased) to recover artifacts.
|
||||
- `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool is bounded by the session's `task` concurrency — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
|
||||
- `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`.
|
||||
- `completion(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out.
|
||||
- `log(message)` — emit a progress line above the status tree. `phase(title)` — start a phase; the status lines that follow group under it.
|
||||
- `budget` — `budget.total` (output-token ceiling, or `None` when none is set), `budget.spent()` (tokens spent this turn — main loop + eval subagents), `budget.remaining()` (`math.inf` when total is `None`), `budget.hard` (whether it's enforced). A ceiling is set by the user: `+Nk` in their message is advisory (you self-limit via `budget.remaining()`), `+Nk!` (or Goal Mode) is hard — `agent()` refuses to spawn once spent reaches it. Gate loops on `budget.total` first, since it's `None` when the user set no budget.
|
||||
`context` must carry the shared contract:
|
||||
|
||||
Everything runs INLINE and synchronously inside the eval call — no background mode, no resume, no separate progress app. Each eval call is one well-scoped fan-out; chain several across calls and turns for multi-phase work, reading each result before you decide the next phase.
|
||||
</helpers>
|
||||
# Goal
|
||||
What the batch accomplishes.
|
||||
# Constraints
|
||||
Rules, non-goals, permissions, and verification limits.
|
||||
# Contract
|
||||
Shared interfaces, output shape, branch/base assumptions, and coordination rules.
|
||||
|
||||
Each task assignment must be self-contained:
|
||||
|
||||
# Target
|
||||
Exact files, symbols, subsystem, or evidence surface; explicit non-goals.
|
||||
# Change
|
||||
What to inspect or modify, step by step, including APIs and patterns to reuse.
|
||||
# Acceptance
|
||||
Observable result, return packet, and local verification. Subagents skip formatters,
|
||||
linters, and project-wide tests; the parent runs shared proof once.
|
||||
{{else}}
|
||||
Call `task` once per independent subagent. Put the full shared background and the leaf work in that call's `assignment`. Do not pass `context` or `tasks[]`: the flat task schema rejects them when batch calls are disabled.
|
||||
|
||||
Each assignment must be self-contained:
|
||||
|
||||
# Target
|
||||
Exact files, symbols, subsystem, or evidence surface; explicit non-goals.
|
||||
# Change
|
||||
Shared background plus what to inspect or modify, step by step, including APIs and patterns to reuse.
|
||||
# Acceptance
|
||||
Observable result, return packet, and local verification. Subagents skip formatters,
|
||||
linters, and project-wide tests; the parent runs shared proof once.
|
||||
{{/if}}
|
||||
|
||||
<structure>
|
||||
For independent per-item chains (review → verify, fetch → extract → score), wrap the WHOLE chain in one function and run it with `parallel()` — then each item flows through its own steps without waiting on the others:
|
||||
Decompose first, then {{#if taskBatch}}batch the independent leaves{{else}}issue one independent task call per leaf in the same turn{{/if}}:
|
||||
|
||||
DIMENSIONS = [{"key": "bugs", "prompt": "…"}, {"key": "perf", "prompt": "…"}]
|
||||
def review_and_verify(d):
|
||||
found = agent(d["prompt"], label=f"review:{d['key']}", schema=FINDINGS_SCHEMA)
|
||||
return parallel([lambda f=f: {**f, "verdict": agent(
|
||||
f"Refute if you can (default refuted when unsure): {f['title']}",
|
||||
label=f"verify:{f['file']}", schema=VERDICT_SCHEMA)} for f in found["findings"]])
|
||||
phase("Review")
|
||||
results = parallel([lambda d=d: review_and_verify(d) for d in DIMENSIONS])
|
||||
confirmed = [f for group in results for f in group if f["verdict"]["is_real"]]
|
||||
{{#if taskBatch}}
|
||||
task(
|
||||
context: "# Goal\nReview the auth diff...\n# Constraints\nRead-only...\n# Contract\nReturn findings as severity/file/line/fix...",
|
||||
tasks: [
|
||||
{ id: "AuthOwner", role: "Auth Storage Reviewer", assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nTrace credential selection...\n# Acceptance\nReturn confirmed findings only..." },
|
||||
{ id: "PromptOwner", role: "Prompt Contract Reviewer", assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance...\n# Acceptance\nReturn mismatches and exact prompt lines..." },
|
||||
]
|
||||
)
|
||||
{{else}}
|
||||
task(
|
||||
role: "Auth Storage Reviewer",
|
||||
assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nReview the auth diff. Shared contract: read-only; return findings as severity/file/line/fix.\n# Acceptance\nReturn confirmed findings only..."
|
||||
)
|
||||
task(
|
||||
role: "Prompt Contract Reviewer",
|
||||
assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance. Shared contract: read-only; return mismatches and exact prompt lines.\n# Acceptance\nReturn confirmed findings only..."
|
||||
)
|
||||
{{/if}}
|
||||
|
||||
Reach for `pipeline()` only when a stage genuinely needs ALL of the previous stage first — dedup/merge across the whole set, early-exit on zero, or "compare against the other findings" — because its inter-stage barrier makes every item wait for the slowest peer:
|
||||
|
||||
phase("Find")
|
||||
found = parallel([lambda d=d: agent(d["prompt"], schema=FINDINGS_SCHEMA) for d in DIMENSIONS])
|
||||
findings = dedupe([f for r in found for f in r["findings"]]) # needs everything at once
|
||||
phase("Verify")
|
||||
verdicts = parallel([lambda f=f: agent(verify_prompt(f), schema=VERDICT_SCHEMA) for f in findings])
|
||||
|
||||
Don't add a barrier just to flatten/map/filter — do that with plain Python between calls. Nested `parallel()` pools each cap independently, so keep total fan-out sane.
|
||||
{{#if taskBatch}}Prefer one wide batch over serial subagent calls when work items do not share files. If tasks overlap, name the overlap and have agents coordinate through IRC before editing.{{else}}Prefer issuing all independent task calls in one assistant turn over serial dispatch when work items do not share files. If tasks overlap, name the overlap and have agents coordinate through IRC before editing.{{/if}}
|
||||
</structure>
|
||||
|
||||
<patterns>
|
||||
Compose the harness the task calls for:
|
||||
- **Adversarial verify** — N independent skeptics per finding, each prompted to REFUTE; keep it only if a majority survive. `votes = parallel([lambda i=i: agent(f"Refute: {claim}. refuted=true if unsure.", schema=VERDICT) for i in range(3)])`, then keep when `sum(not v["refuted"] for v in votes) ≥ 2`.
|
||||
- **Perspective-diverse verify** — give each verifier a distinct lens (correctness, security, perf, does-it-reproduce) instead of N identical refuters.
|
||||
- **Judge panel** — N attempts from different angles, scored by parallel judges; synthesize from the winner, graft the best of the rest.
|
||||
- **Loop-until-dry** — for unknown-size discovery, keep spawning finders until K consecutive rounds surface nothing new; dedup against everything SEEN, not just what was confirmed, or it never converges.
|
||||
- **Multi-modal sweep** — parallel finders each searching a different way (by-container, by-content, by-entity, by-time), each blind to the others.
|
||||
- **Completeness critic** — a final agent that asks "what's missing — modality not run, claim unverified, file unread?"; its answer is the next round.
|
||||
- **Budget/count loops** — `while len(bugs) < 10:` to hit a target, or `while budget.total and budget.remaining() > 50_000:` to scale depth to the turn budget; `log()` each round.
|
||||
- **No silent caps** — if you bound coverage (top-N, no-retry, sampling), `log()` what you dropped; silent truncation reads as "covered everything" when it didn't.
|
||||
|
||||
Scale to the ask: "find any bugs" → a few finders, single-vote verify. "thoroughly audit / be comprehensive" → larger finder pool, 3–5-vote adversarial pass, a synthesis stage.
|
||||
- **Adversarial verify** — dispatch skeptical reviewers with distinct targets, then keep only findings the parent can verify against source.
|
||||
- **Perspective-diverse review** — use separate correctness, security, performance, and maintainability roles instead of identical reviewers.
|
||||
- **Completeness critic** — after the first batch, dispatch one read-only critic that asks what modality, file, claim, or proof was missed.
|
||||
- **No silent caps** — if you bound coverage (top-N, no retry, sampling), state what was dropped and why before acting.
|
||||
- **Parent owns closure** — subagents return evidence; the parent reads it, resolves contradictions, runs proof, and makes the final decision.
|
||||
</patterns>
|
||||
|
||||
<execution>
|
||||
- Decompose the surface first; capture it in `todo` when it spans phases.
|
||||
- Prefer `schema=` for any agent whose output you branch on.
|
||||
- After a fan-out returns, YOU own correctness: read the artifacts, run the gate, verify before acting. Subagents do the legwork; they don't get the last word.
|
||||
- Keep going until the task is closed — a returned fan-out is a step, not a stopping point.
|
||||
- Capture multi-phase workflow state in the visible todo system when available.
|
||||
{{#if taskBatch}}- Batch independent subagents in one `task` call.{{else}}- Dispatch independent subagents as separate `task` calls in the same turn.{{/if}}
|
||||
- Give every subagent a narrow target, explicit non-goals, and a concrete return packet.
|
||||
- After fan-out returns, read the artifacts, patch or decide, and run the shared gate.
|
||||
- Keep going until the task is closed — returned fan-out is a step, not a stopping point.
|
||||
</execution>
|
||||
</system-notice>
|
||||
|
||||
@@ -6,14 +6,22 @@ The shell invokes **real binaries** with simple args. It is NOT full GNU Bash.
|
||||
|
||||
Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a fact and does not depend on shell-specific regex/quoting (`wc -l`, `sort | uniq -c`, `comm`, `diff`, a checksum, `git status`).
|
||||
|
||||
Anything below → `eval` cell, not bash:
|
||||
{{#if hasEval}}Anything below → `eval` cell, not bash:
|
||||
- Inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists for that language
|
||||
- Heredocs (`<<EOF`), `while`/`for`/`if`/`case` shell control flow
|
||||
- `$(…)` command substitution nested inside another command
|
||||
- Pipelines with more than two stages, or stages that need control flow or quote/JSON escaping
|
||||
- Multiline commands, `&&`-chains mixing control flow
|
||||
- Quote/JSON escaping that fights the shell
|
||||
- GNU grep BRE extensions are not guaranteed in the embedded shell: use `grep -E 'json|tool'` for alternation instead of `grep 'json\|tool'`; use the built-in `grep` tool with `pattern: "json|tool"` (Rust regex, so `\bword\b` works there), or `eval` for exact text processing.
|
||||
{{else}}Anything below means you are writing a shell program, not invoking one. Prefer a purpose-built tool, a checked-in script, or a single repo command instead:
|
||||
- Inline interpreter scripts (`-e`/`-c`/`--eval`)
|
||||
- Heredocs (`<<EOF`), `while`/`for`/`if`/`case` shell control flow
|
||||
- `$(…)` command substitution nested inside another command
|
||||
- Pipelines with more than two stages, or stages that need control flow or quote/JSON escaping
|
||||
- Multiline commands, `&&`-chains mixing control flow
|
||||
- Quote/JSON escaping that fights the shell
|
||||
{{/if}}
|
||||
{{#if hasGrep}}- GNU grep BRE extensions are not guaranteed in the embedded shell: use `grep -E 'json|tool'` for alternation instead of `grep 'json\|tool'`; use the built-in `grep` tool with `pattern: "json|tool"` (Rust regex, so `\bword\b` works there){{#if hasEval}}, or `eval` for exact text processing{{/if}}.{{else}}- GNU grep BRE extensions are not guaranteed in the embedded shell: use `grep -E 'json|tool'` for alternation instead of `grep 'json\|tool'`{{#if hasEval}}, or use `eval` for exact text processing{{/if}}.{{/if}}
|
||||
|
||||
<instruction>
|
||||
- `cwd` sets the working dir, not `cd dir && …`
|
||||
@@ -23,14 +31,17 @@ Anything below → `eval` cell, not bash:
|
||||
- `;` only when later commands should run despite earlier failures
|
||||
- Multiple bash calls per message run concurrently. NEVER split order-dependent commands across parallel calls — chain with `&&` in one call.
|
||||
- Internal URIs (`skill://`, `agent://`, …) auto-resolve to FS paths
|
||||
- Need exact pipeline semantics (`cmd | head`, multi-stage filtering) or output truncation? Prefer `eval` and process the stream directly.
|
||||
{{#if hasEval}}- Need exact pipeline semantics (`cmd | head`, multi-stage filtering) or output truncation? Prefer `eval` and process the stream directly.{{else}}- Need exact pipeline semantics (`cmd | head`, multi-stage filtering) or output truncation? Use a checked-in script, purpose-built tool, or single command that owns the output shape.{{/if}}
|
||||
{{#if asyncEnabled}}
|
||||
- `async: true` for long-running commands when you don't need immediate output: returns a background job ID; result delivered as a follow-up.
|
||||
{{/if}}
|
||||
</instruction>
|
||||
|
||||
<critical>
|
||||
- The embedded shell invokes real binaries with simple args; it is NOT full GNU Bash. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, exact pipeline semantics, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps.
|
||||
{{#if hasEval}}- The embedded shell invokes real binaries with simple args; it is NOT full GNU Bash and NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, exact pipeline semantics, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps.{{else}}- The embedded shell invokes real binaries with simple args; it is NOT full GNU Bash and NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts, several piped stages, exact pipeline semantics, or quote/JSON escaping mean you're writing a shell program; use a purpose-built tool or checked-in script instead.{{/if}}
|
||||
{{#if hasGrep}}- NEVER shell out to search content or files: `grep/rg` → `grep`.{{else}}- Avoid shelling out for broad content search; use an active search/read tool when one is available.{{/if}}
|
||||
{{#if hasRead}}{{#if hasGlob}}- NEVER use `ls` or `find` to list or locate files — `ls` → `read` (a directory path lists entries), `find` → the `glob` tool (globbing). This is non-negotiable, even for a single quick listing.{{else}}- Prefer `read` for known file and directory reads. Only use shell listing when no file-listing tool is active.{{/if}}{{else}}{{#if hasGlob}}- Prefer `glob` for file discovery; avoid `find` when `glob` is active.{{else}}- If no file read/listing tool is active, keep shell inspection narrow and state that limitation.{{/if}}{{/if}}
|
||||
- Avoid head/tail/redirections: stderr already merged; long output auto-truncated, FULL capture kept at `artifact://<id>`.
|
||||
</critical>
|
||||
|
||||
<output>
|
||||
@@ -41,9 +52,9 @@ Anything below → `eval` cell, not bash:
|
||||
{{#if asyncEnabled}}
|
||||
# Timeout and async
|
||||
|
||||
- `timeout` is seconds, clamped to `1..3600`; the process is killed on elapse.
|
||||
- `async: true` defers only reporting — it does NOT extend the timeout; a daemon with `async: true` is still killed at the clamped timeout.
|
||||
- Need >3600s? Detach/manage lifecycle yourself (`cmd &`, supervisor, self-restarting script). The shell session persists across calls.
|
||||
- `timeout` is seconds; nonzero values are clamped to `1..3600` and the process is killed on elapse. Set `timeout: 0` only for commands that must run until completion or explicit cancellation.
|
||||
- `async: true` defers only reporting — it does NOT extend a nonzero timeout; use `timeout: 0` when a daemon or watcher must be cancellation-owned.
|
||||
- Need a daemon or >3600s run? Use `async: true` with `timeout: 0` when the harness should keep it alive until cancellation, or detach/manage lifecycle yourself (`cmd &`, supervisor, self-restarting script). The shell session persists across calls.
|
||||
{{/if}}
|
||||
{{#if autoBackgroundEnabled}}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ Greps files using regex.
|
||||
|
||||
<instruction>
|
||||
- Rust regex (RE2-style): alternation is `foo|bar`, not GNU BRE-style `foo\|bar`; Rust word boundaries like `\bword\b` are supported. Use line anchors or post-filters instead of lookaround/backreferences.
|
||||
- `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`).
|
||||
- `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`). Literal colon filename + line range? Use `selector` (e.g. `{"path":"test:1-2","selector":"1-2"}`), not recursive `path:"test:1-2:1-2"`.
|
||||
- Cross-line patterns detected from literal `\n` or `\\n` in `pattern`.
|
||||
</instruction>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Read files, directories, archives, SQLite, images, documents, internal resources, and web URLs via one `path`.
|
||||
Read files, directories, archives, SQLite, images, documents, internal resources, and web URLs via `path` plus optional `selector`.
|
||||
|
||||
<instruction>
|
||||
- SHOULD parallelize independent reads.
|
||||
@@ -7,7 +7,8 @@ Read files, directories, archives, SQLite, images, documents, internal resources
|
||||
|
||||
## Parameters
|
||||
|
||||
- `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`, `ssh://`), or URL. Append `:<sel>` for ranges/modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
|
||||
- `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`, `ssh://`), or URL. Inline `:<sel>` still works for ranges/modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
|
||||
- `selector` — optional selector without leading `:` (e.g. `"50-200"`, `"raw"`, `"raw:50-100"`, `"conflicts"`). Use when `path` contains literal colons: `{"path":"test:1-2","selector":"1-2"}`.
|
||||
|
||||
## Selectors
|
||||
|
||||
@@ -72,6 +73,6 @@ All URI schemes take the same line selectors. `artifact://<id>` recovers spilled
|
||||
`ssh://host/<absolute-path>` reads a remote text file (UTF-8, ≤1 MiB) or lists a directory one level deep, on a pre-configured SSH host or `~/.ssh/config` alias; `ssh://host/` lists the remote root and bare `ssh://` lists the configured hosts. Files are also writable via `write` and searchable via `search`; a directory only lists (`search` refuses a directory, `write` refuses to overwrite one). A literal `:`, `?`, or `#` in the remote path must be percent-encoded (`%3A`/`%3F`/`%23`) — a trailing `:sel` is read as a line selector, and `?`/`#` start a URL query/fragment. Requires a POSIX login shell (`sh`/`bash`/`zsh`); a Windows host or a non-POSIX shell (fish, csh/tcsh) is rejected — use the `ssh` tool there.
|
||||
|
||||
<critical>
|
||||
- Line ranges go in the selector: `path="src/foo.ts:50-200"`.
|
||||
- Literal colon filename + selector? Use `selector`, not recursive `path:"file:sel:sel"`.
|
||||
- Summary footer names elided ranges? Re-issue ONLY those ranges. NEVER guess `..`/`…` content.
|
||||
</critical>
|
||||
|
||||
@@ -1525,10 +1525,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
// entries capture it at fetch time and are dropped at injection if a newer
|
||||
// mutation (any tool) bumped it in the meantime.
|
||||
const fileMutationVersions = new Map<string, number>();
|
||||
const activeToolNames = new Set<string>();
|
||||
const setActiveToolNames = (names: Iterable<string>): void => {
|
||||
activeToolNames.clear();
|
||||
for (const name of names) {
|
||||
activeToolNames.add(name);
|
||||
}
|
||||
};
|
||||
const toolSession: ToolSession = {
|
||||
get cwd() {
|
||||
return sessionManager.getCwd();
|
||||
},
|
||||
isToolActive: name => activeToolNames.has(name),
|
||||
setActiveToolNames,
|
||||
hasUI: options.hasUI ?? false,
|
||||
enableLsp,
|
||||
get hasEditTool() {
|
||||
@@ -2558,6 +2567,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
});
|
||||
hasRegistered = true;
|
||||
|
||||
setActiveToolNames(initialToolNames);
|
||||
const { systemPrompt } = await logger.time(
|
||||
"buildSystemPrompt",
|
||||
rebuildSystemPrompt,
|
||||
@@ -2852,6 +2862,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
rebuildSystemPrompt,
|
||||
reloadSshTool,
|
||||
requestedToolNames: requestedToolNameSet,
|
||||
setActiveToolNames,
|
||||
getMcpServerInstructions: mcpManager
|
||||
? () => {
|
||||
const raw = mcpManager.getServerInstructions();
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type AsideMessage,
|
||||
type CompactionSummaryMessage,
|
||||
countTokens,
|
||||
createToolScopedAbortReason,
|
||||
resolveTelemetry,
|
||||
type StreamFn,
|
||||
ThinkingLevel,
|
||||
@@ -108,6 +109,7 @@ import {
|
||||
clearAnthropicFastModeFallback,
|
||||
deriveClaudeDeviceId,
|
||||
Effort,
|
||||
isUsageLimitOutcome,
|
||||
parseRateLimitReason,
|
||||
realizesPriorityServiceTier,
|
||||
resolveModelServiceTier,
|
||||
@@ -124,6 +126,7 @@ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
||||
import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
|
||||
import {
|
||||
escapeXmlText,
|
||||
extractHttpStatusFromError,
|
||||
extractRetryHint,
|
||||
formatDuration,
|
||||
getAgentDbPath,
|
||||
@@ -179,7 +182,12 @@ import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
|
||||
import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
|
||||
import { buildServiceTierByFamily, serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier";
|
||||
import type { Settings, SkillsSettings } from "../config/settings";
|
||||
import { getDefault, onAppendOnlyModeChanged, validateProviderMaxInFlightRequests } from "../config/settings";
|
||||
import {
|
||||
getDefault,
|
||||
onAppendOnlyModeChanged,
|
||||
onModelRolesChanged,
|
||||
validateProviderMaxInFlightRequests,
|
||||
} from "../config/settings";
|
||||
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
|
||||
import { loadCapability } from "../discovery";
|
||||
import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
|
||||
@@ -237,7 +245,7 @@ import { theme } from "../modes/theme/theme";
|
||||
import { parseTurnBudget } from "../modes/turn-budget";
|
||||
import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
|
||||
import { computeNonMessageBreakdown, computeNonMessageTokens } from "../modes/utils/context-usage";
|
||||
import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
|
||||
import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow";
|
||||
import { createPlanReadMatcher } from "../plan-mode/plan-protection";
|
||||
import type { PlanModeState } from "../plan-mode/state";
|
||||
import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
|
||||
@@ -314,6 +322,7 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
||||
import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
|
||||
import { normalizeModelContextImages } from "../utils/image-loading";
|
||||
import { describeAttachedImagesForTextModel } from "../utils/image-vision-fallback";
|
||||
import { formatLocalCalendarDate } from "../utils/local-date";
|
||||
import { generateSessionTitle } from "../utils/title-generator";
|
||||
import { buildNamedToolChoice, isToolChoiceActive } from "../utils/tool-choice";
|
||||
import type { AuthStorage } from "./auth-storage";
|
||||
@@ -686,6 +695,8 @@ export interface AgentSessionConfig {
|
||||
toolRegistry?: Map<string, AgentTool>;
|
||||
/** Tool names whose current registry entry is still the built-in implementation. */
|
||||
builtInToolNames?: Iterable<string>;
|
||||
/** Update tool-session predicates that render guidance from the live active tool set. */
|
||||
setActiveToolNames?: (names: Iterable<string>) => void;
|
||||
/** Current session pre-LLM message transform pipeline */
|
||||
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
|
||||
/**
|
||||
@@ -724,6 +735,8 @@ export interface AgentSessionConfig {
|
||||
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
/** System prompt builder that can consider tool availability. Returns ordered provider-facing blocks. */
|
||||
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
|
||||
/** Local calendar date provider used by prompt-cache invalidation. Defaults to the host local date. */
|
||||
getLocalCalendarDate?: () => string;
|
||||
/** Rebuild the SSH tool from current capability discovery results. */
|
||||
reloadSshTool?: () => Promise<AgentTool | null>;
|
||||
requestedToolNames?: ReadonlySet<string>;
|
||||
@@ -870,6 +883,7 @@ export interface HandoffResult {
|
||||
export interface SessionHandoffOptions {
|
||||
autoTriggered?: boolean;
|
||||
signal?: AbortSignal;
|
||||
onSwitchCancelled?: () => void;
|
||||
}
|
||||
|
||||
/** Result from cycleModel() */
|
||||
@@ -1555,6 +1569,7 @@ export class AgentSession {
|
||||
#cancelExitRecorder?: () => void;
|
||||
#exitRecorded = false;
|
||||
#unsubscribeAppendOnly?: () => void;
|
||||
#unsubscribeModelRoles?: () => void;
|
||||
/** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
|
||||
#lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
|
||||
#eventListeners: AgentSessionEventListener[] = [];
|
||||
@@ -1716,8 +1731,10 @@ export class AgentSession {
|
||||
#rebuildSystemPrompt:
|
||||
| ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
|
||||
| undefined;
|
||||
#getLocalCalendarDate: () => string;
|
||||
#getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
|
||||
#reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
|
||||
#setActiveToolNames: ((names: Iterable<string>) => void) | undefined;
|
||||
#disconnectOwnedMcpManager: (() => Promise<void>) | undefined;
|
||||
#requestedToolNames: ReadonlySet<string> | undefined;
|
||||
#baseSystemPrompt: string[];
|
||||
@@ -2161,8 +2178,10 @@ export class AgentSession {
|
||||
});
|
||||
this.#convertToLlm = config.convertToLlm ?? convertToLlm;
|
||||
this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
|
||||
this.#getLocalCalendarDate = config.getLocalCalendarDate ?? formatLocalCalendarDate;
|
||||
this.#getMcpServerInstructions = config.getMcpServerInstructions;
|
||||
this.#reloadSshTool = config.reloadSshTool;
|
||||
this.#setActiveToolNames = config.setActiveToolNames;
|
||||
this.#disconnectOwnedMcpManager = config.disconnectOwnedMcpManager;
|
||||
this.#baseSystemPrompt = this.agent.state.systemPrompt;
|
||||
this.#promptModelKey = this.#currentPromptModelKey();
|
||||
@@ -2259,6 +2278,11 @@ export class AgentSession {
|
||||
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
|
||||
// Re-evaluate append-only context mode when the setting changes at runtime.
|
||||
this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
|
||||
this.#unsubscribeModelRoles = onModelRolesChanged(() => {
|
||||
if (!this.#advisorEnabled || this.#isDisposed) return;
|
||||
if (this.#advisors.length > 0 && !this.#advisorRuntimeMatchesCurrentConfig()) this.#stopAdvisorRuntime();
|
||||
this.#buildAdvisorRuntime(true);
|
||||
});
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
// Advisor runtime lifecycle
|
||||
@@ -2391,7 +2415,9 @@ export class AgentSession {
|
||||
#advisorRuntimeSignature(config: AdvisorConfig, slug: string, model: Model, thinkingLevel: ThinkingLevel): string {
|
||||
const tools = config.tools?.length ? config.tools.join("\u001e") : "";
|
||||
const instructions = config.instructions?.trim() ?? "";
|
||||
return [config.name, slug, model.provider, model.id, thinkingLevel, tools, instructions].join("\u001f");
|
||||
return [config.name, slug, formatModelStringWithRouting(model), thinkingLevel, tools, instructions].join(
|
||||
"\u001f",
|
||||
);
|
||||
}
|
||||
|
||||
#advisorRuntimeMatchesCurrentConfig(): boolean {
|
||||
@@ -2539,6 +2565,21 @@ export class AgentSession {
|
||||
maintainContext: incomingTokens => this.#maintainAdvisorContext(advisorRef, incomingTokens),
|
||||
obfuscator: this.#obfuscator,
|
||||
beginAdvisorUpdate: () => advisorRef.emissionGuard.beginUpdate(),
|
||||
onTurnError: async error => {
|
||||
// Mirror the auth-gateway's usage-limit remedy: the in-stream a/b/c
|
||||
// auth retry rotates through siblings within one request but never
|
||||
// blocks the LAST failing credential, so without this the advisor
|
||||
// re-picks the same exhausted account every retry. Usage limits
|
||||
// only — other failures keep the plain retry/notify path (never
|
||||
// suspect-mark a credential on a transient advisor error).
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!isUsageLimitOutcome(extractHttpStatusFromError(error), message)) return;
|
||||
await this.#modelRegistry.authStorage.markUsageLimitReached(advisorModel.provider, advisorSessionId, {
|
||||
retryAfterMs: extractRetryHint(undefined, message),
|
||||
baseUrl: advisorModel.baseUrl,
|
||||
modelId: advisorModel.id,
|
||||
});
|
||||
},
|
||||
notifyFailure: error => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.emitNotice(
|
||||
@@ -4666,7 +4707,8 @@ export class AgentSession {
|
||||
// Decide first: a non-interrupting tool-source match attaches to the
|
||||
// specific tool call's result instead of driving a loop-wide follow-up.
|
||||
const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext);
|
||||
const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext);
|
||||
const matchedToolId = this.#extractTtsrToolCallId(matchContext);
|
||||
const perToolId = shouldInterrupt ? undefined : matchedToolId;
|
||||
if (perToolId) {
|
||||
this.#addPerToolTtsrInjections(perToolId, matches);
|
||||
this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
|
||||
@@ -4682,7 +4724,16 @@ export class AgentSession {
|
||||
// Abort the stream immediately — do not gate on extension callbacks
|
||||
this.#ttsrAbortPending = true;
|
||||
this.#ensureTtsrResumePromise();
|
||||
this.agent.abort(this.#formatTtsrAbortReason(matches));
|
||||
const abortReason = this.#formatTtsrAbortReason(matches);
|
||||
this.agent.abort(
|
||||
matchedToolId
|
||||
? createToolScopedAbortReason(
|
||||
abortReason,
|
||||
{ [matchedToolId]: abortReason },
|
||||
"TTSR interrupt on another tool call",
|
||||
)
|
||||
: abortReason,
|
||||
);
|
||||
// Notify extensions (fire-and-forget, does not block abort)
|
||||
this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
|
||||
// Schedule retry after a short delay
|
||||
@@ -5755,6 +5806,10 @@ export class AgentSession {
|
||||
this.#unsubscribeAppendOnly();
|
||||
this.#unsubscribeAppendOnly = undefined;
|
||||
}
|
||||
if (this.#unsubscribeModelRoles) {
|
||||
this.#unsubscribeModelRoles();
|
||||
this.#unsubscribeModelRoles = undefined;
|
||||
}
|
||||
this.#eventListeners = [];
|
||||
}
|
||||
|
||||
@@ -6298,6 +6353,7 @@ export class AgentSession {
|
||||
),
|
||||
);
|
||||
}
|
||||
this.#setActiveToolNames?.(validToolNames);
|
||||
const activeNameSet = new Set(validToolNames);
|
||||
for (const name of Array.from(this.#selectedDiscoveredToolNames)) {
|
||||
if (!activeNameSet.has(name) || isMCPToolName(name) || !this.#toolRegistry.has(name)) {
|
||||
@@ -6403,6 +6459,7 @@ export class AgentSession {
|
||||
async refreshBaseSystemPrompt(): Promise<void> {
|
||||
if (!this.#rebuildSystemPrompt) return;
|
||||
const activeToolNames = this.getActiveToolNames();
|
||||
this.#setActiveToolNames?.(activeToolNames);
|
||||
const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
|
||||
this.#baseSystemPrompt = built.systemPrompt;
|
||||
this.#baseSystemPromptBeforeMemoryPromotion = undefined;
|
||||
@@ -6525,7 +6582,7 @@ export class AgentSession {
|
||||
entries.sort();
|
||||
instructionsSegment = entries.join("\u0006");
|
||||
}
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const date = this.#getLocalCalendarDate();
|
||||
return `${nameSegment}\u0003${descriptionSegment}\u0005${registrySegment}\u0007${instructionsSegment}|${date}`;
|
||||
}
|
||||
|
||||
@@ -7342,11 +7399,15 @@ export class AgentSession {
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
if (this.#magicKeywordEnabled("workflow") && containsWorkflow(text)) {
|
||||
if (
|
||||
this.#magicKeywordEnabled("workflow") &&
|
||||
containsWorkflow(text) &&
|
||||
this.getActiveToolNames().includes("task")
|
||||
) {
|
||||
keywordNotices.push({
|
||||
role: "custom",
|
||||
customType: "workflow-notice",
|
||||
content: WORKFLOW_NOTICE,
|
||||
content: renderWorkflowNotice({ taskBatch: this.settings.get("task.batch") }),
|
||||
display: false,
|
||||
attribution: "user",
|
||||
timestamp,
|
||||
@@ -8787,6 +8848,7 @@ export class AgentSession {
|
||||
|
||||
const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
|
||||
|
||||
this.#modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(targetModel));
|
||||
this.#clearActiveRetryFallback();
|
||||
this.#setModelWithProviderSessionReset(targetModel);
|
||||
this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
|
||||
@@ -8824,6 +8886,7 @@ export class AgentSession {
|
||||
|
||||
const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
|
||||
|
||||
this.#modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(targetModel));
|
||||
this.#clearActiveRetryFallback();
|
||||
this.#setModelWithProviderSessionReset(targetModel);
|
||||
this.sessionManager.appendModelChange(
|
||||
@@ -8983,6 +9046,7 @@ export class AgentSession {
|
||||
const next = scopedModels[nextIndex];
|
||||
|
||||
// Apply model
|
||||
this.#modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(next.model));
|
||||
this.#clearActiveRetryFallback();
|
||||
this.#setModelWithProviderSessionReset(next.model);
|
||||
this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
|
||||
@@ -9013,6 +9077,7 @@ export class AgentSession {
|
||||
throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
|
||||
}
|
||||
|
||||
this.#modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(nextModel));
|
||||
this.#clearActiveRetryFallback();
|
||||
this.#setModelWithProviderSessionReset(nextModel);
|
||||
this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
|
||||
@@ -10041,6 +10106,17 @@ export class AgentSession {
|
||||
|
||||
// Start a new session
|
||||
const previousSessionFile = this.sessionFile;
|
||||
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
|
||||
const result = (await this.#extensionRunner.emit({
|
||||
type: "session_before_switch",
|
||||
reason: "handoff",
|
||||
})) as SessionBeforeSwitchResult | undefined;
|
||||
|
||||
if (result?.cancel) {
|
||||
options?.onSwitchCancelled?.();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
await this.sessionManager.flush();
|
||||
this.#cancelOwnAsyncJobs();
|
||||
await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
|
||||
@@ -10097,6 +10173,13 @@ export class AgentSession {
|
||||
this.agent.replaceMessages(sessionContext.messages);
|
||||
this.#resetAllAdvisorRuntimes();
|
||||
this.#syncTodoPhasesFromBranch();
|
||||
if (this.#extensionRunner) {
|
||||
await this.#extensionRunner.emit({
|
||||
type: "session_switch",
|
||||
reason: "handoff",
|
||||
previousSessionFile,
|
||||
});
|
||||
}
|
||||
|
||||
return { document: handoffText, savedPath };
|
||||
} catch (error) {
|
||||
@@ -12313,13 +12396,17 @@ export class AgentSession {
|
||||
// queue, not the core steering queue (which handoff's agent.reset() would wipe).
|
||||
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
||||
if (action === "handoff") {
|
||||
let handoffSwitchCancelled = false;
|
||||
const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
|
||||
const handoffResult = await this.handoff(handoffFocus, {
|
||||
autoTriggered: true,
|
||||
signal: autoCompactionSignal,
|
||||
onSwitchCancelled: () => {
|
||||
handoffSwitchCancelled = true;
|
||||
},
|
||||
});
|
||||
if (!handoffResult) {
|
||||
const aborted = autoCompactionSignal.aborted;
|
||||
const aborted = autoCompactionSignal.aborted || handoffSwitchCancelled;
|
||||
if (aborted) {
|
||||
await this.#emitSessionEvent({
|
||||
type: "auto_compaction_end",
|
||||
@@ -13220,6 +13307,7 @@ export class AgentSession {
|
||||
#resolveRetryFallbackRole(currentSelector: string): string | undefined {
|
||||
const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
|
||||
if (!parsedCurrent) return undefined;
|
||||
const chains = this.#getRetryFallbackChains();
|
||||
const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
|
||||
const currentPlainSelector = this.model
|
||||
? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
|
||||
@@ -13229,11 +13317,11 @@ export class AgentSession {
|
||||
? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent)
|
||||
: undefined;
|
||||
|
||||
for (const role of Object.keys(this.#getRetryFallbackChains())) {
|
||||
for (const role of Object.keys(chains)) {
|
||||
const primarySelector = this.#getRetryFallbackPrimarySelector(role);
|
||||
if (primarySelector?.raw === currentSelector) return role;
|
||||
}
|
||||
for (const role of Object.keys(this.#getRetryFallbackChains())) {
|
||||
for (const role of Object.keys(chains)) {
|
||||
const primarySelector = this.#getRetryFallbackPrimarySelector(role);
|
||||
if (!primarySelector) continue;
|
||||
if (currentPlainSelector && primarySelector.raw === currentPlainSelector) return role;
|
||||
@@ -13241,6 +13329,14 @@ export class AgentSession {
|
||||
if (primaryBaseSelector === currentBaseSelector) return role;
|
||||
if (currentPlainBaseSelector && primaryBaseSelector === currentPlainBaseSelector) return role;
|
||||
}
|
||||
const defaultChain = chains.default;
|
||||
if (
|
||||
Array.isArray(defaultChain) &&
|
||||
defaultChain.length > 0 &&
|
||||
this.#getRetryFallbackPrimarySelector("default") === undefined
|
||||
) {
|
||||
return "default";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -13259,9 +13355,27 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
#findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
|
||||
const chain = this.#getRetryFallbackEffectiveChain(role);
|
||||
if (chain.length <= 1) return [];
|
||||
let chain = this.#getRetryFallbackEffectiveChain(role);
|
||||
const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
|
||||
if (chain.length === 0 && role === "default" && parsedCurrent) {
|
||||
const chains = this.#getRetryFallbackChains();
|
||||
const defaultChain = chains.default;
|
||||
if (
|
||||
Array.isArray(defaultChain) &&
|
||||
defaultChain.length > 0 &&
|
||||
this.#getRetryFallbackPrimarySelector("default") === undefined
|
||||
) {
|
||||
const seen = new Set<string>([parsedCurrent.raw]);
|
||||
chain = [parsedCurrent];
|
||||
for (const selector of defaultChain) {
|
||||
const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry);
|
||||
if (!parsed || seen.has(parsed.raw)) continue;
|
||||
seen.add(parsed.raw);
|
||||
chain.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chain.length <= 1) return [];
|
||||
const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
|
||||
const currentPlainSelector =
|
||||
this.model && parsedCurrent
|
||||
@@ -15535,7 +15649,7 @@ export class AgentSession {
|
||||
lastAttemptAtByAccount: coordinator.lastAttemptAtByAccount,
|
||||
});
|
||||
if (!decision.redeem) {
|
||||
logger.debug("codex-auto-reset: skipped", { reason: decision.reason });
|
||||
logger.debug("codex-auto-reset: skipped", { reason: decision.reason, account: accountKey });
|
||||
return false;
|
||||
}
|
||||
if (shouldPromptCodexAutoRedeem(cfg.autoRedeem) && !(await this.#confirmCodexAutoRedeem(decision))) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import projectPromptTemplate from "./prompts/system/project-prompt.md" with { ty
|
||||
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
|
||||
import { shortenPath } from "./tools/render-utils";
|
||||
import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context";
|
||||
import { formatLocalCalendarDate } from "./utils/local-date";
|
||||
import { normalizePromptPath } from "./utils/prompt-path";
|
||||
import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
|
||||
|
||||
@@ -400,7 +401,7 @@ export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {
|
||||
return userLevel?.content ?? null;
|
||||
}
|
||||
|
||||
export const DEFAULT_SYSTEM_PROMPT_TOOL_NAMES = ["read", "bash", "eval", "edit", "write"] as const;
|
||||
export const DEFAULT_SYSTEM_PROMPT_TOOL_NAMES = ["read", "bash", "edit", "write"] as const;
|
||||
|
||||
export interface SystemPromptToolMetadata {
|
||||
label: string;
|
||||
@@ -693,7 +694,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
}
|
||||
}
|
||||
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const date = formatLocalCalendarDate();
|
||||
const dateTime = date;
|
||||
const promptCwd = shortenPath(normalizePromptPath(resolvedCwd));
|
||||
const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext);
|
||||
|
||||
@@ -300,7 +300,7 @@ export async function runInteractiveBashPty(
|
||||
options: {
|
||||
command: string;
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
env?: Record<string, string>;
|
||||
artifactPath?: string;
|
||||
|
||||
@@ -140,6 +140,30 @@ function unquoteToken(token: string): string {
|
||||
return token;
|
||||
}
|
||||
|
||||
function isInsideShellQuote(command: string, index: number): boolean {
|
||||
let quote: "'" | '"' | undefined;
|
||||
for (let i = 0; i < index; i++) {
|
||||
const char = command[i];
|
||||
if (char === "\\" && quote !== "'") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" && quote !== '"') {
|
||||
quote = quote === "'" ? undefined : "'";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' && quote !== "'") {
|
||||
quote = quote === '"' ? undefined : '"';
|
||||
}
|
||||
}
|
||||
return quote !== undefined;
|
||||
}
|
||||
|
||||
function isEmbeddedInQuotedText(command: string, token: string, index: number): boolean {
|
||||
if (token.startsWith("'") || token.startsWith('"')) return false;
|
||||
return isInsideShellQuote(command, index);
|
||||
}
|
||||
|
||||
/** Shell-escape a path using single quotes. */
|
||||
function shellEscape(p: string): string {
|
||||
return `'${p.replace(/'/g, "'\\''")}'`;
|
||||
@@ -216,6 +240,7 @@ export function expandSkillUrls(command: string, skills: readonly Skill[]): stri
|
||||
|
||||
/**
|
||||
* Expand supported internal URLs in a bash command string to shell-escaped absolute paths.
|
||||
* Unresolvable URLs and literal mentions inside larger quoted text are left unchanged.
|
||||
* Supported schemes: skill://, agent://, artifact://, memory://, rule://, local://
|
||||
*/
|
||||
export async function expandInternalUrls(command: string, options: InternalUrlExpansionOptions): Promise<string> {
|
||||
@@ -231,15 +256,22 @@ export async function expandInternalUrls(command: string, options: InternalUrlEx
|
||||
const index = match.index;
|
||||
if (index === undefined) continue;
|
||||
|
||||
if (isEmbeddedInQuotedText(command, token, index)) continue;
|
||||
|
||||
const rawUrl = unquoteToken(token);
|
||||
const url = normalizeLocalScheme(rawUrl);
|
||||
const resolvedPath = await resolveInternalUrlToPath(
|
||||
url,
|
||||
options.skills,
|
||||
options.internalRouter,
|
||||
options.localOptions,
|
||||
options.ensureLocalParentDirs,
|
||||
);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await resolveInternalUrlToPath(
|
||||
url,
|
||||
options.skills,
|
||||
options.internalRouter,
|
||||
options.localOptions,
|
||||
options.ensureLocalParentDirs,
|
||||
);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const replacement = options.noEscape ? resolvedPath : shellEscape(resolvedPath);
|
||||
expanded = `${expanded.slice(0, index)}${replacement}${expanded.slice(index + token.length)}`;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-intera
|
||||
import { checkBashInterception } from "./bash-interceptor";
|
||||
import { canUseInteractiveBashPty } from "./bash-pty-selection";
|
||||
import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
|
||||
import { resolveEvalBackends } from "./eval-backends";
|
||||
import { invalidateGithubCacheForBashCommand } from "./gh-cache-invalidation";
|
||||
import {
|
||||
formatStyledTruncationWarning,
|
||||
@@ -131,7 +132,7 @@ async function saveBashOriginalArtifact(session: ToolSession, originalText: stri
|
||||
}
|
||||
}
|
||||
|
||||
const BASH_TIMEOUT_DESCRIPTION = `timeout in seconds; clamped to ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}`;
|
||||
const BASH_TIMEOUT_DESCRIPTION = `timeout in seconds; 0 disables the command deadline; nonzero values are clamped to ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}`;
|
||||
|
||||
const bashSchemaBase = type({
|
||||
command: type("string").describe("command to execute"),
|
||||
@@ -166,6 +167,7 @@ export interface BashToolDetails {
|
||||
meta?: OutputMeta;
|
||||
timeoutSeconds?: number;
|
||||
requestedTimeoutSeconds?: number;
|
||||
timeoutDisabled?: boolean;
|
||||
wallTimeMs?: number;
|
||||
/** Exit code of a command that ran to completion but failed (non-zero). */
|
||||
exitCode?: number;
|
||||
@@ -375,7 +377,24 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
};
|
||||
readonly label = "Bash";
|
||||
readonly loadMode = "essential";
|
||||
readonly description: string;
|
||||
get description(): string {
|
||||
const evalBackends = resolveEvalBackends(this.session);
|
||||
const isToolActive = (name: string, fallback: boolean): boolean => this.session.isToolActive?.(name) ?? fallback;
|
||||
return prompt.render(bashDescription, {
|
||||
asyncEnabled: this.#asyncEnabled,
|
||||
autoBackgroundEnabled: this.#autoBackgroundEnabled,
|
||||
autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
|
||||
hasAstGrep: isToolActive("ast_grep", this.session.settings.get("astGrep.enabled")),
|
||||
hasAstEdit: isToolActive("ast_edit", this.session.settings.get("astEdit.enabled")),
|
||||
hasGrep: isToolActive("grep", this.session.settings.get("grep.enabled")),
|
||||
hasGlob: isToolActive("glob", this.session.settings.get("glob.enabled")),
|
||||
hasRead: isToolActive("read", true),
|
||||
hasEval: isToolActive(
|
||||
"eval",
|
||||
evalBackends.python || evalBackends.js || evalBackends.ruby || evalBackends.julia,
|
||||
),
|
||||
});
|
||||
}
|
||||
readonly parameters: BashToolSchema;
|
||||
// Non-pty calls run alongside each other (the executor isolates overlapping
|
||||
// runs on the same shell session); pty takes over the terminal UI and must
|
||||
@@ -397,15 +416,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
),
|
||||
);
|
||||
this.parameters = this.#asyncEnabled ? bashSchemaWithAsync : bashSchemaBase;
|
||||
this.description = prompt.render(bashDescription, {
|
||||
asyncEnabled: this.#asyncEnabled,
|
||||
autoBackgroundEnabled: this.#autoBackgroundEnabled,
|
||||
autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
|
||||
hasAstGrep: this.session.settings.get("astGrep.enabled"),
|
||||
hasAstEdit: this.session.settings.get("astEdit.enabled"),
|
||||
hasGrep: this.session.settings.get("grep.enabled"),
|
||||
hasGlob: this.session.settings.get("glob.enabled"),
|
||||
});
|
||||
}
|
||||
|
||||
#formatResultOutput(result: BashResult | BashInteractiveResult): string {
|
||||
@@ -421,7 +431,11 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
* completed command that failed; #buildCompletedResult surfaces it as an
|
||||
* error *result* (carrying execution details) rather than a throw.
|
||||
*/
|
||||
#throwIfUnfinished(result: BashResult | BashInteractiveResult, timeoutSec: number, outputText: string): void {
|
||||
#throwIfUnfinished(
|
||||
result: BashResult | BashInteractiveResult,
|
||||
timeoutSec: number | undefined,
|
||||
outputText: string,
|
||||
): void {
|
||||
if (result.cancelled) {
|
||||
// executeBash output already carries a `[Command cancelled]` notice from
|
||||
// the sink; PTY/bridge interactive output does not, so annotate it here.
|
||||
@@ -431,11 +445,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
}
|
||||
if (isInteractiveResult(result) && result.timedOut) {
|
||||
const out = normalizeResultOutput(result);
|
||||
throw new ToolError(
|
||||
out
|
||||
? `${out}\n\n[Command timed out after ${timeoutSec} seconds]`
|
||||
: `Command timed out after ${timeoutSec} seconds`,
|
||||
);
|
||||
const message =
|
||||
timeoutSec === undefined ? "Command timed out" : `Command timed out after ${timeoutSec} seconds`;
|
||||
throw new ToolError(out ? `${out}\n\n[${message}]` : message);
|
||||
}
|
||||
if (result.exitCode === undefined) {
|
||||
throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`);
|
||||
@@ -444,7 +456,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
|
||||
async #buildCompletedResult(
|
||||
result: BashResult | BashInteractiveResult,
|
||||
timeoutSec: number,
|
||||
timeoutSec: number | undefined,
|
||||
options: {
|
||||
requestedTimeoutSec?: number;
|
||||
notices?: readonly string[];
|
||||
@@ -472,7 +484,12 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
// Aborts / timeouts / missing-status still propagate as thrown errors.
|
||||
this.#throwIfUnfinished(result, timeoutSec, outputText);
|
||||
|
||||
const details: BashToolDetails = { timeoutSeconds: timeoutSec };
|
||||
const details: BashToolDetails = {};
|
||||
if (timeoutSec === undefined) {
|
||||
details.timeoutDisabled = true;
|
||||
} else {
|
||||
details.timeoutSeconds = timeoutSec;
|
||||
}
|
||||
if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
|
||||
details.requestedTimeoutSeconds = options.requestedTimeoutSec;
|
||||
}
|
||||
@@ -503,13 +520,17 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
jobId: string,
|
||||
label: string,
|
||||
previewText: string,
|
||||
timeoutSec: number,
|
||||
timeoutSec: number | undefined,
|
||||
options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
|
||||
): AgentToolResult<BashToolDetails> {
|
||||
const details: BashToolDetails = {
|
||||
timeoutSeconds: timeoutSec,
|
||||
async: { state: "running", jobId, type: "bash" },
|
||||
};
|
||||
if (timeoutSec === undefined) {
|
||||
details.timeoutDisabled = true;
|
||||
} else {
|
||||
details.timeoutSeconds = timeoutSec;
|
||||
}
|
||||
if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
|
||||
details.requestedTimeoutSeconds = options.requestedTimeoutSec;
|
||||
}
|
||||
@@ -539,8 +560,8 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
#startManagedBashJob(options: {
|
||||
command: string;
|
||||
commandCwd: string;
|
||||
timeoutMs: number;
|
||||
timeoutSec: number;
|
||||
timeoutMs: number | undefined;
|
||||
timeoutSec: number | undefined;
|
||||
requestedTimeoutSec?: number;
|
||||
notices?: readonly string[];
|
||||
|
||||
@@ -569,7 +590,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
const result = await executeBash(options.command, {
|
||||
cwd: options.commandCwd,
|
||||
sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
|
||||
timeout: options.timeoutMs,
|
||||
timeout: options.timeoutMs ?? 0,
|
||||
signal: runSignal,
|
||||
env: options.resolvedEnv,
|
||||
artifactPath,
|
||||
@@ -661,8 +682,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
}
|
||||
}
|
||||
|
||||
#resolveAutoBackgroundWaitMs(timeoutMs: number): number {
|
||||
#resolveAutoBackgroundWaitMs(timeoutMs: number | undefined): number {
|
||||
if (this.#autoBackgroundThresholdMs <= 0) return 0;
|
||||
if (timeoutMs === undefined) return this.#autoBackgroundThresholdMs;
|
||||
const timeoutBufferMs = 1_000;
|
||||
return Math.max(0, Math.min(this.#autoBackgroundThresholdMs, timeoutMs - timeoutBufferMs));
|
||||
}
|
||||
@@ -765,13 +787,17 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
throw new ToolError(`Working directory is not a directory: ${commandCwd}`);
|
||||
}
|
||||
|
||||
// Clamp to reasonable range: 1s - 3600s (1 hour)
|
||||
// A timeout of 0 is an explicit long-running-command contract: the user
|
||||
// must still cancel the call or job, but OMP does not impose a deadline.
|
||||
const requestedTimeoutSec = rawTimeout;
|
||||
const timeoutSec = clampTimeout("bash", requestedTimeoutSec);
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
const timeoutDisabled = requestedTimeoutSec === 0;
|
||||
const timeoutSec = timeoutDisabled ? undefined : clampTimeout("bash", requestedTimeoutSec);
|
||||
const timeoutMs = timeoutSec === undefined ? undefined : timeoutSec * 1000;
|
||||
const pendingNotices: string[] = [];
|
||||
const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
|
||||
if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
|
||||
if (timeoutSec !== undefined) {
|
||||
const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
|
||||
if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
|
||||
}
|
||||
|
||||
if (asyncRequested) {
|
||||
if (!this.session.asyncJobManager) {
|
||||
@@ -909,14 +935,16 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
throw new ToolAbortError("Command aborted");
|
||||
}
|
||||
|
||||
const timeoutPromise = Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const }));
|
||||
const timeoutPromise = timeoutMs
|
||||
? Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const }))
|
||||
: undefined;
|
||||
// Poll until the process exits, times out, or the caller aborts.
|
||||
for (;;) {
|
||||
const racers: Array<Promise<BridgeRaceResult>> = [
|
||||
exitPromise.then(s => ({ kind: "exit" as const, status: s })),
|
||||
timeoutPromise,
|
||||
Bun.sleep(250).then(() => ({ kind: "poll" as const })),
|
||||
];
|
||||
if (timeoutPromise) racers.push(timeoutPromise);
|
||||
if (signal) {
|
||||
racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
|
||||
}
|
||||
@@ -1053,7 +1081,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
: await executeBash(command, {
|
||||
cwd: commandCwd,
|
||||
sessionKey: this.session.getSessionId?.() ?? undefined,
|
||||
timeout: timeoutMs,
|
||||
timeout: timeoutMs ?? 0,
|
||||
signal,
|
||||
env: resolvedEnv,
|
||||
artifactPath,
|
||||
@@ -1074,11 +1102,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
||||
}
|
||||
if (isInteractiveResult(result) && result.timedOut) {
|
||||
const out = normalizeResultOutput(result);
|
||||
throw new ToolError(
|
||||
out
|
||||
? `${out}\n\n[Command timed out after ${timeoutSec} seconds]`
|
||||
: `Command timed out after ${timeoutSec} seconds`,
|
||||
);
|
||||
const message =
|
||||
timeoutSec === undefined ? "Command timed out" : `Command timed out after ${timeoutSec} seconds`;
|
||||
throw new ToolError(out ? `${out}\n\n[${message}]` : message);
|
||||
}
|
||||
return this.#buildCompletedResult(result, timeoutSec, {
|
||||
requestedTimeoutSec,
|
||||
@@ -1286,13 +1312,17 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
|
||||
const showingFullOutput = expanded && renderContext?.isFullOutput === true;
|
||||
|
||||
// Build truncation warning
|
||||
const timeoutSeconds = details?.timeoutSeconds ?? renderContext?.timeout;
|
||||
const timeoutDisabled = details?.timeoutDisabled === true || renderContext?.timeout === 0;
|
||||
const timeoutSeconds = timeoutDisabled ? undefined : (details?.timeoutSeconds ?? renderContext?.timeout);
|
||||
const requestedTimeoutSeconds = details?.requestedTimeoutSeconds;
|
||||
const wallTimeMs = details?.wallTimeMs;
|
||||
const statsParts: string[] = [];
|
||||
if (wallTimeMs !== undefined) {
|
||||
statsParts.push(`Wall: ${formatWallTimeSeconds(wallTimeMs)}s`);
|
||||
}
|
||||
if (timeoutDisabled) {
|
||||
statsParts.push("Timeout: disabled");
|
||||
}
|
||||
if (typeof timeoutSeconds === "number") {
|
||||
statsParts.push(
|
||||
requestedTimeoutSeconds !== undefined && requestedTimeoutSeconds !== timeoutSeconds
|
||||
|
||||
@@ -29,15 +29,20 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1
|
||||
* connection dropped, etc.).
|
||||
*/
|
||||
export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000;
|
||||
const ENABLE_AUTOMATION_FLAG = "--enable-automation";
|
||||
// Automation-tell launch flags that puppeteer-core adds by default. We suppress
|
||||
// them via `ignoreDefaultArgs` (the supported escape hatch) to mirror xxxx's
|
||||
// chromiumSwitches patch. `--enable-automation` is the loudest: it sets
|
||||
// chromiumSwitches patch. `--enable-automation` is the loudest: it normally sets
|
||||
// navigator.webdriver=true and shows the "controlled by automated software" infobar.
|
||||
// Edge is the launch-stability exception: it can exit before CDP opens when this
|
||||
// default flag is stripped, so Edge keeps Puppeteer's flag while our explicit
|
||||
// `--disable-blink-features=AutomationControlled` launch arg still handles
|
||||
// navigator.webdriver.
|
||||
// `ignoreDefaultArgs` does exact-string matching, so each entry must be a flag that
|
||||
// puppeteer emits verbatim. The default `--disable-features=...` string can't be
|
||||
// matched this way; it is neutralized in the puppeteer-core patch (ChromeLauncher).
|
||||
const STEALTH_IGNORE_DEFAULT_ARGS = [
|
||||
"--enable-automation",
|
||||
ENABLE_AUTOMATION_FLAG,
|
||||
"--disable-extensions",
|
||||
"--disable-default-apps",
|
||||
"--disable-component-extensions-with-background-pages",
|
||||
@@ -47,6 +52,23 @@ const STEALTH_IGNORE_DEFAULT_ARGS = [
|
||||
"--disable-ipc-flooding-protection",
|
||||
"--metrics-recording-only",
|
||||
];
|
||||
|
||||
function isMicrosoftEdgeExecutable(executablePath: string | undefined): boolean {
|
||||
if (!executablePath) return false;
|
||||
const normalizedPath = executablePath.replaceAll("\\", "/").toLowerCase();
|
||||
const executableName = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1);
|
||||
return (
|
||||
executableName === "msedge.exe" ||
|
||||
executableName === "microsoft edge" ||
|
||||
executableName.startsWith("microsoft-edge")
|
||||
);
|
||||
}
|
||||
|
||||
function stealthIgnoreDefaultArgs(executablePath: string | undefined): string[] {
|
||||
if (!isMicrosoftEdgeExecutable(executablePath)) return [...STEALTH_IGNORE_DEFAULT_ARGS];
|
||||
return STEALTH_IGNORE_DEFAULT_ARGS.filter(arg => arg !== ENABLE_AUTOMATION_FLAG);
|
||||
}
|
||||
|
||||
const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
|
||||
|
||||
const USER_AGENT_TARGET_TIMEOUT_MS = 5_000;
|
||||
@@ -282,12 +304,13 @@ export async function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promis
|
||||
if (ignoreCert === "true" || ignoreCert === "1" || ignoreCert === "yes" || ignoreCert === "on") {
|
||||
launchArgs.push("--ignore-certificate-errors");
|
||||
}
|
||||
const executablePath = await ensureChromiumExecutable();
|
||||
return await puppeteer.launch({
|
||||
headless: opts.headless,
|
||||
defaultViewport: opts.headless ? initialViewport : null,
|
||||
executablePath: await ensureChromiumExecutable(),
|
||||
executablePath,
|
||||
args: launchArgs,
|
||||
ignoreDefaultArgs: [...STEALTH_IGNORE_DEFAULT_ARGS],
|
||||
ignoreDefaultArgs: stealthIgnoreDefaultArgs(executablePath),
|
||||
protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
@@ -737,6 +760,10 @@ export async function applyStealthPatches(
|
||||
await injectStealthScripts(page);
|
||||
}
|
||||
|
||||
export function stealthIgnoreDefaultArgsForTest(executablePath: string | undefined): string[] {
|
||||
return stealthIgnoreDefaultArgs(executablePath);
|
||||
}
|
||||
|
||||
export function targetSupportsUserAgentOverrideForTest(target: Target): boolean {
|
||||
return targetSupportsUserAgentOverride(target);
|
||||
}
|
||||
|
||||
@@ -48,12 +48,14 @@ import {
|
||||
type LineRange,
|
||||
parseLineRanges,
|
||||
pathTargetsSsh,
|
||||
probeLiteralPathExists,
|
||||
type ResolvedSearchTarget,
|
||||
resolveReadPath,
|
||||
resolveToolSearchScope,
|
||||
selectorLineRanges,
|
||||
splitInternalUrlSel,
|
||||
splitPathAndSel,
|
||||
splitPathAndSelPreferringLiteral,
|
||||
toPathList,
|
||||
} from "./path-utils";
|
||||
import {
|
||||
@@ -77,6 +79,9 @@ const searchSchema = type({
|
||||
"path?": searchPathEntry.describe(
|
||||
'file, directory, glob, internal URL, or "<file>:<lines>" selector to search; pass several as a semicolon-delimited list ("src; tests"). Omitted -> searches the workspace root (".")',
|
||||
),
|
||||
"selector?": type("string").describe(
|
||||
'line selector without a leading colon (e.g. "50-100", "50+10", "50-100,200-300"); keeps `path` literal when filenames contain colons',
|
||||
),
|
||||
"case?": type("boolean").describe("case-sensitive search"),
|
||||
"gitignore?": type("boolean").describe("respect gitignore"),
|
||||
"skip?": type("number")
|
||||
@@ -119,6 +124,7 @@ const SEARCH_GREP_TIMEOUT_MS = 30_000;
|
||||
interface GrepPathSpec {
|
||||
original: string;
|
||||
clean: string;
|
||||
literalFilesystemMatch?: boolean;
|
||||
ranges?: [LineRange, ...LineRange[]];
|
||||
}
|
||||
|
||||
@@ -147,9 +153,38 @@ function isReadSelectorGrammar(sel: string): boolean {
|
||||
return lower === "raw" || lower === "conflicts" || parseLineRanges(sel) !== null;
|
||||
}
|
||||
|
||||
function parsePathSpecs(rawEntries: readonly string[]): GrepPathSpec[] {
|
||||
async function parsePathSpecs(
|
||||
rawEntries: readonly string[],
|
||||
cwd: string,
|
||||
explicitSelector?: string,
|
||||
): Promise<GrepPathSpec[]> {
|
||||
const explicitRanges =
|
||||
explicitSelector === undefined || explicitSelector.length === 0 ? undefined : parseLineRanges(explicitSelector);
|
||||
if (explicitSelector !== undefined && !explicitRanges) {
|
||||
throw new ToolError(
|
||||
`selector "${explicitSelector}" is invalid — use line ranges like "50-100", "50+10", or "50-100,200-300" without a leading colon`,
|
||||
);
|
||||
}
|
||||
const specs: GrepPathSpec[] = [];
|
||||
for (const entry of rawEntries) {
|
||||
if (explicitRanges) {
|
||||
// Separate selector parameter makes `path` deterministic: first try the
|
||||
// exact local filesystem path (with read-path normalization), then let
|
||||
// archive/internal/URL resolution handle non-literal structured paths.
|
||||
const rawPathHasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(entry);
|
||||
const probe = rawPathHasScheme ? "missing" : await probeLiteralPathExists(entry, cwd);
|
||||
// `"unknown"` covers EACCES/IO where we cannot confirm existence — treat
|
||||
// it as a literal so a real file such as `test:1-2` under an unreadable
|
||||
// parent is never silently reinterpreted as `test` + selector.
|
||||
const literalMatch = probe !== "missing";
|
||||
specs.push({
|
||||
original: entry,
|
||||
clean: literalMatch && !rawPathHasScheme ? resolveReadPath(entry, cwd) : entry,
|
||||
literalFilesystemMatch: literalMatch,
|
||||
ranges: explicitRanges,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Internal URLs (`artifact://`, `skill://`, …) use the URL-aware splitter,
|
||||
// which peels selector-shaped tails only for selector-capable schemes and
|
||||
// leaves opaque ones (`mcp://`) intact. Unlike filesystem paths, their
|
||||
@@ -168,10 +203,14 @@ function parsePathSpecs(rawEntries: readonly string[]): GrepPathSpec[] {
|
||||
specs.push({ original: entry, clean: internalSplit.path, ranges: selectorLineRanges(internalSplit.sel) });
|
||||
continue;
|
||||
}
|
||||
const split = splitPathAndSel(entry);
|
||||
let clean = entry;
|
||||
// Prefer a literal filesystem match when one exists — a real file named
|
||||
// `test:1-2` outranks the `:1-2` selector interpretation (issue #4618).
|
||||
const strictSplit = splitPathAndSel(entry);
|
||||
const split = await splitPathAndSelPreferringLiteral(entry, cwd);
|
||||
const literalFilesystemMatch = strictSplit.sel !== undefined && split.sel === undefined;
|
||||
let clean = literalFilesystemMatch ? resolveReadPath(entry, cwd) : entry;
|
||||
let ranges: [LineRange, ...LineRange[]] | undefined;
|
||||
if (split.sel) {
|
||||
if (!literalFilesystemMatch && split.sel) {
|
||||
const parsed = parseLineRanges(split.sel);
|
||||
if (!parsed) {
|
||||
throw new ToolError(
|
||||
@@ -184,7 +223,7 @@ function parsePathSpecs(rawEntries: readonly string[]): GrepPathSpec[] {
|
||||
clean = split.path;
|
||||
ranges = parsed;
|
||||
}
|
||||
specs.push({ original: entry, clean, ranges });
|
||||
specs.push({ original: entry, clean, literalFilesystemMatch, ranges });
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
@@ -220,7 +259,7 @@ function matchAbsolutePath(matchPath: string, searchPath: string): string {
|
||||
* cleanup hook the caller MUST invoke in a `finally`.
|
||||
*/
|
||||
async function resolveArchiveSearchPaths(
|
||||
paths: string[],
|
||||
pathSpecs: readonly GrepPathSpec[],
|
||||
cwd: string,
|
||||
): Promise<{
|
||||
resolvedPaths: string[];
|
||||
@@ -229,17 +268,18 @@ async function resolveArchiveSearchPaths(
|
||||
unreadable: string[];
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const resolvedPaths = paths.slice();
|
||||
const resolvedPaths = pathSpecs.map(spec => spec.clean);
|
||||
const displayMap = new Map<string, string>();
|
||||
const displaySet = new Set<string>();
|
||||
const unreadable: string[] = [];
|
||||
let tempDir: string | undefined;
|
||||
const archiveCache = new Map<string, ArchiveReader>();
|
||||
|
||||
for (let idx = 0; idx < paths.length; idx++) {
|
||||
const entry = paths[idx];
|
||||
for (let idx = 0; idx < pathSpecs.length; idx++) {
|
||||
const spec = pathSpecs[idx];
|
||||
if (!spec || spec.literalFilesystemMatch) continue;
|
||||
const entry = spec.clean;
|
||||
const candidates = parseArchivePathCandidates(entry);
|
||||
// Longest archive prefix first; we want the one whose member portion is non-empty.
|
||||
const member = candidates.find(c => c.subPath !== "" && c.archivePath !== entry);
|
||||
if (!member) continue;
|
||||
|
||||
@@ -879,7 +919,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
||||
_onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
|
||||
_toolContext?: AgentToolContext,
|
||||
): Promise<AgentToolResult<GrepToolDetails>> {
|
||||
const { pattern, path: rawPath, case: caseSensitive, gitignore, skip } = params;
|
||||
const { pattern, path: rawPath, selector, case: caseSensitive, gitignore, skip } = params;
|
||||
|
||||
return untilAborted(signal, async () => {
|
||||
// Preserve the pattern verbatim — leading/trailing whitespace is
|
||||
@@ -897,8 +937,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
||||
const scopedPaths = toPathList(rawPath);
|
||||
const effectivePaths = scopedPaths.length > 0 ? scopedPaths : ["."];
|
||||
const rawEntries = await expandDelimitedPathEntries(effectivePaths, this.session.cwd);
|
||||
const pathSpecs = parsePathSpecs(rawEntries);
|
||||
const paths = pathSpecs.map(spec => spec.clean);
|
||||
const pathSpecs = await parsePathSpecs(rawEntries, this.session.cwd, selector);
|
||||
const materializedExternalPaths = new Map<string, string>();
|
||||
const materializeExternalUrlForSearch = async (rawPath: string) => {
|
||||
const target = parseReadUrlTarget(rawPath);
|
||||
@@ -917,7 +956,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
||||
displaySet: archiveDisplaySet,
|
||||
unreadable: archiveUnreadable,
|
||||
cleanup: cleanupArchiveScratch,
|
||||
} = await resolveArchiveSearchPaths(paths, this.session.cwd);
|
||||
} = await resolveArchiveSearchPaths(pathSpecs, this.session.cwd);
|
||||
try {
|
||||
const internalResolution = await resolveInternalSearchInputs({
|
||||
pathSpecs,
|
||||
|
||||
@@ -224,6 +224,10 @@ export interface ToolSession {
|
||||
getAgentId?: () => string | null;
|
||||
/** Look up a registered tool by name (used by the eval js backend's tool bridge). */
|
||||
getToolByName?: (name: string) => AgentTool | undefined;
|
||||
/** Return whether a built-in tool is active in this turn's tool set. */
|
||||
isToolActive?: (name: string) => boolean;
|
||||
/** Update the active built-in tool predicate when a session changes tools mid-run. */
|
||||
setActiveToolNames?: (names: Iterable<string>) => void;
|
||||
/** Agent registry for IRC routing across live sessions. */
|
||||
agentRegistry?: AgentRegistry;
|
||||
/** Get artifacts directory for artifact:// URLs */
|
||||
@@ -647,6 +651,13 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
...(goalModeActive ? ([["goal", HIDDEN_TOOLS.goal]] as const) : []),
|
||||
];
|
||||
|
||||
const activeToolNames = new Set(baseEntries.map(([name]) => name));
|
||||
if (session.setActiveToolNames) {
|
||||
session.setActiveToolNames(activeToolNames);
|
||||
} else {
|
||||
session.isToolActive = name => activeToolNames.has(name);
|
||||
}
|
||||
|
||||
const baseResults = await Promise.all(
|
||||
baseEntries.map(async ([name, factory]) => {
|
||||
const tool = await logger.time(`createTools:${name}`, factory as ToolFactory, session);
|
||||
|
||||
@@ -315,6 +315,46 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
|
||||
return { path: basePath, sel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-way probe for whether the exact filesystem entry named by `filePath`
|
||||
* exists. `stat` (used earlier) failed for reasons other than "no such file"
|
||||
* (dangling symlink, `EACCES` on a parent, transient I/O), and each of those
|
||||
* silently reinterpreted a real literal path such as `test:1-2` as `test`
|
||||
* plus selector `1-2` (issue #4618). `lstat` inspects the entry itself, so a
|
||||
* dangling symlink is still detected as present; ambiguous errors resolve to
|
||||
* `"unknown"` so callers keep the raw path instead of guessing.
|
||||
*/
|
||||
export async function probeLiteralPathExists(filePath: string, cwd: string): Promise<"exists" | "missing" | "unknown"> {
|
||||
const resolved = resolveReadPath(filePath, cwd);
|
||||
try {
|
||||
await fs.promises.lstat(resolved);
|
||||
return "exists";
|
||||
} catch (err) {
|
||||
if (isEnoent(err) || isEnotdir(err)) return "missing";
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async sibling of {@link splitPathAndSel} that prefers a literal filesystem
|
||||
* path over selector interpretation. Filenames whose tail matches the selector
|
||||
* grammar (e.g. `test:1-2`, `log:raw`) are legal on POSIX; without this the
|
||||
* strict splitter peels the tail and both `read` and `grep` refuse to open the
|
||||
* real file (issue #4618). The literal wins on a confirmed `lstat`, and also
|
||||
* on `"unknown"` (`EACCES` on a parent, transient I/O), so an unreachable
|
||||
* literal is never silently reinterpreted as `path + selector`. Only a
|
||||
* definitive `ENOENT`/`ENOTDIR` falls back to the strict split.
|
||||
*/
|
||||
export async function splitPathAndSelPreferringLiteral(
|
||||
rawPath: string,
|
||||
cwd: string,
|
||||
): Promise<{ path: string; sel?: string }> {
|
||||
const strict = splitPathAndSel(rawPath);
|
||||
if (strict.sel === undefined) return strict;
|
||||
const probe = await probeLiteralPathExists(rawPath, cwd);
|
||||
return probe === "missing" ? strict : { path: rawPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of {@link splitPathAndSel} for internal URLs (`scheme://...`).
|
||||
*
|
||||
@@ -669,7 +709,12 @@ export async function splitDelimitedPathEntry(
|
||||
const normalizedEntry = normalizePathLikeInput(entry);
|
||||
if (!hasTopLevelPathDelimiter(normalizedEntry)) return null;
|
||||
if (isInternalUrlPath(normalizedEntry)) return null;
|
||||
|
||||
// A real POSIX file may contain the delimiter and a selector-shaped tail
|
||||
// (`a;b:1-2`, `a b:1-2`). Preserve the raw entry whenever the full literal
|
||||
// resolves — or is only ambiguous — so downstream literal-preferring
|
||||
// splitters see it before delimiter expansion peels or splits (issue #4618
|
||||
// reviewer feedback: delimited expansion ran before the literal check).
|
||||
if ((await probeLiteralPathExists(normalizedEntry, cwd)) !== "missing") return null;
|
||||
const splitter = options.splitter ?? parseSearchPath;
|
||||
const peeledEntry = splitPathAndSel(normalizedEntry).path;
|
||||
if (!hasGlobPathChars(peeledEntry) && (await delimitedPathPartResolves(normalizedEntry, cwd, splitter))) {
|
||||
|
||||
@@ -99,10 +99,12 @@ import {
|
||||
type LineRange,
|
||||
parseLineRanges,
|
||||
pathTargetsSsh,
|
||||
probeLiteralPathExists,
|
||||
resolveReadPath,
|
||||
splitDelimitedPathEntry,
|
||||
splitInternalUrlSel,
|
||||
splitPathAndSel,
|
||||
splitPathAndSelPreferringLiteral,
|
||||
} from "./path-utils";
|
||||
import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
|
||||
import {
|
||||
@@ -746,7 +748,10 @@ function splitPdfImageMemberReadPath(readPath: string): { pdfPath: string; membe
|
||||
|
||||
const readSchema = type({
|
||||
path: type("string").describe(
|
||||
'Local path, internal URI (e.g. "omp://", "issue://123", "pr://123"), or URL; append :<sel> for line ranges or raw mode (e.g. "src/foo.ts:50-100")',
|
||||
'Local path, internal URI (e.g. "omp://", "issue://123", "pr://123"), or URL. Inline :<sel> is still accepted for compatibility.',
|
||||
),
|
||||
"selector?": type("string").describe(
|
||||
'selector without a leading colon (e.g. "50-100", "raw", "raw:50-100", "conflicts"); keeps `path` literal when filenames contain colons',
|
||||
),
|
||||
});
|
||||
|
||||
@@ -2113,6 +2118,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
_toolContext?: AgentToolContext,
|
||||
): Promise<AgentToolResult<ReadToolDetails>> {
|
||||
let { path: readPath } = params;
|
||||
let explicitSelector = params.selector?.trim();
|
||||
let explicitParsedSelector = explicitSelector === undefined ? undefined : parseSel(explicitSelector);
|
||||
if (
|
||||
params.selector !== undefined &&
|
||||
(explicitSelector === undefined || explicitSelector.length === 0 || explicitParsedSelector?.kind === "none")
|
||||
) {
|
||||
throw invalidSelector(params.selector);
|
||||
}
|
||||
if (readPath.startsWith("file://")) {
|
||||
readPath = expandPath(readPath);
|
||||
}
|
||||
@@ -2133,40 +2146,55 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
if (!this.session.settings.get("fetch.enabled")) {
|
||||
throw new ToolError("URL reads are disabled by settings.");
|
||||
}
|
||||
if (parsedUrlTarget.ranges !== undefined) {
|
||||
if (explicitParsedSelector?.kind === "conflicts") {
|
||||
throw new ToolError("The explicit read selector `conflicts` is only supported for local files.");
|
||||
}
|
||||
const urlRaw =
|
||||
explicitParsedSelector === undefined ? parsedUrlTarget.raw : isRawSelector(explicitParsedSelector);
|
||||
const urlRanges =
|
||||
explicitParsedSelector?.kind === "lines" ? explicitParsedSelector.ranges : parsedUrlTarget.ranges;
|
||||
if (urlRanges !== undefined && urlRanges.length > 1) {
|
||||
const cached = await loadReadUrlCacheEntry(
|
||||
this.session,
|
||||
{ path: parsedUrlTarget.path, raw: parsedUrlTarget.raw },
|
||||
{ path: parsedUrlTarget.path, raw: urlRaw },
|
||||
signal,
|
||||
{ ensureArtifact: true, preferCached: true },
|
||||
);
|
||||
return this.#buildInMemoryMultiRangeResult(cached.output, parsedUrlTarget.ranges, {
|
||||
return this.#buildInMemoryMultiRangeResult(cached.output, urlRanges, {
|
||||
details: { ...cached.details },
|
||||
sourceUrl: cached.details.finalUrl,
|
||||
entityLabel: "URL output",
|
||||
raw: parsedUrlTarget.raw,
|
||||
raw: urlRaw,
|
||||
immutable: true,
|
||||
});
|
||||
}
|
||||
if (parsedUrlTarget.offset !== undefined || parsedUrlTarget.limit !== undefined) {
|
||||
const urlRange = urlRanges?.[0];
|
||||
const urlOffset = explicitParsedSelector?.kind === "lines" ? urlRange?.startLine : parsedUrlTarget.offset;
|
||||
const urlLimit =
|
||||
explicitParsedSelector?.kind === "lines" && urlRange
|
||||
? urlRange.endLine !== undefined
|
||||
? urlRange.endLine - urlRange.startLine + 1
|
||||
: undefined
|
||||
: parsedUrlTarget.limit;
|
||||
if (urlOffset !== undefined || urlLimit !== undefined) {
|
||||
const cached = await loadReadUrlCacheEntry(
|
||||
this.session,
|
||||
{ path: parsedUrlTarget.path, raw: parsedUrlTarget.raw },
|
||||
{ path: parsedUrlTarget.path, raw: urlRaw },
|
||||
signal,
|
||||
{
|
||||
ensureArtifact: true,
|
||||
preferCached: true,
|
||||
},
|
||||
);
|
||||
return this.#buildInMemoryTextResult(cached.output, parsedUrlTarget.offset, parsedUrlTarget.limit, {
|
||||
return this.#buildInMemoryTextResult(cached.output, urlOffset, urlLimit, {
|
||||
details: { ...cached.details },
|
||||
sourceUrl: cached.details.finalUrl,
|
||||
entityLabel: "URL output",
|
||||
raw: parsedUrlTarget.raw,
|
||||
raw: urlRaw,
|
||||
immutable: true,
|
||||
});
|
||||
}
|
||||
return executeReadUrl(this.session, { path: parsedUrlTarget.path, raw: parsedUrlTarget.raw }, signal);
|
||||
return executeReadUrl(this.session, { path: parsedUrlTarget.path, raw: urlRaw }, signal);
|
||||
}
|
||||
|
||||
// Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://, omp://, issue://, pr://).
|
||||
@@ -2174,8 +2202,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
// off the URL and surfaced via parseSel rather than confusing handlers.
|
||||
const internalRouter = InternalUrlRouter.instance();
|
||||
if (internalRouter.canHandle(readPath)) {
|
||||
const internalTarget = splitInternalUrlSel(readPath);
|
||||
const parsed = parseSel(internalTarget.sel);
|
||||
const internalTarget =
|
||||
explicitSelector === undefined ? splitInternalUrlSel(readPath) : { path: readPath, sel: explicitSelector };
|
||||
const parsed = explicitParsedSelector ?? parseSel(internalTarget.sel);
|
||||
if (internalTarget.sel !== undefined && parsed.kind === "none") {
|
||||
throw new ToolError(
|
||||
`Invalid selector ':${internalTarget.sel}' on '${internalTarget.path}'. Use :N, :N-M, :N+K, :N- (open-ended), a comma-separated list of ranges, :raw, or a range combined with raw (e.g. :raw:50-100).`,
|
||||
@@ -2192,7 +2221,16 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
skills: this.session.skills,
|
||||
});
|
||||
if (localFile) {
|
||||
readPath = internalTarget.sel === undefined ? localFile.path : `${localFile.path}:${internalTarget.sel}`;
|
||||
readPath = localFile.path;
|
||||
// Promote the URL-embedded selector into the explicit-selector state so
|
||||
// downstream literal-preferring routing does NOT re-split the synthesized
|
||||
// `${localFile.path}:${sel}` string — a sibling literal file at that name
|
||||
// would otherwise shadow the intended local:// URL selector semantics
|
||||
// (issue #4618 reviewer feedback on c493d12).
|
||||
if (explicitSelector === undefined && internalTarget.sel !== undefined) {
|
||||
explicitSelector = internalTarget.sel;
|
||||
explicitParsedSelector = parsed;
|
||||
}
|
||||
} else {
|
||||
return this.#handleInternalUrl(internalTarget.path, parsed, signal);
|
||||
}
|
||||
@@ -2205,48 +2243,68 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
// resolution share misses instead of re-globbing the workspace.
|
||||
const suffixCache: SuffixMatchCache = new Map();
|
||||
|
||||
const archivePath = await this.#resolveArchiveReadPath(readPath, suffixCache, signal);
|
||||
if (archivePath) {
|
||||
const archiveSubPath = splitPathAndSel(archivePath.archiveSubPath);
|
||||
const archiveParsed = parseSel(archiveSubPath.sel);
|
||||
return this.#readArchive(
|
||||
readPath,
|
||||
archiveParsed,
|
||||
{ ...archivePath, archiveSubPath: archiveSubPath.path },
|
||||
signal,
|
||||
);
|
||||
}
|
||||
// Prefer a literal filesystem match over selector interpretation so real
|
||||
// POSIX filenames containing selector-looking suffixes win over structured
|
||||
// archive / sqlite / pdf-image dispatch. With explicit `selector`, `path`
|
||||
// is exact: `path: "test:1-2", selector: "1-2"` means "lines 1-2 from
|
||||
// the literal file test:1-2", without recursively depending on whether a
|
||||
// longer `test:1-2:1-2` filename also exists (issue #4618).
|
||||
const literalSplit =
|
||||
explicitSelector === undefined
|
||||
? await splitPathAndSelPreferringLiteral(readPath, this.session.cwd)
|
||||
: { path: readPath, sel: explicitSelector };
|
||||
const rawPathIsLiteral =
|
||||
explicitSelector !== undefined
|
||||
? readPath.includes(":") && (await probeLiteralPathExists(readPath, this.session.cwd)) !== "missing"
|
||||
: literalSplit.sel === undefined && splitPathAndSel(readPath).sel !== undefined;
|
||||
|
||||
const sqlitePath = await this.#resolveSqliteReadPath(readPath, suffixCache, signal);
|
||||
if (sqlitePath) {
|
||||
return this.#readSqlite(sqlitePath, signal);
|
||||
}
|
||||
|
||||
const pdfImageMemberPath = splitPdfImageMemberReadPath(readPath);
|
||||
if (pdfImageMemberPath) {
|
||||
let absolutePdfPath = resolveReadPath(pdfImageMemberPath.pdfPath, this.session.cwd);
|
||||
let suffixResolution: { from: string; to: string } | undefined;
|
||||
try {
|
||||
const stat = await Bun.file(absolutePdfPath).stat();
|
||||
if (stat.isDirectory())
|
||||
throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' is a directory, not a PDF file`);
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error) || isRemoteMountPath(absolutePdfPath)) throw error;
|
||||
const suffixMatch = await this.#findSuffixMatchCached(suffixCache, pdfImageMemberPath.pdfPath, signal);
|
||||
if (!suffixMatch) throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' not found`);
|
||||
absolutePdfPath = suffixMatch.absolutePath;
|
||||
suffixResolution = { from: pdfImageMemberPath.pdfPath, to: suffixMatch.displayPath };
|
||||
if (!rawPathIsLiteral) {
|
||||
const archivePath = await this.#resolveArchiveReadPath(readPath, suffixCache, signal);
|
||||
if (archivePath) {
|
||||
const archiveSubPath =
|
||||
explicitSelector === undefined
|
||||
? splitPathAndSel(archivePath.archiveSubPath)
|
||||
: { path: archivePath.archiveSubPath, sel: explicitSelector };
|
||||
const archiveParsed = parseSel(archiveSubPath.sel);
|
||||
return this.#readArchive(
|
||||
readPath,
|
||||
archiveParsed,
|
||||
{ ...archivePath, archiveSubPath: archiveSubPath.path },
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
const sqlitePath = await this.#resolveSqliteReadPath(readPath, suffixCache, signal);
|
||||
if (sqlitePath) {
|
||||
return this.#readSqlite(sqlitePath, signal);
|
||||
}
|
||||
|
||||
const pdfImageMemberPath = splitPdfImageMemberReadPath(readPath);
|
||||
if (pdfImageMemberPath) {
|
||||
let absolutePdfPath = resolveReadPath(pdfImageMemberPath.pdfPath, this.session.cwd);
|
||||
let suffixResolution: { from: string; to: string } | undefined;
|
||||
try {
|
||||
const stat = await Bun.file(absolutePdfPath).stat();
|
||||
if (stat.isDirectory())
|
||||
throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' is a directory, not a PDF file`);
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error) || isRemoteMountPath(absolutePdfPath)) throw error;
|
||||
const suffixMatch = await this.#findSuffixMatchCached(suffixCache, pdfImageMemberPath.pdfPath, signal);
|
||||
if (!suffixMatch) throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' not found`);
|
||||
absolutePdfPath = suffixMatch.absolutePath;
|
||||
suffixResolution = { from: pdfImageMemberPath.pdfPath, to: suffixMatch.displayPath };
|
||||
}
|
||||
return this.#readPdfImageMember(
|
||||
absolutePdfPath,
|
||||
pdfImageMemberPath.pdfPath,
|
||||
pdfImageMemberPath.member,
|
||||
suffixResolution,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
return this.#readPdfImageMember(
|
||||
absolutePdfPath,
|
||||
pdfImageMemberPath.pdfPath,
|
||||
pdfImageMemberPath.member,
|
||||
suffixResolution,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
const localTarget = splitPathAndSel(readPath);
|
||||
const localTarget = literalSplit;
|
||||
const localReadPath = localTarget.path;
|
||||
const parsed = parseSel(localTarget.sel);
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** formatLocalCalendarDate formats a Date as YYYY-MM-DD in the host local timezone. */
|
||||
export function formatLocalCalendarDate(date: Date = new Date()): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
@@ -32,22 +32,48 @@ function getExistingWslLocalPath(urlOrPath: string): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Windows `rundll32.exe` command used to hand a URL/path to the
|
||||
* user's registered protocol handler. Anchoring to `%SystemRoot%\System32`
|
||||
* (rather than relying on `rundll32` being on `PATH`) survives environments
|
||||
* where the machine `PATH` no longer references `System32` — a common
|
||||
* real-world misconfiguration where `System32\Wbem` / `WindowsPowerShell` /
|
||||
* `OpenSSH` survive but `System32` itself is dropped. Bare `rundll32` on
|
||||
* such boxes throws `Executable not found in $PATH: "rundll32"` from
|
||||
* `Bun.spawn` before ShellExecute ever sees the URL.
|
||||
* Resolve the Windows opener used to hand a URL/path to the user's registered
|
||||
* protocol handler. PowerShell's `Start-Process` goes through ShellExecute
|
||||
* like the previous `rundll32 url.dll,FileProtocolHandler`, with two
|
||||
* advantages that make the delayed-failure telemetry in {@link openPath}
|
||||
* actually observable on Windows:
|
||||
*
|
||||
* - `rundll32` exits 0 unconditionally, so no launch failure ever reaches the
|
||||
* non-zero-exit logging below. `Start-Process` surfaces the failures
|
||||
* ShellExecute itself reports — missing target file, no handler executable,
|
||||
* access denied — as exit code 1 (verified live: a nonexistent file path
|
||||
* exits 1; `$ErrorActionPreference='Stop'` additionally promotes any
|
||||
* non-terminating error classes). Known limitation shared by every opener:
|
||||
* an unregistered URL scheme exits 0 because Windows "handles" it by
|
||||
* offering the app-picker.
|
||||
* - `-EncodedCommand` carries the target as a UTF-16LE/base64 payload, so no
|
||||
* cmd/PowerShell metacharacter parsing ever sees it (OAuth authorize URLs
|
||||
* carry `&`); inside the decoded script the target is a single-quoted
|
||||
* literal (no `$` expansion) with embedded quotes doubled.
|
||||
*
|
||||
* PowerShell is anchored to `%SystemRoot%\System32` for the same reason the
|
||||
* previous revision anchored `rundll32`: machine PATHs that dropped
|
||||
* `System32` are a real-world occurrence, and bare names throw
|
||||
* `Executable not found in $PATH` from `Bun.spawn`. A bare-name fallback
|
||||
* remains for exotic SystemRoot layouts.
|
||||
*/
|
||||
function windowsOpenerCommand(target: string): string[] {
|
||||
const systemRoot = process.env.SystemRoot?.trim() || process.env.SYSTEMROOT?.trim() || "C:\\Windows";
|
||||
// `path.win32` (not the platform-adaptive `path.join`) keeps Windows path
|
||||
// separators when tests run under a POSIX host and matches Windows call
|
||||
// conventions on the real target.
|
||||
const rundll32 = path.win32.join(systemRoot, "System32", "rundll32.exe");
|
||||
return [rundll32, "url.dll,FileProtocolHandler", target];
|
||||
const absolute = path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
||||
const powershell = fs.existsSync(absolute) ? absolute : "powershell.exe";
|
||||
const script = `$ErrorActionPreference='Stop';Start-Process '${target.replaceAll("'", "''")}'`;
|
||||
return [
|
||||
powershell,
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-EncodedCommand",
|
||||
Buffer.from(script, "utf16le").toString("base64"),
|
||||
];
|
||||
}
|
||||
/** Open a URL or file path in the default browser/application. Best-effort, never throws. */
|
||||
export function openPath(urlOrPath: string): void {
|
||||
|
||||
Reference in New Issue
Block a user