Merge branch 'main' into fix/plugin-directory-extensions

This commit is contained in:
Can Bölük
2026-05-12 06:28:35 +02:00
committed by GitHub
357 changed files with 16519 additions and 9706 deletions
+66 -9
View File
@@ -16,6 +16,13 @@ export interface AsyncJob {
promise: Promise<void>;
resultText?: string;
errorText?: string;
/**
* Registry id of the agent that registered the job (e.g. "0-Main",
* "3-AuthLoader"). Used by scoped cancel/list APIs so a subagent's teardown
* does not cancel its parent's jobs. Undefined for callers that don't
* supply an id (e.g. legacy tests, SDK consumers without an agent context).
*/
ownerId?: string;
}
export interface AsyncJobManagerOptions {
@@ -41,10 +48,38 @@ export interface AsyncJobDeliveryState {
export interface AsyncJobRegisterOptions {
id?: string;
/** Registry id of the agent that owns this job; used to scope cancelAll. */
ownerId?: string;
onProgress?: (text: string, details?: Record<string, unknown>) => void | Promise<void>;
}
/**
* Filter applied to job query/cancel APIs. With `ownerId`, results are
* restricted to jobs registered by that agent (registry id from
* `AgentRegistry`, e.g. "0-Main", "3-AuthLoader").
*/
export interface AsyncJobFilter {
ownerId?: string;
}
export class AsyncJobManager {
static #instance: AsyncJobManager | undefined;
/** Process-global instance shared by internal URL protocol handlers and tools. */
static instance(): AsyncJobManager | undefined {
return AsyncJobManager.#instance;
}
/** Install or clear the process-global instance. */
static setInstance(value: AsyncJobManager | undefined): void {
AsyncJobManager.#instance = value;
}
/** Reset the process-global instance. Test-only. */
static resetForTests(): void {
AsyncJobManager.#instance = undefined;
}
readonly #jobs = new Map<string, AsyncJob>();
readonly #deliveries: AsyncJobDelivery[] = [];
readonly #suppressedDeliveries = new Set<string>();
@@ -56,6 +91,16 @@ export class AsyncJobManager {
#deliveryLoop: Promise<void> | undefined;
#disposed = false;
#filterJobs(jobs: Iterable<AsyncJob>, filter?: AsyncJobFilter): AsyncJob[] {
const ownerId = filter?.ownerId;
if (!ownerId) return Array.from(jobs);
const out: AsyncJob[] = [];
for (const job of jobs) {
if (job.ownerId === ownerId) out.push(job);
}
return out;
}
constructor(options: AsyncJobManagerOptions) {
this.#onJobComplete = options.onJobComplete;
this.#maxRunningJobs = Math.max(1, Math.floor(options.maxRunningJobs ?? DEFAULT_MAX_RUNNING_JOBS));
@@ -95,6 +140,7 @@ export class AsyncJobManager {
label,
abortController,
promise: Promise.resolve(),
ownerId: options?.ownerId,
};
const reportProgress = async (text: string, details?: Record<string, unknown>): Promise<void> => {
@@ -138,9 +184,15 @@ export class AsyncJobManager {
return id;
}
cancel(id: string): boolean {
/**
* Cancel a single job by id. When `filter.ownerId` is set and does not
* match the job's owner, the call is treated as not-found (returns false)
* so cross-agent cancellation is rejected at the manager level.
*/
cancel(id: string, filter?: AsyncJobFilter): boolean {
const job = this.#jobs.get(id);
if (!job) return false;
if (filter?.ownerId && job.ownerId !== filter.ownerId) return false;
if (job.status !== "running") return false;
job.status = "cancelled";
job.abortController.abort();
@@ -152,19 +204,19 @@ export class AsyncJobManager {
return this.#jobs.get(id);
}
getRunningJobs(): AsyncJob[] {
return Array.from(this.#jobs.values()).filter(job => job.status === "running");
getRunningJobs(filter?: AsyncJobFilter): AsyncJob[] {
return this.#filterJobs(this.#jobs.values(), filter).filter(job => job.status === "running");
}
getRecentJobs(limit = 10): AsyncJob[] {
return Array.from(this.#jobs.values())
getRecentJobs(limit = 10, filter?: AsyncJobFilter): AsyncJob[] {
return this.#filterJobs(this.#jobs.values(), filter)
.filter(job => job.status !== "running")
.sort((a, b) => b.startTime - a.startTime)
.slice(0, limit);
}
getAllJobs(): AsyncJob[] {
return Array.from(this.#jobs.values());
getAllJobs(filter?: AsyncJobFilter): AsyncJob[] {
return this.#filterJobs(this.#jobs.values(), filter);
}
getDeliveryState(): AsyncJobDeliveryState {
@@ -221,8 +273,13 @@ export class AsyncJobManager {
return before - this.#deliveries.length;
}
cancelAll(): void {
for (const job of this.getRunningJobs()) {
/**
* Cancel running jobs. With `filter.ownerId` set, cancels only jobs the
* matching agent registered; with no filter, cancels every running job
* (used by `dispose()` to nuke the manager's state).
*/
cancelAll(filter?: AsyncJobFilter): void {
for (const job of this.getRunningJobs(filter)) {
job.status = "cancelled";
job.abortController.abort();
this.#scheduleEviction(job.id);
@@ -209,6 +209,26 @@ export function parseRuleConditionAndScope(frontmatter: RuleFrontmatter): Pick<R
};
}
let activeRules: readonly Rule[] = [];
/**
* Process-global snapshot of rules the active session loaded.
* Read by internal URL protocol handlers (rule://).
*/
export function getActiveRules(): readonly Rule[] {
return activeRules;
}
/** Replace the active rule snapshot. Called once per top-level session. */
export function setActiveRules(value: readonly Rule[]): void {
activeRules = value;
}
/** Reset the active rule snapshot. Test-only. */
export function resetActiveRulesForTests(): void {
activeRules = [];
}
export const ruleCapability = defineCapability<Rule>({
id: "rules",
displayName: "Rules",
@@ -2072,6 +2072,19 @@ export class ModelRegistry {
return this.#models.filter(model => this.#isModelAvailable(model));
}
/**
* Check whether auth is configured for a model's provider.
*
* Mirrors the upstream `@mariozechner/pi-coding-agent` API surface so that
* external plugins/extensions and downstream wrappers (e.g. subagent launch
* paths that pre-flight auth before model resolution) can probe a model
* without resolving an API key. Returns true for keyless providers as well
* as providers with stored credentials. See issue #993.
*/
hasConfiguredAuth(model: Model<Api>): boolean {
return this.#keylessProviders.has(model.provider) || this.authStorage.hasAuth(model.provider);
}
getDiscoverableProviders(): string[] {
const disabledProviders = getDisabledProviderIdsFromSettings();
return this.#discoverableProviders
@@ -16,7 +16,7 @@ import chalk from "chalk";
import MODEL_PRIO from "../priority.json" with { type: "json" };
import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
import { fuzzyMatch } from "../utils/fuzzy";
import { isAuthenticated, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
import type { Settings } from "./settings";
/** Default model IDs for each known provider */
@@ -743,6 +743,12 @@ export function resolveModelOverride(
* `modelRoles.task` pointing at an unqualified id whose only available
* provider variant has no configured credentials — see #985).
*
* Keyless-by-design providers (llama.cpp, ollama, lm-studio) advertise the
* `kNoAuth` sentinel from `getApiKey` to signal that they do not require
* credentials. Those are treated as authenticated here so an explicitly
* configured local model is never silently rerouted to the parent's remote
* provider (see #1008).
*
* If neither the subagent nor the parent has working auth, returns the
* primary resolution unchanged so the existing error path still surfaces
* a meaningful failure downstream.
@@ -764,7 +770,7 @@ export async function resolveModelOverrideWithAuthFallback(
}
const primaryKey = await modelRegistry.getApiKey(primary.model);
if (isAuthenticated(primaryKey)) {
if (primaryKey === kNoAuth || isAuthenticated(primaryKey)) {
return { ...primary, authFallbackUsed: false };
}
@@ -905,7 +905,7 @@ export const SETTINGS_SCHEMA = {
"ask.timeout": {
type: "number",
default: 30,
default: 0,
ui: {
tab: "interaction",
label: "Ask Timeout",
+8
View File
@@ -204,6 +204,7 @@ async function executeSinglePathEntries(
const contentTexts: string[] = [];
const diffTexts: string[] = [];
let firstChangedLine: number | undefined;
let errorCount = 0;
for (let i = 0; i < runs.length; i++) {
const isLast = i === runs.length - 1;
@@ -221,6 +222,7 @@ async function executeSinglePathEntries(
} catch (err) {
const errorText = err instanceof Error ? err.message : String(err);
contentTexts.push(`Error editing ${path}: ${errorText}`);
errorCount++;
}
if (!isLast && onUpdate) {
@@ -230,6 +232,7 @@ async function executeSinglePathEntries(
diff: diffTexts.join("\n"),
firstChangedLine,
},
...(errorCount > 0 ? { isError: true } : {}),
});
}
}
@@ -240,6 +243,11 @@ async function executeSinglePathEntries(
diff: diffTexts.join("\n"),
firstChangedLine,
},
// Any per-entry failure marks the aggregate result as an error so the
// renderer takes the error branch instead of falling through to the
// streaming-edit preview (which displays the *proposed* diff and looks
// indistinguishable from success).
...(errorCount > 0 ? { isError: true } : {}),
};
}
+6 -1
View File
@@ -148,6 +148,8 @@ export interface EditRenderContext {
editDiffPreview?: DiffResult | DiffError;
/** Multi-file streaming diff preview (edits spanning several files) */
perFileDiffPreview?: PerFileDiffPreview[];
/** Raw in-flight edit text shown while a computed diff preview is unavailable */
editStreamingFallback?: string;
/** Function to render diff text with syntax highlighting */
renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
}
@@ -272,7 +274,7 @@ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: T
const parts: string[] = [];
for (const preview of previews) {
if (!preview.diff && !preview.error) continue;
const header = uiTheme.fg("dim", `\n\n\u2500\u2500 ${shortenPath(preview.path)} \u2500\u2500`);
const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`);
if (preview.error) {
parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error, preview.path))}`);
continue;
@@ -306,6 +308,9 @@ function getCallPreview(
if (args.newText || args.patch) {
return renderPlainTextPreview(args.newText ?? args.patch ?? "", uiTheme, rawPath);
}
if (renderContext?.editStreamingFallback) {
return renderContext.editStreamingFallback;
}
return "";
}
+53 -2
View File
@@ -13,14 +13,19 @@
* the injected `editMode` rather than probing argument shape.
*/
import { sanitizeText } from "@oh-my-pi/pi-natives";
import {
ABORT_MARKER,
BEGIN_PATCH_MARKER,
computeHashlineDiff,
computeHashlineSectionDiff,
containsRecognizableHashlineOperations,
END_PATCH_MARKER,
type HashlineInputSection,
splitHashlineInputs,
} from "../hashline";
import type { Theme } from "../modes/theme/theme";
import { replaceTabs, truncateToWidth } from "../tools/render-utils";
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
@@ -61,6 +66,52 @@ export interface EditStreamingStrategy<Args = unknown> {
renderStreamingFallback(args: Args, uiTheme: Theme): string;
}
const STREAMING_FALLBACK_LINES = 12;
const STREAMING_FALLBACK_WIDTH = 80;
function isHashlineHeaderLine(line: string): boolean {
const trimmed = line.trimEnd();
return trimmed.startsWith("@") && trimmed.length > 1;
}
function isHashlineEnvelopeMarkerLine(line: string): boolean {
const trimmed = line.trimEnd();
return trimmed === BEGIN_PATCH_MARKER || trimmed === END_PATCH_MARKER || trimmed === ABORT_MARKER;
}
function trimHashlineStreamingSyntax(lines: string[]): string[] {
let index = lines.findIndex(line => line.trim().length > 0);
if (index === -1) return [];
if (lines[index].trimEnd() === BEGIN_PATCH_MARKER) {
index++;
while (index < lines.length && lines[index].trim().length === 0) index++;
}
if (index < lines.length && isHashlineHeaderLine(lines[index])) {
index++;
}
return lines.slice(index).filter(line => !isHashlineEnvelopeMarkerLine(line));
}
function renderHashlineInputFallback(input: string, uiTheme: Theme): string {
const lines = trimHashlineStreamingSyntax(sanitizeText(input).split("\n"));
if (!lines.some(line => line.trim().length > 0)) return "";
const displayLines = lines.slice(-STREAMING_FALLBACK_LINES);
const hidden = lines.length - displayLines.length;
let text = "\n\n";
text += displayLines
.map(line => uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), STREAMING_FALLBACK_WIDTH)))
.join("\n");
if (hidden > 0) {
text += uiTheme.fg("dim", `\n… (streaming +${hidden} lines)`);
} else {
text += uiTheme.fg("dim", "\n(streaming)");
}
return text;
}
// -----------------------------------------------------------------------------
// Partial-JSON handling
// -----------------------------------------------------------------------------
@@ -273,8 +324,8 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
}
return previews.length > 0 ? previews : null;
},
renderStreamingFallback() {
return "";
renderStreamingFallback(args, uiTheme) {
return typeof args.input === "string" ? renderHashlineInputFallback(args.input, uiTheme) : "";
},
};
@@ -32,9 +32,6 @@ interface VmHelperOptions {
reverse?: boolean;
unique?: boolean;
count?: boolean;
cwd?: string;
timeoutMs?: number;
timeout?: number;
}
interface VmContextState {
@@ -303,41 +300,6 @@ async function createHelpers(state: VmContextState) {
emitStatus(state, { op: "tree", path: root, entries: entryCount, preview: result.slice(0, 1000) });
return result;
},
run: async (
command: string,
options: VmHelperOptions = {},
): Promise<{ stdout: string; stderr: string; exit_code: number }> => {
const cwd = options.cwd ? resolvePath(state, options.cwd) : state.cwd;
const timeoutMs =
typeof options.timeoutMs === "number"
? options.timeoutMs
: typeof options.timeout === "number"
? options.timeout * 1000
: undefined;
const timeoutSignal =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
? AbortSignal.timeout(timeoutMs)
: undefined;
const signal =
state.currentRun?.signal && timeoutSignal
? AbortSignal.any([state.currentRun.signal, timeoutSignal])
: (state.currentRun?.signal ?? timeoutSignal);
const child = Bun.spawn(["bash", "-lc", command], {
cwd,
env: getMergedEnv(state),
stdout: "pipe",
stderr: "pipe",
signal,
});
const [stdout, stderr, exit_code] = await Promise.all([
new Response(child.stdout as ReadableStream<Uint8Array>).text(),
new Response(child.stderr as ReadableStream<Uint8Array>).text(),
child.exited,
]);
const output = `${stdout}${stderr}`.slice(0, 500);
emitStatus(state, { op: "run", cmd: command.slice(0, 120), code: exit_code, output });
return { stdout, stderr, exit_code };
},
env: (key?: string, value?: string): string | Record<string, string> | undefined => {
if (!key) {
const env = Object.fromEntries(Object.entries(getMergedEnv(state)).sort(([a], [b]) => a.localeCompare(b)));
@@ -419,6 +381,7 @@ async function createVmState(
atob,
btoa,
Buffer,
Bun,
process: createProcessSubset(cwd),
require: buildRequire(cwd),
createRequire,
@@ -12,7 +12,6 @@ if (!globalThis.__omp_js_prelude_loaded__) {
const counter = (items, opts = {}) => callHelper("counter", items, toOptions(opts));
const diff = (a, b) => callHelper("diff", a, b);
const tree = (path = ".", opts = {}) => callHelper("tree", path, toOptions(opts));
const run = (cmd, opts = {}) => callHelper("run", cmd, toOptions(opts));
const env = (key, value) => callHelper("env", key, value);
const tool = new Proxy(
@@ -67,6 +66,5 @@ if (!globalThis.__omp_js_prelude_loaded__) {
globalThis.counter = counter;
globalThis.diff = diff;
globalThis.tree = tree;
globalThis.run = run;
globalThis.env = env;
}
+24 -8
View File
@@ -39,6 +39,13 @@ export interface PythonExecutorOptions {
useSharedGateway?: boolean;
/** Session file path for accessing task outputs */
sessionFile?: string;
/**
* Effective artifacts directory for the current session. Subagents share
* the parent's directory, so this can differ from `sessionFile`'s sibling
* dir. When present, exported to the kernel as `PI_ARTIFACTS_DIR` and
* preferred over `PI_SESSION_FILE`-derived paths.
*/
artifactsDir?: string;
/** Artifact path/id for full output storage */
artifactPath?: string;
artifactId?: string;
@@ -102,6 +109,7 @@ let cleanupTimer: NodeJS.Timeout | null = null;
interface KernelSessionExecutionOptions {
useSharedGateway?: boolean;
sessionFile?: string;
artifactsDir?: string;
signal?: AbortSignal;
deadlineMs?: number;
kernelOwnerId?: string;
@@ -123,6 +131,19 @@ function getExecutionDeadlineMs(options?: Pick<PythonExecutorOptions, "deadlineM
return Date.now() + options.timeoutMs;
}
/**
* Build the env block exposed to the Python kernel. Includes the session file
* (for things that need the raw session path) and the effective artifacts
* directory (preferred by the prelude when resolving output IDs, so subagents
* see the parent's flat dir instead of a non-existent sibling).
*/
function buildKernelEnv(options: { sessionFile?: string; artifactsDir?: string }): Record<string, string> | undefined {
const env: Record<string, string> = {};
if (options.sessionFile) env.PI_SESSION_FILE = options.sessionFile;
if (options.artifactsDir) env.PI_ARTIFACTS_DIR = options.artifactsDir;
return Object.keys(env).length > 0 ? env : undefined;
}
function getRemainingTimeoutMs(deadlineMs?: number): number | undefined {
if (deadlineMs === undefined) return undefined;
return deadlineMs - Date.now();
@@ -523,9 +544,7 @@ async function createKernelSession(
isRetry?: boolean,
): Promise<KernelSession> {
requireRemainingTimeoutMs(options.deadlineMs);
const env: Record<string, string> | undefined = options.sessionFile
? { PI_SESSION_FILE: options.sessionFile }
: undefined;
const env = buildKernelEnv(options);
const startOptions = buildKernelStartOptions(cwd, env, options);
let kernel: PythonKernel;
@@ -586,9 +605,7 @@ async function restartKernelSession(
});
}
}
const env: Record<string, string> | undefined = options.sessionFile
? { PI_SESSION_FILE: options.sessionFile }
: undefined;
const env = buildKernelEnv(options);
const startOptions = buildKernelStartOptions(cwd, env, options);
const kernel = await PythonKernel.start(startOptions);
session.kernel = kernel;
@@ -936,10 +953,9 @@ export async function executePython(code: string, options?: PythonExecutorOption
await ensureKernelAvailable(cwd);
const kernelMode = executionOptions.kernelMode ?? "session";
const sessionFile = executionOptions.sessionFile;
if (kernelMode === "per-call") {
const env: Record<string, string> | undefined = sessionFile ? { PI_SESSION_FILE: sessionFile } : undefined;
const env = buildKernelEnv(executionOptions);
requireRemainingTimeoutMs(deadlineMs);
const startOptions = buildKernelStartOptions(cwd, env, executionOptions);
const kernel = await PythonKernel.start(startOptions);
@@ -35,6 +35,7 @@ export default {
kernelMode,
useSharedGateway,
sessionFile: opts.sessionFile,
artifactsDir: opts.session.getArtifactsDir?.() ?? undefined,
kernelOwnerId: opts.kernelOwnerId,
reset: opts.reset,
artifactPath: opts.artifactPath,
+11 -80
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
if "__omp_prelude_loaded__" not in globals():
__omp_prelude_loaded__ = True
from pathlib import Path
import os, json, shutil, subprocess
import os, json
from IPython.display import display as _ipy_display, JSON
_PRESENTABLE_REPRS = (
@@ -79,79 +79,6 @@ if "__omp_prelude_loaded__" not in globals():
f.write(content)
_emit_status("append", path=str(p), chars=len(content))
return p
class ShellResult:
"""Result from shell command execution."""
__slots__ = ("args", "stdout", "stderr", "returncode")
def __init__(self, args: str, stdout: str, stderr: str, returncode: int):
self.args = args
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
@property
def code(self) -> int:
return self.returncode
@property
def exit_code(self) -> int:
return self.returncode
def check_returncode(self) -> None:
if self.returncode != 0:
raise subprocess.CalledProcessError(
self.returncode, self.args, output=self.stdout, stderr=self.stderr
)
def __repr__(self):
if self.returncode == 0:
return ""
return f"exit code {self.returncode}"
def __bool__(self):
return self.returncode == 0
def _make_shell_result(proc: subprocess.CompletedProcess[str], cmd: str) -> ShellResult:
"""Create ShellResult and emit status."""
output = proc.stdout + proc.stderr if proc.stderr else proc.stdout
_emit_status("sh", cmd=cmd[:80], code=proc.returncode, output=output[:500])
return ShellResult(cmd, proc.stdout, proc.stderr, proc.returncode)
import signal as _signal
def _run_with_interrupt(args: list[str], cwd: str | None, timeout: int | None, cmd: str) -> ShellResult:
"""Run subprocess with proper interrupt handling."""
proc = subprocess.Popen(
args,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
start_new_session=True,
)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except KeyboardInterrupt:
os.killpg(proc.pid, _signal.SIGINT)
try:
stdout, stderr = proc.communicate(timeout=2)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, _signal.SIGKILL)
stdout, stderr = proc.communicate()
result = subprocess.CompletedProcess(args, -_signal.SIGINT, stdout, stderr)
return _make_shell_result(result, cmd)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, _signal.SIGKILL)
stdout, stderr = proc.communicate()
result = subprocess.CompletedProcess(args, -_signal.SIGKILL, stdout, stderr)
return _make_shell_result(result, cmd)
result = subprocess.CompletedProcess(args, proc.returncode, stdout, stderr)
return _make_shell_result(result, cmd)
def run(cmd: str, *, cwd: str | Path | None = None, timeout: int | None = None) -> ShellResult:
"""Run a shell command. Returns ShellResult with stdout/stderr and returncode/exit_code fields."""
shell_path = shutil.which("bash") or shutil.which("sh") or "/bin/sh"
args = [shell_path, "-c", cmd]
return _run_with_interrupt(args, str(cwd) if cwd else None, timeout, cmd)
def sort(text: str, *, reverse: bool = False, unique: bool = False) -> str:
"""Sort lines of text."""
@@ -268,12 +195,16 @@ if "__omp_prelude_loaded__" not in globals():
output('explore_0', offset=10, limit=20) # Lines 10-29
output('explore_0', 'reviewer_1') # Read multiple outputs
"""
session_file = os.environ.get("PI_SESSION_FILE")
if not session_file:
_emit_status("output", error="No session file available")
raise RuntimeError("No session - output artifacts unavailable")
artifacts_dir = session_file.rsplit(".", 1)[0] # Strip .jsonl extension
# Prefer PI_ARTIFACTS_DIR so subagents resolve through the parent's
# shared artifacts dir; fall back to deriving from PI_SESSION_FILE
# for legacy callers / top-level sessions where the two coincide.
artifacts_dir = os.environ.get("PI_ARTIFACTS_DIR")
if not artifacts_dir:
session_file = os.environ.get("PI_SESSION_FILE")
if not session_file:
_emit_status("output", error="No session file available")
raise RuntimeError("No session - output artifacts unavailable")
artifacts_dir = session_file.rsplit(".", 1)[0] # Strip .jsonl extension
if not Path(artifacts_dir).exists():
_emit_status("output", error="Artifacts directory not found", path=artifacts_dir)
raise RuntimeError(f"No artifacts directory found: {artifacts_dir}")
@@ -179,6 +179,10 @@
color: var(--accent);
}
.tree-role-developer {
color: var(--dim);
}
.tree-role-assistant {
color: var(--success);
}
@@ -316,6 +320,14 @@
position: relative;
}
.user-message.developer-message {
opacity: 0.7;
}
.user-message.developer-message .markdown-content {
color: var(--dim);
}
.assistant-message {
padding: 0;
position: relative;
File diff suppressed because one or more lines are too long
@@ -456,6 +456,10 @@
const content = truncate(normalize(extractContent(msg.content)));
return labelHtml + `<span class="tree-role-user">user:</span> ${escapeHtml(content)}`;
}
if (msg.role === 'developer') {
const content = truncate(normalize(extractContent(msg.content)));
return labelHtml + `<span class="tree-role-developer">developer:</span> ${escapeHtml(content)}`;
}
if (msg.role === 'assistant') {
const textContent = truncate(normalize(extractContent(msg.content)));
if (textContent) {
@@ -1648,6 +1652,18 @@
return html;
}
if (msg.role === 'developer') {
let html = `<div class="user-message developer-message" id="${entryId}">${copyBtnHtml}${tsHtml}`;
const content = msg.content;
const text = typeof content === 'string' ? content :
content.filter(c => c.type === 'text').map(c => c.text).join('\n');
if (text.trim()) {
html += `<div class="markdown-content">${safeMarkedParse(text)}</div>`;
}
html += '</div>';
return html;
}
if (msg.role === 'assistant') {
let html = `<div class="assistant-message" id="${entryId}">${copyBtnHtml}${tsHtml}`;
@@ -1750,7 +1766,7 @@
// ============================================================
function computeStats(entryList) {
let userMessages = 0, assistantMessages = 0, toolResults = 0;
let userMessages = 0, developerMessages = 0, assistantMessages = 0, toolResults = 0;
let customMessages = 0, compactions = 0, branchSummaries = 0, toolCalls = 0;
const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
@@ -1760,6 +1776,7 @@
if (entry.type === 'message') {
const msg = entry.message;
if (msg.role === 'user') userMessages++;
if (msg.role === 'developer') developerMessages++;
if (msg.role === 'assistant') {
assistantMessages++;
if (msg.model) models.add(msg.provider ? `${msg.provider}/${msg.model}` : msg.model);
@@ -1787,7 +1804,7 @@
}
}
return { userMessages, assistantMessages, toolResults, customMessages, compactions, branchSummaries, toolCalls, tokens, cost, models: Array.from(models) };
return { userMessages, developerMessages, assistantMessages, toolResults, customMessages, compactions, branchSummaries, toolCalls, tokens, cost, models: Array.from(models) };
}
const globalStats = computeStats(entries);
@@ -1803,6 +1820,7 @@
const msgParts = [];
if (globalStats.userMessages) msgParts.push(`${globalStats.userMessages} user`);
if (globalStats.developerMessages) msgParts.push(`${globalStats.developerMessages} developer`);
if (globalStats.assistantMessages) msgParts.push(`${globalStats.assistantMessages} assistant`);
if (globalStats.toolResults) msgParts.push(`${globalStats.toolResults} tool results`);
if (globalStats.customMessages) msgParts.push(`${globalStats.customMessages} custom`);
@@ -28,6 +28,26 @@ export interface LoadSkillsResult {
warnings: SkillWarning[];
}
let activeSkills: readonly Skill[] = [];
/**
* Process-global snapshot of skills the active session loaded.
* Read by internal URL protocol handlers (skill://).
*/
export function getActiveSkills(): readonly Skill[] {
return activeSkills;
}
/** Replace the active skill snapshot. Called once per top-level session. */
export function setActiveSkills(value: readonly Skill[]): void {
activeSkills = value;
}
/** Reset the active skill snapshot. Test-only. */
export function resetActiveSkillsForTests(): void {
activeSkills = [];
}
export interface LoadSkillsFromDirOptions {
/** Directory to scan for skills */
dir: string;
@@ -1,7 +1,10 @@
/**
* Protocol handler for agent:// URLs.
*
* Resolves agent output IDs to artifact files in the session directory.
* Resolves agent output IDs against the artifacts directories of every active
* session. Parents and subagents share outputs via this registry: a subagent
* can read its parent's output IDs because both sessions are registered in
* the shared context.
*
* URL forms:
* - agent://<id> - Full output content
@@ -11,27 +14,27 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { AgentRegistry } from "../registry/agent-registry";
import { applyQuery, pathToQuery } from "./json-query";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
export interface AgentProtocolOptions {
/**
* Returns the artifacts directory path, or null if no session.
* Artifacts directory is the session file path without .jsonl extension.
*/
getArtifactsDir: () => string | null;
}
/**
* List available output IDs in artifacts directory.
* Snapshot of artifacts dirs for every registered session, deduped.
*
* Prefers `sessionManager.getArtifactsDir()` because subagents adopt the
* parent's manager and report the parent's dir there; dedup then collapses
* the whole agent tree to one entry. Falls back to the raw session file
* when no live session reference is attached.
*/
async function listAvailableOutputs(artifactsDir: string): Promise<string[]> {
try {
const files = await fs.readdir(artifactsDir);
return files.filter(f => f.endsWith(".md")).map(f => f.replace(".md", ""));
} catch {
return [];
function artifactsDirsFromRegistry(): string[] {
const dirs: string[] = [];
for (const ref of AgentRegistry.global().list()) {
const dir =
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
if (!dir) continue;
if (!dirs.includes(dir)) dirs.push(dir);
}
return dirs;
}
/**
@@ -44,30 +47,12 @@ export class AgentProtocolHandler implements ProtocolHandler {
readonly scheme = "agent";
readonly immutable = true;
constructor(private readonly options: AgentProtocolOptions) {}
async resolve(url: InternalUrl): Promise<InternalResource> {
const artifactsDir = this.options.getArtifactsDir();
if (!artifactsDir) {
throw new Error("No session - agent outputs unavailable");
}
try {
await fs.stat(artifactsDir);
} catch (err) {
if (isEnoent(err)) {
throw new Error("No artifacts directory found");
}
throw err;
}
// Extract output ID from host
const outputId = url.rawHost || url.hostname;
if (!outputId) {
throw new Error("agent:// URL requires an output ID: agent://<id>");
}
// Check for conflicting extraction methods
const urlPath = url.pathname;
const queryParam = url.searchParams.get("q");
const hasPathExtraction = urlPath && urlPath !== "/" && urlPath !== "";
@@ -77,28 +62,57 @@ export class AgentProtocolHandler implements ProtocolHandler {
throw new Error("agent:// URL cannot combine path extraction with ?q=");
}
// Load the output file
const outputPath = path.join(artifactsDir, `${outputId}.md`);
try {
await fs.stat(outputPath);
} catch (err) {
if (isEnoent(err)) {
const available = await listAvailableOutputs(artifactsDir);
const availableStr = available.length > 0 ? available.join(", ") : "none";
throw new Error(`Not found: ${outputId}\nAvailable: ${availableStr}`);
}
throw err;
const dirs = artifactsDirsFromRegistry();
if (dirs.length === 0) {
throw new Error("No session - agent outputs unavailable");
}
const rawContent = await Bun.file(outputPath).text();
const notes: string[] = [];
let foundPath: string | undefined;
let anyDirExists = false;
const availableIds = new Set<string>();
// Handle extraction
for (const dir of dirs) {
try {
await fs.stat(dir);
anyDirExists = true;
} catch (err) {
if (isEnoent(err)) continue;
throw err;
}
const candidate = path.join(dir, `${outputId}.md`);
try {
await fs.stat(candidate);
foundPath = candidate;
break;
} catch (err) {
if (!isEnoent(err)) throw err;
try {
const files = await fs.readdir(dir);
for (const f of files) {
if (f.endsWith(".md")) availableIds.add(f.replace(/\.md$/, ""));
}
} catch {
// Listing failures are non-fatal; continue searching.
}
}
}
if (!anyDirExists) {
throw new Error("No artifacts directory found");
}
if (!foundPath) {
const availableStr = availableIds.size > 0 ? [...availableIds].join(", ") : "none";
throw new Error(`Not found: ${outputId}\nAvailable: ${availableStr}`);
}
const rawContent = await Bun.file(foundPath).text();
const notes: string[] = [];
let content = rawContent;
let contentType: InternalResource["contentType"] = "text/markdown";
if (hasPathExtraction || hasQueryExtraction) {
// Parse JSON
let jsonValue: unknown;
try {
jsonValue = JSON.parse(rawContent);
@@ -107,9 +121,7 @@ export class AgentProtocolHandler implements ProtocolHandler {
throw new Error(`Output ${outputId} is not valid JSON: ${message}`);
}
// Convert path to query if needed
const query = hasPathExtraction ? pathToQuery(urlPath) : queryParam!;
if (query) {
const extracted = applyQuery(jsonValue, query);
try {
@@ -119,7 +131,6 @@ export class AgentProtocolHandler implements ProtocolHandler {
}
notes.push(`Extracted: ${query}`);
} else {
// Empty path/query means return full JSON
content = JSON.stringify(jsonValue, null, 2);
}
contentType = "application/json";
@@ -130,7 +141,7 @@ export class AgentProtocolHandler implements ProtocolHandler {
content,
contentType,
size: Buffer.byteLength(content, "utf-8"),
sourcePath: outputPath,
sourcePath: foundPath,
notes,
};
}
@@ -1,8 +1,8 @@
/**
* Protocol handler for artifact:// URLs.
*
* Resolves artifact IDs to files in the session artifacts directory.
* Unlike agent://, artifacts are raw text with no JSON extraction.
* Resolves artifact IDs against the artifacts directories of every active
* session. Unlike agent://, artifacts are raw text with no JSON extraction.
*
* URL form:
* - artifact://<id> - Full artifact content
@@ -12,87 +12,87 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { AgentRegistry } from "../registry/agent-registry";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
export interface ArtifactProtocolOptions {
/**
* Returns the artifacts directory path, or null if no session.
*/
getArtifactsDir: () => string | null;
}
/**
* List available artifact IDs in the directory.
*/
async function listAvailableArtifacts(artifactsDir: string): Promise<string[]> {
try {
const files = await fs.readdir(artifactsDir);
return files
.filter(f => /^\d+\./.test(f))
.map(f => f.split(".")[0])
.sort((a, b) => Number(a) - Number(b));
} catch {
return [];
}
}
/**
* Handler for artifact:// URLs.
* Snapshot of artifacts dirs across all registered sessions, deduped.
*
* Resolves numeric artifact IDs to their text content.
* Artifacts are created by tools when output is truncated.
* Subagents adopt their parent's `ArtifactManager`, so their
* `sessionManager.getArtifactsDir()` returns the parent's dir; dedup
* collapses parent + N subagents to a single entry.
*/
function artifactsDirsFromRegistry(): string[] {
const dirs: string[] = [];
for (const ref of AgentRegistry.global().list()) {
const dir =
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
if (!dir) continue;
if (!dirs.includes(dir)) dirs.push(dir);
}
return dirs;
}
export class ArtifactProtocolHandler implements ProtocolHandler {
readonly scheme = "artifact";
readonly immutable = true;
constructor(private readonly options: ArtifactProtocolOptions) {}
async resolve(url: InternalUrl): Promise<InternalResource> {
const artifactsDir = this.options.getArtifactsDir();
if (!artifactsDir) {
throw new Error("No session - artifacts unavailable");
}
// Extract artifact ID from host
const id = url.rawHost || url.hostname;
if (!id) {
throw new Error("artifact:// URL requires a numeric ID: artifact://0");
}
// Validate ID is numeric
if (!/^\d+$/.test(id)) {
throw new Error(`artifact:// ID must be numeric, got: ${id}`);
}
// Check directory exists and find file matching ID prefix
let files: string[];
try {
files = await fs.readdir(artifactsDir);
} catch (err) {
if (isEnoent(err)) {
throw new Error("No artifacts directory found");
}
throw err;
const dirs = artifactsDirsFromRegistry();
if (dirs.length === 0) {
throw new Error("No session - artifacts unavailable");
}
const match = files.find(f => f.startsWith(`${id}.`));
let foundPath: string | undefined;
let anyDirExists = false;
const availableIds = new Set<string>();
if (!match) {
const available = await listAvailableArtifacts(artifactsDir);
const availableStr = available.length > 0 ? available.join(", ") : "none";
for (const dir of dirs) {
let files: string[];
try {
files = await fs.readdir(dir);
anyDirExists = true;
} catch (err) {
if (isEnoent(err)) continue;
throw err;
}
const match = files.find(f => f.startsWith(`${id}.`));
if (match) {
foundPath = path.join(dir, match);
break;
}
for (const f of files) {
const m = f.match(/^(\d+)\./);
if (m) availableIds.add(m[1]);
}
}
if (!anyDirExists) {
throw new Error("No artifacts directory found");
}
if (!foundPath) {
const sorted = [...availableIds].sort((a, b) => Number(a) - Number(b));
const availableStr = sorted.length > 0 ? sorted.join(", ") : "none";
throw new Error(`Artifact ${id} not found. Available: ${availableStr}`);
}
const filePath = path.join(artifactsDir, match);
const content = await Bun.file(filePath).text();
const content = await Bun.file(foundPath).text();
return {
url: url.href,
content,
contentType: "text/plain",
size: Buffer.byteLength(content, "utf-8"),
sourcePath: filePath,
sourcePath: foundPath,
};
}
}
@@ -1,28 +1,15 @@
/**
* Internal URL routing system for internal protocols like agent://, memory://, skill://, mcp://, and local://.
* Internal URL routing system for internal protocols like agent://, memory://,
* skill://, mcp://, and local://.
*
* This module provides a unified way to resolve internal URLs without
* exposing filesystem paths to the agent.
*
* @example
* ```ts
* import { InternalUrlRouter, AgentProtocolHandler, MemoryProtocolHandler, SkillProtocolHandler } from './internal-urls';
*
* const router = new InternalUrlRouter();
* router.register(new AgentProtocolHandler({ getArtifactsDir: () => sessionDir }));
* router.register(new MemoryProtocolHandler({ getMemoryRoot: () => memoryRoot }));
* router.register(new SkillProtocolHandler({ getSkills: () => skills }));
*
* if (router.canHandle('agent://reviewer_0')) {
* const resource = await router.resolve('agent://reviewer_0');
* console.log(resource.content);
* }
* ```
* One process-global `InternalUrlRouter` is shared across sessions. Handlers
* are stateless; they pull whatever they need (active skills/rules, active
* MCP/async managers, AgentRegistry-listed sessions) from the owning module
* on each resolve call.
*/
export * from "./agent-protocol";
export * from "./artifact-protocol";
export * from "./jobs-protocol";
export * from "./json-query";
export * from "./local-protocol";
export * from "./mcp-protocol";
@@ -1,120 +0,0 @@
import type { AsyncJobManager } from "../async";
import { formatDuration } from "../tools/render-utils";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
export interface JobsProtocolOptions {
getAsyncJobManager: () => AsyncJobManager | undefined;
}
function formatJobTime(startTime: number): string {
return new Date(startTime).toISOString();
}
function formatJobDuration(startTime: number): string {
return formatDuration(Math.max(0, Date.now() - startTime));
}
function normalizeJobId(url: InternalUrl): string {
const host = url.rawHost || url.hostname;
const pathname = (url.rawPathname ?? url.pathname).replace(/^\/+/, "").trim();
if (host && pathname) return `${host}/${pathname}`;
if (host) return host;
return pathname;
}
export class JobsProtocolHandler implements ProtocolHandler {
readonly scheme = "jobs";
readonly immutable = true;
constructor(private readonly options: JobsProtocolOptions) {}
async resolve(url: InternalUrl): Promise<InternalResource> {
const manager = this.options.getAsyncJobManager();
if (!manager) {
const content =
"# Jobs\n\nBackground job support is disabled. Enable `async.enabled` or `bash.autoBackground.enabled` to use jobs://.";
return {
url: url.href,
content,
contentType: "text/markdown",
size: Buffer.byteLength(content, "utf-8"),
};
}
const jobId = normalizeJobId(url);
if (!jobId) {
return this.#listJobs(url, manager);
}
return this.#getJob(url, manager, jobId);
}
#listJobs(url: InternalUrl, manager: AsyncJobManager): InternalResource {
const jobs = manager.getAllJobs();
const running = jobs.filter(job => job.status === "running").sort((a, b) => a.startTime - b.startTime);
const done = jobs.filter(job => job.status !== "running").sort((a, b) => b.startTime - a.startTime);
const ordered = [...running, ...done];
if (ordered.length === 0) {
const content = "# Jobs\n\nNo background jobs found.";
return {
url: url.href,
content,
contentType: "text/markdown",
size: Buffer.byteLength(content, "utf-8"),
};
}
const lines = ordered.map(job => {
return `- \`${job.id}\` [${job.type}] **${job.status}** — ${job.label} \n started: ${formatJobTime(job.startTime)} · duration: ${formatJobDuration(job.startTime)}`;
});
const content = `# Jobs\n\n${ordered.length} job${ordered.length === 1 ? "" : "s"}\n\n${lines.join("\n")}`;
return {
url: url.href,
content,
contentType: "text/markdown",
size: Buffer.byteLength(content, "utf-8"),
};
}
#getJob(url: InternalUrl, manager: AsyncJobManager, jobId: string): InternalResource {
const job = manager.getJob(jobId);
if (!job) {
const content = `# Job Not Found\n\n404: No async job found with id \`${jobId}\`.`;
return {
url: url.href,
content,
contentType: "text/markdown",
size: Buffer.byteLength(content, "utf-8"),
};
}
const sections = [
`# Job ${job.id}`,
"",
`- type: ${job.type}`,
`- status: ${job.status}`,
`- label: ${job.label}`,
`- start: ${formatJobTime(job.startTime)}`,
`- duration: ${formatJobDuration(job.startTime)}`,
];
if (job.status === "completed" && job.resultText) {
sections.push("", "## Result", "", "```", job.resultText, "```");
}
if (job.status === "failed" && job.errorText) {
sections.push("", "## Error", "", "```", job.errorText, "```");
}
if (job.status === "cancelled" && job.errorText) {
sections.push("", "## Cancellation", "", "```", job.errorText, "```");
}
const content = sections.join("\n");
return {
url: url.href,
content,
contentType: "text/markdown",
size: Buffer.byteLength(content, "utf-8"),
};
}
}
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { AgentRegistry } from "../registry/agent-registry";
import { parseInternalUrl } from "./parse";
import { validateRelativePath } from "./skill-protocol";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
@@ -136,17 +137,60 @@ export function resolveLocalUrlToPath(input: string | InternalUrl, options: Loca
* Protocol handler for local:// URLs.
*
* URL forms:
* - local:// - Lists all session local files
* - local://<path> - Reads a file under session local root
* - local:// - Lists files at the session local root
* - local://<path> - Reads a file under the session local root
*/
export class LocalProtocolHandler implements ProtocolHandler {
readonly scheme = "local";
readonly immutable = false;
constructor(private readonly options: LocalProtocolOptions) {}
static #override: LocalProtocolOptions | undefined;
/**
* Install a process-global override that wins over the AgentRegistry-based
* derivation. Used by SDK consumers that wire `localProtocolOptions` on
* `createAgentSession` and by subagents that share their parent's root.
*/
static setOverride(value: LocalProtocolOptions | undefined): void {
LocalProtocolHandler.#override = value;
}
/** Reset the process-global override. Test-only. */
static resetOverrideForTests(): void {
LocalProtocolHandler.#override = undefined;
}
/**
* Returns the active local-protocol options.
*
* Resolution order:
* 1. Explicit override installed via {@link setOverride} (used by subagents
* that share their parent's root and by SDK consumers with a custom
* artifacts/session id mapping).
* 2. The main session in `AgentRegistry.global()`. Its `SessionManager`
* supplies both `getArtifactsDir` and `getSessionId`.
*/
static resolveOptions(): LocalProtocolOptions | undefined {
const override = LocalProtocolHandler.#override;
if (override) return override;
const main = AgentRegistry.global()
.list()
.find(ref => ref.kind === "main");
const sessionManager = main?.session?.sessionManager;
if (!sessionManager) return undefined;
return {
getArtifactsDir: () => sessionManager.getArtifactsDir(),
getSessionId: () => sessionManager.getSessionId(),
};
}
async resolve(url: InternalUrl): Promise<InternalResource> {
const localRoot = path.resolve(resolveLocalRoot(this.options));
const opts = LocalProtocolHandler.resolveOptions();
if (!opts) {
throw new Error("No session - local:// unavailable");
}
const localRoot = path.resolve(resolveLocalRoot(opts));
await fs.mkdir(localRoot, { recursive: true });
let resolvedRoot: string;
@@ -172,9 +216,7 @@ export class LocalProtocolHandler implements ProtocolHandler {
const realParent = await fs.realpath(parentDir);
ensureWithinRoot(realParent, resolvedRoot);
} catch (error) {
if (!isEnoent(error)) {
throw error;
}
if (!isEnoent(error)) throw error;
}
let realTargetPath: string;
@@ -1,11 +1,7 @@
import type { MCPManager } from "../mcp/manager";
import { MCPManager } from "../mcp/manager";
import type { MCPResourceReadResult } from "../mcp/types";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
export interface McpProtocolOptions {
getMcpManager: () => MCPManager | undefined;
}
function escapeRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -108,10 +104,8 @@ export class McpProtocolHandler implements ProtocolHandler {
readonly scheme = "mcp";
readonly immutable = true;
constructor(private readonly options: McpProtocolOptions) {}
async resolve(url: InternalUrl): Promise<InternalResource> {
const mcpManager = this.options.getMcpManager();
const mcpManager = MCPManager.instance();
if (!mcpManager) {
throw new Error("No MCP manager available. MCP servers may not be configured.");
}
@@ -1,6 +1,8 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
import { getMemoryRoot } from "../memories";
import { AgentRegistry } from "../registry/agent-registry";
import { validateRelativePath } from "./skill-protocol";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
@@ -8,13 +10,20 @@ const DEFAULT_MEMORY_FILE = "memory_summary.md";
const MEMORY_NAMESPACE = "root";
/**
* Options for the memory:// URL protocol.
* Snapshot of memory roots for every registered session, deduped.
* Each session has its own cwd (possibly a worktree), so subagents and main
* may see different roots.
*/
export interface MemoryProtocolOptions {
/**
* Returns the absolute path to the current project's memory root.
*/
getMemoryRoot: () => string;
function memoryRootsFromRegistry(): string[] {
const agentDir = getAgentDir();
const roots: string[] = [];
for (const ref of AgentRegistry.global().list()) {
const sm = ref.session?.sessionManager;
if (!sm) continue;
const root = getMemoryRoot(agentDir, sm.getCwd());
if (root && !roots.includes(root)) roots.push(root);
}
return roots;
}
function ensureWithinRoot(targetPath: string, rootPath: string): void {
@@ -61,74 +70,95 @@ export function resolveMemoryUrlToPath(url: InternalUrl, memoryRoot: string): st
return path.resolve(memoryRoot, relativePath);
}
async function tryResolveInRoot(url: InternalUrl, memoryRoot: string): Promise<InternalResource | undefined> {
const resolved = path.resolve(memoryRoot);
let resolvedRoot: string;
try {
resolvedRoot = await fs.realpath(resolved);
} catch (error) {
if (isEnoent(error)) return undefined;
throw error;
}
const targetPath = resolveMemoryUrlToPath(url, resolvedRoot);
ensureWithinRoot(targetPath, resolvedRoot);
const parentDir = path.dirname(targetPath);
try {
const realParent = await fs.realpath(parentDir);
ensureWithinRoot(realParent, resolvedRoot);
} catch (error) {
if (!isEnoent(error)) throw error;
}
let realTargetPath: string;
try {
realTargetPath = await fs.realpath(targetPath);
} catch (error) {
if (isEnoent(error)) return undefined;
throw error;
}
ensureWithinRoot(realTargetPath, resolvedRoot);
const stat = await fs.stat(realTargetPath);
if (!stat.isFile()) {
throw new Error(`memory:// URL must resolve to a file: ${url.href}`);
}
const content = await Bun.file(realTargetPath).text();
const ext = path.extname(realTargetPath).toLowerCase();
const contentType: InternalResource["contentType"] = ext === ".md" ? "text/markdown" : "text/plain";
return {
url: url.href,
content,
contentType,
size: Buffer.byteLength(content, "utf-8"),
sourcePath: realTargetPath,
notes: [],
};
}
/**
* Protocol handler for memory:// URLs.
*
* URL forms:
* - memory://root - Reads memory_summary.md
* - memory://root/<path> - Reads a relative file under memory root
* Walks every active session's memory root. Worktree-based subagents have
* their own root; first one containing the file wins. Parent and subagent
* sharing a cwd see the same file regardless of order.
*/
export class MemoryProtocolHandler implements ProtocolHandler {
readonly scheme = "memory";
readonly immutable = true;
constructor(private readonly options: MemoryProtocolOptions) {}
async resolve(url: InternalUrl): Promise<InternalResource> {
const memoryRoot = path.resolve(this.options.getMemoryRoot());
let resolvedRoot: string;
try {
resolvedRoot = await fs.realpath(memoryRoot);
} catch (error) {
if (isEnoent(error)) {
throw new Error(
"Memory artifacts are not available for this project yet. Run a session with memories enabled first.",
);
}
throw error;
const roots = memoryRootsFromRegistry();
if (roots.length === 0) {
throw new Error(
"Memory artifacts are not available for this project yet. Run a session with memories enabled first.",
);
}
const targetPath = resolveMemoryUrlToPath(url, resolvedRoot);
ensureWithinRoot(targetPath, resolvedRoot);
const parentDir = path.dirname(targetPath);
try {
const realParent = await fs.realpath(parentDir);
ensureWithinRoot(realParent, resolvedRoot);
} catch (error) {
if (!isEnoent(error)) {
let anyExists = false;
for (const root of roots) {
try {
await fs.stat(root);
anyExists = true;
} catch (error) {
if (isEnoent(error)) continue;
throw error;
}
const result = await tryResolveInRoot(url, root);
if (result) return result;
}
let realTargetPath: string;
try {
realTargetPath = await fs.realpath(targetPath);
} catch (error) {
if (isEnoent(error)) {
throw new Error(`Memory file not found: ${url.href}`);
}
throw error;
if (!anyExists) {
throw new Error(
"Memory artifacts are not available for this project yet. Run a session with memories enabled first.",
);
}
ensureWithinRoot(realTargetPath, resolvedRoot);
const stat = await fs.stat(realTargetPath);
if (!stat.isFile()) {
throw new Error(`memory:// URL must resolve to a file: ${url.href}`);
}
const content = await Bun.file(realTargetPath).text();
const ext = path.extname(realTargetPath).toLowerCase();
const contentType: InternalResource["contentType"] = ext === ".md" ? "text/markdown" : "text/plain";
return {
url: url.href,
content,
contentType,
size: Buffer.byteLength(content, "utf-8"),
sourcePath: realTargetPath,
notes: [],
};
throw new Error(`Memory file not found: ${url.href}`);
}
}
@@ -1,42 +1,58 @@
/**
* Internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, mcp://, pi://, local://).
*
* One process-global router with one handler per scheme. Access via
* `InternalUrlRouter.instance()`. Handlers are stateless; per-session and
* shared state lives in `./state.ts`.
*/
import { AgentProtocolHandler } from "./agent-protocol";
import { ArtifactProtocolHandler } from "./artifact-protocol";
import { LocalProtocolHandler } from "./local-protocol";
import { McpProtocolHandler } from "./mcp-protocol";
import { MemoryProtocolHandler } from "./memory-protocol";
import { parseInternalUrl } from "./parse";
import { PiProtocolHandler } from "./pi-protocol";
import { RuleProtocolHandler } from "./rule-protocol";
import { SkillProtocolHandler } from "./skill-protocol";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
/**
* Router for internal URL schemes.
*
* Dispatches URLs like `agent://output_id` or `memory://root/memory_summary.md` to
* registered protocol handlers.
*/
export class InternalUrlRouter {
static #instance: InternalUrlRouter | undefined;
#handlers = new Map<string, ProtocolHandler>();
/**
* Register a protocol handler.
* @param handler Handler to register (uses handler.scheme as key)
*/
register(handler: ProtocolHandler): void {
this.#handlers.set(handler.scheme, handler);
constructor() {
this.register(new PiProtocolHandler());
this.register(new AgentProtocolHandler());
this.register(new ArtifactProtocolHandler());
this.register(new MemoryProtocolHandler());
this.register(new LocalProtocolHandler());
this.register(new SkillProtocolHandler());
this.register(new RuleProtocolHandler());
this.register(new McpProtocolHandler());
}
/** Process-global router instance. */
static instance(): InternalUrlRouter {
InternalUrlRouter.#instance ??= new InternalUrlRouter();
return InternalUrlRouter.#instance;
}
/** Reset the global instance in tests. */
static resetForTests(): void {
InternalUrlRouter.#instance = undefined;
}
register(handler: ProtocolHandler): void {
this.#handlers.set(handler.scheme.toLowerCase(), handler);
}
/**
* Check if the router can handle a URL.
* @param input URL string to check
*/
canHandle(input: string): boolean {
const match = input.match(/^([a-z][a-z0-9+.-]*):\/\//i);
if (!match) return false;
const scheme = match[1].toLowerCase();
return this.#handlers.has(scheme);
return this.#handlers.has(match[1].toLowerCase());
}
/**
* Resolve an internal URL to its content.
* @param input URL string (e.g., "agent://reviewer_0", "skill://notion-pages")
* @throws Error if scheme is not registered or resolution fails
*/
async resolve(input: string): Promise<InternalResource> {
const parsed = parseInternalUrl(input);
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
@@ -1,42 +1,24 @@
/**
* Protocol handler for rule:// URLs.
*
* Resolves rule names to their content files.
*
* URL forms:
* - rule://<name> - Reads rule content
*/
import type { Rule } from "../capability/rule";
import { getActiveRules } from "../capability/rule";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
export interface RuleProtocolOptions {
/**
* Returns the currently loaded rules.
*/
getRules: () => readonly Rule[];
}
/**
* Handler for rule:// URLs.
*
* Resolves rule names to their content.
*/
export class RuleProtocolHandler implements ProtocolHandler {
readonly scheme = "rule";
readonly immutable = true;
constructor(private readonly options: RuleProtocolOptions) {}
async resolve(url: InternalUrl): Promise<InternalResource> {
const rules = this.options.getRules();
const rules = getActiveRules();
// Extract rule name from host
const ruleName = url.rawHost || url.hostname;
if (!ruleName) {
throw new Error("rule:// URL requires a rule name: rule://<name>");
}
// Find the rule
const rule = rules.find(r => r.name === ruleName);
if (!rule) {
const available = rules.map(r => r.name);
@@ -8,19 +8,9 @@
* - skill://<name>/<path> - Reads relative path within skill's baseDir
*/
import * as path from "node:path";
import type { Skill } from "../extensibility/skills";
import { getActiveSkills } from "../extensibility/skills";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
export interface SkillProtocolOptions {
/**
* Returns the currently loaded skills.
*/
getSkills: () => readonly Skill[];
}
/**
* Get content type based on file extension.
*/
function getContentType(filePath: string): InternalResource["contentType"] {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".md") return "text/markdown";
@@ -43,25 +33,19 @@ export function validateRelativePath(relativePath: string): void {
/**
* Handler for skill:// URLs.
*
* Resolves skill names to their content files.
*/
export class SkillProtocolHandler implements ProtocolHandler {
readonly scheme = "skill";
readonly immutable = true;
constructor(private readonly options: SkillProtocolOptions) {}
async resolve(url: InternalUrl): Promise<InternalResource> {
const skills = this.options.getSkills();
const skills = getActiveSkills();
// Extract skill name from host
const skillName = url.rawHost || url.hostname;
if (!skillName) {
throw new Error("skill:// URL requires a skill name: skill://<name>");
}
// Find the skill
const skill = skills.find(s => s.name === skillName);
if (!skill) {
const available = skills.map(s => s.name);
@@ -69,41 +53,34 @@ export class SkillProtocolHandler implements ProtocolHandler {
throw new Error(`Unknown skill: ${skillName}\nAvailable: ${availableStr}`);
}
// Determine the file to read
let targetPath: string;
const urlPath = url.pathname;
const hasRelativePath = urlPath && urlPath !== "/" && urlPath !== "";
if (hasRelativePath) {
// Read relative path within skill's baseDir
const relativePath = decodeURIComponent(urlPath.slice(1)); // Remove leading /
const relativePath = decodeURIComponent(urlPath.slice(1));
validateRelativePath(relativePath);
targetPath = path.join(skill.baseDir, relativePath);
// Verify the resolved path is still within baseDir
const resolvedPath = path.resolve(targetPath);
const resolvedBaseDir = path.resolve(skill.baseDir);
if (!resolvedPath.startsWith(resolvedBaseDir + path.sep) && resolvedPath !== resolvedBaseDir) {
throw new Error("Path traversal is not allowed");
}
} else {
// Read SKILL.md
targetPath = skill.filePath;
}
// Read the file
const file = Bun.file(targetPath);
if (!(await file.exists())) {
throw new Error(`File not found: ${targetPath}`);
}
const content = await file.text();
const contentType = getContentType(targetPath);
return {
url: url.href,
content,
contentType,
contentType: getContentType(targetPath),
size: Buffer.byteLength(content, "utf-8"),
sourcePath: targetPath,
notes: [],
+1 -1
View File
@@ -765,7 +765,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
await mgr.upgradeAllPlugins();
logger.debug(`Auto-upgraded ${updates.length} marketplace plugin(s)`);
} else {
logger.debug(`${updates.length} marketplace plugin update(s) available \u2014 /marketplace upgrade`);
logger.debug(`${updates.length} marketplace plugin update(s) available — /marketplace upgrade`);
}
} catch {
// Silently ignore — network failure, corrupt data, offline.
+17
View File
@@ -132,6 +132,23 @@ export interface MCPDiscoverOptions {
* Manages connections to MCP servers and provides tools to the agent.
*/
export class MCPManager {
static #instance: MCPManager | undefined;
/** Process-global instance shared by internal URL protocol handlers and tools. */
static instance(): MCPManager | undefined {
return MCPManager.#instance;
}
/** Install or clear the process-global instance. */
static setInstance(value: MCPManager | undefined): void {
MCPManager.#instance = value;
}
/** Reset the process-global instance. Test-only. */
static resetForTests(): void {
MCPManager.#instance = undefined;
}
#connections = new Map<string, MCPServerConnection>();
#tools: CustomTool<TSchema, MCPToolDetails>[] = [];
#pendingConnections = new Map<string, Promise<MCPServerConnection>>();
@@ -192,7 +192,7 @@ export class SessionObserverOverlayComponent extends Container {
const statsLine = this.#buildStatsLine(session);
if (statsLine) this.#viewerFooterLines.push(statsLine);
this.#viewerFooterLines.push(
theme.fg("dim", "j/k:scroll Enter:expand [/]/\u2190\u2192:cycle agents Esc/Ctrl+S:close g/G:top/bottom"),
theme.fg("dim", "j/k:scroll Enter:expand [/]/←→:cycle agents Esc/Ctrl+S:close g/G:top/bottom"),
);
// Auto-scroll to bottom if we were at bottom
@@ -452,7 +452,7 @@ export class SessionObserverOverlayComponent extends Container {
// Tool call header
const intentStr = call.intent ? theme.fg("dim", ` ${sanitizeLine(call.intent, TRUNCATE_LENGTHS.SHORT)}`) : "";
lines.push(`${cursor} ${theme.fg("accent", "\u25B8")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`);
lines.push(`${cursor} ${theme.fg("accent", "▸")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`);
// Key arguments
const argSummary = this.#formatToolArgs(call.name, call.arguments);
@@ -665,6 +665,12 @@ export class ToolExecutionComponent extends Container {
context.perFileDiffPreview = previews;
}
}
if (!previews?.some(preview => preview.diff)) {
const editMode = this.#editMode;
const strategy = editMode ? EDIT_MODE_STRATEGIES[editMode] : undefined;
const fallback = strategy?.renderStreamingFallback(this.#args, theme);
if (fallback) context.editStreamingFallback = fallback;
}
context.renderDiff = renderDiff;
}
@@ -539,6 +539,10 @@ class TreeList implements Component {
const msgWithContent = msg as { content?: unknown };
const content = normalize(this.#extractContent(msgWithContent.content));
result = theme.fg("accent", "user: ") + content;
} else if (role === "developer") {
const msgWithContent = msg as { content?: unknown };
const content = normalize(this.#extractContent(msgWithContent.content));
result = theme.fg("dim", "developer: ") + theme.fg("muted", content);
} else if (role === "assistant") {
const msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string };
const textContent = normalize(this.#extractContent(msgWithContent.content));
@@ -1,6 +1,6 @@
import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
import { Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui";
import { type Component, Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui";
import { settings } from "../../config/settings";
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
import { ReadToolGroupComponent } from "../../modes/components/read-tool-group";
@@ -15,6 +15,8 @@ import type { ExitPlanModeDetails } from "../../tools";
type AgentSessionEventKind = AgentSessionEvent["type"];
const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
type AgentSessionEventHandlers = {
[E in AgentSessionEventKind]: (event: Extract<AgentSessionEvent, { type: E }>) => Promise<void>;
};
@@ -29,6 +31,7 @@ export class EventController {
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
#idleCompactionTimer?: NodeJS.Timeout;
#ircExpiryTimers = new Map<string, NodeJS.Timeout>();
#handlers: AgentSessionEventHandlers;
constructor(private ctx: InteractiveModeContext) {
@@ -59,6 +62,10 @@ export class EventController {
dispose(): void {
this.#cancelIdleCompaction();
for (const timer of this.#ircExpiryTimers.values()) {
clearTimeout(timer);
}
this.#ircExpiryTimers.clear();
}
#resetReadGroup(): void {
@@ -222,10 +229,24 @@ export class EventController {
}
this.#renderedCustomMessages.add(signature);
this.#resetReadGroup();
this.ctx.addMessageToChat(event.message);
const components = this.ctx.addMessageToChat(event.message);
this.#scheduleIrcExpiry(signature, components);
this.ctx.ui.requestRender();
}
#scheduleIrcExpiry(signature: string, components: Component[]): void {
if (components.length === 0 || this.#ircExpiryTimers.has(signature)) return;
const timer = setTimeout(() => {
this.#ircExpiryTimers.delete(signature);
for (const component of components) {
this.ctx.chatContainer.removeChild(component);
}
this.ctx.ui.requestRender();
}, IRC_MESSAGE_VISIBLE_TTL_MS);
timer.unref?.();
this.#ircExpiryTimers.set(signature, timer);
}
async #handleNotice(event: Extract<AgentSessionEvent, { type: "notice" }>): Promise<void> {
const message = event.source ? `${event.source}: ${event.message}` : event.message;
if (event.level === "error") {
@@ -1238,12 +1238,12 @@ export class MCPCommandController {
? theme.fg("muted", "Connecting")
: theme.fg("warning", "Not connected yet");
this.#showMessage(
["", theme.fg("success", `\u2713 Enabled "${name}"`), "", ` Status: ${status}`, ""].join("\n"),
["", theme.fg("success", `✓ Enabled "${name}"`), "", ` Status: ${status}`, ""].join("\n"),
);
} else {
await this.ctx.mcpManager?.disconnectServer(name);
await this.ctx.session.refreshMCPTools(this.ctx.mcpManager?.getTools() ?? []);
this.#showMessage(["", theme.fg("success", `\u2713 Disabled "${name}"`), ""].join("\n"));
this.#showMessage(["", theme.fg("success", `✓ Disabled "${name}"`), ""].join("\n"));
}
return;
}
@@ -1429,12 +1429,9 @@ export class MCPCommandController {
await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
const serverTools = this.ctx.mcpManager.getTools().filter(t => t.mcpServerName === name);
this.#showMessage(
[
["\n", theme.fg("success", `✓ Reconnected to "${name}"`), ` Tools: ${serverTools.length}`, "\n"].join(
"\n",
theme.fg("success", `\u2713 Reconnected to "${name}"`),
` Tools: ${serverTools.length}`,
"\n",
].join("\n"),
),
);
} else {
this.ctx.showError(`Failed to reconnect to "${name}". Check server status and logs.`);
@@ -1589,8 +1586,8 @@ export class MCPCommandController {
hasAny = true;
lines.push(`${theme.fg("accent", name)}:`);
const check = theme.fg("success", "\u2713");
const cross = theme.fg("dim", "\u2717");
const check = theme.fg("success", "✓");
const cross = theme.fg("dim", "✗");
if (supportsToolsChanged) lines.push(` ${check} tools/list_changed`);
if (supportsResourcesChanged) lines.push(` ${check} resources/list_changed`);
if (supportsPromptsChanged) lines.push(` ${check} prompts/list_changed`);
@@ -1607,7 +1604,7 @@ export class MCPCommandController {
lines.push(` ${check} resources/subscribe ${subStatus}`);
if (enabled && subscribedUris && subscribedUris.size > 0) {
for (const uri of subscribedUris) {
lines.push(` ${theme.fg("success", "\u2713")} ${theme.fg("dim", uri)}`);
lines.push(` ${theme.fg("success", "✓")} ${theme.fg("dim", uri)}`);
}
}
} else if (supportsResources) {
@@ -1502,8 +1502,8 @@ export class InteractiveMode implements InteractiveModeContext {
return this.#uiHelpers.isKnownSlashCommand(text);
}
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
this.#uiHelpers.addMessageToChat(message, options);
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): Component[] {
return this.#uiHelpers.addMessageToChat(message, options);
}
renderSessionContext(
+27 -27
View File
@@ -387,51 +387,51 @@ const NERD_SYMBOLS: SymbolMap = {
"nav.back": "\uf060",
// Tree Connectors (same as unicode)
// pick: ├─ | alt: ├╴ ├╌ ╠═ ┣━
"tree.branch": "\u251c\u2500",
"tree.branch": "├─",
// pick: └─ | alt: └╴ └╌ ╚═ ┗━
"tree.last": "\u2514\u2500",
"tree.last": "└─",
// pick: │ | alt: ┃ ║ ▏ ▕
"tree.vertical": "\u2502",
"tree.vertical": "│",
// pick: ─ | alt: ━ ═ ╌ ┄
"tree.horizontal": "\u2500",
"tree.horizontal": "─",
// pick: └ | alt: ╰ ⎿ ↳
"tree.hook": "\u2514",
"tree.hook": "└",
// Box Drawing - Rounded (same as unicode)
// pick: ╭ | alt: ┌ ┏ ╔
"boxRound.topLeft": "\u256d",
"boxRound.topLeft": "╭",
// pick: ╮ | alt: ┐ ┓ ╗
"boxRound.topRight": "\u256e",
"boxRound.topRight": "╮",
// pick: ╰ | alt: └ ┗ ╚
"boxRound.bottomLeft": "\u2570",
"boxRound.bottomLeft": "╰",
// pick: ╯ | alt: ┘ ┛ ╝
"boxRound.bottomRight": "\u256f",
"boxRound.bottomRight": "╯",
// pick: ─ | alt: ━ ═ ╌
"boxRound.horizontal": "\u2500",
"boxRound.horizontal": "─",
// pick: │ | alt: ┃ ║ ▏
"boxRound.vertical": "\u2502",
"boxRound.vertical": "│",
// Box Drawing - Sharp (same as unicode)
// pick: ┌ | alt: ┏ ╭ ╔
"boxSharp.topLeft": "\u250c",
"boxSharp.topLeft": "┌",
// pick: ┐ | alt: ┓ ╮ ╗
"boxSharp.topRight": "\u2510",
"boxSharp.topRight": "┐",
// pick: └ | alt: ┗ ╰ ╚
"boxSharp.bottomLeft": "\u2514",
"boxSharp.bottomLeft": "└",
// pick: ┘ | alt: ┛ ╯ ╝
"boxSharp.bottomRight": "\u2518",
"boxSharp.bottomRight": "┘",
// pick: ─ | alt: ━ ═ ╌
"boxSharp.horizontal": "\u2500",
"boxSharp.horizontal": "─",
// pick: │ | alt: ┃ ║ ▏
"boxSharp.vertical": "\u2502",
"boxSharp.vertical": "│",
// pick: ┼ | alt: ╋ ╬ ┿
"boxSharp.cross": "\u253c",
"boxSharp.cross": "┼",
// pick: ┬ | alt: ╦ ┯ ┳
"boxSharp.teeDown": "\u252c",
"boxSharp.teeDown": "┬",
// pick: ┴ | alt: ╩ ┷ ┻
"boxSharp.teeUp": "\u2534",
"boxSharp.teeUp": "┴",
// pick: ├ | alt: ╠ ┝ ┣
"boxSharp.teeRight": "\u251c",
"boxSharp.teeRight": "├",
// pick: ┤ | alt: ╣ ┥ ┫
"boxSharp.teeLeft": "\u2524",
"boxSharp.teeLeft": "┤",
// Separators - Nerd Font specific
// pick:  | alt:   
"sep.powerline": "\ue0b0",
@@ -446,7 +446,7 @@ const NERD_SYMBOLS: SymbolMap = {
// pick:  | alt: 
"sep.powerlineThinRight": "\ue0b3",
// pick: █ | alt: ▓ ▒ ░ ▉ ▌
"sep.block": "\u2588",
"sep.block": "█",
// pick: space | alt: ␠ ·
"sep.space": " ",
// pick: > | alt: › » ▸
@@ -454,7 +454,7 @@ const NERD_SYMBOLS: SymbolMap = {
// pick: < | alt: ‹ « ◂
"sep.asciiRight": "<",
// pick: · | alt: • ⋅
"sep.dot": " \u00b7 ",
"sep.dot": " · ",
// pick:  | alt: / ∕ ⁄
"sep.slash": "\ue0bb",
// pick:  | alt: │ ┃ |
@@ -545,16 +545,16 @@ const NERD_SYMBOLS: SymbolMap = {
// pick:  | alt:   •
"format.bullet": "\uf111",
// pick: – | alt: — ― -
"format.dash": "\u2013",
"format.dash": "–",
// pick: ⟨ | alt: [ ⟦
"format.bracketLeft": "⟨",
// pick: ⟩ | alt: ] ⟧
"format.bracketRight": "⟩",
// Markdown-specific
// pick: │ | alt: ┃ ║
"md.quoteBorder": "\u2502",
"md.quoteBorder": "│",
// pick: ─ | alt: ━ ═
"md.hrChar": "\u2500",
"md.hrChar": "─",
// pick:  | alt:  •
"md.bullet": "\uf111",
// Language icons (nerd font devicons)
+1 -1
View File
@@ -170,7 +170,7 @@ export interface InteractiveModeContext {
*/
withLocalSubmission<T>(text: string, fn: () => Promise<T>, options?: { imageCount?: number }): Promise<T>;
isKnownSlashCommand(text: string): boolean;
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void;
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): Component[];
renderSessionContext(
sessionContext: SessionContext,
options?: { updateFooter?: boolean; populateHistory?: boolean },
@@ -1,6 +1,6 @@
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ImageContent, Message } from "@oh-my-pi/pi-ai";
import { Spacer, Text, TruncatedText } from "@oh-my-pi/pi-tui";
import { type Component, Spacer, Text, TruncatedText } from "@oh-my-pi/pi-tui";
import { settings } from "../../config/settings";
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
import { BashExecutionComponent } from "../../modes/components/bash-execution";
@@ -70,7 +70,7 @@ export class UiHelpers {
this.ctx.ui.requestRender();
}
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): Component[] {
switch (message.role) {
case "bashExecution": {
const component = new BashExecutionComponent(message.command, this.ctx.ui, message.excludeFromContext);
@@ -147,26 +147,30 @@ export class UiHelpers {
if (message.customType === "irc:incoming") {
const peer = details?.from ?? "?";
body = details?.message ?? "";
arrow = `\u21e6 ${peer}`;
arrow = `⇦ ${peer}`;
} else if (message.customType === "irc:autoreply") {
const peer = details?.to ?? "?";
body = details?.reply ?? "";
arrow = `\u21e8 ${peer} (auto)`;
arrow = `⇨ ${peer}`;
} else {
const from = details?.from ?? "?";
const to = details?.to ?? "?";
body = details?.body ?? "";
const suffix = details?.kind === "reply" ? " (auto)" : "";
arrow = `${from} \u21e8 ${to}${suffix}`;
arrow = `${from} ⇨ ${to}`;
}
const components: Component[] = [];
const header = `${theme.fg("accent", `[IRC] ${arrow}`)}`;
this.ctx.chatContainer.addChild(new Text(header, 1, 0));
const headerComponent = new Text(header, 1, 0);
this.ctx.chatContainer.addChild(headerComponent);
components.push(headerComponent);
if (body) {
for (const line of body.split("\n")) {
this.ctx.chatContainer.addChild(new Text(theme.fg("muted", ` ${line}`), 0, 0));
const lineComponent = new Text(theme.fg("muted", ` ${line}`), 0, 0);
this.ctx.chatContainer.addChild(lineComponent);
components.push(lineComponent);
}
}
break;
return components;
}
const renderer = this.ctx.session.extensionRunner?.getMessageRenderer(message.customType);
// Both HookMessage and CustomMessage have the same structure, cast for compatibility
@@ -240,6 +244,7 @@ export class UiHelpers {
const _exhaustive: never = message;
}
}
return [];
}
/**
@@ -26,6 +26,7 @@ You decompose, dispatch, verify, and iterate. You do **not** edit code. Every fi
6. **Commit policy.** If the task asks for commits or the repo workflow expects them, commit after each green phase with a focused message. Never commit a red tree. Never commit work the user did not ask to commit.
7. **Respawn, do not absorb.** If a subagent returns incomplete or wrong work, spawn a corrective subagent with the specific gap — do not silently fix it yourself.
8. **No scope creep, no scope shrink.** Do not add work the user did not ask for. Do not relabel unfinished items as "follow-up", "v1", or "MVP" to imply completion.
9. **Subagents do not verify, lint, or format.** Every `task` assignment **MUST** instruct the subagent to skip all gates and formatters. Their job is the edit only. You — the orchestrator — run verification and formatting **once** at the end of the phase across the union of changed files. Avoids redundant runs and racing formatter passes.
</rules>
<workflow>
@@ -1,7 +0,0 @@
Today is {{date}}, and the current working directory is '{{cwd}}'.
<critical>
- Each response **MUST** advance the task. There is no stopping condition other than completion.
- You **MUST** default to informed action; do not ask for confirmation when tools or repo context can answer.
- You **MUST** verify the effect of significant behavioral changes before yielding: run the specific test, command, or scenario that covers your change.
</critical>
@@ -32,6 +32,14 @@ Working directory layout (sorted by mtime, recent first; depth ≤ 3):
</workspace-tree>
{{/if}}
Today is {{date}}, and the current working directory is '{{cwd}}'.
<critical>
- Each response **MUST** advance the task. There is no stopping condition other than completion.
- You **MUST** default to informed action; do not ask for confirmation when tools or repo context can answer.
- You **MUST** verify the effect of significant behavioral changes before yielding: run the specific test, command, or scenario that covers your change.
</critical>
{{#if appendPrompt}}
{{appendPrompt}}
{{/if}}
@@ -37,6 +37,13 @@ Assumptions you didn't validate: incidents to debug.
- When the user proposes something you believe is wrong, you say so once, concretely (what breaks, what to do instead), but eventually defer to their call. You **MUST NOT** relitigate.
</communication>
<critical>
- You **MUST NOT** narrate about or even consider, session limits, token/tool budgets, effort estimates, or how much of the task you think you can finish. These are not your concern:
- Even if it was true, start, as if it was not. It's the only way to make progress.
- Execute the work or delegate it.
- You **MUST NOT** speculate about scope inflation ("this is actually a multi-week effort"). You have no comprehension of time, so stop pretending.
</critical>
[ENV]
You operate within the Oh My Pi coding harness.
- Given a task, you **MUST** complete it using the tools available to you.
@@ -53,7 +60,6 @@ With most FS/bash-like tools, static references to them will automatically resol
- `/<path>`: JSON field extraction
- `artifact://<id>`: Artifact content
- `local://<name>.md`: Plan artifacts and shared content with subagents
- `jobs://<id>`: Job status and result
- `mcp://<uri>`: MCP resource
- `pi://`: Harness documentation; do **NOT** read unless user mentions the harness itself
@@ -8,7 +8,6 @@ Asks user when you need clarification or input during task execution.
- Use `recommended: <index>` to mark default (0-indexed); " (Recommended)" added automatically
- Use `questions` for multiple related questions instead of asking one at a time
- Set `multi: true` on question to allow multiple selections
- `ask.timeout` only applies while choosing options; once the user selects "Other (type your own)", there is no timeout
</instruction>
<caution>
@@ -10,16 +10,6 @@ Executes bash command in shell session for terminal operations like git, bun, ca
{{#if asyncEnabled}}
- Use `async: true` for long-running commands when you don't need immediate output; the call returns a background job ID and the result is delivered automatically as a follow-up.
{{/if}}
{{#if autoBackgroundEnabled}}
- Long-running non-PTY commands may auto-background after ~{{autoBackgroundThresholdSeconds}}s and continue as background jobs.
{{/if}}
{{#if asyncEnabled}}
- Inspect background jobs with `read jobs://` (`read jobs://<job-id>` for detail). To wait for results, call `job` (with `poll`) — do NOT poll `read jobs://` in a loop or yield and hope for delivery.
{{else}}
{{#if autoBackgroundEnabled}}
- For auto-backgrounded jobs, inspect with `read jobs://` and call `job` (with `poll`) to wait — do NOT poll in a loop.
{{/if}}
{{/if}}
</instruction>
<output>
@@ -46,8 +46,6 @@ tree(path?=".", max_depth?=3, show_hidden?=False) → str
Render a directory tree.
diff(a, b) → str
Unified diff between two files.
run(cmd, cwd?=None, timeout?=None) → {stdout, stderr, exit_code}
Run a shell command.
env(key?=None, value?=None) → str | None | dict
No args → full environment as dict. One arg → value of `key`. Two args → set `key=value` and return value.
output(*ids, format?="raw", query?=None, offset?=None, limit?=None) → str | dict | list[dict]
@@ -63,7 +61,7 @@ Cells render like a Jupyter notebook. `display(value)` renders non-presentable d
<caution>
- In session mode, use `*** Reset` on a cell to wipe its language's kernel before running.{{#ifAll py js}} Reset is per-language: a python cell's `*** Reset` does not touch the JavaScript kernel and vice versa.{{/ifAll}}
{{#if js}}- **js**: the VM exposes a selective `process` subset, Web APIs, `Buffer`, `fs/promises`.
{{#if js}}- **js**: the VM exposes a selective `process` subset, Web APIs, `Buffer`, `fs/promises`, and the `Bun` global.
{{/if}}</caution>
<example>
@@ -9,11 +9,12 @@ Pick the operation via `op`. Each op uses a subset of the parameters:
- `pr_diff` — Read one or more pull request diffs. Optional `pr` (single identifier or array for batch). Optional `repo`. Set `nameOnly: true` for changed file names. Use `exclude` to drop generated paths from the diff.
- `pr_checkout` — Check one or more pull requests out into dedicated git worktrees. Optional `pr` (number, URL, branch, or array of any of those — pass an array to batch-check-out multiple PRs in one call), `repo`, `force` (reset existing local branch).
- `pr_push` — Push a checked-out PR branch back to its source branch. Requires the branch to have been checked out via `op: pr_checkout` (carries push metadata). Optional `branch`; defaults to the current checked-out git branch. Optional `forceWithLease`.
- `search_issues` — Search issues using normal GitHub issue search syntax. Required `query`. Optional `repo`, `limit`.
- `search_prs` — Search pull requests using normal GitHub PR search syntax. Required `query`. Optional `repo`, `limit`.
- `search_code` — Search code with GitHub code search syntax. Required `query`. Optional `repo`, `limit`. Returns matching paths with surrounding fragments.
- `search_commits` — Search commits across GitHub. Required `query`. Optional `repo`, `limit`. Returns short SHA, author, and the first line of each commit message.
- `search_repos` — Search repositories across GitHub. Required `query`. Optional `limit` (use query qualifiers like `org:`, `language:` instead of `repo`).
- `search_issues` — Search issues using normal GitHub issue search syntax. Optional `query` (required unless `since`/`until` is set), `repo`, `limit`, `since`, `until`, `dateField`.
- `search_prs` — Search pull requests using normal GitHub PR search syntax. Optional `query` (required unless `since`/`until` is set), `repo`, `limit`, `since`, `until`, `dateField`.
- `search_code` — Search code with GitHub code search syntax. Required `query`. Optional `repo`, `limit`. Returns matching paths with surrounding fragments. Date filtering (`since`/`until`) is **not** supported by GitHub code search.
- `search_commits` — Search commits across GitHub. Optional `query` (required unless `since`/`until` is set), `repo`, `limit`, `since`, `until`. `dateField` is ignored — always uses `committer-date`.
- `search_repos` — Search repositories across GitHub. Optional `query` (required unless `since`/`until` is set), `limit`, `since`, `until`, `dateField` (use query qualifiers like `org:`, `language:` instead of `repo`).
- Date filter format for `since` / `until`: relative duration `<n><unit>` (`m`/`h`/`d`/`w`/`mo`/`y`, e.g. `3d`, `12h`, `2w`), an ISO date `YYYY-MM-DD`, or an ISO datetime. Translated to a single GitHub-search qualifier (`created:≥…`, `created:≤…`, or `created:since..until`). `dateField: "updated"` maps to `updated:` for issues/prs and `pushed:` for repos. When you only want a date filter and no keywords, omit `query` entirely.
- `run_watch` — Watch a GitHub Actions workflow run. Optional `run` (id or URL). Omitting `run` watches all workflow runs for the current HEAD commit; `branch` falls back to the current branch. Optional `tail` (log lines per failed job). Streams snapshots, fast-fails on the first detected job failure (with a brief grace period to capture concurrent failures), then fetches tailed logs for the failed jobs. The full failed-job logs are saved as a session artifact for on-demand reads.
</instruction>
@@ -17,6 +17,7 @@ Purely textual format. The tool has NO awareness of language, indentation, brack
<rules>
- Every line of inserted/replacement content **MUST** be emitted as a payload line starting with `{{hsep}}`.
- `{{hsep}}` is syntax, not content. The inserted text begins after the first `{{hsep}}`; use a bare `{{hsep}}` to insert a blank line.
- Payload is verbatim — don't escape unicode (write `—`, not `\u2014`).
- `< A` inserts before line A; `+ A` inserts after line A. `< BOF` / `+ BOF` both prepend; `< EOF` / `+ EOF` both append.
- `= A..B` replaces the inclusive range with the following payload lines. `= A..B` with no payload blanks the range to a single empty line.
- `- A..B` deletes the inclusive range; `A..A` for one line.
+14 -6
View File
@@ -1,11 +1,19 @@
Manages background jobs: poll to wait for completion, cancel to stop running jobs.
Inspects, waits, or cancels async jobs.
You **MUST** use the `job` tool (in a loop, if necessary) instead of manually reading in a loop or issuing sleep commands.
Background job results are delivered automatically when complete. Reach for this tool only when you need to intervene.
Pass `poll` to wait for one or more background jobs to finalize. If the timeout elapses before any job changes state, it returns the current snapshot (still-running jobs and any already-completed deliveries) without erroring — call `job` again to keep waiting. Calling with no `poll` and no `cancel` waits on every running background job.
# Operations
You **MUST NOT** poll the same job repeatedly without evidence of progress. Between calls, inspect `read jobs://<id>` to confirm new output or activity. If a job is stalled, has hung, or is producing nothing useful, cancel it via `cancel` and try a different approach instead of waiting indefinitely.
## `list: true`
Use to inspect what's running.
Pass `cancel` to stop one or more running background jobs (started via async tool execution or bash auto-backgrounding). You **SHOULD** cancel jobs that are no longer needed or stuck. You **MAY** inspect jobs first with `read jobs://` or `read jobs://<job-id>`.
## `poll: [id, …]`
Block until the specified jobs finish or the wait window elapses.
- Use when you are genuinely blocked on a result and have no other work to do.
- Returns the current snapshot when the timer elapses; running jobs remain running.
- Completed jobs include their final output in the returned snapshot.
`poll` and `cancel` may be combined in a single call: cancellations apply first, then polling waits on the remaining ids. When only `cancel` is provided the call returns immediately without waiting.
## `cancel: [id, …]`
Stop running jobs.
- Use when a job is stalled, hung, or no longer needed.
- Returns immediately after cancelling.
@@ -1,11 +1,16 @@
Launches subagents to parallelize workflows.
{{#if asyncEnabled}}
- `read jobs://` for state, `read jobs://<id>` for detail.
- Use `job` (with `poll`) to wait. **MUST NOT** poll `read jobs://` in a loop.
- Results are delivered automatically when complete.
- If genuinely blocked on task completion, wait with `job` using `poll`; otherwise continue with another task when possible.
- Call `job` with `list: true` to snapshot manager state; pass `poll: [id]` to wait or `cancel: [id]` to stop \u2014 only when inspection or intervention is useful.
{{/if}}
Subagents have no conversation history. Every fact, file path, and decision they need **MUST** be explicit in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
{{#if ircEnabled}}
Subagents have no conversation history, but they can reach you and their siblings live via the `irc` tool. Front-load every fact, file path, and direction they need in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
{{else}}
Subagents have no conversation history. Every fact, file path, and direction they need **MUST** be explicit in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
{{/if}}
<parameters>
- `agent`: agent type for all tasks
@@ -20,16 +25,28 @@ Subagents have no conversation history. Every fact, file path, and decision they
<rules>
- **MUST NOT** assign tasks to run project-wide build/test/lint. Caller verifies after the batch.
- **Subagents do not verify, lint, or format.** Every assignment **MUST** instruct the subagent to skip all gates and formatters. You run them once at the end across the union of changed files — avoids redundant runs and racing formatter passes.
{{#if ircEnabled}}
- Each task: ≤3–5 explicit files. Overlapping file sets are tolerable when peers can coordinate via `irc`, but still fan out to a cluster when the scopes are cleanly separable.
- No globs, no "update all", no package-wide scope.
{{else}}
- Each task: ≤3–5 explicit files. No globs, no "update all", no package-wide scope. Fan out to a cluster instead.
{{/if}}
- Pass large payloads via `local://<path>` URIs, not inline.
{{#if contextEnabled}}- Put shared constraints in `context` once; do not duplicate across assignments.{{/if}}
- Prefer agents that investigate **and** edit in one pass; only spin a read-only discovery step when affected files are genuinely unknown.
</rules>
<parallelization>
{{#if ircEnabled}}
Test: can task B run correctly without seeing A's output? If no, sequence A → B — **unless** B can reasonably ask A for the missing piece over `irc`. Live coordination beats a serial waterfall when the contract is small and easy to describe in a DM.
Still sequence when one task produces a large, evolving contract (generated types, schema migration, core module API) the other consumes wholesale — IRC round-trips do not replace a finished artifact.
Parallel when tasks touch disjoint files, are independent refactors/tests, or only need occasional clarification that can be resolved peer-to-peer.
{{else}}
Test: can task B run correctly without seeing A's output? If no, sequence A → B.
Sequential when one task produces a contract (types, API, schema, core module) the other consumes.
Parallel when tasks touch disjoint files or are independent refactors/tests.
{{/if}}
</parallelization>
{{#if contextEnabled}}
+65 -77
View File
@@ -27,7 +27,7 @@ import chalk from "chalk";
import { AsyncJobManager, isBackgroundJobSupportEnabled } from "./async";
import { createAutoresearchExtension } from "./autoresearch";
import { loadCapability } from "./capability";
import { type Rule, ruleCapability } from "./capability/rule";
import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
import { ModelRegistry } from "./config/model-registry";
import { formatModelString, parseModelPattern, parseModelString, resolveModelRoleValue } from "./config/model-resolver";
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates";
@@ -59,30 +59,22 @@ import {
type ToolDefinition,
wrapRegisteredTools,
} from "./extensibility/extensions";
import { loadSkills as loadSkillsInternal, type Skill, type SkillWarning } from "./extensibility/skills";
import {
loadSkills as loadSkillsInternal,
type Skill,
type SkillWarning,
setActiveSkills,
} from "./extensibility/skills";
import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./extensibility/slash-commands";
import type { HindsightSessionState } from "./hindsight/state";
import {
AgentProtocolHandler,
ArtifactProtocolHandler,
InternalUrlRouter,
JobsProtocolHandler,
LocalProtocolHandler,
type LocalProtocolOptions,
McpProtocolHandler,
MemoryProtocolHandler,
PiProtocolHandler,
RuleProtocolHandler,
SkillProtocolHandler,
} from "./internal-urls";
import { LocalProtocolHandler, type LocalProtocolOptions } from "./internal-urls";
import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "./lsp/startup-events";
import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp";
import { discoverAndLoadMCPTools, MCPManager, type MCPToolsLoadResult } from "./mcp";
import {
collectDiscoverableMCPTools,
formatDiscoverableMCPToolServerSummary,
selectDiscoverableMCPToolNamesByServer,
} from "./mcp/discoverable-tool-metadata";
import { getMemoryRoot } from "./memories";
import { resolveMemoryBackend } from "./memory-backend";
import asyncResultTemplate from "./prompts/tools/async-result.md" with { type: "text" };
import { AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry";
@@ -943,34 +935,39 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return preview;
};
const asyncJobManager = backgroundJobsEnabled
? new AsyncJobManager({
maxRunningJobs: asyncMaxJobs,
onJobComplete: async (jobId, result, job) => {
if (!session || asyncJobManager!.isDeliverySuppressed(jobId)) return;
const formattedResult = await formatAsyncResultForFollowUp(result);
if (asyncJobManager!.isDeliverySuppressed(jobId)) return;
// Only top-level sessions own an AsyncJobManager. Subagents reach the
// parent's manager via `AsyncJobManager.instance()` (set below), so creating
// a second instance here just to leave it orphaned wastes a constructor and
// risks accidental disposal of the parent's manager on subagent teardown.
const asyncJobManager =
backgroundJobsEnabled && !options.parentTaskPrefix
? new AsyncJobManager({
maxRunningJobs: asyncMaxJobs,
onJobComplete: async (jobId, result, job) => {
if (!session || asyncJobManager!.isDeliverySuppressed(jobId)) return;
const formattedResult = await formatAsyncResultForFollowUp(result);
if (asyncJobManager!.isDeliverySuppressed(jobId)) return;
const message = prompt.render(asyncResultTemplate, { jobId, result: formattedResult });
const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined;
await session.sendCustomMessage(
{
customType: "async-result",
content: message,
display: true,
attribution: "agent",
details: {
jobId,
type: job?.type,
label: job?.label,
durationMs,
const message = prompt.render(asyncResultTemplate, { jobId, result: formattedResult });
const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined;
await session.sendCustomMessage(
{
customType: "async-result",
content: message,
display: true,
attribution: "agent",
details: {
jobId,
type: job?.type,
label: job?.label,
durationMs,
},
},
},
{ deliverAs: "followUp", triggerTurn: true },
);
},
})
: undefined;
{ deliverAs: "followUp", triggerTurn: true },
);
},
})
: undefined;
const agentRegistry = options.agentRegistry ?? AgentRegistry.global();
const resolvedAgentId = options.agentId ?? options.parentTaskPrefix ?? MAIN_AGENT_ID;
@@ -1056,44 +1053,27 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return {};
}
},
getArtifactManager: () => sessionManager.getArtifactManager(),
settings,
authStorage,
modelRegistry,
asyncJobManager,
};
// Initialize internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, mcp://, local://)
const internalRouter = new InternalUrlRouter();
// Wire process-wide internal URL singletons owned by their real classes.
// Top-level sessions install the active snapshots; subagents inherit them.
// Artifact and agent-output URLs resolve via `AgentRegistry.global()` —
// the protocol handlers walk each ref's `sessionManager.getArtifactsDir()`,
// which collapses to the parent's dir for subagents (they adopt the
// parent's ArtifactManager) so one lookup hits everything.
const getArtifactsDir = () => sessionManager.getArtifactsDir();
internalRouter.register(new AgentProtocolHandler({ getArtifactsDir }));
internalRouter.register(new ArtifactProtocolHandler({ getArtifactsDir }));
internalRouter.register(
new MemoryProtocolHandler({
getMemoryRoot: () => getMemoryRoot(agentDir, settings.getCwd()),
}),
);
internalRouter.register(
new LocalProtocolHandler(
options.localProtocolOptions ?? {
getArtifactsDir,
getSessionId: () => sessionManager.getSessionId(),
},
),
);
internalRouter.register(
new SkillProtocolHandler({
getSkills: () => skills,
}),
);
internalRouter.register(
new RuleProtocolHandler({
getRules: () => [...rulebookRules, ...alwaysApplyRules],
}),
);
internalRouter.register(new PiProtocolHandler());
internalRouter.register(new JobsProtocolHandler({ getAsyncJobManager: () => asyncJobManager }));
internalRouter.register(new McpProtocolHandler({ getMcpManager: () => mcpManager }));
toolSession.internalRouter = internalRouter;
if (!options.parentTaskPrefix) {
setActiveSkills(skills);
setActiveRules([...rulebookRules, ...alwaysApplyRules]);
if (asyncJobManager) AsyncJobManager.setInstance(asyncJobManager);
}
if (options.localProtocolOptions) {
LocalProtocolHandler.setOverride(options.localProtocolOptions);
}
toolSession.getArtifactsDir = getArtifactsDir;
toolSession.agentOutputManager = new AgentOutputManager(
getArtifactsDir,
@@ -1142,7 +1122,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
customTools.push(...mcpResult.tools.map(loaded => loaded.tool));
}
}
toolSession.mcpManager = mcpManager;
// Only top-level sessions own the global MCPManager. Subagents already
// receive the parent's manager via `options.mcpManager`, and reassigning
// the singleton to the same value is a no-op \u2014 keep the gate explicit
// to mirror the AsyncJobManager ownership rule.
if (mcpManager && !options.parentTaskPrefix) MCPManager.setInstance(mcpManager);
// Add image tools when the active model or configured image providers can generate images.
const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, model));
@@ -1724,6 +1708,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
sessionManager,
settings,
evalKernelOwnerId,
// Defined only for top-level sessions (creation is gated above).
// AgentSession uses this to decide whether it may dispose the global
// AsyncJobManager on teardown; subagents inherit the parent's and
// **MUST NOT** tear it down.
ownedAsyncJobManager: asyncJobManager,
scopedModels: options.scopedModels,
promptTemplates,
slashCommands,
@@ -1760,7 +1749,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
defaultSelectedMCPServerNames: [...discoveryDefaultServers],
ttsrManager,
obfuscator,
asyncJobManager,
agentId: resolvedAgentId,
agentRegistry,
providerSessionId: options.providerSessionId,
@@ -55,7 +55,7 @@ import {
} from "@oh-my-pi/pi-ai";
import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
import { abortableSleep, getAgentDbPath, isEnoent, logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
import type { AsyncJob, AsyncJobManager } from "../async";
import { type AsyncJob, AsyncJobManager } from "../async";
import type { Rule } from "../capability/rule";
import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
import {
@@ -225,8 +225,6 @@ export interface AgentSessionConfig {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
/** Async background jobs launched by tools */
asyncJobManager?: AsyncJobManager;
/** Models to cycle through with Ctrl+P (from --models flag) */
scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
/** Initial session thinking selector. */
@@ -285,6 +283,12 @@ export interface AgentSessionConfig {
obfuscator?: SecretObfuscator;
/** Logical owner for retained Python kernels created by this session. */
evalKernelOwnerId?: string;
/**
* AsyncJobManager that this session installed as the process-global instance.
* Only set for top-level sessions; subagents inherit the parent's manager and
* **MUST NOT** dispose it on their own teardown.
*/
ownedAsyncJobManager?: AsyncJobManager;
/** Agent identity (registry id like "0-Main" or "3-Alice") used for IRC routing. */
agentId?: string;
/** Shared agent registry (for forwarding IRC observations to the main session UI). */
@@ -507,7 +511,6 @@ export class AgentSession {
readonly configWarnings: string[] = [];
#asyncJobManager: AsyncJobManager | undefined = undefined;
#scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
#thinkingLevel: ThinkingLevel | undefined;
#promptTemplates: PromptTemplate[];
@@ -558,6 +561,11 @@ export class AgentSession {
// Python execution state
#evalAbortControllers = new Set<AbortController>();
#evalKernelOwnerId: string;
/**
* AsyncJobManager owned by this session (top-level only). Subagents leave
* this undefined and **MUST NOT** dispose the global instance on teardown.
*/
readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
#pendingPythonMessages: PythonExecutionMessage[] = [];
#activeEvalExecutions = new Set<Promise<unknown>>();
#evalExecutionDisposing = false;
@@ -704,8 +712,8 @@ export class AgentSession {
this.sessionManager = config.sessionManager;
this.settings = config.settings;
// Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
this.#asyncJobManager = config.asyncJobManager;
this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
this.#scopedModels = config.scopedModels ?? [];
this.#thinkingLevel = config.thinkingLevel;
this.#promptTemplates = config.promptTemplates ?? [];
@@ -848,15 +856,16 @@ export class AgentSession {
}
getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
if (!this.#asyncJobManager) return null;
const running = this.#asyncJobManager.getRunningJobs().map(job => ({
const manager = AsyncJobManager.instance();
if (!manager) return null;
const running = manager.getRunningJobs().map(job => ({
id: job.id,
type: job.type,
status: job.status,
label: job.label,
startTime: job.startTime,
}));
const recent = this.#asyncJobManager.getRecentJobs(options?.recentLimit ?? 5).map(job => ({
const recent = manager.getRecentJobs(options?.recentLimit ?? 5).map(job => ({
id: job.id,
type: job.type,
status: job.status,
@@ -866,6 +875,17 @@ export class AgentSession {
return { running, recent };
}
/**
* Cancel async jobs registered by *this* agent only. Used by lifecycle
* transitions (newSession, switchSession, handoff, dispose) so a subagent
* cleans up its own background work without touching its parent's jobs.
* No-op when no manager is installed or this session has no agent id.
*/
#cancelOwnAsyncJobs(): void {
if (!this.#agentId) return;
AsyncJobManager.instance()?.cancelAll({ ownerId: this.#agentId });
}
// =========================================================================
// Event Subscription
// =========================================================================
@@ -1739,7 +1759,6 @@ export class AgentSession {
}
#preCacheStreamingEditFile(event: AgentEvent): void {
if (!this.settings.get("edit.streamingAbort")) return;
if (this.#streamingEditAbortTriggered) return;
if (event.type !== "message_update") return;
@@ -1755,6 +1774,9 @@ export class AgentSession {
const streamingEdit = this.#getStreamingEditToolCall(event);
if (!streamingEdit) return;
// The auto-generated guard runs unconditionally: editing a generated file
// is never the user's intent, and the cost of a false-positive abort is one
// wasted turn vs. silently corrupting a regenerated source.
const shouldCheckAutoGenerated =
!streamingEdit.toolCall.id || !this.#streamingEditPrecheckedToolCallIds.has(streamingEdit.toolCall.id);
if (shouldCheckAutoGenerated) {
@@ -1768,7 +1790,12 @@ export class AgentSession {
);
}
this.#ensureFileCache(streamingEdit.resolvedPath);
// File-cache priming feeds #maybeAbortStreamingEdit's removed-lines check,
// which is the optional patch-preview verification gated by
// edit.streamingAbort. Skip the read when the setting is off.
if (this.settings.get("edit.streamingAbort")) {
this.#ensureFileCache(streamingEdit.resolvedPath);
}
}
#ensureFileCache(resolvedPath: string): void {
@@ -2149,10 +2176,21 @@ export class AgentSession {
}
await this.#cancelPostPromptTasks();
this.#clearTodoClearTimers();
const drained = await this.#asyncJobManager?.dispose({ timeoutMs: 3_000 });
const deliveryState = this.#asyncJobManager?.getDeliveryState();
if (drained === false && deliveryState) {
logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
// Cancel jobs this agent registered so a subagent's teardown doesn't
// leak its background bash/task work into the parent's manager. Only
// the session that owns the manager goes on to dispose it (which itself
// nukes any leftover jobs and pending deliveries).
this.#cancelOwnAsyncJobs();
const ownedAsyncManager = this.#ownedAsyncJobManager;
if (ownedAsyncManager) {
const drained = await ownedAsyncManager.dispose({ timeoutMs: 3_000 });
const deliveryState = ownedAsyncManager.getDeliveryState();
if (drained === false && deliveryState) {
logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
}
if (AsyncJobManager.instance() === ownedAsyncManager) {
AsyncJobManager.setInstance(undefined);
}
}
const pythonExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
if (!pythonExecutionsSettled) {
@@ -3948,7 +3986,7 @@ export class AgentSession {
this.#disconnectFromAgent();
await this.abort();
this.#asyncJobManager?.cancelAll();
this.#cancelOwnAsyncJobs();
this.#closeAllProviderSessions("new session");
this.agent.reset();
if (options?.drop && previousSessionFile) {
@@ -4756,7 +4794,7 @@ export class AgentSession {
// Start a new session
const previousSessionFile = this.sessionFile;
await this.sessionManager.flush();
this.#asyncJobManager?.cancelAll();
this.#cancelOwnAsyncJobs();
await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
this.agent.reset();
this.#syncAgentSessionId();
@@ -6675,7 +6713,7 @@ export class AgentSession {
const incomingRecord: CustomMessage = {
role: "custom",
customType: "irc:incoming",
content: `[IRC \`${args.from}\` \u2192 you]\n\n${args.message}`,
content: `[IRC \`${args.from}\` → you]\n\n${args.message}`,
display: true,
details: { from: args.from, message: args.message },
attribution: "agent",
@@ -6707,7 +6745,7 @@ export class AgentSession {
const replyRecord: CustomMessage = {
role: "custom",
customType: "irc:autoreply",
content: `[IRC you \u2192 \`${args.from}\` (auto)]\n\n${replyText}`,
content: `[IRC you → \`${args.from}\` (auto)]\n\n${replyText}`,
display: true,
details: { to: args.from, reply: replyText },
attribution: "agent",
@@ -6746,7 +6784,7 @@ export class AgentSession {
const mainRef = registry.get(MAIN_AGENT_ID);
const mainSession = mainRef?.session;
if (!mainSession || mainSession === this) return;
const arrow = args.kind === "reply" ? "\u2192 (auto)" : "\u2192";
const arrow = args.kind === "reply" ? "→ (auto)" : "→";
const relayRecord: CustomMessage = {
role: "custom",
customType: "irc:relay",
@@ -7149,7 +7187,7 @@ export class AgentSession {
// Flush pending writes before branching
await this.sessionManager.flush();
this.#asyncJobManager?.cancelAll();
this.#cancelOwnAsyncJobs();
if (!selectedEntry.parentId) {
await this.sessionManager.newSession({ parentSession: previousSessionFile });
@@ -12,6 +12,10 @@ import * as path from "node:path";
*
* Artifacts are stored with sequential IDs in the session's artifact directory.
* The directory is created lazily on first write.
*
* Subagents do not own their own `ArtifactManager`. The parent's instance is
* adopted via `SessionManager.adoptArtifactManager`, so the whole parent +
* subagent tree shares one ID space and one directory.
*/
export class ArtifactManager {
#nextId = 0;
@@ -20,11 +24,10 @@ export class ArtifactManager {
#initialized = false;
/**
* @param sessionFile Path to the session .jsonl file
* @param dir Directory that will hold artifact files. Created lazily on first save.
*/
constructor(sessionFile: string) {
// Artifact directory is session file path without .jsonl extension
this.#dir = sessionFile.slice(0, -6);
constructor(dir: string) {
this.#dir = dir;
}
/**
@@ -275,6 +275,7 @@ export type ReadonlySessionManager = Pick<
| "getSessionFile"
| "getSessionName"
| "getArtifactsDir"
| "getArtifactManager"
| "allocateArtifactPath"
| "saveArtifact"
| "getArtifactPath"
@@ -1622,6 +1623,10 @@ export class SessionManager {
#persistErrorReported = false;
#artifactManager: ArtifactManager | null = null;
#artifactManagerSessionFile: string | null = null;
// When set, take precedence over the lazily-derived per-session manager.
// Subagents adopt the parent's manager so artifact IDs are unique across the
// whole agent tree and all files land in the parent's artifacts dir.
#adoptedArtifactManager: ArtifactManager | null = null;
// In-memory artifact fallback for non-persistent sessions (persist=false).
// Keyed by sequential numeric ID string; mirrors the file-based ArtifactManager ID scheme.
#inMemoryArtifacts: Map<string, string> | null = null;
@@ -1675,6 +1680,7 @@ export class SessionManager {
this.#persistErrorReported = false;
this.#artifactManager = null;
this.#artifactManagerSessionFile = null;
this.#adoptedArtifactManager = null;
this.#buildIndex();
if (this.#sessionFile) {
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
@@ -2120,17 +2126,40 @@ export class SessionManager {
/**
* Returns the session artifacts directory path (session file path without .jsonl).
* Returns null when the session is not persisted to a file.
* When this session has adopted an external ArtifactManager (subagent case),
* returns that manager's directory so reads/writes land in the shared parent
* dir instead of a private (non-existent) subdir.
*/
getArtifactsDir(): string | null {
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager.dir;
const sessionFile = this.#sessionFile;
return sessionFile ? sessionFile.slice(0, -6) : null;
}
/**
* Adopt an externally-owned ArtifactManager. Used by subagents to share
* the parent session's artifact directory and ID counter.
*/
adoptArtifactManager(manager: ArtifactManager): void {
this.#adoptedArtifactManager = manager;
}
/**
* Returns the ArtifactManager this session writes through. Lazily creates
* one bound to the current session file unless an external manager was
* adopted via `adoptArtifactManager`. Returns null only for non-persistent
* sessions with no adopted manager.
*/
getArtifactManager(): ArtifactManager | null {
return this.#getOrCreateArtifactManager();
}
/**
* Returns an artifact manager bound to the current session file.
* Recreates the manager when the active session file changes.
*/
#getOrCreateArtifactManager(): ArtifactManager | null {
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager;
const sessionFile = this.#sessionFile;
if (!sessionFile) {
this.#artifactManager = null;
@@ -2142,7 +2171,7 @@ export class SessionManager {
return this.#artifactManager;
}
const manager = new ArtifactManager(sessionFile);
const manager = new ArtifactManager(sessionFile.slice(0, -6));
this.#artifactManager = manager;
this.#artifactManagerSessionFile = sessionFile;
return manager;
@@ -15,6 +15,11 @@ export interface SSHConnectionTarget {
export type SSHHostOs = "windows" | "linux" | "macos" | "unknown";
export type SSHHostShell = "cmd" | "powershell" | "bash" | "zsh" | "sh" | "unknown";
export type SshPlatform = typeof process.platform;
export function supportsSshControlMaster(platform: SshPlatform = process.platform): boolean {
return platform !== "win32";
}
export interface SSHHostInfo {
version: number;
@@ -33,6 +38,10 @@ const activeHosts = new Map<string, SSHConnectionTarget>();
const pendingConnections = new Map<string, Promise<void>>();
const hostInfoCache = new Map<string, SSHHostInfo>();
interface SSHArgsOptions {
platform?: SshPlatform;
}
function ensureControlDir() {
fs.mkdirSync(CONTROL_DIR, { recursive: true, mode: 0o700 });
try {
@@ -66,20 +75,14 @@ async function validateKeyPermissions(keyPath?: string): Promise<void> {
}
}
function buildCommonArgs(host: SSHConnectionTarget): string[] {
const args = [
"-n",
"-o",
"ControlMaster=auto",
"-o",
`ControlPath=${CONTROL_PATH}`,
"-o",
"ControlPersist=3600",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
];
function buildCommonArgs(host: SSHConnectionTarget, options?: SSHArgsOptions): string[] {
const args = ["-n"];
if (supportsSshControlMaster(options?.platform)) {
args.push("-o", "ControlMaster=auto", "-o", `ControlPath=${CONTROL_PATH}`, "-o", "ControlPersist=3600");
}
args.push("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new");
if (host.port) {
args.push("-p", String(host.port));
@@ -357,9 +360,13 @@ export async function ensureHostInfo(host: SSHConnectionTarget): Promise<SSHHost
return probeHostInfo(host);
}
export async function buildRemoteCommand(host: SSHConnectionTarget, command: string): Promise<string[]> {
export async function buildRemoteCommand(
host: SSHConnectionTarget,
command: string,
options?: SSHArgsOptions,
): Promise<string[]> {
await validateKeyPermissions(host.keyPath);
return [...buildCommonArgs(host), buildSshTarget(host.username, host.host), command];
return [...buildCommonArgs(host, options), buildSshTarget(host.username, host.host), command];
}
let registered = false;
@@ -385,6 +392,14 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise<void>
}
const target = buildSshTarget(host.username, host.host);
if (!supportsSshControlMaster()) {
activeHosts.set(key, host);
if (!hostInfoCache.has(key) && !(await loadHostInfoFromDisk(host))) {
await probeHostInfo(host);
}
return;
}
const check = await runSshSync(["-O", "check", ...buildCommonArgs(host), target]);
if (check.exitCode === 0) {
activeHosts.set(key, host);
@@ -415,6 +430,7 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise<void>
}
async function closeConnectionInternal(host: SSHConnectionTarget): Promise<void> {
if (!supportsSshControlMaster()) return;
const target = buildSshTarget(host.username, host.host);
await runSshSync(["-O", "exit", ...buildCommonArgs(host), target]);
}
+10 -7
View File
@@ -2,7 +2,12 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { $which, getRemoteDir, postmortem } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { getControlDir, getControlPathTemplate, type SSHConnectionTarget } from "./connection-manager";
import {
getControlDir,
getControlPathTemplate,
type SSHConnectionTarget,
supportsSshControlMaster,
} from "./connection-manager";
import { buildSshTarget, sanitizeHostName } from "./utils";
const REMOTE_DIR = getRemoteDir();
@@ -40,14 +45,12 @@ function buildSshfsArgs(host: SSHConnectionTarget): string[] {
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
"ControlMaster=auto",
"-o",
`ControlPath=${CONTROL_PATH}`,
"-o",
"ControlPersist=3600",
];
if (supportsSshControlMaster()) {
args.push("-o", "ControlMaster=auto", "-o", `ControlPath=${CONTROL_PATH}`, "-o", "ControlPersist=3600");
}
if (host.port) {
args.push("-p", String(host.port));
}
@@ -12,7 +12,6 @@ import type { SkillsSettings } from "./config/settings";
import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
import { loadSkills, type Skill } from "./extensibility/skills";
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
import nowPromptTemplate from "./prompts/system/now-prompt.md" with { type: "text" };
import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" };
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
import { shortenPath } from "./tools/render-utils";
@@ -575,10 +574,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
if (projectPrompt) {
systemPrompt.push(projectPrompt);
}
const nowPrompt = prompt.render(nowPromptTemplate, data).trim();
if (nowPrompt) {
systemPrompt.push(nowPrompt);
}
return { systemPrompt };
}
+14 -2
View File
@@ -26,9 +26,11 @@ import submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md
import { AgentRegistry } from "../registry/agent-registry";
import { createAgentSession, discoverAuthStorage } from "../sdk";
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
import type { ArtifactManager } from "../session/artifacts";
import type { AuthStorage } from "../session/auth-storage";
import { SessionManager } from "../session/session-manager";
import { type ContextFileEntry, truncateTail } from "../tools";
import { truncateTail } from "../session/streaming-output";
import type { ContextFileEntry } from "../tools";
import { jtdToJsonSchema, normalizeSchema } from "../tools/jtd-to-json-schema";
import { ToolAbortError } from "../tools/tool-errors";
import type { EventBus } from "../utils/event-bus";
@@ -172,6 +174,12 @@ export interface ExecutorOptions {
settings?: Settings;
/** Override local:// protocol options so subagent shares parent's local:// root */
localProtocolOptions?: LocalProtocolOptions;
/**
* Parent session's ArtifactManager. Subagent adopts it so artifact IDs are
* unique across the whole agent tree and all artifacts land in the parent's
* artifacts directory (no per-subagent subdir).
*/
parentArtifactManager?: ArtifactManager;
parentHindsightSessionState?: HindsightSessionState;
}
@@ -563,6 +571,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
const lspEnabled = enableLsp ?? true;
const ircEnabled = subagentSettings.get("irc.enabled") === true;
const contextFileForPrompt = ircEnabled ? undefined : options.contextFile;
const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
const outputChunks: string[] = [];
@@ -975,6 +984,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
const sessionManager = sessionFile
? await SessionManager.open(sessionFile)
: SessionManager.inMemory(worktree ?? cwd);
if (options.parentArtifactManager) {
sessionManager.adoptArtifactManager(options.parentArtifactManager);
}
const mcpProxyTools = options.mcpManager ? createMCPProxyTools(options.mcpManager) : [];
const enableMCP = !options.mcpManager;
@@ -1001,7 +1013,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
context: options.context?.trim() ?? "",
worktree: worktree ?? "",
outputSchema: normalizedOutputSchema,
contextFile: options.contextFile,
contextFile: contextFileForPrompt,
ircPeers: ircEnabled ? renderIrcPeerRoster(id) : "",
ircSelfId: ircEnabled ? id : "",
});
+19 -5
View File
@@ -20,7 +20,9 @@ import type { Usage } from "@oh-my-pi/pi-ai";
import { $env, prompt, Snowflake } from "@oh-my-pi/pi-utils";
import type { TSchema } from "@sinclair/typebox";
import type { ToolSession } from "..";
import { AsyncJobManager } from "../async";
import { resolveAgentModelPatterns } from "../config/model-resolver";
import { MCPManager } from "../mcp/manager";
import type { Theme } from "../modes/theme/theme";
import planModeSubagentPrompt from "../prompts/system/plan-mode-subagent.md" with { type: "text" };
import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
@@ -141,6 +143,7 @@ function renderDescription(
asyncEnabled: boolean,
disabledAgents: string[],
simpleMode: TaskSimpleMode,
ircEnabled: boolean,
): string {
const filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents;
const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
@@ -151,6 +154,7 @@ function renderDescription(
asyncEnabled,
contextEnabled,
customSchemaEnabled,
ircEnabled,
defaultMode: simpleMode === "default",
schemaFreeMode: simpleMode === "schema-free",
independentMode: simpleMode === "independent",
@@ -229,6 +233,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
this.session.settings.get("async.enabled"),
disabledAgents,
this.#getTaskSimpleMode(),
this.session.settings.get("irc.enabled") === true,
);
}
private constructor(
@@ -270,7 +275,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
return this.#executeSync(_toolCallId, params, signal, onUpdate);
}
const manager = this.session.asyncJobManager;
const manager = AsyncJobManager.instance();
if (!manager) {
return {
content: [{ type: "text", text: "Async execution is enabled but no async job manager is available." }],
@@ -444,6 +449,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
},
{
id: label,
ownerId: this.session.getAgentId?.() ?? undefined,
onProgress: (text, details) => {
const progressDetails =
(details as TaskToolDetails | undefined) ??
@@ -729,6 +735,10 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
getSessionId: this.session.getSessionId ?? (() => null),
};
// Subagents adopt the parent's ArtifactManager so artifact IDs are unique
// across the whole tree and outputs land flat in the parent's dir.
const parentArtifactManager = this.session.getArtifactManager?.() ?? undefined;
// Initialize progress tracking
const progressMap = new Map<number, AgentProgress>();
@@ -785,9 +795,11 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
};
}
// Write parent conversation context for subagents
// Write parent conversation context for subagents. When IRC is available,
// subagents should ask live peers instead of reading a stale markdown dump.
await fs.mkdir(effectiveArtifactsDir, { recursive: true });
const compactContext = this.session.getCompactContext?.();
const shouldWriteConversationContext = this.session.settings.get("irc.enabled") !== true;
const compactContext = shouldWriteConversationContext ? this.session.getCompactContext?.() : undefined;
let contextFilePath: string | undefined;
if (compactContext) {
contextFilePath = path.join(effectiveArtifactsDir, "context.md");
@@ -867,12 +879,13 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
authStorage: this.session.authStorage,
modelRegistry: this.session.modelRegistry,
settings: this.session.settings,
mcpManager: this.session.mcpManager,
mcpManager: MCPManager.instance(),
contextFiles,
skills: availableSkills,
workspaceTree: this.session.workspaceTree,
promptTemplates,
localProtocolOptions,
parentArtifactManager,
parentHindsightSessionState: this.session.getHindsightSessionState?.(),
});
}
@@ -925,12 +938,13 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
authStorage: this.session.authStorage,
modelRegistry: this.session.modelRegistry,
settings: this.session.settings,
mcpManager: this.session.mcpManager,
mcpManager: MCPManager.instance(),
contextFiles,
skills: availableSkills,
workspaceTree: this.session.workspaceTree,
promptTemplates,
localProtocolOptions,
parentArtifactManager,
parentHindsightSessionState: this.session.getHindsightSessionState?.(),
});
if (mergeMode === "branch" && result.exitCode === 0) {
@@ -89,6 +89,7 @@ export interface DiscoverableMCPSearchResult {
const BM25_K1 = 1.2;
const BM25_B = 0.75;
const BM25_DELTA = 1.0;
const FIELD_WEIGHTS = {
name: 6,
label: 4,
@@ -112,13 +113,24 @@ function getSchemaPropertyKeys(parameters: unknown): string[] {
}
function tokenize(value: string): string[] {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[^a-zA-Z0-9]+/g, " ")
.toLowerCase()
.trim()
.split(/\s+/)
.filter(token => token.length > 0);
return (
value
.normalize("NFKD")
// Drop combining marks (accents) so "café" → "cafe".
.replace(/\p{M}+/gu, "")
// Split ACRONYMBoundary: "MCPTool" → "MCP Tool".
.replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, "$1 $2")
// Split camelCase / digit→letter: "fooBar" → "foo Bar", "v2Beta" → "v2 Beta".
.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, "$1 $2")
// Everything that isn't a letter or digit becomes a separator. This subsumes markdown
// punctuation (`|*_`#-~>[]()`), box-drawing glyphs (─│┌), em/en dashes, smart quotes,
// zero-width spaces, NBSPs, etc.
.replace(/[^\p{L}\p{N}]+/gu, " ")
.toLowerCase()
.trim()
.split(/\s+/)
.filter(token => token.length > 0)
);
}
function addWeightedTokens(termFrequencies: Map<string, number>, value: string | undefined, weight: number): void {
@@ -274,7 +286,8 @@ export function searchDiscoverableTools(
const documentFrequency = index.documentFrequencies.get(token) ?? 0;
const idf = Math.log(1 + (index.documents.length - documentFrequency + 0.5) / (documentFrequency + 0.5));
const normalization = BM25_K1 * (1 - BM25_B + BM25_B * (document.length / index.averageLength));
score += queryTermCount * idf * ((termFrequency * (BM25_K1 + 1)) / (termFrequency + normalization));
score +=
queryTermCount * idf * ((termFrequency * (BM25_K1 + 1)) / (termFrequency + normalization) + BM25_DELTA);
}
return { tool: document.tool, score };
})
+3 -2
View File
@@ -7,6 +7,7 @@ import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
@@ -213,10 +214,10 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
if (rawPaths.some(rawPath => rawPath.length === 0)) {
throw new ToolError("`paths` must contain non-empty paths or globs");
}
const internalRouter = this.session.internalRouter;
const internalRouter = InternalUrlRouter.instance();
const resolvedPathInputs: string[] = [];
for (const rawPath of rawPaths) {
if (!internalRouter?.canHandle(rawPath)) {
if (!internalRouter.canHandle(rawPath)) {
resolvedPathInputs.push(rawPath);
continue;
}
+3 -2
View File
@@ -6,6 +6,7 @@ import { Text } from "@oh-my-pi/pi-tui";
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
@@ -158,10 +159,10 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
if (rawPaths.some(rawPath => rawPath.length === 0)) {
throw new ToolError("`paths` must contain non-empty paths or globs");
}
const internalRouter = this.session.internalRouter;
const internalRouter = InternalUrlRouter.instance();
const resolvedPathInputs: string[] = [];
for (const rawPath of rawPaths) {
if (!internalRouter?.canHandle(rawPath)) {
if (!internalRouter.canHandle(rawPath)) {
resolvedPathInputs.push(rawPath);
continue;
}
+15 -9
View File
@@ -4,8 +4,10 @@ import type { Component } from "@oh-my-pi/pi-tui";
import { ImageProtocol, TERMINAL, Text } from "@oh-my-pi/pi-tui";
import { $env, getProjectDir, isEnoent, prompt } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import { AsyncJobManager } from "../async";
import { type BashResult, executeBash } from "../exec/bash-executor";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import { truncateToVisualLines } from "../modes/components/visual-truncate";
import type { Theme } from "../modes/theme/theme";
import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
@@ -326,7 +328,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
}
lines.push(`Background job ${jobId} started: ${label}`);
lines.push("Result will be delivered automatically when complete.");
lines.push(`Use \`job\` (with \`poll\` or \`cancel\`) or \`read jobs://${jobId}\` if needed.`);
lines.push(
`You can use \`job\` to poll until complete, but prefer to continue with another task in the meanwhile if it's not blocking.`,
);
return {
content: [{ type: "text", text: lines.join("\n") }],
details,
@@ -349,7 +353,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
startBackgrounded: boolean;
}): ManagedBashJobHandle {
const manager = this.session.asyncJobManager;
const manager = AsyncJobManager.instance();
if (!manager) {
throw new ToolError("Background job manager unavailable for this session.");
}
@@ -399,6 +403,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
}
},
{
ownerId: this.session.getAgentId?.() ?? undefined,
onProgress: async (text, details) => {
latestText = text;
await options.onUpdate?.({
@@ -501,7 +506,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
const internalUrlOptions: InternalUrlExpansionOptions = {
skills: this.session.skills ?? [],
internalRouter: this.session.internalRouter,
internalRouter: InternalUrlRouter.instance(),
localOptions: {
getArtifactsDir: this.session.getArtifactsDir,
getSessionId: this.session.getSessionId,
@@ -549,7 +554,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
if (asyncRequested) {
if (!this.session.asyncJobManager) {
if (!AsyncJobManager.instance()) {
throw new ToolError("Async job manager unavailable for this session.");
}
const job = this.#startManagedBashJob({
@@ -570,7 +575,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
});
}
if (this.#autoBackgroundEnabled && !pty && this.session.asyncJobManager) {
const autoBgManager = AsyncJobManager.instance();
if (this.#autoBackgroundEnabled && !pty && autoBgManager) {
const autoBackgroundWaitMs = this.#resolveAutoBackgroundWaitMs(timeoutMs);
const startBackgrounded = autoBackgroundWaitMs === 0;
const job = this.#startManagedBashJob({
@@ -593,16 +599,16 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
}
const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
if (waitResult.kind === "completed") {
this.session.asyncJobManager.acknowledgeDeliveries([job.jobId]);
autoBgManager.acknowledgeDeliveries([job.jobId]);
return waitResult.result;
}
if (waitResult.kind === "failed") {
this.session.asyncJobManager.acknowledgeDeliveries([job.jobId]);
autoBgManager.acknowledgeDeliveries([job.jobId]);
throw waitResult.error;
}
if (waitResult.kind === "aborted") {
this.session.asyncJobManager.cancel(job.jobId);
this.session.asyncJobManager.acknowledgeDeliveries([job.jobId]);
autoBgManager.cancel(job.jobId);
autoBgManager.acknowledgeDeliveries([job.jobId]);
throw new ToolAbortError(job.getLatestText() || "Command aborted");
}
job.setBackgrounded(true);
@@ -16,6 +16,13 @@ import type {
WorkerInitPayload,
WorkerOutbound,
} from "./tab-protocol";
// Imported with `type: "file"` so Bun's bundler statically discovers the
// worker entry and embeds it inside `bun build --compile` single-file
// binaries. Without this attribute the bundler cannot reach the entry through
// a `new URL(..., import.meta.url)` literal stored in a local variable, and
// the prebuilt binary surfaces `Timed out initializing browser tab worker`
// (issue #1011) because `/$bunfs/root/tab-worker-entry.ts` is missing.
import tabWorkerEntryUrl from "./tab-worker-entry.ts" with { type: "file" };
interface WorkerHandle {
send(msg: WorkerInbound, transferList?: Transferable[]): void;
@@ -364,8 +371,7 @@ async function raceWithTimeout<T>(
async function spawnTabWorker(): Promise<WorkerHandle> {
try {
const url = new URL("./tab-worker-entry.ts", import.meta.url);
const worker = new Worker(url.href, { type: "module" });
const worker = new Worker(tabWorkerEntryUrl, { type: "module" });
return wrapBunWorker(worker);
} catch (err) {
logger.warn("Bun Worker spawn failed; using inline tab worker (no sync-loop guard)", {
+48 -10
View File
@@ -8,7 +8,7 @@ import { jsBackend, parseEvalInput, pythonBackend, sniffEvalLanguage } from "../
import type { ExecutorBackend } from "../eval/backend";
import evalGrammar from "../eval/eval.lark" with { type: "text" };
import { ABORT_WARNING, type ParsedEvalCell } from "../eval/parse";
import type { EvalCellResult, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
import type { EvalCellResult, EvalDisplayOutput, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { truncateToVisualLines } from "../modes/components/visual-truncate";
import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
@@ -47,6 +47,38 @@ function formatJsonScalar(value: unknown): string {
return "[object]";
}
/** Cap per `display()` value sent back to the model. */
const MAX_DISPLAY_TEXT_BYTES = 8000;
function formatDisplayJsonForText(value: unknown): string {
let text: string;
try {
text = JSON.stringify(value, null, 2) ?? String(value);
} catch {
text = String(value);
}
if (text.length > MAX_DISPLAY_TEXT_BYTES) {
text = `${text.slice(0, MAX_DISPLAY_TEXT_BYTES)}\n… (${text.length - MAX_DISPLAY_TEXT_BYTES} chars truncated)`;
}
return text;
}
/**
* Format display() JSON values into text the model can see. Images are surfaced
* separately as ImageContent so the model can actually inspect them; this helper
* intentionally does not touch images.
*/
function formatDisplayOutputsForText(outputs: EvalDisplayOutput[]): string {
const chunks: string[] = [];
let displayIndex = 0;
for (const output of outputs) {
if (output.type !== "json") continue;
displayIndex++;
chunks.push(`display[${displayIndex}]:\n${formatDisplayJsonForText(output.data)}`);
}
return chunks.join("\n\n");
}
function renderJsonTree(value: unknown, theme: Theme, expanded: boolean, maxDepth = expanded ? 6 : 2): string[] {
const maxItems = expanded ? 20 : 5;
@@ -370,13 +402,16 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
const durationMs = Date.now() - startTime;
const cellStatusEvents: EvalStatusEvent[] = [];
const cellDisplayOutputs: EvalDisplayOutput[] = [];
let cellHasMarkdown = false;
for (const output of result.displayOutputs) {
if (output.type === "json") {
jsonOutputs.push(output.data);
cellDisplayOutputs.push(output);
}
if (output.type === "image") {
images.push({ type: "image", data: output.data, mimeType: output.mimeType });
cellDisplayOutputs.push(output);
}
if (output.type === "status") {
statusEvents.push(output.event);
@@ -387,7 +422,10 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
}
}
const cellOutput = result.output.trim();
const stdoutTrimmed = result.output.trim();
const displayText = formatDisplayOutputsForText(cellDisplayOutputs);
const cellOutput =
stdoutTrimmed && displayText ? `${stdoutTrimmed}\n\n${displayText}` : stdoutTrimmed || displayText;
cellResult.output = cellOutput;
cellResult.exitCode = result.exitCode;
cellResult.durationMs = durationMs;
@@ -431,14 +469,13 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
languages,
cells: cellResults,
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
images: images.length > 0 ? images : undefined,
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
isError: true,
};
if (notice) details.notice = notice;
return toolResult(details)
.text(outputText)
.content([{ type: "text", text: outputText }, ...images])
.truncationFromSummary(summaryForMeta, { direction: "tail" })
.done();
}
@@ -461,14 +498,13 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
languages,
cells: cellResults,
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
images: images.length > 0 ? images : undefined,
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
isError: true,
};
if (notice) details.notice = notice;
return toolResult(details)
.text(outputText)
.content([{ type: "text", text: outputText }, ...images])
.truncationFromSummary(summaryForMeta, { direction: "tail" })
.done();
}
@@ -479,9 +515,12 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
const combinedOutput = cellOutputs.join("\n\n");
const abortSuffix = parsedInput.aborted ? `\n\n${ABORT_WARNING}` : "";
const hasImages = images.length > 0;
const outputText =
(combinedOutput || (jsonOutputs.length > 0 || images.length > 0 ? "(no text output)" : "(no output)")) +
abortSuffix;
(combinedOutput ||
(hasImages
? `(displayed ${images.length} image${images.length === 1 ? "" : "s"}; no text output)`
: "(no output)")) + abortSuffix;
const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput);
const details: EvalToolDetails = {
@@ -489,13 +528,12 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
languages,
cells: cellResults,
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
images: images.length > 0 ? images : undefined,
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
};
if (notice) details.notice = notice;
return toolResult(details)
.text(outputText)
.content([{ type: "text", text: outputText }, ...images])
.truncationFromSummary(summaryForMeta, { direction: "tail" })
.done();
} finally {
+1 -1
View File
@@ -1352,7 +1352,7 @@ export function renderReadUrlCall(
): Component {
const url = args.path ?? args.url ?? "";
const domain = getDomain(url);
const path = truncate(url.replace(/^https?:\/\/[^/]+/, ""), 50, "\u2026");
const path = truncate(url.replace(/^https?:\/\/[^/]+/, ""), 50, "…");
const description = `${domain}${path ? ` ${path}` : ""}`.trim();
const meta: string[] = [];
if (args.raw) meta.push("raw");
+140 -4
View File
@@ -260,6 +260,27 @@ const githubSchema = Type.Object({
examples: ["is:open label:bug"],
}),
),
since: Type.Optional(
Type.String({
description:
"lower-bound date for search_issues/search_prs/search_commits/search_repos. Accepts a relative duration (`<n><unit>` with unit `m`/`h`/`d`/`w`/`mo`/`y`, e.g. `3d`, `12h`, `2w`) or an ISO date (`YYYY-MM-DD`) / datetime. Translated to a `created:>=…` (or `committer-date:`/`pushed:`) qualifier; not supported by search_code.",
examples: ["3d", "2w", "2026-05-01"],
}),
),
until: Type.Optional(
Type.String({
description:
"upper-bound date in the same format as `since`. With both, builds a `field:since..until` range qualifier.",
examples: ["1d", "2026-05-09"],
}),
),
dateField: Type.Optional(
StringEnum(["created", "updated"], {
description:
"date field used by `since`/`until`. issues/prs: `created` (default) or `updated`. repos: `created` (default) or `updated` (mapped to GitHub's `pushed:`). commits: ignored — always uses `committer-date`.",
default: "created",
}),
),
limit: Type.Optional(
Type.Number({
description: "max results (search_issues, search_prs, search_code, search_commits, search_repos)",
@@ -686,6 +707,110 @@ const SEARCH_FIELDS_BY_COMMAND: Record<"issues" | "prs" | "code" | "commits" | "
repos: GH_SEARCH_REPOS_FIELDS,
};
const RELATIVE_DURATION_PATTERN = /^(\d+)\s*(m|h|d|w|mo|y)$/i;
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const FIXED_UNIT_MS: Record<string, number> = {
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 7 * 86_400_000,
};
/**
* Resolve a search date bound to a GitHub-search-compatible literal. Returns
* either a `YYYY-MM-DD` date (relative durations and date-only inputs) or a
* full ISO 8601 datetime string (datetime inputs), so the caller can drop it
* straight into a qualifier like `created:>=<value>`.
*/
export function parseSearchDateBound(raw: string, now: Date = new Date()): string {
const trimmed = raw.trim();
if (!trimmed) {
throw new ToolError("date bound must not be empty");
}
const relMatch = trimmed.match(RELATIVE_DURATION_PATTERN);
if (relMatch) {
const count = Number(relMatch[1]);
const unit = relMatch[2].toLowerCase();
const fixedMs = FIXED_UNIT_MS[unit];
let bound: Date;
if (fixedMs !== undefined) {
bound = new Date(now.getTime() - count * fixedMs);
} else {
bound = new Date(now);
if (unit === "mo") {
bound.setUTCMonth(bound.getUTCMonth() - count);
} else {
bound.setUTCFullYear(bound.getUTCFullYear() - count);
}
}
return bound.toISOString().slice(0, 10);
}
if (ISO_DATE_PATTERN.test(trimmed)) {
return trimmed;
}
const parsedMs = Date.parse(trimmed);
if (!Number.isNaN(parsedMs)) {
return new Date(parsedMs).toISOString();
}
throw new ToolError(
`invalid date bound: ${raw}. Expected a relative duration like "3d", "12h", "2w", an ISO date "YYYY-MM-DD", or an ISO datetime.`,
);
}
/**
* Build the GitHub-search qualifier (e.g. `created:>=2026-05-09`) for the
* provided bounds, or `undefined` if neither bound is set.
*/
export function buildSearchDateQualifier(
field: string,
since: string | undefined,
until: string | undefined,
now?: Date,
): string | undefined {
const sinceVal = since ? parseSearchDateBound(since, now) : undefined;
const untilVal = until ? parseSearchDateBound(until, now) : undefined;
if (sinceVal && untilVal) {
return `${field}:${sinceVal}..${untilVal}`;
}
if (sinceVal) {
return `${field}:>=${sinceVal}`;
}
if (untilVal) {
return `${field}:<=${untilVal}`;
}
return undefined;
}
function resolveSearchDateField(
command: "issues" | "prs" | "commits" | "repos",
requested: "created" | "updated" | undefined,
): string {
if (command === "commits") {
return "committer-date";
}
const dateField = requested ?? "created";
if (command === "repos" && dateField === "updated") {
return "pushed";
}
return dateField;
}
function composeSearchQuery(parts: ReadonlyArray<string | undefined>): string {
const cleaned: string[] = [];
for (const part of parts) {
const trimmed = part?.trim();
if (trimmed) cleaned.push(trimmed);
}
if (cleaned.length === 0) {
throw new ToolError("query is required (or pass since/until to filter by date)");
}
return cleaned.join(" ");
}
function buildGhSearchArgs(
command: "issues" | "prs" | "code" | "commits" | "repos",
query: string,
@@ -2636,9 +2761,11 @@ async function executeSearchIssues(
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const query = requireNonEmpty(params.query, "query");
const repo = normalizeOptionalString(params.repo);
const limit = resolveSearchLimit(params.limit);
const dateField = resolveSearchDateField("issues", params.dateField);
const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
const query = composeSearchQuery([params.query, dateQualifier]);
const args = buildGhSearchArgs("issues", query, limit, repo);
const items = await git.github.json<GhSearchResult[]>(session.cwd, args, signal, {
@@ -2652,9 +2779,11 @@ async function executeSearchPrs(
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const query = requireNonEmpty(params.query, "query");
const repo = normalizeOptionalString(params.repo);
const limit = resolveSearchLimit(params.limit);
const dateField = resolveSearchDateField("prs", params.dateField);
const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
const query = composeSearchQuery([params.query, dateQualifier]);
const args = buildGhSearchArgs("prs", query, limit, repo);
const items = await git.github.json<GhSearchResult[]>(session.cwd, args, signal, {
@@ -2669,6 +2798,9 @@ async function executeSearchCode(
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const query = requireNonEmpty(params.query, "query");
if (params.since !== undefined || params.until !== undefined) {
throw new ToolError("search_code does not support since/until; GitHub code search has no date qualifier.");
}
const repo = normalizeOptionalString(params.repo);
const limit = resolveSearchLimit(params.limit);
const args = buildGhSearchArgs("code", query, limit, repo);
@@ -2684,9 +2816,11 @@ async function executeSearchCommits(
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const query = requireNonEmpty(params.query, "query");
const repo = normalizeOptionalString(params.repo);
const limit = resolveSearchLimit(params.limit);
const dateField = resolveSearchDateField("commits", params.dateField);
const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
const query = composeSearchQuery([params.query, dateQualifier]);
const args = buildGhSearchArgs("commits", query, limit, repo);
const items = await git.github.json<GhSearchCommitResult[]>(session.cwd, args, signal, {
@@ -2700,8 +2834,10 @@ async function executeSearchRepos(
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const query = requireNonEmpty(params.query, "query");
const limit = resolveSearchLimit(params.limit);
const dateField = resolveSearchDateField("repos", params.dateField);
const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
const query = composeSearchQuery([params.query, dateQualifier]);
const args = buildGhSearchArgs("repos", query, limit, undefined);
const items = await git.github.json<GhSearchRepoResult[]>(session.cwd, args, signal);
+4 -9
View File
@@ -1,17 +1,16 @@
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { ToolChoice } from "@oh-my-pi/pi-ai";
import { $env, $flag, logger } from "@oh-my-pi/pi-utils";
import type { AsyncJobManager } from "../async";
import type { PromptTemplate } from "../config/prompt-templates";
import type { Settings } from "../config/settings";
import { EditTool } from "../edit";
import { checkPythonKernelAvailability } from "../eval/py/kernel";
import type { Skill } from "../extensibility/skills";
import type { HindsightSessionState } from "../hindsight/state";
import type { InternalUrlRouter } from "../internal-urls";
import { LspTool } from "../lsp";
import type { PlanModeState } from "../plan-mode/state";
import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
import type { ArtifactManager } from "../session/artifacts";
import type { CustomMessage } from "../session/messages";
import type { ToolChoiceQueue } from "../session/tool-choice-queue";
import { TaskTool } from "../task";
@@ -159,6 +158,8 @@ export interface ToolSession {
agentRegistry?: AgentRegistry;
/** Get artifacts directory for artifact:// URLs */
getArtifactsDir?: () => string | null;
/** Get the ArtifactManager backing this session (shared across parent + subagents). */
getArtifactManager?: () => ArtifactManager | null;
/** Allocate a new artifact path and ID for session-scoped truncated output. */
allocateOutputArtifact?: (toolType: string) => Promise<{ id?: string; path?: string }>;
/** Get session spawns */
@@ -171,14 +172,8 @@ export interface ToolSession {
authStorage?: import("../session/auth-storage").AuthStorage;
/** Model registry for passing to subagents (avoids re-discovery) */
modelRegistry?: import("../config/model-registry").ModelRegistry;
/** MCP manager for proxying MCP calls through parent */
mcpManager?: import("../mcp/manager").MCPManager;
/** Internal URL router for protocols like agent://, skill://, and mcp:// */
internalRouter?: InternalUrlRouter;
/** Agent output manager for unique agent:// IDs across task invocations */
agentOutputManager?: AgentOutputManager;
/** Async background job manager for bash/task async execution */
asyncJobManager?: AsyncJobManager;
/** Settings instance for passing to subagents */
settings: Settings;
/** Plan mode state (if active) */
@@ -282,7 +277,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
browser: s => new BrowserTool(s),
checkpoint: CheckpointTool.createIf,
rewind: RewindTool.createIf,
task: TaskTool.create,
task: s => TaskTool.create(s),
job: JobTool.createIf,
recipe: RecipeTool.createIf,
irc: IrcTool.createIf,
+48 -12
View File
@@ -3,7 +3,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { prompt } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { isBackgroundJobSupportEnabled } from "../async";
import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from "../async";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { Theme } from "../modes/theme/theme";
import jobDescription from "../prompts/tools/job.md" with { type: "text" };
@@ -20,6 +20,7 @@ import {
type ToolUIColor,
type ToolUIStatus,
} from "./render-utils";
import { ToolError } from "./tool-errors";
const jobSchema = Type.Object({
poll: Type.Optional(
@@ -34,6 +35,12 @@ const jobSchema = Type.Object({
examples: [["job-1234"]],
}),
),
list: Type.Optional(
Type.Boolean({
description:
"Return an immediate snapshot of every job spawned by this agent (running + completed within retention). Read-only \u2014 cannot be combined with `poll` or `cancel`.",
}),
),
});
type JobParams = Static<typeof jobSchema>;
@@ -97,7 +104,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
onUpdate?: AgentToolUpdateCallback<JobToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<JobToolDetails>> {
const manager = this.session.asyncJobManager;
const manager = AsyncJobManager.instance();
if (!manager) {
return {
content: [{ type: "text", text: "Async execution is disabled; no background jobs are available." }],
@@ -105,11 +112,24 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
};
}
// Scope every visible operation to the calling agent. Tests / SDK
// consumers without an agent id see everything (legacy behavior).
const ownerId = this.session.getAgentId?.() ?? undefined;
const ownerFilter = ownerId ? { ownerId } : undefined;
// `list` is a read-only snapshot mode. Replaces the legacy `jobs://` URL.
if (params.list) {
if (params.cancel?.length || params.poll?.length) {
throw new ToolError("`list` cannot be combined with `poll` or `cancel`.");
}
return this.#buildResult(manager, manager.getAllJobs(ownerFilter), []);
}
const cancelIds = params.cancel ?? [];
const cancelOutcomes: CancelOutcome[] = [];
for (const id of cancelIds) {
const existing = manager.getJob(id);
if (!existing) {
if (!existing || (ownerId && existing.ownerId !== ownerId)) {
cancelOutcomes.push({ id, status: "not_found", message: `Background job not found: ${id}` });
continue;
}
@@ -121,7 +141,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
});
continue;
}
const cancelled = manager.cancel(id);
const cancelled = manager.cancel(id, ownerFilter);
cancelOutcomes.push(
cancelled
? { id, status: "cancelled", message: `Cancelled background job ${id}.` }
@@ -130,11 +150,11 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
}
const requestedPollIds = params.poll;
// If only `cancel` was provided (no `poll`), don't wait — return immediately.
// If only `cancel` was provided (no `poll`), don't wait \u2014 return immediately.
const shouldPoll = requestedPollIds !== undefined || cancelIds.length === 0;
if (!shouldPoll) {
const cancelledJobs = cancelIds.map(id => manager.getJob(id)).filter(j => j != null);
const cancelledJobs = this.#visibleJobs(manager, cancelIds, ownerId);
return this.#buildResult(manager, cancelledJobs, cancelOutcomes);
}
@@ -142,12 +162,12 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
// - If `poll` was passed explicitly, watch exactly those (filtered to existing).
// - If `poll` was omitted (and so was `cancel`), default to all running jobs.
const jobsToWatch = requestedPollIds
? requestedPollIds.map(id => manager.getJob(id)).filter(j => j != null)
: manager.getRunningJobs();
? this.#visibleJobs(manager, requestedPollIds, ownerId)
: manager.getRunningJobs(ownerFilter);
if (jobsToWatch.length === 0) {
if (cancelOutcomes.length > 0) {
const cancelledJobs = cancelIds.map(id => manager.getJob(id)).filter(j => j != null);
const cancelledJobs = this.#visibleJobs(manager, cancelIds, ownerId);
return this.#buildResult(manager, cancelledJobs, cancelOutcomes);
}
const message = requestedPollIds?.length
@@ -176,7 +196,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
const watchedJobIds = runningJobs.map(job => job.id);
manager.watchJobs(watchedJobIds);
const cancelledJobs = cancelIds.map(id => manager.getJob(id)).filter(j => j != null);
const cancelledJobs = this.#visibleJobs(manager, cancelIds, ownerId);
const allTrackedJobs = [...cancelledJobs, ...jobsToWatch];
const PROGRESS_INTERVAL_MS = 500;
@@ -219,6 +239,22 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
return this.#buildResult(manager, allTrackedJobs, cancelOutcomes);
}
/**
* Resolve a list of job ids to job records visible to the calling agent.
* Drops missing ids and ids owned by other agents, so cross-agent inspection
* via the `job` tool is impossible.
*/
#visibleJobs(manager: AsyncJobManager, ids: string[], ownerId: string | undefined): AsyncJob[] {
const out: AsyncJob[] = [];
for (const id of ids) {
const job = manager.getJob(id);
if (!job) continue;
if (ownerId && job.ownerId !== ownerId) continue;
out.push(job);
}
return out;
}
#snapshotJobs(
jobs: {
id: string;
@@ -232,7 +268,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
): JobSnapshot[] {
const now = Date.now();
return jobs.map(j => {
const current = this.session.asyncJobManager?.getJob(j.id);
const current = AsyncJobManager.instance()?.getJob(j.id);
const latest = current ?? j;
return {
id: latest.id,
@@ -247,7 +283,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
}
#buildResult(
manager: NonNullable<ToolSession["asyncJobManager"]>,
manager: AsyncJobManager,
jobs: {
id: string;
type: "bash" | "task";
+5 -4
View File
@@ -12,6 +12,7 @@ import { getFileReadCache } from "../edit/file-read-cache";
import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../hashline/hash";
import { InternalUrlRouter } from "../internal-urls";
import { parseInternalUrl } from "../internal-urls/parse";
import type { InternalUrl } from "../internal-urls/types";
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
@@ -431,7 +432,7 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
const readSchema = Type.Object({
path: Type.String({
description: 'path or url; append :<sel> for line ranges or raw mode (e.g. "src/foo.ts:50-100")',
examples: ["src/foo.ts", "src/foo.ts:50-100", "https://example.com:L1-L40"],
examples: ["src/foo.ts", "src/foo.ts:50-100", "https://example.com/:1-40"],
}),
});
@@ -1181,8 +1182,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
// Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://)
const internalTarget = splitPathAndSel(readPath);
const internalRouter = this.session.internalRouter;
if (internalRouter?.canHandle(internalTarget.path)) {
const internalRouter = InternalUrlRouter.instance();
if (internalRouter.canHandle(internalTarget.path)) {
const parsed = parseSel(internalTarget.sel);
const { offset, limit } = selToOffsetLimit(parsed);
return this.#handleInternalUrl(internalTarget.path, offset, limit, { raw: isRawSelector(parsed) });
@@ -1551,7 +1552,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
limit?: number,
options?: { raw?: boolean },
): Promise<AgentToolResult<ReadToolDetails>> {
const internalRouter = this.session.internalRouter!;
const internalRouter = InternalUrlRouter.instance();
// Check if URL has query extraction (agent:// only).
// Use parseInternalUrl which handles colons in host (namespaced skills).
+3 -2
View File
@@ -8,6 +8,7 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { getFileReadCache } from "../edit/file-read-cache";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
import searchDescription from "../prompts/tools/search.md" with { type: "text" };
import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
@@ -131,14 +132,14 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
if (rawPaths.some(rawPath => rawPath.length === 0)) {
throw new ToolError("`paths` must contain non-empty paths or globs");
}
const internalRouter = this.session.internalRouter;
const internalRouter = InternalUrlRouter.instance();
const resolvedPathInputs: string[] = [];
// Absolute filesystem paths whose source is immutable (e.g. artifact://,
// pi://, skill://). Hashline anchors are suppressed for these on a
// per-file basis, leaving editable mixed-in files untouched.
const immutableSourcePaths = new Set<string>();
for (const rawPath of rawPaths) {
if (!internalRouter?.canHandle(rawPath)) {
if (!internalRouter.canHandle(rawPath)) {
resolvedPathInputs.push(rawPath);
continue;
}
@@ -631,7 +631,7 @@ function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme): string[] {
for (const task of phase.tasks) {
if (task.status !== "in_progress" || !task.notes || task.notes.length === 0) continue;
const bar = uiTheme.fg("dim", uiTheme.tree.vertical);
const title = uiTheme.fg("dim", chalk.italic(`\u00a7 notes \u2014 ${task.content}`));
const title = uiTheme.fg("dim", chalk.italic(`§ notes — ${task.content}`));
lines.push("");
lines.push(` ${title}`);
for (let j = 0; j < task.notes.length; j++) {
@@ -273,7 +273,7 @@ export const handleMastodon: SpecialHandler = async (
md += `### ${formatDate(status.created_at)}\n\n`;
const content = await htmlToBasicMarkdown(status.content);
md += `${content}\n\n`;
md += `\uD83D\uDCAC ${status.replies_count} \u00B7 \uD83D\uDD01 ${status.reblogs_count} \u00B7 \u2B50 ${status.favourites_count}\n\n`;
md += `💬 ${status.replies_count} · 🔁 ${status.reblogs_count} · ⭐ ${status.favourites_count}\n\n`;
}
}
}
@@ -32,19 +32,19 @@ interface RepologyPackage {
function statusIndicator(status: string): string {
switch (status) {
case "newest":
return "\u2705"; // green check
return "✅"; // green check
case "devel":
return "\uD83D\uDEA7"; // construction
return "🚧"; // construction
case "unique":
return "\uD83D\uDD35"; // blue circle
return "🔵"; // blue circle
case "outdated":
return "\uD83D\uDD34"; // red circle
return "🔴"; // red circle
case "legacy":
return "\u26A0\uFE0F"; // warning
return "⚠\uFE0F"; // warning
case "rolling":
return "\uD83D\uDD04"; // arrows
return "🔄"; // arrows
default:
return "\u2796"; // minus
return "➖"; // minus
}
}