Merge remote-tracking branch 'upstream/main' into feat/error-notify

This commit is contained in:
Mathews-Tom
2026-07-08 20:27:10 +05:30
141 changed files with 7105 additions and 693 deletions
+43
View File
@@ -6,10 +6,53 @@
- Added `error.notify` so failed model turns can emit distinct terminal/desktop notifications without changing completion notifications ([#2691](https://github.com/can1357/oh-my-pi/issues/2691)).
### Added
- Typing `#<number>` (e.g. `#3164`) in the prompt now offers PR and Issue autocomplete candidates that rewrite to the `pr://`/`issue://` internal URL, resolved from the current repo's git remote via the existing `read` tool → InternalUrlRouter → `gh` pipeline. Naming the type (`pr #3164` / `issue #3164`) constrains the candidates to that kind, and embedded hashes like `owner/repo#N`, `foo#N`, or URL fragments are left untouched ([#3218](https://github.com/can1357/oh-my-pi/issues/3218))
### Changed
- Memoized non-message token totals (system prompt, tool schemas, skills) so the per-turn compaction and context-threshold paths recompute them at most once per input change instead of on every call. `getContextBreakdown` and `#estimateStoredContextTokens` previously re-tokenized the system prompt and every tool's wire schema (per-tool `JSON.stringify`) several times per turn over inputs that change at most once per turn.
### Fixed
- Improved handling of unawaited promises in JS eval cells to prevent process crashes
- Added warning logs for unhandled rejections originating from finished eval cells
- Improved advisor robustness by blocking exhausted accounts during consecutive turn failures
- Fixed advisor turns hammering the same usage-limited account: a failed advisor turn now marks the exhausted credential blocked (with the provider's retry hint and usage-report reset time), so the next retry rotates to a sibling instead of re-picking the blocked account every few seconds. Previously the in-stream auth retry rotated within a request but never blocked the last failing credential, and the advisor loop — unlike the primary retry pipeline — never called `markUsageLimitReached`.
- Added the account key to the `codex-auto-reset: skipped` debug log so skip reasons (e.g. `weekly-not-exhausted`) can be attributed to the evaluated account.
- Fixed unawaited promise rejections in JS eval cells crashing the session: a floating rejection now fails the owning cell run (`Unhandled rejection (missing await?): …`) instead of escaping to the global `unhandledRejection` handler, which printed `[Unhandled Rejection]` and killed the process (inline fallback) or tore down the eval worker (dedicated worker). Rejections surfacing after a cell settled are downgraded to a warn log attributed to the finished cell.
- Fixed project `.omp/RULES.md` sticky rules being shadowed by user `~/.omp/agent/RULES.md` rules with the same synthesized `RULES` name, so both user and project sticky rules now inject ([#4739](https://github.com/can1357/oh-my-pi/issues/4739)).
- Fixed bash internal-URL expansion so unresolved literal `memory://` / `skill://` text stays verbatim instead of aborting command execution ([#4737](https://github.com/can1357/oh-my-pi/issues/4737)).
- Fixed `agent://<id>` (and the `output()` eval helper) failing with `Not found` for a subagent spawned by another subagent (any spawn chain 2+ levels deep). `artifactsDirsFromRegistry` scanned only each ref's adopted (root-wide) `ArtifactManager` dir, but a subagent's own children are written one level deeper under its `sessionFile`-derived dir — so a live, addressable nested peer's output was unresolvable. The resolver now collects both candidate dirs per registered agent. ([#4650](https://github.com/can1357/oh-my-pi/issues/4650))
- Fixed plan mode to document `local://` artifacts as writable session-local planning files and to carry every pre-approval `local://` artifact into the fresh session created by Approve and Execute.
- Fixed the browser tool failing to launch Microsoft Edge-only Windows installs with Puppeteer's empty `Code: 0` launch error by keeping Edge's required `--enable-automation` default while preserving Chrome/Chromium stealth launch defaults.
- Fixed bash/tool command environments inheriting Bun-autoloaded launch `.env.local` values, so nested apps can load their own dotenv values without parent deployment variables taking precedence. ([#4723](https://github.com/can1357/oh-my-pi/issues/4723))
- Fixed legacy plugin validation for extension graphs that import JSON with `with { type: "json" }`, leaving JSON files on Bun's native loader instead of parsing them as JavaScript ([#4687](https://github.com/can1357/oh-my-pi/issues/4687)).
- Fixed pasted terminal transcripts beginning with a shell prompt (`$ ...`) being mistaken for local Python shortcuts instead of being submitted as normal prompts ([#4678](https://github.com/can1357/oh-my-pi/issues/4678)).
- Fixed wrapped OAuth copy-URL rows corrupting on paste: continuation chunks no longer carry a leading indent, so a multi-row terminal selection reassembles to the exact authorize URL (browsers strip newlines on paste but preserve or percent-encode embedded spaces, which previously corrupted the URL at every chunk boundary).
- Fixed Windows browser-launch failures being unobservable: the opener now uses `%SystemRoot%`-resolved PowerShell `Start-Process` (via `-EncodedCommand`) instead of `rundll32`, which exits 0 unconditionally. Failures ShellExecute itself reports — missing target, no handler executable, access denied — now surface as non-zero exits and are logged; the encoded payload also keeps OAuth query strings (`&`-bearing) opaque to shell metacharacter parsing.
- Fixed system prompt date rendering to use the host local calendar date instead of UTC.
- Fixed `bash` tool `timeout: 0` so it disables the command deadline instead of falling back to the minimum timeout.
- Fixed `read` and `grep` refusing to access filesystem paths whose names end in a selector-shaped suffix (e.g. `test:1-2`, `log:raw`) by preferring a literal match over the trailing `:<selector>` peel when the raw path exists on disk ([#4618](https://github.com/can1357/oh-my-pi/issues/4618)).
- Fixed wrapped Edit-diff rows leaking inverse video into the result card's right-edge padding: a row that broke inside an intra-line highlight left inverse active at the row end, so the frame padding after it rendered as a default-foreground block ([#4616](https://github.com/can1357/oh-my-pi/pull/4616) by [@chan1103](https://github.com/chan1103))
- Fixed Edit-diff continuation rows escaping into the line-number column when the row's gutter was left-padded (line number narrower than the widest in the diff) or blanked by the gutter dedup (the bare `+` row of a single-line replacement); such rows now wrap behind a continuation gutter, while body lines that merely start with `|` keep wrapping generically ([#4616](https://github.com/can1357/oh-my-pi/pull/4616) by [@chan1103](https://github.com/chan1103))
- Fixed live advisors continuing to use a stale `modelRoles.advisor` selection after `/model` changed the advisor model. ([#4612](https://github.com/can1357/oh-my-pi/issues/4612))
- Fixed Claude plugin slash commands and skills silently vanishing when the plugin manifest declares `commands`/`slash-commands`/`skills` as a JSON array — the shape the Claude plugins reference documents and real plugins like `addyosmani/agent-skills` ship. `resolvePluginDir` in `packages/coding-agent/src/discovery/claude-plugins.ts` typed those fields as `string` and dropped array values on the floor; it now normalizes both shapes, loads every in-root entry, and reports one out-of-plugin-root warning per bad entry. The resolver also now honours Claude's per-field merge semantic — `skills` adds to the default `skills/` scan; `commands`/`slash-commands` replace the default `commands/` — so plugins like `{"skills":["./extra-skills"]}` no longer lose their default `skills/` folder while `{"commands":["./admin"]}` still replaces `commands/` as documented. ([#4609](https://github.com/can1357/oh-my-pi/issues/4609))
- Fixed macOS Backspace on empty search not deleting sessions in the `/resume` picker; Fn+Backspace terminals that deliver `\x7f` instead of `\e[3~` now reach the delete confirmation dialog. ([#4580](https://github.com/can1357/oh-my-pi/pull/4580) by [@JagravNaik](https://github.com/JagravNaik))
- Fixed `/rename` title arguments treating `#` prompt-action tokens as autocomplete triggers instead of literal session title text. ([#4600](https://github.com/can1357/oh-my-pi/issues/4600))
- Fixed empty session `.jsonl` files accumulating in `~/.omp/agent/sessions/<cwd>/` after a draft-then-clear exit cycle. `SessionManager.saveDraft(text)` materializes the session file so the draft sidecar has a parent; a subsequent `saveDraft("")` unlinked the sidecar but left the metadata-only JSONL behind (title slot + session header + startup selector entries, ~500–750 B), and `#shouldHaveSessionFile()` could no longer prune it once `#fileIsCurrent`/`#forceFileCreation` were latched. `SessionManager.close()` now drops only draft-owned metadata-only sessions with no saved draft sidecar to reattach to, while keeping real conversations, meaningful non-message entries such as handoff custom messages, explicit `ensureOnDisk()` sessions, drafts still pending for `--resume`, and never-materialized sessions untouched ([#4571](https://github.com/can1357/oh-my-pi/issues/4571)).
- Fixed the advisor being disabled for the entire session when the advisor role resolves to a reasoning model that exposes no controllable effort surface (Devin `devin/glm-5-2*`: `reasoning: true`, `thinking: undefined` — Cascade routes by sibling model id rather than a wire param). `#resolveAdvisorRuntimeDescriptors` in `packages/coding-agent/src/session/agent-session.ts` used to hardcode `ThinkingLevel.Medium`, which tripped `requireSupportedEffort` on the first advisor prompt with `Thinking effort medium is not supported by devin/glm-5-2. Supported efforts:` (empty list). The advisor descriptor now clamps the requested effort against the resolved model via `resolveThinkingLevelForModel` and forwards no explicit effort when the model has no controllable efforts — matching the `auto`-path fix (`clampAutoThinkingEffort`) and the Autonomous Memory stage fix (`clampThinkingLevelForModel`). Explicit `:off` still disables reasoning, and models that support `medium` (e.g. Anthropic) keep receiving it ([#4579](https://github.com/can1357/oh-my-pi/issues/4579)).
- Fixed legacy extension plugin validation failing with `Export named 'calculateCost' not found in module '.../legacy-pi-ai-shim.ts'` when the extension imports `calculateCost` (or `modelsAreEqual` / `getBundledProviders`) from `@oh-my-pi/pi-ai`. Those symbols were relocated to `@oh-my-pi/pi-catalog/models` during the catalog split but were never bridged back through the legacy `pi-ai` root shim; the shim now re-exports them alongside the existing `getModel` / `getModels` aliases so plugins written against pre-split pi-ai load again ([#4584](https://github.com/can1357/oh-my-pi/issues/4584)).
- Fixed legacy extension plugin validation failing with `Export named 'calculateCost' not found in module '.../legacy-pi-ai-shim.ts'` when the extension imports relocated catalog symbols such as `calculateCost`, `modelsAreEqual`, `getBundledProviders`, `getBundledModel`, or `getBundledModels` from `@oh-my-pi/pi-ai`. Those symbols were relocated to `@oh-my-pi/pi-catalog/models` during the catalog split but were never bridged back through the legacy `pi-ai` root shim; the shim now re-exports them alongside the existing `getModel` / `getModels` aliases so plugins written against pre-split pi-ai load again ([#4584](https://github.com/can1357/oh-my-pi/issues/4584)).
- Fixed legacy pi extension imports of `DefaultResourceLoader` from `@mariozechner/pi-coding-agent` / `@earendil-works/pi-coding-agent` by adding a compatibility loader shim that translates `resourceLoader` into OMP's native session discovery options. ([#4567](https://github.com/can1357/oh-my-pi/issues/4567))
- Fixed legacy Pi extension reloads on POSIX so `loadLegacyPiModule` imports the entry through a cache-busting filesystem path, refreshes load-time graph hooks when reloads add new modules, and threads the current load's `?mtime` tag through the extension source graph — relative `./helper.ts` siblings, `#alias/*` package-imports, extension-local bare dependency entries, and their relative children all rekey per reload, so same-process re-imports pick up edits across the whole graph. ([#4565](https://github.com/can1357/oh-my-pi/issues/4565))
- Fixed bash tool pipeline execution preserving stale upstream output when the final stage was a stripped `head`/`tail` limiter; the tool now runs the command as written so `seq 1 5 | head -n2` returns only `1` and `2`. ([#4562](https://github.com/can1357/oh-my-pi/issues/4562))
- Fixed the status-line token-rate segment rendering as `<number>/s`, which Ghostty auto-detected as a hyperlink on Ctrl+hover. ([#4541](https://github.com/can1357/oh-my-pi/issues/4541))
- Fixed retry fallback model recovery by exposing `retry.fallbackChains` in `/settings`, adding a `/model` action to assign the selected default fallback model, and clearing a selected model's retry cooldown marker on manual model switches. ([#4533](https://github.com/can1357/oh-my-pi/issues/4533))
- Fixed `/handoff` and auto-handoff skipping extension lifecycle hooks by emitting cancellable `session_before_switch` hooks and a `session_switch` with `reason: "handoff"` after the replacement session is ready ([#4434](https://github.com/can1357/oh-my-pi/issues/4434)).
- Fixed TTSR stream interrupts so only the tool call whose stream matched a rule receives the rule-named abort result; sibling tool-call placeholders now use a neutral abort reason ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)).
## [16.3.11] - 2026-07-06
### Changed
@@ -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 };
+16 -1
View File
@@ -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;
+19 -5
View File
@@ -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 }> = [];
+14 -8
View File
@@ -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>
+11
View File
@@ -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))) {
+3 -2
View File
@@ -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)}`;
}
+69 -39
View File
@@ -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);
}
+53 -14
View File
@@ -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,
+11
View File
@@ -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);
+46 -1
View File
@@ -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))) {
+109 -51
View File
@@ -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}`;
}
+36 -10
View File
@@ -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 {
@@ -1,4 +1,5 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { Model } from "@oh-my-pi/pi-ai";
@@ -6,9 +7,10 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils";
describe("AgentSession advisor toggle", () => {
let sharedDir: TempDir;
@@ -22,6 +24,7 @@ describe("AgentSession advisor toggle", () => {
authStorage = await AuthStorage.create(path.join(sharedDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
authStorage.setRuntimeApiKey("openai", "test-key");
authStorage.setRuntimeApiKey("openrouter", "test-key");
modelRegistry = new ModelRegistry(authStorage);
const bundled = getBundledModel("anthropic", "claude-sonnet-4-5");
const replacement = getBundledModel("openai", "gpt-4o-mini");
@@ -98,6 +101,89 @@ describe("AgentSession advisor toggle", () => {
expect(session.getAdvisorAgent()?.state.model.id).toBe(replacementModel.id);
});
it("refreshes the live advisor when the advisor role setting changes", () => {
session.settings.setModelRole("advisor", `${model.provider}/${model.id}`);
expect(session.setAdvisorEnabled(true)).toBe(true);
expect(session.getAdvisorAgent()?.state.model.provider).toBe(model.provider);
expect(session.getAdvisorAgent()?.state.model.id).toBe(model.id);
session.settings.setModelRole("advisor", `${replacementModel.provider}/${replacementModel.id}`);
expect(session.getAdvisorAgent()?.state.model.provider).toBe(replacementModel.provider);
expect(session.getAdvisorAgent()?.state.model.id).toBe(replacementModel.id);
});
it("refreshes the live advisor when only the advisor route changes", () => {
session.settings.setModelRole("advisor", "openrouter/z-ai/glm-4.7@cerebras");
expect(session.setAdvisorEnabled(true)).toBe(true);
expect(session.getAdvisorAgent()?.state.model.provider).toBe("openrouter");
expect(session.getAdvisorAgent()?.state.model.id).toBe("z-ai/glm-4.7");
expect(
(session.getAdvisorAgent()?.state.model.compat as { openRouterRouting?: { only?: string[] } } | undefined)
?.openRouterRouting?.only,
).toEqual(["cerebras"]);
session.settings.setModelRole("advisor", "openrouter/z-ai/glm-4.7@fireworks");
expect(session.getAdvisorAgent()?.state.model.provider).toBe("openrouter");
expect(session.getAdvisorAgent()?.state.model.id).toBe("z-ai/glm-4.7");
expect(
(session.getAdvisorAgent()?.state.model.compat as { openRouterRouting?: { only?: string[] } } | undefined)
?.openRouterRouting?.only,
).toEqual(["fireworks"]);
});
it("refreshes the live advisor after project model-role reloads", async () => {
const projectA = path.join(tempDir.path(), "project-a");
const projectB = path.join(tempDir.path(), "project-b");
const agentDir = path.join(tempDir.path(), "agent");
fs.mkdirSync(getProjectAgentDir(projectA), { recursive: true });
fs.mkdirSync(getProjectAgentDir(projectB), { recursive: true });
fs.mkdirSync(agentDir, { recursive: true });
await Bun.write(
path.join(getProjectAgentDir(projectA), "settings.json"),
JSON.stringify({ modelRoles: { advisor: `${model.provider}/${model.id}` } }),
);
await Bun.write(
path.join(getProjectAgentDir(projectB), "settings.json"),
JSON.stringify({ modelRoles: { advisor: `${replacementModel.provider}/${replacementModel.id}` } }),
);
const settings = await Settings.loadIsolated({
cwd: projectA,
agentDir,
overrides: { "compaction.enabled": false },
});
const customSession = new AgentSession({
agent: new Agent({
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
}),
sessionManager: SessionManager.create(tempDir.path(), tempDir.path()),
settings,
modelRegistry,
advisorTools: [],
});
try {
expect(customSession.setAdvisorEnabled(true)).toBe(true);
expect(customSession.getAdvisorAgent()?.state.model.provider).toBe(model.provider);
expect(customSession.getAdvisorAgent()?.state.model.id).toBe(model.id);
await settings.reloadForCwd(projectB);
expect(customSession.getAdvisorAgent()?.state.model.provider).toBe(replacementModel.provider);
expect(customSession.getAdvisorAgent()?.state.model.id).toBe(replacementModel.id);
} finally {
await customSession.dispose();
AgentStorage.resetInstance();
}
});
it("keeps explicit enable idempotent when the advisor config is unchanged", () => {
session.settings.setModelRole("advisor", `${model.provider}/${model.id}`);
expect(session.setAdvisorEnabled(true)).toBe(true);
@@ -1235,6 +1235,126 @@ describe("AgentSession TTSR resume gate", () => {
expect(text).not.toContain("Request was aborted");
});
it("labels only the matching aborted tool placeholder with the TTSR rule reason", async () => {
collapseSchedulerSettleDelays();
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
let streamCallCount = 0;
const ttsrManager = new TtsrManager({
enabled: true,
contextMode: "discard",
interruptMode: "always",
repeatMode: "once",
repeatGap: 10,
});
ttsrManager.addRule(testRule);
const readToolCallContent: ToolCall = {
type: "toolCall",
id: "call_innocent_read",
name: "read",
arguments: { path: "history://Eval1WithSkill" },
};
const matchedToolCallContent: ToolCall = {
type: "toolCall",
id: "call_ttsr_abort_reason",
name: "mock_edit",
arguments: { snippet: "let val = result.unwrap(" },
};
const makeToolCallMsg = (stopReason: "toolUse" | "aborted" = "toolUse"): AssistantMessage => ({
role: "assistant",
content: [readToolCallContent, matchedToolCallContent],
api: "anthropic-messages",
provider: "anthropic",
model: "mock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
timestamp: Date.now(),
});
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: ["Test"], tools: [] },
streamFn: (_model, _context, options) => {
streamCallCount++;
const stream = new AssistantMessageEventStream();
const signal = options?.signal;
if (streamCallCount === 1) {
queueMicrotask(() => {
const partial = makeToolCallMsg();
if (signal) {
signal.addEventListener(
"abort",
() => {
stream.push({
type: "error",
reason: "aborted",
error: makeToolCallMsg("aborted"),
});
},
{ once: true },
);
}
stream.push({ type: "start", partial });
stream.push({ type: "toolcall_start", contentIndex: 1, partial });
stream.push({
type: "toolcall_delta",
contentIndex: 1,
delta: 'let val = result.unwrap("oops")',
partial,
});
// The abort placeholder is only minted for tool calls that reached
// `toolcall_end`: the agent loop drops incomplete tool calls from an
// aborted turn (partial args are unsafe to replay). Complete the
// innocent read before the rule-driven abort fires so its placeholder
// survives and can carry the neutral sibling label.
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: readToolCallContent, partial });
});
} else {
pushContinuationStream(stream, () => {});
}
return stream;
},
});
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated();
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth-abort-reason.db"));
authStorages.push(authStorage);
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
session = new AgentSession({ agent, sessionManager, settings, modelRegistry, ttsrManager });
await session.prompt("Write some Rust code");
const toolResults = sessionManager
.getEntries()
.filter(entry => entry.type === "message" && entry.message.role === "toolResult")
.map(entry => (entry.type === "message" && entry.message.role === "toolResult" ? entry.message : undefined))
.filter(message => message !== undefined);
const toolResultText = (toolCallId: string): string =>
toolResults
.find(message => message.toolCallId === toolCallId)
?.content.find((part): part is { type: "text"; text: string } => part.type === "text")?.text ?? "";
const readText = toolResultText(readToolCallContent.id);
expect(readText).toContain("Tool execution was aborted: TTSR interrupt on another tool call");
expect(readText).not.toContain("TTSR matched rule: no-unwrap");
// The matching call never reached `toolcall_end`, so the loop drops it from
// the aborted turn (partial args are unsafe to replay) and no placeholder is
// minted. The rule label for a completed matching call is covered by the
// single-call test above.
expect(toolResultText(matchedToolCallContent.id)).toBe("");
});
it("relativizes the rule file path in the TTSR interrupt injection (no absolute leak)", async () => {
collapseSchedulerSettleDelays();
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
@@ -158,6 +158,90 @@ describe("AgentSession handoff", () => {
expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0);
});
it("emits handoff lifecycle hooks on the outgoing and replacement sessions", async () => {
const extensionsResult = await loadExtensions([], tempDir.path());
const extensionRunner = new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const observedEvents: Array<{
type: "session_before_switch" | "session_switch";
reason: string;
previousSessionFile: string | undefined;
activeSessionFile: string | undefined;
messageCount: number;
handoffEntryCount: number;
}> = [];
vi.spyOn(extensionRunner, "hasHandlers").mockImplementation(eventName => eventName === "session_before_switch");
const emit = extensionRunner.emit.bind(extensionRunner);
vi.spyOn(extensionRunner, "emit").mockImplementation(event => {
if (event.type === "session_before_switch" || event.type === "session_switch") {
observedEvents.push({
type: event.type,
reason: event.reason,
previousSessionFile: event.type === "session_switch" ? event.previousSessionFile : undefined,
activeSessionFile: session.sessionFile,
messageCount: sessionManager.getBranch().filter(entry => entry.type === "message").length,
handoffEntryCount: sessionManager
.getBranch()
.filter(entry => entry.type === "custom_message" && entry.customType === "handoff").length,
});
}
return emit(event);
});
await session.dispose();
session = new AgentSession({
agent: new Agent({
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
}),
sessionManager,
settings: Settings.isolated({
"compaction.enabled": true,
"compaction.autoContinue": false,
}),
modelRegistry,
extensionRunner,
obfuscator,
});
const previousSessionFile = session.sessionFile;
const generateHandoffSpy = vi
.spyOn(compactionModule, "generateHandoffFromContext")
.mockResolvedValue("## Goal\nContinue from here");
await session.handoff();
const nextSessionFile = session.sessionFile;
expect(generateHandoffSpy).toHaveBeenCalledTimes(1);
expect(nextSessionFile).not.toBe(previousSessionFile);
expect(observedEvents).toEqual([
{
type: "session_before_switch",
reason: "handoff",
previousSessionFile: undefined,
activeSessionFile: previousSessionFile,
messageCount: 2,
handoffEntryCount: 0,
},
{
type: "session_switch",
reason: "handoff",
previousSessionFile,
activeSessionFile: nextSessionFile,
messageCount: 0,
handoffEntryCount: 1,
},
]);
});
it("runs handoff generation through the configured side stream function", async () => {
const handoffText = "## Goal\nContinue via side stream";
let sideStreamCalls = 0;
@@ -1336,6 +1420,7 @@ describe("AgentSession handoff", () => {
expect(handoffSpy).toHaveBeenCalledWith(expect.stringContaining("Threshold-triggered maintenance"), {
autoTriggered: true,
signal: expect.anything(),
onSwitchCancelled: expect.any(Function),
});
expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(1);
const endEvents = events.filter(event => event.type === "auto_compaction_end");
@@ -1598,6 +1683,89 @@ describe("AgentSession handoff", () => {
});
});
it("treats a vetoed auto-handoff switch as cancelled instead of falling back", async () => {
session.settings.set("compaction.strategy", "handoff");
session.settings.set("compaction.thresholdPercent", 1);
session.settings.set("contextPromotion.enabled", false);
const model = session.model;
if (!model) {
throw new Error("Expected model to be set");
}
const extensionsResult = await loadExtensions([], tempDir.path());
const extensionRunner = new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
vi.spyOn(extensionRunner, "hasHandlers").mockImplementation(eventName => eventName === "session_before_switch");
const emitSpy = vi.spyOn(extensionRunner, "emit").mockImplementation((async () => ({
cancel: true,
})) as ExtensionRunner["emit"]);
await session.dispose();
session = new AgentSession({
agent: new Agent({
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
}),
sessionManager,
settings: session.settings,
modelRegistry,
extensionRunner,
obfuscator,
});
session.subscribe(event => {
events.push(event);
});
const previousSessionFile = session.sessionFile;
const generateHandoffSpy = vi
.spyOn(compactionModule, "generateHandoffFromContext")
.mockResolvedValue("## Goal\nContinue from here");
const assistantMessage: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "maintenance trigger" }],
api: model.api,
provider: model.provider,
model: model.id,
stopReason: "stop",
usage: {
input: 10_000,
output: 1_000,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 11_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
};
session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] });
await waitFor(() => events.filter(event => event.type === "auto_compaction_end").length === 1);
expect(generateHandoffSpy).toHaveBeenCalledTimes(1);
expect(emitSpy).toHaveBeenCalledWith({ type: "session_before_switch", reason: "handoff" });
expect(emitSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: "session_switch" }));
expect(session.sessionFile).toBe(previousSessionFile);
expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0);
const endEvents = events.filter(event => event.type === "auto_compaction_end");
expect(endEvents).toHaveLength(1);
expect(endEvents[0]).toMatchObject({
type: "auto_compaction_end",
action: "handoff",
aborted: true,
willRetry: false,
});
});
it("resets to the base system prompt before generating a handoff", async () => {
const model = session.model;
if (!model) {
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
import { Effort } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import * as autoThinkingClassifier from "@oh-my-pi/pi-coding-agent/auto-thinking/classifier";
@@ -13,8 +13,20 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
async function createMagicKeywordSession(root: string): Promise<{
const mockTaskTool: AgentTool = {
name: "task",
label: "Task",
description: "Mock task tool",
parameters: type({}),
execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }),
};
async function createMagicKeywordSession(
root: string,
tools: AgentTool[] = [mockTaskTool],
): Promise<{
session: AgentSession;
settings: Settings;
authStorage: AuthStorage;
@@ -25,7 +37,7 @@ async function createMagicKeywordSession(root: string): Promise<{
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
tools,
messages: [],
thinkingLevel: Effort.High,
},
@@ -103,6 +115,34 @@ describe("AgentSession magic keyword settings", () => {
]);
});
it("renders workflowz notice for the active task schema", async () => {
const created = await createMagicKeywordSession(root);
session = created.session;
authStorage = created.authStorage;
created.settings.set("task.batch", false);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please workflowz this");
const promptMessages = promptSpy.mock.calls[0]![0] as unknown as Array<{ content?: string; customType?: string }>;
const notice = promptMessages.find(message => message.customType === "workflow-notice")?.content ?? "";
expect(notice).toContain("once per independent subagent");
expect(notice).toContain("Do not pass `context` or `tasks[]`");
expect(notice).not.toContain("Call `task` once per independent fan-out batch");
});
it("skips workflowz notice when the task tool is inactive", async () => {
const created = await createMagicKeywordSession(root, []);
session = created.session;
authStorage = created.authStorage;
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please workflowz this");
const promptMessages = promptSpy.mock.calls[0]![0] as unknown as Array<{ customType?: string }>;
expect(promptMessages.map(message => message.customType).filter(Boolean)).toEqual([]);
});
it("does not use a disabled ultrathink keyword to force auto thinking", async () => {
const created = await createMagicKeywordSession(root);
session = created.session;
@@ -282,6 +282,79 @@ describe("AgentSession retry delay cap", () => {
expect(last.content).toContainEqual({ type: "text", text: "recovered after credential switch" });
});
it("switches same-provider credentials before model fallback on ChatGPT usage limits", async () => {
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
const fallbackModel = getBundledModel("openai", "gpt-5.5");
if (!primaryModel || !fallbackModel) {
throw new Error("Expected bundled primary and fallback test models to exist");
}
authStorage.removeRuntimeApiKey("anthropic");
authStorage.setRuntimeApiKey("openai", "openai-fallback-key");
await authStorage.set("anthropic", [
{ type: "api_key", key: "anthropic-key-1" },
{ type: "api_key", key: "anthropic-key-2" },
]);
const usageLimitError = "Error: You have hit your ChatGPT usage limit (k12 plan). Try again in ~231 min.";
const mock = createMockModel();
const requestedModels: string[] = [];
const requestedKeys: string[] = [];
let agent!: Agent;
agent = new Agent({
getApiKey: model => modelRegistry.resolver(model, agent.sessionId),
initialState: {
model: primaryModel,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
streamFn: (requestedModel, context, options) => {
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
const apiKey = resolveInitialApiKey(options?.apiKey);
requestedKeys.push(apiKey);
if (requestedKeys.length === 1) {
mock.push({ throw: usageLimitError });
} else {
mock.push({ content: ["recovered after sibling account"] });
}
return mock.stream(requestedModel, context, options);
},
});
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.baseDelayMs": 5,
"retry.maxDelayMs": 100,
"retry.maxRetries": 1,
"retry.modelFallback": true,
"retry.fallbackChains": {
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
},
});
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
await session.prompt("Trigger k12 usage limit");
await session.waitForIdle();
expect(requestedModels).toEqual([
`${primaryModel.provider}/${primaryModel.id}`,
`${primaryModel.provider}/${primaryModel.id}`,
]);
expect([...requestedKeys].sort()).toEqual(["anthropic-key-1", "anthropic-key-2"]);
const last = lastAssistant(session);
expect(last.stopReason).toBe("stop");
expect(last.content).toContainEqual({ type: "text", text: "recovered after sibling account" });
});
it("waits for the earliest sibling unblock instead of failing the delay cap", async () => {
// Regression: with every sibling credential momentarily blocked (e.g. a
// short post-401 or usage-probe block), a usage-limit 429 with a
@@ -219,6 +219,59 @@ describe("AgentSession retry fallback", () => {
]);
});
it("uses the active initial model as the default fallback primary when other role fallback chains are configured", async () => {
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
const otherRoleFallbackModel = getBundledModel("openai", "gpt-4o");
if (!primaryModel || !fallbackModel || !otherRoleFallbackModel) {
throw new Error("Expected bundled test models to exist");
}
const requestedModels: string[] = [];
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
const agent = createFallbackAgent(primaryModel, requestedModels);
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.maxRetries": 1,
"retry.fallbackChains": {
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
smol: [`${otherRoleFallbackModel.provider}/${otherRoleFallbackModel.id}`],
},
});
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
session.subscribe(event => {
if (event.type === "retry_fallback_applied") {
fallbackAppliedEvents.push(event);
}
});
await session.prompt("Recover using implicit default primary");
await session.waitForIdle();
expect(requestedModels).toEqual([
`${primaryModel.provider}/${primaryModel.id}`,
`${fallbackModel.provider}/${fallbackModel.id}`,
]);
expect(session.model?.provider).toBe(fallbackModel.provider);
expect(session.model?.id).toBe(fallbackModel.id);
expect(fallbackAppliedEvents).toEqual([
{
type: "retry_fallback_applied",
from: `${primaryModel.provider}/${primaryModel.id}`,
to: `${fallbackModel.provider}/${fallbackModel.id}`,
role: "default",
},
]);
});
it("falls back on structured classifier refusals and pins the fallback", async () => {
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, setSystemTime } from "bun:test";
import { afterEach, describe, expect, it } from "bun:test";
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
import type { Model } from "@oh-my-pi/pi-ai";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
@@ -68,6 +68,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
interface NewSessionOptions {
mcpDiscoveryEnabled?: boolean;
getMcpServerInstructions?: () => Map<string, string> | undefined;
getLocalCalendarDate?: () => string;
}
function newSession(
@@ -101,6 +102,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
}),
mcpDiscoveryEnabled: options.mcpDiscoveryEnabled,
getMcpServerInstructions: options.getMcpServerInstructions,
getLocalCalendarDate: options.getLocalCalendarDate,
});
sessions.push(session);
return { session };
@@ -176,6 +178,52 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
expect(rebuildCount).toBe(baseline + 2);
});
it("updates live active-tool predicates before rebuilding the prompt", async () => {
const activeToolNames = new Set(["read", "bash", "grep"]);
const readTool = createBasicTool("read", "Read");
const bashTool = createBasicTool("bash", "Bash");
const grepTool = createBasicTool("grep", "Grep");
Object.defineProperty(bashTool, "description", {
get: () => (activeToolNames.has("grep") ? "bash sees grep" : "bash hides grep"),
enumerable: true,
configurable: true,
});
const toolRegistry = new Map<string, AgentTool>([
[readTool.name, readTool],
[bashTool.name, bashTool],
[grepTool.name, grepTool],
]);
const agent = new Agent({
initialState: {
model: createModel(),
systemPrompt: ["initial"],
tools: [readTool, bashTool, grepTool],
messages: [],
},
});
const session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "compaction.enabled": false }),
modelRegistry: {} as never,
toolRegistry,
setActiveToolNames: names => {
activeToolNames.clear();
for (const name of names) {
activeToolNames.add(name);
}
},
rebuildSystemPrompt: async (_toolNames, tools) => ({
systemPrompt: [tools.get("bash")?.description ?? "missing bash"],
}),
});
sessions.push(session);
await session.setActiveToolsByName(["read", "bash"]);
expect(agent.state.systemPrompt).toEqual(["bash hides grep"]);
});
it("does not skip when refreshBaseSystemPrompt is called explicitly", async () => {
let rebuildCount = 0;
const { session } = newSession(async toolNames => {
@@ -388,40 +436,38 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
await session.refreshMCPTools([dynamicTool]);
expect(rebuildCount).toBe(baseline + 1);
});
it("rebuilds when the calendar date rolls over between tool-stable MCP refreshes", async () => {
// `buildSystemPrompt` injects today's date into the prompt body.
// A session spanning midnight must not serve yesterday's date after an MCP
// reconnect that happens to bring an identical tool set.
setSystemTime(new Date("2025-01-01T23:59:58Z"));
try {
let rebuildCount = 0;
const { session } = newSession(async toolNames => {
it("rebuilds when the local calendar date rolls over between tool-stable MCP refreshes", async () => {
// `buildSystemPrompt` injects today's local date into the prompt body. The
// signature reads the same date provider so a session spanning local midnight
// must rebuild after an MCP reconnect with an otherwise identical tool set.
let currentDate = "2026-06-30";
let rebuildCount = 0;
const { session } = newSession(
async toolNames => {
rebuildCount++;
return `tools:${toolNames.join(",")}`;
});
const tool = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search");
},
{ getLocalCalendarDate: () => currentDate },
);
const tool = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search");
// First refresh: no signature yet, must rebuild.
await session.refreshMCPTools([tool]);
expect(rebuildCount).toBe(1);
// First refresh: no signature yet, must rebuild.
await session.refreshMCPTools([tool]);
expect(rebuildCount).toBe(1);
// Same tools, same day: signature matches, skip.
await session.refreshMCPTools([tool]);
expect(rebuildCount).toBe(1);
// Same tools, same local day: signature matches, skip.
await session.refreshMCPTools([tool]);
expect(rebuildCount).toBe(1);
// Advance past midnight.
setSystemTime(new Date("2025-01-02T00:00:01Z"));
currentDate = "2026-07-01";
// Same tools, new calendar day: date segment changed, must rebuild.
await session.refreshMCPTools([tool]);
expect(rebuildCount).toBe(2);
// Same tools, new local calendar day: date segment changed, must rebuild.
await session.refreshMCPTools([tool]);
expect(rebuildCount).toBe(2);
// Same tools, same new day: skip again.
await session.refreshMCPTools([tool]);
expect(rebuildCount).toBe(2);
} finally {
setSystemTime(); // restore real time
}
// Same tools, same new local day: skip again.
await session.refreshMCPTools([tool]);
expect(rebuildCount).toBe(2);
});
it("does not rebuild when MCP server instructions change only beyond the 4000-char truncation boundary", async () => {
// `rebuildSystemPrompt` (sdk.ts) truncates each server instruction to 4000 chars
@@ -95,4 +95,54 @@ describe("AuthStorage account rotation", () => {
const exhaustedFallbackKey = await authStorage.getApiKey("openai-codex", sessionId);
expect(exhaustedFallbackKey).toMatch(/^api-acct-/);
});
test("usage-limit rotation can match the failed bearer when session stickiness is missing", async () => {
await authStorage.set("openai-codex", [
{
type: "oauth",
access: "access-1",
refresh: "refresh-1",
expires: Date.now() + 60_000,
accountId: "acct-1",
},
{
type: "oauth",
access: "access-2",
refresh: "refresh-2",
expires: Date.now() + 60_000,
accountId: "acct-2",
},
]);
const sessionId = "missing-sticky-session";
const result = await authStorage.markUsageLimitReached("openai-codex", sessionId, { apiKey: "access-1" });
expect(result.switched).toBe(true);
expect(await authStorage.getApiKey("openai-codex", sessionId)).toBe("api-acct-2");
});
test("usage-limit rotation trusts the failed bearer over stale session stickiness", async () => {
await authStorage.set("openai-codex", [
{
type: "oauth",
access: "plus-access",
refresh: "plus-refresh",
expires: Date.now() + 60_000,
accountId: "plus-acct",
},
{
type: "oauth",
access: "k12-access",
refresh: "k12-refresh",
expires: Date.now() + 60_000,
accountId: "k12-acct",
},
]);
const sessionId = "stale-sticky-session";
const stickyKey = await authStorage.getApiKey("openai-codex", sessionId);
const failedKey = stickyKey === "api-plus-acct" ? "k12-access" : "plus-access";
const result = await authStorage.markUsageLimitReached("openai-codex", sessionId, { apiKey: failedKey });
expect(result.switched).toBe(true);
expect(await authStorage.getApiKey("openai-codex", sessionId)).toBe(stickyKey);
});
});
@@ -402,6 +402,15 @@ exit 64
expect(result.output).not.toContain("done");
});
it("does not arm a deadline when timeout is zero", async () => {
if (process.platform === "win32") {
return;
}
const result = await executeBash("sleep 1.2; echo done", { cwd: tempDir, timeout: 0 });
expect(result.cancelled).toBe(false);
expect(result.output.trim()).toBe("done");
});
it("aborts commands", async () => {
if (process.platform === "win32") {
return;
@@ -1,11 +1,9 @@
/**
* Regression test for #1266:
* Regression tests for top-level `RULES.md` sticky rules.
*
* `RULES.md` (singular, top-level) MUST be loaded as a sticky always-apply rule
* from both `~/.omp/agent/RULES.md` (user) and the nearest `.omp/RULES.md`
* (project, walked up from cwd to repoRoot).
*
* Calls the native provider's `load` directly with the agent dir pointed at a
* tempdir (via setAgentDir) so the user scope can be staged in isolation.
*/
import { afterEach, beforeEach, expect, test } from "bun:test";
import * as fs from "node:fs";
@@ -15,8 +13,8 @@ import { getCapability } from "@oh-my-pi/pi-coding-agent/capability";
import { clearCache } from "@oh-my-pi/pi-coding-agent/capability/fs";
import { type Rule, ruleCapability } from "@oh-my-pi/pi-coding-agent/capability/rule";
import type { LoadContext } from "@oh-my-pi/pi-coding-agent/capability/types";
// Register all discovery providers as a side effect.
import "@oh-my-pi/pi-coding-agent/discovery";
// Importing discovery registers all providers as a side effect.
import { loadCapability } from "@oh-my-pi/pi-coding-agent/discovery";
import { getConfigRootDir, removeSyncWithRetries, setAgentDir } from "@oh-my-pi/pi-utils";
let tempDir: string;
@@ -40,6 +38,11 @@ async function loadNativeRules(ctx: LoadContext): Promise<Rule[]> {
return result.items;
}
async function loadRulesCapability(cwd: string): Promise<Rule[]> {
const result = await loadCapability<Rule>(ruleCapability.id, { cwd, providers: ["native"] });
return result.items;
}
beforeEach(() => {
clearCache();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-rules-md-"));
@@ -81,7 +84,7 @@ test("project .omp/RULES.md becomes an alwaysApply rule", async () => {
const rules = await loadNativeRules({ cwd: project, home, repoRoot: project });
const projectRule = rules.find(r => r._source.level === "project" && r.name === "RULES");
const projectRule = rules.find(r => r._source.level === "project" && r.name === "RULES@project");
expect(projectRule).toBeDefined();
expect(projectRule?.alwaysApply).toBe(true);
expect(projectRule?.content).toContain("Always say hi.");
@@ -94,12 +97,48 @@ test("project RULES.md is found walking up from a sub-package cwd", async () =>
const rules = await loadNativeRules({ cwd: subPkg, home, repoRoot: project });
const projectRule = rules.find(r => r._source.level === "project" && r.name === "RULES");
const projectRule = rules.find(r => r._source.level === "project" && r.name === "RULES@project");
expect(projectRule).toBeDefined();
expect(projectRule?.alwaysApply).toBe(true);
expect(projectRule?.path).toBe(path.join(project, ".omp", "RULES.md"));
});
test("user and project sticky RULES.md both survive public capability dedup", async () => {
const userRulesPath = path.join(home, ".omp", "agent", "RULES.md");
const projectRulesPath = path.join(project, ".omp", "RULES.md");
const userRuleText = "User sticky rule: keep the personal safety checklist active.\n";
const projectRuleText = "Project sticky rule: require repo-local release notes.\n";
writeFile(userRulesPath, userRuleText);
writeFile(projectRulesPath, projectRuleText);
const rules = await loadRulesCapability(project);
const stickyRules = rules.filter(rule => rule.path === userRulesPath || rule.path === projectRulesPath);
expect(stickyRules).toHaveLength(2);
const userRule = stickyRules.find(rule => rule._source.level === "user");
const projectRule = stickyRules.find(rule => rule._source.level === "project");
if (!userRule) throw new Error("user sticky rule missing");
expect(userRule.name).toBe("RULES");
expect(userRule.path).toBe(userRulesPath);
expect(userRule._source.path).toBe(userRulesPath);
expect(userRule.alwaysApply).toBe(true);
expect(userRule.content).toContain(userRuleText.trim());
expect("_shadowed" in userRule).toBe(false);
if (!projectRule) throw new Error("project sticky rule missing");
expect(projectRule.name).toBe("RULES@project");
expect(projectRule.path).toBe(projectRulesPath);
expect(projectRule._source.path).toBe(projectRulesPath);
expect(projectRule.alwaysApply).toBe(true);
expect(projectRule.content).toContain(projectRuleText.trim());
expect("_shadowed" in projectRule).toBe(false);
expect(userRule.name).not.toBe(projectRule.name);
expect(userRule.content).not.toBe(projectRule.content);
});
test("alwaysApply is forced even when frontmatter says false", async () => {
writeFile(path.join(home, ".omp", "agent", "RULES.md"), "---\nalwaysApply: false\n---\nStick around anyway.\n");
@@ -645,6 +645,354 @@ describe("listClaudePluginRoots", () => {
expect(found).toBeUndefined();
});
test("reads slash commands from array-form commands manifest field (Claude plugin path-behavior rules)", async () => {
// Mirrors real-world plugins such as addyosmani/agent-skills whose plugin.json
// declares `"commands": ["./.claude/commands", "./commands"]`. Both directories
// contribute; each command lands under the plugin's namespace.
const pluginsDir = path.join(tempDir, ".claude", "plugins");
const pluginPath = path.join(tempDir, "plugins", "manifest-commands-array");
await fs.mkdir(pluginsDir, { recursive: true });
await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginPath, ".claude", "commands"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "commands"), { recursive: true });
const registry = {
version: 2,
plugins: {
"manifest-commands-array@market": [
{
scope: "user",
installPath: pluginPath,
version: "1.0.0",
installedAt: "2025-01-01T00:00:00Z",
lastUpdated: "2025-01-01T00:00:00Z",
},
],
},
};
await fs.writeFile(path.join(pluginsDir, "installed_plugins.json"), JSON.stringify(registry));
await fs.writeFile(
path.join(pluginPath, ".claude-plugin", "plugin.json"),
JSON.stringify({ commands: ["./.claude/commands", "./commands"] }),
);
await fs.writeFile(path.join(pluginPath, ".claude", "commands", "spec.md"), "Spec\n");
await fs.writeFile(path.join(pluginPath, ".claude", "commands", "plan.md"), "Plan\n");
await fs.writeFile(path.join(pluginPath, "commands", "review.md"), "Review\n");
const result = await loadCapability<SlashCommand>("slash-commands", { cwd: tempDir });
expect(result.warnings).toEqual([]);
const names = result.all
.filter(command => command.name.startsWith("manifest-commands-array:"))
.map(command => command.name)
.sort();
expect(names).toEqual([
"manifest-commands-array:plan",
"manifest-commands-array:review",
"manifest-commands-array:spec",
]);
});
test("reads slash commands from array-form manifest file entries", async () => {
// Claude plugins reference allows command paths to be either flat `.md`
// files or directories. A manifest-declared commands field still replaces
// default `commands/`; plugins that want defaults must list `./commands`.
const pluginsDir = path.join(tempDir, ".claude", "plugins");
const pluginPath = path.join(tempDir, "plugins", "manifest-commands-files");
await fs.mkdir(pluginsDir, { recursive: true });
await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "custom"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "ops"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "commands"), { recursive: true });
const registry = {
version: 2,
plugins: {
"manifest-commands-files@market": [
{
scope: "user",
installPath: pluginPath,
version: "1.0.0",
installedAt: "2025-01-01T00:00:00Z",
lastUpdated: "2025-01-01T00:00:00Z",
},
],
},
};
await fs.writeFile(path.join(pluginsDir, "installed_plugins.json"), JSON.stringify(registry));
await fs.writeFile(
path.join(pluginPath, ".claude-plugin", "plugin.json"),
JSON.stringify({ commands: ["./custom/deploy.md", "./ops"] }),
);
await fs.writeFile(path.join(pluginPath, "custom", "deploy.md"), "Deploy\n");
await fs.writeFile(path.join(pluginPath, "ops", "rollback.md"), "Rollback\n");
await fs.writeFile(path.join(pluginPath, "commands", "default.md"), "Default\n");
const result = await loadCapability<SlashCommand>("slash-commands", { cwd: tempDir });
expect(result.warnings).toEqual([]);
expect(result.all.find(c => c.name === "manifest-commands-files:deploy")?.content).toBe("Deploy\n");
expect(result.all.find(c => c.name === "manifest-commands-files:rollback")?.content).toBe("Rollback\n");
expect(result.all.find(c => c.name === "manifest-commands-files:default")).toBeUndefined();
});
test("array-form commands warns on out-of-root entries while loading valid ones", async () => {
const pluginsDir = path.join(tempDir, ".claude", "plugins");
const pluginPath = path.join(tempDir, "plugins", "manifest-commands-mixed");
const outsideDir = path.join(tempDir, "outside-commands");
await fs.mkdir(pluginsDir, { recursive: true });
await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginPath, ".claude", "commands"), { recursive: true });
await fs.mkdir(outsideDir, { recursive: true });
const registry = {
version: 2,
plugins: {
"manifest-commands-mixed@market": [
{
scope: "user",
installPath: pluginPath,
version: "1.0.0",
installedAt: "2025-01-01T00:00:00Z",
lastUpdated: "2025-01-01T00:00:00Z",
},
],
},
};
await fs.writeFile(path.join(pluginsDir, "installed_plugins.json"), JSON.stringify(registry));
await fs.writeFile(
path.join(pluginPath, ".claude-plugin", "plugin.json"),
JSON.stringify({ commands: ["./.claude/commands", "../../outside-commands"] }),
);
await fs.writeFile(path.join(pluginPath, ".claude", "commands", "spec.md"), "Spec\n");
await fs.writeFile(path.join(outsideDir, "escape.md"), "Escape\n");
const result = await loadCapability<SlashCommand>("slash-commands", { cwd: tempDir });
expect(result.warnings.some(w => w.includes("Ignoring commands path outside plugin root"))).toBe(true);
expect(result.all.find(c => c.name === "manifest-commands-mixed:spec")).toBeDefined();
expect(result.all.find(c => c.name === "manifest-commands-mixed:escape")).toBeUndefined();
});
test("reads skills from array-form skills manifest field", async () => {
const pluginsDir = path.join(tempDir, ".claude", "plugins");
const pluginPath = path.join(tempDir, "plugins", "manifest-skills-array");
await fs.mkdir(pluginsDir, { recursive: true });
await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "extra-skills", "alpha"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "more-skills", "beta"), { recursive: true });
const registry = {
version: 2,
plugins: {
"manifest-skills-array@market": [
{
scope: "user",
installPath: pluginPath,
version: "1.0.0",
installedAt: "2025-01-01T00:00:00Z",
lastUpdated: "2025-01-01T00:00:00Z",
},
],
},
};
await fs.writeFile(path.join(pluginsDir, "installed_plugins.json"), JSON.stringify(registry));
await fs.writeFile(
path.join(pluginPath, ".claude-plugin", "plugin.json"),
JSON.stringify({ skills: ["./extra-skills", "./more-skills"] }),
);
await fs.writeFile(
path.join(pluginPath, "extra-skills", "alpha", "SKILL.md"),
"---\nname: alpha\ndescription: Alpha skill\n---\nBody\n",
);
await fs.writeFile(
path.join(pluginPath, "more-skills", "beta", "SKILL.md"),
"---\nname: beta\ndescription: Beta skill\n---\nBody\n",
);
const result = await loadCapability<Skill>("skills", { cwd: tempDir });
expect(result.warnings).toEqual([]);
expect(result.all.find(s => s.name === "alpha")).toBeDefined();
expect(result.all.find(s => s.name === "beta")).toBeDefined();
});
test("manifest skills field merges with default skills/ directory (adds, not replaces)", async () => {
// Per Claude plugins reference "Path behavior rules":
// `skills` adds to the default `skills/` scan; the default is always loaded
// alongside any manifest-declared directories.
const pluginsDir = path.join(tempDir, ".claude", "plugins");
const pluginPath = path.join(tempDir, "plugins", "manifest-skills-merge");
await fs.mkdir(pluginsDir, { recursive: true });
await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "skills", "default-skill"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "extra-skills", "extra-skill"), { recursive: true });
const registry = {
version: 2,
plugins: {
"manifest-skills-merge@market": [
{
scope: "user",
installPath: pluginPath,
version: "1.0.0",
installedAt: "2025-01-01T00:00:00Z",
lastUpdated: "2025-01-01T00:00:00Z",
},
],
},
};
await fs.writeFile(path.join(pluginsDir, "installed_plugins.json"), JSON.stringify(registry));
await fs.writeFile(
path.join(pluginPath, ".claude-plugin", "plugin.json"),
JSON.stringify({ skills: ["./extra-skills"] }),
);
await fs.writeFile(
path.join(pluginPath, "skills", "default-skill", "SKILL.md"),
"---\nname: default-skill\ndescription: Default skill\n---\nBody\n",
);
await fs.writeFile(
path.join(pluginPath, "extra-skills", "extra-skill", "SKILL.md"),
"---\nname: extra-skill\ndescription: Extra skill\n---\nBody\n",
);
const result = await loadCapability<Skill>("skills", { cwd: tempDir });
expect(result.warnings).toEqual([]);
expect(result.all.find(s => s.name === "default-skill")).toBeDefined();
expect(result.all.find(s => s.name === "extra-skill")).toBeDefined();
});
test("marketplace-root skills manifest field replaces default skills directory", async () => {
// Claude path-behavior rules carve out marketplace entries whose source is the
// marketplace root: their manifest `skills` field selects the published
// subdirectories instead of also loading the root `skills/` directory.
const pluginsDir = path.join(tempDir, ".claude", "plugins");
const pluginPath = path.join(tempDir, "plugins", "manifest-skills-marketplace-root");
await fs.mkdir(pluginsDir, { recursive: true });
await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "skills", "unpublished-root-skill"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "plugins", "published", "skills", "published-skill"), {
recursive: true,
});
const registry = {
version: 2,
plugins: {
"manifest-skills-marketplace-root@market": [
{
scope: "user",
installPath: pluginPath,
version: "1.0.0",
installedAt: "2025-01-01T00:00:00Z",
lastUpdated: "2025-01-01T00:00:00Z",
},
],
},
};
await fs.writeFile(path.join(pluginsDir, "installed_plugins.json"), JSON.stringify(registry));
await fs.writeFile(
path.join(pluginPath, "marketplace.json"),
JSON.stringify({
name: "market",
owner: { name: "Market" },
plugins: [{ name: "manifest-skills-marketplace-root", source: "./" }],
}),
);
await fs.writeFile(
path.join(pluginPath, ".claude-plugin", "plugin.json"),
JSON.stringify({ skills: ["./plugins/published/skills"] }),
);
await fs.writeFile(
path.join(pluginPath, "skills", "unpublished-root-skill", "SKILL.md"),
"---\nname: unpublished-root-skill\ndescription: Unpublished root skill\n---\nBody\n",
);
await fs.writeFile(
path.join(pluginPath, "plugins", "published", "skills", "published-skill", "SKILL.md"),
"---\nname: published-skill\ndescription: Published skill\n---\nBody\n",
);
const result = await loadCapability<Skill>("skills", { cwd: tempDir });
expect(result.warnings).toEqual([]);
expect(result.all.find(s => s.name === "published-skill")).toBeDefined();
expect(result.all.find(s => s.name === "unpublished-root-skill")).toBeUndefined();
});
test("array-form skills entry pointing at a directory containing SKILL.md loads the single skill", async () => {
// Per Claude plugins reference: a skills path may point directly at a directory whose
// SKILL.md is the skill (frontmatter name → invocation, directory basename → fallback).
// Real plugins use `"skills": ["./"]` — that entry must not silently drop the skill.
const pluginsDir = path.join(tempDir, ".claude", "plugins");
const pluginPath = path.join(tempDir, "plugins", "manifest-skills-self");
await fs.mkdir(pluginsDir, { recursive: true });
await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "single"), { recursive: true });
const registry = {
version: 2,
plugins: {
"manifest-skills-self@market": [
{
scope: "user",
installPath: pluginPath,
version: "1.0.0",
installedAt: "2025-01-01T00:00:00Z",
lastUpdated: "2025-01-01T00:00:00Z",
},
],
},
};
await fs.writeFile(path.join(pluginsDir, "installed_plugins.json"), JSON.stringify(registry));
await fs.writeFile(
path.join(pluginPath, ".claude-plugin", "plugin.json"),
JSON.stringify({ skills: ["./single"] }),
);
await fs.writeFile(
path.join(pluginPath, "single", "SKILL.md"),
"---\nname: solo-skill\ndescription: Solo skill\n---\nBody\n",
);
const result = await loadCapability<Skill>("skills", { cwd: tempDir });
expect(result.warnings).toEqual([]);
expect(result.all.find(s => s.name === "solo-skill")).toBeDefined();
});
test("manifest commands field replaces default commands/ directory (Claude replace semantics)", async () => {
// Per Claude plugins reference "Path behavior rules":
// `commands` REPLACES the default `commands/` scan when the manifest key is set.
// A plugin that wants both must list `./commands` explicitly.
const pluginsDir = path.join(tempDir, ".claude", "plugins");
const pluginPath = path.join(tempDir, "plugins", "manifest-commands-replace");
await fs.mkdir(pluginsDir, { recursive: true });
await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "commands"), { recursive: true });
await fs.mkdir(path.join(pluginPath, "admin-commands"), { recursive: true });
const registry = {
version: 2,
plugins: {
"manifest-commands-replace@market": [
{
scope: "user",
installPath: pluginPath,
version: "1.0.0",
installedAt: "2025-01-01T00:00:00Z",
lastUpdated: "2025-01-01T00:00:00Z",
},
],
},
};
await fs.writeFile(path.join(pluginsDir, "installed_plugins.json"), JSON.stringify(registry));
await fs.writeFile(
path.join(pluginPath, ".claude-plugin", "plugin.json"),
JSON.stringify({ commands: ["./admin-commands"] }),
);
// This file lives under the default commands/ dir and MUST NOT load once the
// manifest declares `commands` (Claude's documented "replaces default" semantic).
await fs.writeFile(path.join(pluginPath, "commands", "default.md"), "Default\n");
await fs.writeFile(path.join(pluginPath, "admin-commands", "admin.md"), "Admin\n");
const result = await loadCapability<SlashCommand>("slash-commands", { cwd: tempDir });
expect(result.warnings).toEqual([]);
expect(result.all.find(c => c.name === "manifest-commands-replace:admin")).toBeDefined();
expect(result.all.find(c => c.name === "manifest-commands-replace:default")).toBeUndefined();
});
});
describe("discoverAgents plugin precedence", () => {
@@ -0,0 +1,114 @@
import { describe, expect, it } from "bun:test";
import { WorkerCore } from "@oh-my-pi/pi-coding-agent/eval/js/worker-core";
import type {
SessionSnapshot,
Transport,
WorkerInbound,
WorkerOutbound,
} from "@oh-my-pi/pi-coding-agent/eval/js/worker-protocol";
interface WorkerHarness {
send(message: WorkerInbound): void;
onMessage(handler: (message: WorkerOutbound) => void): () => void;
}
function createWorkerHarness(): WorkerHarness {
const hostListeners = new Set<(message: WorkerOutbound) => void>();
const workerListeners = new Set<(message: WorkerInbound) => void>();
const transport: Transport = {
send: message => {
queueMicrotask(() => {
for (const listener of hostListeners) listener(message);
});
},
onMessage: handler => {
workerListeners.add(handler);
return () => workerListeners.delete(handler);
},
close: () => {},
};
new WorkerCore(transport);
return {
send(message) {
queueMicrotask(() => {
for (const listener of workerListeners) listener(message);
});
},
onMessage(handler) {
hostListeners.add(handler);
return () => hostListeners.delete(handler);
},
};
}
function waitForMessage(
harness: WorkerHarness,
predicate: (message: WorkerOutbound) => boolean,
): Promise<WorkerOutbound> {
const { promise, resolve } = Promise.withResolvers<WorkerOutbound>();
let unsubscribe = (): void => {};
unsubscribe = harness.onMessage(message => {
if (!predicate(message)) return;
unsubscribe();
resolve(message);
});
return promise;
}
async function initializeWorker(harness: WorkerHarness, snapshot: SessionSnapshot): Promise<void> {
const ready = waitForMessage(harness, message => message.type === "ready");
harness.send({ type: "init", snapshot });
expect((await ready).type).toBe("ready");
}
describe("WorkerCore", () => {
it("reports same-realm cwd conflicts through the worker protocol", async () => {
const first = createWorkerHarness();
const second = createWorkerHarness();
const cwd = process.cwd();
await initializeWorker(first, { cwd, sessionId: "same-realm-first", localRoots: {} });
await initializeWorker(second, { cwd, sessionId: "same-realm-second", localRoots: {} });
const gate = Promise.withResolvers<void>();
const entered = Promise.withResolvers<void>();
(globalThis as { __omp_worker_core_gate?: { entered(): void; wait: Promise<void> } }).__omp_worker_core_gate = {
entered: () => entered.resolve(),
wait: gate.promise,
};
try {
first.send({
type: "run",
runId: "hold-first-runtime",
code: "globalThis.__omp_worker_core_gate.entered(); await globalThis.__omp_worker_core_gate.wait;",
filename: "[same-realm-first].js",
snapshot: { cwd, sessionId: "same-realm-first", localRoots: {} },
});
await entered.promise;
const result = waitForMessage(
second,
message => message.type === "result" && message.runId === "overlap-second-runtime",
);
second.send({
type: "run",
runId: "overlap-second-runtime",
code: "1 + 1;",
filename: "[same-realm-second].js",
snapshot: { cwd, sessionId: "same-realm-second", localRoots: {} },
});
expect(await result).toMatchObject({
type: "result",
runId: "overlap-second-runtime",
ok: false,
error: { message: "Cannot set cwd while another same-realm JS runtime is running" },
});
} finally {
gate.resolve();
delete (globalThis as { __omp_worker_core_gate?: { entered(): void; wait: Promise<void> } })
.__omp_worker_core_gate;
first.send({ type: "close" });
second.send({ type: "close" });
}
});
});
@@ -308,6 +308,22 @@ describe("legacy-pi in-place module loading (issue #1674)", () => {
expect(mod.css).toBe(".x{color:red}");
});
it("leaves JSON import-attribute targets on Bun's native loader", async () => {
const dir = await writePackage({
"package.json": JSON.stringify({ name: "json-import-ext", version: "1.0.0" }),
"prices.json": JSON.stringify({ input: 0.15 }),
"index.ts": [
'import prices from "./prices.json" with { type: "json" };',
"export const inputPrice = prices.input;",
"export default function (pi) { void pi; }",
].join("\n"),
});
const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { inputPrice: number };
expect(mod.inputPrice).toBe(0.15);
});
it("loads the extension's own node_modules deps natively while remapping legacy pi imports", async () => {
const dir = await writePackage({
"package.json": JSON.stringify({ name: "dep-ext", version: "1.0.0" }),
@@ -251,6 +251,23 @@ describe("InputController keybinding setup", () => {
expect(spies.resetDisplay).toHaveBeenCalledTimes(1);
});
it("does not mark pasted shell prompts as Python mode while editing", async () => {
const { InputController, ctx, editor } = await createContext();
const controller = new InputController(ctx);
controller.setupKeyHandlers();
editor.onChange?.("$ cd ~/project && sudo ./build-and-push.sh o5.7 2>&1 | tail -4");
expect(ctx.isPythonMode).toBe(false);
expect(ctx.updateEditorBorderColor).not.toHaveBeenCalled();
editor.onChange?.("$ print(1)");
expect(ctx.isPythonMode).toBe(true);
expect(ctx.updateEditorBorderColor).toHaveBeenCalledTimes(1);
});
it("registers retry as an editor action and retries the failed turn", async () => {
const { InputController, ctx, editor, spies } = await createContext();
const controller = new InputController(ctx);
@@ -109,6 +109,36 @@ describe("InputController Python prompt prefix", () => {
]);
});
it("submits pasted shell-prompt transcripts with OMP chrome as a normal prompt", async () => {
const transcript =
"$ cd ~/project && sudo ./build-and-push.sh o5.7 2>&1 | tail -4\n" +
" |\n" +
" in: 282 out: 152 cache 344K t: 3.3s tok/s: 351.9/s\n" +
" is this command stuck in limbo";
const { ctx, editor, handlePythonCommand, onInputCallback, startPendingSubmission, submitted } = createContext();
const controller = new InputController(ctx);
controller.setupEditorSubmitHandler();
await editor.onSubmit?.(transcript);
expect(handlePythonCommand).not.toHaveBeenCalled();
expect(startPendingSubmission).toHaveBeenCalledWith({
text: transcript,
images: undefined,
imageLinks: undefined,
streamingBehavior: "steer",
});
expect(onInputCallback).toHaveBeenCalledTimes(1);
expect(submitted).toEqual([
{
text: transcript,
images: undefined,
imageLinks: undefined,
streamingBehavior: "steer",
},
]);
});
it("keeps space-separated Python shortcuts available", async () => {
const { ctx, editor, handlePythonCommand, onInputCallback } = createContext();
const controller = new InputController(ctx);
@@ -415,6 +415,61 @@ describe("InteractiveMode plan review rendering", () => {
expect(await Bun.file(resolvedPlanPath).text()).toContain("edited body");
});
it("carries pre-approval local artifacts into the fresh approve-and-execute session", async () => {
const planFilePath = "local://handoff-plan.md";
const localOptions = {
getArtifactsDir: () => session.sessionManager.getArtifactsDir(),
getSessionId: () => session.sessionManager.getSessionId(),
};
const oldLocalRoot = resolveLocalUrlToPath("local://", localOptions);
const oldPlanPath = resolveLocalUrlToPath(planFilePath, localOptions);
const oldArtifactPath = resolveLocalUrlToPath("local://handoff/nested/context.txt", localOptions);
await fs.mkdir(path.dirname(oldArtifactPath), { recursive: true });
await Bun.write(oldArtifactPath, "pre-approval handoff");
await Bun.write(oldPlanPath, "# Plan\n\noriginal body\n");
mode.planModeEnabled = true;
mode.planModePlanFilePath = planFilePath;
const planContent = "# Plan\n\nfinal approved body\n";
vi.spyOn(mode, "showPlanReview").mockImplementation(async (_plan, _title, _options, dialogOptions) => {
dialogOptions?.onPlanEdited?.(planContent);
return "Approve and execute";
});
vi.spyOn(mode, "handleClearCommand").mockImplementation(async () => {
await session.sessionManager.newSession();
});
let artifactAtPrompt = "";
let planAtPrompt = "";
const prompt = vi.spyOn(session, "prompt").mockImplementation(async () => {
const promptArtifactPath = resolveLocalUrlToPath("local://handoff/nested/context.txt", localOptions);
const promptPlanPath = resolveLocalUrlToPath(planFilePath, localOptions);
artifactAtPrompt = (await Bun.file(promptArtifactPath).exists())
? await Bun.file(promptArtifactPath).text()
: "<missing>";
planAtPrompt = (await Bun.file(promptPlanPath).exists()) ? await Bun.file(promptPlanPath).text() : "<missing>";
return undefined as never;
});
expect(await Bun.file(oldArtifactPath).text()).toBe("pre-approval handoff");
await mode.handlePlanApproval({
planFilePath,
planExists: true,
title: "HANDOFF",
});
const newLocalRoot = resolveLocalUrlToPath("local://", localOptions);
const newArtifactPath = resolveLocalUrlToPath("local://handoff/nested/context.txt", localOptions);
const newPlanPath = resolveLocalUrlToPath(planFilePath, localOptions);
expect(newLocalRoot).not.toBe(oldLocalRoot);
expect(await Bun.file(newArtifactPath).text()).toBe("pre-approval handoff");
expect(await Bun.file(newPlanPath).text()).toBe(planContent);
expect(artifactAtPrompt).toBe("pre-approval handoff");
expect(planAtPrompt).toBe(planContent);
expect(await Bun.file(oldArtifactPath).text()).toBe("pre-approval handoff");
expect(prompt).toHaveBeenCalledWith(expect.any(String), { synthetic: true });
});
it("offers approve-and-keep-context as a distinct plan approval path", async () => {
const planFilePath = "local://PLAN.md";
const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, {
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -7,6 +7,7 @@ import {
fetchMarketplace,
parseMarketplaceCatalog,
} from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace";
import * as git from "@oh-my-pi/pi-coding-agent/utils/git";
import { removeSyncWithRetries } from "@oh-my-pi/pi-utils";
// Fixture lives at test/marketplace/fixtures/valid-marketplace/
@@ -231,6 +232,24 @@ describe("fetchMarketplace", () => {
);
});
it("hides temp clone paths in cloned catalog validation errors", async () => {
const cloneSpy = spyOn(git, "clone").mockImplementation(async (_url, targetDir) => {
fs.mkdirSync(path.join(targetDir, ".claude-plugin"), { recursive: true });
fs.writeFileSync(
path.join(targetDir, ".claude-plugin", "marketplace.json"),
JSON.stringify({ name: "broken-marketplace", plugins: [] }),
);
});
try {
await expect(fetchMarketplace("kubeshark/kubeshark", tmpDir)).rejects.toThrow(
'Cloned repository https://github.com/kubeshark/kubeshark.git: Missing or invalid field "owner" in catalog: .claude-plugin/marketplace.json (source: kubeshark/kubeshark)',
);
} finally {
cloneSpy.mockRestore();
}
});
// Network-dependent tests — skip in CI / offline environments.
// These verify real git clone and HTTP fetch error handling.
it.skip("github source throws on nonexistent repo", async () => {
@@ -14,6 +14,35 @@ function normalizeRenderedText(text: string): string {
return stripVTControlCharacters(text).replace(/\s+/g, " ").trim();
}
const DEFAULT_RETRY_FALLBACK_ACTION_LABEL = "Set as DEFAULT retry fallback";
const DEFAULT_RETRY_FALLBACK_ACTION = "retryFallback";
type ModelSelectorAction = "modelRole" | typeof DEFAULT_RETRY_FALLBACK_ACTION;
type TestRoleSelectArgs = [
model: Model,
role: string | null,
thinkingLevel?: ConfiguredThinkingLevel,
selector?: string,
action?: ModelSelectorAction,
];
type TestRoleSelectCallback = (...args: TestRoleSelectArgs) => void;
function isSelectedMenuLine(line: string): boolean {
const trimmed = line.trimStart();
return trimmed.startsWith("❯") || trimmed.startsWith("▸") || trimmed.startsWith(">") || trimmed.startsWith("\uf054");
}
function selectMenuAction(selector: ModelSelectorComponent, label: string): void {
for (let attempt = 0; attempt < 20; attempt++) {
const selectedTarget = stripVTControlCharacters(selector.render(220).join("\n"))
.split("\n")
.find(line => line.includes(label) && isSelectedMenuLine(line));
if (selectedTarget) return;
selector.handleInput("\x1b[B");
}
throw new Error(`Menu action not selectable: ${label}`);
}
function createSelector(model: Model, settings: Settings): ModelSelectorComponent {
const modelRegistry = {
getAll: () => [model],
@@ -66,7 +95,7 @@ function createContextTestModel(id: string, contextWindow: number): Model {
function createScopedSelector(
models: Model[],
settings: Settings,
onSelect: (model: Model, role: string | null, thinkingLevel?: ConfiguredThinkingLevel, selector?: string) => void,
onSelect: TestRoleSelectCallback,
options?: { temporaryOnly?: boolean; currentContextTokens?: number },
): ModelSelectorComponent {
const modelRegistry = {
@@ -82,7 +111,13 @@ function createScopedSelector(
settings,
modelRegistry,
models.map(model => ({ model })),
(model, role, thinkingLevel, selector) => onSelect(model, role, thinkingLevel, selector),
(
model: Model,
role: string | null,
thinkingLevel?: ConfiguredThinkingLevel,
selector?: string,
action?: ModelSelectorAction,
) => onSelect(model, role, thinkingLevel, selector, action),
() => {},
options,
);
@@ -299,6 +334,32 @@ describe("ModelSelector role badge thinking display", () => {
expect(onSelect.mock.calls[0]?.[3]).toBe("test/only-small");
});
test("assigns selected model as default retry fallback without opening thinking options", () => {
installTestTheme();
const settings = Settings.isolated({});
const fallback = createContextTestModel("retry-fallback-model", 128_000);
const onSelect = vi.fn();
const selector = createScopedSelector([fallback], settings, onSelect);
installTestTheme();
selector.handleInput("\n");
const menuRendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(menuRendered).toContain("Action for: retry-fallback-model");
expect(menuRendered).toContain(DEFAULT_RETRY_FALLBACK_ACTION_LABEL);
selectMenuAction(selector, DEFAULT_RETRY_FALLBACK_ACTION_LABEL);
selector.handleInput("\n");
const afterEnter = normalizeRenderedText(selector.render(220).join("\n"));
expect(afterEnter).not.toContain("Thinking for:");
expect(onSelect).toHaveBeenCalledTimes(1);
const call = onSelect.mock.calls[0];
expect(call?.[0]).toBe(fallback);
expect(call?.[1]).toBe("default");
expect(call?.[3]).toBe("test/retry-fallback-model");
expect(call?.[4]).toBe(DEFAULT_RETRY_FALLBACK_ACTION);
});
test("uses cached models for Enter while offline refresh is still pending", () => {
installTestTheme();
const settings = Settings.isolated({});
@@ -107,4 +107,22 @@ describe("settings layout", () => {
group: "Services",
});
});
it("exposes retry fallback chains as editable JSON in the model settings", () => {
const def = getSettingsForTab("model").find(item => item.path === "retry.fallbackChains");
expect(def).toMatchObject({
path: "retry.fallbackChains",
type: "text",
tab: "model",
group: "Retry & Fallback",
label: "Retry Fallback Chains",
});
if (!def) throw new Error("retry.fallbackChains setting definition missing");
const description = def.description.toLowerCase();
expect(description).toContain("json");
expect(description).toContain("fallback");
expect(description).toContain("selector");
});
});
@@ -41,7 +41,8 @@ describe("buildHotkeysMarkdown", () => {
expect(markdown).toContain("| `Ctrl+L` | Reset terminal display |");
expect(markdown).toContain("| `Alt+R` | Retry last failed assistant turn |");
expect(markdown).toContain("| `Alt+Shift+P` | Toggle plan mode |");
expect(markdown).toContain("| `#` | Open prompt actions |");
expect(markdown).toContain("| `#<number>` | GitHub issue/PR reference");
expect(markdown).toContain("| `#` / `#<text>` | Prompt actions");
for (const line of lines) {
if (line.length === 0) continue;
expect(line.startsWith(" ")).toBe(false);
@@ -24,10 +24,12 @@ const LONG_AUTH_URL =
const LINEAR_AUTH_URL = `https://mcp.linear.app/authorize?response_type=code&client_id=abcdefghij0123456789ABCDEFGHIJ0123456789&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&scope=read%20write%20mcp%3Aall&state=0123456789abcdef0123456789abcdef&code_challenge=5MlkJfN2GhX9uP0rQ7sT8vB1oCwDeFgHiJkLmNoPqRsTuVwXyZ&code_challenge_method=S256`;
/**
* Reassemble the copy-URL rows for `label` into a single string, mirroring what
* a browser would produce when a multi-row selection is pasted into its
* address bar (whitespace stripped between chunks). Returns "" if the label
* row isn't found.
* Reassemble the copy-URL rows for `label` into a single string, mirroring a
* real multi-row terminal selection pasted into an address bar: browsers
* strip the newlines, but any other leading bytes survive (verbatim or
* percent-encoded) — so chunks are concatenated RAW, with no indent-stripping
* that could mask a corrupting prefix. Returns "" if the label row isn't
* found.
*/
function reassembleUrl(plainLines: string[], label: string): string {
const start = plainLines.findIndex(line => line.startsWith(` ${label}`));
@@ -36,15 +38,13 @@ function reassembleUrl(plainLines: string[], label: string): string {
// Inline form contains the whole URL on the label row.
const inlineMatch = first.match(new RegExp(`^ ${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} (.*)$`));
if (inlineMatch) return inlineMatch[1]!;
// Wrapped form: label alone, then continuation rows with a single-space indent.
// Wrapped form: indented label row, then UNINDENTED continuation chunks.
// Any indented row (the next label) or blank row ends this URL's chunks.
let joined = "";
for (let i = start + 1; i < plainLines.length; i++) {
const line = plainLines[i]!;
// Continuation rows have exactly one leading space; a different indent
// or label ends this URL's rows.
if (!line.startsWith(" ") || line.startsWith(" ") || line.trim().length === 0) break;
if (line.includes(":") && /^ [A-Z][^:]*:/.test(line)) break; // next label row
joined += line.slice(1);
if (line.startsWith(" ") || line.trim().length === 0) break;
joined += line;
}
return joined;
}
@@ -89,10 +89,12 @@ describe("MCPAuthorizationLinkPrompt", () => {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
// Wrapping puts the label on its own row followed by continuation
// chunks with a single-space indent.
// Wrapping puts the label on its own indented row followed by
// UNINDENTED continuation chunks — zero leading bytes, so a multi-row
// selection pastes back to the exact URL.
const labelRow = plainLines.indexOf(` ${COPY_URL_LABEL}`);
expect(labelRow).toBeGreaterThanOrEqual(0);
expect(plainLines[labelRow + 1]!.startsWith(" ")).toBe(false);
// Chunks reassemble to the URL byte-for-byte — the trailing
// `code_challenge_method=S256` MUST be present.
@@ -140,7 +142,7 @@ describe("MCPAuthorizationLinkPrompt", () => {
it("floors the wrap width so degenerately-narrow viewports still emit every character", () => {
// Below 16 cols the terminal is unusable, but the render still emits
// chunks (bounded at 16 - indent = 15 chars). No character is silently
// chunks (bounded at the 16-column floor). No character is silently
// dropped; the user can widen and reflow.
const lines = new MCPAuthorizationLinkPrompt(LINEAR_AUTH_URL).render(4);
const plainLines = lines.map(line => stripVTControlCharacters(line));
@@ -0,0 +1,156 @@
import { describe, expect, it } from "bun:test";
import { KeybindingsManager as AppKeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings";
import { getGithubRefContext, getGithubRefSuggestions } from "@oh-my-pi/pi-coding-agent/modes/github-ref-autocomplete";
import { createPromptActionAutocompleteProvider } from "@oh-my-pi/pi-coding-agent/modes/prompt-action-autocomplete";
function makeProvider() {
return createPromptActionAutocompleteProvider({
commands: [],
basePath: "/tmp",
keybindings: AppKeybindingsManager.inMemory({}),
copyCurrentLine: () => {},
copyPrompt: () => {},
undo: () => {},
moveCursorToMessageEnd: () => {},
moveCursorToMessageStart: () => {},
moveCursorToLineStart: () => {},
moveCursorToLineEnd: () => {},
});
}
describe("github-ref autocomplete — token detection", () => {
it("matches a standalone #<number> ending at the cursor", () => {
expect(getGithubRefContext("#3164")).toEqual({ prefix: "#3164", qualifier: null, number: "3164" });
expect(getGithubRefContext("look at #3164")).toEqual({ prefix: "#3164", qualifier: null, number: "3164" });
// only the token ending at the cursor (the $ anchor) wins
expect(getGithubRefContext("see #1 then #3164")).toEqual({
prefix: "#3164",
qualifier: null,
number: "3164",
});
});
it("requires a token boundary before # so embedded hashes don't match", () => {
// cross-repo reference, mid-word, URL fragment — none should offer candidates
expect(getGithubRefContext("owner/repo#3164")).toBeNull();
expect(getGithubRefContext("foo#3164")).toBeNull();
expect(getGithubRefContext("C#12")).toBeNull();
expect(getGithubRefContext("https://github.com/can1357/oh-my-pi#3164")).toBeNull();
expect(getGithubRefContext("path/#3164")).toBeNull();
});
it("does not match bare #, text, mixed, zero, or leading zeros", () => {
expect(getGithubRefContext("#")).toBeNull();
expect(getGithubRefContext("#copy")).toBeNull();
expect(getGithubRefContext("#3164abc")).toBeNull();
expect(getGithubRefContext("#3a")).toBeNull();
// a space after the digits closes the token
expect(getGithubRefContext("#3164 ")).toBeNull();
expect(getGithubRefContext("#0")).toBeNull();
expect(getGithubRefContext("#0123")).toBeNull();
});
it("detects a pr/pull/issue qualifier word immediately before the number", () => {
expect(getGithubRefContext("pr #3164")).toEqual({ prefix: "pr #3164", qualifier: "pr", number: "3164" });
expect(getGithubRefContext("PR #3164")).toEqual({ prefix: "PR #3164", qualifier: "pr", number: "3164" });
expect(getGithubRefContext("pull #3164")).toEqual({ prefix: "pull #3164", qualifier: "pr", number: "3164" });
expect(getGithubRefContext("issue #3164")).toEqual({
prefix: "issue #3164",
qualifier: "issue",
number: "3164",
});
// the word right before the number is the qualifier, even with leading text
expect(getGithubRefContext("look at the issue #3164")?.qualifier).toBe("issue");
});
it("does not treat arbitrary words, or qualifiers glued to a path, as the type", () => {
expect(getGithubRefContext("review #3164")?.qualifier).toBeNull();
// "pr" inside "src/pr" is preceded by '/', not a boundary, so it is not a qualifier
expect(getGithubRefContext("src/pr #3164")?.qualifier).toBeNull();
// a qualifier with no space before the # is not recognized
expect(getGithubRefContext("pr#3164")).toBeNull();
});
});
describe("github-ref autocomplete — suggestions", () => {
it("offers both candidates when no qualifier is given", () => {
const result = getGithubRefSuggestions("#3164");
expect(result).not.toBeNull();
expect(result!.prefix).toBe("#3164");
expect(result!.items).toEqual([
{ value: "pr://3164", label: "PR #3164", description: "GitHub pull request" },
{ value: "issue://3164", label: "Issue #3164", description: "GitHub issue" },
]);
});
it("offers only the PR candidate for a pr/pull qualifier", () => {
const result = getGithubRefSuggestions("pr #3164");
expect(result!.prefix).toBe("pr #3164");
expect(result!.items).toEqual([{ value: "pr://3164", label: "PR #3164", description: "GitHub pull request" }]);
});
it("offers only the Issue candidate for an issue qualifier", () => {
const result = getGithubRefSuggestions("issue #3164");
expect(result!.items).toEqual([{ value: "issue://3164", label: "Issue #3164", description: "GitHub issue" }]);
});
it("returns null for embedded or non-ref text", () => {
expect(getGithubRefSuggestions("owner/repo#3164")).toBeNull();
expect(getGithubRefSuggestions("#copy")).toBeNull();
expect(getGithubRefSuggestions("#0")).toBeNull();
});
});
describe("github-ref autocomplete — provider integration", () => {
it("yields both candidates and rewrites the token to the chosen internal URL", async () => {
const provider = makeProvider();
const suggestions = await provider.getSuggestions(["review #3164"], 0, 12);
expect(suggestions).not.toBeNull();
expect(suggestions!.prefix).toBe("#3164");
expect(suggestions!.items.map(item => item.value)).toEqual(["pr://3164", "issue://3164"]);
const pr = suggestions!.items[0]!;
const issue = suggestions!.items[1]!;
const prResult = provider.applyCompletion(["review #3164"], 0, 12, pr, suggestions!.prefix);
expect(prResult.lines).toEqual(["review pr://3164 "]);
expect(prResult.cursorCol).toBe("review pr://3164 ".length);
const issueResult = provider.applyCompletion(["review #3164"], 0, 12, issue, suggestions!.prefix);
expect(issueResult.lines).toEqual(["review issue://3164 "]);
});
it("constrains to the named type and consumes the qualifier on accept", async () => {
const provider = makeProvider();
const suggestions = await provider.getSuggestions(["review pr #3164"], 0, 15);
expect(suggestions).not.toBeNull();
expect(suggestions!.prefix).toBe("pr #3164");
expect(suggestions!.items.map(item => item.value)).toEqual(["pr://3164"]);
const pr = suggestions!.items[0]!;
const result = provider.applyCompletion(["review pr #3164"], 0, 15, pr, suggestions!.prefix);
// the "pr " qualifier is replaced along with the number, not left dangling
expect(result.lines).toEqual(["review pr://3164 "]);
});
it("revalidates stale prefixes against the live cursor token before applying", async () => {
const provider = makeProvider();
const staleSuggestions = await provider.getSuggestions(["review #316"], 0, 11);
expect(staleSuggestions).not.toBeNull();
const stalePr = staleSuggestions!.items[0]!;
const updatedNumber = provider.applyCompletion(["review #3164"], 0, 12, stalePr, staleSuggestions!.prefix);
expect(updatedNumber.lines).toEqual(["review pr://3164 "]);
expect(updatedNumber.cursorCol).toBe("review pr://3164 ".length);
const embeddedHash = provider.applyCompletion(["owner/repo#3164"], 0, 15, stalePr, staleSuggestions!.prefix);
expect(embeddedHash.lines).toEqual(["owner/repo#3164"]);
expect(embeddedHash.cursorCol).toBe(15);
});
it("does not offer candidates for embedded hashes (falls through to other providers)", async () => {
const provider = makeProvider();
const isRef = (value: string) => value.startsWith("pr://") || value.startsWith("issue://");
const embedded = await provider.getSuggestions(["owner/repo#3164"], 0, 15);
expect(embedded?.items.every(item => !isRef(item.value)) ?? true).toBe(true);
});
});
@@ -1,6 +1,11 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { containsWorkflow, highlightWorkflow, WORKFLOW_NOTICE } from "@oh-my-pi/pi-coding-agent/modes/workflow";
import {
containsWorkflow,
highlightWorkflow,
renderWorkflowNotice,
WORKFLOW_NOTICE,
} from "@oh-my-pi/pi-coding-agent/modes/workflow";
beforeAll(() => {
// highlightWorkflow reads the global theme's color mode.
@@ -48,9 +53,18 @@ describe("workflow keyword highlighting", () => {
});
describe("workflow notice", () => {
it("is a non-empty system notice carrying the eval-fan-out contract", () => {
it("is a non-empty system notice carrying the task fan-out contract", () => {
expect(WORKFLOW_NOTICE.length).toBeGreaterThan(0);
expect(WORKFLOW_NOTICE).toContain("**workflowz** keyword");
expect(WORKFLOW_NOTICE).toContain("parallel(");
expect(WORKFLOW_NOTICE).toContain("Use the `task` tool for batched fan-out");
expect(WORKFLOW_NOTICE).toContain("tasks[]");
});
it("renders flat task-call guidance when task.batch is disabled", () => {
const notice = renderWorkflowNotice({ taskBatch: false });
expect(notice).toContain("once per independent subagent");
expect(notice).toContain("Do not pass `context` or `tasks[]`");
expect(notice).toContain("one independent task call per leaf");
expect(notice).not.toContain("Call `task` once per independent fan-out batch");
});
});
@@ -1,4 +1,7 @@
import { describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { buildNonInteractiveEnv } from "@oh-my-pi/pi-coding-agent/exec/non-interactive-env";
describe("buildNonInteractiveEnv", () => {
@@ -45,3 +48,54 @@ describe("buildNonInteractiveEnv", () => {
expect(env.LC_ALL).toBeUndefined();
});
});
it("keeps launch .env.local values out of child shell config", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "omp-env-local-"));
try {
await Bun.write(
path.join(tmp, ".env.local"),
"CONVEX_DEPLOYMENT=anonymous:root-local\nCONVEX_URL=http://127.0.0.1:3210\n",
);
const procmgrPath = path.resolve(import.meta.dir, "../../utils/src/procmgr.ts");
const script = [
`import { getShellConfig } from ${JSON.stringify(procmgrPath)};`,
"const env = getShellConfig().env;",
"console.log(JSON.stringify({",
" deployment: env.CONVEX_DEPLOYMENT ?? null,",
" url: env.CONVEX_URL ?? null,",
" inherited: env.OMP_TEST_INHERITED_MARKER ?? null,",
"}));",
].join("\n");
const proc = Bun.spawn([process.execPath, "--no-install", "--eval", script], {
cwd: tmp,
env: {
HOME: process.env.HOME ?? "",
OMP_TEST_INHERITED_MARKER: "keep-me",
PATH: process.env.PATH ?? "",
SHELL: process.env.SHELL ?? "/bin/bash",
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
const payload: {
deployment: string | null;
url: string | null;
inherited: string | null;
} = JSON.parse(stdout);
expect(payload).toEqual({
deployment: null,
url: null,
inherited: "keep-me",
});
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
});
@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller";
import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state";
let settingsState: SettingsTestState | undefined;
@@ -51,4 +54,74 @@ describe("selector setting side effects", () => {
expect(invalidate).toHaveBeenCalledTimes(1);
expect(requestRender).toHaveBeenCalledTimes(1);
});
it("replaces malformed default retry fallback chains from the model selector action", async () => {
const testTheme = await getThemeByName("dark");
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
setThemeInstance(testTheme);
const settings = Settings.isolated({});
settings.set("retry.fallbackChains", { default: "not-an-array" } as unknown as Record<string, string[]>);
const fallback = buildModel({
id: "retry-fallback-model",
name: "retry-fallback-model",
api: "ollama-chat",
baseUrl: "https://example.com",
reasoning: false,
provider: "test",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 1024,
});
const showStatus = vi.fn();
const showError = vi.fn();
const controller = new SelectorController({
ui: { requestRender: vi.fn(), setFocus: vi.fn() },
editorContainer: { clear: vi.fn(), addChild: vi.fn() },
editor: {},
settings,
session: {
model: undefined,
modelRegistry: {
getAll: () => [fallback],
getDiscoverableProviders: () => [],
},
scopedModels: [{ model: fallback }],
getContextUsage: () => undefined,
},
statusLine: { invalidate: vi.fn() },
updateEditorBorderColor: vi.fn(),
keybindings: { getKeys: () => [] },
showStatus,
showError,
} as unknown as ConstructorParameters<typeof SelectorController>[0]);
let selector: { handleInput(input: string): void; render(width: number): string[] } | undefined;
controller.showSelector = create => {
const result = create(() => {});
selector = result.component as typeof selector;
};
controller.showModelSelector();
if (!selector) throw new Error("Expected model selector to be shown");
selector.handleInput("\n");
for (let attempt = 0; attempt < 20; attempt++) {
const selectedLine = stripVTControlCharacters(selector.render(220).join("\n"))
.split("\n")
.find(line => {
if (!line.includes("Set as DEFAULT retry fallback")) return false;
const trimmed = line.trimStart();
return trimmed.startsWith("❯") || trimmed.startsWith("▸") || trimmed.startsWith(">");
});
if (selectedLine) break;
selector.handleInput("\x1b[B");
if (attempt === 19) throw new Error("Default retry fallback action was not selectable");
}
selector.handleInput("\n");
await Promise.resolve();
expect(showError).not.toHaveBeenCalled();
expect(settings.get("retry.fallbackChains")).toEqual({ default: ["test/retry-fallback-model"] });
expect(showStatus).toHaveBeenCalledWith("Default fallback model: test/retry-fallback-model");
});
});
@@ -77,13 +77,27 @@ function createCtx(overrides?: { pathMaxLength?: number; branch?: string | null
};
}
function createStatusLineSession(sessionName: string) {
function createStatusLineSession(sessionName: string, modelName?: string) {
const model = modelName ? { name: modelName, contextWindow: 128000 } : undefined;
return {
state: { messages: [] },
state: { messages: [], model },
messages: [],
model: model ?? { contextWindow: 128000 },
contextUsageRevision: 0,
systemPrompt: [],
agent: { state: { tools: [] } },
skills: [],
isStreaming: false,
isAutoThinking: false,
autoResolvedThinkingLevel: () => undefined,
isAdvisorActive: () => false,
isFastModeActive: () => false,
getAsyncJobSnapshot: () => ({ running: [] }),
getCurrentModel: () => undefined,
isFastModeEnabled: () => false,
getContextUsage: () => ({ tokens: 0, contextWindow: 128000 }),
getGoalModeState: () => null,
modelRegistry: { isUsingOAuth: () => false },
sessionManager: {
getSessionName: () => sessionName,
getUsageStatistics: () => ({
@@ -102,6 +116,10 @@ function createStatusLineSession(sessionName: string) {
} as unknown as ConstructorParameters<typeof StatusLineComponent>[0];
}
function stripAnsi(value: string): string {
return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
}
describe("status line session accent", () => {
function buildComponent(sessionAccent: boolean) {
const component = new StatusLineComponent(createStatusLineSession("Named session"));
@@ -244,10 +262,17 @@ describe("overflow: path shrinks before git is dropped", () => {
}
}
// Left-pop loop (fallback)
// Left-segment fallback loop.
const leftOverflowDropIndex = (): number => {
for (let i = leftSegIds.length - 1; i >= 0; i--) {
if (leftSegIds[i] !== "path") return i;
}
return left.length - 1;
};
while (groupWidth() > width && left.length > 0) {
left.pop();
leftSegIds.pop();
const dropIdx = leftOverflowDropIndex();
left.splice(dropIdx, 1);
leftSegIds.splice(dropIdx, 1);
}
return { surviving: [...leftSegIds], contents: [...left] };
@@ -285,18 +310,20 @@ describe("overflow: path shrinks before git is dropped", () => {
});
it("shrinks a short path when maxLength exceeds actual path length", () => {
// Short dir name — rendered path is well under maxLength=80
// Short dir name — rendered path is well under the configured maxLength.
const shortDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-short-"));
setProjectDir(shortDir);
try {
const ctx = createCtx({ pathMaxLength: 80, branch: "feat/long-branch-name" });
const maxLength = 160;
const ctx = createCtx({ pathMaxLength: maxLength, branch: "feat/long-branch-name" });
const fullPath = renderSegment("path", ctx);
const fullGit = renderSegment("git", ctx);
const pathVW = visibleWidth(fullPath.content);
const gitVW = visibleWidth(fullGit.content);
// Sanity: path is shorter than maxLength — this is the bug scenario
expect(pathVW).toBeLessThan(80);
// Sanity: path is shorter than maxLength — this is the bug scenario.
// macOS temp paths can exceed 80 columns once the path icon is included.
expect(pathVW).toBeLessThan(maxLength);
// Width that fits a shrunken path + git but not the full path + git
const tightWidth = Math.floor(pathVW * 0.5) + gitVW + 10;
@@ -338,3 +365,61 @@ describe("overflow: path shrinks before git is dropped", () => {
}
});
});
describe("overflow: path survives before model", () => {
it("drops the model segment before the cwd path when both cannot fit", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "omp-statusline-overflow-"));
const cwd = path.join(root, "cwdxyz");
fs.mkdirSync(cwd);
setProjectDir(cwd);
const modelName = `MODEL_SHOULD_DROP_${"x".repeat(24)}`;
const session = createStatusLineSession("overflow test", modelName);
const component = new StatusLineComponent(session);
const pathOptions = {
abbreviate: false,
maxLength: 32,
stripWorkPrefix: false,
};
component.updateSettings({
preset: "custom",
leftSegments: ["pi", "model", "path"],
rightSegments: [],
separator: "none",
sessionAccent: false,
transparent: true,
segmentOptions: {
model: { showThinkingLevel: false },
path: pathOptions,
},
});
const ctx = {
...createCtx({ pathMaxLength: pathOptions.maxLength }),
session,
options: {
model: { showThinkingLevel: false },
path: pathOptions,
},
} as SegmentContext;
const pi = renderSegment("pi", ctx).content;
const model = renderSegment("model", ctx).content;
const minPath = renderSegment("path", {
...ctx,
options: { ...ctx.options, path: { ...pathOptions, maxLength: 4 } },
}).content;
const separatorWidth = visibleWidth(theme.sep.space);
const groupWidth = (parts: string[]) =>
parts.reduce((sum, part) => sum + visibleWidth(part), 0) +
Math.max(0, parts.length - 1) * (separatorWidth + 2) +
2;
const width = groupWidth([pi, model]) + 1;
expect(groupWidth([pi, model, minPath])).toBeGreaterThan(width);
expect(groupWidth([pi, minPath])).toBeLessThanOrEqual(width);
const rendered = stripAnsi(component.getTopBorder(width).content);
expect(rendered).toContain("xyz");
expect(rendered).not.toContain("MODEL_SHOULD_DROP");
});
});
@@ -2,13 +2,15 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { buildSystemPrompt as buildSdkSystemPrompt } from "@oh-my-pi/pi-coding-agent/sdk";
import {
buildSystemPrompt,
buildSystemPromptToolMetadata,
DEFAULT_SYSTEM_PROMPT_TOOL_NAMES,
type SystemPromptToolMetadata,
} from "@oh-my-pi/pi-coding-agent/system-prompt";
import type { Tool } from "@oh-my-pi/pi-coding-agent/tools";
import { createTools, type Tool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { cleanupTempHome } from "./helpers/temp-home-cleanup";
const EMPTY_TREE = {
@@ -92,6 +94,16 @@ describe("system prompt tool inventory", () => {
return text.slice(inventoryStart, inventoryEnd);
}
function makeToolSession(settings: Settings): ToolSession {
return {
cwd: tempDir,
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings,
} as ToolSession;
}
it("renders a compact name list only when native tools are active and descriptors stay in schemas", async () => {
const text = await render({ nativeTools: true, inlineToolDescriptors: false });
expect(text).toContain("- Read: `read`");
@@ -132,6 +144,46 @@ describe("system prompt tool inventory", () => {
}
expect(inventory).not.toContain("- `browser`");
expect(inventory).not.toContain("- `task`");
expect(inventory).not.toContain("- `eval`");
});
it("omits eval prompt guidance when every eval backend is disabled", async () => {
const settings = Settings.isolated({
"eval.py": false,
"eval.js": false,
"eval.rb": false,
"eval.jl": false,
});
const session = makeToolSession(settings);
const tools = await createTools(session, ["bash", "eval"]);
const toolNames = tools.map(tool => tool.name);
const bash = tools.find(tool => tool.name === "bash");
expect(toolNames).toContain("bash");
expect(toolNames).not.toContain("eval");
expect(bash?.description).toContain("purpose-built tool");
expect(bash?.description).not.toContain("eval` cell");
expect(bash?.description).not.toContain("use `eval` cells");
expect(bash?.description).not.toContain("Prefer `eval`");
expect(bash?.description).not.toContain("`grep` tool");
expect(bash?.description).not.toContain("`ls` → `read`");
expect(bash?.description).not.toContain("`find` → the `glob` tool");
const { systemPrompt } = await buildSystemPrompt({
cwd: tempDir,
contextFiles: [],
skills: [],
rules: [],
toolNames,
tools: buildSystemPromptToolMetadata(new Map(tools.map(tool => [tool.name, tool]))),
workspaceTree: { ...EMPTY_TREE, rootPath: tempDir },
nativeTools: true,
inlineToolDescriptors: true,
});
const text = systemPrompt.join("\n\n");
expect(text).not.toContain("Default for any compute");
expect(text).not.toContain("use `eval` cells");
});
it("SDK wrapper renders provided tools instead of the fallback inventory", async () => {
@@ -21,6 +21,69 @@ const EMPTY_TREE = {
agentsMdFiles: [],
};
async function expectPromptDateFromStartupTimezone(options: {
tempDir: string;
tempHomeDir: string;
timeZone: string;
now: string;
expectedDate: string;
rejectedDate: string;
}): Promise<void> {
const scenarioPath = path.join(options.tempDir, "prompt-date-timezone.test.ts");
await Bun.write(
scenarioPath,
`import { expect, it, setSystemTime } from "bun:test";
import { buildSystemPrompt } from ${JSON.stringify(path.resolve(import.meta.dir, "../src/system-prompt.ts"))};
it("renders the prompt date in the startup timezone", async () => {
setSystemTime(new Date(process.env.OMP_TEST_NOW!));
try {
const { systemPrompt } = await buildSystemPrompt({
cwd: process.cwd(),
contextFiles: [],
skills: [],
rules: [],
toolNames: [],
workspaceTree: {
rootPath: process.cwd(),
rendered: "",
truncated: false,
totalLines: 0,
agentsMdFiles: [],
},
activeRepoContext: null,
});
const rendered = systemPrompt.join("\\n\\n");
expect(rendered).toContain(\`Today is \${process.env.OMP_EXPECTED_DATE}\`);
expect(rendered).not.toContain(\`Today is \${process.env.OMP_REJECTED_DATE}\`);
} finally {
setSystemTime();
}
});
`,
);
const child = Bun.spawn([process.execPath, "test", scenarioPath], {
cwd: options.tempDir,
env: {
...process.env,
HOME: options.tempHomeDir,
TZ: options.timeZone,
OMP_TEST_NOW: options.now,
OMP_EXPECTED_DATE: options.expectedDate,
OMP_REJECTED_DATE: options.rejectedDate,
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
]);
expect(`${stdout}\n${stderr}`).toContain("1 pass");
expect(exitCode).toBe(0);
}
describe("system prompt model identifier", () => {
let tempDir = "";
let tempHomeDir = "";
@@ -49,6 +112,17 @@ describe("system prompt model identifier", () => {
expect(systemPrompt.join("\n\n")).toContain("Model: anthropic/claude-opus-4");
});
it("renders the prompt date from the startup local timezone rather than UTC", async () => {
await expectPromptDateFromStartupTimezone({
tempDir,
tempHomeDir,
timeZone: "America/Los_Angeles",
now: "2026-07-01T03:15:00Z",
expectedDate: "2026-06-30",
rejectedDate: "2026-07-01",
});
});
it("omits the model line when no model is provided", async () => {
const { systemPrompt } = await buildSystemPrompt({
cwd: tempDir,
+15
View File
@@ -1492,6 +1492,21 @@ function b() {
expect(result.details?.requestedTimeoutSeconds).toBe(7200);
});
it("should disable the command deadline when timeout is zero", async () => {
vi.spyOn(toolTimeouts, "clampTimeout").mockReturnValue(0.05);
const result = await bashTool.execute("test-call-timeout-disabled", {
command: "printf 'start\\n'; sleep 0.1; printf 'done\\n'",
timeout: 0,
});
const output = getTextOutput(result);
expect(output).toContain("start");
expect(output).toContain("done");
expect(result.details?.timeoutDisabled).toBe(true);
expect(result.details?.timeoutSeconds).toBeUndefined();
});
it("should respect timeout", async () => {
// Reduce the effective timeout through the production clamp seam; the
// real subprocess kill-on-timeout path is still exercised, just faster.
@@ -178,6 +178,22 @@ describe("expandInternalUrls", () => {
);
});
it("leaves literal internal URLs embedded in quoted text unchanged", async () => {
const router = createInternalRouter({
"memory://root/summary.md": { sourcePath: "/tmp/memories/summary.md" },
});
const command = `printf '%s\\n' 'the literal memory://root/summary.md string'`;
await expect(expandInternalUrls(command, { skills: [], internalRouter: router })).resolves.toBe(command);
});
it("leaves unresolved quoted literal URLs unchanged", async () => {
const router = createInternalRouter({});
const command = "grep 'memory://xyz-quoted' file.txt";
await expect(expandInternalUrls(command, { skills: [], internalRouter: router })).resolves.toBe(command);
});
it("expands agent:// URLs when router is available", async () => {
const router = createInternalRouter({
"agent://abc": { sourcePath: "/tmp/session/abc.md" },
@@ -239,34 +255,30 @@ describe("expandInternalUrls", () => {
);
});
it("throws when local:// URL is used without local protocol options", async () => {
await expect(expandInternalUrls("mv foo local://bar", { skills: [] })).rejects.toThrow(
"Cannot resolve local:// URL in bash command: local protocol options are unavailable for this session.",
);
it("leaves local:// URLs unchanged without local protocol options", async () => {
const command = "mv foo local://bar";
await expect(expandInternalUrls(command, { skills: [] })).resolves.toBe(command);
});
it("throws when non-skill URL is used without an internal router", async () => {
await expect(expandInternalUrls("cat artifact://1", { skills: [] })).rejects.toThrow(
"Cannot resolve artifact:// URL in bash command",
);
it("leaves non-skill URLs unchanged without an internal router", async () => {
const command = "cat artifact://1";
await expect(expandInternalUrls(command, { skills: [] })).resolves.toBe(command);
});
it("throws when internal router resolves URL without sourcePath", async () => {
it("leaves internal URLs unchanged when they resolve without sourcePath", async () => {
const router = createInternalRouter({
"rule://my-rule": {},
});
await expect(expandInternalUrls("cat rule://my-rule", { skills: [], internalRouter: router })).rejects.toThrow(
"rule:// URL resolved without a filesystem path",
);
const command = "cat rule://my-rule";
await expect(expandInternalUrls(command, { skills: [], internalRouter: router })).resolves.toBe(command);
});
it("surfaces resolver errors with actionable context", async () => {
it("leaves internal URLs unchanged when the resolver fails", async () => {
const router = createInternalRouter({
"memory://root/missing.md": { error: "Memory file not found" },
});
await expect(
expandInternalUrls("cat memory://root/missing.md", { skills: [], internalRouter: router }),
).rejects.toThrow("Failed to resolve memory:// URL in bash command");
const command = "cat memory://root/missing.md";
await expect(expandInternalUrls(command, { skills: [], internalRouter: router })).resolves.toBe(command);
});
it("does not match local:/ inside filesystem paths (e.g. /repo/local:/PLAN.md)", async () => {
@@ -0,0 +1,35 @@
import { describe, expect, it } from "bun:test";
import { stealthIgnoreDefaultArgsForTest } from "@oh-my-pi/pi-coding-agent/tools/browser/launch";
const AUTOMATION_FLAG = "--enable-automation";
const EDGE_EXECUTABLE_PATHS = [
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/usr/bin/microsoft-edge-stable",
] as const;
const CHROME_EXECUTABLE_PATHS = [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/chromium",
] as const;
describe("browser launch stealth defaults", () => {
it("keeps Puppeteer's automation default for Microsoft Edge executables", () => {
for (const executablePath of EDGE_EXECUTABLE_PATHS) {
const ignoreDefaultArgs = stealthIgnoreDefaultArgsForTest(executablePath);
expect(ignoreDefaultArgs).not.toContain(AUTOMATION_FLAG);
expect(ignoreDefaultArgs).toContain("--disable-extensions");
}
});
it("continues filtering Puppeteer's automation default for Chrome and Chromium executables", () => {
for (const executablePath of CHROME_EXECUTABLE_PATHS) {
const ignoreDefaultArgs = stealthIgnoreDefaultArgsForTest(executablePath);
expect(ignoreDefaultArgs).toContain(AUTOMATION_FLAG);
}
});
});
@@ -7,10 +7,12 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { renderGalleryState, resolveFixture } from "@oh-my-pi/pi-coding-agent/cli/gallery-cli";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { editToolRenderer } from "@oh-my-pi/pi-coding-agent/edit/renderer";
import { renderDiff } from "@oh-my-pi/pi-coding-agent/modes/components/diff";
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
beforeAll(async () => {
resetSettingsForTest();
@@ -519,3 +521,172 @@ describe("editToolRenderer", () => {
expect(text).not.toContain("No changes");
});
});
describe("editToolRenderer diff line wrapping", () => {
// Renders a completed single-line replacement (`-N|old` + `+N|new`) through
// the real renderDiff so the result carries its production shapes: a blanked
// dedup gutter on the `+` row (` +│`) and intra-line inverse highlights.
async function renderSingleLineReplacement(
oldLine: string,
newLine: string,
width: number,
): Promise<readonly string[]> {
const uiTheme = await getUiTheme();
const component = editToolRenderer.renderResult(
{
content: [{ type: "text", text: "Updated demo.ts" }],
details: { diff: `-42|${oldLine}\n+42|${newLine}`, op: "update", path: "demo.ts" },
},
{ expanded: true, isPartial: false, renderContext: { renderDiff } },
uiTheme,
{ file_path: "demo.ts" },
);
return component.render(width);
}
/** Net SGR inverse state after scanning a row; 38/48 extended-color args must not be misread as attribute 7. */
function inverseActiveAtRowEnd(row: string): boolean {
let inverse = false;
for (const match of row.matchAll(/\x1b\[([0-9;]*)m/g)) {
const params = match[1].split(";");
for (let i = 0; i < params.length; i++) {
const param = params[i];
if (param === "38" || param === "48") {
i += params[i + 1] === "2" ? 4 : params[i + 1] === "5" ? 2 : 0;
} else if (param === "" || param === "0") inverse = false;
else if (param === "7") inverse = true;
else if (param === "27") inverse = false;
}
}
return inverse;
}
it("keeps added-line continuation rows inside the blanked dedup gutter", async () => {
// renderDiff blanks the repeated line number on the `+` row of a
// single-line replacement (` +│`); the wrapper must still recognize that
// gutter instead of falling back to generic wrapping at column 0.
const rows = (
await renderSingleLineReplacement(
" the previous synopsis paragraph rambled across quarterly reconciliation notes enumerating every provisional ledger amendment the archival committee had deferred pending review by the regional custodians during the extended winter recess of the auditing season",
" the revised synopsis paragraph now catalogues seasonal festival logistics enumerating lantern shipments drum rehearsals and ribbon inventories that the parade stewards confirmed before dawn, closing with the zephyrQuota tally and the marbledFinale banner",
100,
)
).map(row => Bun.stripANSI(row));
// The tail of the added line lands on continuation rows, which must carry
// the spaces-only continuation gutter rather than start as bare prose.
const tailRows = rows.filter(row => row.includes("zephyrQuota") || row.includes("marbledFinale"));
expect(tailRows.length).toBeGreaterThanOrEqual(1);
for (const row of tailRows) expect(row).toMatch(/^│\s+│/);
// Every body row stays inside a code-frame gutter (`-42│`, ` +│`, ` │`).
for (const row of rows.slice(1, -1)) expect(row).toMatch(/^│\s*[+-]?\s*\d*│/);
});
it("closes inverse video at every wrapped row end so frame padding stays uninverted", async () => {
// A long contiguous rewritten phrase forces the wrap boundary to land
// inside an inverse-highlighted span; the frame pads each row with spaces,
// so any inverse still active at row end paints those cells as gray blocks.
const previousLevel = chalk.level;
chalk.level = 3;
let rows: readonly string[];
try {
rows = await renderSingleLineReplacement(
" stanza recounts venerable chronicle passages spanning bygone dynasties whose archivists engraved ledgers onto vellum scrolls",
" stanza celebrates luminous festival processions winding through lantern boulevards while drummers herald jubilant choruses beneath cascading ribbons and fireworks",
100,
);
} finally {
chalk.level = previousLevel;
}
// Precondition: some continuation row's content reopens with inverse right
// after its gutter, proving a highlighted span crossed a wrap boundary. If
// diffWords tokenization ever changes so no span crosses, this fails loudly
// instead of letting the row-end assertions pass vacuously.
expect(rows.some(row => /│\x1b\[7m/.test(row))).toBe(true);
for (const row of rows) expect(inverseActiveAtRowEnd(row)).toBe(false);
});
// Error results reuse the same body-line wrapper as diff rows; these tests
// pin the boundary between prose that merely looks pipe-ish and real gutters.
async function renderErrorResultRows(errorText: string): Promise<string[]> {
const uiTheme = await getUiTheme();
const component = editToolRenderer.renderResult(
{
content: [{ type: "text", text: errorText }],
details: { diff: "", op: "update", path: "demo.ts" },
isError: true,
},
{ expanded: true, isPartial: false, renderContext: { renderDiff } },
uiTheme,
{ file_path: "demo.ts" },
);
return component.render(100).map(row => Bun.stripANSI(row));
}
it("does not give pipe-leading error text a phantom diff gutter when wrapping", async () => {
// Error text is not a diff row even when it starts with `|`: an empty
// gutter must wrap generically, not spawn `|` continuation prefixes.
const rows = await renderErrorResultRows(
"| pipe-leading diagnostic output that is quite long and should certainly wrap at the render width because it keeps going on and on with more words than fit in one row of the frame",
);
const bodyRows = rows.slice(1, -1);
// Precondition: the text actually wrapped, and the `|` lead survived on row one.
expect(bodyRows.length).toBeGreaterThanOrEqual(2);
expect(bodyRows[0]).toMatch(/^│\| /);
for (const row of bodyRows.slice(1)) expect(row).not.toMatch(/^│\s*\|/);
});
it("wraps spaces-then-bare-pipe error text generically instead of minting a gutter", async () => {
// A digit-less ASCII "|" gutter never comes out of formatCodeFrameLine or
// canonical diff rows; indented bare-pipe error text must wrap generically.
const rows = await renderErrorResultRows(
" | indented bare-pipe diagnostic output that is quite long and should certainly wrap at the render width because it keeps going on and on with more words than fit in one row of the frame",
);
const bodyRows = rows.slice(1, -1);
// Precondition: the text actually wrapped, and the pipe lead survived on row one.
expect(bodyRows.length).toBeGreaterThanOrEqual(2);
expect(bodyRows[0]).toMatch(/^│\s+\| /);
for (const row of bodyRows.slice(1)) expect(row).not.toMatch(/^│\s*\|/);
});
it("wraps digit-leading pipe error text generically when the marker column is missing", async () => {
// Canonical ASCII-pipe rows always carry a marker column (`-42|`, ` 42|`);
// `123|` prose has a digit there instead, so it is not a diff row.
const rows = await renderErrorResultRows(
"123| numbered pipe-leading diagnostic output that is quite long and should certainly wrap at the render width because it keeps going on and on with more words than fit in one row of the frame",
);
const bodyRows = rows.slice(1, -1);
// Precondition: the text actually wrapped, and the numbered lead survived on row one.
expect(bodyRows.length).toBeGreaterThanOrEqual(2);
expect(bodyRows[0]).toMatch(/^│123\| /);
for (const row of bodyRows.slice(1)) expect(row).not.toMatch(/^│\s*\|/);
});
it("keeps the numbered ASCII-pipe gutter for canonical rows through the plain fallback", async () => {
// Without renderContext the plain fallback passes canonical rows through
// verbatim; a numbered "-42|" row must still take the gutter path and
// carry an " |" continuation gutter, not generic prose wrapping.
const uiTheme = await getUiTheme();
const component = editToolRenderer.renderResult(
{
content: [{ type: "text", text: "Updated demo.ts" }],
details: {
diff: "-42| the previous synopsis paragraph rambled across quarterly reconciliation notes enumerating every provisional ledger amendment the archival committee had deferred pending review by the regional custodians",
op: "update",
path: "demo.ts",
},
},
{ expanded: true, isPartial: false },
uiTheme,
{ file_path: "demo.ts" },
);
const rows = component.render(100).map(row => Bun.stripANSI(row));
const bodyRows = rows.slice(1, -1);
// Precondition: the row actually wrapped past its first visual line.
expect(bodyRows.length).toBeGreaterThanOrEqual(2);
expect(bodyRows[0]).toMatch(/^│-42\|/);
for (const row of bodyRows.slice(1)) expect(row).toMatch(/^│\s+\|/);
});
});
@@ -450,6 +450,35 @@ describe("GrepTool internal URL resolution", () => {
expect(text).toMatch(/^\*\d+:.*needle/m);
});
it("read local://<name>:<sel> honors URL selector even when a sibling literal `<name>:<sel>` file exists (issue #4618)", async () => {
const localRoot = path.join(artifactsDir, "local");
await fs.mkdir(localRoot, { recursive: true });
// Base file targeted by `local://notes.md`; selector should slice this one.
await Bun.write(
path.join(localRoot, "notes.md"),
`${Array.from({ length: 10 }, (_, i) => `url-target line ${i + 1}`).join("\n")}\n`,
);
// Sibling literal `notes.md:1-2` under the same local root — must NOT
// shadow the URL selector semantics of `local://notes.md:1-2`.
await Bun.write(path.join(localRoot, "notes.md:1-2"), "sibling literal shadow\n");
LocalProtocolHandler.setOverride({ getArtifactsDir: () => artifactsDir, getSessionId: () => "session" });
const session = createSession({ hasEditTool: true });
session.settings.set("read.summarize.enabled", false);
const result = await new ReadTool(session).execute("test-read-local-url-selector", {
path: "local://notes.md:1-2",
});
const text = getResultText(result);
// The base file was targeted (URL selector semantics preserved), not the
// sibling literal. Content check is enough — the read tool's context
// expansion around the requested range is unrelated to the shadow bug.
expect(text).toContain("url-target line 1");
expect(text).toContain("url-target line 2");
expect(text).not.toContain("sibling literal shadow");
});
it("keeps hashlines on mutable files when mixed with immutable artifact:// inputs", async () => {
const content = "alpha line\nbeta needle line\ngamma line\n";
await Bun.write(path.join(artifactsDir, "11.bash.log"), content);
@@ -264,6 +264,37 @@ describe("createTools", () => {
expect(names).toEqual(["read", "goal", "resolve"]);
});
it("records active tools on the original session object", async () => {
const session = createTestSession();
await createTools(session, ["bash"]);
expect(session.isToolActive?.("bash")).toBe(true);
expect(session.isToolActive?.("read")).toBe(false);
});
it("renders bash guidance from the live active tool predicate", async () => {
const activeToolNames = new Set<string>();
const session = createTestSession({
isToolActive: name => activeToolNames.has(name),
setActiveToolNames: names => {
activeToolNames.clear();
for (const name of names) {
activeToolNames.add(name);
}
},
});
const tools = await createTools(session, ["bash", "grep", "read", "glob"]);
const bash = tools.find(tool => tool.name === "bash");
expect(bash?.description).toContain("`grep` tool");
session.setActiveToolNames?.(["bash"]);
expect(bash?.description).not.toContain("`grep` tool");
expect(bash?.description).not.toContain("`ls` → `read`");
expect(bash?.description).not.toContain("`find` → the `glob` tool");
});
it("includes search_tool_bm25 when MCP tool discovery is enabled and executable", async () => {
const session = createTestSession({
settings: createSettingsWithOverrides({
@@ -0,0 +1,346 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import {
probeLiteralPathExists,
splitPathAndSel,
splitPathAndSelPreferringLiteral,
} from "@oh-my-pi/pi-coding-agent/tools/path-utils";
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
import { GrepTool } from "../../src/tools/grep";
function getText(result: { content: Array<{ type: string; text?: string }> }): string {
return result.content
.filter(entry => entry.type === "text")
.map(entry => entry.text ?? "")
.join("\n");
}
const EMPTY_ZIP_EOCD = new Uint8Array([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
// Regression: filenames whose tail matches the read-tool selector grammar
// (e.g. `test:1-2`, `log:raw`) used to be shredded by `splitPathAndSel` before
// either tool checked the filesystem — see issue #4618. Both `read` and `grep`
// must prefer a real literal file over the selector interpretation.
describe("literal colon filename resolution (issue #4618)", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "literal-colon-"));
});
afterEach(async () => {
await removeWithRetries(tmpDir);
});
function createSession(overrides: Partial<ToolSession> = {}): ToolSession {
return {
cwd: tmpDir,
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated({ "grep.contextBefore": 0, "grep.contextAfter": 0 }),
...overrides,
};
}
describe("splitPathAndSelPreferringLiteral", () => {
it("keeps the raw path intact when a literal colon file exists on disk", async () => {
const literal = "test:1-2";
await Bun.write(path.join(tmpDir, literal), "test\n");
// Strict splitter still peels — this documents the contract the
// literal-preferring variant sits on top of.
expect(splitPathAndSel(literal)).toEqual({ path: "test", sel: "1-2" });
expect(await splitPathAndSelPreferringLiteral(literal, tmpDir)).toEqual({ path: literal });
});
it("keeps a shell-escaped literal path intact when the resolved file exists", async () => {
await fs.mkdir(path.join(tmpDir, "dir"), { recursive: true });
await Bun.write(path.join(tmpDir, "dir", "a b:1-2"), "escaped literal\n");
expect(await splitPathAndSelPreferringLiteral("dir/a\\ b:1-2", tmpDir)).toEqual({
path: "dir/a\\ b:1-2",
});
});
it("falls back to selector interpretation when the literal path does not exist", async () => {
// No file created — the selector split wins because the raw path
// cannot be stat'd.
expect(await splitPathAndSelPreferringLiteral("test:1-2", tmpDir)).toEqual({
path: "test",
sel: "1-2",
});
});
it("also protects `:raw`-shaped literal filenames", async () => {
const literal = "log:raw";
await Bun.write(path.join(tmpDir, literal), "line one\nline two\n");
expect(await splitPathAndSelPreferringLiteral(literal, tmpDir)).toEqual({ path: literal });
});
it("keeps a literal dangling symlink intact (lstat exists even though stat fails)", async () => {
const literal = path.join(tmpDir, "test:1-2");
await fs.symlink(path.join(tmpDir, "missing-target"), literal);
expect(await probeLiteralPathExists(literal, tmpDir)).toBe("exists");
expect(await splitPathAndSelPreferringLiteral(literal, tmpDir)).toEqual({ path: literal });
});
it("returns the strict split unchanged when there is no selector tail", async () => {
expect(await splitPathAndSelPreferringLiteral("plain.txt", tmpDir)).toEqual({
path: "plain.txt",
});
});
});
describe("probeLiteralPathExists", () => {
it('returns "missing" for a path that clearly does not exist', async () => {
expect(await probeLiteralPathExists(path.join(tmpDir, "never-here:1-2"), tmpDir)).toBe("missing");
});
it('returns "exists" for a regular file', async () => {
const literal = path.join(tmpDir, "regular:1-2");
await Bun.write(literal, "hi\n");
expect(await probeLiteralPathExists(literal, tmpDir)).toBe("exists");
});
it('returns "exists" for a dangling symlink', async () => {
const literal = path.join(tmpDir, "dangling:1-2");
await fs.symlink(path.join(tmpDir, "nowhere"), literal);
expect(await probeLiteralPathExists(literal, tmpDir)).toBe("exists");
});
});
describe("read tool", () => {
it("reads a literal file whose name ends in a selector-shaped suffix", async () => {
const literal = "test:1-2";
const absolute = path.join(tmpDir, literal);
await Bun.write(absolute, "test\n");
const tool = new ReadTool(createSession());
const result = await tool.execute("read-literal", { path: absolute });
const output = getText(result);
expect(output).toContain("test");
// The strict split would have opened `test` (which doesn't exist)
// and thrown "Path 'test' not found".
expect(output).not.toMatch(/not found/i);
});
it("reads a shell-escaped literal file whose name ends in a selector-shaped suffix", async () => {
await fs.mkdir(path.join(tmpDir, "dir"), { recursive: true });
await Bun.write(path.join(tmpDir, "dir", "a b:1-2"), "escaped literal read\n");
const tool = new ReadTool(createSession());
const result = await tool.execute("read-escaped-literal", { path: "dir/a\\ b:1-2" });
const output = getText(result);
expect(output).toContain("escaped literal read");
});
it("prefers a real `foo:1-2` file over interpreting `:1-2` as a range on `foo`", async () => {
await Bun.write(path.join(tmpDir, "foo"), "line 1\nline 2\nline 3\n");
await Bun.write(path.join(tmpDir, "foo:1-2"), "colon file wins\n");
const tool = new ReadTool(createSession());
const result = await tool.execute("read-literal-wins", {
path: path.join(tmpDir, "foo:1-2"),
});
const output = getText(result);
expect(output).toContain("colon file wins");
expect(output).not.toContain("line 1");
});
it("still honors the `:5-10` selector when only the base file exists on disk", async () => {
const absolute = path.join(tmpDir, "notes");
const lines = Array.from({ length: 40 }, (_, i) => `line ${i + 1}`).join("\n");
await Bun.write(absolute, `${lines}\n`);
const session = createSession();
session.settings.set("read.summarize.enabled", false);
const tool = new ReadTool(session);
const result = await tool.execute("read-selector-preserved", {
path: `${absolute}:5-10`,
});
const output = getText(result);
expect(output).toContain("line 5");
expect(output).toContain("line 10");
// Lines well outside the requested range must not appear — the selector
// still peels because the raw `notes:5-10` path does not exist literally.
expect(output).not.toContain("line 30");
expect(output).not.toContain("line 40");
});
it("uses explicit `selector` to read lines from a literal selector-shaped filename deterministically", async () => {
const literal = path.join(tmpDir, "test:1-2");
const longerLiteral = path.join(tmpDir, "test:1-2:5-6");
const lines = Array.from({ length: 40 }, (_, i) => `literal line ${i + 1}`).join("\n");
await Bun.write(literal, `${lines}\n`);
await Bun.write(longerLiteral, "wrong longer literal\n");
const session = createSession();
session.settings.set("read.summarize.enabled", false);
const tool = new ReadTool(session);
const result = await tool.execute("read-explicit-selector-literal", {
path: literal,
selector: "5-6",
});
const output = getText(result);
expect(output).toContain("literal line 5");
expect(output).toContain("literal line 6");
expect(output).not.toContain("literal line 30");
expect(output).not.toContain("wrong longer literal");
});
it("reads a literal file that looks like an archive selector (`data.zip:1-2`)", async () => {
// A real POSIX file whose name ends in a selector-shaped tail after an
// archive extension. The archive resolver would otherwise open `data.zip`
// alongside it and error on the phantom member.
const baseArchive = path.join(tmpDir, "data.zip");
// Empty zip bytes — the file just needs to stat as a real archive so
// the archive resolver would happily accept it.
await Bun.write(baseArchive, EMPTY_ZIP_EOCD);
const literal = path.join(tmpDir, "data.zip:1-2");
await Bun.write(literal, "literal archive-shaped file\n");
const tool = new ReadTool(createSession());
const result = await tool.execute("read-literal-zip-selector", { path: literal });
const output = getText(result);
expect(output).toContain("literal archive-shaped file");
});
it("reads a literal file that looks like a sqlite selector (`notes.db:1-2`)", async () => {
// A real POSIX file whose base name matches a sqlite-shaped path plus a
// selector-shaped tail. The sqlite resolver would misroute this to
// `notes.db` and try to open a table named `1-2`.
const baseDb = path.join(tmpDir, "notes.db");
// SQLite database header (16-byte magic string plus zero-padding).
const header = new Uint8Array(4096);
header.set(Buffer.from("SQLite format 3\0", "utf-8"), 0);
await Bun.write(baseDb, header);
const literal = path.join(tmpDir, "notes.db:1-2");
await Bun.write(literal, "literal db-shaped file\n");
const tool = new ReadTool(createSession());
const result = await tool.execute("read-literal-db-selector", { path: literal });
const output = getText(result);
expect(output).toContain("literal db-shaped file");
});
});
describe("grep tool", () => {
it("searches inside a literal `test:1-2` file", async () => {
const literal = "test:1-2";
const absolute = path.join(tmpDir, literal);
await Bun.write(absolute, "needle\n");
const tool = new GrepTool(createSession());
const result = await tool.execute("grep-literal", {
pattern: "needle",
path: absolute,
});
const output = getText(result);
expect(output).toContain("needle");
expect(output).not.toMatch(/not found/i);
});
it("uses explicit `selector` to grep a literal selector-shaped filename deterministically", async () => {
const literal = path.join(tmpDir, "test:1-2");
const longerLiteral = path.join(tmpDir, "test:1-2:2-2");
await Bun.write(literal, "needle outside\nneedle inside\nneedle outside again\n");
await Bun.write(longerLiteral, "wrong longer literal needle\n");
const tool = new GrepTool(createSession());
const result = await tool.execute("grep-explicit-selector-literal", {
pattern: "needle",
path: literal,
selector: "2-2",
});
const output = getText(result);
expect(output).toContain("needle inside");
expect(output).not.toContain("needle outside again");
expect(output).not.toContain("wrong longer literal");
});
it("searches a shell-escaped literal file whose name ends in a selector-shaped suffix", async () => {
await fs.mkdir(path.join(tmpDir, "dir"), { recursive: true });
await Bun.write(path.join(tmpDir, "dir", "a b:1-2"), "escaped literal needle\n");
const tool = new GrepTool(createSession());
const result = await tool.execute("grep-escaped-literal", {
pattern: "needle",
path: "dir/a\\ b:1-2",
});
const output = getText(result);
expect(output).toContain("escaped literal needle");
});
it("searches a literal file whose name contains a semicolon and selector-shaped tail (`a;b:1-2`)", async () => {
// Semicolon is the delimited-path separator; without a raw-literal
// probe in `splitDelimitedPathEntry`, expandDelimitedPathEntries would
// split `a;b:1-2` into `["a", "b:1-2"]` before grep saw the literal file.
const literal = path.join(tmpDir, "a;b:1-2");
await Bun.write(literal, "delimited literal needle\n");
const tool = new GrepTool(createSession());
const result = await tool.execute("grep-literal-semicolon-selector", {
pattern: "needle",
path: literal,
});
const output = getText(result);
expect(output).toContain("delimited literal needle");
expect(output).not.toMatch(/not found/i);
});
it("searches a literal file that looks like an archive selector (`data.zip:1-2`)", async () => {
// The base archive exists too; grep must not rematerialize the raw
// literal path as archive `data.zip` plus phantom member `1-2`.
const baseArchive = path.join(tmpDir, "data.zip");
await Bun.write(baseArchive, EMPTY_ZIP_EOCD);
const literal = path.join(tmpDir, "data.zip:1-2");
await Bun.write(literal, "literal archive needle\n");
const tool = new GrepTool(createSession());
const result = await tool.execute("grep-literal-zip-selector", {
pattern: "needle",
path: literal,
});
const output = getText(result);
expect(output).toContain("literal archive needle");
});
it("preserves `:N-M` line-range filtering when the literal file does not exist", async () => {
const absolute = path.join(tmpDir, "notes.txt");
await Bun.write(absolute, "one\ntwo\nthree\nfour\n");
const tool = new GrepTool(createSession());
const rangedResult = await tool.execute("grep-range-filter", {
pattern: ".",
path: `${absolute}:1-2`,
});
const rangedOutput = getText(rangedResult);
expect(rangedOutput).toContain("one");
expect(rangedOutput).toContain("two");
// Lines outside the range are filtered out.
expect(rangedOutput).not.toContain("three");
expect(rangedOutput).not.toContain("four");
});
});
});
+70 -13
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { openPath } from "@oh-my-pi/pi-coding-agent/utils/open";
import * as piUtils from "@oh-my-pi/pi-utils";
import type { Subprocess } from "bun";
@@ -91,6 +92,12 @@ describe("openPath", () => {
it("opens existing WSL mount files through wslview with a Windows path", () => {
setPlatform("linux");
process.env.WSL_DISTRO_NAME = "Ubuntu";
// Keep the mocked linux platform deterministic on a Windows dev host:
// the real path.resolve would rewrite /mnt/c/… against the drive root.
const realResolve = path.resolve;
vi.spyOn(path, "resolve").mockImplementation((...segments: string[]) =>
segments.length === 1 && segments[0] === existingLinuxPath ? existingLinuxPath : realResolve(...segments),
);
vi.spyOn(piUtils, "$which").mockImplementation(command => (command === "wslview" ? "/usr/bin/wslview" : null));
vi.spyOn(fs, "existsSync").mockImplementation(candidate => candidate === existingLinuxPath);
@@ -133,50 +140,100 @@ describe("openPath", () => {
expect(spawnCalls.map(call => call.cmd)).toEqual([["xdg-open", existingLinuxPath]]);
});
it("resolves rundll32 through %SystemRoot% so a broken machine PATH cannot silence the opener", () => {
it("resolves PowerShell through %SystemRoot% so a broken machine PATH cannot silence the opener", () => {
setPlatform("win32");
const originalSystemRoot = process.env.SystemRoot;
process.env.SystemRoot = "D:\\CustomWindows";
const powershellPath = "D:\\CustomWindows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
vi.spyOn(fs, "existsSync").mockImplementation(candidate => candidate === powershellPath);
try {
const spawnCalls: SpawnCall[] = [];
spySpawn(spawnCalls);
openPath("https://mcp.linear.app/authorize?state=xyz&code_challenge_method=S256");
const url = "https://mcp.linear.app/authorize?state=xyz&code_challenge_method=S256";
openPath(url);
expect(spawnCalls).toHaveLength(1);
const [call] = spawnCalls;
// Absolute rundll32 path — bare `rundll32` was the whole bug on Windows
// boxes where the machine PATH no longer references System32.
expect(call?.cmd[0]).toBe("D:\\CustomWindows\\System32\\rundll32.exe");
// Handler + URL forwarded verbatim as a single argv slot so `&` in the
// query string cannot be interpreted as a shell separator.
expect(call?.cmd.slice(1)).toEqual([
"url.dll,FileProtocolHandler",
"https://mcp.linear.app/authorize?state=xyz&code_challenge_method=S256",
// Absolute PowerShell path — bare executable names were the whole bug
// on Windows boxes where the machine PATH no longer references
// System32.
expect(call?.cmd[0]).toBe(powershellPath);
expect(call?.cmd.slice(1, -1)).toEqual([
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-EncodedCommand",
]);
// The target rides inside the UTF-16LE payload: no cmd/PowerShell
// metacharacter parsing ever sees the `&` in the query string, and the
// terminating error preference makes Start-Process failures exit 1 so
// openPath's non-zero-exit telemetry observes them (rundll32 always
// exited 0).
const decoded = Buffer.from(String(call?.cmd.at(-1)), "base64").toString("utf16le");
expect(decoded).toBe(`$ErrorActionPreference='Stop';Start-Process '${url}'`);
} finally {
if (originalSystemRoot === undefined) delete process.env.SystemRoot;
else process.env.SystemRoot = originalSystemRoot;
}
});
it("falls back to C:\\Windows for rundll32 when SystemRoot is unset", () => {
it("doubles embedded single quotes so the target stays one PowerShell literal", () => {
setPlatform("win32");
vi.spyOn(fs, "existsSync").mockReturnValue(true);
const spawnCalls: SpawnCall[] = [];
spySpawn(spawnCalls);
openPath("C:\\Users\\o'brien\\report.html");
const decoded = Buffer.from(String(spawnCalls[0]?.cmd.at(-1)), "base64").toString("utf16le");
expect(decoded).toBe("$ErrorActionPreference='Stop';Start-Process 'C:\\Users\\o''brien\\report.html'");
});
it("falls back to C:\\Windows for PowerShell when SystemRoot is unset, and to the bare name when absent", () => {
setPlatform("win32");
const originalSystemRoot = process.env.SystemRoot;
const originalSystemRootLower = process.env.SYSTEMROOT;
delete process.env.SystemRoot;
delete process.env.SYSTEMROOT;
try {
const defaultPath = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
const existsSpy = vi.spyOn(fs, "existsSync").mockImplementation(candidate => candidate === defaultPath);
const spawnCalls: SpawnCall[] = [];
spySpawn(spawnCalls);
openPath("https://example.com");
expect(spawnCalls[0]?.cmd[0]).toBe(defaultPath);
expect(spawnCalls).toHaveLength(1);
expect(spawnCalls[0]?.cmd[0]).toBe("C:\\Windows\\System32\\rundll32.exe");
// Exotic layout: resolved path missing → bare name via PATH.
existsSpy.mockReturnValue(false);
openPath("https://example.com");
expect(spawnCalls[1]?.cmd[0]).toBe("powershell.exe");
} finally {
if (originalSystemRoot !== undefined) process.env.SystemRoot = originalSystemRoot;
if (originalSystemRootLower !== undefined) process.env.SYSTEMROOT = originalSystemRootLower;
}
});
it("logs when the opener exits non-zero so Start-Process failures are diagnosable", async () => {
setPlatform("win32");
vi.spyOn(fs, "existsSync").mockReturnValue(true);
const warnSpy = vi.spyOn(piUtils.logger, "warn").mockImplementation((() => {}) as never);
const failing = {
pid: 1,
exited: Promise.resolve(1),
kill: () => true,
} as unknown as Subprocess;
vi.spyOn(Bun, "spawn").mockImplementation(() => failing);
openPath("https://example.com");
await failing.exited;
await Promise.resolve();
expect(warnSpy).toHaveBeenCalledWith(
"External opener exited with non-zero status",
expect.objectContaining({ exitCode: 1 }),
);
});
});