Skip to content

Commit 6f26e07

Browse files
committed
feat(triggers): run.command dispatches a registered extension command (#189)
The producer half of issue #189 Gap 2 (the runner protocol merged as #192): a trigger may now name a registered pi extension command instead of a flow, on all four trigger kinds. Parse (shared validator, both services fail identically): exactly one of run.flow or run.command, refused before every flow check so a command-only entry never sees the wrong error; the value is validated exactly as the runner validates PI_COMMAND (non-empty, no leading slash, no surrounding whitespace, no control characters DEL included) so a file that loads never refuses in-container; run.task, run.instructions and run.resume are refused beside command, each naming both fields; everything else stays orthogonal; an unflagged trigger normalizes byte-identically. Prompt: a command job's prompt is exactly /name args, local and forge alike, no envelope, no pointer, no trailing newline; the delivery rides /job/event.json as the handler's data channel. CONST-ISSUE-TEXT-IS-DATA is preserved and arguably strengthened: payload text reaches a command job only as a file the handler chooses to parse, never as prompt prose. Never AI-reachable, stricter than flows and BUILT rather than fallen out (the OQ-022 inversion, now recorded there): chain requests carrying a command key refuse outright as chain-command-refused before the charset check, with no opt-in, and dispatch_run refuses a slash-leading flow with a readable message on both invocation paths while remaining structurally incapable of naming a command. Commands may chain OUT through their own outbox; nothing chains INTO a command. Receiver: knownFlows collects only flow-carrying rules; all four forge gates (github, gitlab, forgejo, azure) dispatch command rules with the comment phrase-plus-flow override channel INERT on them, so a collaborator can neither retarget nor veto a command by appending words. Worker: cron and forge job data carry command; both dedup keys use a cmd: prefix so a command and a flow of one name cannot collide; image-preflight refuses a command job on an image not declaring the commands capability, pre-spend, as job-image-commands-unsupported; doctor counts command triggers and says where they are verifiable. Admin: display and TUI show /name; the graph carries command for issue #188; writeTriggers inherits the exclusion. Specs: INT-TRIGGERS-FILE-CONTRACT AMENDED (the command clause on the run.image no-model-callable-path template), INT-OUTBOX-CONTRACT AMENDED, DES-COMMAND-ENTRY-POINT AMENDED (producer half), DES-AI-TRIGGER-FLOW-GATE AMENDED, DES-JOB-OUTBOX-CHAINING AMENDED, DES-TRIGGER-INSTRUCTION-IN-THE-ENVELOPE AMENDED (the byte-for-byte objection answered: command prompts are NEW prompts), OQ-022 AMENDED, REQ-AI-TRIGGERED-RUNS, REQ-CRON-SCHEDULED-JOBS and REQ-TRIGGER-AUTHOR-GATE AMENDED. UNCHANGED, checked: DES-TRIGGERS-UNIFIED-FILE, REQ-DEPLOYMENT-BOOTSTRAP, OQ-008, OQ-019 (its (b) stays open), OQ-009. docs/workflows.md doctrine rewritten (run.flow and run.command are the two entry points; the nobody-to-type-it premise refuted at the pin), both READMEs and docs/job-image.md updated, triggers.example.json gains a cron command entry. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent d6a816a commit 6f26e07

50 files changed

Lines changed: 1699 additions & 151 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ receiver (forges), and editable from the panel:
126126
### The four trigger types: what fires each one, and what it runs
127127

128128
pi-dispatch is the trigger layer. Every entry is one `{ on, run }` pair: **`on` is what fires it**, and
129-
**`run` is the skill it runs** (`flow` names a `.pi/skills/<flow>` in the target repo: see
130-
[Flows](#flows-the-custom-prompt-a-trigger-runs) for what that file is, and
129+
**`run` is what it runs**, either a flow or a registered command (`flow` names a `.pi/skills/<flow>` in
130+
the target repo: see [Flows](#flows-the-custom-prompt-a-trigger-runs) for what that file is, and
131131
[Multi-stage workflows](#multi-stage-workflows-and-third-party-pi-extensions) for chaining skills or
132132
staging a workflow extension).
133133

@@ -138,16 +138,23 @@ staging a workflow extension).
138138
| `comment` | a comment containing your phrase | `phrase`, for example `@pi` | the phrase, and **one comment trigger per forge** | the comment body plus the issue title and body |
139139
| `pull_request` | a PR or MR event, including a submitted GitHub review | `action`, a non-empty array in your forge's own words | `action`, plus the same label predicate; where the forge has a label action and you name it, a positive selector becomes **required**; on a GitHub review, also `reviewState` | the PR title and body, plus the review body when a review fired it |
140140

141-
Every type also needs `run.kind` (`local` for cron, else the forge) and `run.flow`. Cron additionally
141+
One variation changes that last column for every type: a trigger that names `run.command` instead of
142+
`run.flow` gives the agent exactly `/command args` as its whole prompt, and the issue, comment or PR text
143+
waits in `/job/event.json` for the command's handler to read (see
144+
[Multi-stage workflows](#multi-stage-workflows-and-third-party-pi-extensions)).
145+
146+
Every type also needs `run.kind` (`local` for cron, else the forge) and exactly one of `run.flow` or
147+
`run.command` (naming both, or neither, refuses to load in both services). Cron additionally
142148
needs `folder` (a host path the worker checks exists when it loads the file; make it absolute, since a
143-
relative path resolves against the worker's own directory) and `task`. Azure `label` and `comment`
144-
triggers need `run.repository`, because a work item belongs to a project and names no repository.
149+
relative path resolves against the worker's own directory) and, with `flow`, a `task`. Azure `label` and
150+
`comment` triggers need `run.repository`, because a work item belongs to a project and names no repository.
145151

146152
Two matching behaviours worth knowing before you arm a paid trigger:
147153

148154
- **A comment can choose the flow.** `<phrase> <flow>` in the comment body overrides the trigger's
149155
`run.flow` whenever that word matches another trigger's flow in the same file, so `run.flow` is a
150-
default rather than a fixed pairing.
156+
default rather than a fixed pairing. On a rule that names `run.command` this channel is inert: trailing
157+
words never retarget or suppress the command, and reach the job only as data in `/job/event.json`.
151158
- **Label triggers match differently per forge.** GitHub and Forgejo match the issue's **whole current
152159
label set**, so reopening an already-labelled issue, or adding an unrelated label to one, fires
153160
again. GitLab and Azure match only the labels **that event added**, which is exactly why they do not
@@ -193,6 +200,13 @@ permission check, dedup, quiet hours, the image preflight, branch protection, an
193200
Each is a deliberate file-only edit (no panel key, no AI tool, because each one changes what code runs
194201
or what it costs):
195202

203+
- `"command"` replaces `flow` (exactly one of the two, on any trigger type): the job dispatches a
204+
registered pi extension command headlessly, its whole prompt being `/command args`. Arguments are fixed
205+
in the reviewed file; the event text reaches the handler only as `/job/event.json`, which it reads
206+
itself. A command is never AI-triggerable: job chaining refuses any request naming one, and
207+
`dispatch_run` cannot express one ([`docs/workflows.md`](docs/workflows.md)). The job image must declare
208+
the `commands` capability (the shipped image does); a command job on an image that does not is refused
209+
before it costs anything.
196210
- `"image"` names the container image for that trigger's jobs; absent means `PI_JOB_IMAGE`. The image
197211
decides what is in the box, never what the box can do: the isolation flags are the worker's, always
198212
([`docs/job-image.md`](docs/job-image.md)).
@@ -282,17 +296,19 @@ in every job container**, pinned to an exact version and present offline.
282296
runs inside the one job the trigger produced:
283297

284298
```text
285-
label / comment / PR / cron -> one job, one container -> run.flow (a skill) -> the skills it calls,
286-
or a workflow extension
299+
label / comment / PR / cron -> one job, one container -> run.flow | run.command -> the skills it calls,
300+
or a workflow extension
287301
```
288302

289303
Four basics follow from that shape:
290304

291-
- **`run.flow` is the only entry point.** A trigger names a flow, never a workflow. Which stages run, and
292-
in what order, is decided inside the job by that skill.
305+
- **`run.flow` and `run.command` are the two entry points.** A trigger names a flow or a registered
306+
command, never a workflow. In a flow job, which stages run is decided inside the job by that skill; in a
307+
command job, the named command dispatches directly, with its arguments fixed in the reviewed file.
293308
- **A job is not an interactive session.** The container hands pi one assembled prompt and reads the exit
294-
line, so a workflow extension's slash command (`/wf`, in the example below) has nobody to type it. In a
295-
job a workflow starts because the flow's instructions drive it, or because you also staged a small
309+
line. That one prompt can be a slash command: `"command": "wf"` on a trigger dispatches a workflow
310+
extension's `/wf` (in the example below) exactly as a typed one would, with no model turn in between. In
311+
a flow job a workflow starts because the flow's instructions drive it, or because you also staged a small
296312
extension that calls the workflow API from a lifecycle hook.
297313
- **One trigger is one job, one budget slot, one turn budget.** Ten stages share the same `PI_MAX_TURNS`
298314
and the same per-job token budget; exhausting either aborts the job as a policy refusal that is never

admin/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ Every trigger is one `{ on, run }` entry in a single `triggers.json`, read live
4646

4747
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.
4848

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.
5050

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).
5252

5353
## The console: `/dispatch`
5454

@@ -67,7 +67,7 @@ One command puts a live TUI over the whole deployment:
6767

6868
- **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.
6969
- **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.
7171
- **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
7272

7373
## Install
@@ -80,7 +80,7 @@ pi install npm:@edgehero/pi-dispatch-admin # then, in pi: /dispatch
8080

8181
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.
8282

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.
8484

8585
## Get the whole thing
8686

admin/src/graph-model.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,11 @@ export function buildGraphModel({ triggers, schedulers, folderSkills, injectedSk
342342
pattern: isCron ? (t.pattern ?? null) : null,
343343
label: triggerMatchLabel(t),
344344
flow: t.flow ?? null,
345+
// A command trigger's dispatched extension command (issue #189), flow's mutually-exclusive
346+
// sibling. Carried on the node so the display half (issue #188) has the fact to render; nothing
347+
// in THIS module consumes it -- a command names no SKILL.md, so the config-edge machinery below
348+
// (guarded on `typeof t.flow === "string"`) correctly draws nothing for it.
349+
command: t.command ?? null,
345350
replicas: t.replicas ?? null,
346351
folderKey: group?.key ?? null,
347352
runs: Number.isInteger(stats?.runs) ? stats.runs : 0,

admin/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,8 @@ function registerTools(pi: ExtensionAPI): void {
371371
"Enqueues a PAID pi-dispatch agent run against a local folder, editing it in place with no undo. " +
372372
"Only folders under the operator's PI_DISPATCH_RUN_ROOTS, and only flows whose .pi/skills/<flow>/SKILL.md " +
373373
"(at HEAD) sets ai-trigger: allow, can be started. Refuses a dirty git working tree — no force option. " +
374-
"Rate-limited per hour.",
374+
"Rate-limited per hour. Flows only: a registered extension command (a run.command trigger) is never " +
375+
"AI-triggerable and a /name flow is refused.",
375376
executionMode: "sequential",
376377
parameters: Type.Object({ folder: Type.String(), flow: Type.String(), task: Type.String() }),
377378
async execute(_toolCallId, params) {

0 commit comments

Comments
 (0)