@edgehero/pi-dispatch-admin 0.8.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # pi-dispatch: run the pi coding agent as a self-hosted service
6
6
 
7
- **Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine.** [pi](https://github.com/earendil-works/pi) is a superb coding agent, but it has no job queue, no spend limit, and, by its own README, no permission system. pi-dispatch adds exactly those, so you can run pi unattended and safely: on a cron schedule, on your repo's issues and PRs, or straight from the CLI. Every job runs in an isolated container, spend is bounded before a single token is spent, and everything the agent did (and what it cost) is recorded, graphed and priced for you.
7
+ **Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine.** [pi](https://github.com/earendil-works/pi) is a superb coding agent, but it has no job queue, no spend limit, and, by its own README, no permission system. pi-dispatch adds exactly those, so you can run pi unattended and safely: on a cron schedule, on your repo's issues and PRs, or straight from the CLI. Every job runs in an isolated container, spend is bounded before a single token is spent, and everything the agent did (and what it cost) is recorded, graphed and priced for you. On a forge you stay in the loop at both ends: a job starts only when someone with write access to the repo asks for one, by a label, an `@pi` comment or a review, and what comes back is a pull request you review and land, because pi-dispatch never merges anything. This is not another agent to weigh against the one you already use; it is the queue, the budget and the box for the pi you already run.
8
8
 
9
9
  > This npm package, **`@edgehero/pi-dispatch-admin`**, is the **operator console** (a pi extension). The service itself is `@edgehero/pi-dispatch` (worker + CLI) and `@edgehero/pi-dispatch-receiver` (the webhook edge); the [main repo](https://github.com/edgehero/pi-dispatch) has the container image, docs, and SECURITY.md.
10
10
 
@@ -46,28 +46,28 @@ Every trigger is one `{ on, run }` entry in a single `triggers.json`, read live
46
46
 
47
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
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.
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. `run.command` is the second entry point: instead of a flow, a trigger may name a registered command a staged extension provides (`"command": "wf run nightly"`), and the job's whole prompt is that dispatch line, handled by the extension with no model turn in between. It picks which vetted command dispatches, never what code runs, and it is never AI-triggerable: job chaining refuses any request naming one, and `dispatch_run` cannot express one.
50
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).
51
+ **How a workflow gets triggered, in one line:** `label / comment / PR / cron` fires **one job in one container**, that job runs `run.flow` or `run.command`, and the entry point drives whatever stages follow. Four consequences worth knowing before you build on it. A trigger names a **flow or a registered command, never a workflow**, so which stages run is decided by what that entry point does. A job is **not an interactive session** (the container hands pi one assembled prompt and reads the exit line), and that one prompt can be the command itself: `run.command` dispatches a workflow extension's slash command headlessly, while in a flow 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).
52
52
 
53
53
  ## The console: `/dispatch`
54
54
 
55
55
  One command puts a live TUI over the whole deployment:
56
56
 
57
57
  <p align="center">
58
- <img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png?v=0.11.0" alt="The /dispatch panel: status, spend meters, triggers, runs, and settings" width="820">
58
+ <img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png?v=1.0.0" alt="The /dispatch panel: status, spend meters, triggers (including a command trigger shown as its /name), pause windows, runs, and settings" width="820">
59
59
  </p>
60
60
 
61
61
  - **Status and spend.** Queue and worker state, day/week/month spend meters, a daily token counter, and a run-history table with per-job tokens and cost.
62
62
  - **Insights, the one analytics page.** Press `i` on the panel (or type `/dispatch insights`) and a single self contained page opens in your browser: the budget dials (the one lever that actually changes what all of this costs), per-plan verdicts against API rates, daily, cumulative and per-flow spend charts, the four breakdowns (flow, trigger, model, repo), and the whole trigger and flow topology with spend badged onto the triggers that earned it. Every dollar carries its class: a plan-covered run never renders as $0.00, and an estimate is always marked as one. Design your agent loops as triggers and skills, see the loops you actually built, and see what each one costs, all in one place ([`docs/insights.md`](https://github.com/edgehero/pi-dispatch/blob/main/docs/insights.md)).
63
63
 
64
64
  <p align="center">
65
- <img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/insights-view.png?v=0.11.0" alt="The insights page: KPI tiles, a plan verdict card, the daily spend chart, the four breakdowns with plan-covered buckets drawn as chips instead of dollar bars, and the topology with spend badges" width="820">
65
+ <img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/insights-view.png?v=1.0.0" alt="The insights page: KPI tiles, a plan verdict card, the daily spend chart, the four breakdowns with plan-covered buckets drawn as chips instead of dollar bars, and the topology with spend badges and tier-resolved flows" width="820">
66
66
  </p>
67
67
 
68
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.
69
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.
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.
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). Commands sit outside its reach entirely: `dispatch_run` speaks flows only, and a chained job's request naming a `command` is refused outright, with no opt-in. 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.
71
71
  - **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
72
72
 
73
73
  ## Install
@@ -80,7 +80,7 @@ pi install npm:@edgehero/pi-dispatch-admin # then, in pi: /dispatch
80
80
 
81
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.
82
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.
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. No allowlist reaches commands either: a `run.command` fires from the reviewed triggers file only.
84
84
 
85
85
  ## Get the whole thing
86
86
 
package/dist/index.mjs CHANGED
@@ -7598,6 +7598,12 @@ var init_build = __esm({
7598
7598
  }
7599
7599
  });
7600
7600
 
7601
+ // ../worker/src/egress.mjs
7602
+ var init_egress = __esm({
7603
+ "../worker/src/egress.mjs"() {
7604
+ }
7605
+ });
7606
+
7601
7607
  // ../worker/src/forges.mjs
7602
7608
  function isForgeKind(kind) {
7603
7609
  return typeof kind === "string" && Object.hasOwn(FORGES, kind);
@@ -7698,6 +7704,7 @@ function defaultSettingsFile() {
7698
7704
  var CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT;
7699
7705
  var init_config2 = __esm({
7700
7706
  "../worker/src/config.mjs"() {
7707
+ init_egress();
7701
7708
  init_forges();
7702
7709
  CHAIN_DEPTH_MAX_DEFAULT = 1;
7703
7710
  CHAIN_MAX_PER_JOB_DEFAULT = 2;
@@ -7927,13 +7934,14 @@ function normalizeCron(on, run, index, path, state) {
7927
7934
  if (fieldCount !== 5 && fieldCount !== 6) {
7928
7935
  throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
7929
7936
  }
7937
+ const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
7930
7938
  if (!isNonEmptyString2(run.folder)) {
7931
7939
  throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
7932
7940
  }
7933
- if (!isNonEmptyString2(run.flow)) {
7934
- throw configError(`cron trigger "${id}": run.flow must be a non-empty string: ${path}`);
7941
+ if (command === void 0 && !isNonEmptyString2(run.flow)) {
7942
+ throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
7935
7943
  }
7936
- if (!isNonEmptyString2(run.task)) {
7944
+ if (command === void 0 && !isNonEmptyString2(run.task)) {
7937
7945
  throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
7938
7946
  }
7939
7947
  if (run.github !== void 0 && typeof run.github !== "boolean") {
@@ -7947,7 +7955,7 @@ function normalizeCron(on, run, index, path, state) {
7947
7955
  validateReplicas(run, `cron trigger "${id}"`, path);
7948
7956
  return {
7949
7957
  on: { type: "cron", id, pattern },
7950
- 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 } }
7958
+ 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, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir } }
7951
7959
  };
7952
7960
  }
7953
7961
  function validatePackagesFlag(run, at, path) {
@@ -8006,6 +8014,35 @@ function validateInstructions(run, at, path, { cron = false } = {}) {
8006
8014
  }
8007
8015
  return text;
8008
8016
  }
8017
+ function validateCommand(run, at, path, { onType }) {
8018
+ const raw = run.command;
8019
+ if (run.flow !== void 0 && raw !== void 0) {
8020
+ throw configError(`${at}: exactly one of run.flow or run.command must be set -- a trigger dispatches either a flow or a registered command, and with both present the file does not say which one runs: ${path}`);
8021
+ }
8022
+ if (raw === void 0) return void 0;
8023
+ if (!isNonEmptyString2(raw)) {
8024
+ throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
8025
+ }
8026
+ if (raw !== raw.trim()) {
8027
+ throw configError(`${at}: run.command must not have leading or trailing whitespace -- the arguments reach the command handler verbatim, so trimming here would make the reviewed file disagree with what runs (got ${JSON.stringify(raw)}): ${path}`);
8028
+ }
8029
+ if (raw.startsWith("/")) {
8030
+ throw configError(`${at}: run.command must not start with "/" -- the runner prepends the slash when it builds the /name args prompt, so a written one would dispatch "//name" (got ${JSON.stringify(raw)}): ${path}`);
8031
+ }
8032
+ if (/[\u0000-\u001F\u007F]/.test(raw)) {
8033
+ throw configError(`${at}: run.command must not contain control characters -- a newline would smuggle a second line into what the operator reviewed as one command line (got ${JSON.stringify(raw)}): ${path}`);
8034
+ }
8035
+ if (onType === "cron" && run.task !== void 0) {
8036
+ throw configError(`${at}: run.command and run.task cannot be combined -- a command job's prompt IS the /name args line, so there is no task text for the runner to render; put the arguments in run.command: ${path}`);
8037
+ }
8038
+ if (onType !== "cron" && run.instructions !== void 0) {
8039
+ throw configError(`${at}: run.command and run.instructions cannot be combined -- instructions render into the prompt envelope, and a command job's prompt is the exact /name args line with no envelope, so nothing would render them: ${path}`);
8040
+ }
8041
+ if (run.resume === true) {
8042
+ throw configError(`${at}: combining run.command and run.resume is not yet covered -- what a resumed session should do with a re-dispatched command is undesigned, so this is a gap to close, not a limit: ${path}`);
8043
+ }
8044
+ return raw;
8045
+ }
8009
8046
  function validatePredicate(on, index, path, requirePositive) {
8010
8047
  const at = `trigger at index ${index}`;
8011
8048
  for (const key of ["any", "all", "none"]) {
@@ -8041,9 +8078,6 @@ function validateReplicas(run, at, path) {
8041
8078
  if (run.kind === "local") {
8042
8079
  throw configError(`${at}: run.replicas is not available on a cron trigger -- a local job's /workspace IS the operator's folder, bind-mounted read-write, so two replicas would edit one working tree with no gate and no undo: ${path}`);
8043
8080
  }
8044
- if (run.kind !== "github") {
8045
- throw configError(`${at}: run.replicas is not yet covered for ${run.kind} triggers (github only in this version); every forge mints its branch the same way, so this is a gap to close, not a limit: ${path}`);
8046
- }
8047
8081
  if (!Number.isInteger(replicas) || replicas < 2 || replicas > REPLICAS_MAX) {
8048
8082
  throw configError(`${at}: run.replicas must be an integer between 2 and ${REPLICAS_MAX} when present -- ${REPLICAS_MAX} is the ceiling because PI_CONCURRENCY defaults to 3, so a further replica would queue instead of racing, and 1 is refused because a one-member replica set is a flag that does nothing (got ${JSON.stringify(replicas)}): ${path}`);
8049
8083
  }
@@ -8055,8 +8089,9 @@ function validateReplicas(run, at, path) {
8055
8089
  function normalizeLabel(on, run, index, path) {
8056
8090
  const at = `trigger at index ${index}`;
8057
8091
  const predicate = validatePredicate(on, index, path, true);
8058
- if (!isNonEmptyString2(run.flow)) {
8059
- throw configError(`${at}: label trigger run.flow must be a non-empty string: ${path}`);
8092
+ const command = validateCommand(run, at, path, { onType: "label" });
8093
+ if (command === void 0 && !isNonEmptyString2(run.flow)) {
8094
+ throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8060
8095
  }
8061
8096
  const packages = validatePackagesFlag(run, at, path);
8062
8097
  const image = validateImageRef(run, at, path);
@@ -8067,7 +8102,7 @@ function normalizeLabel(on, run, index, path) {
8067
8102
  const replicas = validateReplicas(run, at, path);
8068
8103
  return {
8069
8104
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
8070
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
8105
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
8071
8106
  };
8072
8107
  }
8073
8108
  function normalizeComment(on, run, index, path, state) {
@@ -8075,8 +8110,9 @@ function normalizeComment(on, run, index, path, state) {
8075
8110
  if (!isNonEmptyString2(on.phrase)) {
8076
8111
  throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
8077
8112
  }
8078
- if (!isNonEmptyString2(run.flow)) {
8079
- throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string: ${path}`);
8113
+ const command = validateCommand(run, at, path, { onType: "comment" });
8114
+ if (command === void 0 && !isNonEmptyString2(run.flow)) {
8115
+ throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
8080
8116
  }
8081
8117
  state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
8082
8118
  if (state.commentCounts[run.kind] > 1) {
@@ -8091,7 +8127,7 @@ function normalizeComment(on, run, index, path, state) {
8091
8127
  const replicas = validateReplicas(run, at, path);
8092
8128
  return {
8093
8129
  on: { type: "comment", phrase: on.phrase },
8094
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
8130
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
8095
8131
  };
8096
8132
  }
8097
8133
  function normalizePullRequest(on, run, index, path) {
@@ -8114,8 +8150,9 @@ function normalizePullRequest(on, run, index, path) {
8114
8150
  throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
8115
8151
  }
8116
8152
  const predicate = validatePredicate(on, index, path, requirePositive);
8117
- if (!isNonEmptyString2(run.flow)) {
8118
- throw configError(`${at}: pull_request trigger run.flow must be a non-empty string: ${path}`);
8153
+ const command = validateCommand(run, at, path, { onType: "pull_request" });
8154
+ if (command === void 0 && !isNonEmptyString2(run.flow)) {
8155
+ throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8119
8156
  }
8120
8157
  const packages = validatePackagesFlag(run, at, path);
8121
8158
  const image = validateImageRef(run, at, path);
@@ -8135,7 +8172,7 @@ function normalizePullRequest(on, run, index, path) {
8135
8172
  all: predicate.all,
8136
8173
  none: predicate.none
8137
8174
  },
8138
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
8175
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
8139
8176
  };
8140
8177
  }
8141
8178
  function validateReviewState(on, actions, run, at, path) {
@@ -8516,14 +8553,18 @@ import { Queue } from "bullmq";
8516
8553
  function makeQueue(connection) {
8517
8554
  return new Queue(QUEUE, { connection });
8518
8555
  }
8519
- async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8556
+ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8520
8557
  const minute = now.toISOString().slice(0, 16);
8521
- const id = jobId ?? localJobId({ folder, flow, task, minute });
8558
+ const id = jobId ?? localJobId({ folder, flow: command !== void 0 ? `cmd:${command}` : flow, task, minute });
8522
8559
  const data = {
8523
8560
  kind: "local",
8524
8561
  folder,
8525
8562
  flow,
8526
8563
  task,
8564
+ // The registered pi command this job dispatches instead of a flow (issue #189). Conditional like
8565
+ // `image`, so a flow job's data keeps exactly the keys it has today; the parse-level XOR means a
8566
+ // job carrying it has flow/task undefined, which JSON serialization drops.
8567
+ ...command !== void 0 && { command },
8527
8568
  provider,
8528
8569
  model,
8529
8570
  maxTurns,
@@ -8663,12 +8704,15 @@ function buildDockerRunArgs({
8663
8704
  name,
8664
8705
  memory = "4g",
8665
8706
  cpus = "2",
8707
+ network = null,
8666
8708
  extraFlags = []
8667
8709
  }) {
8668
8710
  if (!image) throw new Error("docker run: image is required");
8669
8711
  if (!name) throw new Error("docker run: container name is required");
8670
8712
  if (!workspace) throw new Error("docker run: workspace mount is required");
8671
- const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`, ...extraFlags];
8713
+ const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`];
8714
+ if (network) args.push(`--network=${network}`);
8715
+ args.push(...extraFlags);
8672
8716
  for (const [k, v] of Object.entries(env ?? {})) {
8673
8717
  if (v === void 0 || v === null) continue;
8674
8718
  args.push("-e", `${k}=${v}`);
@@ -8714,8 +8758,8 @@ var init_docker_run = __esm({
8714
8758
  });
8715
8759
 
8716
8760
  // ../worker/src/packages.mjs
8717
- import { existsSync, readFileSync } from "node:fs";
8718
- import { join } from "node:path";
8761
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
8762
+ import { basename, dirname as dirname2, join, resolve } from "node:path";
8719
8763
  function isNonEmptyString5(value) {
8720
8764
  return typeof value === "string" && value.trim() !== "";
8721
8765
  }
@@ -8739,6 +8783,55 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
8739
8783
  return null;
8740
8784
  }
8741
8785
  }
8786
+ function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExists = existsSync, readDir = readdirSync } = {}) {
8787
+ const out = { skills: [], unenumerable: [] };
8788
+ const manifest = readStageManifest({ globalPiDir, readFile, fileExists });
8789
+ if (!manifest) return out;
8790
+ for (const pkg of manifest.packages) {
8791
+ const root = join(globalPiDir, PACKAGES_SUBDIR, pkg.dir);
8792
+ let sources;
8793
+ try {
8794
+ const parsed = JSON.parse(readFile(join(root, "package.json"), "utf8"));
8795
+ const pi = parsed !== null && typeof parsed === "object" && parsed.pi !== null && typeof parsed.pi === "object" ? parsed.pi : null;
8796
+ if (pi) {
8797
+ const entries = Array.isArray(pi.skills) ? pi.skills.filter((e) => typeof e === "string") : [];
8798
+ if (entries.some((e) => e.startsWith("!") || e.startsWith("+") || e.startsWith("-") || e.includes("*") || e.includes("?"))) {
8799
+ out.unenumerable.push(pkg.name);
8800
+ continue;
8801
+ }
8802
+ sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve(root, e));
8803
+ } else {
8804
+ sources = [join(root, "skills")];
8805
+ }
8806
+ } catch {
8807
+ continue;
8808
+ }
8809
+ for (const source of sources) {
8810
+ for (const skillFile of walkSkillFiles(source, fileExists, readDir)) {
8811
+ out.skills.push({ name: basename(dirname2(skillFile)), package: pkg.name, dir: pkg.dir });
8812
+ }
8813
+ }
8814
+ }
8815
+ return out;
8816
+ }
8817
+ function walkSkillFiles(source, fileExists, readDir, depth = 3) {
8818
+ if (basename(source) === "SKILL.md") return fileExists(source) ? [source] : [];
8819
+ const found = [];
8820
+ const own = join(source, "SKILL.md");
8821
+ if (fileExists(own)) found.push(own);
8822
+ if (depth === 0) return found;
8823
+ let children;
8824
+ try {
8825
+ children = readDir(source, { withFileTypes: true });
8826
+ } catch {
8827
+ return found;
8828
+ }
8829
+ for (const child of children) {
8830
+ if (!child.isDirectory?.()) continue;
8831
+ found.push(...walkSkillFiles(join(source, child.name), fileExists, readDir, depth - 1));
8832
+ }
8833
+ return found;
8834
+ }
8742
8835
  var PACKAGES_SUBDIR, STAGE_MANIFEST, CONTAINER_PACKAGES_ROOT, FROM_DECLARED, FROM_HOST, MAX_DIR_LENGTH;
8743
8836
  var init_packages = __esm({
8744
8837
  "../worker/src/packages.mjs"() {
@@ -8925,11 +9018,13 @@ function findLoopHints(text) {
8925
9018
  }
8926
9019
  return hints;
8927
9020
  }
8928
- function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, foldersTruncated, forgeRepos, cronStats, runJoin, chainEdges, caps, nowMs, triggerCosts } = {}) {
9021
+ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, overlaySkills, stagedSkills, foldersTruncated, forgeRepos, cronStats, runJoin, chainEdges, caps, nowMs, triggerCosts } = {}) {
8929
9022
  const triggerList2 = Array.isArray(triggers?.triggers) ? triggers.triggers : [];
8930
9023
  const schedulerList = Array.isArray(schedulers) ? schedulers : [];
8931
9024
  const folders = folderSkills && typeof folderSkills === "object" ? folderSkills : {};
8932
9025
  const injected = injectedSkills && typeof injectedSkills === "object" ? injectedSkills : {};
9026
+ const overlay = overlaySkills && typeof overlaySkills === "object" && Array.isArray(overlaySkills.skills) ? overlaySkills : null;
9027
+ const staged = stagedSkills && typeof stagedSkills === "object" && Array.isArray(stagedSkills.skills) ? stagedSkills : null;
8933
9028
  const statsById = cronStats?.byId && typeof cronStats.byId === "object" ? cronStats.byId : {};
8934
9029
  const statsByIndex = runJoin?.byIndex && typeof runJoin.byIndex === "object" ? runJoin.byIndex : {};
8935
9030
  const observed = Array.isArray(chainEdges?.edges) ? chainEdges.edges : [];
@@ -8955,13 +9050,18 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
8955
9050
  // collectGraphInputs' cap flag rides its spread into this input (review finding: this was
8956
9051
  // hardcoded false, so the cap-reached banner could never fire on any surface).
8957
9052
  folders: foldersTruncated === true,
8958
- skills: Object.values(folders).some((f) => f?.truncated === true),
9053
+ skills: Object.values(folders).some((f) => f?.truncated === true) || overlay?.truncated === true || staged?.truncated === true,
8959
9054
  edges: chainEdges?.truncated === true
8960
9055
  },
8961
9056
  droppedObservedEdges: 0,
8962
9057
  // Injected dirs whose readdir failed (review finding): the OQ-022 badge is the one fact the
8963
9058
  // injected enumeration exists for, and an unreadable dir must not make it silently vanish.
8964
- injectedUnreachable: Object.entries(injected).filter(([, r]) => typeof r?.unreachable === "string").map(([dir]) => dir).sort()
9059
+ injectedUnreachable: Object.entries(injected).filter(([, r]) => typeof r?.unreachable === "string").map(([dir]) => dir).sort(),
9060
+ // The two tier-honesty counters (issue #188): an overlay whose skills/ failed to read, and the
9061
+ // staged packages whose pattern manifests defeat enumeration. Both feed the softened state
9062
+ // below AND a banner, because a tier the ladder silently skipped would read as "checked".
9063
+ overlayUnreachable: overlay?.unreachable === "unreadable",
9064
+ stagedUnenumerable: (Array.isArray(staged?.unenumerable) ? staged.unenumerable : []).filter((p) => typeof p === "string").sort()
8965
9065
  }
8966
9066
  };
8967
9067
  const folderByPath = /* @__PURE__ */ new Map();
@@ -9034,6 +9134,28 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
9034
9134
  }
9035
9135
  }
9036
9136
  }
9137
+ const overlayNodeId = /* @__PURE__ */ new Map();
9138
+ if (overlay) {
9139
+ for (const skill of overlay.skills) {
9140
+ if (typeof skill?.name !== "string" || skill.name === "" || overlayNodeId.has(skill.name)) continue;
9141
+ const id = `overlay:${skill.name}`;
9142
+ overlayNodeId.set(skill.name, id);
9143
+ model.nodes.push({ id, kind: "overlay", name: skill.name });
9144
+ }
9145
+ }
9146
+ const stagedNodeId = /* @__PURE__ */ new Map();
9147
+ if (staged) {
9148
+ const seenStagedIds = /* @__PURE__ */ new Set();
9149
+ for (const skill of staged.skills) {
9150
+ if (typeof skill?.name !== "string" || skill.name === "" || typeof skill?.dir !== "string" || skill.dir === "") continue;
9151
+ const id = `staged:${skill.dir}:${skill.name}`;
9152
+ if (!seenStagedIds.has(id)) {
9153
+ seenStagedIds.add(id);
9154
+ model.nodes.push({ id, kind: "staged", name: skill.name, package: typeof skill.package === "string" && skill.package !== "" ? skill.package : skill.dir });
9155
+ }
9156
+ if (!stagedNodeId.has(skill.name)) stagedNodeId.set(skill.name, id);
9157
+ }
9158
+ }
9037
9159
  const missingNode = (group, name) => {
9038
9160
  const key = `${group.key} ${name}`;
9039
9161
  let node = skillNode.get(key);
@@ -9044,6 +9166,14 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
9044
9166
  group.skillIds.push(node.id);
9045
9167
  return node;
9046
9168
  };
9169
+ const notAtHeadNode = (group, name, tiers) => {
9170
+ const node = missingNode(group, name);
9171
+ if (node.kind === "skill-missing") node.kind = "skill-not-at-head";
9172
+ if (node.kind === "skill-not-at-head") {
9173
+ node.tiersUnknown = [.../* @__PURE__ */ new Set([...Array.isArray(node.tiersUnknown) ? node.tiersUnknown : [], ...tiers])].sort();
9174
+ }
9175
+ return node;
9176
+ };
9047
9177
  for (const t of triggerList2) {
9048
9178
  if (!t || typeof t !== "object" || !Number.isInteger(t.index)) continue;
9049
9179
  const id = `trigger:${t.index}`;
@@ -9080,6 +9210,11 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
9080
9210
  pattern: isCron ? t.pattern ?? null : null,
9081
9211
  label: triggerMatchLabel(t),
9082
9212
  flow: t.flow ?? null,
9213
+ // A command trigger's dispatched extension command (issue #189), flow's mutually-exclusive
9214
+ // sibling. Carried on the node so the display half (issue #188) has the fact to render; nothing
9215
+ // in THIS module consumes it -- a command names no SKILL.md, so the config-edge machinery below
9216
+ // (guarded on `typeof t.flow === "string"`) correctly draws nothing for it.
9217
+ command: t.command ?? null,
9083
9218
  replicas: t.replicas ?? null,
9084
9219
  folderKey: group?.key ?? null,
9085
9220
  runs: Number.isInteger(stats?.runs) ? stats.runs : 0,
@@ -9110,9 +9245,41 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
9110
9245
  existing.isFlow = true;
9111
9246
  model.edges.push({ from: id, to: existing.id, kind: "config" });
9112
9247
  } else {
9113
- const target = missingNode(group, t.flow);
9114
- model.edges.push({ from: id, to: target.id, kind: "config" });
9115
- model.flags.push({ nodeId: id, flag: "no-skill", detail: `.pi/skills/${t.flow}/SKILL.md absent at HEAD` });
9248
+ const probes = [];
9249
+ if (typeof t.skillsDir === "string" && t.skillsDir !== "") {
9250
+ const r = injected[t.skillsDir];
9251
+ if (!r || typeof r.unreachable === "string") probes.push({ tier: TIER_INJECTED, state: "unknown" });
9252
+ else if ((Array.isArray(r.skills) ? r.skills : []).some((s) => s?.name === t.flow)) probes.push({ tier: TIER_INJECTED, state: "hit", nodeId: `injected:${t.skillsDir}:${t.flow}` });
9253
+ else probes.push({ tier: TIER_INJECTED, state: r.truncated === true ? "unknown" : "miss" });
9254
+ }
9255
+ if (overlay === null || typeof overlay.unreachable === "string") probes.push({ tier: TIER_OVERLAY, state: "unknown" });
9256
+ else if (overlayNodeId.has(t.flow)) probes.push({ tier: TIER_OVERLAY, state: "hit", nodeId: overlayNodeId.get(t.flow) });
9257
+ else probes.push({ tier: TIER_OVERLAY, state: overlay.truncated === true ? "unknown" : "miss" });
9258
+ if (t.packages === false) {
9259
+ probes.push({ tier: `${TIER_STAGED} (withheld: run.packages false)`, state: "miss" });
9260
+ } else if (staged === null) probes.push({ tier: TIER_STAGED, state: "unknown" });
9261
+ else if (stagedNodeId.has(t.flow)) probes.push({ tier: TIER_STAGED, state: "hit", nodeId: stagedNodeId.get(t.flow) });
9262
+ else probes.push({ tier: TIER_STAGED, state: staged.unenumerable.length > 0 || staged.truncated === true ? "unknown" : "miss" });
9263
+ let target = null;
9264
+ let blocked = false;
9265
+ for (const probe of probes) {
9266
+ if (probe.state === "hit" && !blocked) {
9267
+ target = probe.nodeId;
9268
+ break;
9269
+ }
9270
+ if (probe.state === "unknown") blocked = true;
9271
+ }
9272
+ if (target !== null) {
9273
+ model.edges.push({ from: id, to: target, kind: "config" });
9274
+ } else if (probes.some((p) => p.state === "unknown")) {
9275
+ const soft = notAtHeadNode(group, t.flow, probes.filter((p) => p.state === "unknown").map((p) => p.tier));
9276
+ model.edges.push({ from: id, to: soft.id, kind: "config" });
9277
+ } else {
9278
+ const target2 = missingNode(group, t.flow);
9279
+ model.edges.push({ from: id, to: target2.id, kind: "config" });
9280
+ const alsoChecked = probes.length > 0 ? `; also checked: ${probes.map((p) => p.tier).join(", ")}` : "";
9281
+ model.flags.push({ nodeId: id, flag: "no-skill", detail: `.pi/skills/${t.flow}/SKILL.md absent at HEAD${alsoChecked}` });
9282
+ }
9116
9283
  }
9117
9284
  } else if (group) {
9118
9285
  const target = missingNode(group, t.flow);
@@ -9215,7 +9382,7 @@ function clipName(name) {
9215
9382
  const s = String(name);
9216
9383
  return s.length > 64 ? `${s.slice(0, 64)}\u2026` : s;
9217
9384
  }
9218
- var FRONTMATTER_BLOCK_RE, META_VALUE_MAX_CHARS, CHAIN_VOCAB_RE, CHAIN_VOCAB_RADIUS, LOOP_HINT_RE, LOOP_HINT_MAX, GRAPH_EDGE_KINDS, GRAPH_FLAGS;
9385
+ var FRONTMATTER_BLOCK_RE, META_VALUE_MAX_CHARS, CHAIN_VOCAB_RE, CHAIN_VOCAB_RADIUS, LOOP_HINT_RE, LOOP_HINT_MAX, GRAPH_EDGE_KINDS, GRAPH_FLAGS, GRAPH_NODE_KINDS, TIER_INJECTED, TIER_OVERLAY, TIER_STAGED;
9219
9386
  var init_graph_model = __esm({
9220
9387
  "src/graph-model.mjs"() {
9221
9388
  init_flow_gate();
@@ -9228,7 +9395,7 @@ var init_graph_model = __esm({
9228
9395
  GRAPH_EDGE_KINDS = Object.freeze(["config", "observed", "potential", "cron-rearm"]);
9229
9396
  GRAPH_FLAGS = Object.freeze([
9230
9397
  "no-skill",
9231
- // config edge target absent at HEAD in an ENUMERATED folder (readFlowGate's precise token)
9398
+ // config edge target absent in EVERY checkable applicable tier (readFlowGate's precise token, now with the full ladder behind it)
9232
9399
  "charset-invalid",
9233
9400
  // run.flow fails SKILL_NAME_RE: can never materialise, a distinct defect from no-skill
9234
9401
  "orphan",
@@ -9242,6 +9409,26 @@ var init_graph_model = __esm({
9242
9409
  "pr-spend-loop-risk"
9243
9410
  // pull_request on opened/synchronize: the OQ-020 cross-actor spend-loop signature
9244
9411
  ]);
9412
+ GRAPH_NODE_KINDS = Object.freeze([
9413
+ "trigger",
9414
+ "skill",
9415
+ // committed at HEAD in an enumerated folder
9416
+ "skill-missing",
9417
+ // absent in every checkable applicable tier: the one red, dangling state
9418
+ "skill-unverified",
9419
+ // the folder itself was never readable (forge/remote): a read that never happened
9420
+ "skill-not-at-head",
9421
+ // absent at HEAD, but some lower tier was not checkable from this session
9422
+ "injected",
9423
+ // run.skillsDir working-tree skill: trigger-reachable, never AI-reachable
9424
+ "overlay",
9425
+ // deployment overlay skills/: trigger-reachable, never AI-reachable
9426
+ "staged"
9427
+ // a staged pi package's skill: trigger-reachable, never AI-reachable
9428
+ ]);
9429
+ TIER_INJECTED = "injected run.skillsDir";
9430
+ TIER_OVERLAY = "overlay skills/";
9431
+ TIER_STAGED = "staged packages";
9245
9432
  }
9246
9433
  });
9247
9434
 
@@ -9889,6 +10076,11 @@ async function enqueueDispatchRun({
9889
10076
  if (typeof flow !== "string" || flow.trim() === "") {
9890
10077
  return { refused: "no flow \u2014 a flow is required to trigger a run" };
9891
10078
  }
10079
+ if (flow.trim().startsWith("/")) {
10080
+ return {
10081
+ refused: `flow '${flow}' names a command, not a flow \u2014 commands are not AI-triggerable; a registered extension command runs only from a reviewed triggers.json entry (run.command)`
10082
+ };
10083
+ }
9892
10084
  const { valkeyUrl, dispatchRunRoots, dispatchRunPerHour } = resolvePaths(env);
9893
10085
  if (aiInvoked && !folderUnderRoots(folder, dispatchRunRoots)) {
9894
10086
  return { refused: "folder not under PI_DISPATCH_RUN_ROOTS" };
@@ -10098,8 +10290,8 @@ async function readBudget({ url, redisFn = makeRedisClient, timeoutMs = 2500 } =
10098
10290
  }
10099
10291
  function withTimeout(promise, ms, fallback) {
10100
10292
  let timer;
10101
- const timeout = new Promise((resolve) => {
10102
- timer = setTimeout(() => resolve(fallback), ms);
10293
+ const timeout = new Promise((resolve2) => {
10294
+ timer = setTimeout(() => resolve2(fallback), ms);
10103
10295
  if (typeof timer?.unref === "function") timer.unref();
10104
10296
  });
10105
10297
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
@@ -10193,6 +10385,7 @@ function normalizeTriggerForDisplay(entry) {
10193
10385
  if (on === null || typeof on !== "object") return null;
10194
10386
  const run = entry.run !== null && typeof entry.run === "object" ? entry.run : {};
10195
10387
  const flow = typeof run.flow === "string" ? run.flow : null;
10388
+ const command = typeof run.command === "string" ? run.command : null;
10196
10389
  const packages = run.packages !== false;
10197
10390
  const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
10198
10391
  const skillsDir = typeof run.skillsDir === "string" && run.skillsDir.trim() !== "" ? run.skillsDir : null;
@@ -10208,6 +10401,7 @@ function normalizeTriggerForDisplay(entry) {
10208
10401
  pattern: typeof on.pattern === "string" ? on.pattern : null,
10209
10402
  folder: typeof run.folder === "string" ? run.folder : null,
10210
10403
  flow,
10404
+ command,
10211
10405
  // Optional per-cron model override (passthrough into job.data); null when the entry resolves the
10212
10406
  // deployment default. Surfaced so the drill-in shows which schedules pin their own model.
10213
10407
  model: typeof run.model === "string" ? run.model : null,
@@ -10218,9 +10412,9 @@ function normalizeTriggerForDisplay(entry) {
10218
10412
  resume
10219
10413
  };
10220
10414
  case "label":
10221
- return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, skillsDir, instructions, resume, replicas, forge };
10415
+ return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, command, packages, image, skillsDir, instructions, resume, replicas, forge };
10222
10416
  case "comment":
10223
- return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, skillsDir, instructions, resume, replicas, forge };
10417
+ return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, replicas, forge };
10224
10418
  case "pull_request":
10225
10419
  return {
10226
10420
  type: "pull_request",
@@ -10229,6 +10423,7 @@ function normalizeTriggerForDisplay(entry) {
10229
10423
  all: normalizeSelector(on.all),
10230
10424
  none: normalizeSelector(on.none),
10231
10425
  flow,
10426
+ command,
10232
10427
  packages,
10233
10428
  image,
10234
10429
  skillsDir,
@@ -10476,8 +10671,69 @@ function readInjectedSkills({ skillsDir, fs = nodeFs2 } = {}) {
10476
10671
  }
10477
10672
  return { skills, truncated, unreachable: null };
10478
10673
  }
10479
- function collectGraphInputs({ triggers, readFolder = readFolderSkills, readInjected = readInjectedSkills } = {}) {
10480
- const out = { folderSkills: {}, injectedSkills: {}, foldersTruncated: false };
10674
+ function readOverlaySkills({ globalPiDir, fs = nodeFs2 } = {}) {
10675
+ if (typeof globalPiDir !== "string" || globalPiDir === "") return { skills: [], truncated: false, unreachable: null };
10676
+ const dir = join2(globalPiDir, "skills");
10677
+ let names;
10678
+ try {
10679
+ names = fs.readdirSync(dir);
10680
+ } catch (err) {
10681
+ if (err?.code === "ENOENT") return { skills: [], truncated: false, unreachable: null };
10682
+ return { skills: [], truncated: false, unreachable: "unreadable" };
10683
+ }
10684
+ const valid = names.filter((n) => SKILL_NAME_RE.test(n)).sort();
10685
+ const truncated = valid.length > GRAPH_LIMITS.maxSkillsPerFolder;
10686
+ const skills = [];
10687
+ for (const name of valid.slice(0, GRAPH_LIMITS.maxSkillsPerFolder)) {
10688
+ let present = false;
10689
+ try {
10690
+ present = fs.existsSync(join2(dir, name, "SKILL.md"));
10691
+ } catch {
10692
+ present = false;
10693
+ }
10694
+ if (present) skills.push({ name });
10695
+ }
10696
+ return { skills, truncated, unreachable: null };
10697
+ }
10698
+ function readStagedSkillsList({ globalPiDir, fs = nodeFs2, readStaged = readStagedSkills } = {}) {
10699
+ const empty = { skills: [], unenumerable: [], truncated: false };
10700
+ if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
10701
+ let result;
10702
+ try {
10703
+ result = readStaged({
10704
+ globalPiDir,
10705
+ readFile: (path) => fs.readFileSync(path, "utf8"),
10706
+ fileExists: (path) => fs.existsSync(path),
10707
+ readDir: (path, opts) => fs.readdirSync(path, opts)
10708
+ });
10709
+ } catch {
10710
+ return empty;
10711
+ }
10712
+ const seen = /* @__PURE__ */ new Set();
10713
+ const skills = [];
10714
+ for (const s of Array.isArray(result?.skills) ? result.skills : []) {
10715
+ if (typeof s?.name !== "string" || !SKILL_NAME_RE.test(s.name)) continue;
10716
+ if (typeof s.dir !== "string" || s.dir === "") continue;
10717
+ const key = `${s.dir} ${s.name}`;
10718
+ if (seen.has(key)) continue;
10719
+ seen.add(key);
10720
+ skills.push({ name: s.name, package: typeof s.package === "string" && s.package !== "" ? s.package : s.dir, dir: s.dir });
10721
+ }
10722
+ return {
10723
+ skills: skills.slice(0, GRAPH_LIMITS.maxStagedSkills),
10724
+ unenumerable: (Array.isArray(result?.unenumerable) ? result.unenumerable : []).filter((p) => typeof p === "string").sort(),
10725
+ truncated: skills.length > GRAPH_LIMITS.maxStagedSkills
10726
+ };
10727
+ }
10728
+ function collectGraphInputs({
10729
+ triggers,
10730
+ globalPiDir = null,
10731
+ readFolder = readFolderSkills,
10732
+ readInjected = readInjectedSkills,
10733
+ readOverlay: readOverlay2 = readOverlaySkills,
10734
+ readStaged = readStagedSkillsList
10735
+ } = {}) {
10736
+ const out = { folderSkills: {}, injectedSkills: {}, overlaySkills: null, stagedSkills: null, foldersTruncated: false };
10481
10737
  if (!Array.isArray(triggers)) return out;
10482
10738
  const folders = [];
10483
10739
  const injectedDirs = [];
@@ -10492,6 +10748,10 @@ function collectGraphInputs({ triggers, readFolder = readFolderSkills, readInjec
10492
10748
  for (const dir of injectedDirs.slice(0, GRAPH_LIMITS.maxFoldersScanned)) {
10493
10749
  out.injectedSkills[dir] = readInjected({ skillsDir: dir });
10494
10750
  }
10751
+ if (typeof globalPiDir === "string" && globalPiDir !== "") {
10752
+ out.overlaySkills = readOverlay2({ globalPiDir });
10753
+ out.stagedSkills = readStaged({ globalPiDir });
10754
+ }
10495
10755
  return out;
10496
10756
  }
10497
10757
  function forgeRepoTargets({ records } = {}) {
@@ -10582,8 +10842,10 @@ var init_read_model = __esm({
10582
10842
  // distinct observed chain edges kept; beyond this the graph says "truncated"
10583
10843
  windowDays: 30,
10584
10844
  // run-record window the graph folds; matches the default PI_LOG_RETENTION_DAYS
10585
- maxReposListed: 5
10845
+ maxReposListed: 5,
10586
10846
  // repos named per forge group, from record targets; a scope label, not an inventory
10847
+ maxStagedSkills: 64
10848
+ // staged-package skills kept for display; one stage read per graph build
10587
10849
  });
10588
10850
  GRAPH_LS_TREE_MAX_BYTES = 1 << 20;
10589
10851
  }
@@ -10637,7 +10899,11 @@ function normalizeModel(model) {
10637
10899
  // dropped (issue #175): three surfaces of one model must not disagree about what was refused
10638
10900
  // or unreadable. Sorted and clipped like every other array on this page.
10639
10901
  chainRefusals: (m.meta?.chainRefusals && typeof m.meta.chainRefusals === "object" ? Object.entries(m.meta.chainRefusals) : []).flatMap(([scope, count]) => typeof scope === "string" && intOr(count, 0) > 0 ? [{ scope: clip(scope, 80), count: intOr(count, 0) }] : []).sort((a, b) => cmpStr(a.scope, b.scope)),
10640
- injectedUnreachable: (Array.isArray(m.meta?.injectedUnreachable) ? m.meta.injectedUnreachable : []).filter((d) => typeof d === "string").map((d) => clip(d, 120)).sort().slice(0, 8)
10902
+ injectedUnreachable: (Array.isArray(m.meta?.injectedUnreachable) ? m.meta.injectedUnreachable : []).filter((d) => typeof d === "string").map((d) => clip(d, 120)).sort().slice(0, 8),
10903
+ // The two tier-honesty counters (issue #188), the injectedUnreachable rule applied to the
10904
+ // deployment-wide tiers: a tier the ladder silently skipped would read as "checked".
10905
+ overlayUnreachable: m.meta?.overlayUnreachable === true,
10906
+ stagedUnenumerable: (Array.isArray(m.meta?.stagedUnenumerable) ? m.meta.stagedUnenumerable : []).filter((d) => typeof d === "string").map((d) => clip(d, 80)).sort().slice(0, 8)
10641
10907
  };
10642
10908
  const folders = [];
10643
10909
  for (const f of Array.isArray(m.folders) ? m.folders : []) {
@@ -10671,6 +10937,13 @@ function normalizeModel(model) {
10671
10937
  onType: typeof n.onType === "string" ? n.onType : null,
10672
10938
  pattern: typeof n.pattern === "string" ? n.pattern : null,
10673
10939
  flow: typeof n.flow === "string" ? n.flow : null,
10940
+ // A command trigger's slash command (issue #189's node fact, #188's rendering): the tip is the
10941
+ // detail surface, so the full command rides, clipped like every other string on this page.
10942
+ command: typeof n.command === "string" ? clip(n.command, 80) : null,
10943
+ // The staged tier node's owning package, and the softened state's evidence: which tiers this
10944
+ // session could not check. Both clipped/sorted/capped so a hostile model cannot inflate a tip.
10945
+ package: typeof n.package === "string" ? clip(n.package, 80) : null,
10946
+ tiersUnknown: Array.isArray(n.tiersUnknown) ? [...new Set(n.tiersUnknown.filter((t) => typeof t === "string").map((t) => clip(t, 40)))].sort().slice(0, 4) : [],
10674
10947
  replicas: intOr(n.replicas, null),
10675
10948
  folderKey: typeof n.folderKey === "string" ? n.folderKey : null,
10676
10949
  runs: intOr(n.runs, 0),
@@ -10751,6 +11024,8 @@ function layoutNormalized(norm) {
10751
11024
  placedByOrig.set(n.id, p);
10752
11025
  let g = n.folderKey === null ? null : groupIndexByKey.get(n.folderKey) ?? null;
10753
11026
  if (!g && n.kind === "injected") g = groupIndexByKey.get("~injected") ?? addGroup("~injected", "injected skills (run.skillsDir)", "local", null, null);
11027
+ if (!g && n.kind === "overlay") g = groupIndexByKey.get("~overlay") ?? addGroup("~overlay", "overlay skills (global pi dir)", "local", null, null);
11028
+ if (!g && n.kind === "staged") g = groupIndexByKey.get("~staged") ?? addGroup("~staged", "staged package skills", "local", null, null);
10754
11029
  if (!g) g = groupIndexByKey.get(n.folderKey ?? "~ungrouped") ?? addGroup(n.folderKey ?? "~ungrouped", "ungrouped", "local", null, null);
10755
11030
  g.members.push(p);
10756
11031
  p.groupId = g.id;
@@ -11080,13 +11355,23 @@ function buildTip(n, flags, groupLabel, nowMs) {
11080
11355
  if (n.kind === "trigger") {
11081
11356
  lines.push(`trigger \xB7 ${n.onType ?? "?"} \xB7 ${n.label ?? ""}`.trim());
11082
11357
  if (n.flow !== null) lines.push(`flow: ${n.flow}${n.replicas !== null ? ` \xD7${n.replicas}` : ""}`);
11358
+ else if (n.command !== null) lines.push(`command: /${n.command}${n.replicas !== null ? ` \xD7${n.replicas}` : ""}`);
11083
11359
  } else if (n.kind === "injected") {
11084
11360
  lines.push(`injected skill \xB7 ${n.name ?? "?"}`);
11085
11361
  lines.push("trigger-reachable via run.skillsDir, never AI-reachable");
11362
+ } else if (n.kind === "overlay") {
11363
+ lines.push(`overlay skill \xB7 ${n.name ?? "?"}`);
11364
+ lines.push("deployment overlay skills/, trigger-reachable, never AI-reachable");
11365
+ } else if (n.kind === "staged") {
11366
+ lines.push(`staged skill \xB7 ${n.name ?? "?"}${n.package !== null ? ` \xB7 package ${n.package}` : ""}`);
11367
+ lines.push("staged pi package, trigger-reachable, never AI-reachable");
11086
11368
  } else if (n.kind === "skill-missing") {
11087
11369
  lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 missing at HEAD`);
11088
11370
  } else if (n.kind === "skill-unverified") {
11089
11371
  lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 unverified (repo not readable from this host)`);
11372
+ } else if (n.kind === "skill-not-at-head") {
11373
+ lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 not committed at HEAD`);
11374
+ if (n.tiersUnknown.length > 0) lines.push(`not checkable from this session: ${n.tiersUnknown.join(", ")}`);
11090
11375
  } else {
11091
11376
  lines.push(`skill \xB7 ${n.name ?? "?"}${n.isSub ? " \xB7 sub-skill (never a flow)" : ""}`);
11092
11377
  }
@@ -11133,6 +11418,7 @@ function nodeState(n, flagNames) {
11133
11418
  return { stroke: DANGER, dash: "10,4", faded: false };
11134
11419
  }
11135
11420
  if (n.kind === "skill-unverified") return { stroke: PAGE_DIM, dash: "10,4", faded: false };
11421
+ if (n.kind === "skill-not-at-head") return { stroke: PAGE_AMBER, dash: "10,4", faded: false };
11136
11422
  if (flagNames.has("orphan")) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
11137
11423
  return { stroke: CHIP_STROKE, dash: null, faded: false };
11138
11424
  }
@@ -11248,7 +11534,8 @@ function legendHtml(norm) {
11248
11534
  row(legendSwatch(`<circle cx="8" cy="6" r="5" fill="${BADGE_ORANGE}"/>`), "unread / injected ai-trigger / PR spend-loop risk");
11249
11535
  row(legendSwatch(`<circle cx="8" cy="6" r="4" fill="none" stroke="${BADGE_GREEN}" stroke-width="2"/>`), "chainable: ai-trigger allow");
11250
11536
  row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${CHIP_STROKE}" stroke-dasharray="8,3"/>`), "orphan: no trigger, no ai-trigger, no mention");
11251
- row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${DANGER}" stroke-dasharray="10,4"/>`), "dangling: flow absent or name invalid");
11537
+ row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${DANGER}" stroke-dasharray="10,4"/>`), "dangling: absent in every checkable tier or name invalid");
11538
+ row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${PAGE_AMBER}" stroke-dasharray="10,4"/>`), "not at HEAD: some skill tiers not checkable from this session");
11252
11539
  row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${PAGE_DIM}" stroke-dasharray="10,4"/>`), "unverified: repo not readable from this host");
11253
11540
  rows.push(`<div class="caps">${escapeHtml(capsLineText(norm.caps))}</div>`);
11254
11541
  const honesty = [];
@@ -11260,6 +11547,8 @@ function legendHtml(norm) {
11260
11547
  const refused = norm.meta.chainRefusals.reduce((a, r) => a + r.count, 0);
11261
11548
  if (refused > 0) honesty.push(`${refused} chain requests refused (caps or gate)`);
11262
11549
  if (norm.meta.injectedUnreachable.length > 0) honesty.push(`injected skills dir unreadable: ${norm.meta.injectedUnreachable.join(", ")}`);
11550
+ if (norm.meta.overlayUnreachable) honesty.push("overlay skills dir unreadable (global pi dir)");
11551
+ if (norm.meta.stagedUnenumerable.length > 0) honesty.push(`staged packages not enumerable (manifest patterns): ${norm.meta.stagedUnenumerable.join(", ")}`);
11263
11552
  for (const line of honesty) rows.push(`<div class="honesty">${escapeHtml(line)}</div>`);
11264
11553
  return `<div id="legend">${rows.join("")}</div>`;
11265
11554
  }
@@ -11406,7 +11695,10 @@ var init_graph_html = __esm({
11406
11695
  skill: "\u0192",
11407
11696
  "skill-missing": "!",
11408
11697
  "skill-unverified": "?",
11409
- injected: "+"
11698
+ "skill-not-at-head": "\u22EF",
11699
+ injected: "+",
11700
+ overlay: "\u25CE",
11701
+ staged: "\u25A3"
11410
11702
  });
11411
11703
  ORANGE_FLAGS = /* @__PURE__ */ new Set(["unread", "injected-ai-trigger", "pr-spend-loop-risk"]);
11412
11704
  DANGLING_FLAGS = /* @__PURE__ */ new Set(["no-skill", "charset-invalid"]);
@@ -38371,7 +38663,7 @@ var init_all = __esm({
38371
38663
 
38372
38664
  // ../worker/src/pricing.mjs
38373
38665
  import { readFileSync as readFileSync2 } from "node:fs";
38374
- import { dirname as dirname2, join as join4 } from "node:path";
38666
+ import { dirname as dirname3, join as join4 } from "node:path";
38375
38667
  import { fileURLToPath } from "node:url";
38376
38668
  function listPricedModels() {
38377
38669
  const out = [];
@@ -38400,7 +38692,7 @@ function readTextFromDisk(path) {
38400
38692
  }
38401
38693
  function readPiAiVersion(readText) {
38402
38694
  try {
38403
- let dir = dirname2(fileURLToPath(import.meta.resolve("@earendil-works/pi-ai")));
38695
+ let dir = dirname3(fileURLToPath(import.meta.resolve("@earendil-works/pi-ai")));
38404
38696
  for (let hops = 0; hops < 8; hops++) {
38405
38697
  try {
38406
38698
  const parsed = JSON.parse(readText(join4(dir, "package.json")));
@@ -38410,7 +38702,7 @@ function readPiAiVersion(readText) {
38410
38702
  }
38411
38703
  } catch {
38412
38704
  }
38413
- const parent = dirname2(dir);
38705
+ const parent = dirname3(dir);
38414
38706
  if (parent === dir) break;
38415
38707
  dir = parent;
38416
38708
  }
@@ -38455,7 +38747,7 @@ var init_pricing = __esm({
38455
38747
  });
38456
38748
 
38457
38749
  // ../worker/src/sandbox-store.mjs
38458
- import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
38750
+ import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
38459
38751
  import { isAbsolute as isAbsolute2, join as join5, relative } from "node:path";
38460
38752
  function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
38461
38753
  if (!sandboxDir || jobId === void 0 || jobId === null) return null;
@@ -38472,7 +38764,7 @@ var init_sandbox_store = __esm({
38472
38764
  "../worker/src/sandbox-store.mjs"() {
38473
38765
  init_run_history();
38474
38766
  SANDBOX_MANIFEST = "manifest.json";
38475
- defaultFs = { lstatSync, mkdirSync, readFileSync: readFileSync3, readdirSync, renameSync, rmSync, writeFileSync };
38767
+ defaultFs = { lstatSync, mkdirSync, readFileSync: readFileSync3, readdirSync: readdirSync2, renameSync, rmSync, writeFileSync };
38476
38768
  }
38477
38769
  });
38478
38770
 
@@ -38483,7 +38775,7 @@ import { promisify as promisify4 } from "node:util";
38483
38775
  function sandboxContainerName(jobId) {
38484
38776
  return `${SANDBOX_NAME_PREFIX}${sanitizeJobId(jobId)}`;
38485
38777
  }
38486
- function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0 }) {
38778
+ function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0, network = null, egressEnv: proxyEnv = {} }) {
38487
38779
  return buildDockerRunArgs({
38488
38780
  image,
38489
38781
  name,
@@ -38492,9 +38784,20 @@ function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], ter
38492
38784
  // The ONLY two variables, and neither is a credential. TERM so the shell renders; TMOUT so a
38493
38785
  // forgotten session closes itself. `buildDockerRunArgs` skips undefined, so an unset TERM or a
38494
38786
  // disabled idle timeout emits nothing rather than an empty string.
38787
+ // A sandbox joins the SAME kind of network a job did, by the same builder, so the boundary cannot
38788
+ // land on job containers and miss this one. Leaving sandboxes on the default bridge was the tempting
38789
+ // alternative and it is the wrong one: it reads as a convenience (install a missing dependency while
38790
+ // debugging) and it is a WIDER reach than the run the sandbox exists to reproduce. A shell that can
38791
+ // go where the run could not is not reproducing the run. Nothing an operator wants is lost, because
38792
+ // the forge and the registry are on the allowlist a job needed anyway.
38793
+ network,
38495
38794
  env: {
38496
38795
  TERM: term || void 0,
38497
- TMOUT: idleSeconds > 0 ? String(idleSeconds) : void 0
38796
+ TMOUT: idleSeconds > 0 ? String(idleSeconds) : void 0,
38797
+ // Still NO CREDENTIALS, and that clause is untouched: a proxy URL is not a credential, and
38798
+ // buildContainerEnv is still not reused here. The env is two variables about the terminal and,
38799
+ // when a policy is armed, three about the network.
38800
+ ...proxyEnv
38498
38801
  },
38499
38802
  // Ahead of the env and the mounts, and well ahead of the image, which buildDockerRunArgs keeps as
38500
38803
  // the final positional. `--entrypoint` also clears the image's CMD; this repo's Dockerfile sets
@@ -38518,10 +38821,10 @@ function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, f
38518
38821
  return { manifest, name: sandboxContainerName(jobId), publish };
38519
38822
  }
38520
38823
  function launchSandbox({ args, spawnFn = spawn }) {
38521
- return new Promise((resolve) => {
38824
+ return new Promise((resolve2) => {
38522
38825
  const child = spawnFn("docker", args, { stdio: "inherit" });
38523
- child.on("error", (err) => resolve({ code: null, error: err }));
38524
- child.on("close", (code) => resolve({ code }));
38826
+ child.on("error", (err) => resolve2({ code: null, error: err }));
38827
+ child.on("close", (code) => resolve2({ code }));
38525
38828
  });
38526
38829
  }
38527
38830
  var exec3, SANDBOX_NAME_PREFIX;
@@ -38606,7 +38909,7 @@ function renderTriggers({ schedulers, triggers } = {}) {
38606
38909
  return out.join("\n");
38607
38910
  }
38608
38911
  function triggerLine(t) {
38609
- const flow = t?.flow ?? "-";
38912
+ const flow = t?.flow ?? commandSlashLabel(t) ?? "-";
38610
38913
  const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
38611
38914
  const pkgs = t?.packages === true ? " [packages]" : "";
38612
38915
  const img = t?.image ? ` [image ${t.image}]` : "";
@@ -38630,6 +38933,9 @@ function triggerLine(t) {
38630
38933
  return "(unknown trigger)";
38631
38934
  }
38632
38935
  }
38936
+ function commandSlashLabel(t) {
38937
+ return typeof t?.command === "string" && t.command.trim() !== "" ? `/${t.command.trim().split(/\s+/)[0]}` : null;
38938
+ }
38633
38939
  function ruleClauses(rule) {
38634
38940
  const clauses = [];
38635
38941
  for (const key of ["any", "all", "none"]) {
@@ -39574,7 +39880,7 @@ function matchColored(t, styler) {
39574
39880
  }
39575
39881
  function targetColored(t, styler) {
39576
39882
  const arrow = styler.fg("dim", "\u2192");
39577
- const flow = styler.bold(styler.fg("text", t?.flow ?? "-"));
39883
+ const flow = styler.bold(styler.fg("text", t?.flow ?? commandSlashLabel(t) ?? "-"));
39578
39884
  if (t?.type === "cron") {
39579
39885
  const base = t.folder ? String(t.folder).split(/[/\\]/).filter(Boolean).pop() ?? "" : "";
39580
39886
  const folderPart = base ? styler.fg("muted", base) + styler.fg("dim", "/") : "";
@@ -39678,7 +39984,7 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
39678
39984
  const kv = (k, v, color = "text") => fitLine(styler.cell(k, 12, { color: "muted" }) + " " + styler.fg(color, v), inner, styler);
39679
39985
  const blank = () => out.push(styler.cell("", inner));
39680
39986
  const section = (label) => out.push(styler.divider(label, null, inner));
39681
- let header = styler.fg(KIND_COLOR[t.type] ?? "muted", t.type ?? "?") + " " + styler.bold(styler.fg("text", `\u2192 ${t.flow ?? "-"}`));
39987
+ let header = styler.fg(KIND_COLOR[t.type] ?? "muted", t.type ?? "?") + " " + styler.bold(styler.fg("text", `\u2192 ${t.flow ?? commandSlashLabel(t) ?? "-"}`));
39682
39988
  if (t.type === "cron" && sched) {
39683
39989
  const healthy = !sched.overdueMs;
39684
39990
  header += " " + (healthy ? styler.fg("success", "\u2714 healthy") : styler.fg("warning", `\u26A0 overdue ${formatDuration(sched.overdueMs)}`));
@@ -39714,6 +40020,7 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
39714
40020
  out.push(kv("target", forgeTargetLabel(t?.forge), "accent"));
39715
40021
  out.push(kv("model", "deployment default", "dim"));
39716
40022
  }
40023
+ if (typeof t.command === "string" && t.command.trim() !== "") out.push(kv("command", `/${t.command.trim()}`, "accent"));
39717
40024
  out.push(kv("image", t.image ?? "deployment default", t.image ? "accent" : "dim"));
39718
40025
  out.push(kv("resume", t.resume === true ? "continues the previous session" : "cold start", t.resume === true ? "warning" : "dim"));
39719
40026
  out.push(kv("replicas", t.replicas > 1 ? `${t.replicas} sandboxes race this flow` : "one run per delivery", t.replicas > 1 ? "warning" : "dim"));
@@ -39942,7 +40249,11 @@ function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
39942
40249
  out.push(kv("chain", chainBits.join(" \xB7 ")));
39943
40250
  if (r.replica > 0) {
39944
40251
  const sibs = (Array.isArray(allRuns) ? allRuns : []).filter(
39945
- (x) => x?.replica > 0 && x.replica !== r.replica && x.target === r.target && x.flow === r.flow
40252
+ // `kind` is load-bearing since #187, not decoration: `target` is repo + separator + number, and
40253
+ // targetFor renders github and forgejo with the SAME `#`, so a github `o/r#5` and a forgejo `o/r#5`
40254
+ // are one string. While only github could replicate, no two replica runs could collide; now they can,
40255
+ // and naming a stranger's job as your sibling is worse than naming none.
40256
+ (x) => x?.replica > 0 && x.replica !== r.replica && x.kind === r.kind && x.target === r.target && x.flow === r.flow
39946
40257
  );
39947
40258
  const repBits = [`r${r.replica}/${r.replicas ?? "?"}`];
39948
40259
  repBits.push(sibs.length > 0 ? `sibling ${sibs.map((s) => `r${s.replica} ${s.jobId}`).join(", ")}` : "no sibling in this window");
@@ -40096,16 +40407,16 @@ async function runAttached(ctx, { title, argv0, args, cwd, env, shell, spawnFn =
40096
40407
  ${title}
40097
40408
 
40098
40409
  `);
40099
- outcome = await new Promise((resolve) => {
40410
+ outcome = await new Promise((resolve2) => {
40100
40411
  let child;
40101
40412
  try {
40102
40413
  child = spawnFn(argv0, args, { stdio: "inherit", cwd, env, ...shell ? { shell: true } : {} });
40103
40414
  } catch (err) {
40104
- resolve({ code: null, error: err });
40415
+ resolve2({ code: null, error: err });
40105
40416
  return;
40106
40417
  }
40107
- child.on("error", (err) => resolve({ code: null, error: err }));
40108
- child.on("close", (code) => resolve({ code }));
40418
+ child.on("error", (err) => resolve2({ code: null, error: err }));
40419
+ child.on("close", (code) => resolve2({ code }));
40109
40420
  });
40110
40421
  } catch (err) {
40111
40422
  outcome = { code: null, error: err };
@@ -40556,8 +40867,8 @@ var init_setup_wizard = __esm({
40556
40867
  init_deployment_pointer();
40557
40868
  init_read_model();
40558
40869
  init_index();
40559
- RUNTIME_VERSION = "0.3.0";
40560
- RECEIVER_VERSION = "0.2.1";
40870
+ RUNTIME_VERSION = "1.1.0";
40871
+ RECEIVER_VERSION = "1.1.0";
40561
40872
  RUNTIME_PKG = "@edgehero/pi-dispatch";
40562
40873
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
40563
40874
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -40699,7 +41010,7 @@ function registerTools(pi) {
40699
41010
  pi.registerTool({
40700
41011
  name: "dispatch_run",
40701
41012
  label: "pi-dispatch run",
40702
- description: "Enqueues a PAID pi-dispatch agent run against a local folder, editing it in place with no undo. Only folders under the operator's PI_DISPATCH_RUN_ROOTS, and only flows whose .pi/skills/<flow>/SKILL.md (at HEAD) sets ai-trigger: allow, can be started. Refuses a dirty git working tree \u2014 no force option. Rate-limited per hour.",
41013
+ description: "Enqueues a PAID pi-dispatch agent run against a local folder, editing it in place with no undo. Only folders under the operator's PI_DISPATCH_RUN_ROOTS, and only flows whose .pi/skills/<flow>/SKILL.md (at HEAD) sets ai-trigger: allow, can be started. Refuses a dirty git working tree \u2014 no force option. Rate-limited per hour. Flows only: a registered extension command (a run.command trigger) is never AI-triggerable and a /name flow is refused.",
40703
41014
  executionMode: "sequential",
40704
41015
  parameters: typebox_exports.Object({ folder: typebox_exports.String(), flow: typebox_exports.String(), task: typebox_exports.String() }),
40705
41016
  async execute(_toolCallId, params) {
@@ -40762,12 +41073,16 @@ function registerTools(pi) {
40762
41073
  pi.registerTool({
40763
41074
  name: "dispatch_trigger_add",
40764
41075
  label: "pi-dispatch add trigger",
40765
- description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
41076
+ description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved, forgejo is label_updated|opened|synchronized|reopened, azure is created|updated. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
40766
41077
  executionMode: "sequential",
40767
41078
  parameters: typebox_exports.Object({
40768
41079
  kind: typebox_exports.String(),
40769
41080
  flow: typebox_exports.String(),
40770
41081
  forge: typebox_exports.Optional(typebox_exports.String()),
41082
+ // buildTriggerEntry has always READ `f.repository`, and the schema has always stripped it -- so an
41083
+ // azure label/comment trigger was unauthorable by the model, refused at the write for want of a field
41084
+ // it had no way to send. Surfaced by #187 widening what the description above must say about forges.
41085
+ repository: typebox_exports.Optional(typebox_exports.String()),
40771
41086
  id: typebox_exports.Optional(typebox_exports.String()),
40772
41087
  pattern: typebox_exports.Optional(typebox_exports.String()),
40773
41088
  folder: typebox_exports.Optional(typebox_exports.String()),
@@ -41186,7 +41501,7 @@ async function assembleGraph(paths) {
41186
41501
  return buildGraphModel({
41187
41502
  triggers,
41188
41503
  schedulers: Array.isArray(schedulers) ? schedulers : [],
41189
- ...collectGraphInputs({ triggers: triggerList2 }),
41504
+ ...collectGraphInputs({ triggers: triggerList2, globalPiDir: paths.globalPiDir }),
41190
41505
  cronStats: cronRunStats({ records: recs, schedulerIds: triggerList2.filter((t) => t.type === "cron" && typeof t.id === "string").map((t) => t.id) }),
41191
41506
  runJoin: joinRunsToTriggers({ records: recs, triggerCount: triggers?.count, triggerTypes: Object.fromEntries(triggerList2.map((t) => [t.index, t.type])) }),
41192
41507
  chainEdges: observedChainEdges({ records: recs }),
@@ -41395,7 +41710,7 @@ could not start docker: ${error.message}
41395
41710
  }
41396
41711
  }
41397
41712
  function pauseForMessage() {
41398
- return new Promise((resolve) => setTimeout(resolve, 2500));
41713
+ return new Promise((resolve2) => setTimeout(resolve2, 2500));
41399
41714
  }
41400
41715
  function splitWords(s) {
41401
41716
  return String(s ?? "").split(/\s+/).map((w) => w.trim()).filter(Boolean);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-admin",
3
- "version": "0.8.0",
3
+ "version": "1.1.0",
4
4
  "description": "Operator console (a pi extension) + skill for pi-dispatch: run the pi coding agent as a self-hosted service. A /dispatch TUI for the queue, spend caps, run history, editable GitHub, GitLab, Forgejo and Azure DevOps triggers (cron/label/comment/PR/MR/work item), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -122,14 +122,16 @@ job time; and `pi-dispatch doctor` lists every image their triggers name and fla
122
122
 
123
123
  ## The forge a trigger listens to — `run.kind`
124
124
 
125
- A webhook trigger names its forge: `"kind": "github"` or `"kind": "gitlab"`. Everything else about the
126
- trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `flow`, `packages`, `image`.
125
+ A webhook trigger names its forge: `"kind"` is `github`, `gitlab`, `forgejo` or `azure`. Everything else
126
+ about the trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `flow`, `packages`,
127
+ `image`, `replicas`.
127
128
 
128
- Two things are NOT the same, and both refuse at load rather than misbehaving quietly:
129
+ Three things are NOT the same, and all of them refuse at load rather than misbehaving quietly:
129
130
 
130
131
  - **`pull_request` actions are the forge's own words.** GitHub takes
131
132
  `labeled | opened | synchronize | reopened | review_submitted`; GitLab takes
132
- `open | update | reopen | approved`. A word
133
+ `open | update | reopen | approved`; Forgejo takes
134
+ `label_updated | opened | synchronized | reopened`; Azure takes `created | updated`. A word
133
135
  from the wrong forge is refused when the file is written. It would not break anything otherwise — it
134
136
  would simply never match an event, and the trigger would look configured while doing nothing.
135
137
  `review_submitted` is GitHub's `pull_request_review` event: it fires on **every** submitted review, so
@@ -138,6 +140,29 @@ Two things are NOT the same, and both refuse at load rather than misbehaving qui
138
140
  load, because a narrowing that cannot apply reads as one that does.
139
141
  - **One `comment` trigger per forge.** Two GitHub comment triggers are refused; one GitHub and one GitLab
140
142
  are fine.
143
+ - **Azure names no repository.** A work item belongs to a project, so an azure `label` or `comment` trigger
144
+ MUST set `run.repository` (the repo within the project to clone) and every other forge's must not. An
145
+ azure `pull_request` trigger may not carry a label predicate at all: Azure tags work items, never pull
146
+ requests, so `any`/`all`/`none` could never match and a rule that loads clean and never fires reads as a
147
+ broken harness.
148
+
149
+ ## Racing two agents on one event — `run.replicas`
150
+
151
+ `"replicas": 2` on a `label`, `comment` or `pull_request` trigger turns one delivery into **two independent
152
+ paid jobs**: two containers, two branches (`pi/issue-7-r1` and `-r2`), two review requests, one human
153
+ picking. It works on every forge. Absent, nothing changes.
154
+
155
+ Three things to say when an operator asks about it:
156
+
157
+ - **It multiplies spend, and the caps are the only ceiling.** Each replica reserves its own budget slot
158
+ before its own tokens, so a `replicas: 2` trigger firing ten times a day consumes twenty slots of the
159
+ daily cap, not ten. Nothing is discounted; that is the feature, not an oversight.
160
+ - **You cannot set it from here.** There is no `replicas` parameter on `dispatch_trigger_add` or
161
+ `_edit`, and no panel key. It is a reviewed edit to `triggers.json`, deliberately: a spend multiplier is
162
+ plainly a capability a model should not gain. Say so rather than looking for a way around it.
163
+ - **It refuses on cron, and beside `resume`.** A local job's `/workspace` IS the operator's folder, so two
164
+ replicas would edit one working tree with no gate and no undo. And a resumed run continues one lineage
165
+ where replicas exist to fork it.
141
166
 
142
167
  `dispatch_trigger_add` takes an optional `forge` parameter, defaulting to `github`. Unlike `image`, this
143
168
  one IS offered to the model — a model that can already add a GitHub trigger can already arm a paid run,