@edgehero/pi-dispatch-admin 0.4.0 → 0.6.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 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. Anything you already installed with `pi install` is staged automatically, at the version your host has, so there is nothing to declare twice.
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
@@ -7935,11 +7935,13 @@ function normalizeCron(on, run, index, path, state) {
7935
7935
  }
7936
7936
  const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
7937
7937
  const image = validateImageRef(run, `cron trigger "${id}"`, path);
7938
+ const skillsDir = validateSkillsDir(run, `cron trigger "${id}"`, path);
7939
+ validateInstructions(run, `cron trigger "${id}"`, path, { cron: true });
7938
7940
  const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
7939
7941
  validateReplicas(run, `cron trigger "${id}"`, path);
7940
7942
  return {
7941
7943
  on: { type: "cron", id, pattern },
7942
- run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume }
7944
+ run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...skillsDir !== void 0 && { skillsDir } }
7943
7945
  };
7944
7946
  }
7945
7947
  function validatePackagesFlag(run, at, path) {
@@ -7952,6 +7954,11 @@ function validateResumeFlag(run, at, path) {
7952
7954
  if (run.resume !== void 0 && typeof run.resume !== "boolean") {
7953
7955
  throw configError(`${at}: run.resume must be true or false when present: ${path}`);
7954
7956
  }
7957
+ if (run.resume === true && run.kind === "local") {
7958
+ throw configError(
7959
+ `${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}`
7960
+ );
7961
+ }
7955
7962
  return run.resume;
7956
7963
  }
7957
7964
  function validateImageRef(run, at, path) {
@@ -7968,6 +7975,31 @@ function validateImageRef(run, at, path) {
7968
7975
  }
7969
7976
  return image;
7970
7977
  }
7978
+ function validateSkillsDir(run, at, path) {
7979
+ const dir = run.skillsDir;
7980
+ if (dir === void 0) return void 0;
7981
+ if (typeof dir !== "string" || dir.trim() === "") {
7982
+ throw configError(`${at}: run.skillsDir must be a non-empty string when present: ${path}`);
7983
+ }
7984
+ if (dir !== dir.trim()) {
7985
+ throw configError(`${at}: run.skillsDir must not have leading or trailing whitespace (got ${JSON.stringify(dir)}): ${path}`);
7986
+ }
7987
+ return dir;
7988
+ }
7989
+ function validateInstructions(run, at, path, { cron = false } = {}) {
7990
+ const text = run.instructions;
7991
+ if (text === void 0) return void 0;
7992
+ if (cron) {
7993
+ throw configError(`${at}: run.instructions is not accepted on a cron trigger -- a local job's prompt IS run.task, the same operator-authored text in the same place. Put the standing instruction at the top of run.task: ${path}`);
7994
+ }
7995
+ if (typeof text !== "string" || text.trim() === "") {
7996
+ throw configError(`${at}: run.instructions must be a non-empty string when present: ${path}`);
7997
+ }
7998
+ if (text.length > INSTRUCTIONS_MAX) {
7999
+ throw configError(`${at}: run.instructions is ${text.length} characters, over the ${INSTRUCTIONS_MAX} cap -- a standing instruction is a sentence or two. Anything longer belongs in the flow's own SKILL.md, or in the global overlay's APPEND_SYSTEM.md if it applies to every job: ${path}`);
8000
+ }
8001
+ return text;
8002
+ }
7971
8003
  function validatePredicate(on, index, path, requirePositive) {
7972
8004
  const at = `trigger at index ${index}`;
7973
8005
  for (const key of ["any", "all", "none"]) {
@@ -8022,12 +8054,14 @@ function normalizeLabel(on, run, index, path) {
8022
8054
  }
8023
8055
  const packages = validatePackagesFlag(run, at, path);
8024
8056
  const image = validateImageRef(run, at, path);
8057
+ const skillsDir = validateSkillsDir(run, at, path);
8058
+ const instructions = validateInstructions(run, at, path);
8025
8059
  const resume = validateResumeFlag(run, at, path);
8026
8060
  const repository = validateRepository(run, "label", at, path);
8027
8061
  const replicas = validateReplicas(run, at, path);
8028
8062
  return {
8029
8063
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
8030
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...repository !== void 0 && { repository } }
8064
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
8031
8065
  };
8032
8066
  }
8033
8067
  function normalizeComment(on, run, index, path, state) {
@@ -8044,12 +8078,14 @@ function normalizeComment(on, run, index, path, state) {
8044
8078
  }
8045
8079
  const packages = validatePackagesFlag(run, at, path);
8046
8080
  const image = validateImageRef(run, at, path);
8081
+ const skillsDir = validateSkillsDir(run, at, path);
8082
+ const instructions = validateInstructions(run, at, path);
8047
8083
  const resume = validateResumeFlag(run, at, path);
8048
8084
  const repository = validateRepository(run, "comment", at, path);
8049
8085
  const replicas = validateReplicas(run, at, path);
8050
8086
  return {
8051
8087
  on: { type: "comment", phrase: on.phrase },
8052
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...repository !== void 0 && { repository } }
8088
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
8053
8089
  };
8054
8090
  }
8055
8091
  function normalizePullRequest(on, run, index, path) {
@@ -8065,6 +8101,7 @@ function normalizePullRequest(on, run, index, path) {
8065
8101
  throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
8066
8102
  }
8067
8103
  }
8104
+ const reviewState = validateReviewState(on, actions, run, at, path);
8068
8105
  const labelAction = forgeSpec(run.kind)?.prLabelAction;
8069
8106
  const requirePositive = typeof labelAction === "string" && actions.includes(labelAction);
8070
8107
  if (run.kind === "azure" && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
@@ -8076,22 +8113,55 @@ function normalizePullRequest(on, run, index, path) {
8076
8113
  }
8077
8114
  const packages = validatePackagesFlag(run, at, path);
8078
8115
  const image = validateImageRef(run, at, path);
8116
+ const skillsDir = validateSkillsDir(run, at, path);
8117
+ const instructions = validateInstructions(run, at, path);
8079
8118
  const resume = validateResumeFlag(run, at, path);
8080
8119
  validateRepository(run, "pull_request", at, path);
8081
8120
  const replicas = validateReplicas(run, at, path);
8082
8121
  return {
8083
- on: { type: "pull_request", action: [...actions], any: predicate.any, all: predicate.all, none: predicate.none },
8084
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas }
8122
+ on: {
8123
+ type: "pull_request",
8124
+ action: [...actions],
8125
+ // Absent rather than present-and-undefined: an unnarrowed rule's normalized shape must stay
8126
+ // byte-identical to the one every pre-#66 trigger file produces.
8127
+ ...reviewState !== void 0 && { reviewState },
8128
+ any: predicate.any,
8129
+ all: predicate.all,
8130
+ none: predicate.none
8131
+ },
8132
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
8085
8133
  };
8086
8134
  }
8087
- var ON_TYPES, PR_ACTIONS, ID_CHARSET, REPLICAS_MAX;
8135
+ function validateReviewState(on, actions, run, at, path) {
8136
+ if (on.reviewState === void 0) return void 0;
8137
+ if (run.kind !== "github") {
8138
+ throw configError(`${at}: on.reviewState is github-only (got ${run.kind}), no other forge reports a review verdict: ${path}`);
8139
+ }
8140
+ if (!actions.includes(REVIEW_ACTION)) {
8141
+ throw configError(`${at}: on.reviewState requires on.action to include ${JSON.stringify(REVIEW_ACTION)}, otherwise it narrows nothing: ${path}`);
8142
+ }
8143
+ if (!Array.isArray(on.reviewState) || on.reviewState.length === 0) {
8144
+ throw configError(`${at}: on.reviewState must be a non-empty array: ${path}`);
8145
+ }
8146
+ const expected = [...REVIEW_STATES].join("|");
8147
+ for (const s of on.reviewState) {
8148
+ if (!REVIEW_STATES.has(s)) {
8149
+ throw configError(`${at}: on.reviewState has an unsupported review state ${JSON.stringify(s)} (expected ${expected}): ${path}`);
8150
+ }
8151
+ }
8152
+ return [...on.reviewState];
8153
+ }
8154
+ var ON_TYPES, PR_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, INSTRUCTIONS_MAX;
8088
8155
  var init_triggers = __esm({
8089
8156
  "../worker/src/triggers.mjs"() {
8090
8157
  init_config2();
8091
8158
  init_forges();
8092
8159
  ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request"]);
8093
8160
  PR_ACTIONS = {
8094
- github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened"]),
8161
+ github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
8162
+ // GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
8163
+ // `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
8164
+ // what `on.reviewState` below exists to narrow.
8095
8165
  gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"]),
8096
8166
  // Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
8097
8167
  // one-letter difference that an operator would otherwise discover as a trigger that loads clean and
@@ -8104,8 +8174,11 @@ var init_triggers = __esm({
8104
8174
  // requests -- which is why azure's `prLabelAction` is null and a predicated PR rule is refused below.
8105
8175
  azure: /* @__PURE__ */ new Set(["created", "updated"])
8106
8176
  };
8177
+ REVIEW_STATES = /* @__PURE__ */ new Set(["approved", "changes_requested", "commented"]);
8178
+ REVIEW_ACTION = "review_submitted";
8107
8179
  ID_CHARSET = /^[A-Za-z0-9._-]+$/;
8108
8180
  REPLICAS_MAX = 3;
8181
+ INSTRUCTIONS_MAX = 2e3;
8109
8182
  }
8110
8183
  });
8111
8184
 
@@ -8437,7 +8510,7 @@ import { Queue } from "bullmq";
8437
8510
  function makeQueue(connection) {
8438
8511
  return new Queue(QUEUE, { connection });
8439
8512
  }
8440
- async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8513
+ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8441
8514
  const minute = now.toISOString().slice(0, 16);
8442
8515
  const id = jobId ?? localJobId({ folder, flow, task, minute });
8443
8516
  const data = {
@@ -8449,6 +8522,11 @@ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, max
8449
8522
  model,
8450
8523
  maxTurns,
8451
8524
  ...image !== void 0 && { image },
8525
+ // The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
8526
+ // Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
8527
+ // than inside `trigger` because a worker-host path is an execution knob, not a fact about the
8528
+ // delivery -- and `trigger` is the object copied into /job/event.json.
8529
+ ...skillsDir !== void 0 && { skillsDir },
8452
8530
  ...chainDepth !== void 0 && { chainDepth },
8453
8531
  ...parentJobId !== void 0 && { parentJobId }
8454
8532
  };
@@ -8529,14 +8607,40 @@ var init_git_dirty = __esm({
8529
8607
  }
8530
8608
  });
8531
8609
 
8610
+ // ../worker/src/host-pi.mjs
8611
+ var init_host_pi = __esm({
8612
+ "../worker/src/host-pi.mjs"() {
8613
+ init_packages();
8614
+ }
8615
+ });
8616
+
8617
+ // ../worker/src/copy-tree.mjs
8618
+ var ENTRY_NAME_RE, INJECT_LIMITS;
8619
+ var init_copy_tree = __esm({
8620
+ "../worker/src/copy-tree.mjs"() {
8621
+ ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
8622
+ INJECT_LIMITS = Object.freeze({
8623
+ // The binding one, and its reason is NOT disk. Every loaded skill contributes a <name> and a
8624
+ // description (spec-capped at 1024 chars) to the SYSTEM PROMPT of every job of that trigger, so
8625
+ // this bounds the cached prefix rather than the filesystem. UNVERIFIED figure, in the sense
8626
+ // ISOLATION_FLAGS' --pids-limit=512 is: a ceiling on absurdity, not a measured budget.
8627
+ maxDirs: 64,
8628
+ maxFiles: 512,
8629
+ maxBytes: 4 << 20,
8630
+ maxDepth: 8
8631
+ });
8632
+ }
8633
+ });
8634
+
8532
8635
  // ../worker/src/import-pi.mjs
8533
8636
  import { execFile as execFile2 } from "node:child_process";
8534
8637
  import { promisify as promisify2 } from "node:util";
8535
- var ENTRY_NAME_RE, execFileAsync;
8638
+ var execFileAsync;
8536
8639
  var init_import_pi = __esm({
8537
8640
  "../worker/src/import-pi.mjs"() {
8538
8641
  init_packages();
8539
- ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
8642
+ init_host_pi();
8643
+ init_copy_tree();
8540
8644
  execFileAsync = promisify2(execFile2);
8541
8645
  }
8542
8646
  });
@@ -8622,14 +8726,14 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
8622
8726
  const { name, version, dir } = entry;
8623
8727
  if (!isNonEmptyString5(name) || !isNonEmptyString5(version) || !isNonEmptyString5(dir)) return null;
8624
8728
  if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
8625
- packages.push({ name, version, dir });
8729
+ packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
8626
8730
  }
8627
8731
  return { stagedAt: typeof parsed.stagedAt === "string" ? parsed.stagedAt : null, packages };
8628
8732
  } catch {
8629
8733
  return null;
8630
8734
  }
8631
8735
  }
8632
- var PACKAGES_SUBDIR, STAGE_MANIFEST, CONTAINER_PACKAGES_ROOT, MAX_DIR_LENGTH;
8736
+ var PACKAGES_SUBDIR, STAGE_MANIFEST, CONTAINER_PACKAGES_ROOT, FROM_DECLARED, FROM_HOST, MAX_DIR_LENGTH;
8633
8737
  var init_packages = __esm({
8634
8738
  "../worker/src/packages.mjs"() {
8635
8739
  init_config2();
@@ -8638,6 +8742,8 @@ var init_packages = __esm({
8638
8742
  PACKAGES_SUBDIR = "packages";
8639
8743
  STAGE_MANIFEST = "packages.json";
8640
8744
  CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
8745
+ FROM_DECLARED = "pi-packages";
8746
+ FROM_HOST = "host";
8641
8747
  MAX_DIR_LENGTH = 64;
8642
8748
  }
8643
8749
  });
@@ -9051,6 +9157,8 @@ function normalizeTriggerForDisplay(entry) {
9051
9157
  const flow = typeof run.flow === "string" ? run.flow : null;
9052
9158
  const packages = run.packages !== false;
9053
9159
  const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
9160
+ const skillsDir = typeof run.skillsDir === "string" && run.skillsDir.trim() !== "" ? run.skillsDir : null;
9161
+ const instructions = typeof run.instructions === "string" && run.instructions.trim() !== "";
9054
9162
  const resume = run.resume === true;
9055
9163
  const forge = typeof run.kind === "string" && run.kind.trim() !== "" ? run.kind : null;
9056
9164
  const replicas = Number.isInteger(run.replicas) && run.replicas > 1 ? run.replicas : null;
@@ -9067,12 +9175,14 @@ function normalizeTriggerForDisplay(entry) {
9067
9175
  model: typeof run.model === "string" ? run.model : null,
9068
9176
  packages,
9069
9177
  image,
9178
+ skillsDir,
9179
+ instructions,
9070
9180
  resume
9071
9181
  };
9072
9182
  case "label":
9073
- return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, resume, replicas, forge };
9183
+ return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, skillsDir, instructions, resume, replicas, forge };
9074
9184
  case "comment":
9075
- return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, resume, replicas, forge };
9185
+ return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, skillsDir, instructions, resume, replicas, forge };
9076
9186
  case "pull_request":
9077
9187
  return {
9078
9188
  type: "pull_request",
@@ -9083,6 +9193,8 @@ function normalizeTriggerForDisplay(entry) {
9083
9193
  flow,
9084
9194
  packages,
9085
9195
  image,
9196
+ skillsDir,
9197
+ instructions,
9086
9198
  resume,
9087
9199
  replicas,
9088
9200
  forge
@@ -35565,19 +35677,21 @@ function triggerLine(t) {
35565
35677
  const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
35566
35678
  const pkgs = t?.packages === true ? " [packages]" : "";
35567
35679
  const img = t?.image ? ` [image ${t.image}]` : "";
35680
+ const skl = t?.skillsDir ? ` [skills ${String(t.skillsDir).split(/[\\/]/).filter(Boolean).pop()}]` : "";
35681
+ const ins = t?.instructions === true ? " [instructions]" : "";
35568
35682
  const res = t?.resume === true ? " [resume]" : "";
35569
35683
  const rep = t?.replicas > 1 ? ` [x${t.replicas}]` : "";
35570
35684
  switch (t?.type) {
35571
35685
  case "cron":
35572
- return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${res}${rep}`;
35686
+ return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
35573
35687
  case "label":
35574
- return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${res}${rep}`;
35688
+ return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
35575
35689
  case "comment":
35576
- return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${res}${rep}`;
35690
+ return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
35577
35691
  case "pull_request": {
35578
35692
  const clauses = ruleClauses(t);
35579
35693
  const action = `action[${(t.action ?? []).join(",")}]`;
35580
- return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${res}${rep}`;
35694
+ return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
35581
35695
  }
35582
35696
  default:
35583
35697
  return "(unknown trigger)";
@@ -36963,7 +37077,9 @@ function trustModel(t) {
36963
37077
  ];
36964
37078
  default:
36965
37079
  return [
36966
- t?.type === "comment" ? "authorized by a collaborator comment (author_association) + HMAC" : "authorized by a collaborator's label + HMAC webhook + author gate",
37080
+ // A review trigger reads the REVIEWER's author_association, not the PR author's (issue #66), so it
37081
+ // cannot share the generic line: an operator reading "author gate" would picture the wrong person.
37082
+ t?.type === "comment" ? "authorized by a collaborator comment (author_association) + HMAC" : t?.type === "pull_request" && (t?.action ?? []).includes("review_submitted") ? "authorized by the REVIEWER's author_association (not the PR author's) + HMAC" : "authorized by a collaborator's label + HMAC webhook + author gate",
36967
37083
  "dedup by X-GitHub-Delivery GUID (redelivery-safe)",
36968
37084
  where
36969
37085
  ];
@@ -37456,17 +37572,24 @@ var init_dashboard = __esm({
37456
37572
  // src/setup-wizard.ts
37457
37573
  var setup_wizard_exports = {};
37458
37574
  __export(setup_wizard_exports, {
37575
+ RECEIVER_VERSION: () => RECEIVER_VERSION,
37459
37576
  RUNTIME_VERSION: () => RUNTIME_VERSION,
37460
37577
  detectDeployment: () => detectDeployment,
37578
+ dockerHint: () => dockerHint,
37461
37579
  listRepoSkills: () => listRepoSkills,
37462
37580
  npmInstallArgs: () => npmInstallArgs,
37581
+ npmInstallArgsFor: () => npmInstallArgsFor,
37463
37582
  npmSpawnOptions: () => npmSpawnOptions,
37583
+ probeDocker: () => probeDocker,
37584
+ readInstalledVersion: () => readInstalledVersion,
37585
+ receiverInstallArgs: () => receiverInstallArgs,
37464
37586
  registerNudge: () => registerNudge,
37465
37587
  runAttached: () => runAttached,
37466
- runSetupWizard: () => runSetupWizard
37588
+ runSetupWizard: () => runSetupWizard,
37589
+ runtimeDirFor: () => runtimeDirFor
37467
37590
  });
37468
37591
  import * as nodeFs4 from "node:fs";
37469
- import { spawn as nodeSpawn } from "node:child_process";
37592
+ import { execFileSync as execFileSync3, spawn as nodeSpawn } from "node:child_process";
37470
37593
  import { homedir as homedir2 } from "node:os";
37471
37594
  import { join as join6 } from "node:path";
37472
37595
  function envIsSet(env, key) {
@@ -37545,10 +37668,10 @@ ${title}
37545
37668
  const result = await custom.call(ctx.ui, factory, { overlay: true });
37546
37669
  return result ?? { code: null, error: new Error("the overlay closed before the command finished") };
37547
37670
  }
37548
- function npmInstallArgs() {
37671
+ function npmInstallArgsFor(pkgName, version) {
37549
37672
  return [
37550
37673
  "install",
37551
- `@edgehero/pi-dispatch@${RUNTIME_VERSION}`,
37674
+ `${pkgName}@${version}`,
37552
37675
  "--omit=dev",
37553
37676
  "--omit=optional",
37554
37677
  "--ignore-scripts",
@@ -37557,6 +37680,54 @@ function npmInstallArgs() {
37557
37680
  "--loglevel=error"
37558
37681
  ];
37559
37682
  }
37683
+ function npmInstallArgs() {
37684
+ return npmInstallArgsFor(RUNTIME_PKG, RUNTIME_VERSION);
37685
+ }
37686
+ function receiverInstallArgs() {
37687
+ return npmInstallArgsFor(RECEIVER_PKG, RECEIVER_VERSION);
37688
+ }
37689
+ function probeDocker(execFn = execFileSync3) {
37690
+ try {
37691
+ execFn("docker", ["version"], { stdio: "ignore", timeout: DOCKER_PROBE_TIMEOUT_MS });
37692
+ return { ok: true };
37693
+ } catch (err) {
37694
+ if (err?.code === "ENOENT" || err?.errno === "ENOENT") return { missing: true };
37695
+ if (typeof err?.status === "number") return { daemonDown: `\`docker version\` exited ${err.status}` };
37696
+ return { daemonDown: err?.message ?? String(err) };
37697
+ }
37698
+ }
37699
+ function dockerHint(platform) {
37700
+ if (platform === "darwin") {
37701
+ 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.";
37702
+ }
37703
+ if (platform === "win32") {
37704
+ return "Windows: install and START Docker Desktop (docs.docker.com/desktop/install/windows-install) \u2014 it provides the `docker` CLI and a running daemon.";
37705
+ }
37706
+ return "Linux: install the docker engine from your distribution's own packages, following docs.docker.com/engine/install, then start and enable its daemon.";
37707
+ }
37708
+ async function ensureDocker(ui, notify, { platform, probeDockerFn }) {
37709
+ for (let round = 1; ; round++) {
37710
+ const probe = probeDockerFn();
37711
+ if (probe.ok) return true;
37712
+ const why = probe.missing ? "no `docker` on PATH" : `docker is installed but not answering (${probe.daemonDown})`;
37713
+ notify?.(`docker check: ${why}. ${dockerHint(platform)}`, "warning");
37714
+ if (round >= DOCKER_PROBE_ROUNDS) {
37715
+ notify?.(
37716
+ `docker still not ready after ${DOCKER_PROBE_ROUNDS} checks \u2014 stopping setup before anything was installed. Re-run /dispatch setup once \`docker version\` answers.`,
37717
+ "error"
37718
+ );
37719
+ return false;
37720
+ }
37721
+ const choice = await ui.select("Docker is not ready", ["Re-check", "Continue anyway", "Stop"]);
37722
+ if (choice === "Re-check") continue;
37723
+ if (choice === "Continue anyway") {
37724
+ notify?.("continuing without a docker answer \u2014 `up` runs its own docker checks and refuses there if it is still missing", "info");
37725
+ return true;
37726
+ }
37727
+ notify?.("setup stopped before anything was installed \u2014 re-run /dispatch setup when docker is ready", "info");
37728
+ return false;
37729
+ }
37730
+ }
37560
37731
  function npmSpawnOptions(platform, dir) {
37561
37732
  return platform === "win32" ? { bin: "npm.cmd", options: { cwd: dir, shell: true } } : { bin: "npm", options: { cwd: dir } };
37562
37733
  }
@@ -37568,6 +37739,9 @@ function listRepoSkills(cwd, fs = nodeFs4) {
37568
37739
  return [];
37569
37740
  }
37570
37741
  }
37742
+ function runtimeDirFor(dir, pkg = "pi-dispatch") {
37743
+ return join6(dir, "node_modules", "@edgehero", pkg);
37744
+ }
37571
37745
  function readInstalledVersion(fs, runtimeDir) {
37572
37746
  try {
37573
37747
  const pkg = JSON.parse(fs.readFileSync(join6(runtimeDir, "package.json"), "utf8"));
@@ -37576,6 +37750,13 @@ function readInstalledVersion(fs, runtimeDir) {
37576
37750
  return void 0;
37577
37751
  }
37578
37752
  }
37753
+ function ensureDeploymentPackageJson(fs, dir) {
37754
+ const rootPkg = join6(dir, "package.json");
37755
+ if (!fs.existsSync(rootPkg)) {
37756
+ fs.writeFileSync(rootPkg, `${JSON.stringify({ name: "pi-dispatch-deployment", private: true }, null, 2)}
37757
+ `);
37758
+ }
37759
+ }
37579
37760
  async function runSetupWizard(paths, ctx, notify, deps = {}) {
37580
37761
  const {
37581
37762
  fs = nodeFs4,
@@ -37591,14 +37772,16 @@ async function runSetupWizard(paths, ctx, notify, deps = {}) {
37591
37772
  writeTriggersFn = writeTriggers,
37592
37773
  resolvePathsFn = resolvePaths,
37593
37774
  listRepoSkillsFn = listRepoSkills,
37594
- existsSyncFn = (p) => fs.existsSync(p)
37775
+ existsSyncFn = (p) => fs.existsSync(p),
37776
+ probeDockerFn = probeDocker,
37777
+ initialDetection
37595
37778
  } = deps;
37596
37779
  const ui = ctx?.ui;
37597
37780
  if (typeof ui?.input !== "function" || typeof ui?.select !== "function" || typeof ui?.confirm !== "function") {
37598
37781
  notify?.("setup needs dialogs (newer pi) \u2014 no input/select/confirm available", "warning");
37599
37782
  return;
37600
37783
  }
37601
- const det = await detectFn({ env, cwd: ctx?.cwd, fs });
37784
+ const det = initialDetection ?? await detectFn({ env, cwd: ctx?.cwd, fs });
37602
37785
  notify?.(`pi-dispatch setup \u2014 detected: ${det.detail}`, "info");
37603
37786
  const intent = await ui.select("pi-dispatch setup", ["Guided setup", "Open the panel anyway", "Cancel"]);
37604
37787
  if (intent === "Open the panel anyway") {
@@ -37615,8 +37798,9 @@ async function runSetupWizard(paths, ctx, notify, deps = {}) {
37615
37798
  notify?.(`could not create ${dir}: ${err?.message ?? err} \u2014 setup cannot continue`, "error");
37616
37799
  return;
37617
37800
  }
37618
- const runtimeDir = join6(dir, "node_modules", "@edgehero", "pi-dispatch");
37801
+ const runtimeDir = runtimeDirFor(dir);
37619
37802
  const cliPath = join6(runtimeDir, "src", "cli.mjs");
37803
+ if (!await ensureDocker(ui, notify, { platform, probeDockerFn })) return;
37620
37804
  if (readInstalledVersion(fs, runtimeDir) === RUNTIME_VERSION) {
37621
37805
  notify?.(`runtime ${RUNTIME_VERSION} already installed in ${dir} \u2014 skipping npm install`, "info");
37622
37806
  } else {
@@ -37632,11 +37816,7 @@ async function runSetupWizard(paths, ctx, notify, deps = {}) {
37632
37816
  if (!okInstall) {
37633
37817
  notify?.(`install skipped \u2014 later steps assume the runtime at ${runtimeDir}`, "info");
37634
37818
  } 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
- }
37819
+ ensureDeploymentPackageJson(fs, dir);
37640
37820
  const res = await runAttachedFn(ctx, {
37641
37821
  title: `npm install @edgehero/pi-dispatch@${RUNTIME_VERSION} \u2014 in ${dir}`,
37642
37822
  argv0: bin,
@@ -37738,6 +37918,7 @@ ${JSON.stringify(pointer, null, 2)}`);
37738
37918
  } else {
37739
37919
  notify?.(`later: node ${cliPath} setup github (in ${dir})`, "info");
37740
37920
  }
37921
+ await offerTriggerEdge(dir, ctx, ui, notify, { fs, platform, env, execPath, cliPath, runAttachedFn });
37741
37922
  if (typeof ctx?.cwd === "string" && ctx.cwd !== "" && existsSyncFn(ctx.cwd)) {
37742
37923
  await offerFirstTrigger(ctx.cwd, dir, ui, notify, { fs, listRepoSkillsFn, writeTriggersFn });
37743
37924
  }
@@ -37747,6 +37928,110 @@ ${JSON.stringify(pointer, null, 2)}`);
37747
37928
  await openDashboardFn(resolvePathsFn(env), ctx, notify);
37748
37929
  }
37749
37930
  }
37931
+ async function offerTriggerEdge(dir, ctx, ui, notify, { fs, platform, env, execPath, cliPath, runAttachedFn }) {
37932
+ const choice = await ui.select("How should GitHub events reach the queue?", [
37933
+ EDGE_SERVICE,
37934
+ EDGE_COMPOSE,
37935
+ EDGE_POLL,
37936
+ "Skip"
37937
+ ]);
37938
+ if (choice === EDGE_SERVICE) {
37939
+ const args = receiverInstallArgs();
37940
+ const { bin, options } = npmSpawnOptions(platform, dir);
37941
+ const unitHint = `node ${cliPath} service install --receiver (in ${dir})`;
37942
+ const ok = await ui.confirm(
37943
+ "Install the webhook receiver",
37944
+ `Run in ${dir}:
37945
+ ${bin} ${args.join(" ")}
37946
+
37947
+ then, in the same folder:
37948
+ ${execPath} ${cliPath} service install --receiver
37949
+
37950
+ (scripts disabled; installs the pinned ${RECEIVER_PKG}@${RECEIVER_VERSION}, then renders a user-level receiver unit for this host)`
37951
+ );
37952
+ if (!ok) {
37953
+ notify?.(`receiver skipped \u2014 later: ${bin} ${args.join(" ")} (in ${dir}), then ${unitHint}`, "info");
37954
+ return;
37955
+ }
37956
+ ensureDeploymentPackageJson(fs, dir);
37957
+ const res = await runAttachedFn(ctx, {
37958
+ title: `npm install ${RECEIVER_PKG}@${RECEIVER_VERSION} \u2014 in ${dir}`,
37959
+ argv0: bin,
37960
+ args,
37961
+ cwd: options.cwd,
37962
+ shell: options.shell,
37963
+ env
37964
+ });
37965
+ const installed = readInstalledVersion(fs, runtimeDirFor(dir, "pi-dispatch-receiver"));
37966
+ if (installed !== RECEIVER_VERSION) {
37967
+ const how = res?.error ? `npm could not run: ${res.error.message}` : `npm exited ${res?.code}`;
37968
+ notify?.(
37969
+ `${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}`,
37970
+ "error"
37971
+ );
37972
+ return;
37973
+ }
37974
+ await runAttachedFn(ctx, {
37975
+ title: `pi-dispatch service install --receiver \u2014 in ${dir}`,
37976
+ argv0: execPath,
37977
+ args: [cliPath, "service", "install", "--receiver"],
37978
+ cwd: dir,
37979
+ env
37980
+ });
37981
+ return;
37982
+ }
37983
+ if (choice === EDGE_COMPOSE) {
37984
+ const src = join6(runtimeDirFor(dir), "deploy", "docker-compose.yml");
37985
+ const dest = join6(dir, "docker-compose.yml");
37986
+ if (fs.existsSync(dest)) {
37987
+ notify?.(`${dest} already exists \u2014 keeping yours as it is (setup never overwrites a compose file you may have edited)`, "info");
37988
+ } else {
37989
+ try {
37990
+ fs.copyFileSync(src, dest);
37991
+ notify?.(`copied the runtime's compose file to ${dest}`, "info");
37992
+ } catch (err) {
37993
+ notify?.(
37994
+ `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)`,
37995
+ "error"
37996
+ );
37997
+ return;
37998
+ }
37999
+ }
38000
+ const args = ["compose", "-f", dest, "--profile", "receiver", "up", "-d"];
38001
+ const ok = await ui.confirm(
38002
+ "Start the receiver container",
38003
+ `Run in ${dir}:
38004
+ docker ${args.join(" ")}
38005
+
38006
+ the receiver profile reads ${join6(dir, ".env")} \u2014 WEBHOOK_SECRET and the forge credentials must be in there, or the container refuses to boot.`
38007
+ );
38008
+ if (!ok) {
38009
+ notify?.(`later: docker ${args.join(" ")} (in ${dir})`, "info");
38010
+ return;
38011
+ }
38012
+ await runAttachedFn(ctx, {
38013
+ title: `docker compose --profile receiver up -d \u2014 in ${dir}`,
38014
+ argv0: "docker",
38015
+ args,
38016
+ cwd: dir,
38017
+ env
38018
+ });
38019
+ return;
38020
+ }
38021
+ if (choice === EDGE_POLL) {
38022
+ notify?.(
38023
+ `polling needs no public URL, no DNS and no tunnel \u2014 two commands, in ${dir}:
38024
+ 1) node ${cliPath} setup github --no-webhook (mints the App with its webhook INACTIVE \u2014 the polling-ready shape)
38025
+ 2) npx @edgehero/pi-dispatch-receiver poll (the producer; run it from the deployment folder, under whatever keeps it alive)`,
38026
+ "info"
38027
+ );
38028
+ return;
38029
+ }
38030
+ notify?.(
38031
+ `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.`,
38032
+ "info"
38033
+ );
38034
+ }
37750
38035
  async function offerFirstTrigger(repoCwd, deployDir, ui, notify, { fs, listRepoSkillsFn, writeTriggersFn }) {
37751
38036
  const offer = await ui.select("First trigger", ["A cron trigger for this repo", "Skip"]);
37752
38037
  if (offer !== "A cron trigger for this repo") return;
@@ -37806,23 +38091,40 @@ function registerNudge(pi, deps = {}) {
37806
38091
  }
37807
38092
  });
37808
38093
  }
37809
- var RUNTIME_VERSION, NUDGE_MARKER_BASENAME, CWD_SCAFFOLD_FILES, ENV_PATH_KEYS, CRON_ID_RE;
38094
+ 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
38095
  var init_setup_wizard = __esm({
37811
38096
  "src/setup-wizard.ts"() {
37812
38097
  init_flow_gate();
37813
38098
  init_deployment_pointer();
37814
38099
  init_read_model();
37815
38100
  init_index();
37816
- RUNTIME_VERSION = "0.1.1";
38101
+ RUNTIME_VERSION = "0.2.0";
38102
+ RECEIVER_VERSION = "0.2.0";
38103
+ RUNTIME_PKG = "@edgehero/pi-dispatch";
38104
+ RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
37817
38105
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
37818
38106
  CWD_SCAFFOLD_FILES = [".env", "triggers.json", "pause-windows.json", "subscriptions.json"];
37819
38107
  ENV_PATH_KEYS = POINTER_ENV_ALLOWLIST.filter((key) => key !== "VALKEY_URL");
38108
+ DOCKER_PROBE_ROUNDS = 5;
38109
+ DOCKER_PROBE_TIMEOUT_MS = 1e4;
37820
38110
  CRON_ID_RE = /^[A-Za-z0-9._-]+$/;
38111
+ EDGE_SERVICE = "Install the webhook receiver as a service";
38112
+ EDGE_COMPOSE = "Run the receiver with docker compose";
38113
+ EDGE_POLL = "Show the polling command";
37821
38114
  }
37822
38115
  });
37823
38116
 
37824
38117
  // src/index.ts
38118
+ import { VERSION } from "@earendil-works/pi-coding-agent";
37825
38119
  import { fileURLToPath as fileURLToPath2 } from "node:url";
38120
+ import * as nodeFs5 from "node:fs";
38121
+ function computePiVersionAdvisory(version, supported) {
38122
+ if (version === supported || version === "0.0.0") return void 0;
38123
+ 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`;
38124
+ }
38125
+ function _setPiVersionAdvisoryForTests(message) {
38126
+ piVersionAdvisory = message;
38127
+ }
37826
38128
  function admin(pi) {
37827
38129
  for (const member of USED_API) {
37828
38130
  if (typeof pi[member] !== "function") {
@@ -37832,6 +38134,10 @@ function admin(pi) {
37832
38134
  return;
37833
38135
  }
37834
38136
  }
38137
+ if (!piVersionCheckDone) {
38138
+ piVersionCheckDone = true;
38139
+ piVersionAdvisory = computePiVersionAdvisory(VERSION, SUPPORTED_PI_VERSION);
38140
+ }
37835
38141
  try {
37836
38142
  applyDeploymentPointer();
37837
38143
  } catch {
@@ -37995,7 +38301,7 @@ function registerTools(pi) {
37995
38301
  pi.registerTool({
37996
38302
  name: "dispatch_trigger_add",
37997
38303
  label: "pi-dispatch add trigger",
37998
- 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, gitlab is open|update|reopen|approved. 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.",
38304
+ 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.",
37999
38305
  executionMode: "sequential",
38000
38306
  parameters: typebox_exports.Object({
38001
38307
  kind: typebox_exports.String(),
@@ -38262,22 +38568,28 @@ async function dispatch(pi, args, ctx) {
38262
38568
  setGlyphs(paths.asciiGlyphs);
38263
38569
  const pnote = takePointerNotice();
38264
38570
  if (pnote) notify?.(pnote, "warning");
38571
+ if (piVersionAdvisory) {
38572
+ notify?.(piVersionAdvisory, "info");
38573
+ piVersionAdvisory = void 0;
38574
+ }
38265
38575
  if (sub === "") {
38266
- const { detectDeployment: detectDeployment2, runSetupWizard: runSetupWizard2 } = await Promise.resolve().then(() => (init_setup_wizard(), setup_wizard_exports));
38576
+ const { detectDeployment: detectDeployment2, runSetupWizard: runSetupWizard2, readInstalledVersion: readInstalledVersion2, runtimeDirFor: runtimeDirFor2, RUNTIME_VERSION: RUNTIME_VERSION2 } = await Promise.resolve().then(() => (init_setup_wizard(), setup_wizard_exports));
38267
38577
  const det = await detectDeployment2({ env: process.env, cwd: ctx?.cwd });
38268
38578
  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?"
38579
+ await runSetupWizard2(paths, ctx, notify, { openDashboardFn: openDashboard, initialDetection: det });
38580
+ return;
38581
+ }
38582
+ if (det.state === "pointer" && !runtimeSkewNoticed) {
38583
+ const ptrRes = readPointer({ path: pointerPath(process.env) });
38584
+ const deploymentDir = ptrRes.pointer?.deploymentDir;
38585
+ const installed = deploymentDir ? readInstalledVersion2(nodeFs5, runtimeDirFor2(deploymentDir)) : void 0;
38586
+ if (typeof installed === "string" && installed !== RUNTIME_VERSION2) {
38587
+ runtimeSkewNoticed = true;
38588
+ notify?.(
38589
+ `deployment runtime ${installed}, this console pins ${RUNTIME_VERSION2} \u2014 run /dispatch setup to upgrade`,
38590
+ "warning"
38273
38591
  );
38274
- if (ok) {
38275
- await runSetupWizard2(paths, ctx, notify, { openDashboardFn: openDashboard });
38276
- return;
38277
- }
38278
38592
  }
38279
- notify?.(`${USAGE} \u2014 run /dispatch setup when you are ready`, "info");
38280
- return;
38281
38593
  }
38282
38594
  if (det.state === "cwd" || det.state === "reachable") {
38283
38595
  notify?.(`using ${det.detail} \u2014 /dispatch setup can write a deployment pointer so this works from anywhere`, "info");
@@ -38846,7 +39158,7 @@ function completeArguments(prefix) {
38846
39158
  }
38847
39159
  return null;
38848
39160
  }
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;
39161
+ 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
39162
  var init_index = __esm({
38851
39163
  "src/index.ts"() {
38852
39164
  init_build();
@@ -38863,6 +39175,8 @@ var init_index = __esm({
38863
39175
  init_keys2();
38864
39176
  USED_API = ["registerCommand", "registerTool", "sendMessage", "on"];
38865
39177
  SUPPORTED_PI_VERSION = "0.80.7";
39178
+ piVersionCheckDone = false;
39179
+ runtimeSkewNoticed = false;
38866
39180
  CHANNEL = "pi-dispatch-admin";
38867
39181
  REBUILT_NOTICE = (reason) => `replaced invalid settings file (${reason}) \u2014 other keys were lost`;
38868
39182
  USAGE = "usage: /dispatch <status|pause|resume|run|runs|logs|budget|costs|triggers|settings|set|unset|setup>";
@@ -38883,7 +39197,7 @@ var init_index = __esm({
38883
39197
  ];
38884
39198
  FORGE_PROMPT = "forge \u2014 github, gitlab, forgejo or azure";
38885
39199
  PR_ACTION_VOCAB = {
38886
- github: { hint: "labeled opened synchronize reopened", dflt: "labeled" },
39200
+ github: { hint: "labeled opened synchronize reopened review_submitted", dflt: "labeled" },
38887
39201
  gitlab: { hint: "open update reopen approved", dflt: "update" },
38888
39202
  forgejo: { hint: "label_updated opened synchronized reopened", dflt: "label_updated" },
38889
39203
  azure: { hint: "created updated", dflt: "updated" }
@@ -38899,7 +39213,9 @@ init_index();
38899
39213
  export {
38900
39214
  SUPPORTED_PI_VERSION,
38901
39215
  USED_API,
39216
+ _setPiVersionAdvisoryForTests,
38902
39217
  buildTriggerEntry,
39218
+ computePiVersionAdvisory,
38903
39219
  admin as default,
38904
39220
  handleDashboardAction
38905
39221
  };
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.6.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",
@@ -128,9 +128,14 @@ trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `
128
128
  Two things are NOT the same, and both refuse at load rather than misbehaving quietly:
129
129
 
130
130
  - **`pull_request` actions are the forge's own words.** GitHub takes
131
- `labeled | opened | synchronize | reopened`; GitLab takes `open | update | reopen | approved`. A word
131
+ `labeled | opened | synchronize | reopened | review_submitted`; GitLab takes
132
+ `open | update | reopen | approved`. A word
132
133
  from the wrong forge is refused when the file is written. It would not break anything otherwise — it
133
134
  would simply never match an event, and the trigger would look configured while doing nothing.
135
+ `review_submitted` is GitHub's `pull_request_review` event: it fires on **every** submitted review, so
136
+ add `reviewState` (`approved | changes_requested | commented`) to narrow which verdicts are worth paying
137
+ for. That field is github-only and legal only beside `review_submitted`; anywhere else it refuses at
138
+ load, because a narrowing that cannot apply reads as one that does.
134
139
  - **One `comment` trigger per forge.** Two GitHub comment triggers are refused; one GitHub and one GitLab
135
140
  are fine.
136
141