@edgehero/pi-dispatch-admin 0.1.1 → 0.1.3
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 +50 -21
- package/dist/index.mjs +88 -1
- package/package.json +10 -3
- package/skills/operate-pi-dispatch/SKILL.md +4 -3
package/README.md
CHANGED
|
@@ -2,36 +2,65 @@
|
|
|
2
2
|
<img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/banner.png" alt="pi-dispatch — run the pi coding agent as a self-hosted service" width="880">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
-
#
|
|
5
|
+
# pi-dispatch — run the pi coding agent as a self-hosted service
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
[pi](https://github.com/earendil-works/pi) extension that adds a `/dispatch` command (a live TUI overlay +
|
|
9
|
-
model-callable tools) for running the pi coding agent as a self-hosted service.
|
|
7
|
+
**[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 is the operational layer that adds exactly those, so you can run pi **unattended and safely**: on a cron schedule, on your repo's GitHub issues and PRs, or straight from the CLI — every job in an isolated container, with spend bounded *before* a single token is spent.
|
|
10
8
|
|
|
11
|
-
> This is
|
|
12
|
-
> run-history — install it in your own pi, alongside the pi-dispatch service. On its own it will show
|
|
13
|
-
> "queue unreachable".
|
|
9
|
+
> This npm package — **`@edgehero/pi-dispatch-admin`** — is the **operator console** (a pi extension). The queue, worker, container image, and GitHub receiver that make up the service live in the [main repo](https://github.com/edgehero/pi-dispatch); install this to **drive** a deployment, and get the whole thing there.
|
|
14
10
|
|
|
15
|
-
##
|
|
11
|
+
## How it works
|
|
16
12
|
|
|
17
|
-
|
|
18
|
-
history with per-job token & cost, and a unified **triggers** pane (cron / label / comment / pull_request).
|
|
19
|
-
- **Editable** triggers and **scheduled pause windows** ("quiet hours" per folder/repo), applied live.
|
|
20
|
-
- **Confirm-gated** model tools so an agent can operate the deployment (change limits, manage triggers/pauses)
|
|
21
|
-
with **a human approving each change** — fail-closed when no operator is present.
|
|
22
|
-
- The bundled `operate-pi-dispatch` **skill** documenting those human-in-the-loop gates.
|
|
13
|
+
Every trigger produces the same job, through the same path — one queue, one container, one budget:
|
|
23
14
|
|
|
24
|
-
|
|
15
|
+
```
|
|
16
|
+
CLI · cron · GitHub issue / PR
|
|
17
|
+
│ enqueue
|
|
18
|
+
▼
|
|
19
|
+
Valkey + BullMQ durable queue — survives reboots, absorbs bursts
|
|
20
|
+
│
|
|
21
|
+
▼
|
|
22
|
+
under the daily cap + ── if not: refused here, before any spend
|
|
23
|
+
per-job turn budget?
|
|
24
|
+
│ yes
|
|
25
|
+
▼
|
|
26
|
+
docker run --rm one ephemeral container per job:
|
|
27
|
+
--cap-drop=ALL · non-root --no-new-privileges · /job mounted read-only
|
|
28
|
+
│
|
|
29
|
+
▼
|
|
30
|
+
pi + your .pi/skills edits your code in place, opens a PR, comments back
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- **The container is the boundary.** pi ships no permission system, so every job runs `--cap-drop=ALL`, non-root, ephemeral, with its instructions mounted read-only. That *is* the missing permission system — enforced by Docker, not hoped for.
|
|
34
|
+
- **Spend is bounded before tokens.** A per-job turn budget plus daily / weekly / monthly caps, checked *before* a container starts. A runaway or a junk trigger costs you a refusal, not a surprise bill.
|
|
35
|
+
- **Three triggers, one job.** A CLI command, a cron schedule, or a GitHub issue/PR — same queue, same box, same budget. Cron is the unattended one: recurring work on your own hardware, in an image you control.
|
|
36
|
+
- **Your image, your tools.** Bake a project's toolchain into the Dockerfile; it ships **Playwright + Chromium**, so a flow can build a frontend, screenshot it, and iterate on the rendered result.
|
|
37
|
+
|
|
38
|
+
## The console — `/dispatch`
|
|
39
|
+
|
|
40
|
+
This extension puts a live TUI over the whole deployment in one command:
|
|
41
|
+
|
|
42
|
+
<p align="center">
|
|
43
|
+
<img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png" alt="The /dispatch panel: status, spend meters, triggers, runs, and settings" width="820">
|
|
44
|
+
</p>
|
|
45
|
+
|
|
46
|
+
- **Status & spend.** Queue and worker state, day/week/month **spend meters** + a daily token counter, and a run-history table with per-job token & cost.
|
|
47
|
+
- **Triggers, editable live.** cron / label / comment / pull_request, with colored drill-ins showing *what fires* each one, *what it runs*, and its *trust model* — added, edited, and deleted without a restart.
|
|
48
|
+
- **Quiet hours.** Scheduled pause windows per folder or repo: defer a scope's runs **between certain times** — recurring daily, weekday- and date-bounded, timezone-aware — and resume automatically. Deferred, never dropped, at zero budget cost.
|
|
49
|
+
- **AI-operable, with a human gate.** Model-callable tools let an agent change limits and manage triggers and pause windows — but **every write pops an operator confirmation the model can't answer**, and is refused when no operator is present. The bundled `operate-pi-dispatch` **skill** teaches the agent to use those gates.
|
|
50
|
+
- **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
|
|
51
|
+
|
|
52
|
+
## Install the console
|
|
25
53
|
|
|
26
54
|
```bash
|
|
27
55
|
pi install npm:@edgehero/pi-dispatch-admin
|
|
28
56
|
```
|
|
29
57
|
|
|
30
|
-
Then run pi and
|
|
31
|
-
|
|
58
|
+
Then run pi and open `/dispatch`. Point it at your deployment with `VALKEY_URL` / `PI_LOGS_DIR` / `PI_SETTINGS_FILE` / `PI_TRIGGERS_FILE` / `PI_PAUSE_WINDOWS_FILE` — the same values your worker uses. It reads that deployment's queue and run history; on its own it will just say "queue unreachable."
|
|
59
|
+
|
|
60
|
+
## Get the whole thing
|
|
61
|
+
|
|
62
|
+
The queue, worker, container image, and GitHub webhook receiver — the actual service — are in the main repo. **Start there:**
|
|
32
63
|
|
|
33
|
-
|
|
64
|
+
### → **https://github.com/edgehero/pi-dispatch**
|
|
34
65
|
|
|
35
|
-
|
|
36
|
-
**https://github.com/edgehero/pi-dispatch** — start there. MIT. Read
|
|
37
|
-
[`SECURITY.md`](https://github.com/edgehero/pi-dispatch/blob/main/SECURITY.md) before you rely on it.
|
|
66
|
+
MIT, self-hosted. Read [`SECURITY.md`](https://github.com/edgehero/pi-dispatch/blob/main/SECURITY.md) before you rely on it — it states plainly what is and isn't defended (short version: the trust model is a GitHub Action's — whoever can merge to your default branch can instruct the agent).
|
package/dist/index.mjs
CHANGED
|
@@ -6591,6 +6591,51 @@ ${JSON.stringify(w)}` },
|
|
|
6591
6591
|
return toolText(JSON.stringify(result));
|
|
6592
6592
|
}
|
|
6593
6593
|
});
|
|
6594
|
+
pi.registerTool({
|
|
6595
|
+
name: "dispatch_pause_edit",
|
|
6596
|
+
label: "pi-dispatch edit pause window",
|
|
6597
|
+
description: "Changes fields of an existing pause window (by array index from dispatch_pauses) and applies it live. Provide only the fields to change (scope/from/to/tz/days/dateFrom/dateTo); the rest keep their current value. The operator MUST approve a confirm dialog showing the before->after; refused with no interactive operator.",
|
|
6598
|
+
executionMode: "sequential",
|
|
6599
|
+
parameters: typebox_exports.Object({
|
|
6600
|
+
index: typebox_exports.Integer({ minimum: 0 }),
|
|
6601
|
+
scope: typebox_exports.Optional(typebox_exports.String()),
|
|
6602
|
+
from: typebox_exports.Optional(typebox_exports.String()),
|
|
6603
|
+
to: typebox_exports.Optional(typebox_exports.String()),
|
|
6604
|
+
tz: typebox_exports.Optional(typebox_exports.String()),
|
|
6605
|
+
days: typebox_exports.Optional(typebox_exports.Array(typebox_exports.String())),
|
|
6606
|
+
dateFrom: typebox_exports.Optional(typebox_exports.String()),
|
|
6607
|
+
dateTo: typebox_exports.Optional(typebox_exports.String())
|
|
6608
|
+
}),
|
|
6609
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
6610
|
+
const paths = resolvePaths(process.env);
|
|
6611
|
+
const p = readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath });
|
|
6612
|
+
const list = Array.isArray(p?.windows) ? p.windows : [];
|
|
6613
|
+
const cur = list[params.index];
|
|
6614
|
+
if (!cur) throw new Error(`no pause window at index ${params.index} (have ${list.length})`);
|
|
6615
|
+
const before = buildPauseWindow(cur);
|
|
6616
|
+
const merged = buildPauseWindow({
|
|
6617
|
+
scope: params.scope ?? cur.scope,
|
|
6618
|
+
from: params.from ?? cur.from,
|
|
6619
|
+
to: params.to ?? cur.to,
|
|
6620
|
+
tz: params.tz ?? cur.tz,
|
|
6621
|
+
days: params.days ?? cur.days,
|
|
6622
|
+
dateFrom: params.dateFrom ?? cur.dateFrom,
|
|
6623
|
+
dateTo: params.dateTo ?? cur.dateTo
|
|
6624
|
+
});
|
|
6625
|
+
const result = await confirmedWrite(
|
|
6626
|
+
ctx,
|
|
6627
|
+
{ title: `Edit pause window #${params.index + 1}`, message: `pause window #${params.index + 1}:
|
|
6628
|
+
${JSON.stringify(before)}
|
|
6629
|
+
\u2192 ${JSON.stringify(merged)}` },
|
|
6630
|
+
() => {
|
|
6631
|
+
const res = writePauseWindows({ pauseWindowsPath: paths.pauseWindowsPath, mutate: (l) => l.map((w, i) => i === params.index ? merged : w) });
|
|
6632
|
+
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
6633
|
+
return { applied: true, index: params.index, window: merged };
|
|
6634
|
+
}
|
|
6635
|
+
);
|
|
6636
|
+
return toolText(JSON.stringify(result));
|
|
6637
|
+
}
|
|
6638
|
+
});
|
|
6594
6639
|
pi.registerTool({
|
|
6595
6640
|
name: "dispatch_trigger_delete",
|
|
6596
6641
|
label: "pi-dispatch delete trigger",
|
|
@@ -6814,9 +6859,10 @@ async function handleDashboardAction(result, paths, ctx) {
|
|
|
6814
6859
|
}
|
|
6815
6860
|
}
|
|
6816
6861
|
async function managePausesViaDialogs(paths, ui, notify) {
|
|
6817
|
-
const action = await ui.select("Pause windows", ["Add a pause window", "Delete a pause window"]);
|
|
6862
|
+
const action = await ui.select("Pause windows", ["Add a pause window", "Edit a pause window", "Delete a pause window"]);
|
|
6818
6863
|
if (!action) return;
|
|
6819
6864
|
if (action.startsWith("Add")) return addPauseWindowViaDialogs(paths, ui, notify);
|
|
6865
|
+
if (action.startsWith("Edit")) return editPauseWindowViaDialogs(paths, ui, notify);
|
|
6820
6866
|
return deletePauseWindowViaDialogs(paths, ui, notify);
|
|
6821
6867
|
}
|
|
6822
6868
|
async function addPauseWindowViaDialogs(paths, ui, notify) {
|
|
@@ -6838,6 +6884,47 @@ async function addPauseWindowViaDialogs(paths, ui, notify) {
|
|
|
6838
6884
|
const res = writePauseWindows({ pauseWindowsPath: paths.pauseWindowsPath, mutate: (list) => [...list, w] });
|
|
6839
6885
|
notify?.(res.ok ? `pause window added (live) \u2014 ${w.scope} ${w.from}-${w.to}` : `add rejected: ${res.invalid}`, res.ok ? "info" : "error");
|
|
6840
6886
|
}
|
|
6887
|
+
async function editPauseWindowViaDialogs(paths, ui, notify) {
|
|
6888
|
+
const p = readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath });
|
|
6889
|
+
const list = Array.isArray(p?.windows) ? p.windows : [];
|
|
6890
|
+
if (list.length === 0) {
|
|
6891
|
+
notify?.("no pause windows to edit", "info");
|
|
6892
|
+
return;
|
|
6893
|
+
}
|
|
6894
|
+
const labels = list.map((w, i) => `#${i + 1} ${w.scope} ${w.from}-${w.to} ${w.tz}${w.days ? ` [${w.days.join(",")}]` : ""}`);
|
|
6895
|
+
const picked = await ui.select("Edit which pause window", labels);
|
|
6896
|
+
if (!picked) return;
|
|
6897
|
+
const index = labels.indexOf(picked);
|
|
6898
|
+
if (index < 0) return;
|
|
6899
|
+
const cur = list[index];
|
|
6900
|
+
const ask = async (label, current) => ui.input(`${label} \u2014 blank keeps "${current}"`, current);
|
|
6901
|
+
const keep = (v, current) => v === void 0 || v.trim() === "" ? current : v;
|
|
6902
|
+
const scope = await ask("scope", cur.scope ?? "");
|
|
6903
|
+
if (scope === void 0) return;
|
|
6904
|
+
const from = await ask("from (HH:MM)", cur.from ?? "");
|
|
6905
|
+
if (from === void 0) return;
|
|
6906
|
+
const to = await ask("to (HH:MM)", cur.to ?? "");
|
|
6907
|
+
if (to === void 0) return;
|
|
6908
|
+
const tz = await ask("tz (IANA / UTC)", cur.tz ?? "UTC");
|
|
6909
|
+
if (tz === void 0) return;
|
|
6910
|
+
const days = await ask("days (space-separated)", (cur.days ?? []).join(" "));
|
|
6911
|
+
if (days === void 0) return;
|
|
6912
|
+
const dateFrom = await ask("dateFrom (YYYY-MM-DD)", cur.dateFrom ?? "");
|
|
6913
|
+
if (dateFrom === void 0) return;
|
|
6914
|
+
const dateTo = await ask("dateTo (YYYY-MM-DD)", cur.dateTo ?? "");
|
|
6915
|
+
if (dateTo === void 0) return;
|
|
6916
|
+
const merged = buildPauseWindow({
|
|
6917
|
+
scope: keep(scope, cur.scope),
|
|
6918
|
+
from: keep(from, cur.from),
|
|
6919
|
+
to: keep(to, cur.to),
|
|
6920
|
+
tz: keep(tz, cur.tz),
|
|
6921
|
+
days: keep(days, cur.days),
|
|
6922
|
+
dateFrom: keep(dateFrom, cur.dateFrom),
|
|
6923
|
+
dateTo: keep(dateTo, cur.dateTo)
|
|
6924
|
+
});
|
|
6925
|
+
const res = writePauseWindows({ pauseWindowsPath: paths.pauseWindowsPath, mutate: (l) => l.map((w, i) => i === index ? merged : w) });
|
|
6926
|
+
notify?.(res.ok ? `pause window #${index + 1} updated (live) \u2014 ${merged.scope} ${merged.from}-${merged.to}` : `edit rejected: ${res.invalid}`, res.ok ? "info" : "error");
|
|
6927
|
+
}
|
|
6841
6928
|
async function deletePauseWindowViaDialogs(paths, ui, notify) {
|
|
6842
6929
|
const p = readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath });
|
|
6843
6930
|
const list = Array.isArray(p?.windows) ? p.windows : [];
|
package/package.json
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-admin",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Operator console (a pi extension) + skill for
|
|
3
|
+
"version": "0.1.3",
|
|
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 triggers (cron/label/comment/PR), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
7
8
|
"pi",
|
|
8
9
|
"pi-coding-agent",
|
|
9
10
|
"coding-agent",
|
|
10
|
-
"
|
|
11
|
+
"ai-agent",
|
|
12
|
+
"autonomous-agents",
|
|
13
|
+
"self-hosted",
|
|
14
|
+
"job-queue",
|
|
15
|
+
"cron",
|
|
16
|
+
"tui",
|
|
17
|
+
"dashboard"
|
|
11
18
|
],
|
|
12
19
|
"license": "MIT",
|
|
13
20
|
"author": "Rob Boerman",
|
|
@@ -30,9 +30,10 @@ dialog before it takes effect**:
|
|
|
30
30
|
- `dispatch_set` — change a limit/setting (e.g. `dailyCap`, `weeklyCap`, `maxTurns`, `model`). Omit `value`
|
|
31
31
|
to unset.
|
|
32
32
|
- `dispatch_trigger_add` / `dispatch_trigger_edit` / `dispatch_trigger_delete` — manage triggers.
|
|
33
|
-
- `dispatch_pause_add` / `dispatch_pause_delete` — manage scheduled pause windows
|
|
34
|
-
hours": runs for a scope are deferred between certain times and auto-resume after;
|
|
35
|
-
them).
|
|
33
|
+
- `dispatch_pause_add` / `dispatch_pause_edit` / `dispatch_pause_delete` — manage scheduled pause windows
|
|
34
|
+
(per folder/repo "quiet hours": runs for a scope are deferred between certain times and auto-resume after;
|
|
35
|
+
`dispatch_pauses` lists them with their index). `dispatch_pause_edit` is a partial change — pass the index
|
|
36
|
+
plus only the fields to alter. Deferring never drops a job and costs no budget.
|
|
36
37
|
|
|
37
38
|
Use them like this:
|
|
38
39
|
|