@edgehero/pi-dispatch-admin 0.4.0 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +27 -7
  2. package/dist/index.mjs +231 -26
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  Every trigger produces the same job, through the same path: one queue, one container, one budget.
14
14
 
15
15
  ```
16
- CLI · cron · GitHub issue / PR
16
+ CLI · cron · label / comment / PR on GitHub, GitLab, Forgejo or Azure DevOps
17
17
  │ enqueue
18
18
  ▼
19
19
  Valkey + BullMQ durable queue: survives reboots, absorbs bursts
@@ -23,14 +23,32 @@ Every trigger produces the same job, through the same path: one queue, one conta
23
23
  per-job turn budget?
24
24
  │ yes
25
25
  ▼
26
- docker run --rm one ephemeral container per job:
27
- --cap-drop=ALL · non-root --no-new-privileges · /job mounted read-only
26
+ docker run --rm one ephemeral container per job: --cap-drop=ALL,
27
+ --security-opt no-new-privileges, memory/CPU/pids
28
+ limits, /job mounted read-only
28
29
  │
29
30
  ▼
30
- pi + your .pi/skills edits your code in place, opens a PR, comments back
31
+ pi + your .pi/skills edits your code in place, opens a PR or MR, comments back
31
32
  ```
32
33
 
33
- The container is the boundary (pi's missing permission system, enforced by Docker). Spend is checked before a container starts, so a runaway or a junk trigger costs a refusal, not a surprise bill. The job image is yours to shape, per deployment or per trigger, and it ships Playwright and Chromium so a flow can build a frontend, screenshot it, and iterate on the render.
34
+ The container is the boundary (pi's missing permission system, enforced by Docker). Those isolation flags are built by the worker's own `docker run` argv, so nothing an image contains can weaken them; the non-root user is a property of the **image**, which is why an image has to meet the conformance checklist in [`docs/job-image.md`](https://github.com/edgehero/pi-dispatch/blob/main/docs/job-image.md). Spend is checked before a container starts, so a runaway or a junk trigger costs a refusal, not a surprise bill. The job image is yours to shape, per deployment or per trigger, and it ships Playwright and Chromium so a flow can build a frontend, screenshot it, and iterate on the render.
35
+
36
+ ## Triggers: what starts a job
37
+
38
+ Every trigger is one `{ on, run }` entry in a single `triggers.json`, read live by the worker (cron) and the receiver (forge webhooks), and editable from the console. **`on` is what fires it; `run` is the skill it runs.**
39
+
40
+ | `on.type` | Fires on | What narrows it | What the agent gets as its task |
41
+ |---|---|---|---|
42
+ | `cron` | your schedule | nothing: a schedule is its own condition | the `task` written in the file |
43
+ | `label` | a label on an **issue** (or an Azure work item), never a pull request | a label predicate: `any`, `all`, or `none` (which can suppress a fire, never cause one) | the issue title and body |
44
+ | `comment` | a comment containing your phrase, for example `@pi` | the phrase, and one comment trigger per forge | the comment body, plus the issue title and body |
45
+ | `pull_request` | a PR or MR event | `action`, in your forge's own words, plus the same label predicate | the PR title and body |
46
+
47
+ Four forges: GitHub, GitLab, Forgejo (and Gitea), Azure DevOps. **Who may fire a trigger is your forge's decision, not this service's**: on GitHub the label *is* the approval, because only collaborators can apply one, while GitLab, Forgejo and Azure resolve the actor's permission through their APIs. Each forge's action vocabulary is validated when the file loads, so a word from the wrong forge is refused instead of silently never matching.
48
+
49
+ **Flows, and workflows.** `run.flow` names a skill committed to the target repo at `.pi/skills/<flow>/SKILL.md`, read from the **default branch**, so the repo owns the prompt and merging it is the repo's consent. A skill may call other skills, which is already a workflow. For typed multi-stage ones, a pi extension such as `@juicesharp/rpiv-workflow` can be staged into the deployment: pinned to an exact version, installed on your host (never at job time, since jobs run offline), loaded in every container, and declinable per trigger.
50
+
51
+ **How a workflow gets triggered, in one line:** `label / comment / PR / cron` fires **one job in one container**, that job runs `run.flow`, and the flow drives whatever stages follow. Four consequences worth knowing before you build on it. A trigger names a **flow, never a workflow**, so which stages run is decided inside the job by that skill. A job is **not an interactive session** (the container hands pi one assembled prompt and reads the exit line), so a workflow extension's slash command has nobody to type it: in a job the flow's instructions drive the workflow, or a small extension you also staged calls its API from a lifecycle hook. One trigger is **one job, one budget slot and one turn budget**, so ten stages share the same `PI_MAX_TURNS` and per-job token budget, and exhausting either ends the job as a policy refusal that is never retried. And whether the workflow's own state survives depends on the trigger kind: a cron or CLI job has your folder mounted read-write so state persists between runs, while a forge job gets a fresh clone that is discarded with the container. Full reference: [`docs/workflows.md`](https://github.com/edgehero/pi-dispatch/blob/main/docs/workflows.md).
34
52
 
35
53
  ## The console: `/dispatch`
36
54
 
@@ -49,7 +67,7 @@ One command puts a live TUI over the whole deployment:
49
67
 
50
68
  - **Triggers, editable live.** cron, label, comment and pull_request triggers with colored drill-ins showing what fires each one, what it runs, and its trust model. Added, edited and deleted without a restart. Triggers that run third-party code or a custom image are badged; opting in or out of either stays an edit to the reviewed `triggers.json`, which neither the console nor a model-callable tool will make for you.
51
69
  - **Quiet hours.** Scheduled pause windows per folder or repo: defer runs between certain times, timezone-aware, and resume automatically. Deferred, never dropped, at zero budget cost.
52
- - **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 cannot answer, and refuses when no operator is present. The bundled `operate-pi-dispatch` skill teaches the agent those gates.
70
+ - **AI-operable, with a human gate.** Model-callable tools let an agent change limits and manage triggers and pause windows, and every **config** write pops an operator confirmation the model cannot answer, refusing outright when no operator is present. Two tools sit outside that gate on purpose: `dispatch_pause` and `dispatch_resume` write durable queue state but are reversible and spend nothing, so they carry no confirm. One more sits outside it and is **not** money-safe: `dispatch_run` enqueues a **paid** run that edits a local folder in place with no undo, bounded instead by six independent limits (the `PI_DISPATCH_RUN_ROOTS` folder allowlist, a committed per-flow `ai-trigger: allow` opt-in read at a pre-agent SHA, a dirty-tree refusal with no force option, no spend knobs on the tool, a per-hour rate limit, and the worker's daily cap). Read [`SECURITY.md`](https://github.com/edgehero/pi-dispatch/blob/main/SECURITY.md) on that one before you enable it. The bundled `operate-pi-dispatch` skill teaches the agent those gates.
53
71
  - **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
54
72
 
55
73
  ## Install
@@ -58,10 +76,12 @@ One command puts a live TUI over the whole deployment:
58
76
  pi install npm:@edgehero/pi-dispatch-admin # then, in pi: /dispatch
59
77
  ```
60
78
 
61
- **No deployment yet? The console builds one.** When `/dispatch` finds nothing, it offers `/dispatch setup`: pick a folder, consent to an npm install of the pinned runtime, watch `pi-dispatch up` run its own prompts, optionally install the worker as a service, and land in the panel. Every step asks first and can be declined; nothing is written into your repo and no credential passes through a dialog.
79
+ **This is the default way to set up pi-dispatch.** With nothing configured, `/dispatch` takes you straight into guided setup, in this order: an opening choice, a deployment folder, a Docker check with per-OS pointers if it is missing, a consented npm install of the pinned runtime (Docker is checked **first** on purpose, so no bandwidth is spent on a host where `up` cannot work anyway), `pi-dispatch up` running its own prompts in your terminal, the deployment pointer that lets `/dispatch` find this deployment from any directory, a notice naming the file your provider key belongs in, an optional worker service, optional GitHub App credentials (`setup github`, the one step that mints a private key), an optional trigger edge (receiver service, docker compose profile, or the polling command), and an optional first **cron** trigger for the repo you are sitting in. Every step shows what it will do, asks first, and can be declined; nothing is written into your repo and no credential passes through a dialog. A deployment whose queue is merely down keeps the unreachable banner instead: setup appears when there is nothing, never over an outage.
62
80
 
63
81
  Already have a deployment? The panel finds it through the deployment pointer setup writes, or through the same env vars your worker uses (`VALKEY_URL`, `PI_LOGS_DIR`, `PI_SETTINGS_FILE`, `PI_TRIGGERS_FILE`, `PI_PAUSE_WINDOWS_FILE`, `PI_SUBSCRIPTIONS_FILE`). Your env always wins.
64
82
 
83
+ `dispatch_run` is inert until you set one more variable yourself: `PI_DISPATCH_RUN_ROOTS` defaults to empty, and an empty allowlist refuses every folder. The deployment pointer deliberately cannot set it (the pointer carries paths, never capability grants), so widening that allowlist is always your own env edit.
84
+
65
85
  ## Get the whole thing
66
86
 
67
87
  ### → **https://github.com/edgehero/pi-dispatch**
package/dist/index.mjs CHANGED
@@ -7952,6 +7952,11 @@ function validateResumeFlag(run, at, path) {
7952
7952
  if (run.resume !== void 0 && typeof run.resume !== "boolean") {
7953
7953
  throw configError(`${at}: run.resume must be true or false when present: ${path}`);
7954
7954
  }
7955
+ if (run.resume === true && run.kind === "local") {
7956
+ throw configError(
7957
+ `${at}: run.resume is not yet covered for cron triggers (forge triggers only in this version) -- resolveSession is handed to the forge preparers only, so a local job would stage no transcript, mount no /session and promote nothing, then exit 0 as though it had; the local session key exists in session-key.mjs and nothing reaches it, so this is a gap to close, not a limit: ${path}`
7958
+ );
7959
+ }
7955
7960
  return run.resume;
7956
7961
  }
7957
7962
  function validateImageRef(run, at, path) {
@@ -37456,17 +37461,24 @@ var init_dashboard = __esm({
37456
37461
  // src/setup-wizard.ts
37457
37462
  var setup_wizard_exports = {};
37458
37463
  __export(setup_wizard_exports, {
37464
+ RECEIVER_VERSION: () => RECEIVER_VERSION,
37459
37465
  RUNTIME_VERSION: () => RUNTIME_VERSION,
37460
37466
  detectDeployment: () => detectDeployment,
37467
+ dockerHint: () => dockerHint,
37461
37468
  listRepoSkills: () => listRepoSkills,
37462
37469
  npmInstallArgs: () => npmInstallArgs,
37470
+ npmInstallArgsFor: () => npmInstallArgsFor,
37463
37471
  npmSpawnOptions: () => npmSpawnOptions,
37472
+ probeDocker: () => probeDocker,
37473
+ readInstalledVersion: () => readInstalledVersion,
37474
+ receiverInstallArgs: () => receiverInstallArgs,
37464
37475
  registerNudge: () => registerNudge,
37465
37476
  runAttached: () => runAttached,
37466
- runSetupWizard: () => runSetupWizard
37477
+ runSetupWizard: () => runSetupWizard,
37478
+ runtimeDirFor: () => runtimeDirFor
37467
37479
  });
37468
37480
  import * as nodeFs4 from "node:fs";
37469
- import { spawn as nodeSpawn } from "node:child_process";
37481
+ import { execFileSync as execFileSync3, spawn as nodeSpawn } from "node:child_process";
37470
37482
  import { homedir as homedir2 } from "node:os";
37471
37483
  import { join as join6 } from "node:path";
37472
37484
  function envIsSet(env, key) {
@@ -37545,10 +37557,10 @@ ${title}
37545
37557
  const result = await custom.call(ctx.ui, factory, { overlay: true });
37546
37558
  return result ?? { code: null, error: new Error("the overlay closed before the command finished") };
37547
37559
  }
37548
- function npmInstallArgs() {
37560
+ function npmInstallArgsFor(pkgName, version) {
37549
37561
  return [
37550
37562
  "install",
37551
- `@edgehero/pi-dispatch@${RUNTIME_VERSION}`,
37563
+ `${pkgName}@${version}`,
37552
37564
  "--omit=dev",
37553
37565
  "--omit=optional",
37554
37566
  "--ignore-scripts",
@@ -37557,6 +37569,54 @@ function npmInstallArgs() {
37557
37569
  "--loglevel=error"
37558
37570
  ];
37559
37571
  }
37572
+ function npmInstallArgs() {
37573
+ return npmInstallArgsFor(RUNTIME_PKG, RUNTIME_VERSION);
37574
+ }
37575
+ function receiverInstallArgs() {
37576
+ return npmInstallArgsFor(RECEIVER_PKG, RECEIVER_VERSION);
37577
+ }
37578
+ function probeDocker(execFn = execFileSync3) {
37579
+ try {
37580
+ execFn("docker", ["version"], { stdio: "ignore", timeout: DOCKER_PROBE_TIMEOUT_MS });
37581
+ return { ok: true };
37582
+ } catch (err) {
37583
+ if (err?.code === "ENOENT" || err?.errno === "ENOENT") return { missing: true };
37584
+ if (typeof err?.status === "number") return { daemonDown: `\`docker version\` exited ${err.status}` };
37585
+ return { daemonDown: err?.message ?? String(err) };
37586
+ }
37587
+ }
37588
+ function dockerHint(platform) {
37589
+ if (platform === "darwin") {
37590
+ return "macOS: install and START Docker Desktop (docs.docker.com/desktop/install/mac-install) or OrbStack (orbstack.dev) \u2014 either one provides the `docker` CLI and a running daemon.";
37591
+ }
37592
+ if (platform === "win32") {
37593
+ return "Windows: install and START Docker Desktop (docs.docker.com/desktop/install/windows-install) \u2014 it provides the `docker` CLI and a running daemon.";
37594
+ }
37595
+ return "Linux: install the docker engine from your distribution's own packages, following docs.docker.com/engine/install, then start and enable its daemon.";
37596
+ }
37597
+ async function ensureDocker(ui, notify, { platform, probeDockerFn }) {
37598
+ for (let round = 1; ; round++) {
37599
+ const probe = probeDockerFn();
37600
+ if (probe.ok) return true;
37601
+ const why = probe.missing ? "no `docker` on PATH" : `docker is installed but not answering (${probe.daemonDown})`;
37602
+ notify?.(`docker check: ${why}. ${dockerHint(platform)}`, "warning");
37603
+ if (round >= DOCKER_PROBE_ROUNDS) {
37604
+ notify?.(
37605
+ `docker still not ready after ${DOCKER_PROBE_ROUNDS} checks \u2014 stopping setup before anything was installed. Re-run /dispatch setup once \`docker version\` answers.`,
37606
+ "error"
37607
+ );
37608
+ return false;
37609
+ }
37610
+ const choice = await ui.select("Docker is not ready", ["Re-check", "Continue anyway", "Stop"]);
37611
+ if (choice === "Re-check") continue;
37612
+ if (choice === "Continue anyway") {
37613
+ notify?.("continuing without a docker answer \u2014 `up` runs its own docker checks and refuses there if it is still missing", "info");
37614
+ return true;
37615
+ }
37616
+ notify?.("setup stopped before anything was installed \u2014 re-run /dispatch setup when docker is ready", "info");
37617
+ return false;
37618
+ }
37619
+ }
37560
37620
  function npmSpawnOptions(platform, dir) {
37561
37621
  return platform === "win32" ? { bin: "npm.cmd", options: { cwd: dir, shell: true } } : { bin: "npm", options: { cwd: dir } };
37562
37622
  }
@@ -37568,6 +37628,9 @@ function listRepoSkills(cwd, fs = nodeFs4) {
37568
37628
  return [];
37569
37629
  }
37570
37630
  }
37631
+ function runtimeDirFor(dir, pkg = "pi-dispatch") {
37632
+ return join6(dir, "node_modules", "@edgehero", pkg);
37633
+ }
37571
37634
  function readInstalledVersion(fs, runtimeDir) {
37572
37635
  try {
37573
37636
  const pkg = JSON.parse(fs.readFileSync(join6(runtimeDir, "package.json"), "utf8"));
@@ -37576,6 +37639,13 @@ function readInstalledVersion(fs, runtimeDir) {
37576
37639
  return void 0;
37577
37640
  }
37578
37641
  }
37642
+ function ensureDeploymentPackageJson(fs, dir) {
37643
+ const rootPkg = join6(dir, "package.json");
37644
+ if (!fs.existsSync(rootPkg)) {
37645
+ fs.writeFileSync(rootPkg, `${JSON.stringify({ name: "pi-dispatch-deployment", private: true }, null, 2)}
37646
+ `);
37647
+ }
37648
+ }
37579
37649
  async function runSetupWizard(paths, ctx, notify, deps = {}) {
37580
37650
  const {
37581
37651
  fs = nodeFs4,
@@ -37591,14 +37661,16 @@ async function runSetupWizard(paths, ctx, notify, deps = {}) {
37591
37661
  writeTriggersFn = writeTriggers,
37592
37662
  resolvePathsFn = resolvePaths,
37593
37663
  listRepoSkillsFn = listRepoSkills,
37594
- existsSyncFn = (p) => fs.existsSync(p)
37664
+ existsSyncFn = (p) => fs.existsSync(p),
37665
+ probeDockerFn = probeDocker,
37666
+ initialDetection
37595
37667
  } = deps;
37596
37668
  const ui = ctx?.ui;
37597
37669
  if (typeof ui?.input !== "function" || typeof ui?.select !== "function" || typeof ui?.confirm !== "function") {
37598
37670
  notify?.("setup needs dialogs (newer pi) \u2014 no input/select/confirm available", "warning");
37599
37671
  return;
37600
37672
  }
37601
- const det = await detectFn({ env, cwd: ctx?.cwd, fs });
37673
+ const det = initialDetection ?? await detectFn({ env, cwd: ctx?.cwd, fs });
37602
37674
  notify?.(`pi-dispatch setup \u2014 detected: ${det.detail}`, "info");
37603
37675
  const intent = await ui.select("pi-dispatch setup", ["Guided setup", "Open the panel anyway", "Cancel"]);
37604
37676
  if (intent === "Open the panel anyway") {
@@ -37615,8 +37687,9 @@ async function runSetupWizard(paths, ctx, notify, deps = {}) {
37615
37687
  notify?.(`could not create ${dir}: ${err?.message ?? err} \u2014 setup cannot continue`, "error");
37616
37688
  return;
37617
37689
  }
37618
- const runtimeDir = join6(dir, "node_modules", "@edgehero", "pi-dispatch");
37690
+ const runtimeDir = runtimeDirFor(dir);
37619
37691
  const cliPath = join6(runtimeDir, "src", "cli.mjs");
37692
+ if (!await ensureDocker(ui, notify, { platform, probeDockerFn })) return;
37620
37693
  if (readInstalledVersion(fs, runtimeDir) === RUNTIME_VERSION) {
37621
37694
  notify?.(`runtime ${RUNTIME_VERSION} already installed in ${dir} \u2014 skipping npm install`, "info");
37622
37695
  } else {
@@ -37632,11 +37705,7 @@ async function runSetupWizard(paths, ctx, notify, deps = {}) {
37632
37705
  if (!okInstall) {
37633
37706
  notify?.(`install skipped \u2014 later steps assume the runtime at ${runtimeDir}`, "info");
37634
37707
  } else {
37635
- const rootPkg = join6(dir, "package.json");
37636
- if (!fs.existsSync(rootPkg)) {
37637
- fs.writeFileSync(rootPkg, `${JSON.stringify({ name: "pi-dispatch-deployment", private: true }, null, 2)}
37638
- `);
37639
- }
37708
+ ensureDeploymentPackageJson(fs, dir);
37640
37709
  const res = await runAttachedFn(ctx, {
37641
37710
  title: `npm install @edgehero/pi-dispatch@${RUNTIME_VERSION} \u2014 in ${dir}`,
37642
37711
  argv0: bin,
@@ -37738,6 +37807,7 @@ ${JSON.stringify(pointer, null, 2)}`);
37738
37807
  } else {
37739
37808
  notify?.(`later: node ${cliPath} setup github (in ${dir})`, "info");
37740
37809
  }
37810
+ await offerTriggerEdge(dir, ctx, ui, notify, { fs, platform, env, execPath, cliPath, runAttachedFn });
37741
37811
  if (typeof ctx?.cwd === "string" && ctx.cwd !== "" && existsSyncFn(ctx.cwd)) {
37742
37812
  await offerFirstTrigger(ctx.cwd, dir, ui, notify, { fs, listRepoSkillsFn, writeTriggersFn });
37743
37813
  }
@@ -37747,6 +37817,110 @@ ${JSON.stringify(pointer, null, 2)}`);
37747
37817
  await openDashboardFn(resolvePathsFn(env), ctx, notify);
37748
37818
  }
37749
37819
  }
37820
+ async function offerTriggerEdge(dir, ctx, ui, notify, { fs, platform, env, execPath, cliPath, runAttachedFn }) {
37821
+ const choice = await ui.select("How should GitHub events reach the queue?", [
37822
+ EDGE_SERVICE,
37823
+ EDGE_COMPOSE,
37824
+ EDGE_POLL,
37825
+ "Skip"
37826
+ ]);
37827
+ if (choice === EDGE_SERVICE) {
37828
+ const args = receiverInstallArgs();
37829
+ const { bin, options } = npmSpawnOptions(platform, dir);
37830
+ const unitHint = `node ${cliPath} service install --receiver (in ${dir})`;
37831
+ const ok = await ui.confirm(
37832
+ "Install the webhook receiver",
37833
+ `Run in ${dir}:
37834
+ ${bin} ${args.join(" ")}
37835
+
37836
+ then, in the same folder:
37837
+ ${execPath} ${cliPath} service install --receiver
37838
+
37839
+ (scripts disabled; installs the pinned ${RECEIVER_PKG}@${RECEIVER_VERSION}, then renders a user-level receiver unit for this host)`
37840
+ );
37841
+ if (!ok) {
37842
+ notify?.(`receiver skipped \u2014 later: ${bin} ${args.join(" ")} (in ${dir}), then ${unitHint}`, "info");
37843
+ return;
37844
+ }
37845
+ ensureDeploymentPackageJson(fs, dir);
37846
+ const res = await runAttachedFn(ctx, {
37847
+ title: `npm install ${RECEIVER_PKG}@${RECEIVER_VERSION} \u2014 in ${dir}`,
37848
+ argv0: bin,
37849
+ args,
37850
+ cwd: options.cwd,
37851
+ shell: options.shell,
37852
+ env
37853
+ });
37854
+ const installed = readInstalledVersion(fs, runtimeDirFor(dir, "pi-dispatch-receiver"));
37855
+ if (installed !== RECEIVER_VERSION) {
37856
+ const how = res?.error ? `npm could not run: ${res.error.message}` : `npm exited ${res?.code}`;
37857
+ notify?.(
37858
+ `${how}; installed receiver version is ${installed ?? "(absent)"}, not the pinned ${RECEIVER_VERSION} \u2014 skipping the receiver unit (the rest of the deployment is unaffected). Fix the install, then run: ${unitHint}`,
37859
+ "error"
37860
+ );
37861
+ return;
37862
+ }
37863
+ await runAttachedFn(ctx, {
37864
+ title: `pi-dispatch service install --receiver \u2014 in ${dir}`,
37865
+ argv0: execPath,
37866
+ args: [cliPath, "service", "install", "--receiver"],
37867
+ cwd: dir,
37868
+ env
37869
+ });
37870
+ return;
37871
+ }
37872
+ if (choice === EDGE_COMPOSE) {
37873
+ const src = join6(runtimeDirFor(dir), "deploy", "docker-compose.yml");
37874
+ const dest = join6(dir, "docker-compose.yml");
37875
+ if (fs.existsSync(dest)) {
37876
+ notify?.(`${dest} already exists \u2014 keeping yours as it is (setup never overwrites a compose file you may have edited)`, "info");
37877
+ } else {
37878
+ try {
37879
+ fs.copyFileSync(src, dest);
37880
+ notify?.(`copied the runtime's compose file to ${dest}`, "info");
37881
+ } catch (err) {
37882
+ notify?.(
37883
+ `could not copy ${src}: ${err?.message ?? err} \u2014 the receiver profile needs that file; skipping this step (install the runtime first, or copy it by hand)`,
37884
+ "error"
37885
+ );
37886
+ return;
37887
+ }
37888
+ }
37889
+ const args = ["compose", "-f", dest, "--profile", "receiver", "up", "-d"];
37890
+ const ok = await ui.confirm(
37891
+ "Start the receiver container",
37892
+ `Run in ${dir}:
37893
+ docker ${args.join(" ")}
37894
+
37895
+ the receiver profile reads ${join6(dir, ".env")} \u2014 WEBHOOK_SECRET and the forge credentials must be in there, or the container refuses to boot.`
37896
+ );
37897
+ if (!ok) {
37898
+ notify?.(`later: docker ${args.join(" ")} (in ${dir})`, "info");
37899
+ return;
37900
+ }
37901
+ await runAttachedFn(ctx, {
37902
+ title: `docker compose --profile receiver up -d \u2014 in ${dir}`,
37903
+ argv0: "docker",
37904
+ args,
37905
+ cwd: dir,
37906
+ env
37907
+ });
37908
+ return;
37909
+ }
37910
+ if (choice === EDGE_POLL) {
37911
+ notify?.(
37912
+ `polling needs no public URL, no DNS and no tunnel \u2014 two commands, in ${dir}:
37913
+ 1) node ${cliPath} setup github --no-webhook (mints the App with its webhook INACTIVE \u2014 the polling-ready shape)
37914
+ 2) npx @edgehero/pi-dispatch-receiver poll (the producer; run it from the deployment folder, under whatever keeps it alive)`,
37915
+ "info"
37916
+ );
37917
+ return;
37918
+ }
37919
+ notify?.(
37920
+ `trigger edge left for later \u2014 all three ways stay open from ${dir}: \`service install --receiver\` (a receiver unit on this host), \`docker compose --profile receiver up -d\` (a receiver container), or \`npx @edgehero/pi-dispatch-receiver poll\` (no public URL at all). Local cron triggers need none of them.`,
37921
+ "info"
37922
+ );
37923
+ }
37750
37924
  async function offerFirstTrigger(repoCwd, deployDir, ui, notify, { fs, listRepoSkillsFn, writeTriggersFn }) {
37751
37925
  const offer = await ui.select("First trigger", ["A cron trigger for this repo", "Skip"]);
37752
37926
  if (offer !== "A cron trigger for this repo") return;
@@ -37806,23 +37980,40 @@ function registerNudge(pi, deps = {}) {
37806
37980
  }
37807
37981
  });
37808
37982
  }
37809
- var RUNTIME_VERSION, NUDGE_MARKER_BASENAME, CWD_SCAFFOLD_FILES, ENV_PATH_KEYS, CRON_ID_RE;
37983
+ var RUNTIME_VERSION, RECEIVER_VERSION, RUNTIME_PKG, RECEIVER_PKG, NUDGE_MARKER_BASENAME, CWD_SCAFFOLD_FILES, ENV_PATH_KEYS, DOCKER_PROBE_ROUNDS, DOCKER_PROBE_TIMEOUT_MS, CRON_ID_RE, EDGE_SERVICE, EDGE_COMPOSE, EDGE_POLL;
37810
37984
  var init_setup_wizard = __esm({
37811
37985
  "src/setup-wizard.ts"() {
37812
37986
  init_flow_gate();
37813
37987
  init_deployment_pointer();
37814
37988
  init_read_model();
37815
37989
  init_index();
37816
- RUNTIME_VERSION = "0.1.1";
37990
+ RUNTIME_VERSION = "0.1.2";
37991
+ RECEIVER_VERSION = "0.1.1";
37992
+ RUNTIME_PKG = "@edgehero/pi-dispatch";
37993
+ RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
37817
37994
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
37818
37995
  CWD_SCAFFOLD_FILES = [".env", "triggers.json", "pause-windows.json", "subscriptions.json"];
37819
37996
  ENV_PATH_KEYS = POINTER_ENV_ALLOWLIST.filter((key) => key !== "VALKEY_URL");
37997
+ DOCKER_PROBE_ROUNDS = 5;
37998
+ DOCKER_PROBE_TIMEOUT_MS = 1e4;
37820
37999
  CRON_ID_RE = /^[A-Za-z0-9._-]+$/;
38000
+ EDGE_SERVICE = "Install the webhook receiver as a service";
38001
+ EDGE_COMPOSE = "Run the receiver with docker compose";
38002
+ EDGE_POLL = "Show the polling command";
37821
38003
  }
37822
38004
  });
37823
38005
 
37824
38006
  // src/index.ts
38007
+ import { VERSION } from "@earendil-works/pi-coding-agent";
37825
38008
  import { fileURLToPath as fileURLToPath2 } from "node:url";
38009
+ import * as nodeFs5 from "node:fs";
38010
+ function computePiVersionAdvisory(version, supported) {
38011
+ if (version === supported || version === "0.0.0") return void 0;
38012
+ return `pi-dispatch admin: running on pi ${version}, tested with pi ${supported} \u2014 things should work; report anything odd, and check for a newer @edgehero/pi-dispatch-admin`;
38013
+ }
38014
+ function _setPiVersionAdvisoryForTests(message) {
38015
+ piVersionAdvisory = message;
38016
+ }
37826
38017
  function admin(pi) {
37827
38018
  for (const member of USED_API) {
37828
38019
  if (typeof pi[member] !== "function") {
@@ -37832,6 +38023,10 @@ function admin(pi) {
37832
38023
  return;
37833
38024
  }
37834
38025
  }
38026
+ if (!piVersionCheckDone) {
38027
+ piVersionCheckDone = true;
38028
+ piVersionAdvisory = computePiVersionAdvisory(VERSION, SUPPORTED_PI_VERSION);
38029
+ }
37835
38030
  try {
37836
38031
  applyDeploymentPointer();
37837
38032
  } catch {
@@ -38262,22 +38457,28 @@ async function dispatch(pi, args, ctx) {
38262
38457
  setGlyphs(paths.asciiGlyphs);
38263
38458
  const pnote = takePointerNotice();
38264
38459
  if (pnote) notify?.(pnote, "warning");
38460
+ if (piVersionAdvisory) {
38461
+ notify?.(piVersionAdvisory, "info");
38462
+ piVersionAdvisory = void 0;
38463
+ }
38265
38464
  if (sub === "") {
38266
- const { detectDeployment: detectDeployment2, runSetupWizard: runSetupWizard2 } = await Promise.resolve().then(() => (init_setup_wizard(), setup_wizard_exports));
38465
+ const { detectDeployment: detectDeployment2, runSetupWizard: runSetupWizard2, readInstalledVersion: readInstalledVersion2, runtimeDirFor: runtimeDirFor2, RUNTIME_VERSION: RUNTIME_VERSION2 } = await Promise.resolve().then(() => (init_setup_wizard(), setup_wizard_exports));
38267
38466
  const det = await detectDeployment2({ env: process.env, cwd: ctx?.cwd });
38268
38467
  if (det.state === "none") {
38269
- if (typeof ctx?.ui?.confirm === "function") {
38270
- const ok = await ctx.ui.confirm(
38271
- "pi-dispatch setup",
38272
- "No deployment found (no pointer, no env, no config here, queue unreachable). Set one up now?"
38468
+ await runSetupWizard2(paths, ctx, notify, { openDashboardFn: openDashboard, initialDetection: det });
38469
+ return;
38470
+ }
38471
+ if (det.state === "pointer" && !runtimeSkewNoticed) {
38472
+ const ptrRes = readPointer({ path: pointerPath(process.env) });
38473
+ const deploymentDir = ptrRes.pointer?.deploymentDir;
38474
+ const installed = deploymentDir ? readInstalledVersion2(nodeFs5, runtimeDirFor2(deploymentDir)) : void 0;
38475
+ if (typeof installed === "string" && installed !== RUNTIME_VERSION2) {
38476
+ runtimeSkewNoticed = true;
38477
+ notify?.(
38478
+ `deployment runtime ${installed}, this console pins ${RUNTIME_VERSION2} \u2014 run /dispatch setup to upgrade`,
38479
+ "warning"
38273
38480
  );
38274
- if (ok) {
38275
- await runSetupWizard2(paths, ctx, notify, { openDashboardFn: openDashboard });
38276
- return;
38277
- }
38278
38481
  }
38279
- notify?.(`${USAGE} \u2014 run /dispatch setup when you are ready`, "info");
38280
- return;
38281
38482
  }
38282
38483
  if (det.state === "cwd" || det.state === "reachable") {
38283
38484
  notify?.(`using ${det.detail} \u2014 /dispatch setup can write a deployment pointer so this works from anywhere`, "info");
@@ -38846,7 +39047,7 @@ function completeArguments(prefix) {
38846
39047
  }
38847
39048
  return null;
38848
39049
  }
38849
- var USED_API, SUPPORTED_PI_VERSION, CHANNEL, REBUILT_NOTICE, USAGE, KNOWN_SUBCOMMANDS, FORGE_PROMPT, PR_ACTION_VOCAB, COSTS_WINDOWS2, COSTS_USAGE, DAY_MS2, PRICING, VIEWPORT_LINES;
39050
+ var USED_API, SUPPORTED_PI_VERSION, piVersionAdvisory, piVersionCheckDone, runtimeSkewNoticed, CHANNEL, REBUILT_NOTICE, USAGE, KNOWN_SUBCOMMANDS, FORGE_PROMPT, PR_ACTION_VOCAB, COSTS_WINDOWS2, COSTS_USAGE, DAY_MS2, PRICING, VIEWPORT_LINES;
38850
39051
  var init_index = __esm({
38851
39052
  "src/index.ts"() {
38852
39053
  init_build();
@@ -38863,6 +39064,8 @@ var init_index = __esm({
38863
39064
  init_keys2();
38864
39065
  USED_API = ["registerCommand", "registerTool", "sendMessage", "on"];
38865
39066
  SUPPORTED_PI_VERSION = "0.80.7";
39067
+ piVersionCheckDone = false;
39068
+ runtimeSkewNoticed = false;
38866
39069
  CHANNEL = "pi-dispatch-admin";
38867
39070
  REBUILT_NOTICE = (reason) => `replaced invalid settings file (${reason}) \u2014 other keys were lost`;
38868
39071
  USAGE = "usage: /dispatch <status|pause|resume|run|runs|logs|budget|costs|triggers|settings|set|unset|setup>";
@@ -38899,7 +39102,9 @@ init_index();
38899
39102
  export {
38900
39103
  SUPPORTED_PI_VERSION,
38901
39104
  USED_API,
39105
+ _setPiVersionAdvisoryForTests,
38902
39106
  buildTriggerEntry,
39107
+ computePiVersionAdvisory,
38903
39108
  admin as default,
38904
39109
  handleDashboardAction
38905
39110
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-admin",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -54,7 +54,7 @@
54
54
  "ioredis": "5.11.1"
55
55
  },
56
56
  "peerDependencies": {
57
- "@earendil-works/pi-coding-agent": "0.80.7"
57
+ "@earendil-works/pi-coding-agent": "*"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@earendil-works/pi-coding-agent": "0.80.7",