From fcfec2241152f103372fc6ea75f4c11f05500244 Mon Sep 17 00:00:00 2001 From: Danie van Zyl Date: Wed, 2 Sep 2026 09:43:17 +0200 Subject: [PATCH] feat: add Pi marketplace package --- .github/workflows/pi-package.yml | 50 +++ .github/workflows/release.yml | 44 +- LICENSE | 21 + README.md | 43 +- extensions/github-status-section.ts | 226 ++++++++++ extensions/picture-status-bar.ts | 262 ++++++++++++ extensions/status-window/README.md | 80 ++++ extensions/status-window/api.ts | 42 ++ extensions/status-window/index.ts | 167 ++++++++ extensions/subagents/README.md | 56 +++ .../subagents/agents/gh-search-researcher.md | 194 +++++++++ .../subagents/agents/web-search-researcher.md | 114 +++++ extensions/subagents/index.ts | 395 ++++++++++++++++++ extensions/subagents/personas.ts | 116 +++++ package.json | 49 +++ 15 files changed, 1849 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/pi-package.yml create mode 100644 LICENSE create mode 100644 extensions/github-status-section.ts create mode 100644 extensions/picture-status-bar.ts create mode 100644 extensions/status-window/README.md create mode 100644 extensions/status-window/api.ts create mode 100644 extensions/status-window/index.ts create mode 100644 extensions/subagents/README.md create mode 100644 extensions/subagents/agents/gh-search-researcher.md create mode 100644 extensions/subagents/agents/web-search-researcher.md create mode 100644 extensions/subagents/index.ts create mode 100644 extensions/subagents/personas.ts create mode 100644 package.json diff --git a/.github/workflows/pi-package.yml b/.github/workflows/pi-package.yml new file mode 100644 index 0000000..180ae36 --- /dev/null +++ b/.github/workflows/pi-package.yml @@ -0,0 +1,50 @@ +name: pi-package + +on: + pull_request: + paths: + - "extensions/**" + - "package.json" + - "LICENSE" + - ".github/workflows/pi-package.yml" + push: + branches: [main] + paths: + - "extensions/**" + - "package.json" + - "LICENSE" + - ".github/workflows/pi-package.yml" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install typecheck dependencies + run: npm install --no-save --ignore-scripts typescript @types/node @earendil-works/pi-coding-agent + + - name: Typecheck Pi extensions + run: >- + npx tsc --noEmit --target ES2022 --module NodeNext + --moduleResolution NodeNext --skipLibCheck --types node + --allowImportingTsExtensions + extensions/status-window/index.ts + extensions/status-window/api.ts + extensions/github-status-section.ts + extensions/picture-status-bar.ts + extensions/subagents/index.ts + extensions/subagents/personas.ts + + - name: Validate package contents + run: npm pack --dry-run + + - name: Load package in Pi + run: npx pi --approve --no-extensions -e . --list-models >/dev/null diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6459aee..ae98b43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,9 @@ name: release # Auto-versioning for the marketplace plugin. On every push to main: # 1. validate the manifests parse # 2. pick the next version (auto patch-bump, or honor a manual bump in plugin.json) -# 3. write it into plugin.json + marketplace.json so all version fields agree -# 4. commit the bump (with [skip ci] to avoid re-triggering), tag v, release +# 3. write it into plugin.json + marketplace.json + package.json +# 4. publish the Pi npm package when NPM_TOKEN is configured +# 5. commit the bump (with [skip ci] to avoid re-triggering), tag v, release # # GitHub Actions natively skips runs whose head commit message contains "[skip ci]", # so the bump commit this workflow pushes will NOT trigger another run. @@ -23,6 +24,8 @@ concurrency: jobs: release: runs-on: ubuntu-latest + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - uses: actions/checkout@v4 with: @@ -33,10 +36,15 @@ jobs: run: | jq empty plugin.json jq empty .claude-plugin/marketplace.json + jq empty package.json # required fields must be present test "$(jq -r '.name // empty' plugin.json)" != "" test "$(jq -r '.version // empty' plugin.json)" != "" test "$(jq -r '.metadata.version // empty' .claude-plugin/marketplace.json)" != "" + test "$(jq -r '.name // empty' package.json)" != "" + test "$(jq -r '.version // empty' package.json)" != "" + jq -e '.keywords | index("pi-package") != null' package.json >/dev/null + npm pack --dry-run >/dev/null - name: Compute next version id: ver @@ -81,6 +89,34 @@ jobs: jq --arg v "$v" '.metadata.version = $v | .plugins |= map(if .source == "./" then .version = $v else . end)' \ .claude-plugin/marketplace.json > "$tmp" && mv "$tmp" .claude-plugin/marketplace.json + tmp=$(mktemp) + jq --arg v "$v" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json + + - name: Set up Node for npm + if: steps.ver.outputs.skip == 'false' && env.NPM_TOKEN != '' + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Publish Pi package + if: steps.ver.outputs.skip == 'false' && env.NPM_TOKEN != '' + env: + NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }} + run: | + set -euo pipefail + name=$(jq -r '.name' package.json) + version=$(jq -r '.version' package.json) + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "$name@$version already published" + else + npm publish --access public --provenance + fi + + - name: NPM publishing not configured + if: steps.ver.outputs.skip == 'false' && env.NPM_TOKEN == '' + run: echo "NPM_TOKEN is not configured; skipping Pi package publication" + - name: Commit, tag, and release if: steps.ver.outputs.skip == 'false' env: @@ -92,8 +128,8 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - if ! git diff --quiet -- plugin.json .claude-plugin/marketplace.json; then - git add plugin.json .claude-plugin/marketplace.json + if ! git diff --quiet -- plugin.json .claude-plugin/marketplace.json package.json; then + git add plugin.json .claude-plugin/marketplace.json package.json git commit -m "chore(release): v$v [skip ci]" git push origin HEAD:main fi diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a2b703f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Danie van Zyl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7d3486f..16fb3b9 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,52 @@ -# Claude Code Config +# Code Skills -Personal configuration for [Claude Code](https://claude.ai/claude-code) — Anthropic's CLI for Claude. +Personal coding-agent platform for [Claude Code](https://claude.ai/claude-code) and [Pi](https://pi.dev). -## Installing skills +## Installation -Install skills from this repo with: +Install skills with: ```sh npx skills@latest add danievanzyl/code-skills ``` +Install the Pi package from npm: + +```sh +pi install npm:@danievanzyl/code-skills +``` + +Try the current Git checkout without installing: + +```sh +pi --no-extensions -e . +``` + +The Pi package includes a shared fixed status window, GitHub status section, custom editor status bar, background subagents, and bundled GitHub/web research personas. The fixed dock requires Pi's `fullscreen` TUI mode, selectable through `/settings`. + +### Publishing the Pi package + +The `pi-package` npm keyword makes the published package eligible for [pi.dev/packages](https://pi.dev/packages). Before the first release: + +1. Create or select the `@danievanzyl` npm organization/scope. +2. Publish once locally with `npm login && npm publish --access public`, or add an npm automation token as the repository secret `NPM_TOKEN`. +3. Later merges to `main` synchronize `package.json` with the Claude plugin version and publish automatically when `NPM_TOKEN` is configured. + +Validate before publishing: + +```sh +npm pack --dry-run +pi --approve --no-extensions -e . --list-models +``` + ## Structure | Path | Description | |------|-------------| -| `CLAUDE.md` | Global instructions applied to all sessions | -| `agents/` | Custom sub-agent definitions (codebase-analyzer, codebase-locator, etc.) | +| `CLAUDE.md` | Global Claude instructions applied to all sessions | +| `agents/` | Claude sub-agent definitions | +| `extensions/` | Pi extensions and bundled Pi subagent personas | +| `package.json` | npm/Pi package manifest | | `skills/` | Reusable skill definitions — hand-authored + vendored (see below) | | `commands/` | Custom slash commands | | `settings.json` | Claude Code settings | diff --git a/extensions/github-status-section.ts b/extensions/github-status-section.ts new file mode 100644 index 0000000..c6c6071 --- /dev/null +++ b/extensions/github-status-section.ts @@ -0,0 +1,226 @@ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { + STATUS_REQUEST_EVENT, + invalidateStatusSection, + publishStatusSection, + removeStatusSection, + type StatusRow, + type StatusSection, +} from "./status-window/api.ts"; + +type CheckBucket = "pass" | "fail" | "pending" | "skipping" | "cancel"; + +interface PullRequest { + number: number; + title: string; + state: string; + isDraft: boolean; + url: string; + reviewDecision?: string; +} + +interface Check { + name: string; + workflow?: string; + state: string; + bucket: CheckBucket; + link?: string; +} + +interface WorkflowRun { + name: string; + workflowName?: string; + status: string; + conclusion?: string; + url: string; + headBranch?: string; +} + +interface GithubState { + refreshing: boolean; + updatedAt?: number; + account?: string; + authError?: string; + repo?: string; + repoError?: string; + branch?: string; + pr?: PullRequest; + checks: Check[]; + runs: WorkflowRun[]; +} + +function parseJson(text: string, fallback: T): T { + try { return JSON.parse(text) as T; } + catch { return fallback; } +} + +function shortError(stderr: string, fallback: string): string { + const firstLine = stderr.trim().split("\n")[0]; + if (!firstLine) return fallback; + if (/no git remotes found|failed to determine base repo/i.test(firstLine)) return "no GitHub remote"; + if (/not logged|authentication|authenticate/i.test(firstLine)) return "not authenticated"; + if (/command not found|ENOENT/i.test(firstLine)) return "gh is not installed"; + return firstLine; +} + +export default function githubStatusSection(pi: ExtensionAPI) { + let state: GithubState = { refreshing: false, checks: [], runs: [] }; + let timer: ReturnType | undefined; + let refreshPromise: Promise | undefined; + let disposed = false; + let hidden = false; + + const section: StatusSection = { + id: "github", + priority: 50, + visible: () => !hidden, + getSnapshot: () => { + const rows: StatusRow[] = []; + rows.push(state.account + ? { label: "Auth", icon: "", text: `@${state.account}`, tone: "success" } + : { label: "Auth", icon: "", text: state.authError ?? "not authenticated", tone: "error" }); + rows.push(state.repo + ? { label: "Repo", text: state.repo, tone: "accent" } + : { label: "Repo", text: state.repoError ?? "no GitHub remote", tone: "warning" }); + rows.push({ label: "Branch", icon: "", text: state.branch ?? "detached", tone: "warning" }); + + if (state.pr) { + const pr = state.pr; + const prState = pr.isDraft ? "DRAFT" : pr.state; + rows.push({ label: "PR", icon: "", text: `#${pr.number} ${prState}`, tone: pr.isDraft ? "dim" : pr.state === "OPEN" ? "success" : "warning" }); + rows.push({ text: pr.title, tone: "text", indent: 10 }); + if (pr.reviewDecision) rows.push({ + label: "Review", + text: pr.reviewDecision.replaceAll("_", " "), + tone: pr.reviewDecision === "APPROVED" ? "success" : pr.reviewDecision === "CHANGES_REQUESTED" ? "error" : "warning", + }); + } else { + rows.push({ label: "PR", text: "no pull request for branch", tone: "dim" }); + } + + const passed = state.checks.filter((check) => check.bucket === "pass").length; + const failed = state.checks.filter((check) => check.bucket === "fail" || check.bucket === "cancel").length; + const pending = state.checks.filter((check) => check.bucket === "pending").length; + const total = state.checks.length; + if (total) { + const suffix = failed ? `, ${failed} failed` : pending ? `, ${pending} pending` : ""; + rows.push({ + label: "CI", + icon: failed ? "" : pending ? "" : "", + text: `${passed}/${total} passed${suffix}`, + tone: failed ? "error" : pending ? "warning" : "success", + }); + } else rows.push({ label: "CI", text: "no PR checks", tone: "dim" }); + + for (const run of state.runs.slice(0, 3)) { + const status = run.status === "completed" ? (run.conclusion ?? "completed") : run.status; + const failedRun = ["failure", "cancelled", "timed_out", "action_required", "startup_failure"].includes(status); + const pendingRun = ["queued", "in_progress", "pending", "requested", "waiting"].includes(status); + rows.push({ + label: "Workflow", + icon: failedRun ? "" : pendingRun ? "" : status === "success" ? "" : "", + text: run.workflowName || run.name || "workflow", + tone: failedRun ? "error" : pendingRun ? "warning" : status === "success" ? "success" : "dim", + }); + } + + const updated = state.updatedAt + ? `updated ${new Date(state.updatedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` + : "waiting for refresh"; + return { + title: "GitHub", + icon: "", + rows, + footer: state.refreshing ? ` refreshing · ${updated}` : `${updated} · /gh-status refresh`, + }; + }, + }; + + const publish = () => publishStatusSection(pi, section); + pi.events.on(STATUS_REQUEST_EVENT, publish); + + const run = async (ctx: ExtensionContext, command: string, args: string[]) => { + const result = await pi.exec(command, args, { cwd: ctx.cwd, timeout: 10_000 }).catch((error) => ({ + stdout: "", stderr: error instanceof Error ? error.message : String(error), code: 1, + })); + return { ok: result.code === 0, stdout: result.stdout.trim(), stderr: result.stderr.trim() }; + }; + + const refresh = (ctx: ExtensionContext) => { + if (refreshPromise || disposed) return refreshPromise; + state = { ...state, refreshing: true }; + invalidateStatusSection(pi, section.id); + refreshPromise = (async () => { + const [branchResult, authResult, repoResult] = await Promise.all([ + run(ctx, "git", ["branch", "--show-current"]), + run(ctx, "gh", ["api", "user", "--jq", ".login"]), + run(ctx, "gh", ["repo", "view", "--json", "nameWithOwner,url"]), + ]); + if (disposed) return; + const branch = branchResult.stdout || undefined; + const repo = parseJson<{ nameWithOwner?: string }>(repoResult.stdout, {}).nameWithOwner; + let pr: PullRequest | undefined; + let checks: Check[] = []; + let runs: WorkflowRun[] = []; + if (repo) { + const [prResult, runsResult] = await Promise.all([ + run(ctx, "gh", ["pr", "view", "--json", "number,title,state,isDraft,url,reviewDecision"]), + branch ? run(ctx, "gh", ["run", "list", "--branch", branch, "--limit", "3", "--json", "name,workflowName,status,conclusion,url,headBranch"]) : Promise.resolve({ ok: false, stdout: "", stderr: "" }), + ]); + pr = parseJson(prResult.stdout, undefined); + runs = parseJson(runsResult.stdout, []); + if (pr) { + const checksResult = await run(ctx, "gh", ["pr", "checks", "--json", "name,workflow,state,bucket,link"]); + checks = parseJson(checksResult.stdout, []); + } + } + if (disposed) return; + state = { + refreshing: false, + updatedAt: Date.now(), + account: authResult.ok ? authResult.stdout : undefined, + authError: authResult.ok ? undefined : shortError(authResult.stderr, "not authenticated"), + repo, + repoError: repo ? undefined : shortError(repoResult.stderr, "no GitHub remote"), + branch, pr, checks, runs, + }; + })().catch((error) => { + state = { ...state, refreshing: false, updatedAt: Date.now(), repoError: error instanceof Error ? error.message : String(error) }; + }).finally(() => { + refreshPromise = undefined; + invalidateStatusSection(pi, section.id); + }); + return refreshPromise; + }; + + pi.registerCommand("gh-status", { + description: "Control or refresh the GitHub status section (show|hide|toggle|refresh)", + handler: async (args, ctx) => { + const action = args.trim().toLowerCase() || "toggle"; + if (action === "refresh") { await refresh(ctx); return; } + if (action === "show") hidden = false; + else if (action === "hide") hidden = true; + else if (action === "toggle") hidden = !hidden; + else { ctx.ui.notify("Usage: /gh-status show|hide|toggle|refresh", "warning"); return; } + invalidateStatusSection(pi, section.id); + if (!hidden) await refresh(ctx); + }, + }); + + pi.on("agent_settled", (_event, ctx) => { void refresh(ctx); }); + pi.on("session_start", (_event, ctx) => { + disposed = false; + hidden = false; + state = { refreshing: false, checks: [], runs: [] }; + publish(); + void refresh(ctx); + timer = setInterval(() => void refresh(ctx), 30_000); + timer.unref(); + }); + pi.on("session_shutdown", () => { + disposed = true; + if (timer) clearInterval(timer); + timer = undefined; + removeStatusSection(pi, section.id); + }); +} diff --git a/extensions/picture-status-bar.ts b/extensions/picture-status-bar.ts new file mode 100644 index 0000000..e7b4574 --- /dev/null +++ b/extensions/picture-status-bar.ts @@ -0,0 +1,262 @@ +import { + CustomEditor, + type ExtensionAPI, + type ExtensionContext, + type KeybindingsManager, +} from "@earendil-works/pi-coding-agent"; +import type { Component, EditorTheme, TUI } from "@earendil-works/pi-tui"; +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; + +interface GitState { + branch?: string; + changed: number; + untracked: number; +} + +class EmptyFooter implements Component { + render(): string[] { return []; } + invalidate(): void {} +} + +type MutableStack = { + entries?: Array<{ component?: MutableStack; minSize?: number }>; +}; + +function collapseFullscreenFooter(app: TUI): void { + if (app.mode !== "fullscreen") return; + // Pi currently gives the fullscreen footer a hard-coded minSize of 1. + // Feature-detect the internal stack and let our intentionally empty footer + // measure to zero. If Pi's layout changes, this safely becomes a no-op. + const root = (app as TUI & { layoutRoot?: MutableStack }).layoutRoot; + const dock = root?.entries?.[1]?.component; + const footerEntry = dock?.entries?.at(-1); + if (footerEntry) footerEntry.minSize = 0; +} + +function formatContext(ctx: ExtensionContext): string { + const usage = ctx.getContextUsage(); + const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow; + const percent = usage?.percent; + + if (!contextWindow || percent === null || percent === undefined) return "ctx ?"; + const windowLabel = + contextWindow >= 1_000_000 + ? `${(contextWindow / 1_000_000).toFixed(1)}M` + : `${Math.round(contextWindow / 1000)}k`; + return `${percent.toFixed(1)}%/${windowLabel}`; +} + +function formatPath(cwd: string): string { + const home = process.env.HOME; + return home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd; +} + +function getHerdrPaneId(): string | undefined { + const paneId = process.env.HERDR_PANE_ID?.trim(); + return process.env.HERDR_ENV === "1" && paneId ? paneId : undefined; +} + +function fitBorderLine( + status: string, + width: number, + colorBorder: (text: string) => string, + leftCorner: string, + rightCorner: string, +): string { + if (width <= 0) return ""; + if (width === 1) return colorBorder("─"); + + const cornersWidth = 2; + const available = Math.max(0, width - cornersWidth); + const fittedStatus = truncateToWidth(status, available, ""); + const fillWidth = Math.max(0, available - visibleWidth(fittedStatus)); + + return colorBorder(leftCorner) + fittedStatus + colorBorder("─".repeat(fillWidth)) + colorBorder(rightCorner); +} + +function fitAnimatedBottomBorder( + status: string, + width: number, + colorBorder: (text: string) => string, + colorProgress: (text: string) => string, + working: boolean, + tick: number, +): string { + if (width <= 0) return ""; + if (width === 1) return colorBorder("─"); + + const available = Math.max(0, width - 2); + const fittedStatus = truncateToWidth(status, available, ""); + const fillWidth = Math.max(0, available - visibleWidth(fittedStatus)); + + if (!working || fillWidth < 2) { + return colorBorder("╰") + fittedStatus + colorBorder("─".repeat(fillWidth)) + colorBorder("╯"); + } + + const progressWidth = Math.min(3, fillWidth); + const maxPosition = Math.max(0, fillWidth - progressWidth); + const cycle = Math.max(1, maxPosition * 2); + const phase = tick % cycle; + const position = phase <= maxPosition ? phase : cycle - phase; + const before = "─".repeat(position); + const progress = "━".repeat(progressWidth); + const after = "─".repeat(fillWidth - position - progressWidth); + + return colorBorder("╰") + fittedStatus + colorBorder(before) + colorProgress(progress) + colorBorder(after) + colorBorder("╯"); +} + +export default function (pi: ExtensionAPI) { + let tui: TUI | undefined; + let working = false; + let animationTick = 0; + let spinnerTimer: ReturnType | undefined; + let git: GitState = { changed: 0, untracked: 0 }; + let gitRefresh: Promise | undefined; + + const stopSpinner = () => { + if (spinnerTimer) clearInterval(spinnerTimer); + spinnerTimer = undefined; + }; + + const refreshGit = (ctx: ExtensionContext) => { + if (gitRefresh) return gitRefresh; + + gitRefresh = (async () => { + const result = await pi + .exec("git", ["status", "--porcelain=v1", "--branch"], { + cwd: ctx.cwd, + timeout: 2000, + }) + .catch(() => undefined); + + if (!result || result.code !== 0) { + git = { changed: 0, untracked: 0 }; + return; + } + + const lines = result.stdout.split("\n").filter(Boolean); + const header = lines.shift(); + let branch = header?.replace(/^##\s+/, "").split("...")[0]?.trim(); + branch = branch?.replace(/^No commits yet on\s+/, ""); + + let changed = 0; + let untracked = 0; + for (const line of lines) { + if (line.startsWith("??")) untracked++; + else changed++; + } + + git = { branch: branch || undefined, changed, untracked }; + })().finally(() => { + gitRefresh = undefined; + tui?.requestRender(); + }); + + return gitRefresh; + }; + + pi.on("agent_start", (_event, ctx) => { + working = true; + stopSpinner(); + animationTick = 0; + spinnerTimer = setInterval(() => { + animationTick++; + tui?.requestRender(); + }, 55); + void refreshGit(ctx); + tui?.requestRender(); + }); + + pi.on("agent_settled", (_event, ctx) => { + working = false; + stopSpinner(); + void refreshGit(ctx); + tui?.requestRender(); + }); + + pi.on("tool_execution_end", (_event, ctx) => { + void refreshGit(ctx); + }); + + pi.on("model_select", () => tui?.requestRender()); + pi.on("thinking_level_select", () => tui?.requestRender()); + + pi.on("session_shutdown", () => { + stopSpinner(); + tui = undefined; + }); + + pi.on("session_start", (_event, ctx) => { + ctx.ui.setWorkingVisible(false); + ctx.ui.setFooter(() => new EmptyFooter()); + void refreshGit(ctx); + + class PictureStatusEditor extends CustomEditor { + private readonly defaultBorderColor: (text: string) => string; + + constructor(app: TUI, theme: EditorTheme, keybindings: KeybindingsManager) { + super(app, theme, keybindings, { paddingX: 0 }); + this.defaultBorderColor = this.borderColor; + tui = app; + collapseFullscreenFooter(app); + app.requestRender(); + } + + render(width: number): string[] { + const theme = ctx.ui.theme; + const contextPercent = ctx.getContextUsage()?.percent ?? 0; + this.borderColor = + contextPercent >= 20 + ? (text) => theme.fg("error", text) + : contextPercent > 12 + ? (text) => theme.fg("warning", text) + : this.defaultBorderColor; + + const lines = super.render(width); + if (lines.length === 0) return lines; + + const separator = theme.fg("dim", " › "); + const model = ctx.model?.id ?? "no-model"; + const thinking = pi.getThinkingLevel(); + + const parts = [ + theme.fg("warning", theme.bold("π")), + theme.fg("accent", ` ${model}`), + theme.fg("warning", `󰧑 ${thinking}`), + ]; + + if (git.branch) { + let gitText = ` ${git.branch}`; + if (git.changed > 0) gitText += ` *${git.changed}`; + if (git.untracked > 0) gitText += ` ?${git.untracked}`; + parts.push(theme.fg("warning", gitText)); + } + + parts.push(theme.fg("muted", `◧ ${formatContext(ctx)}`)); + + const borderColor = (text: string) => this.borderColor(text); + const topStatus = borderColor("─") + parts.join(separator) + borderColor("─"); + const paneId = getHerdrPaneId(); + const location = paneId + ? theme.fg("thinkingHigh", ` ${paneId} `) + theme.fg("accent", `${formatPath(ctx.cwd)} `) + : theme.fg("accent", ` ${formatPath(ctx.cwd)} `); + const pathStatus = borderColor("─") + location + borderColor("─"); + + lines[0] = fitBorderLine(topStatus, width, borderColor, "╭", "╮"); + if (lines.length > 1) { + lines[lines.length - 1] = fitAnimatedBottomBorder( + pathStatus, + width, + borderColor, + (text) => theme.fg("accent", text), + working, + animationTick, + ); + } + return lines; + } + } + + ctx.ui.setEditorComponent((app, theme, keybindings) => new PictureStatusEditor(app, theme, keybindings)); + }); +} diff --git a/extensions/status-window/README.md b/extensions/status-window/README.md new file mode 100644 index 0000000..7b2f152 --- /dev/null +++ b/extensions/status-window/README.md @@ -0,0 +1,80 @@ +# Shared status window + +A single non-capturing top-right window that composes status sections from independent Pi extensions. This prevents plugins from creating overlapping overlays. + +## Behavior + +- Width: 48 columns +- Maximum height: 24 rows +- Hidden on terminals narrower than 70 columns +- Sections are stacked by descending `priority` +- Empty/inactive sections are omitted +- Overflow is truncated with an omitted-line count + +Commands: + +```text +/status-window show +/status-window hide +/status-window toggle +/status-window sections +``` + +## Registering a section + +Import the shared API and publish a data-oriented section. Registration is idempotent by `id`. + +```ts +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { + STATUS_REQUEST_EVENT, + invalidateStatusSection, + publishStatusSection, + removeStatusSection, + type StatusSection, +} from "./status-window/api.ts"; + +export default function (pi: ExtensionAPI) { + let value = "waiting"; + + const section: StatusSection = { + id: "example", + priority: 10, + visible: () => true, + getSnapshot: () => ({ + title: "Example", + icon: "●", + rows: [ + { label: "State", text: value, tone: "success" }, + ], + footer: "/example refresh", + }), + }; + + const publish = () => publishStatusSection(pi, section); + pi.events.on(STATUS_REQUEST_EVENT, publish); + + pi.on("session_start", publish); + pi.on("session_shutdown", () => removeStatusSection(pi, section.id)); + + // Call after changing data or visibility. + const update = (next: string) => { + value = next; + invalidateStatusSection(pi, section.id); + }; +} +``` + +## Row schema + +```ts +type StatusRow = { + label?: string; + text: string; + tone?: "text" | "accent" | "muted" | "dim" | "success" | "warning" | "error"; + icon?: string; + indent?: number; +}; +``` + +Plugins provide plain data rather than arbitrary components, allowing the host to enforce sizing, borders, theme colors, ordering, and responsive behavior consistently. diff --git a/extensions/status-window/api.ts b/extensions/status-window/api.ts new file mode 100644 index 0000000..d815e57 --- /dev/null +++ b/extensions/status-window/api.ts @@ -0,0 +1,42 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export const STATUS_REGISTER_EVENT = "status-window:register"; +export const STATUS_UNREGISTER_EVENT = "status-window:unregister"; +export const STATUS_INVALIDATE_EVENT = "status-window:invalidate"; +export const STATUS_REQUEST_EVENT = "status-window:request-registrations"; + +export type StatusTone = "text" | "accent" | "muted" | "dim" | "success" | "warning" | "error"; + +export interface StatusRow { + label?: string; + text: string; + tone?: StatusTone; + icon?: string; + indent?: number; +} + +export interface StatusSectionSnapshot { + title: string; + icon?: string; + rows: StatusRow[]; + footer?: string; +} + +export interface StatusSection { + id: string; + priority?: number; + visible?: () => boolean; + getSnapshot: () => StatusSectionSnapshot; +} + +export function publishStatusSection(pi: ExtensionAPI, section: StatusSection): void { + pi.events.emit(STATUS_REGISTER_EVENT, section); +} + +export function invalidateStatusSection(pi: ExtensionAPI, id: string): void { + pi.events.emit(STATUS_INVALIDATE_EVENT, { id }); +} + +export function removeStatusSection(pi: ExtensionAPI, id: string): void { + pi.events.emit(STATUS_UNREGISTER_EVENT, { id }); +} diff --git a/extensions/status-window/index.ts b/extensions/status-window/index.ts new file mode 100644 index 0000000..edc1896 --- /dev/null +++ b/extensions/status-window/index.ts @@ -0,0 +1,167 @@ +import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; +import type { Component, OverlayHandle, TUI } from "@earendil-works/pi-tui"; +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { + STATUS_INVALIDATE_EVENT, + STATUS_REGISTER_EVENT, + STATUS_REQUEST_EVENT, + STATUS_UNREGISTER_EVENT, + type StatusRow, + type StatusSection, +} from "./api.ts"; + +const WINDOW_WIDTH = 48; +const MAX_CONTENT_LINES = 21; + +class EmptyAnchor implements Component { + render(): string[] { return []; } + invalidate(): void {} +} + +class StatusWindow implements Component { + constructor( + private readonly theme: Theme, + private readonly getSections: () => StatusSection[], + ) {} + + private renderRow(row: StatusRow, innerWidth: number): string { + const tone = row.tone ?? "text"; + const indent = " ".repeat(Math.max(0, row.indent ?? 0)); + const label = row.label ? `${this.theme.fg("dim", row.label.padEnd(9))} ` : ""; + const icon = row.icon ? `${this.theme.fg(tone, row.icon)} ` : ""; + return truncateToWidth(` ${indent}${label}${icon}${this.theme.fg(tone, row.text)}`, innerWidth, "…", true); + } + + render(width: number): string[] { + const w = Math.max(24, Math.min(width, WINDOW_WIDTH)); + const inner = w - 2; + const border = (text: string) => this.theme.fg("border", text); + const row = (content = "") => { + const clipped = truncateToWidth(content, inner, "", true); + return border("│") + clipped + " ".repeat(Math.max(0, inner - visibleWidth(clipped))) + border("│"); + }; + const divider = () => border("├") + border("─".repeat(inner)) + border("┤"); + const sections = this.getSections().filter((section) => section.visible?.() ?? true); + const content: string[] = []; + + for (const section of sections) { + let snapshot; + try { + snapshot = section.getSnapshot(); + } catch (error) { + snapshot = { + title: section.id, + rows: [{ text: error instanceof Error ? error.message : String(error), tone: "error" as const }], + }; + } + if (content.length > 0) content.push(divider()); + const title = `${snapshot.icon ? `${snapshot.icon} ` : ""}${snapshot.title}`; + content.push(row(` ${this.theme.fg("accent", this.theme.bold(title))}`)); + for (const statusRow of snapshot.rows) content.push(row(this.renderRow(statusRow, inner))); + if (snapshot.footer) content.push(row(` ${this.theme.fg("dim", snapshot.footer)}`)); + } + + if (content.length === 0) return []; + let omitted = 0; + if (content.length > MAX_CONTENT_LINES) { + omitted = content.length - (MAX_CONTENT_LINES - 1); + content.splice(MAX_CONTENT_LINES - 1); + content.push(row(` ${this.theme.fg("dim", `… ${omitted} more line${omitted === 1 ? "" : "s"}`)}`)); + } + return [ + border("╭") + border("─".repeat(inner)) + border("╮"), + ...content, + border("╰") + border("─".repeat(inner)) + border("╯"), + ]; + } + + invalidate(): void {} +} + +export default function statusWindowExtension(pi: ExtensionAPI) { + const sections = new Map(); + let tui: TUI | undefined; + let overlay: OverlayHandle | undefined; + let panel: StatusWindow | undefined; + let hiddenByUser = false; + let disposed = false; + + const visibleSections = () => [...sections.values()] + .filter((section) => section.visible?.() ?? true) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.id.localeCompare(b.id)); + + const update = () => { + const shouldHide = hiddenByUser || visibleSections().length === 0; + overlay?.setHidden(shouldHide); + tui?.requestRender(); + }; + + pi.events.on(STATUS_REGISTER_EVENT, (payload: unknown) => { + const section = payload as StatusSection; + if (!section?.id || typeof section.getSnapshot !== "function") return; + sections.set(section.id, section); + update(); + }); + pi.events.on(STATUS_UNREGISTER_EVENT, (payload: unknown) => { + const id = (payload as { id?: string })?.id; + if (id) sections.delete(id); + update(); + }); + pi.events.on(STATUS_INVALIDATE_EVENT, () => update()); + + pi.registerCommand("status-window", { + description: "Control the shared plugin status window (show|hide|toggle|sections)", + handler: async (args, ctx) => { + const action = args.trim().toLowerCase() || "toggle"; + if (action === "sections") { + const list = [...sections.values()] + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) + .map((section) => `${section.id}${section.visible?.() === false ? " (inactive)" : ""}`) + .join(", "); + ctx.ui.notify(list || "No status sections registered", "info"); + return; + } + if (action === "show") hiddenByUser = false; + else if (action === "hide") hiddenByUser = true; + else if (action === "toggle") hiddenByUser = !hiddenByUser; + else { + ctx.ui.notify("Usage: /status-window show|hide|toggle|sections", "warning"); + return; + } + update(); + }, + }); + + pi.on("session_start", (_event, ctx) => { + disposed = false; + sections.clear(); + ctx.ui.setWidget("status-window-anchor", (app, theme) => { + tui = app; + panel = new StatusWindow(theme, visibleSections); + queueMicrotask(() => { + if (disposed || overlay || !panel) return; + overlay = app.showOverlay(panel, { + nonCapturing: true, + anchor: "top-right", + width: WINDOW_WIDTH, + maxHeight: 24, + margin: { top: 1, right: 1 }, + visible: (terminalWidth) => terminalWidth >= 70, + }); + update(); + }); + return new EmptyAnchor(); + }); + pi.events.emit(STATUS_REQUEST_EVENT, undefined); + }); + + pi.on("session_shutdown", (_event, ctx) => { + disposed = true; + overlay?.hide(); + overlay = undefined; + panel = undefined; + tui = undefined; + sections.clear(); + ctx.ui.setWidget("status-window-anchor", undefined); + }); +} diff --git a/extensions/subagents/README.md b/extensions/subagents/README.md new file mode 100644 index 0000000..55dc636 --- /dev/null +++ b/extensions/subagents/README.md @@ -0,0 +1,56 @@ +# Markdown subagents + +Pi extension that runs Markdown-defined personas in isolated child `pi` processes. Dispatch is non-blocking: the tool returns immediately, and each final report is delivered back to the parent context as a follow-up message. + +## Persona locations + +Default (`scope: "user"`): + +- Bundled package personas in `extensions/subagents/agents/*.md` +- User overrides in `~/.pi/agent/agents/*.md` + +Optional trusted-project scopes also load the nearest: + +- `.pi/agents/*.md` + +Later sources override an earlier persona with the same `name`: user personas override bundled personas, and project personas override both when using `scope: "both"`. + +## Persona format + +Persona files use YAML frontmatter: + +```markdown +--- +name: code-reviewer +description: Reviews changes for correctness and regressions +tools: read, ls, bash +model: sonnet +--- + +You are a focused code reviewer. Use `rg` for content search and `fd` for file discovery through `bash`. Cite file and line references. +``` + +Use Pi-native tool names in `tools`. For shell-based searching, grant `bash` and instruct the persona to use `rg` and `fd`. If `model` is absent or `inherit`, the child inherits the parent's model and thinking level. + +## Usage + +- `/subagents` lists user personas. +- `/subagents both` also lists project personas. +- The parent model calls `subagent` with `{ persona, task }`. +- `/subagent-cancel ` cancels background jobs. + +Example request: + +```text +Ask codebase-analyzer to trace the authentication flow and report file:line references. +``` + +## Isolation and skill inheritance + +Each invocation uses `pi --mode json -p --no-session`, so it has a fresh conversation and no persisted child session. It runs in the parent's working directory, retaining normal project context and file access. The parent agent is free to continue working while the child runs; completion is injected with `deliverAs: "followUp"` and triggers a parent turn when idle. + +In TUI mode, active jobs register a `subagents` section in the shared top-right status window. Each row shows the persona name, elapsed wall time, latest context size, and cumulative input/output tokens. The section updates every second and disappears automatically when no jobs are running. It shares one window with GitHub and future status plugins, avoiding overlapping overlays. + +`inheritSkills` defaults to `true`. The extension captures the skills loaded in the current parent turn, disables child skill auto-discovery, and passes those exact skill paths with repeated `--skill` flags. Set `inheritSkills: false` to let the child perform normal skill discovery instead. + +The `subagent` tool is excluded in children to avoid recursive delegation. Pressing Ctrl+C aborts the child process. Reports returned to the parent are capped at 50 KB. diff --git a/extensions/subagents/agents/gh-search-researcher.md b/extensions/subagents/agents/gh-search-researcher.md new file mode 100644 index 0000000..2428b6a --- /dev/null +++ b/extensions/subagents/agents/gh-search-researcher.md @@ -0,0 +1,194 @@ +--- +name: gh-search-researcher +description: Need to research GitHub repos, PRs, issues, discussions, code, or users? The gh-search-researcher uses the gh CLI exclusively to find information across GitHub. Great for finding issues, understanding PR history, searching code on GitHub, exploring repo activity, and investigating GitHub-hosted projects. Re-run with an altered prompt if the first pass doesn't satisfy. +tools: bash, read, ls +color: green +model: gpt-5.6-terra +--- + +You are an expert GitHub research specialist. You use the `gh` CLI tool exclusively to discover and retrieve information from GitHub. You never use WebSearch or WebFetch — all research is done through `gh` commands. + +## Core Responsibilities + +When you receive a research query, you will: + +1. **Analyze the Query**: Break down the request to identify: + - Target repos, orgs, or users + - Whether the answer lives in issues, PRs, discussions, code, releases, or repo metadata + - Multiple search angles to ensure comprehensive coverage + +2. **Execute Strategic gh Commands**: + - Start with broad searches to understand the landscape + - Refine with specific filters (labels, authors, dates, states) + - Use multiple command variations to capture different perspectives + - Combine search results with detail fetches for full context + +3. **Fetch and Analyze Content**: + - Use `gh` subcommands to retrieve full details from promising results + - Prioritize official repos, maintainer comments, and authoritative sources + - Extract specific quotes and sections relevant to the query + - Note dates and versions to ensure currency + +4. **Synthesize Findings**: + - Organize information by relevance and authority + - Include exact quotes with proper attribution + - Provide direct GitHub URLs to sources + - Highlight conflicting information or version-specific details + - Note gaps in available information + +## Key gh Commands + +### Searching Code + +```bash +gh search code "query" --repo owner/repo +gh search code "query" --language go --owner org +gh search code "query" --filename config.yaml +``` + +### Searching Issues + +```bash +gh search issues "query" --repo owner/repo +gh search issues "query" --label bug --state open +gh search issues "query" --author username +gh issue list --repo owner/repo --label "bug" --state open +gh issue view NUMBER --repo owner/repo +gh issue view NUMBER --repo owner/repo --comments +``` + +### Searching PRs + +```bash +gh search prs "query" --repo owner/repo +gh search prs "query" --state merged --author username +gh pr list --repo owner/repo --state merged --search "query" +gh pr view NUMBER --repo owner/repo +gh pr view NUMBER --repo owner/repo --comments +gh pr diff NUMBER --repo owner/repo +``` + +### Searching Repos + +```bash +gh search repos "query" --language python --sort stars +gh search repos "query" --owner org --sort updated +gh repo view owner/repo +gh repo view owner/repo --json description,stargazerCount,issues,pullRequests +``` + +### Browsing Repo Content + +```bash +gh api repos/owner/repo/contents/path +gh api repos/owner/repo/readme +gh release list --repo owner/repo +gh release view TAG --repo owner/repo +``` + +### Discussions + +```bash +gh api repos/owner/repo/discussions --paginate +gh search issues "query" --repo owner/repo --type discussion (via API) +``` + +### Using the API Directly + +```bash +gh api search/code -X GET -f q="query+repo:owner/repo" +gh api search/issues -X GET -f q="query+repo:owner/repo+is:issue" +gh api repos/owner/repo/commits --jq '.[].commit.message' +gh api graphql -f query='{ ... }' +``` + +## Search Strategies + +### For Bug Investigation + +- Search issues for error messages or symptoms +- Check closed issues for past fixes +- Look at recent PRs for related changes +- Review release notes for relevant versions + +### For Feature Discovery + +- Search issues with "feature request" or "enhancement" labels +- Check discussions for RFC or proposal threads +- Look at merged PRs for recent additions +- Review repo README and docs + +### For Understanding a Project + +- `gh repo view` for overview and metadata +- Check recent releases for activity level +- List top issues and PRs to understand priorities +- Search code for specific patterns or implementations + +### For Comparing Projects + +- `gh repo view --json` for star counts, forks, issues +- Check release frequency and recency +- Compare issue response times +- Look at contributor activity + +### For Finding Examples + +- Search code across repos for usage patterns +- Look at test files for expected behavior +- Check discussions/issues for user-shared examples +- Review PRs for implementation patterns + +## Output Format + +Structure your findings as: + +``` +## Summary +[Brief overview of key findings] + +## Detailed Findings + +### [Topic/Source 1] +**Source**: [Repo/Issue/PR with GitHub URL] +**Relevance**: [Why this source is authoritative/useful] +**Key Information**: +- Direct quote or finding +- Another relevant point + +### [Topic/Source 2] +[Continue pattern...] + +## Additional Resources +- [GitHub URL 1] - Brief description +- [GitHub URL 2] - Brief description + +## Gaps or Limitations +[Note any information that couldn't be found or requires further investigation] +``` + +## Quality Guidelines + +- **Accuracy**: Quote sources accurately and provide direct GitHub URLs +- **Relevance**: Focus on information that directly addresses the query +- **Currency**: Note dates and version information +- **Authority**: Prioritize maintainer comments, official repos, and high-signal sources +- **Completeness**: Search from multiple angles (issues, PRs, code, discussions) +- **Transparency**: Clearly indicate when information is outdated, conflicting, or uncertain + +## Search Efficiency + +- Start with 2-3 well-crafted gh searches before deep-diving +- Fetch full details on only the most promising 3-5 results initially +- If initial results are insufficient, refine filters and try again +- Use `--json` and `--jq` flags to extract structured data efficiently +- Combine `--limit` flags to avoid overwhelming output +- Use `--sort` and `--order` to surface the most relevant results first + +## Important Constraints + +- **Only use `gh` CLI commands** — no WebSearch, no WebFetch, no curl to non-GitHub APIs +- All bash commands should be `gh` commands or simple text processing (jq, head, tail, etc.) +- If a query can't be answered via GitHub, state that clearly and suggest the user try web-search-researcher instead + +Remember: You are the user's expert guide to GitHub information. Be thorough but efficient, always cite your sources with GitHub URLs, and provide actionable information that directly addresses their needs. Think deeply as you work. diff --git a/extensions/subagents/agents/web-search-researcher.md b/extensions/subagents/agents/web-search-researcher.md new file mode 100644 index 0000000..d4d2cc4 --- /dev/null +++ b/extensions/subagents/agents/web-search-researcher.md @@ -0,0 +1,114 @@ +--- +name: web-search-researcher +description: Do you find yourself desiring information that you don't quite feel well-trained (confident) on? Information that is modern and potentially only discoverable on the web? Use the web-search-researcher subagent_type today to find any and all answers to your questions! It will research deeply to figure out and attempt to answer your questions! If you aren't immediately satisfied you can get your money back! (Not really - but you can re-run web-search-researcher with an altered prompt in the event you're not satisfied the first time) +tools: bash, read, ls +color: yellow +model: gpt-5.6-terra +--- + +You are an expert web research specialist focused on finding accurate, relevant information from web sources. Use `bash` with `curl -fsSL` to retrieve web pages and search endpoints, and use `gh` for GitHub sources. Use `read` and `ls` for direct inspection, `rg` through `bash` for content search, and `fd` through `bash` for file discovery when local official documentation is available. + +## Core Responsibilities + +When you receive a research query, you will: + +1. **Analyze the Query**: Break down the user's request to identify: + - Key search terms and concepts + - Types of sources likely to have answers (documentation, blogs, forums, academic papers) + - Multiple search angles to ensure comprehensive coverage + +2. **Execute Strategic Searches**: + - Start with broad searches to understand the landscape + - Refine with specific technical terms and phrases + - Use multiple search variations to capture different perspectives + - Include site-specific searches when targeting known authoritative sources (e.g., "site:docs.stripe.com webhook signature") + +3. **Fetch and Analyze Content**: + - Use `bash` with `curl -fsSL` to retrieve full content from promising URLs + - Prioritize official documentation, reputable technical blogs, and authoritative sources + - Extract specific quotes and sections relevant to the query + - Note publication dates to ensure currency of information + +4. **Synthesize Findings**: + - Organize information by relevance and authority + - Include exact quotes with proper attribution + - Provide direct links to sources + - Highlight any conflicting information or version-specific details + - Note any gaps in available information + +## Search Strategies + +### For API/Library Documentation + +- Search for official docs first: "[library name] official documentation [specific feature]" +- Look for changelog or release notes for version-specific information +- Find code examples in official repositories or trusted tutorials + +### For Best Practices + +- Search for recent articles (include year in search when relevant) +- Look for content from recognized experts or organizations +- Cross-reference multiple sources to identify consensus +- Search for both "best practices" and "anti-patterns" to get full picture + +### For Technical Solutions + +- Use specific error messages or technical terms in quotes +- Search Stack Overflow and technical forums for real-world solutions +- Look for GitHub issues and discussions in relevant repositories +- Find blog posts describing similar implementations + +### For Comparisons + +- Search for "X vs Y" comparisons +- Look for migration guides between technologies +- Find benchmarks and performance comparisons +- Search for decision matrices or evaluation criteria + +## Output Format + +Structure your findings as: + +``` +## Summary +[Brief overview of key findings] + +## Detailed Findings + +### [Topic/Source 1] +**Source**: [Name with link] +**Relevance**: [Why this source is authoritative/useful] +**Key Information**: +- Direct quote or finding (with link to specific section if possible) +- Another relevant point + +### [Topic/Source 2] +[Continue pattern...] + +## Additional Resources +- [Relevant link 1] - Brief description +- [Relevant link 2] - Brief description + +## Gaps or Limitations +[Note any information that couldn't be found or requires further investigation] +``` + +## Quality Guidelines + +- **Accuracy**: Always quote sources accurately and provide direct links +- **Relevance**: Focus on information that directly addresses the user's query +- **Currency**: Note publication dates and version information when relevant +- **Authority**: Prioritize official sources, recognized experts, and peer-reviewed content +- **Completeness**: Search from multiple angles to ensure comprehensive coverage +- **Transparency**: Clearly indicate when information is outdated, conflicting, or uncertain + +## Search Efficiency + +- Start with 2-3 well-crafted searches before fetching content +- Fetch only the most promising 3-5 pages initially +- If live search or network access is unavailable, state that limitation explicitly rather than presenting local documentation as live web verification +- If initial results are insufficient, refine search terms and try again +- Use search operators effectively: quotes for exact phrases, minus for exclusions, site: for specific domains +- Consider searching in different forms: tutorials, documentation, Q&A sites, and discussion forums + +Remember: You are the user's expert guide to web information. Be thorough but efficient, always cite your sources, and provide actionable information that directly addresses their needs. Think deeply as you work. diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts new file mode 100644 index 0000000..695ad83 --- /dev/null +++ b/extensions/subagents/index.ts @@ -0,0 +1,395 @@ +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { Message } from "@earendil-works/pi-ai"; +import { StringEnum } from "@earendil-works/pi-ai"; +import type { ExtensionAPI, Skill } from "@earendil-works/pi-coding-agent"; +import { Text } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import { + STATUS_REQUEST_EVENT, + invalidateStatusSection, + publishStatusSection, + removeStatusSection, + type StatusSection, +} from "../status-window/api.ts"; +import { discoverPersonas, type Persona, type PersonaScope } from "./personas.ts"; + +const MAX_REPORT_BYTES = 50 * 1024; + +type UsageStats = { + input: number; + output: number; + context: number; + turns: number; +}; + +type RunDetails = { + jobId: string; + persona: string; + source: Persona["source"]; + filePath: string; + task: string; + model?: string; + inheritedSkills: string[]; + exitCode: number; + wallTimeMs: number; + usage: UsageStats; +}; + +type Job = { + id: string; + persona: string; + task: string; + startedAt: number; + model?: string; + usage: UsageStats; + controller: AbortController; +}; + +function formatCount(value: number): string { + if (value < 1_000) return String(value); + if (value < 1_000_000) return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)}k`; + return `${(value / 1_000_000).toFixed(1)}m`; +} + +function formatDuration(ms: number): string { + const seconds = Math.max(0, Math.floor(ms / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`; +} + +function piInvocation(args: string[]): { command: string; args: string[] } { + const script = process.argv[1]; + if (script && !script.startsWith("/$bunfs/root/") && fs.existsSync(script)) { + return { command: process.execPath, args: [script, ...args] }; + } + const executable = path.basename(process.execPath).toLowerCase(); + return /^(node|bun)(\.exe)?$/.test(executable) + ? { command: "pi", args } + : { command: process.execPath, args }; +} + +function textFrom(message: Message): string { + if (message.role !== "assistant") return ""; + return message.content + .filter((part): part is Extract<(typeof message.content)[number], { type: "text" }> => part.type === "text") + .map((part) => part.text) + .join("\n"); +} + +function capReport(text: string): string { + if (Buffer.byteLength(text, "utf8") <= MAX_REPORT_BYTES) return text; + let end = Math.min(text.length, MAX_REPORT_BYTES); + while (Buffer.byteLength(text.slice(0, end), "utf8") > MAX_REPORT_BYTES) end--; + return `${text.slice(0, end)}\n\n[Subagent report truncated to 50 KB.]`; +} + +async function writeSystemPrompt(persona: Persona): Promise<{ dir: string; file: string }> { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-persona-")); + const file = path.join(dir, "SYSTEM.md"); + const prompt = `${persona.prompt}\n\n# Subagent contract\n\nWork only on the delegated task. Your conversation is isolated from the parent agent. When finished, return a concise report containing the result, important evidence, files changed, and any unresolved issues. Do not ask the parent follow-up questions unless the task cannot proceed.`; + await fs.promises.writeFile(file, prompt, { encoding: "utf8", mode: 0o600 }); + return { dir, file }; +} + +async function runPersona(options: { + jobId: string; + persona: Persona; + task: string; + cwd: string; + parentModel?: string; + thinkingLevel?: ThinkingLevel; + skills: Skill[]; + inheritSkills: boolean; + signal: AbortSignal; + onProgress: (usage: UsageStats) => void; +}): Promise<{ report: string; stderr: string; details: RunDetails }> { + const { jobId, persona, task, cwd, parentModel, thinkingLevel, skills, inheritSkills, signal, onProgress } = options; + const startedAt = Date.now(); + const temp = await writeSystemPrompt(persona); + const args = ["--mode", "json", "-p", "--no-session", "--append-system-prompt", temp.file, "--exclude-tools", "subagent"]; + const model = persona.model ?? parentModel; + if (model) args.push("--model", model); + if (!persona.model && thinkingLevel) args.push("--thinking", thinkingLevel); + if (persona.tools !== undefined) { + if (persona.tools.length) args.push("--tools", persona.tools.join(",")); + else args.push("--no-tools"); + } + + const inheritedSkills = inheritSkills ? skills.filter((skill) => fs.existsSync(skill.filePath)) : []; + if (inheritSkills) { + args.push("--no-skills"); + for (const skill of inheritedSkills) args.push("--skill", skill.filePath); + } + args.push(`Task delegated by the parent agent:\n\n${task}`); + + let stderr = ""; + let buffer = ""; + let finalReport = ""; + const usage: UsageStats = { input: 0, output: 0, context: 0, turns: 0 }; + let aborted = false; + + try { + const exitCode = await new Promise((resolve) => { + const invocation = piInvocation(args); + const child = spawn(invocation.command, invocation.args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + let closed = false; + let killTimer: NodeJS.Timeout | undefined; + + const processLine = (line: string) => { + if (!line.trim()) return; + try { + const event = JSON.parse(line) as { type?: string; message?: Message }; + if (event.type === "message_end" && event.message?.role === "assistant") { + usage.turns++; + const messageUsage = event.message.usage; + if (messageUsage) { + usage.input += messageUsage.input || 0; + usage.output += messageUsage.output || 0; + usage.context = messageUsage.totalTokens || 0; + } + const text = textFrom(event.message); + if (text) finalReport = text; + onProgress({ ...usage }); + } + } catch { + // Ignore non-event stdout diagnostics. + } + }; + + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) processLine(line); + }); + child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); + child.on("error", (error) => { stderr += `${error.message}\n`; }); + child.on("close", (code) => { + closed = true; + if (killTimer) clearTimeout(killTimer); + if (buffer.trim()) processLine(buffer); + resolve(code ?? 1); + }); + + const abort = () => { + aborted = true; + child.kill("SIGTERM"); + killTimer = setTimeout(() => { if (!closed) child.kill("SIGKILL"); }, 5000); + killTimer.unref(); + }; + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort, { once: true }); + }); + + if (aborted) throw new Error("Subagent was aborted"); + const report = finalReport || stderr.trim() || "(subagent produced no report)"; + return { + report: capReport(exitCode === 0 ? report : `Subagent exited with code ${exitCode}.\n\n${report}`), + stderr, + details: { + jobId, + persona: persona.name, + source: persona.source, + filePath: persona.filePath, + task, + model, + inheritedSkills: inheritedSkills.map((skill) => skill.name), + exitCode, + wallTimeMs: Date.now() - startedAt, + usage, + }, + }; + } finally { + await fs.promises.rm(temp.dir, { recursive: true, force: true }); + } +} + +const ScopeSchema = StringEnum(["user", "project", "both"] as const); + +export default function subagentsExtension(pi: ExtensionAPI) { + let currentSkills: Skill[] = []; + let nextJobId = 1; + let shuttingDown = false; + const jobs = new Map(); + let statusTimer: NodeJS.Timeout | undefined; + + const section: StatusSection = { + id: "subagents", + priority: 100, + visible: () => jobs.size > 0, + getSnapshot: () => ({ + title: `Subagents (${jobs.size})`, + icon: "󰚩", + rows: [...jobs.values()].flatMap((job) => [ + { icon: "●", text: `${job.persona} · ${formatDuration(Date.now() - job.startedAt)}`, tone: "warning" as const }, + { text: `ctx ${formatCount(job.usage.context)} ↑ ${formatCount(job.usage.input)} ↓ ${formatCount(job.usage.output)}`, tone: "dim" as const, indent: 2 }, + ]), + footer: "/subagent-cancel ", + }), + }; + const publish = () => publishStatusSection(pi, section); + pi.events.on(STATUS_REQUEST_EVENT, publish); + + const updateStatus = () => { + if (jobs.size > 0 && !statusTimer) { + statusTimer = setInterval(() => invalidateStatusSection(pi, section.id), 1000); + statusTimer.unref(); + } else if (jobs.size === 0 && statusTimer) { + clearInterval(statusTimer); + statusTimer = undefined; + } + invalidateStatusSection(pi, section.id); + }; + + pi.on("before_agent_start", (event) => { + currentSkills = [...(event.systemPromptOptions.skills ?? [])]; + }); + + pi.on("session_start", () => { + shuttingDown = false; + publish(); + }); + + pi.on("session_shutdown", () => { + shuttingDown = true; + for (const job of jobs.values()) job.controller.abort(); + jobs.clear(); + if (statusTimer) clearInterval(statusTimer); + statusTimer = undefined; + removeStatusSection(pi, section.id); + }); + + pi.registerMessageRenderer("subagent-complete", (message, { expanded, outputPad }, theme) => { + const details = message.details as RunDetails | undefined; + const status = details?.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const heading = `${status} ${theme.fg("accent", theme.bold(details?.persona ?? "subagent"))} ${theme.fg("muted", details ? formatDuration(details.wallTimeMs) : "")}`; + const content = typeof message.content === "string" + ? message.content + : message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n"); + const body = expanded ? content : content.split("\n").slice(0, 10).join("\n"); + return new Text(`${heading}\n${body}`, outputPad, 0); + }); + + pi.registerCommand("subagents", { + description: "List available Markdown subagent personas", + handler: async (args, ctx) => { + const scope = (["user", "project", "both"].includes(args.trim()) ? args.trim() : "user") as PersonaScope; + const personas = discoverPersonas(ctx.cwd, scope); + const text = personas.length + ? personas.map((persona) => `${persona.name} [${persona.source}] — ${persona.description}`).join("\n") + : "No subagent personas found."; + ctx.ui.notify(text, "info"); + }, + }); + + pi.registerCommand("subagent-cancel", { + description: "Cancel a background subagent by job id, or all jobs", + handler: async (args, ctx) => { + const target = args.trim(); + const selected = target === "all" ? [...jobs.values()] : jobs.has(target) ? [jobs.get(target)!] : []; + for (const job of selected) job.controller.abort(); + ctx.ui.notify(selected.length ? `Cancelling ${selected.length} subagent job(s)` : `No matching subagent job: ${target}`, selected.length ? "info" : "warning"); + }, + }); + + pi.registerTool({ + name: "subagent", + label: "Subagent", + description: "Start a Markdown persona in an isolated background pi process. Returns immediately; completion is delivered to the parent context as a follow-up message. Personas are bundled with the package and can be overridden from ~/.pi/agent/agents/*.md.", + promptSnippet: "Start focused work in a non-blocking isolated Markdown-defined subagent", + promptGuidelines: ["Use subagent for focused delegated work. It runs asynchronously; continue useful parent work after dispatch and incorporate the completion message when it arrives."], + parameters: Type.Object({ + list: Type.Optional(Type.Boolean({ description: "List available personas without running one" })), + persona: Type.Optional(Type.String({ description: "Persona name from a Markdown file; required unless list is true" })), + task: Type.Optional(Type.String({ description: "Self-contained task and expected report; required unless list is true" })), + scope: Type.Optional(ScopeSchema), + inheritSkills: Type.Optional(Type.Boolean({ description: "Pass exactly the parent context's loaded skills to the child (default true)" })), + cwd: Type.Optional(Type.String({ description: "Child working directory; defaults to the parent cwd" })), + }), + async execute(_id, params, _signal, _onUpdate, ctx) { + const scope: PersonaScope = params.scope ?? "user"; + if (scope !== "user" && !ctx.isProjectTrusted()) throw new Error("Project-local personas require a trusted project"); + const personas = discoverPersonas(ctx.cwd, scope); + if (params.list) { + const listing = personas.length + ? personas.map((item) => `${item.name} [${item.source}] — ${item.description}`).join("\n") + : "No subagent personas found."; + return { content: [{ type: "text", text: listing }], details: undefined }; + } + if (!params.persona || !params.task) throw new Error("persona and task are required unless list is true"); + const persona = personas.find((candidate) => candidate.name === params.persona); + if (!persona) throw new Error(`Unknown persona '${params.persona}'. Available: ${personas.map((item) => item.name).join(", ") || "none"}`); + + const id = `sa-${nextJobId++}`; + const controller = new AbortController(); + const job: Job = { + id, + persona: persona.name, + task: params.task, + startedAt: Date.now(), + model: persona.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined), + usage: { input: 0, output: 0, context: 0, turns: 0 }, + controller, + }; + jobs.set(id, job); + updateStatus(); + + void runPersona({ + jobId: id, + persona, + task: params.task, + cwd: params.cwd ? path.resolve(ctx.cwd, params.cwd) : ctx.cwd, + parentModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, + thinkingLevel: ctx.thinkingLevel, + skills: [...currentSkills], + inheritSkills: params.inheritSkills ?? true, + signal: controller.signal, + onProgress: (usage) => { + job.usage = usage; + updateStatus(); + }, + }).then((result) => { + jobs.delete(id); + updateStatus(); + if (shuttingDown) return; + pi.sendMessage({ + customType: "subagent-complete", + content: `Background subagent ${persona.name} completed job ${id}. Treat this report as delegated evidence and continue the user's task.\n\n${result.report}`, + display: true, + details: result.details, + }, { deliverAs: "followUp", triggerTurn: true }); + }).catch((error: unknown) => { + jobs.delete(id); + updateStatus(); + if (shuttingDown) return; + const message = error instanceof Error ? error.message : String(error); + pi.sendMessage({ + customType: "subagent-complete", + content: `Background subagent ${persona.name} failed job ${id}: ${message}`, + display: true, + details: { jobId: id, persona: persona.name, exitCode: 1, wallTimeMs: Date.now() - job.startedAt, usage: job.usage }, + }, { deliverAs: "followUp", triggerTurn: true }); + }); + + return { + content: [{ type: "text", text: `Started background subagent ${persona.name} as ${id}. It will report back automatically; continue with other work.` }], + details: { jobId: id, persona: persona.name, startedAt: job.startedAt }, + }; + }, + renderCall(args, theme) { + if (args.list) return new Text(theme.fg("toolTitle", theme.bold("subagent list")), 0, 0); + const rawTask = args.task ?? "..."; + const task = rawTask.length > 80 ? `${rawTask.slice(0, 80)}…` : rawTask; + return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.persona ?? "...")}\n${theme.fg("dim", task)}`, 0, 0); + }, + renderResult(result, _options, theme) { + const text = result.content.find((part) => part.type === "text"); + return new Text(theme.fg("success", text?.type === "text" ? text.text : "Started"), 0, 0); + }, + }); +} diff --git a/extensions/subagents/personas.ts b/extensions/subagents/personas.ts new file mode 100644 index 0000000..7d54202 --- /dev/null +++ b/extensions/subagents/personas.ts @@ -0,0 +1,116 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent"; + +export type PersonaScope = "user" | "project" | "both"; + +export interface Persona { + name: string; + description: string; + prompt: string; + model?: string; + tools?: string[]; + filePath: string; + source: "pi-package" | "pi-user" | "pi-project"; +} + +type PersonaFrontmatter = { + name?: unknown; + description?: unknown; + model?: unknown; + tools?: unknown; +}; + +const TOOL_ALIASES: Record = { + bash: "bash", + read: "read", + write: "write", + edit: "edit", + multiedit: "edit", + grep: "grep", + glob: "find", + find: "find", + ls: "ls", +}; + +function parseTools(value: unknown): string[] | undefined { + if (!Array.isArray(value) && typeof value !== "string") return undefined; + const raw = Array.isArray(value) ? value : value.split(","); + const tools = raw + .filter((item): item is string => typeof item === "string") + .map((item) => TOOL_ALIASES[item.trim().toLowerCase()]) + .filter((item): item is string => Boolean(item)); + return [...new Set(tools)]; +} + +function loadDirectory(dir: string, source: Persona["source"]): Persona[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + + const personas: Persona[] = []; + for (const entry of entries) { + if (!entry.name.endsWith(".md") || (!entry.isFile() && !entry.isSymbolicLink())) continue; + const filePath = path.join(dir, entry.name); + try { + const content = fs.readFileSync(filePath, "utf8"); + const { frontmatter, body } = parseFrontmatter(content); + const fallbackName = path.basename(entry.name, ".md"); + const name = typeof frontmatter.name === "string" && frontmatter.name.trim() + ? frontmatter.name.trim() + : fallbackName; + const description = typeof frontmatter.description === "string" + ? frontmatter.description.trim() + : `Subagent persona loaded from ${entry.name}`; + personas.push({ + name, + description, + prompt: body.trim(), + model: typeof frontmatter.model === "string" && frontmatter.model !== "inherit" + ? frontmatter.model + : undefined, + tools: parseTools(frontmatter.tools), + filePath, + source, + }); + } catch { + // A malformed persona must not prevent other personas from loading. + } + } + return personas; +} + +function nearestDirectory(cwd: string, relativeParts: string[]): string | undefined { + let current = path.resolve(cwd); + while (true) { + const candidate = path.join(current, ...relativeParts); + try { + if (fs.statSync(candidate).isDirectory()) return candidate; + } catch { + // Keep walking. + } + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +export function discoverPersonas(cwd: string, scope: PersonaScope): Persona[] { + const groups: Persona[][] = []; + if (scope !== "project") { + const packageAgentsDir = path.join(__dirname, "agents"); + groups.push(loadDirectory(packageAgentsDir, "pi-package")); + groups.push(loadDirectory(path.join(getAgentDir(), "agents"), "pi-user")); + } + if (scope !== "user") { + const piDir = nearestDirectory(cwd, [CONFIG_DIR_NAME, "agents"]); + if (piDir) groups.push(loadDirectory(piDir, "pi-project")); + } + + const byName = new Map(); + for (const group of groups) for (const persona of group) byName.set(persona.name, persona); + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c7e7dce --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "@danievanzyl/code-skills", + "version": "0.1.42", + "description": "Pi extensions for shared status UI, background subagents, GitHub status, and terminal workflow", + "keywords": [ + "pi-package", + "pi-coding-agent", + "coding-agent", + "subagents", + "terminal-ui" + ], + "license": "MIT", + "author": { + "name": "Danie van Zyl", + "email": "danie.van.zyl@gmail.com", + "url": "https://github.com/danievanzyl" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/danievanzyl/code-skills.git" + }, + "homepage": "https://github.com/danievanzyl/code-skills#readme", + "bugs": { + "url": "https://github.com/danievanzyl/code-skills/issues" + }, + "files": [ + "extensions", + "README.md", + "LICENSE" + ], + "peerDependencies": { + "@earendil-works/pi-agent-core": "*", + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "typebox": "*" + }, + "pi": { + "extensions": [ + "./extensions/status-window/index.ts", + "./extensions/github-status-section.ts", + "./extensions/picture-status-bar.ts", + "./extensions/subagents/index.ts" + ] + }, + "publishConfig": { + "access": "public" + } +}