@edgehero/pi-dispatch-admin 0.1.3 → 0.1.5

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
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/banner.png" alt="pi-dispatch — run the pi coding agent as a self-hosted service" width="880">
2
+ <img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/banner.png?v=0.4.0" alt="pi-dispatch — run the pi coding agent as a self-hosted service" width="880">
3
3
  </p>
4
4
 
5
5
  # pi-dispatch — run the pi coding agent as a self-hosted service
@@ -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`
@@ -40,11 +40,13 @@ Every trigger produces the same job, through the same path — one queue, one co
40
40
  This extension puts a live TUI over the whole deployment in one command:
41
41
 
42
42
  <p align="center">
43
- <img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png" alt="The /dispatch panel: status, spend meters, triggers, runs, and settings" width="820">
43
+ <img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png?v=0.4.0" alt="The /dispatch panel: status, spend meters, triggers, runs, and settings" width="820">
44
44
  </p>
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,15 +4220,92 @@ __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
+ // ../worker/src/forges.mjs
4227
+ var FORGES = {
4228
+ github: {
4229
+ jobIdPrefix: "gh-",
4230
+ deliveryIdName: "X-GitHub-Delivery GUID",
4231
+ pullRequestSep: "#",
4232
+ tokenVars: ["GITHUB_TOKEN", "GH_TOKEN"],
4233
+ hostVar: null,
4234
+ prLabelAction: "labeled"
4235
+ },
4236
+ gitlab: {
4237
+ jobIdPrefix: "gl-",
4238
+ deliveryIdName: "webhook-id / Idempotency-Key",
4239
+ pullRequestSep: "!",
4240
+ tokenVars: ["GITLAB_TOKEN", "GL_TOKEN"],
4241
+ hostVar: "GITLAB_HOST",
4242
+ // A label added to a merge request arrives as a plain `update`, indistinguishable from any other
4243
+ // edit, so there is no action for a positive-selector rule to attach to. (Separately, a GitLab label
4244
+ // is not an approval at all -- a Guest can set one at issue creation -- which is why every gitlab
4245
+ // trigger is gated on the actor's resolved access level.)
4246
+ prLabelAction: null
4247
+ },
4248
+ forgejo: {
4249
+ // Forgejo's webhook transport is byte-compatible with GitHub's -- it signs the raw body HMAC-SHA256
4250
+ // and sends X-Hub-Signature-256, X-GitHub-Delivery and X-GitHub-Event. So the delivery id is a GUID
4251
+ // with the same across-retry stability GitHub's has, and REQ-DEDUP-BY-DELIVERY-GUID transfers
4252
+ // unchanged. Only the PREFIX differs, and only so the id spaces stay disjoint.
4253
+ jobIdPrefix: "fj-",
4254
+ deliveryIdName: "X-GitHub-Delivery GUID",
4255
+ // Forgejo numbers issues and pull requests from ONE per-repository sequence, exactly as GitHub does
4256
+ // -- an issue and a PR cannot share an index. So `#` names one thing and no discriminator is needed.
4257
+ pullRequestSep: "#",
4258
+ // `tea` reads GITEA_SERVER_TOKEN; the unprefixed name is what the API examples and most scripts use.
4259
+ tokenVars: ["FORGEJO_TOKEN", "GITEA_SERVER_TOKEN"],
4260
+ hostVar: "FORGEJO_HOST",
4261
+ // Forgejo names the action, so the positive-selector rule applies exactly as it does on GitHub.
4262
+ // Note this is HYGIENE, not the gate: unlike GitHub, every forgejo trigger is additionally gated on
4263
+ // the actor's resolved repository permission -- see filter-forgejo.mjs for why that is not
4264
+ // redundant belt-and-braces but the only claim about Forgejo this project is willing to make.
4265
+ prLabelAction: "label_updated"
4266
+ },
4267
+ azure: {
4268
+ // Azure Service Hooks send NO delivery-id header at all, so this id comes from the body's top-level
4269
+ // `id` GUID -- the one departure `verify-gitlab.mjs` explicitly refuses to make, taken here because
4270
+ // the refusal is right for a forge that HAS a header and inapplicable to one that has none.
4271
+ // (Issue #43 proposes `notificationId`, which is a per-subscription integer sequence: two
4272
+ // subscriptions collide on delivery 1.)
4273
+ jobIdPrefix: "az-",
4274
+ deliveryIdName: "service hook payload id",
4275
+ // Azure numbers work items and pull requests from SEPARATE sequences -- work item ids are
4276
+ // organization-scoped, pull request ids are not -- so `project/repo#123` and `project/repo!123` are
4277
+ // different objects and would collide on one separator, exactly as GitLab's issue #5 and MR !5 do.
4278
+ pullRequestSep: "!",
4279
+ // `az repos` reads AZURE_DEVOPS_EXT_PAT; SYSTEM_ACCESSTOKEN is what a pipeline-shaped script expects.
4280
+ tokenVars: ["AZURE_DEVOPS_EXT_PAT", "SYSTEM_ACCESSTOKEN"],
4281
+ hostVar: "AZURE_DEVOPS_ORG_URL",
4282
+ // Azure attaches no labels to a pull request at all, so there is no action to attach the rule to and
4283
+ // a predicated PR rule could never match. The loader refuses one rather than letting it load dead.
4284
+ prLabelAction: null
4285
+ }
4286
+ };
4287
+ var FORGE_KINDS = Object.freeze(Object.keys(FORGES));
4288
+ var RUN_KINDS = Object.freeze(["local", ...FORGE_KINDS]);
4289
+ function isForgeKind(kind) {
4290
+ return typeof kind === "string" && Object.hasOwn(FORGES, kind);
4291
+ }
4292
+ function forgeSpec(kind) {
4293
+ return isForgeKind(kind) ? FORGES[kind] : void 0;
4294
+ }
4295
+ var MINTED_TOKEN_VARS = new Set(FORGE_KINDS.flatMap((kind) => FORGES[kind].tokenVars));
4296
+
4226
4297
  // ../worker/src/config.mjs
4227
4298
  function configError(message) {
4228
4299
  const error = new Error(message);
4229
4300
  error.piDispatchConfig = true;
4230
4301
  return error;
4231
4302
  }
4303
+ function defaultJobsDir() {
4304
+ return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/jobs`.replace(/\\/g, "/");
4305
+ }
4306
+ function defaultSandboxDir(env = process.env) {
4307
+ return `${env.PI_JOBS_DIR ?? defaultJobsDir()}/sandboxes`.replace(/\\/g, "/");
4308
+ }
4232
4309
  function defaultLogsDir() {
4233
4310
  return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/logs`.replace(/\\/g, "/");
4234
4311
  }
@@ -4371,8 +4448,20 @@ function windowState(reserved, cap, softHoldPct = null) {
4371
4448
 
4372
4449
  // ../worker/src/triggers.mjs
4373
4450
  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"]);
4451
+ var PR_ACTIONS = {
4452
+ github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened"]),
4453
+ gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"]),
4454
+ // Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
4455
+ // one-letter difference that an operator would otherwise discover as a trigger that loads clean and
4456
+ // never fires. `label_cleared` is deliberately ABSENT and always will be: REMOVING a label must never
4457
+ // start a paid run, and it has no GitHub counterpart to inherit that rule from.
4458
+ forgejo: /* @__PURE__ */ new Set(["label_updated", "opened", "synchronized", "reopened"]),
4459
+ // Azure's Service Hook events reduced to the two that leave something to act on. `git.pullrequest.merged`
4460
+ // is omitted for the same reason GitLab's `merge` and `close` are: a job started by a merge has nothing
4461
+ // left to do. There is no label action at all -- Azure attaches tags to WORK ITEMS, never to pull
4462
+ // requests -- which is why azure's `prLabelAction` is null and a predicated PR rule is refused below.
4463
+ azure: /* @__PURE__ */ new Set(["created", "updated"])
4464
+ };
4376
4465
  var ID_CHARSET = /^[A-Za-z0-9._-]+$/;
4377
4466
  function isNonEmptyString2(value) {
4378
4467
  return typeof value === "string" && value.trim() !== "";
@@ -4388,7 +4477,7 @@ function parseTriggers(text, path) {
4388
4477
  if (!Array.isArray(entries)) {
4389
4478
  throw configError(`triggers file must have a "triggers" array: ${path}`);
4390
4479
  }
4391
- const state = { seenCronIds: /* @__PURE__ */ new Set(), commentCount: 0 };
4480
+ const state = { seenCronIds: /* @__PURE__ */ new Set(), commentCounts: {} };
4392
4481
  return entries.map((entry, index) => normalizeTrigger(entry, index, path, state));
4393
4482
  }
4394
4483
  function normalizeTrigger(entry, index, path, state) {
@@ -4406,8 +4495,8 @@ function normalizeTrigger(entry, index, path, state) {
4406
4495
  if (!ON_TYPES.has(on.type)) {
4407
4496
  throw configError(`${at}: on.type must be one of cron|label|comment|pull_request (got ${JSON.stringify(on.type)}): ${path}`);
4408
4497
  }
4409
- if (!RUN_KINDS.has(run.kind)) {
4410
- throw configError(`${at}: run.kind must be one of local|github (got ${JSON.stringify(run.kind)}): ${path}`);
4498
+ if (!RUN_KINDS.includes(run.kind)) {
4499
+ throw configError(`${at}: run.kind must be one of ${RUN_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
4411
4500
  }
4412
4501
  if (on.type === "cron") {
4413
4502
  if (run.kind !== "local") {
@@ -4415,8 +4504,8 @@ function normalizeTrigger(entry, index, path, state) {
4415
4504
  }
4416
4505
  return normalizeCron(on, run, index, path, state);
4417
4506
  }
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}`);
4507
+ if (!isForgeKind(run.kind)) {
4508
+ throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of ${FORGE_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
4420
4509
  }
4421
4510
  if (on.type === "label") return normalizeLabel(on, run, index, path);
4422
4511
  if (on.type === "comment") return normalizeComment(on, run, index, path, state);
@@ -4455,11 +4544,43 @@ function normalizeCron(on, run, index, path, state) {
4455
4544
  if (!isNonEmptyString2(run.task)) {
4456
4545
  throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
4457
4546
  }
4547
+ if (run.github !== void 0 && typeof run.github !== "boolean") {
4548
+ throw configError(`cron trigger "${id}": run.github must be true or false when present: ${path}`);
4549
+ }
4550
+ const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
4551
+ const image = validateImageRef(run, `cron trigger "${id}"`, path);
4552
+ const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
4458
4553
  return {
4459
4554
  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 }
4555
+ 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 }
4461
4556
  };
4462
4557
  }
4558
+ function validatePackagesFlag(run, at, path) {
4559
+ if (run.packages !== void 0 && typeof run.packages !== "boolean") {
4560
+ throw configError(`${at}: run.packages must be true or false when present: ${path}`);
4561
+ }
4562
+ return run.packages;
4563
+ }
4564
+ function validateResumeFlag(run, at, path) {
4565
+ if (run.resume !== void 0 && typeof run.resume !== "boolean") {
4566
+ throw configError(`${at}: run.resume must be true or false when present: ${path}`);
4567
+ }
4568
+ return run.resume;
4569
+ }
4570
+ function validateImageRef(run, at, path) {
4571
+ const image = run.image;
4572
+ if (image === void 0) return void 0;
4573
+ if (typeof image !== "string" || image.trim() === "") {
4574
+ throw configError(`${at}: run.image must be a non-empty string when present: ${path}`);
4575
+ }
4576
+ if (image !== image.trim()) {
4577
+ throw configError(`${at}: run.image must not have leading or trailing whitespace (got ${JSON.stringify(image)}): ${path}`);
4578
+ }
4579
+ if (image.startsWith("-")) {
4580
+ 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}`);
4581
+ }
4582
+ return image;
4583
+ }
4463
4584
  function validatePredicate(on, index, path, requirePositive) {
4464
4585
  const at = `trigger at index ${index}`;
4465
4586
  for (const key of ["any", "all", "none"]) {
@@ -4474,13 +4595,35 @@ function validatePredicate(on, index, path, requirePositive) {
4474
4595
  }
4475
4596
  return { any: on.any, all: on.all, none: on.none };
4476
4597
  }
4598
+ function validateRepository(run, onType, at, path) {
4599
+ const needed = run.kind === "azure" && (onType === "label" || onType === "comment");
4600
+ const raw = run.repository;
4601
+ if (raw === void 0) {
4602
+ if (!needed) return void 0;
4603
+ throw configError(`${at}: an azure ${onType} trigger must set run.repository -- a work item belongs to a project, not a repository, so nothing in the delivery says where to clone: ${path}`);
4604
+ }
4605
+ if (!needed) {
4606
+ throw configError(`${at}: run.repository is only meaningful on an azure label or comment trigger (a pull request names its own repository): ${path}`);
4607
+ }
4608
+ if (!isNonEmptyString2(raw) || raw !== raw.trim() || raw.includes("/")) {
4609
+ throw configError(`${at}: run.repository must be a non-empty repository NAME within the project, with no slashes and no surrounding whitespace (got ${JSON.stringify(raw)}): ${path}`);
4610
+ }
4611
+ return raw;
4612
+ }
4477
4613
  function normalizeLabel(on, run, index, path) {
4478
4614
  const at = `trigger at index ${index}`;
4479
4615
  const predicate = validatePredicate(on, index, path, true);
4480
4616
  if (!isNonEmptyString2(run.flow)) {
4481
4617
  throw configError(`${at}: label trigger run.flow must be a non-empty string: ${path}`);
4482
4618
  }
4483
- return { on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none }, run: { kind: "github", flow: run.flow } };
4619
+ const packages = validatePackagesFlag(run, at, path);
4620
+ const image = validateImageRef(run, at, path);
4621
+ const resume = validateResumeFlag(run, at, path);
4622
+ const repository = validateRepository(run, "label", at, path);
4623
+ return {
4624
+ on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
4625
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, ...repository !== void 0 && { repository } }
4626
+ };
4484
4627
  }
4485
4628
  function normalizeComment(on, run, index, path, state) {
4486
4629
  const at = `trigger at index ${index}`;
@@ -4490,11 +4633,18 @@ function normalizeComment(on, run, index, path, state) {
4490
4633
  if (!isNonEmptyString2(run.flow)) {
4491
4634
  throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string: ${path}`);
4492
4635
  }
4493
- state.commentCount += 1;
4494
- if (state.commentCount > 1) {
4495
- throw configError(`${at}: at most one comment trigger is allowed: ${path}`);
4636
+ state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
4637
+ if (state.commentCounts[run.kind] > 1) {
4638
+ throw configError(`${at}: at most one ${run.kind} comment trigger is allowed: ${path}`);
4496
4639
  }
4497
- return { on: { type: "comment", phrase: on.phrase }, run: { kind: "github", flow: run.flow } };
4640
+ const packages = validatePackagesFlag(run, at, path);
4641
+ const image = validateImageRef(run, at, path);
4642
+ const resume = validateResumeFlag(run, at, path);
4643
+ const repository = validateRepository(run, "comment", at, path);
4644
+ return {
4645
+ on: { type: "comment", phrase: on.phrase },
4646
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, ...repository !== void 0 && { repository } }
4647
+ };
4498
4648
  }
4499
4649
  function normalizePullRequest(on, run, index, path) {
4500
4650
  const at = `trigger at index ${index}`;
@@ -4502,19 +4652,29 @@ function normalizePullRequest(on, run, index, path) {
4502
4652
  if (!Array.isArray(actions) || actions.length === 0) {
4503
4653
  throw configError(`${at}: pull_request on.action must be a non-empty array: ${path}`);
4504
4654
  }
4655
+ const allowed = PR_ACTIONS[run.kind];
4656
+ const expected = [...allowed].join("|");
4505
4657
  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}`);
4658
+ if (!allowed.has(a)) {
4659
+ throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
4508
4660
  }
4509
4661
  }
4510
- const requirePositive = actions.includes("labeled");
4662
+ const labelAction = forgeSpec(run.kind)?.prLabelAction;
4663
+ const requirePositive = typeof labelAction === "string" && actions.includes(labelAction);
4664
+ if (run.kind === "azure" && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
4665
+ 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}`);
4666
+ }
4511
4667
  const predicate = validatePredicate(on, index, path, requirePositive);
4512
4668
  if (!isNonEmptyString2(run.flow)) {
4513
4669
  throw configError(`${at}: pull_request trigger run.flow must be a non-empty string: ${path}`);
4514
4670
  }
4671
+ const packages = validatePackagesFlag(run, at, path);
4672
+ const image = validateImageRef(run, at, path);
4673
+ const resume = validateResumeFlag(run, at, path);
4674
+ validateRepository(run, "pull_request", at, path);
4515
4675
  return {
4516
4676
  on: { type: "pull_request", action: [...actions], any: predicate.any, all: predicate.all, none: predicate.none },
4517
- run: { kind: "github", flow: run.flow }
4677
+ run: { kind: run.kind, flow: run.flow, packages, image, resume }
4518
4678
  };
4519
4679
  }
4520
4680
 
@@ -4690,7 +4850,7 @@ var QUEUE = "pi-jobs";
4690
4850
  function makeQueue(connection) {
4691
4851
  return new Queue(QUEUE, { connection });
4692
4852
  }
4693
- async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
4853
+ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
4694
4854
  const minute = now.toISOString().slice(0, 16);
4695
4855
  const id = jobId ?? localJobId({ folder, flow, task, minute });
4696
4856
  const data = {
@@ -4701,6 +4861,7 @@ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, max
4701
4861
  provider,
4702
4862
  model,
4703
4863
  maxTurns,
4864
+ ...image !== void 0 && { image },
4704
4865
  ...chainDepth !== void 0 && { chainDepth },
4705
4866
  ...parentJobId !== void 0 && { parentJobId }
4706
4867
  };
@@ -4755,15 +4916,111 @@ async function defaultGit(gitDir, args, { raw = false } = {}) {
4755
4916
 
4756
4917
  // ../worker/src/git-dirty.mjs
4757
4918
  import { execFileSync } from "node:child_process";
4758
- function gitDirty(folder, { exec: exec2 = execFileSync } = {}) {
4919
+ function gitDirty(folder, { exec: exec3 = execFileSync } = {}) {
4759
4920
  try {
4760
- const out = exec2("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
4921
+ const out = exec3("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
4761
4922
  return out.trim().length > 0;
4762
4923
  } catch {
4763
4924
  return null;
4764
4925
  }
4765
4926
  }
4766
4927
 
4928
+ // ../worker/src/packages.mjs
4929
+ import { existsSync, readFileSync } from "node:fs";
4930
+ import { join } from "node:path";
4931
+
4932
+ // ../worker/src/import-pi.mjs
4933
+ import { execFile as execFile2 } from "node:child_process";
4934
+ import { promisify as promisify2 } from "node:util";
4935
+ var ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
4936
+ var execFileAsync = promisify2(execFile2);
4937
+
4938
+ // ../worker/src/docker-run.mjs
4939
+ var CONTAINER_GLOBAL_PI_DIR = "/opt/pi-global";
4940
+ var CONTAINER_SESSION_DIR = "/session";
4941
+ var CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
4942
+ var ISOLATION_FLAGS = [
4943
+ // The image must ALREADY be on this host. `docker run` defaults to --pull=missing, so an unknown name is
4944
+ // a registry FETCH: a typo in the operator's image config would otherwise pull and execute a stranger's
4945
+ // image under a name that looks like theirs. Every other flag here bounds what a chosen image may DO;
4946
+ // this one bounds which image is chosen at all, which is why it leads. The same make-it-unreachable move
4947
+ // PI_OFFLINE=1 makes one layer up, and it costs nothing the documented flow was using: the README's
4948
+ // install step is an explicit `docker pull && docker tag`, and `pi-job:latest` is a local-only tag with
4949
+ // no registry behind it. A readable diagnosis is the preflight's job (image-preflight.mjs); this is the
4950
+ // part that cannot be raced.
4951
+ "--pull=never",
4952
+ "--rm",
4953
+ // ephemeral: gone after the run
4954
+ "--init",
4955
+ // reap zombies (Chromium spawns many); node is PID 1 and does not reap
4956
+ "--cap-drop=ALL",
4957
+ // pi would otherwise inherit the launching user's capabilities
4958
+ "--security-opt",
4959
+ "no-new-privileges",
4960
+ "--pids-limit=512",
4961
+ // bound a fork bomb (UNVERIFIED figure; measured headroom ~4.5x, see spec)
4962
+ "--shm-size=1g"
4963
+ // Chromium OOMs on the default 64MB /dev/shm; NOT --ipc=host (shares host ns)
4964
+ ];
4965
+ function buildDockerRunArgs({
4966
+ image,
4967
+ env,
4968
+ jobDir,
4969
+ workspace,
4970
+ outboxDir,
4971
+ sessionDir,
4972
+ globalPiDir,
4973
+ name,
4974
+ memory = "4g",
4975
+ cpus = "2",
4976
+ extraFlags = []
4977
+ }) {
4978
+ if (!image) throw new Error("docker run: image is required");
4979
+ if (!name) throw new Error("docker run: container name is required");
4980
+ if (!workspace) throw new Error("docker run: workspace mount is required");
4981
+ const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`, ...extraFlags];
4982
+ for (const [k, v] of Object.entries(env ?? {})) {
4983
+ if (v === void 0 || v === null) continue;
4984
+ args.push("-e", `${k}=${v}`);
4985
+ }
4986
+ if (jobDir) args.push("-v", `${jobDir}:/job:ro`);
4987
+ args.push("-v", `${workspace}:/workspace`);
4988
+ if (outboxDir) args.push("-v", `${outboxDir}:/outbox`);
4989
+ if (sessionDir) args.push("-v", `${sessionDir}:${CONTAINER_SESSION_DIR}`);
4990
+ if (globalPiDir) args.push("-v", `${globalPiDir}:${CONTAINER_GLOBAL_PI_DIR}:ro`);
4991
+ args.push(image);
4992
+ return args;
4993
+ }
4994
+
4995
+ // ../worker/src/packages.mjs
4996
+ var PACKAGES_SUBDIR = "packages";
4997
+ var STAGE_MANIFEST = "packages.json";
4998
+ var CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
4999
+ var MAX_DIR_LENGTH = 64;
5000
+ function isNonEmptyString4(value) {
5001
+ return typeof value === "string" && value.trim() !== "";
5002
+ }
5003
+ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists = existsSync } = {}) {
5004
+ if (!globalPiDir) return null;
5005
+ const path = join(globalPiDir, PACKAGES_SUBDIR, STAGE_MANIFEST);
5006
+ try {
5007
+ if (!fileExists(path)) return null;
5008
+ const parsed = JSON.parse(readFile(path, "utf8"));
5009
+ if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.packages)) return null;
5010
+ const packages = [];
5011
+ for (const entry of parsed.packages) {
5012
+ if (entry === null || typeof entry !== "object") return null;
5013
+ const { name, version, dir } = entry;
5014
+ if (!isNonEmptyString4(name) || !isNonEmptyString4(version) || !isNonEmptyString4(dir)) return null;
5015
+ if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
5016
+ packages.push({ name, version, dir });
5017
+ }
5018
+ return { stagedAt: typeof parsed.stagedAt === "string" ? parsed.stagedAt : null, packages };
5019
+ } catch {
5020
+ return null;
5021
+ }
5022
+ }
5023
+
4767
5024
  // src/read-model.mjs
4768
5025
  function resolvePaths(env = process.env) {
4769
5026
  return {
@@ -4772,7 +5029,18 @@ function resolvePaths(env = process.env) {
4772
5029
  settingsFile: settingsFilePath(env),
4773
5030
  triggersPath: env.PI_TRIGGERS_FILE ?? "deploy/triggers.json",
4774
5031
  pauseWindowsPath: env.PI_PAUSE_WINDOWS_FILE ?? "deploy/pause-windows.json",
5032
+ // The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY), where the staged third-party pi
5033
+ // packages live under `packages/`. `|| null` so unset AND empty both read as "no overlay" -- the
5034
+ // normal deployment, in which no trigger can arm any package.
5035
+ globalPiDir: env.PI_GLOBAL_PI_DIR || null,
4775
5036
  captureJobLogs: env.PI_CAPTURE_JOB_LOGS === "1",
5037
+ // REQ-RESURRECTABLE-SANDBOX: where finished runs' directories are retained, and for how long. Read
5038
+ // from env for the same reason as everything above -- never loadConfig. The `0` here means retention
5039
+ // OFF (the panel then says so rather than offering a key that always refuses), which is the opposite
5040
+ // of the log/session sentinels; worker/src/config.mjs carries the full reasoning.
5041
+ sandboxDir: env.PI_SANDBOX_DIR || defaultSandboxDir(env),
5042
+ sandboxRetentionHours: parseNonNegInt(env.PI_SANDBOX_RETENTION_HOURS, 24),
5043
+ sandboxIdleMinutes: parseNonNegInt(env.PI_SANDBOX_IDLE_MINUTES, 30),
4776
5044
  // The two dispatch_run bounds the extension enforces producer-side, read DIRECTLY from env (never
4777
5045
  // loadConfig, which throws on unrelated GitHub-auth problems). `delimitedList`/`nonNegativeInt` are
4778
5046
  // private to the worker's config, so the same shapes are reimplemented here. Default roots [] fails
@@ -4820,9 +5088,9 @@ async function setQueuePaused({ url, paused, makeQueueFn = makeQueue, parseConne
4820
5088
  }
4821
5089
  }
4822
5090
  var DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
4823
- function revParseHead(folder, { exec: exec2 = execFileSync2 } = {}) {
5091
+ function revParseHead(folder, { exec: exec3 = execFileSync2 } = {}) {
4824
5092
  try {
4825
- const out = exec2("git", ["-C", folder, "rev-parse", "HEAD"], { encoding: "utf8" });
5093
+ const out = exec3("git", ["-C", folder, "rev-parse", "HEAD"], { encoding: "utf8" });
4826
5094
  const sha = out.trim();
4827
5095
  return sha.length > 0 ? sha : null;
4828
5096
  } catch {
@@ -5058,19 +5326,19 @@ function listRuns({ logsDir, limit = 10, fs = nodeFs2 }) {
5058
5326
  const records = [];
5059
5327
  for (const name of names) {
5060
5328
  if (!name.endsWith(".json")) continue;
5061
- const record = readJsonFile(join(logsDir, name), fs);
5329
+ const record = readJsonFile(join2(logsDir, name), fs);
5062
5330
  if (record && typeof record === "object") records.push(record);
5063
5331
  }
5064
5332
  records.sort(byEndedAtDesc);
5065
5333
  return records.slice(0, cap);
5066
5334
  }
5067
5335
  function readRun({ logsDir, jobId, fs = nodeFs2 }) {
5068
- return readJsonFile(join(logsDir, `${sanitizeJobId(jobId)}.json`), fs);
5336
+ return readJsonFile(join2(logsDir, `${sanitizeJobId(jobId)}.json`), fs);
5069
5337
  }
5070
5338
  function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs2 }) {
5071
5339
  let text;
5072
5340
  try {
5073
- text = fs.readFileSync(join(logsDir, `${sanitizeJobId(jobId)}.log`), "utf8");
5341
+ text = fs.readFileSync(join2(logsDir, `${sanitizeJobId(jobId)}.log`), "utf8");
5074
5342
  } catch {
5075
5343
  return { missing: true };
5076
5344
  }
@@ -5114,6 +5382,10 @@ function normalizeTriggerForDisplay(entry) {
5114
5382
  if (on === null || typeof on !== "object") return null;
5115
5383
  const run = entry.run !== null && typeof entry.run === "object" ? entry.run : {};
5116
5384
  const flow = typeof run.flow === "string" ? run.flow : null;
5385
+ const packages = run.packages !== false;
5386
+ const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
5387
+ const resume = run.resume === true;
5388
+ const forge = typeof run.kind === "string" && run.kind.trim() !== "" ? run.kind : null;
5117
5389
  switch (on.type) {
5118
5390
  case "cron":
5119
5391
  return {
@@ -5124,12 +5396,15 @@ function normalizeTriggerForDisplay(entry) {
5124
5396
  flow,
5125
5397
  // Optional per-cron model override (passthrough into job.data); null when the entry resolves the
5126
5398
  // deployment default. Surfaced so the drill-in shows which schedules pin their own model.
5127
- model: typeof run.model === "string" ? run.model : null
5399
+ model: typeof run.model === "string" ? run.model : null,
5400
+ packages,
5401
+ image,
5402
+ resume
5128
5403
  };
5129
5404
  case "label":
5130
- return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow };
5405
+ return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, resume, forge };
5131
5406
  case "comment":
5132
- return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow };
5407
+ return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, resume, forge };
5133
5408
  case "pull_request":
5134
5409
  return {
5135
5410
  type: "pull_request",
@@ -5137,7 +5412,11 @@ function normalizeTriggerForDisplay(entry) {
5137
5412
  any: normalizeSelector(on.any),
5138
5413
  all: normalizeSelector(on.all),
5139
5414
  none: normalizeSelector(on.none),
5140
- flow
5415
+ flow,
5416
+ packages,
5417
+ image,
5418
+ resume,
5419
+ forge
5141
5420
  };
5142
5421
  default:
5143
5422
  return null;
@@ -5147,6 +5426,28 @@ function normalizeSelector(value) {
5147
5426
  if (!Array.isArray(value)) return [];
5148
5427
  return value.filter((member) => typeof member === "string");
5149
5428
  }
5429
+ function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest: readManifest2 = readStageManifest } = {}) {
5430
+ const empty = { stagedAt: null, packages: [] };
5431
+ if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
5432
+ let manifest;
5433
+ try {
5434
+ manifest = readManifest2({
5435
+ globalPiDir,
5436
+ readFile: (path) => fs.readFileSync(path, "utf8"),
5437
+ fileExists: (path) => fs.existsSync(path)
5438
+ });
5439
+ } catch {
5440
+ return empty;
5441
+ }
5442
+ const entries = Array.isArray(manifest?.packages) ? manifest.packages : [];
5443
+ return {
5444
+ stagedAt: typeof manifest?.stagedAt === "string" ? manifest.stagedAt : null,
5445
+ packages: entries.filter((p) => p && typeof p.name === "string").map(nameAtVersion)
5446
+ };
5447
+ }
5448
+ function nameAtVersion(pkg) {
5449
+ return typeof pkg.version === "string" && pkg.version !== "" ? `${pkg.name}@${pkg.version}` : pkg.name;
5450
+ }
5150
5451
  function listRunIds({ logsDir, fs = nodeFs2 }) {
5151
5452
  let names;
5152
5453
  try {
@@ -5186,6 +5487,75 @@ function clampLines(lines) {
5186
5487
  return Math.min(2e3, Math.max(1, n));
5187
5488
  }
5188
5489
 
5490
+ // ../worker/src/sandbox.mjs
5491
+ import { execFile as execFile3, spawn } from "node:child_process";
5492
+ import { existsSync as existsSync2 } from "node:fs";
5493
+ import { promisify as promisify3 } from "node:util";
5494
+
5495
+ // ../worker/src/sandbox-store.mjs
5496
+ import { lstatSync, mkdirSync, readFileSync as readFileSync2, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
5497
+ import { isAbsolute, join as join3, relative } from "node:path";
5498
+ var SANDBOX_MANIFEST = "manifest.json";
5499
+ var defaultFs = { lstatSync, mkdirSync, readFileSync: readFileSync2, readdirSync, renameSync, rmSync, writeFileSync };
5500
+ function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
5501
+ if (!sandboxDir || jobId === void 0 || jobId === null) return null;
5502
+ const dir = join3(sandboxDir, sanitizeJobId(jobId));
5503
+ try {
5504
+ const manifest = JSON.parse(fs.readFileSync(join3(dir, SANDBOX_MANIFEST), "utf8"));
5505
+ return { ...manifest, dir };
5506
+ } catch {
5507
+ return null;
5508
+ }
5509
+ }
5510
+
5511
+ // ../worker/src/sandbox.mjs
5512
+ var exec2 = promisify3(execFile3);
5513
+ var SANDBOX_NAME_PREFIX = "pi-sandbox-";
5514
+ function sandboxContainerName(jobId) {
5515
+ return `${SANDBOX_NAME_PREFIX}${sanitizeJobId(jobId)}`;
5516
+ }
5517
+ function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0 }) {
5518
+ return buildDockerRunArgs({
5519
+ image,
5520
+ name,
5521
+ workspace,
5522
+ jobDir,
5523
+ // The ONLY two variables, and neither is a credential. TERM so the shell renders; TMOUT so a
5524
+ // forgotten session closes itself. `buildDockerRunArgs` skips undefined, so an unset TERM or a
5525
+ // disabled idle timeout emits nothing rather than an empty string.
5526
+ env: {
5527
+ TERM: term || void 0,
5528
+ TMOUT: idleSeconds > 0 ? String(idleSeconds) : void 0
5529
+ },
5530
+ // Ahead of the env and the mounts, and well ahead of the image, which buildDockerRunArgs keeps as
5531
+ // the final positional. `--entrypoint` also clears the image's CMD; this repo's Dockerfile sets
5532
+ // none, so `bash` runs bare and `-it` makes it interactive, in the baked WORKDIR as the baked
5533
+ // non-root USER.
5534
+ extraFlags: ["-i", "-t", "--entrypoint", "bash", ...publish]
5535
+ });
5536
+ }
5537
+ function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, fileExists = existsSync2 }) {
5538
+ if (!jobId) return { refused: "no-job-id", message: "a job id is required (see `pi-dispatch sandbox --list`)" };
5539
+ const manifest = readManifest({ sandboxDir, jobId, ...fs ? { fs } : {} });
5540
+ if (!manifest) {
5541
+ return retentionHours === 0 ? { refused: "retention-off", message: "workspace retention is off \u2014 set PI_SANDBOX_RETENTION_HOURS to a positive number to make future runs resurrectable" } : { refused: "absent", message: `no retained workspace for ${jobId} \u2014 it was swept after ${retentionHours}h, or the run predates retention (\`pi-dispatch sandbox --list\` shows what is left)` };
5542
+ }
5543
+ if (!manifest.image) {
5544
+ return { refused: "no-image", message: `the manifest for ${jobId} names no image, so the sandbox cannot reproduce the run` };
5545
+ }
5546
+ if (!manifest.workspace || !fileExists(manifest.workspace)) {
5547
+ return { refused: "workspace-gone", message: `the workspace for ${jobId} is no longer at ${manifest.workspace} \u2014 a local folder that moved cannot be re-opened` };
5548
+ }
5549
+ return { manifest, name: sandboxContainerName(jobId), publish };
5550
+ }
5551
+ function launchSandbox({ args, spawnFn = spawn }) {
5552
+ return new Promise((resolve) => {
5553
+ const child = spawnFn("docker", args, { stdio: "inherit" });
5554
+ child.on("error", (err) => resolve({ code: null, error: err }));
5555
+ child.on("close", (code) => resolve({ code }));
5556
+ });
5557
+ }
5558
+
5189
5559
  // src/render.mjs
5190
5560
  var SETTINGS_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyCap", "monthlyCap", "maxTokens", "dailyTokenCap", "concurrency", "softHoldPct"];
5191
5561
  var BUDGET_WINDOWS = [
@@ -5279,17 +5649,21 @@ function renderTriggers({ schedulers, triggers } = {}) {
5279
5649
  }
5280
5650
  function triggerLine(t) {
5281
5651
  const flow = t?.flow ?? "-";
5652
+ const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
5653
+ const pkgs = t?.packages === true ? " [packages]" : "";
5654
+ const img = t?.image ? ` [image ${t.image}]` : "";
5655
+ const res = t?.resume === true ? " [resume]" : "";
5282
5656
  switch (t?.type) {
5283
5657
  case "cron":
5284
- return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}`;
5658
+ return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${res}`;
5285
5659
  case "label":
5286
- return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}`;
5660
+ return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${res}`;
5287
5661
  case "comment":
5288
- return `comment "${t.phrase ?? "-"}" \u2192 ${flow}`;
5662
+ return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${res}`;
5289
5663
  case "pull_request": {
5290
5664
  const clauses = ruleClauses(t);
5291
5665
  const action = `action[${(t.action ?? []).join(",")}]`;
5292
- return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}`;
5666
+ return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${res}`;
5293
5667
  }
5294
5668
  default:
5295
5669
  return "(unknown trigger)";
@@ -5571,6 +5945,11 @@ function createDashboardDeps(paths) {
5571
5945
  settings: readSettingsView({ settingsFile: paths.settingsFile }),
5572
5946
  triggers: readTriggers({ triggersPath: paths.triggersPath }),
5573
5947
  pauseWindows: readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath }),
5948
+ // The operator's staged third-party pi packages (REQ-GLOBAL-PI-OVERLAY), for the armed triggers'
5949
+ // trust model. Like the four reads above it is a plain file read whose fs access lives entirely in
5950
+ // read-model.mjs -- this module never touches the filesystem -- and it degrades to a safe empty
5951
+ // shape rather than throwing, so a broken overlay cannot take the whole snapshot down.
5952
+ stagedPackages: readStagedPackages({ globalPiDir: paths.globalPiDir }),
5574
5953
  // ONLY the id off the active Job -- a Job's `.data` holds issue title/body/username (PII), so it
5575
5954
  // never enters the snapshot (no-pii-in-logs, INT-RUN-HISTORY-FILE-CONTRACT).
5576
5955
  activeJobId: activeList?.[0]?.id ?? null
@@ -5610,6 +5989,7 @@ function makeDashboard({
5610
5989
  let view = "LIST";
5611
5990
  let selected = 0;
5612
5991
  let detailRun = null;
5992
+ let detailSandbox = null;
5613
5993
  let detailTrigger = null;
5614
5994
  let tailJobId = null;
5615
5995
  let tail = null;
@@ -5668,7 +6048,9 @@ function makeDashboard({
5668
6048
  tailJobId,
5669
6049
  tail,
5670
6050
  tailTop,
5671
- tailAvailable: typeof deps?.tailLog === "function"
6051
+ tailAvailable: typeof deps?.tailLog === "function",
6052
+ detailSandbox,
6053
+ sandboxAvailable: typeof deps?.launchSandbox === "function"
5672
6054
  }, styler);
5673
6055
  },
5674
6056
  invalidate() {
@@ -5677,7 +6059,24 @@ function makeDashboard({
5677
6059
  if (view === "RUN_DETAIL") {
5678
6060
  if (matchesKey(data, "escape")) {
5679
6061
  view = "LIST";
6062
+ detailSandbox = null;
5680
6063
  tui?.requestRender?.();
6064
+ return;
6065
+ }
6066
+ if (data === "b" || data === "B") {
6067
+ if (typeof deps?.launchSandbox !== "function" || !detailRun?.jobId) return;
6068
+ if (detailSandbox && detailSandbox.retained === false) return;
6069
+ void (async () => {
6070
+ tui?.stop?.();
6071
+ try {
6072
+ await deps.launchSandbox({ jobId: detailRun.jobId });
6073
+ } catch {
6074
+ } finally {
6075
+ tui?.start?.();
6076
+ tui?.requestRender?.(true);
6077
+ }
6078
+ })();
6079
+ return;
5681
6080
  }
5682
6081
  return;
5683
6082
  }
@@ -5743,6 +6142,7 @@ function makeDashboard({
5743
6142
  void refresh();
5744
6143
  } else {
5745
6144
  detailRun = row.record;
6145
+ detailSandbox = typeof deps?.sandboxInfo === "function" ? deps.sandboxInfo({ jobId: row.record?.jobId }) : null;
5746
6146
  view = "RUN_DETAIL";
5747
6147
  tui?.requestRender?.();
5748
6148
  }
@@ -5803,7 +6203,7 @@ function budgetMeters(budget, settings2, width) {
5803
6203
  return out;
5804
6204
  }
5805
6205
  function renderPanel(snapshot, width, state, styler) {
5806
- const { view, selected, detailRun, detailTrigger, tailJobId, tail, tailTop, tailAvailable } = state;
6206
+ const { view, selected, detailRun, detailTrigger, tailJobId, tail, tailTop, tailAvailable, detailSandbox, sandboxAvailable } = state;
5807
6207
  const framed = Number.isFinite(width) && Math.trunc(width) >= MIN_WIDTH2;
5808
6208
  const inner = Math.trunc(width) - 4;
5809
6209
  const title = "pi-dispatch";
@@ -5812,7 +6212,7 @@ function renderPanel(snapshot, width, state, styler) {
5812
6212
  const detailTitle = `trigger \xB7 ${t?.type ?? "?"}`;
5813
6213
  const dw = framed ? Math.min(Math.trunc(width), DRILL_WIDTH) : Math.trunc(width);
5814
6214
  const sched = cronSchedInfo(t, snapshot);
5815
- const lines = renderTriggerDetail(t, framed ? dw - 4 : 24, styler, sched);
6215
+ const lines = renderTriggerDetail(t, framed ? dw - 4 : 24, styler, sched, snapshot?.stagedPackages);
5816
6216
  if (!framed) return [detailTitle, "", ...lines.map((l) => styler.stripAnsi(l)), "", "e edit \xB7 x delete \xB7 esc back"];
5817
6217
  const boxed = frame(styler, { title: detailTitle, width: dw, lines, footer: triggerDetailHints(dw - 4, styler) });
5818
6218
  return centerBlock(boxed, Math.trunc(width), dw);
@@ -5821,9 +6221,10 @@ function renderPanel(snapshot, width, state, styler) {
5821
6221
  const detailTitle = `run ${detailRun?.jobId ?? "-"}`;
5822
6222
  const dw = framed ? Math.min(Math.trunc(width), DRILL_WIDTH) : Math.trunc(width);
5823
6223
  const allRuns = Array.isArray(snapshot?.runs) ? snapshot.runs : [];
5824
- const lines = renderRunDetail(detailRun, framed ? dw - 4 : 24, styler, allRuns);
5825
- if (!framed) return [detailTitle, "", ...lines.map((l) => styler.stripAnsi(l)), "", "esc back"];
5826
- const boxed = frame(styler, { title: detailTitle, width: dw, lines, footer: runDetailHints(dw - 4, styler) });
6224
+ const canOpen = Boolean(sandboxAvailable && detailSandbox?.retained);
6225
+ const lines = renderRunDetail(detailRun, framed ? dw - 4 : 24, styler, allRuns, detailSandbox);
6226
+ if (!framed) return [detailTitle, "", ...lines.map((l) => styler.stripAnsi(l)), "", canOpen ? "b sandbox \xB7 esc back" : "esc back"];
6227
+ const boxed = frame(styler, { title: detailTitle, width: dw, lines, footer: runDetailHints(dw - 4, styler, canOpen) });
5827
6228
  return centerBlock(boxed, Math.trunc(width), dw);
5828
6229
  }
5829
6230
  if (view === "LIVE_TAIL") {
@@ -5984,7 +6385,10 @@ function triggerRow(t, sel, inner, styler) {
5984
6385
  const cursor = sel ? styler.fg("accent", "\u203A") : " ";
5985
6386
  const kind = t?.type ?? "?";
5986
6387
  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);
6388
+ const pkgs = t?.packages === true ? " " + styler.fg("warning", "[packages]") : "";
6389
+ const img = t?.image ? " " + styler.fg("accent", `[${t.image}]`) : "";
6390
+ const res = t?.resume === true ? " " + styler.fg("warning", "[resume]") : "";
6391
+ return fitLine(`${cursor} ${badge} ${matchColored(t, styler)} ${targetColored(t, styler)}${pkgs}${img}${res}`, inner, styler);
5988
6392
  }
5989
6393
  function matchColored(t, styler) {
5990
6394
  switch (t?.type) {
@@ -6013,7 +6417,7 @@ function targetColored(t, styler) {
6013
6417
  const folderPart = base ? styler.fg("muted", base) + styler.fg("dim", "/") : "";
6014
6418
  return `${arrow} ${styler.fg("success", "local")} ${folderPart}${flow}`;
6015
6419
  }
6016
- return `${arrow} ${styler.fg("accent", "github")} ${flow}`;
6420
+ return `${arrow} ${styler.fg("accent", t?.forge ?? "github")} ${flow}`;
6017
6421
  }
6018
6422
  function pauseLines(pauseWindows, inner, styler) {
6019
6423
  const lines = [];
@@ -6084,7 +6488,7 @@ function settingsLines(settings2, inner, styler) {
6084
6488
  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
6489
  return [fitLine(l1, inner, styler), fitLine(l2, inner, styler)];
6086
6490
  }
6087
- function renderTriggerDetail(t, inner, styler, sched = null) {
6491
+ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
6088
6492
  if (!t) return [styler.cell("(no trigger)", inner, { color: "dim" })];
6089
6493
  const out = [];
6090
6494
  const kv = (k, v, color = "text") => fitLine(styler.cell(k, 12, { color: "muted" }) + " " + styler.fg(color, v), inner, styler);
@@ -6122,38 +6526,72 @@ function renderTriggerDetail(t, inner, styler, sched = null) {
6122
6526
  out.push(kv("folder", `${t.folder ?? "-"}`, "success"));
6123
6527
  out.push(kv("model", t.model ?? "deployment default", t.model ? "accent" : "dim"));
6124
6528
  } else {
6125
- out.push(kv("job", "github", "accent"));
6126
- out.push(kv("target", "the triggering repo#issue / PR", "accent"));
6529
+ out.push(kv("job", t.forge ?? "-", "accent"));
6530
+ out.push(kv("target", forgeTargetLabel(t?.forge), "accent"));
6127
6531
  out.push(kv("model", "deployment default", "dim"));
6128
6532
  }
6533
+ out.push(kv("image", t.image ?? "deployment default", t.image ? "accent" : "dim"));
6534
+ out.push(kv("resume", t.resume === true ? "continues the previous session" : "cold start", t.resume === true ? "warning" : "dim"));
6129
6535
  blank();
6130
6536
  section("trust model");
6131
6537
  for (const line of trustModel(t)) out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("text", line), inner, styler));
6538
+ if (t.packages === true) {
6539
+ const loads = (text) => out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("warning", text), inner, styler));
6540
+ loads(`packages loaded \xB7 ${stagedNames(staged)}`);
6541
+ loads("third-party code on adversarial input, open network egress");
6542
+ }
6543
+ if (t.resume === true) {
6544
+ const keeps = (text) => out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("warning", text), inner, styler));
6545
+ keeps("persists the agent transcript to PI_SESSIONS_DIR");
6546
+ keeps("issue text, file contents, tool output, the agent's own reasoning");
6547
+ keeps("replayed into the next job on the same branch; forks never resume");
6548
+ }
6132
6549
  return out;
6133
6550
  }
6551
+ function stagedNames(staged) {
6552
+ const list = Array.isArray(staged?.packages) ? staged.packages : [];
6553
+ return list.length > 0 ? list.join(" \xB7 ") : "(none staged in the overlay)";
6554
+ }
6555
+ function forgeTargetLabel(forge) {
6556
+ if (forge === "gitlab") return "the triggering project#issue / !MR";
6557
+ if (forge === "azure") return "the triggering project/repo#work-item / !PR";
6558
+ return "the triggering repo#issue / PR";
6559
+ }
6134
6560
  function trustModel(t) {
6135
- switch (t?.type) {
6136
- case "cron":
6561
+ if (t?.type === "cron") {
6562
+ return [
6563
+ "authorized by the operator's triggers file, at boot",
6564
+ "dedup by time \u2014 deterministic repeat:<id>:<millis> id",
6565
+ "lives in the worker \xB7 task is operator-authored"
6566
+ ];
6567
+ }
6568
+ const subject = t?.type === "comment" ? "adversarial comment text" : "adversarial issue/PR text";
6569
+ const where = `lives in the receiver \xB7 task is ${subject}`;
6570
+ switch (t?.forge) {
6571
+ case "gitlab":
6137
6572
  return [
6138
- "authorized by the operator's triggers file, at boot",
6139
- "dedup by time \u2014 deterministic repeat:<id>:<millis> id",
6140
- "lives in the worker \xB7 task is operator-authored"
6573
+ "authorized by the actor's resolved project access level (>= Developer) \u2014 a label is NOT approval here",
6574
+ "HMAC over the body (19.0+) or a bare X-Gitlab-Token \xB7 dedup by webhook-id",
6575
+ where
6141
6576
  ];
6142
- case "label":
6143
- case "pull_request":
6577
+ case "forgejo":
6144
6578
  return [
6145
- "authorized by a collaborator's label + HMAC webhook + author gate",
6146
- "dedup by X-GitHub-Delivery GUID (redelivery-safe)",
6147
- "lives in the receiver \xB7 task is adversarial issue/PR text"
6579
+ "authorized by the actor's resolved repository permission (admin|write), on every trigger type",
6580
+ "HMAC over the body (GitHub-compatible) \xB7 dedup by X-GitHub-Delivery GUID",
6581
+ where
6148
6582
  ];
6149
- case "comment":
6583
+ case "azure":
6150
6584
  return [
6151
- "authorized by a collaborator comment (author_association) + HMAC",
6152
- "dedup by X-GitHub-Delivery GUID",
6153
- "lives in the receiver \xB7 task is adversarial comment text"
6585
+ "authorized by the actor's resolved project membership \u2014 work items name the actor only by email",
6586
+ "NO HMAC: a shared secret that covers no bytes \xB7 dedup by the payload's own id (OQ-015)",
6587
+ where
6154
6588
  ];
6155
6589
  default:
6156
- return [];
6590
+ return [
6591
+ t?.type === "comment" ? "authorized by a collaborator comment (author_association) + HMAC" : "authorized by a collaborator's label + HMAC webhook + author gate",
6592
+ "dedup by X-GitHub-Delivery GUID (redelivery-safe)",
6593
+ where
6594
+ ];
6157
6595
  }
6158
6596
  }
6159
6597
  function triggerDetailHints(inner, styler) {
@@ -6225,7 +6663,7 @@ function renderLiveTail({ snapshot, framed, width, tailJobId, tail, tailTop, tai
6225
6663
  if (!framed) return [boxTitle, "", ...lines, "", footer];
6226
6664
  return box({ title: boxTitle, footer, width, sections: [{ lines }] });
6227
6665
  }
6228
- function renderRunDetail(record, inner, styler, allRuns = []) {
6666
+ function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
6229
6667
  const r = record ?? {};
6230
6668
  const show = (v) => v === null || v === void 0 ? "-" : String(v);
6231
6669
  const out = [];
@@ -6248,12 +6686,20 @@ function renderRunDetail(record, inner, styler, allRuns = []) {
6248
6686
  out.push(kv("turns", turnBits.join(" \xB7 ")));
6249
6687
  const cost = typeof r.tokens?.cost === "number" ? ` \xB7 $${r.tokens.cost.toFixed(4)}` : "";
6250
6688
  out.push(kv("tokens", `${show(r.tokens?.total)}${cost}`));
6689
+ const otherTotal = r.tokens?.otherTotal;
6690
+ if (typeof otherTotal === "number" && Number.isFinite(otherTotal) && otherTotal > 0) {
6691
+ out.push(kv("of which", `subagents: ${otherTotal}`, "dim"));
6692
+ }
6251
6693
  const children = (Array.isArray(allRuns) ? allRuns : []).filter((x) => x?.parentJobId && r.jobId && x.parentJobId === r.jobId);
6252
6694
  const chainBits = [r.parentJobId ? `child of ${r.parentJobId}` : "root"];
6253
6695
  if (r.chainDepth !== null && r.chainDepth !== void 0) chainBits.push(`depth ${r.chainDepth}`);
6254
6696
  if (children.length > 0) chainBits.push(`spawned ${children.length} \u2192 ${children.map((c) => c.jobId).join(", ")}`);
6255
6697
  if (r.chainRefused) chainBits.push(`${r.chainRefused} refused`);
6256
6698
  out.push(kv("chain", chainBits.join(" \xB7 ")));
6699
+ if (sandbox) {
6700
+ const state = sandbox.retained ? `${sandbox.running ? "running" : "retained"}${sandbox.expiresIn ? ` \xB7 ${sandbox.expiresIn} left` : ""}` : sandbox.reason ?? "swept";
6701
+ out.push(kv("sandbox", state, sandbox.retained ? "success" : "dim"));
6702
+ }
6257
6703
  out.push(styler.cell("", inner));
6258
6704
  out.push(styler.divider("post-mortem", null, inner));
6259
6705
  out.push(fitLine(styler.fg("dim", "container torn down at job end \xB7 stored PII-free fields + optional raw-log overlay only"), inner, styler));
@@ -6291,8 +6737,10 @@ function formatDuration(ms) {
6291
6737
  const h = Math.floor(m / 60);
6292
6738
  return `${h}h ${m % 60}m`;
6293
6739
  }
6294
- function runDetailHints(inner, styler) {
6295
- return fitLine(styler.fg("accent", "esc") + " " + styler.fg("dim", "back"), inner, styler);
6740
+ function runDetailHints(inner, styler, canOpen = false) {
6741
+ const k = (key, label) => styler.fg("accent", key) + " " + styler.fg("dim", label);
6742
+ const bits = canOpen ? [k("b", "sandbox"), k("esc", "back")] : [k("esc", "back")];
6743
+ return fitLine(bits.join(styler.fg("dim", " \xB7 ")), inner, styler);
6296
6744
  }
6297
6745
 
6298
6746
  // src/index.ts
@@ -6465,11 +6913,12 @@ function registerTools(pi) {
6465
6913
  pi.registerTool({
6466
6914
  name: "dispatch_trigger_add",
6467
6915
  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.",
6916
+ 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
6917
  executionMode: "sequential",
6470
6918
  parameters: typebox_exports.Object({
6471
6919
  kind: typebox_exports.String(),
6472
6920
  flow: typebox_exports.String(),
6921
+ forge: typebox_exports.Optional(typebox_exports.String()),
6473
6922
  id: typebox_exports.Optional(typebox_exports.String()),
6474
6923
  pattern: typebox_exports.Optional(typebox_exports.String()),
6475
6924
  folder: typebox_exports.Optional(typebox_exports.String()),
@@ -6674,6 +7123,13 @@ function triggerList(paths) {
6674
7123
  const t = readTriggers({ triggersPath: paths.triggersPath });
6675
7124
  return Array.isArray(t?.triggers) ? t.triggers : [];
6676
7125
  }
7126
+ var FORGE_PROMPT = "forge \u2014 github, gitlab, forgejo or azure";
7127
+ var PR_ACTION_VOCAB = {
7128
+ github: { hint: "labeled opened synchronize reopened", dflt: "labeled" },
7129
+ gitlab: { hint: "open update reopen approved", dflt: "update" },
7130
+ forgejo: { hint: "label_updated opened synchronized reopened", dflt: "label_updated" },
7131
+ azure: { hint: "created updated", dflt: "updated" }
7132
+ };
6677
7133
  function buildTriggerEntry(kind, f) {
6678
7134
  if (kind === "cron") {
6679
7135
  const run = { kind: "local", folder: f.folder, flow: f.flow, task: f.task };
@@ -6685,13 +7141,16 @@ function buildTriggerEntry(kind, f) {
6685
7141
  if (maxTurns !== void 0) run.maxTurns = maxTurns;
6686
7142
  return { on: { type: "cron", id: f.id, pattern: f.pattern }, run };
6687
7143
  }
6688
- if (kind === "label") return { on: { type: "label", any: asWords(f.labels ?? f.any) }, run: { kind: "github", flow: f.flow } };
6689
- if (kind === "comment") return { on: { type: "comment", phrase: f.phrase }, run: { kind: "github", flow: f.flow } };
7144
+ const forge = optStr(f.forge) ?? "github";
7145
+ const repository = optStr(f.repository);
7146
+ const forgeRun = (rest) => ({ kind: forge, ...rest, ...repository ? { repository } : {} });
7147
+ if (kind === "label") return { on: { type: "label", any: asWords(f.labels ?? f.any) }, run: forgeRun({ flow: f.flow }) };
7148
+ if (kind === "comment") return { on: { type: "comment", phrase: f.phrase }, run: forgeRun({ flow: f.flow }) };
6690
7149
  if (kind === "pull_request") {
6691
7150
  const on = { type: "pull_request", action: asWords(f.action) };
6692
7151
  const any = asWords(f.labels ?? f.any);
6693
7152
  if (any.length > 0) on.any = any;
6694
- return { on, run: { kind: "github", flow: f.flow } };
7153
+ return { on, run: { kind: forge, flow: f.flow } };
6695
7154
  }
6696
7155
  return null;
6697
7156
  }
@@ -6825,7 +7284,13 @@ async function openDashboard(paths, ctx, notify) {
6825
7284
  deps: {
6826
7285
  ...createDashboardDeps(paths),
6827
7286
  // log read stays here; overlay-only, returns readLogTail's result verbatim
6828
- tailLog: ({ jobId, lines }) => readLogTail({ logsDir: paths.logsDir, jobId, lines })
7287
+ tailLog: ({ jobId, lines }) => readLogTail({ logsDir: paths.logsDir, jobId, lines }),
7288
+ // REQ-RESURRECTABLE-SANDBOX, both seams. The fs read and the spawn live HERE for the same reason
7289
+ // tailLog does: dashboard.ts renders and holds the tui, and touches no filesystem and no child
7290
+ // process of its own. It brackets the launch with tui.stop()/tui.start(); this side only builds
7291
+ // the argv and runs it attached to the terminal.
7292
+ sandboxInfo: ({ jobId }) => readSandboxInfo(paths, jobId),
7293
+ launchSandbox: ({ jobId }) => openSandboxSession(paths, jobId)
6829
7294
  }
6830
7295
  });
6831
7296
  const opts = { overlay: true, overlayOptions: { width: "75%", maxHeight: "90%", anchor: "center" } };
@@ -6835,6 +7300,54 @@ async function openDashboard(paths, ctx, notify) {
6835
7300
  await handleDashboardAction(result, paths, ctx);
6836
7301
  }
6837
7302
  }
7303
+ function readSandboxInfo(paths, jobId) {
7304
+ if (!jobId) return null;
7305
+ if (!paths?.sandboxRetentionHours) return { retained: false, reason: "retention off" };
7306
+ const manifest = readManifest({ sandboxDir: paths.sandboxDir, jobId });
7307
+ if (!manifest) return { retained: false, reason: "swept" };
7308
+ const keepUntil = Date.parse(manifest.keepUntil ?? "");
7309
+ const createdAt = Date.parse(manifest.createdAt ?? "");
7310
+ const until = Number.isFinite(keepUntil) ? keepUntil : createdAt + paths.sandboxRetentionHours * 36e5;
7311
+ if (!Number.isFinite(until)) return { retained: true };
7312
+ const hours = Math.max(0, Math.round((until - Date.now()) / 36e5));
7313
+ return { retained: true, pinned: Number.isFinite(keepUntil), expiresIn: hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d` };
7314
+ }
7315
+ async function openSandboxSession(paths, jobId) {
7316
+ const resolved = resolveSandbox({
7317
+ jobId,
7318
+ sandboxDir: paths.sandboxDir,
7319
+ retentionHours: paths.sandboxRetentionHours
7320
+ });
7321
+ if (resolved.refused) {
7322
+ process.stdout.write(`
7323
+ cannot open a sandbox for ${jobId}: ${resolved.message}
7324
+ `);
7325
+ await pauseForMessage();
7326
+ return;
7327
+ }
7328
+ const args = buildSandboxRunArgs({
7329
+ image: resolved.manifest.image,
7330
+ name: resolved.name,
7331
+ workspace: resolved.manifest.workspace,
7332
+ jobDir: resolved.manifest.dir,
7333
+ term: process.env.TERM,
7334
+ idleSeconds: (paths.sandboxIdleMinutes ?? 0) * 60
7335
+ });
7336
+ process.stdout.write(`
7337
+ opening ${sandboxContainerName(jobId)} \u2014 image ${resolved.manifest.image}
7338
+ `);
7339
+ process.stdout.write("no credentials are set in this container. exit the shell to return to the panel.\n\n");
7340
+ const { error } = await launchSandbox({ args });
7341
+ if (error) {
7342
+ process.stdout.write(`
7343
+ could not start docker: ${error.message}
7344
+ `);
7345
+ await pauseForMessage();
7346
+ }
7347
+ }
7348
+ function pauseForMessage() {
7349
+ return new Promise((resolve) => setTimeout(resolve, 2500));
7350
+ }
6838
7351
  function splitWords(s) {
6839
7352
  return String(s ?? "").split(/\s+/).map((w) => w.trim()).filter(Boolean);
6840
7353
  }
@@ -6965,25 +7478,36 @@ async function addTriggerViaDialogs(paths, ui, notify) {
6965
7478
  if (maxTurns === void 0) return;
6966
7479
  entry = buildTriggerEntry("cron", { id, pattern, folder, flow, task, model, provider, maxTurns });
6967
7480
  } else if (kind === "label") {
6968
- const labels = await ui.input("labels \u2014 space-separated, any-of (a collaborator applies one to fire)", "pi:fix");
7481
+ const forge = await ui.input(FORGE_PROMPT, "github");
7482
+ if (forge === void 0) return;
7483
+ const labels = await ui.input("labels \u2014 space-separated, any-of (a project member applies one to fire)", "pi:fix");
6969
7484
  if (labels === void 0) return;
6970
7485
  const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the issue text is the task)", "fix");
6971
7486
  if (flow === void 0) return;
6972
- entry = buildTriggerEntry("label", { labels, flow });
7487
+ const repository = forge === "azure" ? await ui.input("repository \u2014 the repo within the project to clone (azure work items name none)", "") : void 0;
7488
+ if (repository === void 0 && forge === "azure") return;
7489
+ entry = buildTriggerEntry("label", { labels, flow, forge, repository });
6973
7490
  } else if (kind === "comment") {
7491
+ const forge = await ui.input(FORGE_PROMPT, "github");
7492
+ if (forge === void 0) return;
6974
7493
  const phrase = await ui.input("trigger phrase \u2014 a comment containing this fires the flow (e.g. @pi)", "@pi");
6975
7494
  if (phrase === void 0) return;
6976
7495
  const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the comment/issue text is the task)", "fix");
6977
7496
  if (flow === void 0) return;
6978
- entry = buildTriggerEntry("comment", { phrase, flow });
7497
+ const repository = forge === "azure" ? await ui.input("repository \u2014 the repo within the project to clone (azure work items name none)", "") : void 0;
7498
+ if (repository === void 0 && forge === "azure") return;
7499
+ entry = buildTriggerEntry("comment", { phrase, flow, forge, repository });
6979
7500
  } else {
6980
- const action = await ui.input("PR actions \u2014 space-separated: labeled opened synchronize reopened", "labeled");
7501
+ const forge = await ui.input(FORGE_PROMPT, "github");
7502
+ if (forge === void 0) return;
7503
+ const vocab = PR_ACTION_VOCAB[forge] ?? PR_ACTION_VOCAB.github;
7504
+ const action = await ui.input(`MR/PR actions \u2014 space-separated: ${vocab.hint}`, vocab.dflt);
6981
7505
  if (action === void 0) return;
6982
- const labels = await ui.input("labels for 'labeled' \u2014 space-separated (blank for the auto actions)", "pi:review");
7506
+ const labels = forge === "azure" ? "" : await ui.input("labels for 'labeled' \u2014 space-separated (blank for the auto actions)", "pi:review");
6983
7507
  if (labels === void 0) return;
6984
7508
  const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the PR text is the task)", "review");
6985
7509
  if (flow === void 0) return;
6986
- entry = buildTriggerEntry("pull_request", { action, labels, flow });
7510
+ entry = buildTriggerEntry("pull_request", { action, labels, flow, forge });
6987
7511
  }
6988
7512
  const res = writeTriggers({ triggersPath: paths.triggersPath, mutate: (list) => [...list, entry] });
6989
7513
  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.3",
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.5",
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",
7
7
  "pi-extension",
@@ -11,6 +11,11 @@
11
11
  "ai-agent",
12
12
  "autonomous-agents",
13
13
  "self-hosted",
14
+ "github",
15
+ "gitlab",
16
+ "forgejo",
17
+ "gitea",
18
+ "azure-devops",
14
19
  "job-queue",
15
20
  "cron",
16
21
  "tui",
@@ -37,7 +42,7 @@
37
42
  "skills": [
38
43
  "./skills"
39
44
  ],
40
- "image": "https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/banner.png"
45
+ "image": "https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/banner.png?v=0.3.0"
41
46
  },
42
47
  "scripts": {
43
48
  "build": "node build.mjs",
@@ -49,3 +49,79 @@ Use them like this:
49
49
  the change can't be made without an operator at the terminal — it is not a bug to work around.
50
50
 
51
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.