@edgehero/pi-dispatch-admin 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -6
  2. package/dist/index.mjs +344 -55
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -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
@@ -7927,13 +7927,14 @@ function normalizeCron(on, run, index, path, state) {
7927
7927
  if (fieldCount !== 5 && fieldCount !== 6) {
7928
7928
  throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
7929
7929
  }
7930
+ const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
7930
7931
  if (!isNonEmptyString2(run.folder)) {
7931
7932
  throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
7932
7933
  }
7933
- if (!isNonEmptyString2(run.flow)) {
7934
- throw configError(`cron trigger "${id}": run.flow must be a non-empty string: ${path}`);
7934
+ if (command === void 0 && !isNonEmptyString2(run.flow)) {
7935
+ throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
7935
7936
  }
7936
- if (!isNonEmptyString2(run.task)) {
7937
+ if (command === void 0 && !isNonEmptyString2(run.task)) {
7937
7938
  throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
7938
7939
  }
7939
7940
  if (run.github !== void 0 && typeof run.github !== "boolean") {
@@ -7947,7 +7948,7 @@ function normalizeCron(on, run, index, path, state) {
7947
7948
  validateReplicas(run, `cron trigger "${id}"`, path);
7948
7949
  return {
7949
7950
  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 } }
7951
+ 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
7952
  };
7952
7953
  }
7953
7954
  function validatePackagesFlag(run, at, path) {
@@ -8006,6 +8007,35 @@ function validateInstructions(run, at, path, { cron = false } = {}) {
8006
8007
  }
8007
8008
  return text;
8008
8009
  }
8010
+ function validateCommand(run, at, path, { onType }) {
8011
+ const raw = run.command;
8012
+ if (run.flow !== void 0 && raw !== void 0) {
8013
+ 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}`);
8014
+ }
8015
+ if (raw === void 0) return void 0;
8016
+ if (!isNonEmptyString2(raw)) {
8017
+ throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
8018
+ }
8019
+ if (raw !== raw.trim()) {
8020
+ 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}`);
8021
+ }
8022
+ if (raw.startsWith("/")) {
8023
+ 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}`);
8024
+ }
8025
+ if (/[\u0000-\u001F\u007F]/.test(raw)) {
8026
+ 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}`);
8027
+ }
8028
+ if (onType === "cron" && run.task !== void 0) {
8029
+ 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}`);
8030
+ }
8031
+ if (onType !== "cron" && run.instructions !== void 0) {
8032
+ 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}`);
8033
+ }
8034
+ if (run.resume === true) {
8035
+ 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}`);
8036
+ }
8037
+ return raw;
8038
+ }
8009
8039
  function validatePredicate(on, index, path, requirePositive) {
8010
8040
  const at = `trigger at index ${index}`;
8011
8041
  for (const key of ["any", "all", "none"]) {
@@ -8055,8 +8085,9 @@ function validateReplicas(run, at, path) {
8055
8085
  function normalizeLabel(on, run, index, path) {
8056
8086
  const at = `trigger at index ${index}`;
8057
8087
  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}`);
8088
+ const command = validateCommand(run, at, path, { onType: "label" });
8089
+ if (command === void 0 && !isNonEmptyString2(run.flow)) {
8090
+ throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8060
8091
  }
8061
8092
  const packages = validatePackagesFlag(run, at, path);
8062
8093
  const image = validateImageRef(run, at, path);
@@ -8067,7 +8098,7 @@ function normalizeLabel(on, run, index, path) {
8067
8098
  const replicas = validateReplicas(run, at, path);
8068
8099
  return {
8069
8100
  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 } }
8101
+ 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
8102
  };
8072
8103
  }
8073
8104
  function normalizeComment(on, run, index, path, state) {
@@ -8075,8 +8106,9 @@ function normalizeComment(on, run, index, path, state) {
8075
8106
  if (!isNonEmptyString2(on.phrase)) {
8076
8107
  throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
8077
8108
  }
8078
- if (!isNonEmptyString2(run.flow)) {
8079
- throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string: ${path}`);
8109
+ const command = validateCommand(run, at, path, { onType: "comment" });
8110
+ if (command === void 0 && !isNonEmptyString2(run.flow)) {
8111
+ throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
8080
8112
  }
8081
8113
  state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
8082
8114
  if (state.commentCounts[run.kind] > 1) {
@@ -8091,7 +8123,7 @@ function normalizeComment(on, run, index, path, state) {
8091
8123
  const replicas = validateReplicas(run, at, path);
8092
8124
  return {
8093
8125
  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 } }
8126
+ 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
8127
  };
8096
8128
  }
8097
8129
  function normalizePullRequest(on, run, index, path) {
@@ -8114,8 +8146,9 @@ function normalizePullRequest(on, run, index, path) {
8114
8146
  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
8147
  }
8116
8148
  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}`);
8149
+ const command = validateCommand(run, at, path, { onType: "pull_request" });
8150
+ if (command === void 0 && !isNonEmptyString2(run.flow)) {
8151
+ throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8119
8152
  }
8120
8153
  const packages = validatePackagesFlag(run, at, path);
8121
8154
  const image = validateImageRef(run, at, path);
@@ -8135,7 +8168,7 @@ function normalizePullRequest(on, run, index, path) {
8135
8168
  all: predicate.all,
8136
8169
  none: predicate.none
8137
8170
  },
8138
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
8171
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
8139
8172
  };
8140
8173
  }
8141
8174
  function validateReviewState(on, actions, run, at, path) {
@@ -8516,14 +8549,18 @@ import { Queue } from "bullmq";
8516
8549
  function makeQueue(connection) {
8517
8550
  return new Queue(QUEUE, { connection });
8518
8551
  }
8519
- async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8552
+ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8520
8553
  const minute = now.toISOString().slice(0, 16);
8521
- const id = jobId ?? localJobId({ folder, flow, task, minute });
8554
+ const id = jobId ?? localJobId({ folder, flow: command !== void 0 ? `cmd:${command}` : flow, task, minute });
8522
8555
  const data = {
8523
8556
  kind: "local",
8524
8557
  folder,
8525
8558
  flow,
8526
8559
  task,
8560
+ // The registered pi command this job dispatches instead of a flow (issue #189). Conditional like
8561
+ // `image`, so a flow job's data keeps exactly the keys it has today; the parse-level XOR means a
8562
+ // job carrying it has flow/task undefined, which JSON serialization drops.
8563
+ ...command !== void 0 && { command },
8527
8564
  provider,
8528
8565
  model,
8529
8566
  maxTurns,
@@ -8714,8 +8751,8 @@ var init_docker_run = __esm({
8714
8751
  });
8715
8752
 
8716
8753
  // ../worker/src/packages.mjs
8717
- import { existsSync, readFileSync } from "node:fs";
8718
- import { join } from "node:path";
8754
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
8755
+ import { basename, dirname as dirname2, join, resolve } from "node:path";
8719
8756
  function isNonEmptyString5(value) {
8720
8757
  return typeof value === "string" && value.trim() !== "";
8721
8758
  }
@@ -8739,6 +8776,55 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
8739
8776
  return null;
8740
8777
  }
8741
8778
  }
8779
+ function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExists = existsSync, readDir = readdirSync } = {}) {
8780
+ const out = { skills: [], unenumerable: [] };
8781
+ const manifest = readStageManifest({ globalPiDir, readFile, fileExists });
8782
+ if (!manifest) return out;
8783
+ for (const pkg of manifest.packages) {
8784
+ const root = join(globalPiDir, PACKAGES_SUBDIR, pkg.dir);
8785
+ let sources;
8786
+ try {
8787
+ const parsed = JSON.parse(readFile(join(root, "package.json"), "utf8"));
8788
+ const pi = parsed !== null && typeof parsed === "object" && parsed.pi !== null && typeof parsed.pi === "object" ? parsed.pi : null;
8789
+ if (pi) {
8790
+ const entries = Array.isArray(pi.skills) ? pi.skills.filter((e) => typeof e === "string") : [];
8791
+ if (entries.some((e) => e.startsWith("!") || e.startsWith("+") || e.startsWith("-") || e.includes("*") || e.includes("?"))) {
8792
+ out.unenumerable.push(pkg.name);
8793
+ continue;
8794
+ }
8795
+ sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve(root, e));
8796
+ } else {
8797
+ sources = [join(root, "skills")];
8798
+ }
8799
+ } catch {
8800
+ continue;
8801
+ }
8802
+ for (const source of sources) {
8803
+ for (const skillFile of walkSkillFiles(source, fileExists, readDir)) {
8804
+ out.skills.push({ name: basename(dirname2(skillFile)), package: pkg.name, dir: pkg.dir });
8805
+ }
8806
+ }
8807
+ }
8808
+ return out;
8809
+ }
8810
+ function walkSkillFiles(source, fileExists, readDir, depth = 3) {
8811
+ if (basename(source) === "SKILL.md") return fileExists(source) ? [source] : [];
8812
+ const found = [];
8813
+ const own = join(source, "SKILL.md");
8814
+ if (fileExists(own)) found.push(own);
8815
+ if (depth === 0) return found;
8816
+ let children;
8817
+ try {
8818
+ children = readDir(source, { withFileTypes: true });
8819
+ } catch {
8820
+ return found;
8821
+ }
8822
+ for (const child of children) {
8823
+ if (!child.isDirectory?.()) continue;
8824
+ found.push(...walkSkillFiles(join(source, child.name), fileExists, readDir, depth - 1));
8825
+ }
8826
+ return found;
8827
+ }
8742
8828
  var PACKAGES_SUBDIR, STAGE_MANIFEST, CONTAINER_PACKAGES_ROOT, FROM_DECLARED, FROM_HOST, MAX_DIR_LENGTH;
8743
8829
  var init_packages = __esm({
8744
8830
  "../worker/src/packages.mjs"() {
@@ -8925,11 +9011,13 @@ function findLoopHints(text) {
8925
9011
  }
8926
9012
  return hints;
8927
9013
  }
8928
- function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, foldersTruncated, forgeRepos, cronStats, runJoin, chainEdges, caps, nowMs, triggerCosts } = {}) {
9014
+ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, overlaySkills, stagedSkills, foldersTruncated, forgeRepos, cronStats, runJoin, chainEdges, caps, nowMs, triggerCosts } = {}) {
8929
9015
  const triggerList2 = Array.isArray(triggers?.triggers) ? triggers.triggers : [];
8930
9016
  const schedulerList = Array.isArray(schedulers) ? schedulers : [];
8931
9017
  const folders = folderSkills && typeof folderSkills === "object" ? folderSkills : {};
8932
9018
  const injected = injectedSkills && typeof injectedSkills === "object" ? injectedSkills : {};
9019
+ const overlay = overlaySkills && typeof overlaySkills === "object" && Array.isArray(overlaySkills.skills) ? overlaySkills : null;
9020
+ const staged = stagedSkills && typeof stagedSkills === "object" && Array.isArray(stagedSkills.skills) ? stagedSkills : null;
8933
9021
  const statsById = cronStats?.byId && typeof cronStats.byId === "object" ? cronStats.byId : {};
8934
9022
  const statsByIndex = runJoin?.byIndex && typeof runJoin.byIndex === "object" ? runJoin.byIndex : {};
8935
9023
  const observed = Array.isArray(chainEdges?.edges) ? chainEdges.edges : [];
@@ -8955,13 +9043,18 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
8955
9043
  // collectGraphInputs' cap flag rides its spread into this input (review finding: this was
8956
9044
  // hardcoded false, so the cap-reached banner could never fire on any surface).
8957
9045
  folders: foldersTruncated === true,
8958
- skills: Object.values(folders).some((f) => f?.truncated === true),
9046
+ skills: Object.values(folders).some((f) => f?.truncated === true) || overlay?.truncated === true || staged?.truncated === true,
8959
9047
  edges: chainEdges?.truncated === true
8960
9048
  },
8961
9049
  droppedObservedEdges: 0,
8962
9050
  // Injected dirs whose readdir failed (review finding): the OQ-022 badge is the one fact the
8963
9051
  // 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()
9052
+ injectedUnreachable: Object.entries(injected).filter(([, r]) => typeof r?.unreachable === "string").map(([dir]) => dir).sort(),
9053
+ // The two tier-honesty counters (issue #188): an overlay whose skills/ failed to read, and the
9054
+ // staged packages whose pattern manifests defeat enumeration. Both feed the softened state
9055
+ // below AND a banner, because a tier the ladder silently skipped would read as "checked".
9056
+ overlayUnreachable: overlay?.unreachable === "unreadable",
9057
+ stagedUnenumerable: (Array.isArray(staged?.unenumerable) ? staged.unenumerable : []).filter((p) => typeof p === "string").sort()
8965
9058
  }
8966
9059
  };
8967
9060
  const folderByPath = /* @__PURE__ */ new Map();
@@ -9034,6 +9127,28 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
9034
9127
  }
9035
9128
  }
9036
9129
  }
9130
+ const overlayNodeId = /* @__PURE__ */ new Map();
9131
+ if (overlay) {
9132
+ for (const skill of overlay.skills) {
9133
+ if (typeof skill?.name !== "string" || skill.name === "" || overlayNodeId.has(skill.name)) continue;
9134
+ const id = `overlay:${skill.name}`;
9135
+ overlayNodeId.set(skill.name, id);
9136
+ model.nodes.push({ id, kind: "overlay", name: skill.name });
9137
+ }
9138
+ }
9139
+ const stagedNodeId = /* @__PURE__ */ new Map();
9140
+ if (staged) {
9141
+ const seenStagedIds = /* @__PURE__ */ new Set();
9142
+ for (const skill of staged.skills) {
9143
+ if (typeof skill?.name !== "string" || skill.name === "" || typeof skill?.dir !== "string" || skill.dir === "") continue;
9144
+ const id = `staged:${skill.dir}:${skill.name}`;
9145
+ if (!seenStagedIds.has(id)) {
9146
+ seenStagedIds.add(id);
9147
+ model.nodes.push({ id, kind: "staged", name: skill.name, package: typeof skill.package === "string" && skill.package !== "" ? skill.package : skill.dir });
9148
+ }
9149
+ if (!stagedNodeId.has(skill.name)) stagedNodeId.set(skill.name, id);
9150
+ }
9151
+ }
9037
9152
  const missingNode = (group, name) => {
9038
9153
  const key = `${group.key} ${name}`;
9039
9154
  let node = skillNode.get(key);
@@ -9044,6 +9159,14 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
9044
9159
  group.skillIds.push(node.id);
9045
9160
  return node;
9046
9161
  };
9162
+ const notAtHeadNode = (group, name, tiers) => {
9163
+ const node = missingNode(group, name);
9164
+ if (node.kind === "skill-missing") node.kind = "skill-not-at-head";
9165
+ if (node.kind === "skill-not-at-head") {
9166
+ node.tiersUnknown = [.../* @__PURE__ */ new Set([...Array.isArray(node.tiersUnknown) ? node.tiersUnknown : [], ...tiers])].sort();
9167
+ }
9168
+ return node;
9169
+ };
9047
9170
  for (const t of triggerList2) {
9048
9171
  if (!t || typeof t !== "object" || !Number.isInteger(t.index)) continue;
9049
9172
  const id = `trigger:${t.index}`;
@@ -9080,6 +9203,11 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
9080
9203
  pattern: isCron ? t.pattern ?? null : null,
9081
9204
  label: triggerMatchLabel(t),
9082
9205
  flow: t.flow ?? null,
9206
+ // A command trigger's dispatched extension command (issue #189), flow's mutually-exclusive
9207
+ // sibling. Carried on the node so the display half (issue #188) has the fact to render; nothing
9208
+ // in THIS module consumes it -- a command names no SKILL.md, so the config-edge machinery below
9209
+ // (guarded on `typeof t.flow === "string"`) correctly draws nothing for it.
9210
+ command: t.command ?? null,
9083
9211
  replicas: t.replicas ?? null,
9084
9212
  folderKey: group?.key ?? null,
9085
9213
  runs: Number.isInteger(stats?.runs) ? stats.runs : 0,
@@ -9110,9 +9238,41 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
9110
9238
  existing.isFlow = true;
9111
9239
  model.edges.push({ from: id, to: existing.id, kind: "config" });
9112
9240
  } 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` });
9241
+ const probes = [];
9242
+ if (typeof t.skillsDir === "string" && t.skillsDir !== "") {
9243
+ const r = injected[t.skillsDir];
9244
+ if (!r || typeof r.unreachable === "string") probes.push({ tier: TIER_INJECTED, state: "unknown" });
9245
+ 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}` });
9246
+ else probes.push({ tier: TIER_INJECTED, state: r.truncated === true ? "unknown" : "miss" });
9247
+ }
9248
+ if (overlay === null || typeof overlay.unreachable === "string") probes.push({ tier: TIER_OVERLAY, state: "unknown" });
9249
+ else if (overlayNodeId.has(t.flow)) probes.push({ tier: TIER_OVERLAY, state: "hit", nodeId: overlayNodeId.get(t.flow) });
9250
+ else probes.push({ tier: TIER_OVERLAY, state: overlay.truncated === true ? "unknown" : "miss" });
9251
+ if (t.packages === false) {
9252
+ probes.push({ tier: `${TIER_STAGED} (withheld: run.packages false)`, state: "miss" });
9253
+ } else if (staged === null) probes.push({ tier: TIER_STAGED, state: "unknown" });
9254
+ else if (stagedNodeId.has(t.flow)) probes.push({ tier: TIER_STAGED, state: "hit", nodeId: stagedNodeId.get(t.flow) });
9255
+ else probes.push({ tier: TIER_STAGED, state: staged.unenumerable.length > 0 || staged.truncated === true ? "unknown" : "miss" });
9256
+ let target = null;
9257
+ let blocked = false;
9258
+ for (const probe of probes) {
9259
+ if (probe.state === "hit" && !blocked) {
9260
+ target = probe.nodeId;
9261
+ break;
9262
+ }
9263
+ if (probe.state === "unknown") blocked = true;
9264
+ }
9265
+ if (target !== null) {
9266
+ model.edges.push({ from: id, to: target, kind: "config" });
9267
+ } else if (probes.some((p) => p.state === "unknown")) {
9268
+ const soft = notAtHeadNode(group, t.flow, probes.filter((p) => p.state === "unknown").map((p) => p.tier));
9269
+ model.edges.push({ from: id, to: soft.id, kind: "config" });
9270
+ } else {
9271
+ const target2 = missingNode(group, t.flow);
9272
+ model.edges.push({ from: id, to: target2.id, kind: "config" });
9273
+ const alsoChecked = probes.length > 0 ? `; also checked: ${probes.map((p) => p.tier).join(", ")}` : "";
9274
+ model.flags.push({ nodeId: id, flag: "no-skill", detail: `.pi/skills/${t.flow}/SKILL.md absent at HEAD${alsoChecked}` });
9275
+ }
9116
9276
  }
9117
9277
  } else if (group) {
9118
9278
  const target = missingNode(group, t.flow);
@@ -9215,7 +9375,7 @@ function clipName(name) {
9215
9375
  const s = String(name);
9216
9376
  return s.length > 64 ? `${s.slice(0, 64)}\u2026` : s;
9217
9377
  }
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;
9378
+ 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
9379
  var init_graph_model = __esm({
9220
9380
  "src/graph-model.mjs"() {
9221
9381
  init_flow_gate();
@@ -9228,7 +9388,7 @@ var init_graph_model = __esm({
9228
9388
  GRAPH_EDGE_KINDS = Object.freeze(["config", "observed", "potential", "cron-rearm"]);
9229
9389
  GRAPH_FLAGS = Object.freeze([
9230
9390
  "no-skill",
9231
- // config edge target absent at HEAD in an ENUMERATED folder (readFlowGate's precise token)
9391
+ // config edge target absent in EVERY checkable applicable tier (readFlowGate's precise token, now with the full ladder behind it)
9232
9392
  "charset-invalid",
9233
9393
  // run.flow fails SKILL_NAME_RE: can never materialise, a distinct defect from no-skill
9234
9394
  "orphan",
@@ -9242,6 +9402,26 @@ var init_graph_model = __esm({
9242
9402
  "pr-spend-loop-risk"
9243
9403
  // pull_request on opened/synchronize: the OQ-020 cross-actor spend-loop signature
9244
9404
  ]);
9405
+ GRAPH_NODE_KINDS = Object.freeze([
9406
+ "trigger",
9407
+ "skill",
9408
+ // committed at HEAD in an enumerated folder
9409
+ "skill-missing",
9410
+ // absent in every checkable applicable tier: the one red, dangling state
9411
+ "skill-unverified",
9412
+ // the folder itself was never readable (forge/remote): a read that never happened
9413
+ "skill-not-at-head",
9414
+ // absent at HEAD, but some lower tier was not checkable from this session
9415
+ "injected",
9416
+ // run.skillsDir working-tree skill: trigger-reachable, never AI-reachable
9417
+ "overlay",
9418
+ // deployment overlay skills/: trigger-reachable, never AI-reachable
9419
+ "staged"
9420
+ // a staged pi package's skill: trigger-reachable, never AI-reachable
9421
+ ]);
9422
+ TIER_INJECTED = "injected run.skillsDir";
9423
+ TIER_OVERLAY = "overlay skills/";
9424
+ TIER_STAGED = "staged packages";
9245
9425
  }
9246
9426
  });
9247
9427
 
@@ -9889,6 +10069,11 @@ async function enqueueDispatchRun({
9889
10069
  if (typeof flow !== "string" || flow.trim() === "") {
9890
10070
  return { refused: "no flow \u2014 a flow is required to trigger a run" };
9891
10071
  }
10072
+ if (flow.trim().startsWith("/")) {
10073
+ return {
10074
+ 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)`
10075
+ };
10076
+ }
9892
10077
  const { valkeyUrl, dispatchRunRoots, dispatchRunPerHour } = resolvePaths(env);
9893
10078
  if (aiInvoked && !folderUnderRoots(folder, dispatchRunRoots)) {
9894
10079
  return { refused: "folder not under PI_DISPATCH_RUN_ROOTS" };
@@ -10098,8 +10283,8 @@ async function readBudget({ url, redisFn = makeRedisClient, timeoutMs = 2500 } =
10098
10283
  }
10099
10284
  function withTimeout(promise, ms, fallback) {
10100
10285
  let timer;
10101
- const timeout = new Promise((resolve) => {
10102
- timer = setTimeout(() => resolve(fallback), ms);
10286
+ const timeout = new Promise((resolve2) => {
10287
+ timer = setTimeout(() => resolve2(fallback), ms);
10103
10288
  if (typeof timer?.unref === "function") timer.unref();
10104
10289
  });
10105
10290
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
@@ -10193,6 +10378,7 @@ function normalizeTriggerForDisplay(entry) {
10193
10378
  if (on === null || typeof on !== "object") return null;
10194
10379
  const run = entry.run !== null && typeof entry.run === "object" ? entry.run : {};
10195
10380
  const flow = typeof run.flow === "string" ? run.flow : null;
10381
+ const command = typeof run.command === "string" ? run.command : null;
10196
10382
  const packages = run.packages !== false;
10197
10383
  const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
10198
10384
  const skillsDir = typeof run.skillsDir === "string" && run.skillsDir.trim() !== "" ? run.skillsDir : null;
@@ -10208,6 +10394,7 @@ function normalizeTriggerForDisplay(entry) {
10208
10394
  pattern: typeof on.pattern === "string" ? on.pattern : null,
10209
10395
  folder: typeof run.folder === "string" ? run.folder : null,
10210
10396
  flow,
10397
+ command,
10211
10398
  // Optional per-cron model override (passthrough into job.data); null when the entry resolves the
10212
10399
  // deployment default. Surfaced so the drill-in shows which schedules pin their own model.
10213
10400
  model: typeof run.model === "string" ? run.model : null,
@@ -10218,9 +10405,9 @@ function normalizeTriggerForDisplay(entry) {
10218
10405
  resume
10219
10406
  };
10220
10407
  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 };
10408
+ 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
10409
  case "comment":
10223
- return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, skillsDir, instructions, resume, replicas, forge };
10410
+ return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, replicas, forge };
10224
10411
  case "pull_request":
10225
10412
  return {
10226
10413
  type: "pull_request",
@@ -10229,6 +10416,7 @@ function normalizeTriggerForDisplay(entry) {
10229
10416
  all: normalizeSelector(on.all),
10230
10417
  none: normalizeSelector(on.none),
10231
10418
  flow,
10419
+ command,
10232
10420
  packages,
10233
10421
  image,
10234
10422
  skillsDir,
@@ -10476,8 +10664,69 @@ function readInjectedSkills({ skillsDir, fs = nodeFs2 } = {}) {
10476
10664
  }
10477
10665
  return { skills, truncated, unreachable: null };
10478
10666
  }
10479
- function collectGraphInputs({ triggers, readFolder = readFolderSkills, readInjected = readInjectedSkills } = {}) {
10480
- const out = { folderSkills: {}, injectedSkills: {}, foldersTruncated: false };
10667
+ function readOverlaySkills({ globalPiDir, fs = nodeFs2 } = {}) {
10668
+ if (typeof globalPiDir !== "string" || globalPiDir === "") return { skills: [], truncated: false, unreachable: null };
10669
+ const dir = join2(globalPiDir, "skills");
10670
+ let names;
10671
+ try {
10672
+ names = fs.readdirSync(dir);
10673
+ } catch (err) {
10674
+ if (err?.code === "ENOENT") return { skills: [], truncated: false, unreachable: null };
10675
+ return { skills: [], truncated: false, unreachable: "unreadable" };
10676
+ }
10677
+ const valid = names.filter((n) => SKILL_NAME_RE.test(n)).sort();
10678
+ const truncated = valid.length > GRAPH_LIMITS.maxSkillsPerFolder;
10679
+ const skills = [];
10680
+ for (const name of valid.slice(0, GRAPH_LIMITS.maxSkillsPerFolder)) {
10681
+ let present = false;
10682
+ try {
10683
+ present = fs.existsSync(join2(dir, name, "SKILL.md"));
10684
+ } catch {
10685
+ present = false;
10686
+ }
10687
+ if (present) skills.push({ name });
10688
+ }
10689
+ return { skills, truncated, unreachable: null };
10690
+ }
10691
+ function readStagedSkillsList({ globalPiDir, fs = nodeFs2, readStaged = readStagedSkills } = {}) {
10692
+ const empty = { skills: [], unenumerable: [], truncated: false };
10693
+ if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
10694
+ let result;
10695
+ try {
10696
+ result = readStaged({
10697
+ globalPiDir,
10698
+ readFile: (path) => fs.readFileSync(path, "utf8"),
10699
+ fileExists: (path) => fs.existsSync(path),
10700
+ readDir: (path, opts) => fs.readdirSync(path, opts)
10701
+ });
10702
+ } catch {
10703
+ return empty;
10704
+ }
10705
+ const seen = /* @__PURE__ */ new Set();
10706
+ const skills = [];
10707
+ for (const s of Array.isArray(result?.skills) ? result.skills : []) {
10708
+ if (typeof s?.name !== "string" || !SKILL_NAME_RE.test(s.name)) continue;
10709
+ if (typeof s.dir !== "string" || s.dir === "") continue;
10710
+ const key = `${s.dir} ${s.name}`;
10711
+ if (seen.has(key)) continue;
10712
+ seen.add(key);
10713
+ skills.push({ name: s.name, package: typeof s.package === "string" && s.package !== "" ? s.package : s.dir, dir: s.dir });
10714
+ }
10715
+ return {
10716
+ skills: skills.slice(0, GRAPH_LIMITS.maxStagedSkills),
10717
+ unenumerable: (Array.isArray(result?.unenumerable) ? result.unenumerable : []).filter((p) => typeof p === "string").sort(),
10718
+ truncated: skills.length > GRAPH_LIMITS.maxStagedSkills
10719
+ };
10720
+ }
10721
+ function collectGraphInputs({
10722
+ triggers,
10723
+ globalPiDir = null,
10724
+ readFolder = readFolderSkills,
10725
+ readInjected = readInjectedSkills,
10726
+ readOverlay: readOverlay2 = readOverlaySkills,
10727
+ readStaged = readStagedSkillsList
10728
+ } = {}) {
10729
+ const out = { folderSkills: {}, injectedSkills: {}, overlaySkills: null, stagedSkills: null, foldersTruncated: false };
10481
10730
  if (!Array.isArray(triggers)) return out;
10482
10731
  const folders = [];
10483
10732
  const injectedDirs = [];
@@ -10492,6 +10741,10 @@ function collectGraphInputs({ triggers, readFolder = readFolderSkills, readInjec
10492
10741
  for (const dir of injectedDirs.slice(0, GRAPH_LIMITS.maxFoldersScanned)) {
10493
10742
  out.injectedSkills[dir] = readInjected({ skillsDir: dir });
10494
10743
  }
10744
+ if (typeof globalPiDir === "string" && globalPiDir !== "") {
10745
+ out.overlaySkills = readOverlay2({ globalPiDir });
10746
+ out.stagedSkills = readStaged({ globalPiDir });
10747
+ }
10495
10748
  return out;
10496
10749
  }
10497
10750
  function forgeRepoTargets({ records } = {}) {
@@ -10582,8 +10835,10 @@ var init_read_model = __esm({
10582
10835
  // distinct observed chain edges kept; beyond this the graph says "truncated"
10583
10836
  windowDays: 30,
10584
10837
  // run-record window the graph folds; matches the default PI_LOG_RETENTION_DAYS
10585
- maxReposListed: 5
10838
+ maxReposListed: 5,
10586
10839
  // repos named per forge group, from record targets; a scope label, not an inventory
10840
+ maxStagedSkills: 64
10841
+ // staged-package skills kept for display; one stage read per graph build
10587
10842
  });
10588
10843
  GRAPH_LS_TREE_MAX_BYTES = 1 << 20;
10589
10844
  }
@@ -10637,7 +10892,11 @@ function normalizeModel(model) {
10637
10892
  // dropped (issue #175): three surfaces of one model must not disagree about what was refused
10638
10893
  // or unreadable. Sorted and clipped like every other array on this page.
10639
10894
  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)
10895
+ injectedUnreachable: (Array.isArray(m.meta?.injectedUnreachable) ? m.meta.injectedUnreachable : []).filter((d) => typeof d === "string").map((d) => clip(d, 120)).sort().slice(0, 8),
10896
+ // The two tier-honesty counters (issue #188), the injectedUnreachable rule applied to the
10897
+ // deployment-wide tiers: a tier the ladder silently skipped would read as "checked".
10898
+ overlayUnreachable: m.meta?.overlayUnreachable === true,
10899
+ stagedUnenumerable: (Array.isArray(m.meta?.stagedUnenumerable) ? m.meta.stagedUnenumerable : []).filter((d) => typeof d === "string").map((d) => clip(d, 80)).sort().slice(0, 8)
10641
10900
  };
10642
10901
  const folders = [];
10643
10902
  for (const f of Array.isArray(m.folders) ? m.folders : []) {
@@ -10671,6 +10930,13 @@ function normalizeModel(model) {
10671
10930
  onType: typeof n.onType === "string" ? n.onType : null,
10672
10931
  pattern: typeof n.pattern === "string" ? n.pattern : null,
10673
10932
  flow: typeof n.flow === "string" ? n.flow : null,
10933
+ // A command trigger's slash command (issue #189's node fact, #188's rendering): the tip is the
10934
+ // detail surface, so the full command rides, clipped like every other string on this page.
10935
+ command: typeof n.command === "string" ? clip(n.command, 80) : null,
10936
+ // The staged tier node's owning package, and the softened state's evidence: which tiers this
10937
+ // session could not check. Both clipped/sorted/capped so a hostile model cannot inflate a tip.
10938
+ package: typeof n.package === "string" ? clip(n.package, 80) : null,
10939
+ tiersUnknown: Array.isArray(n.tiersUnknown) ? [...new Set(n.tiersUnknown.filter((t) => typeof t === "string").map((t) => clip(t, 40)))].sort().slice(0, 4) : [],
10674
10940
  replicas: intOr(n.replicas, null),
10675
10941
  folderKey: typeof n.folderKey === "string" ? n.folderKey : null,
10676
10942
  runs: intOr(n.runs, 0),
@@ -10751,6 +11017,8 @@ function layoutNormalized(norm) {
10751
11017
  placedByOrig.set(n.id, p);
10752
11018
  let g = n.folderKey === null ? null : groupIndexByKey.get(n.folderKey) ?? null;
10753
11019
  if (!g && n.kind === "injected") g = groupIndexByKey.get("~injected") ?? addGroup("~injected", "injected skills (run.skillsDir)", "local", null, null);
11020
+ if (!g && n.kind === "overlay") g = groupIndexByKey.get("~overlay") ?? addGroup("~overlay", "overlay skills (global pi dir)", "local", null, null);
11021
+ if (!g && n.kind === "staged") g = groupIndexByKey.get("~staged") ?? addGroup("~staged", "staged package skills", "local", null, null);
10754
11022
  if (!g) g = groupIndexByKey.get(n.folderKey ?? "~ungrouped") ?? addGroup(n.folderKey ?? "~ungrouped", "ungrouped", "local", null, null);
10755
11023
  g.members.push(p);
10756
11024
  p.groupId = g.id;
@@ -11080,13 +11348,23 @@ function buildTip(n, flags, groupLabel, nowMs) {
11080
11348
  if (n.kind === "trigger") {
11081
11349
  lines.push(`trigger \xB7 ${n.onType ?? "?"} \xB7 ${n.label ?? ""}`.trim());
11082
11350
  if (n.flow !== null) lines.push(`flow: ${n.flow}${n.replicas !== null ? ` \xD7${n.replicas}` : ""}`);
11351
+ else if (n.command !== null) lines.push(`command: /${n.command}${n.replicas !== null ? ` \xD7${n.replicas}` : ""}`);
11083
11352
  } else if (n.kind === "injected") {
11084
11353
  lines.push(`injected skill \xB7 ${n.name ?? "?"}`);
11085
11354
  lines.push("trigger-reachable via run.skillsDir, never AI-reachable");
11355
+ } else if (n.kind === "overlay") {
11356
+ lines.push(`overlay skill \xB7 ${n.name ?? "?"}`);
11357
+ lines.push("deployment overlay skills/, trigger-reachable, never AI-reachable");
11358
+ } else if (n.kind === "staged") {
11359
+ lines.push(`staged skill \xB7 ${n.name ?? "?"}${n.package !== null ? ` \xB7 package ${n.package}` : ""}`);
11360
+ lines.push("staged pi package, trigger-reachable, never AI-reachable");
11086
11361
  } else if (n.kind === "skill-missing") {
11087
11362
  lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 missing at HEAD`);
11088
11363
  } else if (n.kind === "skill-unverified") {
11089
11364
  lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 unverified (repo not readable from this host)`);
11365
+ } else if (n.kind === "skill-not-at-head") {
11366
+ lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 not committed at HEAD`);
11367
+ if (n.tiersUnknown.length > 0) lines.push(`not checkable from this session: ${n.tiersUnknown.join(", ")}`);
11090
11368
  } else {
11091
11369
  lines.push(`skill \xB7 ${n.name ?? "?"}${n.isSub ? " \xB7 sub-skill (never a flow)" : ""}`);
11092
11370
  }
@@ -11133,6 +11411,7 @@ function nodeState(n, flagNames) {
11133
11411
  return { stroke: DANGER, dash: "10,4", faded: false };
11134
11412
  }
11135
11413
  if (n.kind === "skill-unverified") return { stroke: PAGE_DIM, dash: "10,4", faded: false };
11414
+ if (n.kind === "skill-not-at-head") return { stroke: PAGE_AMBER, dash: "10,4", faded: false };
11136
11415
  if (flagNames.has("orphan")) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
11137
11416
  return { stroke: CHIP_STROKE, dash: null, faded: false };
11138
11417
  }
@@ -11248,7 +11527,8 @@ function legendHtml(norm) {
11248
11527
  row(legendSwatch(`<circle cx="8" cy="6" r="5" fill="${BADGE_ORANGE}"/>`), "unread / injected ai-trigger / PR spend-loop risk");
11249
11528
  row(legendSwatch(`<circle cx="8" cy="6" r="4" fill="none" stroke="${BADGE_GREEN}" stroke-width="2"/>`), "chainable: ai-trigger allow");
11250
11529
  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");
11530
+ 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");
11531
+ 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
11532
  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
11533
  rows.push(`<div class="caps">${escapeHtml(capsLineText(norm.caps))}</div>`);
11254
11534
  const honesty = [];
@@ -11260,6 +11540,8 @@ function legendHtml(norm) {
11260
11540
  const refused = norm.meta.chainRefusals.reduce((a, r) => a + r.count, 0);
11261
11541
  if (refused > 0) honesty.push(`${refused} chain requests refused (caps or gate)`);
11262
11542
  if (norm.meta.injectedUnreachable.length > 0) honesty.push(`injected skills dir unreadable: ${norm.meta.injectedUnreachable.join(", ")}`);
11543
+ if (norm.meta.overlayUnreachable) honesty.push("overlay skills dir unreadable (global pi dir)");
11544
+ if (norm.meta.stagedUnenumerable.length > 0) honesty.push(`staged packages not enumerable (manifest patterns): ${norm.meta.stagedUnenumerable.join(", ")}`);
11263
11545
  for (const line of honesty) rows.push(`<div class="honesty">${escapeHtml(line)}</div>`);
11264
11546
  return `<div id="legend">${rows.join("")}</div>`;
11265
11547
  }
@@ -11406,7 +11688,10 @@ var init_graph_html = __esm({
11406
11688
  skill: "\u0192",
11407
11689
  "skill-missing": "!",
11408
11690
  "skill-unverified": "?",
11409
- injected: "+"
11691
+ "skill-not-at-head": "\u22EF",
11692
+ injected: "+",
11693
+ overlay: "\u25CE",
11694
+ staged: "\u25A3"
11410
11695
  });
11411
11696
  ORANGE_FLAGS = /* @__PURE__ */ new Set(["unread", "injected-ai-trigger", "pr-spend-loop-risk"]);
11412
11697
  DANGLING_FLAGS = /* @__PURE__ */ new Set(["no-skill", "charset-invalid"]);
@@ -38371,7 +38656,7 @@ var init_all = __esm({
38371
38656
 
38372
38657
  // ../worker/src/pricing.mjs
38373
38658
  import { readFileSync as readFileSync2 } from "node:fs";
38374
- import { dirname as dirname2, join as join4 } from "node:path";
38659
+ import { dirname as dirname3, join as join4 } from "node:path";
38375
38660
  import { fileURLToPath } from "node:url";
38376
38661
  function listPricedModels() {
38377
38662
  const out = [];
@@ -38400,7 +38685,7 @@ function readTextFromDisk(path) {
38400
38685
  }
38401
38686
  function readPiAiVersion(readText) {
38402
38687
  try {
38403
- let dir = dirname2(fileURLToPath(import.meta.resolve("@earendil-works/pi-ai")));
38688
+ let dir = dirname3(fileURLToPath(import.meta.resolve("@earendil-works/pi-ai")));
38404
38689
  for (let hops = 0; hops < 8; hops++) {
38405
38690
  try {
38406
38691
  const parsed = JSON.parse(readText(join4(dir, "package.json")));
@@ -38410,7 +38695,7 @@ function readPiAiVersion(readText) {
38410
38695
  }
38411
38696
  } catch {
38412
38697
  }
38413
- const parent = dirname2(dir);
38698
+ const parent = dirname3(dir);
38414
38699
  if (parent === dir) break;
38415
38700
  dir = parent;
38416
38701
  }
@@ -38455,7 +38740,7 @@ var init_pricing = __esm({
38455
38740
  });
38456
38741
 
38457
38742
  // ../worker/src/sandbox-store.mjs
38458
- import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
38743
+ import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
38459
38744
  import { isAbsolute as isAbsolute2, join as join5, relative } from "node:path";
38460
38745
  function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
38461
38746
  if (!sandboxDir || jobId === void 0 || jobId === null) return null;
@@ -38472,7 +38757,7 @@ var init_sandbox_store = __esm({
38472
38757
  "../worker/src/sandbox-store.mjs"() {
38473
38758
  init_run_history();
38474
38759
  SANDBOX_MANIFEST = "manifest.json";
38475
- defaultFs = { lstatSync, mkdirSync, readFileSync: readFileSync3, readdirSync, renameSync, rmSync, writeFileSync };
38760
+ defaultFs = { lstatSync, mkdirSync, readFileSync: readFileSync3, readdirSync: readdirSync2, renameSync, rmSync, writeFileSync };
38476
38761
  }
38477
38762
  });
38478
38763
 
@@ -38518,10 +38803,10 @@ function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, f
38518
38803
  return { manifest, name: sandboxContainerName(jobId), publish };
38519
38804
  }
38520
38805
  function launchSandbox({ args, spawnFn = spawn }) {
38521
- return new Promise((resolve) => {
38806
+ return new Promise((resolve2) => {
38522
38807
  const child = spawnFn("docker", args, { stdio: "inherit" });
38523
- child.on("error", (err) => resolve({ code: null, error: err }));
38524
- child.on("close", (code) => resolve({ code }));
38808
+ child.on("error", (err) => resolve2({ code: null, error: err }));
38809
+ child.on("close", (code) => resolve2({ code }));
38525
38810
  });
38526
38811
  }
38527
38812
  var exec3, SANDBOX_NAME_PREFIX;
@@ -38606,7 +38891,7 @@ function renderTriggers({ schedulers, triggers } = {}) {
38606
38891
  return out.join("\n");
38607
38892
  }
38608
38893
  function triggerLine(t) {
38609
- const flow = t?.flow ?? "-";
38894
+ const flow = t?.flow ?? commandSlashLabel(t) ?? "-";
38610
38895
  const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
38611
38896
  const pkgs = t?.packages === true ? " [packages]" : "";
38612
38897
  const img = t?.image ? ` [image ${t.image}]` : "";
@@ -38630,6 +38915,9 @@ function triggerLine(t) {
38630
38915
  return "(unknown trigger)";
38631
38916
  }
38632
38917
  }
38918
+ function commandSlashLabel(t) {
38919
+ return typeof t?.command === "string" && t.command.trim() !== "" ? `/${t.command.trim().split(/\s+/)[0]}` : null;
38920
+ }
38633
38921
  function ruleClauses(rule) {
38634
38922
  const clauses = [];
38635
38923
  for (const key of ["any", "all", "none"]) {
@@ -39574,7 +39862,7 @@ function matchColored(t, styler) {
39574
39862
  }
39575
39863
  function targetColored(t, styler) {
39576
39864
  const arrow = styler.fg("dim", "\u2192");
39577
- const flow = styler.bold(styler.fg("text", t?.flow ?? "-"));
39865
+ const flow = styler.bold(styler.fg("text", t?.flow ?? commandSlashLabel(t) ?? "-"));
39578
39866
  if (t?.type === "cron") {
39579
39867
  const base = t.folder ? String(t.folder).split(/[/\\]/).filter(Boolean).pop() ?? "" : "";
39580
39868
  const folderPart = base ? styler.fg("muted", base) + styler.fg("dim", "/") : "";
@@ -39678,7 +39966,7 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
39678
39966
  const kv = (k, v, color = "text") => fitLine(styler.cell(k, 12, { color: "muted" }) + " " + styler.fg(color, v), inner, styler);
39679
39967
  const blank = () => out.push(styler.cell("", inner));
39680
39968
  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 ?? "-"}`));
39969
+ let header = styler.fg(KIND_COLOR[t.type] ?? "muted", t.type ?? "?") + " " + styler.bold(styler.fg("text", `\u2192 ${t.flow ?? commandSlashLabel(t) ?? "-"}`));
39682
39970
  if (t.type === "cron" && sched) {
39683
39971
  const healthy = !sched.overdueMs;
39684
39972
  header += " " + (healthy ? styler.fg("success", "\u2714 healthy") : styler.fg("warning", `\u26A0 overdue ${formatDuration(sched.overdueMs)}`));
@@ -39714,6 +40002,7 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
39714
40002
  out.push(kv("target", forgeTargetLabel(t?.forge), "accent"));
39715
40003
  out.push(kv("model", "deployment default", "dim"));
39716
40004
  }
40005
+ if (typeof t.command === "string" && t.command.trim() !== "") out.push(kv("command", `/${t.command.trim()}`, "accent"));
39717
40006
  out.push(kv("image", t.image ?? "deployment default", t.image ? "accent" : "dim"));
39718
40007
  out.push(kv("resume", t.resume === true ? "continues the previous session" : "cold start", t.resume === true ? "warning" : "dim"));
39719
40008
  out.push(kv("replicas", t.replicas > 1 ? `${t.replicas} sandboxes race this flow` : "one run per delivery", t.replicas > 1 ? "warning" : "dim"));
@@ -40096,16 +40385,16 @@ async function runAttached(ctx, { title, argv0, args, cwd, env, shell, spawnFn =
40096
40385
  ${title}
40097
40386
 
40098
40387
  `);
40099
- outcome = await new Promise((resolve) => {
40388
+ outcome = await new Promise((resolve2) => {
40100
40389
  let child;
40101
40390
  try {
40102
40391
  child = spawnFn(argv0, args, { stdio: "inherit", cwd, env, ...shell ? { shell: true } : {} });
40103
40392
  } catch (err) {
40104
- resolve({ code: null, error: err });
40393
+ resolve2({ code: null, error: err });
40105
40394
  return;
40106
40395
  }
40107
- child.on("error", (err) => resolve({ code: null, error: err }));
40108
- child.on("close", (code) => resolve({ code }));
40396
+ child.on("error", (err) => resolve2({ code: null, error: err }));
40397
+ child.on("close", (code) => resolve2({ code }));
40109
40398
  });
40110
40399
  } catch (err) {
40111
40400
  outcome = { code: null, error: err };
@@ -40556,8 +40845,8 @@ var init_setup_wizard = __esm({
40556
40845
  init_deployment_pointer();
40557
40846
  init_read_model();
40558
40847
  init_index();
40559
- RUNTIME_VERSION = "0.3.0";
40560
- RECEIVER_VERSION = "0.2.1";
40848
+ RUNTIME_VERSION = "1.0.0";
40849
+ RECEIVER_VERSION = "1.0.0";
40561
40850
  RUNTIME_PKG = "@edgehero/pi-dispatch";
40562
40851
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
40563
40852
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -40699,7 +40988,7 @@ function registerTools(pi) {
40699
40988
  pi.registerTool({
40700
40989
  name: "dispatch_run",
40701
40990
  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.",
40991
+ 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
40992
  executionMode: "sequential",
40704
40993
  parameters: typebox_exports.Object({ folder: typebox_exports.String(), flow: typebox_exports.String(), task: typebox_exports.String() }),
40705
40994
  async execute(_toolCallId, params) {
@@ -41186,7 +41475,7 @@ async function assembleGraph(paths) {
41186
41475
  return buildGraphModel({
41187
41476
  triggers,
41188
41477
  schedulers: Array.isArray(schedulers) ? schedulers : [],
41189
- ...collectGraphInputs({ triggers: triggerList2 }),
41478
+ ...collectGraphInputs({ triggers: triggerList2, globalPiDir: paths.globalPiDir }),
41190
41479
  cronStats: cronRunStats({ records: recs, schedulerIds: triggerList2.filter((t) => t.type === "cron" && typeof t.id === "string").map((t) => t.id) }),
41191
41480
  runJoin: joinRunsToTriggers({ records: recs, triggerCount: triggers?.count, triggerTypes: Object.fromEntries(triggerList2.map((t) => [t.index, t.type])) }),
41192
41481
  chainEdges: observedChainEdges({ records: recs }),
@@ -41395,7 +41684,7 @@ could not start docker: ${error.message}
41395
41684
  }
41396
41685
  }
41397
41686
  function pauseForMessage() {
41398
- return new Promise((resolve) => setTimeout(resolve, 2500));
41687
+ return new Promise((resolve2) => setTimeout(resolve2, 2500));
41399
41688
  }
41400
41689
  function splitWords(s) {
41401
41690
  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.0.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",