pi-helm

pi / pi-web cockpit extension: a reactive, policy-gated ticket/worker system built on the DSH-free dsh-superagent Engine, wired to pi-web sessions and a loopback GitHub webhook.

Packages

Package details

extension

Install pi-helm from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-helm
Package
pi-helm
Version
0.4.1
Published
Nov 6, 2026
Downloads
439/mo · 439/wk
Author
jlamweil
License
MIT
Types
extension
Size
360.7 KB
Dependencies
1 dependency · 2 peers
Pi manifest JSON
{
  "extensions": [
    "./extension/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-helm

A pi / pi-web cockpit extension: a reactive, policy-gated ticket / worker system. GitHub events become Triggers; an in-process domain Engine (SQLite-backed) evaluates them under a Policy; accepted Tickets run in one pi session + workspace per ticket; results flow back through a main-session relay; a panel shows "what needs a human".

The domain core is the DSH-free engine lifted from deepseek-ai/dsh-superagent-prototype (MIT). The adapters that wire it to pi-web are original to this package.


How it works

GitHub/GitLab webhook (one loopback listener)
  → auth verify (GitHub HMAC / GitLab X-Gitlab-Token) → dispatch → shape into Trigger → engine.receive
  → Engine.evaluateDue → Assessor (judge) → engine.decide
  → Ticket + Assignment → Executor launches a pi worker session (worker-<ticketId>)
  → WorkerPoller watches live workers; on settle it harvests the final message
     (GET /api/messages) and drives engine.report
  → Outbox delivers acknowledgements / answers to the main session
  → Panel reads the SQLite state (stateless, crash-safe)

Everything is checkpointed in a single SQLite file (WAL), written by exactly one connection. Closing and reopening the repository resumes cleanly; open tickets are reconciled on startup.

Because pi-web assigns each session its own id (a client cannot choose it), the executor keeps a durable map from the logical worker-<ticketId> ref to the real session id it created, and addresses the worker by that real id — re-capturing a fresh one if the session is ever gone.

How you interact with it

pi-helm registers four pi tools you drive in natural language — this is the primary way to use it. Ask your agent "check the helm status" / "run a task on repo X" / "what's the result of ticket Y", and pi calls the right tool. The tools act on the single running cockpit (the same one a GitHub webhook would feed), so console work and webhook work share one durable queue.

  • helm_run { task, repo?, model? } — start a worker task now, from your session, without waiting for a GitHub event. The console trigger is an explicit mandate (you're always an allowed sender), so it is always honored — even under the dry-run judge. Returns a ticket id. Pass model to run that one task on a specific worker model (e.g. "unistra/t3/unistra-coder").
  • helm_status — read-only: open tickets, their worker state, latest reports, and open questions.
  • helm_result { ticket } — read-only: one ticket's current state + latest worker report.
  • helm_end { ticket, reason? } — stop a ticket and its worker.

Results also relay to your main session as follow-up messages (via the outbox), so you see worker answers and "needs a human" questions even if you don't poll the tools. And the panel stays available from the command line as a read-only escape hatch:

  • npm run panel — print the open tickets straight from the SQLite file (safe any time, read-only).

The Panel module also exposes resumeTicket / endTicket (the Engine's retryTicket / stopTicket) as programmatic handles, used by the integration scripts.

Multi-project + the PR is the done-criterion

Configure repos once and point tasks at them; each ticket works in its own isolated git worktree, and the ticket's life is tied to the pull request it opens:

  • COCKPIT_REPOS — a JSON map of the projects a cockpit serves: { "repos": { "acme/api": { "url": "…", "defaultBranch": "main", "source": "github" } } }. Each entry's source is github (default) or gitlab — it keys the branch thread, the worker's PR/MR prompt, and the done-criterion to that platform, so a repo can live on either. A trigger names its repo via meta.repo, and the match is exact on the key:
    • GitHub triggers set meta.repo to the repo's owner/name (full_name) — so key the repo by its full_name (e.g. "acme/api"), not a short alias, or it won't route.
    • GitLab triggers set meta.repo to the project's path_with_namespace (owner/name) — key it the same way.
    • the console helm_run { repo } sets meta.repo to whatever you pass — use the same key. No repo named (or a key that matches nothing) → the task runs in a scratch workspace (backwards-compatible).
  • Per-ticket worktree — for a repo task, pi-helm keeps a cached local clone per repo and creates a git worktree on branch helm-<ticketId>; the worker's session cwd is that worktree, so its changes never touch another ticket. (A prepare script per repo can seed the worktree if you need it.)
  • The PR/MR is the done-criterion — the worker commits + pushes its branch; helm pre-binds the PR/MR follow before the worker reports ready, so a ready worker holds on the open PR/MR (it does not "complete"). The source reports the helm-* branch's PR/MR lifecycle onto that thread: merged ends the ticket merged; closed (unmerged) ends it closed. For a github repo this is the pull request (gh pr create); for a gitlab repo it's the merge request (glab mr create) — the worker is told which. "Done" is an observable git event, not a model opinion.
  • Accept mode (autonomy dial) — Policy carries accept: off | auto | observe (default auto). off refuses inferred (admission-table) work entirely; only an explicit (addressed + listed) trigger still passes. auto/observe admit inferred work on its admission cell. Revalidate marks an inferred-accepted ticket stale if the mode flips off.

Live verification (turnkey): scripts/live-d3.sh boots a real pi-web and drives a repo task whose worker is a real model session; it confirms the worker commits + pushes helm-<ticket>, the ticket holds on the open PR follow, and a signed pull_request closed(merged) delivery ends it merged. Only the GitHub API itself (opening a real PR + the real webhook) is not exercised — that needs a GitHub token

  • a tunnel (scripts/tunnel.sh + scripts/github-webhook.sh).

Modules

Dir Role
core/ Domain engine (Tickets, Evals, Parts, Intake, Ports) — lifted from dsh-superagent, MIT.
app/ launchCockpit factory, the SQLite repositories, the outbox/relay, and the judges (deterministic + model-backed).
executor/ PiExecutor (worker lifecycle), PiSessionBackend (pi-web session API), and the zero-token WorkerPoller.
sources/ GitHub + GitLab webhook shaping + trigger builders + the one-listener dispatcher.
webhook/ Extension-hosted loopback HTTP listener.
policy/ Admission-table policy gate (gate, revalidate, admitsInferred).
panel/ Stateless SQLite read + Markdown render + resume/end.
extension/ The thin pi ExtensionAPI entry (env → launchCockpit → main loop).

Requirements

  • Node ≥ 22.5 (uses the built-in node:sqlite).
  • A running pi-web server (the extension talks to its session API over HTTP).
  • pi (@earendil-works/pi-coding-agent) as the extension host — provided by the host, declared as a peer dependency.

Install

As a pi package (npm / git / local)

The package uses the pi package manifest, so any pi package source works:

pi install npm:pi-helm          # from npm
pi install git:github.com/<you>/pi-helm@v0.1.0
pi install ./pi-helm            # local path

The manifest declares the extension entry:

{
  "pi": { "extensions": ["./extension/index.ts"] }
}

Runtime dependencies are installed with the package: zod (declared in dependencies). Host-provided packages (@earendil-works/pi-coding-agent) are peer dependencies and are not bundled.

Into a live pi-web extension dir

pi-web loads extension files from $PI_WEB_CWD/.pi/web/extensions/ and ~/.pi/web/extensions/ (see server/extensions.ts → resolvePiWebExtensionPaths). Because the entry imports its siblings by relative path, the source dirs must sit next to it. scripts/install-ext.sh symlinks the whole tree and drops a thin wrapper:

PI_WEB_CWD=/path/to/cockpit-workspace bash scripts/install-ext.sh

Quickstart

  1. Start a pi-web server and note its base URL (PI_WEB_MOCK=1 is fine for a no-model local run).

  2. Install pi-helm (pi install … above, or symlink the source into a pi-web extensions dir with scripts/install-ext.sh).

  3. Set the required env and start a pi session that loads the extension:

    export PI_WEB_BASE_URL=http://127.0.0.1:<port>   # required — the running pi-web
    export COCKPIT_SECRET=<any string>                 # required — GitHub webhook HMAC
    export COCKPIT_POLICY='{"allowedSenders":["<your-gh-username>"]}'  # who may direct the agent (see note below)
    # optional (real model judge):
    export COCKPIT_MODEL_URL=... COCKPIT_MODEL_TOKEN=... COCKPIT_MODEL_NAME=...
  4. On session_start, pi-helm launches the loopback GitHub webhook listener, the Engine, the executor + zero-token worker poller, and the outbox relay. Everything is checkpointed in COCKPIT_DB (default .pi-helm/cockpit.db).

  5. Post a GitHub event that mentions the agent from an allowed sender (see "GitHub webhook setup"). It becomes a Ticket, runs in a per-ticket worker session, and reports back. Inspect any time with npm run panel.

How a mention becomes a ticket (the domain rule). The Engine only accepts work for an explicit mandate: a sender listed in COCKPIT_POLICY.allowedSenders directly mentioning @<agent>. Any other sender's mention is inferred and is admitted only if it matches an enabled rule (COCKPIT_POLICY.enabledRules) — the dry-run (no-model) judge emits no rules, so it declines inferred candidates. Hence the quickstart sets allowedSenders; otherwise the demo trigger is inferred and no ticket is created. (With a real model judge, inferred candidates can be accepted when their assessment matches enabled rules.)

A tunnel-free proof of the whole chain (mock pi-web, real engine/executor/poller, no fakes in the reactive path) is npm run integrate:local: webhook → ticket → worker → settle → report → ended.

GitHub webhook setup

  • Point a GitHub repository webhook at the listener URL (port from COCKPIT_PORT, default 7890). A local machine behind NAT needs a public tunnel (cloudflared/ngrok) — see "Notes & limitations".
  • Put the same string in GitHub's webhook Secret field and COCKPIT_SECRET; pi-helm rejects any delivery whose X-Hub-Signature-256 HMAC does not match.
  • Subscribe at least to issues, issue_comment, and pull_request. The @<COCKPIT_AGENT> mention routing plus the Policy gate decide which events actually become Tickets.

GitLab webhook setup

Optional — for repos that live on GitLab instead of GitHub (set source: "gitlab" on the repo in COCKPIT_REPOS). GitLab authenticates webhooks with a static token (not an HMAC), so you need the matching token on both halves:

  • Launch pi-helm with COCKPIT_GITLAB_TOKEN=<token>; the same loopback listener then accepts GitLab deliveries (auth by X-Gitlab-Token). Without it, GitLab deliveries are rejected (opt-in).
  • Point a GitLab project hook at the listener URL using that token: PROJECT=you/repo WEBHOOK_URL=<tunnel-url> GITLAB_TOKEN=<pat> COCKPIT_GITLAB_TOKEN=<token> scripts/gitlab-webhook.sh. It enables the issues, merge_requests, and notes events.
  • The worker needs glab + a GitLab token in its environment (GITLAB_TOKEN / GITLAB_PAT / GITLAB_HOST) so it can open the merge request — scripts/launch-helm.sh forwards those when set.

GitHub and GitLab can drive the same cockpit: the one listener routes by the auth header, and webhook offsets are namespaced per delivery, so the two platforms never collide.

Configuration

All configuration comes from environment variables, read by the extension entry.

Var Meaning Default
PI_WEB_BASE_URL Base URL of the running pi-web server (required). —
PI_WEB_TOKEN Optional bearer token for pi-web. —
COCKPIT_AGENT Agent name GitHub mentions route to. agent
COCKPIT_OPERATOR Identity of the human at the keyboard; console triggers come from them and they're always an allowed sender (so helm_run is always honored). operator
COCKPIT_SECRET GitHub webhook HMAC secret (required). —
COCKPIT_GITLAB_TOKEN GitLab static hook token (X-Gitlab-Token). When set, the same listener also accepts GitLab deliveries (opt-in multi-platform). off
COCKPIT_PORT Webhook port (0 = free port; negative = disable the listener). 7890
COCKPIT_WORKDIR Root dir for per-ticket worker workspaces. .pi-helm/work
COCKPIT_WORKER_MODEL The model each worker runs on by default, provider/id (e.g. unistra/t3/unistra-coder). A per-task model on helm_run overrides this for that ticket. pi-web's default model
COCKPIT_WORKER_MODELS Comma-separated allowlist of worker models. A task asking for a model outside it is refused loudly — a guardrail against running a worker on a tool-broken model. no restriction
COCKPIT_REPOS JSON { "repos": { <alias>: { url, defaultBranch?, source?, prepare? } } }. A task naming an alias (via helm_run { repo } or a webhook event's repository) runs in a git worktree of that repo on branch helm-<ticket>; the ticket then follows its PR/MR (per source, github or gitlab). none (scratch workspaces)
COCKPIT_DB SQLite path. .pi-helm/cockpit.db
COCKPIT_POLICY JSON PolicyConfig. deny-inferred default
COCKPIT_MAIN_SESSION pi session that relays outbox replies. current session
COCKPIT_MODEL_URL OpenAI-compatible chat endpoint; set to use a real model judge. dry-run judge
COCKPIT_MODEL_TOKEN Bearer token for the model endpoint. —
COCKPIT_MODEL_NAME Model id to request. gpt-4o

Without COCKPIT_MODEL_URL, the assessor runs a deterministic dry-run judge (accepts a candidate whose triggers addressed the agent). With it, the assessor runs a model-backed judge that emits a strict VERDICT: accept|ignore verdict; unparseable responses are treated as unavailable so the eval retries rather than guessing.

Worker model

A worker (the agent that does the actual task) is a pi-web session. Which model it runs on is resolved at launch, in this order — the first that applies wins:

  1. Per-task override — a model passed to helm_run { task, model }, or meta.model on the origin trigger. This is how you run one task on a specific model.
  2. COCKPIT_WORKER_MODEL — the default every worker runs on (e.g. unistra/t3/unistra-coder).
  3. pi-web's default — if neither is set, the worker inherits the model configured on the pi-web host itself (no /api/model switch is issued).

When a model is resolved, pi-helm issues POST /api/model on the worker session before sending the launch prompt, so the first token is already produced by the right model. If the requested model is not available on the host, the launch is aborted and the ticket is reported as failed — it does not silently fall back.

Capability caveat. Only some models can actually execute tools (run shell, edit files, call extension tools). On the reference bench host, unistra/t3/unistra-coder is the model verified to do so; some other models (e.g. a reasoning-only model) can mis-route tool calls. Keep the default on a tool-capable model, and use COCKPIT_WORKER_MODELS as an allowlist so a mistyped or mis-routed task fails loudly instead of silently running on a model that can't do the job. The worker model is independent of the judge model (COCKPIT_MODEL_URL) — the judge decides whether to take a task, the worker does the task.

Going live

Run it against a real pi-web, a real GitHub webhook, and (optionally) a real model judge. The full runbook is in docs/go-live.md — architecture diagram, per-step detail, config reference, operations, security, and troubleshooting. The turnkey path is three scripts:

# 1) start an isolated pi-web with the extension loaded (+ set COCKPIT_SECRET, PIWEB, BASE_AGENT)
scripts/launch-helm.sh
# 2) tunnel the loopback webhook port to a public URL
scripts/tunnel.sh
# 3) point a GitHub webhook at it (same secret)
scripts/github-webhook.sh

Then post an issue/comment mentioning the agent and inspect with npm run panel. Start with PI_WEB_MOCK=1 on step 1 for a no-model, no-GitHub pipeline dry-run.

Development

npm test          # vitest run
npm run typecheck # tsc --noEmit
npm run smoke     # core engine smoke (in-memory)
npm run smoke:store   # core engine smoke (SQLite file)
npm run smoke:e2e     # full flow + durability across close/reopen
npm run panel         # read-only: print the open tickets from the SQLite DB (safe any time)

# Local integration (need a running pi-web; PI_WEB_MOCK=1 is fine; set PI_WEB_BASE_URL):
npm run integrate:local      # launchCockpit + real fetch client: webhook -> worker -> settle -> report -> ended
npm run integrate:extension  # load the real extension entry (stub ExtensionAPI) against a running pi-web

Notes & limitations

  • Webhook reachability: the listener binds to loopback for sandbox/ai-jail compatibility. Live GitHub delivery requires a public tunnel (cloudflared/ngrok), because GitHub's webhook service cannot reach a loopback address. A polling source is the tunnel-free alternative; both feed the same engine.receive.
  • Single writer: one SQLite connection backs the Engine aggregates, webhook offsets + singleton guard, and the outbox. The one-listener singleton guard prevents a second instance from double-binding the webhook port.
  • Scope: this is a reactive orchestration layer. It does not add tools to the agent or change pi's model/runtime.

Troubleshooting

  • PI_WEB_BASE_URL unset / unreachable — the extension logs and does not launch; point it at the running pi-web and restart the session.
  • Webhook deliveries rejected (401) — GitHub's webhook Secret and COCKPIT_SECRET differ, or the HMAC check fails; the listener rejects mismatches by design.
  • A Ticket never starts — confirm the worker workspace appears under COCKPIT_WORKDIR and that GET /api/sessions on pi-web lists the worker session; the executor maps worker-<ticketId> to pi-web's server-assigned session id durably.
  • Model judge stays unavailable — the endpoint is unreachable or the model isn't emitting a strict VERDICT: accept|ignore line; the eval retries rather than guessing.

License

MIT. The core/ domain engine is derived from deepseek-ai/dsh-superagent-prototype (© 2026 DeepSeek Harness Team, MIT); see LICENSE for the full terms and attribution.