Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 20 additions & 13 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,17 +205,22 @@ coding tools, then renders the result to text, JSONL, or pi-tui.
`--resume`, `--session <ref>`, and the `cua-browser` custom entry.
- `harness-named-sessions.ts` — persisted Kernel browser metadata for
`cua session start|stop|list|show` and `-s <name>`.
- `harness-skills.ts` — pi `loadSkills` over `~/.agents/skills`,
`<cwd>/.agents/skills`, and `--skill <path>`; `/skill:<name>`
expansion.
- `harness-skills.ts` — skill and context discovery via pi's
`DefaultResourceLoader` (the loader pi's own TUI uses), so the set
includes pi-installed packages plus `~/.agents/skills`,
`<cwd>/.agents/skills`, the pi agent dir, and `--skill <path>`.
pi extensions are not loaded — cua drives the lower-level
`AgentHarness`, which cannot bind pi `AgentSession` extensions.
Also handles `/skill:<name>` expansion.
- `action/` — constrained one-shot prompts (`open|click|type|press|observe|url|screenshot|do`)
and a bounded harness-driven runner.
- `print.ts` — single-shot `--print` text output.
- `output/harness-jsonl.ts` — JSONL event sink for `-o jsonl`.
- `tui/` — pi-tui 0.79 interactive front-end: `Markdown` message list,
`Image` screenshot widget, status line, telemetry footer, `Editor`
with autocomplete-backed slash commands (`/model`, `/thinking`,
`/compact`, `/skill:<name>`).
- `tui/` — pi-tui 0.79 interactive front-end styled with pi's theme
system: `Markdown` message list, `Image` screenshot widget, status
line, telemetry footer, `Editor` with autocomplete-backed slash
commands (`/model`, `/thinking`, `/compact`, `/skill:<name>`), and a
startup preamble with `[Context]` and `[Skills]` sections.

### `@onkernel/ptywright`

Expand Down Expand Up @@ -258,16 +263,18 @@ The CLI's `buildCuaHarness` in `packages/cli/src/harness.ts`:
`@onkernel/cua-ai`.
2. Wires `JsonlSessionRepo` and a `Session` for transcript persistence
and resume.
3. Loads pi skills from `~/.agents/skills`, `<cwd>/.agents/skills`, and
any `--skill <path>` flags and exposes them via `resources.skills`.
3. Discovers skills and context files through pi's
`DefaultResourceLoader` (installed packages, `~/.agents/skills`,
`<cwd>/.agents/skills`, the pi agent dir, and `--skill <path>`) and
exposes the skills via `resources.skills`.
4. Provides `extraTools` from `createCodingTools(cwd)`
(`@earendil-works/pi-coding-agent`) for bash/read/edit/write/grep/find/ls.
5. Resolves the API key via `requireCuaEnvApiKeyForModel(ref)` and
spreads any `<PROVIDER>_BASE_URL` env override onto the model object.
6. Composes the `systemPrompt` callback from
`resolveCuaRuntimeSpec(model).defaultSystemPrompt` plus
`formatSkillsForSystemPrompt(resources.skills)` so it stays correct
across `setModel()`.
`resolveCuaRuntimeSpec(model).defaultSystemPrompt`,
`formatSkillsForSystemPrompt(resources.skills)`, and the loaded
context files so it stays correct across `setModel()`.

## Component map

Expand All @@ -289,7 +296,7 @@ flowchart LR
aiPkg --> piAi
harness --> coding["bash/read/edit/write/... (pi-coding-agent)"]
harness --> sessions["JsonlSessionRepo (re-exported from cua-agent)"]
harness --> skillsMod["loadSkills (re-exported from cua-agent)"]
harness --> skillsMod["DefaultResourceLoader (pi-coding-agent): skills + context"]
cli --> tui["cli/src/tui/main.ts (pi-tui)"]
cli --> jsonl["cli/src/output/harness-jsonl.ts"]
pty["ptywright (dev/test only)"] --> tui
Expand Down
25 changes: 20 additions & 5 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,19 @@ For named sessions, the exact transcript path is in
[Session transcripts section in the top-level README](../../README.md#session-transcripts)
for the JSONL schema and `jq` analysis examples.

## Skills
## Skills and context

`cua` follows the cross-agent
[`~/.agents/skills/`](https://agentskills.io) standard. Discovery
defaults:
`cua` resolves skills and context files through pi's resource loader
(the same loader pi's own TUI uses), so the discovery set matches pi.
Skills load from:

- `~/.agents/skills/` (user-global)
- `~/.agents/skills/` (user-global, the cross-agent
[`~/.agents/skills/`](https://agentskills.io) standard)
- `<cwd>/.agents/skills/` (project-local)
- the pi agent dir (`~/.pi/agent/`)
- pi-installed packages (`pi install …` records the package in pi's
settings and clones it under the agent dir; its bundled skills load
here too)

Plus any explicit `--skill <path>` flags. Disable with `--no-skills`
(`-ns`).
Expand All @@ -152,6 +157,16 @@ the system prompt; the model uses the `read` tool to load a skill's
full body when its description matches the task. Use `/skill:<name>`
in a prompt to force-load a skill body inline.

Context files (`AGENTS.md` / `CLAUDE.md`) discovered by the resource
loader are appended to the system prompt and listed in the TUI's
`[Context]` section. `--no-skills` disables skill discovery only;
context files still load, since they describe the project rather than
add agent capabilities.

pi *extensions* are not executed by `cua`: extensions bind into pi's
`AgentSession`, and `cua` drives the lower-level `AgentHarness`
directly. Installed-package skills and context still load.

## Image protocol

Force the inline-screenshot protocol with `--image-protocol` or
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
readMetadataFromFile,
resolveSessionRef,
} from "./harness-sessions";
import { discoverCuaSkills } from "./harness-skills";
import { type ContextFile, discoverCuaSkills } from "./harness-skills";
import { runPrint } from "./print";

const MODELS_HELP = `cua models — list supported -m/--model values
Expand Down Expand Up @@ -341,6 +341,7 @@ interface HarnessRuntime {
resolved: ResolvedSession | undefined;
session: Session;
skills: Skill[];
contextFiles: ContextFile[];
harness: ReturnType<typeof buildCuaHarness>;
provider: string;
modelRef: CuaModelRef;
Expand All @@ -363,7 +364,7 @@ async function setupHarnessRuntime(
const auth = resolveAuth(flags);
const cwd = process.cwd();
const env = new NodeExecutionEnv({ cwd });
const { skills } = await discoverCuaSkills({
const { skills, contextFiles } = await discoverCuaSkills({
cwd,
env,
extraPaths: flags.skillPaths,
Expand Down Expand Up @@ -410,6 +411,7 @@ async function setupHarnessRuntime(
session,
model: auth.modelRef,
skills,
contextFiles,
thinkingLevel,
modelBaseUrl: baseUrlOverride,
});
Expand All @@ -419,6 +421,7 @@ async function setupHarnessRuntime(
resolved,
session,
skills,
contextFiles,
harness,
provider,
modelRef: auth.modelRef,
Expand Down Expand Up @@ -506,6 +509,7 @@ export async function runInteractiveCommand(
browserHandle: runtime.handle,
session: runtime.session,
skills: runtime.skills,
contextFiles: runtime.contextFiles,
modelRef: runtime.modelRef,
provider: runtime.provider,
initialPrompt: initialPrompt || undefined,
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ Options:
--session <ref> Resume a specific session: path | partial id | latest
--session-dir <dir> Override the sessions directory
--no-session Don't persist this session to disk
--skill <path> Load a skill file or directory (repeatable).
Defaults: ~/.agents/skills/, <cwd>/.agents/skills/
--skill <path> Load an extra skill file or directory (repeatable).
Skills also load from ~/.agents/skills/,
<cwd>/.agents/skills/, the pi agent dir
(~/.pi/agent/), and pi-installed packages.
-ns, --no-skills Disable skill discovery entirely
--debug-tui Enable TUI render diagnostics for manual repros
-v, --verbose Verbose progress output to stderr
Expand Down
113 changes: 98 additions & 15 deletions packages/cli/src/harness-skills.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { type ExecutionEnv, loadSkills, type Skill, type SkillDiagnostic } from "@onkernel/cua-agent";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
DefaultResourceLoader,
getAgentDir,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
import { dirname, join } from "node:path";

export interface DiscoverSkillsOptions {
cwd: string;
Expand All @@ -10,30 +13,110 @@ export interface DiscoverSkillsOptions {
extraPaths?: string[];
/** Disable all skill discovery. */
disabled?: boolean;
/** pi agent dir to resolve installed packages from. Defaults to `getAgentDir()`. */
agentDir?: string;
}

export interface ContextFile {
path: string;
content: string;
}

export interface DiscoverSkillsResult {
skills: Skill[];
sources: string[];
contextFiles: ContextFile[];
diagnostics: SkillDiagnostic[];
}

/**
* Discover skills following the cross-agent `~/.agents/skills/` standard.
* Discover skills and context files via pi's `DefaultResourceLoader`, the same
* loader pi's own TUI uses. This resolves skills from installed pi packages
* (`pi install …` writes them under the agent dir and records them in
* settings.json) in addition to `~/.agents/skills/`, `<cwd>/.agents/skills/`,
* `~/.pi/agent/skills/`, and explicit `--skill` paths.
*
* Startup never blocks on an interactive prompt: project settings start
* untrusted (no trust prompt), and `PI_OFFLINE` keeps a configured-but-not-
* installed package from triggering a network install — it is skipped instead.
*
* Discovery order: explicit `--skill` paths, then `~/.agents/skills/`,
* then `<cwd>/.agents/skills/`. Missing paths are skipped silently.
* pi extensions are not loaded (`noExtensions`): cua's harness drives the
* lower-level `AgentHarness` directly and cannot bind pi `AgentSession`
* extensions.
*/
export async function discoverCuaSkills(opts: DiscoverSkillsOptions): Promise<DiscoverSkillsResult> {
if (opts.disabled) return { skills: [], sources: [], diagnostics: [] };
const extras = (opts.extraPaths ?? []).filter((p) => p && p.trim().length > 0);
const userAgentsDir = join(homedir(), ".agents", "skills");
const projectAgentsDir = join(opts.cwd, ".agents", "skills");
const candidates = [...extras, userAgentsDir, projectAgentsDir];
const sources = candidates.filter((p) => existsSync(p));
if (sources.length === 0) return { skills: [], sources: [], diagnostics: [] };
const result = await loadSkills(opts.env, sources);
return { skills: result.skills, sources, diagnostics: result.diagnostics };
const agentDir = opts.agentDir ?? getAgentDir();
const settingsManager = SettingsManager.create(opts.cwd, agentDir, { projectTrusted: false });
// Project-local `<cwd>/.agents/skills` is loaded explicitly rather than via
// pi's trusted project scan. That scan only runs when the project is trusted
// (which would mean prompting the user and binding untrusted `.pi/`
// extensions); `additionalSkillPaths` loads the directory unconditionally and
// never binds extensions, so project skills work without a trust prompt.
const projectSkillDir = join(opts.cwd, ".agents", "skills");
const additionalSkillPaths = [...extras, projectSkillDir];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No-skills still loads project skills

Medium Severity

With --no-skills, pi still loads paths passed via additionalSkillPaths (same mechanism as explicit --skill). This code always appends <cwd>/.agents/skills to that list, so project-local skills can remain in the system prompt even when automatic discovery is meant to be off.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d5491d6. Configure here.

const loader = new DefaultResourceLoader({
cwd: opts.cwd,
agentDir,
settingsManager,
additionalSkillPaths,
noSkills: opts.disabled === true,
noExtensions: true,
noPromptTemplates: true,
noThemes: true,
});

const restoreOffline = forceOfflinePackageResolution();
try {
await loader.reload();
} finally {
restoreOffline();
}

const piSkills = loader.getSkills().skills;
const contextFiles = loader.getAgentsFiles().agentsFiles;

// pi's loader resolves the skill *file paths* — the superset that includes
// package skills — but its skill objects don't carry the file body. cua's
// harness needs the full instructions, so re-read the discovered skills
// through cua-agent's `loadSkills`, which produces the `{ content }` shape
// the harness and `/skill:<name>` expansion consume. Scan each skill's root
// directory, then keep only the files pi actually enumerated (so a skills
// root holding both a loose `.md` and a nested `SKILL.md` doesn't load the
// nested skill twice).
const discoveredPaths = new Set(piSkills.map((s) => s.filePath));
const skillDirs = [...new Set(piSkills.map((s) => dirname(s.filePath)))];
if (skillDirs.length === 0) {
return { skills: [], contextFiles, diagnostics: [] };
}
const loaded = await loadSkills(opts.env, skillDirs);
const skills = dedupeByFilePath(loaded.skills.filter((s) => discoveredPaths.has(s.filePath)));
return { skills, contextFiles, diagnostics: loaded.diagnostics };
}

function dedupeByFilePath(skills: Skill[]): Skill[] {
const seen = new Set<string>();
const result: Skill[] = [];
for (const skill of skills) {
if (seen.has(skill.filePath)) continue;
seen.add(skill.filePath);
result.push(skill);
}
return result;
}

/**
* `DefaultResourceLoader.reload()` resolves packages without an `onMissing`
* callback, which would auto-install a configured-but-missing package over the
* network. `PI_OFFLINE` makes that resolution skip missing packages instead, so
* startup can never hang on an install. Restores any prior value afterward.
*/
function forceOfflinePackageResolution(): () => void {
const previous = process.env.PI_OFFLINE;
if (previous !== undefined) return () => {};
process.env.PI_OFFLINE = "1";
return () => {
delete process.env.PI_OFFLINE;
};
}

/**
Expand Down
23 changes: 19 additions & 4 deletions packages/cli/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "@onkernel/cua-ai";
import type Kernel from "@onkernel/sdk";
import { createCodingTools } from "@earendil-works/pi-coding-agent";
import type { ContextFile } from "./harness-skills";

/** Options for {@link buildCuaHarness}. */
export interface BuildCuaHarnessOptions {
Expand All @@ -27,6 +28,8 @@ export interface BuildCuaHarnessOptions {
session: Session;
model: CuaModelRef;
skills?: Skill[];
/** Context files (AGENTS.md, CLAUDE.md, …) appended to the system prompt. */
contextFiles?: ContextFile[];
thinkingLevel?: ThinkingLevel;
/** Override the default coding-tools extraTools (bash/read/edit/write/grep/find/ls). */
extraTools?: CuaAgentHarnessOptions["extraTools"];
Expand All @@ -45,6 +48,7 @@ export interface BuildCuaHarnessOptions {
*/
export function buildCuaHarness(opts: BuildCuaHarnessOptions): CuaAgentHarness {
const skills = opts.skills ?? [];
const contextFiles = opts.contextFiles ?? [];
const extraTools = opts.extraTools ?? createCodingTools(opts.cwd);
const model: CuaModelRef | Model<Api> = opts.modelBaseUrl
? { ...getCuaModel(opts.model), baseUrl: opts.modelBaseUrl }
Expand All @@ -60,7 +64,7 @@ export function buildCuaHarness(opts: BuildCuaHarnessOptions): CuaAgentHarness {
thinkingLevel: opts.thinkingLevel,
systemPrompt: ({ model: activeModel, resources }) => {
const runtime = resolveCuaRuntimeSpec(activeModel);
return composeSystemPrompt(runtime.defaultSystemPrompt, resources.skills ?? []);
return composeSystemPrompt(runtime.defaultSystemPrompt, resources.skills ?? [], contextFiles);
},
getApiKeyAndHeaders:
opts.getApiKeyAndHeaders ??
Expand All @@ -71,8 +75,19 @@ export function buildCuaHarness(opts: BuildCuaHarnessOptions): CuaAgentHarness {
});
}

function composeSystemPrompt(base: string, skills: Skill[]): string {
function composeSystemPrompt(base: string, skills: Skill[], contextFiles: ContextFile[]): string {
const sections = [base.trim()];
const skillBlock = formatSkillsForSystemPrompt(skills).trim();
if (!skillBlock) return base;
return `${base.trim()}\n\n${skillBlock}\n`;
if (skillBlock) sections.push(skillBlock);
const contextBlock = formatContextFiles(contextFiles);
if (contextBlock) sections.push(contextBlock);
return `${sections.join("\n\n")}\n`;
}

function formatContextFiles(contextFiles: ContextFile[]): string {
const blocks = contextFiles
.filter((file) => file.content.trim().length > 0)
.map((file) => `## ${file.path}\n\n${file.content.trim()}`);
if (blocks.length === 0) return "";
return `# Context\n\n${blocks.join("\n\n")}`;
}
Loading
Loading