@edgehero/pi-dispatch-admin 0.1.2 → 0.1.4

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
@@ -32,7 +32,7 @@ Every trigger produces the same job, through the same path — one queue, one co
32
32
 
33
33
  - **The container is the boundary.** pi ships no permission system, so every job runs `--cap-drop=ALL`, non-root, ephemeral, with its instructions mounted read-only. That *is* the missing permission system — enforced by Docker, not hoped for.
34
34
  - **Spend is bounded before tokens.** A per-job turn budget plus daily / weekly / monthly caps, checked *before* a container starts. A runaway or a junk trigger costs you a refusal, not a surprise bill.
35
- - **Three triggers, one job.** A CLI command, a cron schedule, or a GitHub issue/PR — same queue, same box, same budget. Cron is the unattended one: recurring work on your own hardware, in an image you control.
35
+ - **Three triggers, one job.** A CLI command, a cron schedule, or a GitHub issue/PR — same queue, same box, same budget. Cron is the unattended one: recurring work on your own hardware, in an image you control — one per deployment, or one per trigger.
36
36
  - **Your image, your tools.** Bake a project's toolchain into the Dockerfile; it ships **Playwright + Chromium**, so a flow can build a frontend, screenshot it, and iterate on the rendered result.
37
37
 
38
38
  ## The console — `/dispatch`
@@ -45,6 +45,8 @@ This extension puts a live TUI over the whole deployment in one command:
45
45
 
46
46
  - **Status & spend.** Queue and worker state, day/week/month **spend meters** + a daily token counter, and a run-history table with per-job token & cost.
47
47
  - **Triggers, editable live.** cron / label / comment / pull_request, with colored drill-ins showing *what fires* each one, *what it runs*, and its *trust model* — added, edited, and deleted without a restart.
48
+ - **You can see which triggers run third-party code.** A trigger loads the pi packages you pinned into your global overlay **unless** it carries `run.packages: false`, so the ones that do get a **`[packages]`** badge in the trigger list, and their **trust model** names the staged `name@version` set plus the one-line consequence — third-party code, on adversarial input, with open network egress. Declining is deliberately *not* a panel action: it stays an edit to the reviewed `triggers.json`, which neither the console nor a model-callable tool will make for you.
49
+ - **You can see which image each trigger runs.** A trigger may name its own container image with `run.image` (absent = the deployment default), and the trigger list shows the tag while the drill-in states it either way — because which image a job runs *is* which code it runs. Like `packages`, it is display-only: naming an image stays an edit to the reviewed `triggers.json`, which neither the console nor a model-callable tool will make for you.
48
50
  - **Quiet hours.** Scheduled pause windows per folder or repo: defer a scope's runs **between certain times** — recurring daily, weekday- and date-bounded, timezone-aware — and resume automatically. Deferred, never dropped, at zero budget cost.
49
51
  - **AI-operable, with a human gate.** Model-callable tools let an agent change limits and manage triggers and pause windows — but **every write pops an operator confirmation the model can't answer**, and is refused when no operator is present. The bundled `operate-pi-dispatch` **skill** teaches the agent to use those gates.
50
52
  - **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
@@ -55,7 +57,7 @@ This extension puts a live TUI over the whole deployment in one command:
55
57
  pi install npm:@edgehero/pi-dispatch-admin
56
58
  ```
57
59
 
58
- Then run pi and open `/dispatch`. Point it at your deployment with `VALKEY_URL` / `PI_LOGS_DIR` / `PI_SETTINGS_FILE` / `PI_TRIGGERS_FILE` / `PI_PAUSE_WINDOWS_FILE` — the same values your worker uses. It reads that deployment's queue and run history; on its own it will just say "queue unreachable."
60
+ Then run pi and open `/dispatch`. Point it at your deployment with `VALKEY_URL` / `PI_LOGS_DIR` / `PI_SETTINGS_FILE` / `PI_TRIGGERS_FILE` / `PI_PAUSE_WINDOWS_FILE` / `PI_GLOBAL_PI_DIR` — the same values your worker uses. It reads that deployment's queue and run history; on its own it will just say "queue unreachable."
59
61
 
60
62
  ## Get the whole thing
61
63
 
package/dist/index.mjs CHANGED
@@ -4220,7 +4220,7 @@ __export(typebox_exports, {
4220
4220
 
4221
4221
  // src/read-model.mjs
4222
4222
  import * as nodeFs2 from "node:fs";
4223
- import { join, delimiter, sep } from "node:path";
4223
+ import { join as join2, delimiter, sep } from "node:path";
4224
4224
  import { execFileSync as execFileSync2 } from "node:child_process";
4225
4225
 
4226
4226
  // ../worker/src/config.mjs
@@ -4371,8 +4371,12 @@ function windowState(reserved, cap, softHoldPct = null) {
4371
4371
 
4372
4372
  // ../worker/src/triggers.mjs
4373
4373
  var ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request"]);
4374
- var RUN_KINDS = /* @__PURE__ */ new Set(["local", "github"]);
4375
- var PR_ACTIONS = /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened"]);
4374
+ var RUN_KINDS = /* @__PURE__ */ new Set(["local", "github", "gitlab"]);
4375
+ var FORGE_KINDS = /* @__PURE__ */ new Set(["github", "gitlab"]);
4376
+ var PR_ACTIONS = {
4377
+ github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened"]),
4378
+ gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"])
4379
+ };
4376
4380
  var ID_CHARSET = /^[A-Za-z0-9._-]+$/;
4377
4381
  function isNonEmptyString2(value) {
4378
4382
  return typeof value === "string" && value.trim() !== "";
@@ -4388,7 +4392,7 @@ function parseTriggers(text, path) {
4388
4392
  if (!Array.isArray(entries)) {
4389
4393
  throw configError(`triggers file must have a "triggers" array: ${path}`);
4390
4394
  }
4391
- const state = { seenCronIds: /* @__PURE__ */ new Set(), commentCount: 0 };
4395
+ const state = { seenCronIds: /* @__PURE__ */ new Set(), commentCounts: {} };
4392
4396
  return entries.map((entry, index) => normalizeTrigger(entry, index, path, state));
4393
4397
  }
4394
4398
  function normalizeTrigger(entry, index, path, state) {
@@ -4407,7 +4411,7 @@ function normalizeTrigger(entry, index, path, state) {
4407
4411
  throw configError(`${at}: on.type must be one of cron|label|comment|pull_request (got ${JSON.stringify(on.type)}): ${path}`);
4408
4412
  }
4409
4413
  if (!RUN_KINDS.has(run.kind)) {
4410
- throw configError(`${at}: run.kind must be one of local|github (got ${JSON.stringify(run.kind)}): ${path}`);
4414
+ throw configError(`${at}: run.kind must be one of local|github|gitlab (got ${JSON.stringify(run.kind)}): ${path}`);
4411
4415
  }
4412
4416
  if (on.type === "cron") {
4413
4417
  if (run.kind !== "local") {
@@ -4415,8 +4419,8 @@ function normalizeTrigger(entry, index, path, state) {
4415
4419
  }
4416
4420
  return normalizeCron(on, run, index, path, state);
4417
4421
  }
4418
- if (run.kind !== "github") {
4419
- throw configError(`${at}: a ${on.type} trigger produces a github job; run.kind must be "github" (got ${JSON.stringify(run.kind)}): ${path}`);
4422
+ if (!FORGE_KINDS.has(run.kind)) {
4423
+ throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of github|gitlab (got ${JSON.stringify(run.kind)}): ${path}`);
4420
4424
  }
4421
4425
  if (on.type === "label") return normalizeLabel(on, run, index, path);
4422
4426
  if (on.type === "comment") return normalizeComment(on, run, index, path, state);
@@ -4455,11 +4459,36 @@ function normalizeCron(on, run, index, path, state) {
4455
4459
  if (!isNonEmptyString2(run.task)) {
4456
4460
  throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
4457
4461
  }
4462
+ if (run.github !== void 0 && typeof run.github !== "boolean") {
4463
+ throw configError(`cron trigger "${id}": run.github must be true or false when present: ${path}`);
4464
+ }
4465
+ const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
4466
+ const image = validateImageRef(run, `cron trigger "${id}"`, path);
4458
4467
  return {
4459
4468
  on: { type: "cron", id, pattern },
4460
- run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns }
4469
+ 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 }
4461
4470
  };
4462
4471
  }
4472
+ function validatePackagesFlag(run, at, path) {
4473
+ if (run.packages !== void 0 && typeof run.packages !== "boolean") {
4474
+ throw configError(`${at}: run.packages must be true or false when present: ${path}`);
4475
+ }
4476
+ return run.packages;
4477
+ }
4478
+ function validateImageRef(run, at, path) {
4479
+ const image = run.image;
4480
+ if (image === void 0) return void 0;
4481
+ if (typeof image !== "string" || image.trim() === "") {
4482
+ throw configError(`${at}: run.image must be a non-empty string when present: ${path}`);
4483
+ }
4484
+ if (image !== image.trim()) {
4485
+ throw configError(`${at}: run.image must not have leading or trailing whitespace (got ${JSON.stringify(image)}): ${path}`);
4486
+ }
4487
+ if (image.startsWith("-")) {
4488
+ throw configError(`${at}: run.image must not start with "-" -- it is passed as the image positional in the docker argv, where a leading dash parses as a flag (got ${JSON.stringify(image)}): ${path}`);
4489
+ }
4490
+ return image;
4491
+ }
4463
4492
  function validatePredicate(on, index, path, requirePositive) {
4464
4493
  const at = `trigger at index ${index}`;
4465
4494
  for (const key of ["any", "all", "none"]) {
@@ -4480,7 +4509,9 @@ function normalizeLabel(on, run, index, path) {
4480
4509
  if (!isNonEmptyString2(run.flow)) {
4481
4510
  throw configError(`${at}: label trigger run.flow must be a non-empty string: ${path}`);
4482
4511
  }
4483
- return { on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none }, run: { kind: "github", flow: run.flow } };
4512
+ const packages = validatePackagesFlag(run, at, path);
4513
+ const image = validateImageRef(run, at, path);
4514
+ return { on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none }, run: { kind: run.kind, flow: run.flow, packages, image } };
4484
4515
  }
4485
4516
  function normalizeComment(on, run, index, path, state) {
4486
4517
  const at = `trigger at index ${index}`;
@@ -4490,11 +4521,13 @@ function normalizeComment(on, run, index, path, state) {
4490
4521
  if (!isNonEmptyString2(run.flow)) {
4491
4522
  throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string: ${path}`);
4492
4523
  }
4493
- state.commentCount += 1;
4494
- if (state.commentCount > 1) {
4495
- throw configError(`${at}: at most one comment trigger is allowed: ${path}`);
4524
+ state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
4525
+ if (state.commentCounts[run.kind] > 1) {
4526
+ throw configError(`${at}: at most one ${run.kind} comment trigger is allowed: ${path}`);
4496
4527
  }
4497
- return { on: { type: "comment", phrase: on.phrase }, run: { kind: "github", flow: run.flow } };
4528
+ const packages = validatePackagesFlag(run, at, path);
4529
+ const image = validateImageRef(run, at, path);
4530
+ return { on: { type: "comment", phrase: on.phrase }, run: { kind: run.kind, flow: run.flow, packages, image } };
4498
4531
  }
4499
4532
  function normalizePullRequest(on, run, index, path) {
4500
4533
  const at = `trigger at index ${index}`;
@@ -4502,19 +4535,23 @@ function normalizePullRequest(on, run, index, path) {
4502
4535
  if (!Array.isArray(actions) || actions.length === 0) {
4503
4536
  throw configError(`${at}: pull_request on.action must be a non-empty array: ${path}`);
4504
4537
  }
4538
+ const allowed = PR_ACTIONS[run.kind];
4539
+ const expected = [...allowed].join("|");
4505
4540
  for (const a of actions) {
4506
- if (!PR_ACTIONS.has(a)) {
4507
- throw configError(`${at}: pull_request on.action has an unsupported action ${JSON.stringify(a)} (expected labeled|opened|synchronize|reopened): ${path}`);
4541
+ if (!allowed.has(a)) {
4542
+ throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
4508
4543
  }
4509
4544
  }
4510
- const requirePositive = actions.includes("labeled");
4545
+ const requirePositive = run.kind === "github" && actions.includes("labeled");
4511
4546
  const predicate = validatePredicate(on, index, path, requirePositive);
4512
4547
  if (!isNonEmptyString2(run.flow)) {
4513
4548
  throw configError(`${at}: pull_request trigger run.flow must be a non-empty string: ${path}`);
4514
4549
  }
4550
+ const packages = validatePackagesFlag(run, at, path);
4551
+ const image = validateImageRef(run, at, path);
4515
4552
  return {
4516
4553
  on: { type: "pull_request", action: [...actions], any: predicate.any, all: predicate.all, none: predicate.none },
4517
- run: { kind: "github", flow: run.flow }
4554
+ run: { kind: run.kind, flow: run.flow, packages, image }
4518
4555
  };
4519
4556
  }
4520
4557
 
@@ -4690,7 +4727,7 @@ var QUEUE = "pi-jobs";
4690
4727
  function makeQueue(connection) {
4691
4728
  return new Queue(QUEUE, { connection });
4692
4729
  }
4693
- async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
4730
+ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
4694
4731
  const minute = now.toISOString().slice(0, 16);
4695
4732
  const id = jobId ?? localJobId({ folder, flow, task, minute });
4696
4733
  const data = {
@@ -4701,6 +4738,7 @@ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, max
4701
4738
  provider,
4702
4739
  model,
4703
4740
  maxTurns,
4741
+ ...image !== void 0 && { image },
4704
4742
  ...chainDepth !== void 0 && { chainDepth },
4705
4743
  ...parentJobId !== void 0 && { parentJobId }
4706
4744
  };
@@ -4764,6 +4802,48 @@ function gitDirty(folder, { exec: exec2 = execFileSync } = {}) {
4764
4802
  }
4765
4803
  }
4766
4804
 
4805
+ // ../worker/src/packages.mjs
4806
+ import { existsSync, readFileSync } from "node:fs";
4807
+ import { join } from "node:path";
4808
+
4809
+ // ../worker/src/import-pi.mjs
4810
+ import { execFile as execFile2 } from "node:child_process";
4811
+ import { promisify as promisify2 } from "node:util";
4812
+ var ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
4813
+ var execFileAsync = promisify2(execFile2);
4814
+
4815
+ // ../worker/src/docker-run.mjs
4816
+ var CONTAINER_GLOBAL_PI_DIR = "/opt/pi-global";
4817
+
4818
+ // ../worker/src/packages.mjs
4819
+ var PACKAGES_SUBDIR = "packages";
4820
+ var STAGE_MANIFEST = "packages.json";
4821
+ var CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
4822
+ var MAX_DIR_LENGTH = 64;
4823
+ function isNonEmptyString4(value) {
4824
+ return typeof value === "string" && value.trim() !== "";
4825
+ }
4826
+ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists = existsSync } = {}) {
4827
+ if (!globalPiDir) return null;
4828
+ const path = join(globalPiDir, PACKAGES_SUBDIR, STAGE_MANIFEST);
4829
+ try {
4830
+ if (!fileExists(path)) return null;
4831
+ const parsed = JSON.parse(readFile(path, "utf8"));
4832
+ if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.packages)) return null;
4833
+ const packages = [];
4834
+ for (const entry of parsed.packages) {
4835
+ if (entry === null || typeof entry !== "object") return null;
4836
+ const { name, version, dir } = entry;
4837
+ if (!isNonEmptyString4(name) || !isNonEmptyString4(version) || !isNonEmptyString4(dir)) return null;
4838
+ if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
4839
+ packages.push({ name, version, dir });
4840
+ }
4841
+ return { stagedAt: typeof parsed.stagedAt === "string" ? parsed.stagedAt : null, packages };
4842
+ } catch {
4843
+ return null;
4844
+ }
4845
+ }
4846
+
4767
4847
  // src/read-model.mjs
4768
4848
  function resolvePaths(env = process.env) {
4769
4849
  return {
@@ -4772,6 +4852,10 @@ function resolvePaths(env = process.env) {
4772
4852
  settingsFile: settingsFilePath(env),
4773
4853
  triggersPath: env.PI_TRIGGERS_FILE ?? "deploy/triggers.json",
4774
4854
  pauseWindowsPath: env.PI_PAUSE_WINDOWS_FILE ?? "deploy/pause-windows.json",
4855
+ // The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY), where the staged third-party pi
4856
+ // packages live under `packages/`. `|| null` so unset AND empty both read as "no overlay" -- the
4857
+ // normal deployment, in which no trigger can arm any package.
4858
+ globalPiDir: env.PI_GLOBAL_PI_DIR || null,
4775
4859
  captureJobLogs: env.PI_CAPTURE_JOB_LOGS === "1",
4776
4860
  // The two dispatch_run bounds the extension enforces producer-side, read DIRECTLY from env (never
4777
4861
  // loadConfig, which throws on unrelated GitHub-auth problems). `delimitedList`/`nonNegativeInt` are
@@ -5058,19 +5142,19 @@ function listRuns({ logsDir, limit = 10, fs = nodeFs2 }) {
5058
5142
  const records = [];
5059
5143
  for (const name of names) {
5060
5144
  if (!name.endsWith(".json")) continue;
5061
- const record = readJsonFile(join(logsDir, name), fs);
5145
+ const record = readJsonFile(join2(logsDir, name), fs);
5062
5146
  if (record && typeof record === "object") records.push(record);
5063
5147
  }
5064
5148
  records.sort(byEndedAtDesc);
5065
5149
  return records.slice(0, cap);
5066
5150
  }
5067
5151
  function readRun({ logsDir, jobId, fs = nodeFs2 }) {
5068
- return readJsonFile(join(logsDir, `${sanitizeJobId(jobId)}.json`), fs);
5152
+ return readJsonFile(join2(logsDir, `${sanitizeJobId(jobId)}.json`), fs);
5069
5153
  }
5070
5154
  function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs2 }) {
5071
5155
  let text;
5072
5156
  try {
5073
- text = fs.readFileSync(join(logsDir, `${sanitizeJobId(jobId)}.log`), "utf8");
5157
+ text = fs.readFileSync(join2(logsDir, `${sanitizeJobId(jobId)}.log`), "utf8");
5074
5158
  } catch {
5075
5159
  return { missing: true };
5076
5160
  }
@@ -5114,6 +5198,9 @@ function normalizeTriggerForDisplay(entry) {
5114
5198
  if (on === null || typeof on !== "object") return null;
5115
5199
  const run = entry.run !== null && typeof entry.run === "object" ? entry.run : {};
5116
5200
  const flow = typeof run.flow === "string" ? run.flow : null;
5201
+ const packages = run.packages !== false;
5202
+ const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
5203
+ const forge = typeof run.kind === "string" && run.kind.trim() !== "" ? run.kind : null;
5117
5204
  switch (on.type) {
5118
5205
  case "cron":
5119
5206
  return {
@@ -5124,12 +5211,14 @@ function normalizeTriggerForDisplay(entry) {
5124
5211
  flow,
5125
5212
  // Optional per-cron model override (passthrough into job.data); null when the entry resolves the
5126
5213
  // deployment default. Surfaced so the drill-in shows which schedules pin their own model.
5127
- model: typeof run.model === "string" ? run.model : null
5214
+ model: typeof run.model === "string" ? run.model : null,
5215
+ packages,
5216
+ image
5128
5217
  };
5129
5218
  case "label":
5130
- return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow };
5219
+ return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, forge };
5131
5220
  case "comment":
5132
- return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow };
5221
+ return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, forge };
5133
5222
  case "pull_request":
5134
5223
  return {
5135
5224
  type: "pull_request",
@@ -5137,7 +5226,10 @@ function normalizeTriggerForDisplay(entry) {
5137
5226
  any: normalizeSelector(on.any),
5138
5227
  all: normalizeSelector(on.all),
5139
5228
  none: normalizeSelector(on.none),
5140
- flow
5229
+ flow,
5230
+ packages,
5231
+ image,
5232
+ forge
5141
5233
  };
5142
5234
  default:
5143
5235
  return null;
@@ -5147,6 +5239,28 @@ function normalizeSelector(value) {
5147
5239
  if (!Array.isArray(value)) return [];
5148
5240
  return value.filter((member) => typeof member === "string");
5149
5241
  }
5242
+ function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest = readStageManifest } = {}) {
5243
+ const empty = { stagedAt: null, packages: [] };
5244
+ if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
5245
+ let manifest;
5246
+ try {
5247
+ manifest = readManifest({
5248
+ globalPiDir,
5249
+ readFile: (path) => fs.readFileSync(path, "utf8"),
5250
+ fileExists: (path) => fs.existsSync(path)
5251
+ });
5252
+ } catch {
5253
+ return empty;
5254
+ }
5255
+ const entries = Array.isArray(manifest?.packages) ? manifest.packages : [];
5256
+ return {
5257
+ stagedAt: typeof manifest?.stagedAt === "string" ? manifest.stagedAt : null,
5258
+ packages: entries.filter((p) => p && typeof p.name === "string").map(nameAtVersion)
5259
+ };
5260
+ }
5261
+ function nameAtVersion(pkg) {
5262
+ return typeof pkg.version === "string" && pkg.version !== "" ? `${pkg.name}@${pkg.version}` : pkg.name;
5263
+ }
5150
5264
  function listRunIds({ logsDir, fs = nodeFs2 }) {
5151
5265
  let names;
5152
5266
  try {
@@ -5279,17 +5393,20 @@ function renderTriggers({ schedulers, triggers } = {}) {
5279
5393
  }
5280
5394
  function triggerLine(t) {
5281
5395
  const flow = t?.flow ?? "-";
5396
+ const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
5397
+ const pkgs = t?.packages === true ? " [packages]" : "";
5398
+ const img = t?.image ? ` [image ${t.image}]` : "";
5282
5399
  switch (t?.type) {
5283
5400
  case "cron":
5284
- return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}`;
5401
+ return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}`;
5285
5402
  case "label":
5286
- return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}`;
5403
+ return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}`;
5287
5404
  case "comment":
5288
- return `comment "${t.phrase ?? "-"}" \u2192 ${flow}`;
5405
+ return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}`;
5289
5406
  case "pull_request": {
5290
5407
  const clauses = ruleClauses(t);
5291
5408
  const action = `action[${(t.action ?? []).join(",")}]`;
5292
- return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}`;
5409
+ return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}`;
5293
5410
  }
5294
5411
  default:
5295
5412
  return "(unknown trigger)";
@@ -5571,6 +5688,11 @@ function createDashboardDeps(paths) {
5571
5688
  settings: readSettingsView({ settingsFile: paths.settingsFile }),
5572
5689
  triggers: readTriggers({ triggersPath: paths.triggersPath }),
5573
5690
  pauseWindows: readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath }),
5691
+ // The operator's staged third-party pi packages (REQ-GLOBAL-PI-OVERLAY), for the armed triggers'
5692
+ // trust model. Like the four reads above it is a plain file read whose fs access lives entirely in
5693
+ // read-model.mjs -- this module never touches the filesystem -- and it degrades to a safe empty
5694
+ // shape rather than throwing, so a broken overlay cannot take the whole snapshot down.
5695
+ stagedPackages: readStagedPackages({ globalPiDir: paths.globalPiDir }),
5574
5696
  // ONLY the id off the active Job -- a Job's `.data` holds issue title/body/username (PII), so it
5575
5697
  // never enters the snapshot (no-pii-in-logs, INT-RUN-HISTORY-FILE-CONTRACT).
5576
5698
  activeJobId: activeList?.[0]?.id ?? null
@@ -5812,7 +5934,7 @@ function renderPanel(snapshot, width, state, styler) {
5812
5934
  const detailTitle = `trigger \xB7 ${t?.type ?? "?"}`;
5813
5935
  const dw = framed ? Math.min(Math.trunc(width), DRILL_WIDTH) : Math.trunc(width);
5814
5936
  const sched = cronSchedInfo(t, snapshot);
5815
- const lines = renderTriggerDetail(t, framed ? dw - 4 : 24, styler, sched);
5937
+ const lines = renderTriggerDetail(t, framed ? dw - 4 : 24, styler, sched, snapshot?.stagedPackages);
5816
5938
  if (!framed) return [detailTitle, "", ...lines.map((l) => styler.stripAnsi(l)), "", "e edit \xB7 x delete \xB7 esc back"];
5817
5939
  const boxed = frame(styler, { title: detailTitle, width: dw, lines, footer: triggerDetailHints(dw - 4, styler) });
5818
5940
  return centerBlock(boxed, Math.trunc(width), dw);
@@ -5984,7 +6106,10 @@ function triggerRow(t, sel, inner, styler) {
5984
6106
  const cursor = sel ? styler.fg("accent", "\u203A") : " ";
5985
6107
  const kind = t?.type ?? "?";
5986
6108
  const badge = styler.cell(kind, KIND_WIDTH, { color: KIND_COLOR[kind] ?? "muted" });
5987
- return fitLine(`${cursor} ${badge} ${matchColored(t, styler)} ${targetColored(t, styler)}`, inner, styler);
6109
+ const forge = t?.forge && t.forge !== "github" ? " " + styler.fg("accent", `[${t.forge}]`) : "";
6110
+ const pkgs = t?.packages === true ? " " + styler.fg("warning", "[packages]") : "";
6111
+ const img = t?.image ? " " + styler.fg("accent", `[${t.image}]`) : "";
6112
+ return fitLine(`${cursor} ${badge} ${matchColored(t, styler)} ${targetColored(t, styler)}${forge}${pkgs}${img}`, inner, styler);
5988
6113
  }
5989
6114
  function matchColored(t, styler) {
5990
6115
  switch (t?.type) {
@@ -6084,7 +6209,7 @@ function settingsLines(settings2, inner, styler) {
6084
6209
  const l2 = [kv("dailyCap", o.dailyCap), tk("dailyTokenCap", o.dailyTokenCap), kv("concurrency", o.concurrency), styler.fg("muted", "softHold") + " " + styler.fg("text", pctVal)].join(gap);
6085
6210
  return [fitLine(l1, inner, styler), fitLine(l2, inner, styler)];
6086
6211
  }
6087
- function renderTriggerDetail(t, inner, styler, sched = null) {
6212
+ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
6088
6213
  if (!t) return [styler.cell("(no trigger)", inner, { color: "dim" })];
6089
6214
  const out = [];
6090
6215
  const kv = (k, v, color = "text") => fitLine(styler.cell(k, 12, { color: "muted" }) + " " + styler.fg(color, v), inner, styler);
@@ -6122,15 +6247,25 @@ function renderTriggerDetail(t, inner, styler, sched = null) {
6122
6247
  out.push(kv("folder", `${t.folder ?? "-"}`, "success"));
6123
6248
  out.push(kv("model", t.model ?? "deployment default", t.model ? "accent" : "dim"));
6124
6249
  } else {
6125
- out.push(kv("job", "github", "accent"));
6126
- out.push(kv("target", "the triggering repo#issue / PR", "accent"));
6250
+ out.push(kv("job", t.forge ?? "-", "accent"));
6251
+ out.push(kv("target", t.forge === "gitlab" ? "the triggering project#issue / !MR" : "the triggering repo#issue / PR", "accent"));
6127
6252
  out.push(kv("model", "deployment default", "dim"));
6128
6253
  }
6254
+ out.push(kv("image", t.image ?? "deployment default", t.image ? "accent" : "dim"));
6129
6255
  blank();
6130
6256
  section("trust model");
6131
6257
  for (const line of trustModel(t)) out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("text", line), inner, styler));
6258
+ if (t.packages === true) {
6259
+ const loads = (text) => out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("warning", text), inner, styler));
6260
+ loads(`packages loaded \xB7 ${stagedNames(staged)}`);
6261
+ loads("third-party code on adversarial input, open network egress");
6262
+ }
6132
6263
  return out;
6133
6264
  }
6265
+ function stagedNames(staged) {
6266
+ const list = Array.isArray(staged?.packages) ? staged.packages : [];
6267
+ return list.length > 0 ? list.join(" \xB7 ") : "(none staged in the overlay)";
6268
+ }
6134
6269
  function trustModel(t) {
6135
6270
  switch (t?.type) {
6136
6271
  case "cron":
@@ -6248,6 +6383,10 @@ function renderRunDetail(record, inner, styler, allRuns = []) {
6248
6383
  out.push(kv("turns", turnBits.join(" \xB7 ")));
6249
6384
  const cost = typeof r.tokens?.cost === "number" ? ` \xB7 $${r.tokens.cost.toFixed(4)}` : "";
6250
6385
  out.push(kv("tokens", `${show(r.tokens?.total)}${cost}`));
6386
+ const otherTotal = r.tokens?.otherTotal;
6387
+ if (typeof otherTotal === "number" && Number.isFinite(otherTotal) && otherTotal > 0) {
6388
+ out.push(kv("of which", `subagents: ${otherTotal}`, "dim"));
6389
+ }
6251
6390
  const children = (Array.isArray(allRuns) ? allRuns : []).filter((x) => x?.parentJobId && r.jobId && x.parentJobId === r.jobId);
6252
6391
  const chainBits = [r.parentJobId ? `child of ${r.parentJobId}` : "root"];
6253
6392
  if (r.chainDepth !== null && r.chainDepth !== void 0) chainBits.push(`depth ${r.chainDepth}`);
@@ -6465,11 +6604,12 @@ function registerTools(pi) {
6465
6604
  pi.registerTool({
6466
6605
  name: "dispatch_trigger_add",
6467
6606
  label: "pi-dispatch add trigger",
6468
- 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. For github 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.",
6607
+ description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened, gitlab is open|update|reopen|approved. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
6469
6608
  executionMode: "sequential",
6470
6609
  parameters: typebox_exports.Object({
6471
6610
  kind: typebox_exports.String(),
6472
6611
  flow: typebox_exports.String(),
6612
+ forge: typebox_exports.Optional(typebox_exports.String()),
6473
6613
  id: typebox_exports.Optional(typebox_exports.String()),
6474
6614
  pattern: typebox_exports.Optional(typebox_exports.String()),
6475
6615
  folder: typebox_exports.Optional(typebox_exports.String()),
@@ -6591,6 +6731,51 @@ ${JSON.stringify(w)}` },
6591
6731
  return toolText(JSON.stringify(result));
6592
6732
  }
6593
6733
  });
6734
+ pi.registerTool({
6735
+ name: "dispatch_pause_edit",
6736
+ label: "pi-dispatch edit pause window",
6737
+ description: "Changes fields of an existing pause window (by array index from dispatch_pauses) and applies it live. Provide only the fields to change (scope/from/to/tz/days/dateFrom/dateTo); the rest keep their current value. The operator MUST approve a confirm dialog showing the before->after; refused with no interactive operator.",
6738
+ executionMode: "sequential",
6739
+ parameters: typebox_exports.Object({
6740
+ index: typebox_exports.Integer({ minimum: 0 }),
6741
+ scope: typebox_exports.Optional(typebox_exports.String()),
6742
+ from: typebox_exports.Optional(typebox_exports.String()),
6743
+ to: typebox_exports.Optional(typebox_exports.String()),
6744
+ tz: typebox_exports.Optional(typebox_exports.String()),
6745
+ days: typebox_exports.Optional(typebox_exports.Array(typebox_exports.String())),
6746
+ dateFrom: typebox_exports.Optional(typebox_exports.String()),
6747
+ dateTo: typebox_exports.Optional(typebox_exports.String())
6748
+ }),
6749
+ async execute(_id, params, _signal, _onUpdate, ctx) {
6750
+ const paths = resolvePaths(process.env);
6751
+ const p = readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath });
6752
+ const list = Array.isArray(p?.windows) ? p.windows : [];
6753
+ const cur = list[params.index];
6754
+ if (!cur) throw new Error(`no pause window at index ${params.index} (have ${list.length})`);
6755
+ const before = buildPauseWindow(cur);
6756
+ const merged = buildPauseWindow({
6757
+ scope: params.scope ?? cur.scope,
6758
+ from: params.from ?? cur.from,
6759
+ to: params.to ?? cur.to,
6760
+ tz: params.tz ?? cur.tz,
6761
+ days: params.days ?? cur.days,
6762
+ dateFrom: params.dateFrom ?? cur.dateFrom,
6763
+ dateTo: params.dateTo ?? cur.dateTo
6764
+ });
6765
+ const result = await confirmedWrite(
6766
+ ctx,
6767
+ { title: `Edit pause window #${params.index + 1}`, message: `pause window #${params.index + 1}:
6768
+ ${JSON.stringify(before)}
6769
+ \u2192 ${JSON.stringify(merged)}` },
6770
+ () => {
6771
+ const res = writePauseWindows({ pauseWindowsPath: paths.pauseWindowsPath, mutate: (l) => l.map((w, i) => i === params.index ? merged : w) });
6772
+ if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
6773
+ return { applied: true, index: params.index, window: merged };
6774
+ }
6775
+ );
6776
+ return toolText(JSON.stringify(result));
6777
+ }
6778
+ });
6594
6779
  pi.registerTool({
6595
6780
  name: "dispatch_trigger_delete",
6596
6781
  label: "pi-dispatch delete trigger",
@@ -6640,13 +6825,14 @@ function buildTriggerEntry(kind, f) {
6640
6825
  if (maxTurns !== void 0) run.maxTurns = maxTurns;
6641
6826
  return { on: { type: "cron", id: f.id, pattern: f.pattern }, run };
6642
6827
  }
6643
- if (kind === "label") return { on: { type: "label", any: asWords(f.labels ?? f.any) }, run: { kind: "github", flow: f.flow } };
6644
- if (kind === "comment") return { on: { type: "comment", phrase: f.phrase }, run: { kind: "github", flow: f.flow } };
6828
+ const forge = optStr(f.forge) ?? "github";
6829
+ if (kind === "label") return { on: { type: "label", any: asWords(f.labels ?? f.any) }, run: { kind: forge, flow: f.flow } };
6830
+ if (kind === "comment") return { on: { type: "comment", phrase: f.phrase }, run: { kind: forge, flow: f.flow } };
6645
6831
  if (kind === "pull_request") {
6646
6832
  const on = { type: "pull_request", action: asWords(f.action) };
6647
6833
  const any = asWords(f.labels ?? f.any);
6648
6834
  if (any.length > 0) on.any = any;
6649
- return { on, run: { kind: "github", flow: f.flow } };
6835
+ return { on, run: { kind: forge, flow: f.flow } };
6650
6836
  }
6651
6837
  return null;
6652
6838
  }
@@ -6814,9 +7000,10 @@ async function handleDashboardAction(result, paths, ctx) {
6814
7000
  }
6815
7001
  }
6816
7002
  async function managePausesViaDialogs(paths, ui, notify) {
6817
- const action = await ui.select("Pause windows", ["Add a pause window", "Delete a pause window"]);
7003
+ const action = await ui.select("Pause windows", ["Add a pause window", "Edit a pause window", "Delete a pause window"]);
6818
7004
  if (!action) return;
6819
7005
  if (action.startsWith("Add")) return addPauseWindowViaDialogs(paths, ui, notify);
7006
+ if (action.startsWith("Edit")) return editPauseWindowViaDialogs(paths, ui, notify);
6820
7007
  return deletePauseWindowViaDialogs(paths, ui, notify);
6821
7008
  }
6822
7009
  async function addPauseWindowViaDialogs(paths, ui, notify) {
@@ -6838,6 +7025,47 @@ async function addPauseWindowViaDialogs(paths, ui, notify) {
6838
7025
  const res = writePauseWindows({ pauseWindowsPath: paths.pauseWindowsPath, mutate: (list) => [...list, w] });
6839
7026
  notify?.(res.ok ? `pause window added (live) \u2014 ${w.scope} ${w.from}-${w.to}` : `add rejected: ${res.invalid}`, res.ok ? "info" : "error");
6840
7027
  }
7028
+ async function editPauseWindowViaDialogs(paths, ui, notify) {
7029
+ const p = readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath });
7030
+ const list = Array.isArray(p?.windows) ? p.windows : [];
7031
+ if (list.length === 0) {
7032
+ notify?.("no pause windows to edit", "info");
7033
+ return;
7034
+ }
7035
+ const labels = list.map((w, i) => `#${i + 1} ${w.scope} ${w.from}-${w.to} ${w.tz}${w.days ? ` [${w.days.join(",")}]` : ""}`);
7036
+ const picked = await ui.select("Edit which pause window", labels);
7037
+ if (!picked) return;
7038
+ const index = labels.indexOf(picked);
7039
+ if (index < 0) return;
7040
+ const cur = list[index];
7041
+ const ask = async (label, current) => ui.input(`${label} \u2014 blank keeps "${current}"`, current);
7042
+ const keep = (v, current) => v === void 0 || v.trim() === "" ? current : v;
7043
+ const scope = await ask("scope", cur.scope ?? "");
7044
+ if (scope === void 0) return;
7045
+ const from = await ask("from (HH:MM)", cur.from ?? "");
7046
+ if (from === void 0) return;
7047
+ const to = await ask("to (HH:MM)", cur.to ?? "");
7048
+ if (to === void 0) return;
7049
+ const tz = await ask("tz (IANA / UTC)", cur.tz ?? "UTC");
7050
+ if (tz === void 0) return;
7051
+ const days = await ask("days (space-separated)", (cur.days ?? []).join(" "));
7052
+ if (days === void 0) return;
7053
+ const dateFrom = await ask("dateFrom (YYYY-MM-DD)", cur.dateFrom ?? "");
7054
+ if (dateFrom === void 0) return;
7055
+ const dateTo = await ask("dateTo (YYYY-MM-DD)", cur.dateTo ?? "");
7056
+ if (dateTo === void 0) return;
7057
+ const merged = buildPauseWindow({
7058
+ scope: keep(scope, cur.scope),
7059
+ from: keep(from, cur.from),
7060
+ to: keep(to, cur.to),
7061
+ tz: keep(tz, cur.tz),
7062
+ days: keep(days, cur.days),
7063
+ dateFrom: keep(dateFrom, cur.dateFrom),
7064
+ dateTo: keep(dateTo, cur.dateTo)
7065
+ });
7066
+ const res = writePauseWindows({ pauseWindowsPath: paths.pauseWindowsPath, mutate: (l) => l.map((w, i) => i === index ? merged : w) });
7067
+ notify?.(res.ok ? `pause window #${index + 1} updated (live) \u2014 ${merged.scope} ${merged.from}-${merged.to}` : `edit rejected: ${res.invalid}`, res.ok ? "info" : "error");
7068
+ }
6841
7069
  async function deletePauseWindowViaDialogs(paths, ui, notify) {
6842
7070
  const p = readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath });
6843
7071
  const list = Array.isArray(p?.windows) ? p.windows : [];
@@ -6878,25 +7106,32 @@ async function addTriggerViaDialogs(paths, ui, notify) {
6878
7106
  if (maxTurns === void 0) return;
6879
7107
  entry = buildTriggerEntry("cron", { id, pattern, folder, flow, task, model, provider, maxTurns });
6880
7108
  } else if (kind === "label") {
6881
- const labels = await ui.input("labels \u2014 space-separated, any-of (a collaborator applies one to fire)", "pi:fix");
7109
+ const forge = await ui.input("forge \u2014 github or gitlab", "github");
7110
+ if (forge === void 0) return;
7111
+ const labels = await ui.input("labels \u2014 space-separated, any-of (a project member applies one to fire)", "pi:fix");
6882
7112
  if (labels === void 0) return;
6883
7113
  const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the issue text is the task)", "fix");
6884
7114
  if (flow === void 0) return;
6885
- entry = buildTriggerEntry("label", { labels, flow });
7115
+ entry = buildTriggerEntry("label", { labels, flow, forge });
6886
7116
  } else if (kind === "comment") {
7117
+ const forge = await ui.input("forge \u2014 github or gitlab", "github");
7118
+ if (forge === void 0) return;
6887
7119
  const phrase = await ui.input("trigger phrase \u2014 a comment containing this fires the flow (e.g. @pi)", "@pi");
6888
7120
  if (phrase === void 0) return;
6889
7121
  const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the comment/issue text is the task)", "fix");
6890
7122
  if (flow === void 0) return;
6891
- entry = buildTriggerEntry("comment", { phrase, flow });
7123
+ entry = buildTriggerEntry("comment", { phrase, flow, forge });
6892
7124
  } else {
6893
- const action = await ui.input("PR actions \u2014 space-separated: labeled opened synchronize reopened", "labeled");
7125
+ const forge = await ui.input("forge \u2014 github or gitlab", "github");
7126
+ if (forge === void 0) return;
7127
+ const actionHint = forge === "gitlab" ? "open update reopen approved" : "labeled opened synchronize reopened";
7128
+ const action = await ui.input(`MR/PR actions \u2014 space-separated: ${actionHint}`, forge === "gitlab" ? "update" : "labeled");
6894
7129
  if (action === void 0) return;
6895
7130
  const labels = await ui.input("labels for 'labeled' \u2014 space-separated (blank for the auto actions)", "pi:review");
6896
7131
  if (labels === void 0) return;
6897
7132
  const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the PR text is the task)", "review");
6898
7133
  if (flow === void 0) return;
6899
- entry = buildTriggerEntry("pull_request", { action, labels, flow });
7134
+ entry = buildTriggerEntry("pull_request", { action, labels, flow, forge });
6900
7135
  }
6901
7136
  const res = writeTriggers({ triggersPath: paths.triggersPath, mutate: (list) => [...list, entry] });
6902
7137
  notify?.(res.ok ? `trigger added (live) \u2014 ${kind} \u2192 ${entry.run.flow}` : `add rejected: ${res.invalid}`, res.ok ? "info" : "error");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-admin",
3
- "version": "0.1.2",
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 triggers (cron/label/comment/PR), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
3
+ "version": "0.1.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 and GitLab triggers (cron/label/comment/PR/MR), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
@@ -11,6 +11,8 @@
11
11
  "ai-agent",
12
12
  "autonomous-agents",
13
13
  "self-hosted",
14
+ "github",
15
+ "gitlab",
14
16
  "job-queue",
15
17
  "cron",
16
18
  "tui",
@@ -30,9 +30,10 @@ dialog before it takes effect**:
30
30
  - `dispatch_set` — change a limit/setting (e.g. `dailyCap`, `weeklyCap`, `maxTurns`, `model`). Omit `value`
31
31
  to unset.
32
32
  - `dispatch_trigger_add` / `dispatch_trigger_edit` / `dispatch_trigger_delete` — manage triggers.
33
- - `dispatch_pause_add` / `dispatch_pause_delete` — manage scheduled pause windows (per folder/repo "quiet
34
- hours": runs for a scope are deferred between certain times and auto-resume after; `dispatch_pauses` lists
35
- them). Deferring never drops a job and costs no budget.
33
+ - `dispatch_pause_add` / `dispatch_pause_edit` / `dispatch_pause_delete` — manage scheduled pause windows
34
+ (per folder/repo "quiet hours": runs for a scope are deferred between certain times and auto-resume after;
35
+ `dispatch_pauses` lists them with their index). `dispatch_pause_edit` is a partial change — pass the index
36
+ plus only the fields to alter. Deferring never drops a job and costs no budget.
36
37
 
37
38
  Use them like this:
38
39
 
@@ -48,3 +49,79 @@ Use them like this:
48
49
  the change can't be made without an operator at the terminal — it is not a bug to work around.
49
50
 
50
51
  The confirm is the approval step. Treat a decline as a final, legitimate answer.
52
+
53
+ ## Staged packages — `run.packages`, and why you cannot set it
54
+
55
+ When a trigger fires, the job loads the third-party **pi packages the operator staged** into their global
56
+ overlay dir (`PI_GLOBAL_PI_DIR`, under `packages/`, pinned by version). `run.packages` is an **opt-out**:
57
+ absent and `true` both load them, and only an explicit `run.packages: false` withholds them from that one
58
+ trigger. So the question to answer for a user is never "is this trigger armed" — it is "did this trigger
59
+ decline".
60
+
61
+ It matters because a loading trigger runs pinned third-party code against adversarial input (issue/PR/comment
62
+ text) with open network egress. So the panel makes it visible: a loading trigger is badged `[packages]` in
63
+ the trigger list, and its trust-model drill-in names the staged `name@version` set.
64
+
65
+ **Which packages are staged, and whether a trigger declines them, are both operator edits to reviewed files
66
+ — never a panel action and never a tool call.** This is deliberate, not a gap:
67
+
68
+ - `dispatch_triggers` shows you *whether* a trigger loads them. The `/dispatch` panel displays it too, and
69
+ has no key that sets it.
70
+ - `dispatch_trigger_add` and `dispatch_trigger_edit` **have no `packages` parameter**. You can change it in
71
+ neither direction — you cannot make a trigger load packages and you cannot make one decline them — the
72
+ same reason `dispatch_run` withholds the provider and model from you.
73
+
74
+ So if a user asks you to change a trigger's packages flag, or to stage a package: **say plainly that you
75
+ cannot, and that it is an edit they make to `triggers.json` (and to their overlay dir) themselves.** Do not
76
+ attempt it through `dispatch_trigger_edit`, do not write the triggers file by another route, and do not treat
77
+ the missing parameter as a bug to work around. Reporting which triggers load the staged set and explaining
78
+ the change they would make is the whole of your part.
79
+
80
+ ## The job image — `run.image`, and why you cannot set it
81
+
82
+ A trigger may carry `run.image`: the container image that trigger's jobs run in. Absent means the
83
+ deployment default (`PI_JOB_IMAGE`). It exists so one flow can have a Python toolchain and another Node +
84
+ Playwright, without one image carrying the union of both.
85
+
86
+ Report it when asked, and be precise about what it does and does not decide. **Which image a job runs is
87
+ which code it runs** — the pi version, the runner, the guardrail floor and the loader's discovery posture
88
+ all come from the image. What it does *not* decide is what the container may do: `--cap-drop=ALL`, the
89
+ non-root user, the read-only `/job` and the closed env allowlist are built by the worker for every image
90
+ alike. The panel shows the tag in the trigger list and states it in the drill-in either way.
91
+
92
+ **You cannot change it, in either direction.** `dispatch_trigger_add` and `dispatch_trigger_edit` have **no
93
+ `image` parameter**, `dispatch_run` has none, and there is no allowlist for you to consult — because there
94
+ is nothing model-callable to bound. Naming an image is an operator edit to the reviewed `triggers.json`,
95
+ exactly like `run.packages`.
96
+
97
+ So if a user asks you to point a trigger at a different image, or to build one: **say plainly that you
98
+ cannot, and that it is an edit they make to `triggers.json` themselves.** Do not route around it via
99
+ `dispatch_trigger_edit`, which changes the flow only. Two useful things you *can* say: the image must be
100
+ built or pulled on the worker's own host, because jobs run with `--pull=never` and nothing is fetched at
101
+ job time; and `pi-dispatch doctor` lists every image their triggers name and flags one that is missing.
102
+
103
+ ## The forge a trigger listens to — `run.kind`
104
+
105
+ A webhook trigger names its forge: `"kind": "github"` or `"kind": "gitlab"`. Everything else about the
106
+ trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `flow`, `packages`, `image`.
107
+
108
+ Two things are NOT the same, and both refuse at load rather than misbehaving quietly:
109
+
110
+ - **`pull_request` actions are the forge's own words.** GitHub takes
111
+ `labeled | opened | synchronize | reopened`; GitLab takes `open | update | reopen | approved`. A word
112
+ from the wrong forge is refused when the file is written. It would not break anything otherwise — it
113
+ would simply never match an event, and the trigger would look configured while doing nothing.
114
+ - **One `comment` trigger per forge.** Two GitHub comment triggers are refused; one GitHub and one GitLab
115
+ are fine.
116
+
117
+ `dispatch_trigger_add` takes an optional `forge` parameter, defaulting to `github`. Unlike `image`, this
118
+ one IS offered to the model — a model that can already add a GitHub trigger can already arm a paid run,
119
+ and naming GitLab instead does not widen that. Both paths stay behind the same operator confirm.
120
+
121
+ If you are asked why a GitLab trigger did not fire, the usual answers in order:
122
+
123
+ 1. **The actor was not a Developer.** Every GitLab trigger is gated on the actor's project access level,
124
+ including label triggers — a GitLab label is not an approval the way a GitHub one is.
125
+ 2. **No label was added by that event.** GitLab has no `labeled` action; the trigger fires on the labels
126
+ an event *added*, so editing an already-labelled issue does nothing. This is deliberate.
127
+ 3. **The action word belongs to the other forge.** See above.