@edgehero/pi-dispatch-admin 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.mjs +36 -10
- package/package.json +1 -1
- package/skills/operate-pi-dispatch/SKILL.md +29 -4
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# pi-dispatch: run the pi coding agent as a self-hosted service
|
|
6
6
|
|
|
7
|
-
**Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine.** [pi](https://github.com/earendil-works/pi) is a superb coding agent, but it has no job queue, no spend limit, and, by its own README, no permission system. pi-dispatch adds exactly those, so you can run pi unattended and safely: on a cron schedule, on your repo's issues and PRs, or straight from the CLI. Every job runs in an isolated container, spend is bounded before a single token is spent, and everything the agent did (and what it cost) is recorded, graphed and priced for you.
|
|
7
|
+
**Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine.** [pi](https://github.com/earendil-works/pi) is a superb coding agent, but it has no job queue, no spend limit, and, by its own README, no permission system. pi-dispatch adds exactly those, so you can run pi unattended and safely: on a cron schedule, on your repo's issues and PRs, or straight from the CLI. Every job runs in an isolated container, spend is bounded before a single token is spent, and everything the agent did (and what it cost) is recorded, graphed and priced for you. On a forge you stay in the loop at both ends: a job starts only when someone with write access to the repo asks for one, by a label, an `@pi` comment or a review, and what comes back is a pull request you review and land, because pi-dispatch never merges anything. This is not another agent to weigh against the one you already use; it is the queue, the budget and the box for the pi you already run.
|
|
8
8
|
|
|
9
9
|
> This npm package, **`@edgehero/pi-dispatch-admin`**, is the **operator console** (a pi extension). The service itself is `@edgehero/pi-dispatch` (worker + CLI) and `@edgehero/pi-dispatch-receiver` (the webhook edge); the [main repo](https://github.com/edgehero/pi-dispatch) has the container image, docs, and SECURITY.md.
|
|
10
10
|
|
package/dist/index.mjs
CHANGED
|
@@ -7598,6 +7598,12 @@ var init_build = __esm({
|
|
|
7598
7598
|
}
|
|
7599
7599
|
});
|
|
7600
7600
|
|
|
7601
|
+
// ../worker/src/egress.mjs
|
|
7602
|
+
var init_egress = __esm({
|
|
7603
|
+
"../worker/src/egress.mjs"() {
|
|
7604
|
+
}
|
|
7605
|
+
});
|
|
7606
|
+
|
|
7601
7607
|
// ../worker/src/forges.mjs
|
|
7602
7608
|
function isForgeKind(kind) {
|
|
7603
7609
|
return typeof kind === "string" && Object.hasOwn(FORGES, kind);
|
|
@@ -7698,6 +7704,7 @@ function defaultSettingsFile() {
|
|
|
7698
7704
|
var CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT;
|
|
7699
7705
|
var init_config2 = __esm({
|
|
7700
7706
|
"../worker/src/config.mjs"() {
|
|
7707
|
+
init_egress();
|
|
7701
7708
|
init_forges();
|
|
7702
7709
|
CHAIN_DEPTH_MAX_DEFAULT = 1;
|
|
7703
7710
|
CHAIN_MAX_PER_JOB_DEFAULT = 2;
|
|
@@ -8071,9 +8078,6 @@ function validateReplicas(run, at, path) {
|
|
|
8071
8078
|
if (run.kind === "local") {
|
|
8072
8079
|
throw configError(`${at}: run.replicas is not available on a cron trigger -- a local job's /workspace IS the operator's folder, bind-mounted read-write, so two replicas would edit one working tree with no gate and no undo: ${path}`);
|
|
8073
8080
|
}
|
|
8074
|
-
if (run.kind !== "github") {
|
|
8075
|
-
throw configError(`${at}: run.replicas is not yet covered for ${run.kind} triggers (github only in this version); every forge mints its branch the same way, so this is a gap to close, not a limit: ${path}`);
|
|
8076
|
-
}
|
|
8077
8081
|
if (!Number.isInteger(replicas) || replicas < 2 || replicas > REPLICAS_MAX) {
|
|
8078
8082
|
throw configError(`${at}: run.replicas must be an integer between 2 and ${REPLICAS_MAX} when present -- ${REPLICAS_MAX} is the ceiling because PI_CONCURRENCY defaults to 3, so a further replica would queue instead of racing, and 1 is refused because a one-member replica set is a flag that does nothing (got ${JSON.stringify(replicas)}): ${path}`);
|
|
8079
8083
|
}
|
|
@@ -8700,12 +8704,15 @@ function buildDockerRunArgs({
|
|
|
8700
8704
|
name,
|
|
8701
8705
|
memory = "4g",
|
|
8702
8706
|
cpus = "2",
|
|
8707
|
+
network = null,
|
|
8703
8708
|
extraFlags = []
|
|
8704
8709
|
}) {
|
|
8705
8710
|
if (!image) throw new Error("docker run: image is required");
|
|
8706
8711
|
if (!name) throw new Error("docker run: container name is required");
|
|
8707
8712
|
if (!workspace) throw new Error("docker run: workspace mount is required");
|
|
8708
|
-
const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}
|
|
8713
|
+
const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`];
|
|
8714
|
+
if (network) args.push(`--network=${network}`);
|
|
8715
|
+
args.push(...extraFlags);
|
|
8709
8716
|
for (const [k, v] of Object.entries(env ?? {})) {
|
|
8710
8717
|
if (v === void 0 || v === null) continue;
|
|
8711
8718
|
args.push("-e", `${k}=${v}`);
|
|
@@ -38768,7 +38775,7 @@ import { promisify as promisify4 } from "node:util";
|
|
|
38768
38775
|
function sandboxContainerName(jobId) {
|
|
38769
38776
|
return `${SANDBOX_NAME_PREFIX}${sanitizeJobId(jobId)}`;
|
|
38770
38777
|
}
|
|
38771
|
-
function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0 }) {
|
|
38778
|
+
function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0, network = null, egressEnv: proxyEnv = {} }) {
|
|
38772
38779
|
return buildDockerRunArgs({
|
|
38773
38780
|
image,
|
|
38774
38781
|
name,
|
|
@@ -38777,9 +38784,20 @@ function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], ter
|
|
|
38777
38784
|
// The ONLY two variables, and neither is a credential. TERM so the shell renders; TMOUT so a
|
|
38778
38785
|
// forgotten session closes itself. `buildDockerRunArgs` skips undefined, so an unset TERM or a
|
|
38779
38786
|
// disabled idle timeout emits nothing rather than an empty string.
|
|
38787
|
+
// A sandbox joins the SAME kind of network a job did, by the same builder, so the boundary cannot
|
|
38788
|
+
// land on job containers and miss this one. Leaving sandboxes on the default bridge was the tempting
|
|
38789
|
+
// alternative and it is the wrong one: it reads as a convenience (install a missing dependency while
|
|
38790
|
+
// debugging) and it is a WIDER reach than the run the sandbox exists to reproduce. A shell that can
|
|
38791
|
+
// go where the run could not is not reproducing the run. Nothing an operator wants is lost, because
|
|
38792
|
+
// the forge and the registry are on the allowlist a job needed anyway.
|
|
38793
|
+
network,
|
|
38780
38794
|
env: {
|
|
38781
38795
|
TERM: term || void 0,
|
|
38782
|
-
TMOUT: idleSeconds > 0 ? String(idleSeconds) : void 0
|
|
38796
|
+
TMOUT: idleSeconds > 0 ? String(idleSeconds) : void 0,
|
|
38797
|
+
// Still NO CREDENTIALS, and that clause is untouched: a proxy URL is not a credential, and
|
|
38798
|
+
// buildContainerEnv is still not reused here. The env is two variables about the terminal and,
|
|
38799
|
+
// when a policy is armed, three about the network.
|
|
38800
|
+
...proxyEnv
|
|
38783
38801
|
},
|
|
38784
38802
|
// Ahead of the env and the mounts, and well ahead of the image, which buildDockerRunArgs keeps as
|
|
38785
38803
|
// the final positional. `--entrypoint` also clears the image's CMD; this repo's Dockerfile sets
|
|
@@ -40231,7 +40249,11 @@ function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
|
|
|
40231
40249
|
out.push(kv("chain", chainBits.join(" \xB7 ")));
|
|
40232
40250
|
if (r.replica > 0) {
|
|
40233
40251
|
const sibs = (Array.isArray(allRuns) ? allRuns : []).filter(
|
|
40234
|
-
|
|
40252
|
+
// `kind` is load-bearing since #187, not decoration: `target` is repo + separator + number, and
|
|
40253
|
+
// targetFor renders github and forgejo with the SAME `#`, so a github `o/r#5` and a forgejo `o/r#5`
|
|
40254
|
+
// are one string. While only github could replicate, no two replica runs could collide; now they can,
|
|
40255
|
+
// and naming a stranger's job as your sibling is worse than naming none.
|
|
40256
|
+
(x) => x?.replica > 0 && x.replica !== r.replica && x.kind === r.kind && x.target === r.target && x.flow === r.flow
|
|
40235
40257
|
);
|
|
40236
40258
|
const repBits = [`r${r.replica}/${r.replicas ?? "?"}`];
|
|
40237
40259
|
repBits.push(sibs.length > 0 ? `sibling ${sibs.map((s) => `r${s.replica} ${s.jobId}`).join(", ")}` : "no sibling in this window");
|
|
@@ -40845,8 +40867,8 @@ var init_setup_wizard = __esm({
|
|
|
40845
40867
|
init_deployment_pointer();
|
|
40846
40868
|
init_read_model();
|
|
40847
40869
|
init_index();
|
|
40848
|
-
RUNTIME_VERSION = "1.
|
|
40849
|
-
RECEIVER_VERSION = "1.
|
|
40870
|
+
RUNTIME_VERSION = "1.1.0";
|
|
40871
|
+
RECEIVER_VERSION = "1.1.0";
|
|
40850
40872
|
RUNTIME_PKG = "@edgehero/pi-dispatch";
|
|
40851
40873
|
RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
|
|
40852
40874
|
NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
|
|
@@ -41051,12 +41073,16 @@ function registerTools(pi) {
|
|
|
41051
41073
|
pi.registerTool({
|
|
41052
41074
|
name: "dispatch_trigger_add",
|
|
41053
41075
|
label: "pi-dispatch add trigger",
|
|
41054
|
-
description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
|
|
41076
|
+
description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved, forgejo is label_updated|opened|synchronized|reopened, azure is created|updated. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
|
|
41055
41077
|
executionMode: "sequential",
|
|
41056
41078
|
parameters: typebox_exports.Object({
|
|
41057
41079
|
kind: typebox_exports.String(),
|
|
41058
41080
|
flow: typebox_exports.String(),
|
|
41059
41081
|
forge: typebox_exports.Optional(typebox_exports.String()),
|
|
41082
|
+
// buildTriggerEntry has always READ `f.repository`, and the schema has always stripped it -- so an
|
|
41083
|
+
// azure label/comment trigger was unauthorable by the model, refused at the write for want of a field
|
|
41084
|
+
// it had no way to send. Surfaced by #187 widening what the description above must say about forges.
|
|
41085
|
+
repository: typebox_exports.Optional(typebox_exports.String()),
|
|
41060
41086
|
id: typebox_exports.Optional(typebox_exports.String()),
|
|
41061
41087
|
pattern: typebox_exports.Optional(typebox_exports.String()),
|
|
41062
41088
|
folder: typebox_exports.Optional(typebox_exports.String()),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-admin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Operator console (a pi extension) + skill for pi-dispatch: run the pi coding agent as a self-hosted service. A /dispatch TUI for the queue, spend caps, run history, editable GitHub, GitLab, Forgejo and Azure DevOps triggers (cron/label/comment/PR/MR/work item), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -122,14 +122,16 @@ job time; and `pi-dispatch doctor` lists every image their triggers name and fla
|
|
|
122
122
|
|
|
123
123
|
## The forge a trigger listens to — `run.kind`
|
|
124
124
|
|
|
125
|
-
A webhook trigger names its forge: `"kind"
|
|
126
|
-
trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `flow`, `packages`,
|
|
125
|
+
A webhook trigger names its forge: `"kind"` is `github`, `gitlab`, `forgejo` or `azure`. Everything else
|
|
126
|
+
about the trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `flow`, `packages`,
|
|
127
|
+
`image`, `replicas`.
|
|
127
128
|
|
|
128
|
-
|
|
129
|
+
Three things are NOT the same, and all of them refuse at load rather than misbehaving quietly:
|
|
129
130
|
|
|
130
131
|
- **`pull_request` actions are the forge's own words.** GitHub takes
|
|
131
132
|
`labeled | opened | synchronize | reopened | review_submitted`; GitLab takes
|
|
132
|
-
`open | update | reopen | approved
|
|
133
|
+
`open | update | reopen | approved`; Forgejo takes
|
|
134
|
+
`label_updated | opened | synchronized | reopened`; Azure takes `created | updated`. A word
|
|
133
135
|
from the wrong forge is refused when the file is written. It would not break anything otherwise — it
|
|
134
136
|
would simply never match an event, and the trigger would look configured while doing nothing.
|
|
135
137
|
`review_submitted` is GitHub's `pull_request_review` event: it fires on **every** submitted review, so
|
|
@@ -138,6 +140,29 @@ Two things are NOT the same, and both refuse at load rather than misbehaving qui
|
|
|
138
140
|
load, because a narrowing that cannot apply reads as one that does.
|
|
139
141
|
- **One `comment` trigger per forge.** Two GitHub comment triggers are refused; one GitHub and one GitLab
|
|
140
142
|
are fine.
|
|
143
|
+
- **Azure names no repository.** A work item belongs to a project, so an azure `label` or `comment` trigger
|
|
144
|
+
MUST set `run.repository` (the repo within the project to clone) and every other forge's must not. An
|
|
145
|
+
azure `pull_request` trigger may not carry a label predicate at all: Azure tags work items, never pull
|
|
146
|
+
requests, so `any`/`all`/`none` could never match and a rule that loads clean and never fires reads as a
|
|
147
|
+
broken harness.
|
|
148
|
+
|
|
149
|
+
## Racing two agents on one event — `run.replicas`
|
|
150
|
+
|
|
151
|
+
`"replicas": 2` on a `label`, `comment` or `pull_request` trigger turns one delivery into **two independent
|
|
152
|
+
paid jobs**: two containers, two branches (`pi/issue-7-r1` and `-r2`), two review requests, one human
|
|
153
|
+
picking. It works on every forge. Absent, nothing changes.
|
|
154
|
+
|
|
155
|
+
Three things to say when an operator asks about it:
|
|
156
|
+
|
|
157
|
+
- **It multiplies spend, and the caps are the only ceiling.** Each replica reserves its own budget slot
|
|
158
|
+
before its own tokens, so a `replicas: 2` trigger firing ten times a day consumes twenty slots of the
|
|
159
|
+
daily cap, not ten. Nothing is discounted; that is the feature, not an oversight.
|
|
160
|
+
- **You cannot set it from here.** There is no `replicas` parameter on `dispatch_trigger_add` or
|
|
161
|
+
`_edit`, and no panel key. It is a reviewed edit to `triggers.json`, deliberately: a spend multiplier is
|
|
162
|
+
plainly a capability a model should not gain. Say so rather than looking for a way around it.
|
|
163
|
+
- **It refuses on cron, and beside `resume`.** A local job's `/workspace` IS the operator's folder, so two
|
|
164
|
+
replicas would edit one working tree with no gate and no undo. And a resumed run continues one lineage
|
|
165
|
+
where replicas exist to fork it.
|
|
141
166
|
|
|
142
167
|
`dispatch_trigger_add` takes an optional `forge` parameter, defaulting to `github`. Unlike `image`, this
|
|
143
168
|
one IS offered to the model — a model that can already add a GitHub trigger can already arm a paid run,
|