@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 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
- # @edgehero/pi-dispatch-admin
5
+ # pi-dispatch — run the pi coding agent as a self-hosted service
6
6
 
7
- The **operator console** for [**pi-dispatch**](https://github.com/edgehero/pi-dispatch) — a
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 a **companion** to a running pi-dispatch deployment. It reads that deployment's Valkey queue and
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
- ## What it gives you
11
+ ## How it works
16
12
 
17
- - A `/dispatch` overlay: live queue state, day/week/month **spend meters** + a daily token counter, run
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
- ## Install
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 use `/dispatch`. Point it at your deployment with `VALKEY_URL` / `PI_LOGS_DIR` /
31
- `PI_SETTINGS_FILE` / `PI_TRIGGERS_FILE` / `PI_PAUSE_WINDOWS_FILE` (the same values your pi-dispatch worker uses).
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
- ## The service
64
+ ### → **https://github.com/edgehero/pi-dispatch**
34
65
 
35
- The queue, worker, container image, and GitHub receiver live in the main repo:
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.1",
4
- "description": "Operator console (a pi extension) + skill for the pi-dispatch self-hosted agent service: a /dispatch TUI for queue, spend, run history, triggers, and scheduled pause windows. Runs against a live pi-dispatch deployment.",
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
- "extension"
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 (per folder/repo "quiet
34
- hours": runs for a scope are deferred between certain times and auto-resume after; `dispatch_pauses` lists
35
- them). Deferring never drops a job and costs no budget.
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