Merge branch 'main' into fix/plugin-directory-extensions
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 : "",
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)", {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user