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.
Package details
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. Passmodelto 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'ssourceisgithub(default) orgitlab— 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 viameta.repo, and the match is exact on the key:- GitHub triggers set
meta.repoto the repo'sowner/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.repoto the project'spath_with_namespace(owner/name) — key it the same way. - the console
helm_run { repo }setsmeta.repoto 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).
- GitHub triggers set
- Per-ticket worktree — for a repo task, pi-helm keeps a cached local clone per repo and creates a
git worktreeon branchhelm-<ticketId>; the worker's session cwd is that worktree, so its changes never touch another ticket. (Apreparescript 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 ticketmerged; closed (unmerged) ends itclosed. For agithubrepo this is the pull request (gh pr create); for agitlabrepo 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) —
Policycarriesaccept: off | auto | observe(defaultauto).offrefuses inferred (admission-table) work entirely; only an explicit (addressed + listed) trigger still passes.auto/observeadmit 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
Start a pi-web server and note its base URL (
PI_WEB_MOCK=1is fine for a no-model local run).Install pi-helm (
pi install …above, or symlink the source into a pi-web extensions dir withscripts/install-ext.sh).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=...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 inCOCKPIT_DB(default.pi-helm/cockpit.db).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 withnpm 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.allowedSendersdirectly 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 setsallowedSenders; 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, default7890). 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 whoseX-Hub-Signature-256HMAC does not match. - Subscribe at least to
issues,issue_comment, andpull_request. The@<COCKPIT_AGENT>mention routing plus thePolicygate decide which events actually becomeTickets.
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 byX-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 theissues,merge_requests, andnotesevents. - 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.shforwards 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:
- Per-task override — a
modelpassed tohelm_run { task, model }, ormeta.modelon the origin trigger. This is how you run one task on a specific model. COCKPIT_WORKER_MODEL— the default every worker runs on (e.g.unistra/t3/unistra-coder).- pi-web's default — if neither is set, the worker inherits the model configured on the pi-web
host itself (no
/api/modelswitch 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-coderis 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 useCOCKPIT_WORKER_MODELSas 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_URLunset / 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_SECRETdiffer, or the HMAC check fails; the listener rejects mismatches by design. - A Ticket never starts — confirm the worker workspace appears under
COCKPIT_WORKDIRand thatGET /api/sessionson pi-web lists the worker session; the executor mapsworker-<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 strictVERDICT: accept|ignoreline; 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.