@edgehero/pi-dispatch-admin 0.1.4 → 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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/dist/index.mjs +439 -63
  3. package/package.json +6 -3
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
@@ -40,7 +40,7 @@ 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.
package/dist/index.mjs CHANGED
@@ -4223,12 +4223,89 @@ import * as nodeFs2 from "node:fs";
4223
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,11 +4448,19 @@ 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", "gitlab"]);
4375
- var FORGE_KINDS = /* @__PURE__ */ new Set(["github", "gitlab"]);
4376
4451
  var PR_ACTIONS = {
4377
4452
  github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened"]),
4378
- gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"])
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"])
4379
4464
  };
4380
4465
  var ID_CHARSET = /^[A-Za-z0-9._-]+$/;
4381
4466
  function isNonEmptyString2(value) {
@@ -4410,8 +4495,8 @@ function normalizeTrigger(entry, index, path, state) {
4410
4495
  if (!ON_TYPES.has(on.type)) {
4411
4496
  throw configError(`${at}: on.type must be one of cron|label|comment|pull_request (got ${JSON.stringify(on.type)}): ${path}`);
4412
4497
  }
4413
- if (!RUN_KINDS.has(run.kind)) {
4414
- throw configError(`${at}: run.kind must be one of local|github|gitlab (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}`);
4415
4500
  }
4416
4501
  if (on.type === "cron") {
4417
4502
  if (run.kind !== "local") {
@@ -4419,8 +4504,8 @@ function normalizeTrigger(entry, index, path, state) {
4419
4504
  }
4420
4505
  return normalizeCron(on, run, index, path, state);
4421
4506
  }
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}`);
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}`);
4424
4509
  }
4425
4510
  if (on.type === "label") return normalizeLabel(on, run, index, path);
4426
4511
  if (on.type === "comment") return normalizeComment(on, run, index, path, state);
@@ -4464,9 +4549,10 @@ function normalizeCron(on, run, index, path, state) {
4464
4549
  }
4465
4550
  const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
4466
4551
  const image = validateImageRef(run, `cron trigger "${id}"`, path);
4552
+ const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
4467
4553
  return {
4468
4554
  on: { type: "cron", id, pattern },
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 }
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 }
4470
4556
  };
4471
4557
  }
4472
4558
  function validatePackagesFlag(run, at, path) {
@@ -4475,6 +4561,12 @@ function validatePackagesFlag(run, at, path) {
4475
4561
  }
4476
4562
  return run.packages;
4477
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
+ }
4478
4570
  function validateImageRef(run, at, path) {
4479
4571
  const image = run.image;
4480
4572
  if (image === void 0) return void 0;
@@ -4503,6 +4595,21 @@ function validatePredicate(on, index, path, requirePositive) {
4503
4595
  }
4504
4596
  return { any: on.any, all: on.all, none: on.none };
4505
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
+ }
4506
4613
  function normalizeLabel(on, run, index, path) {
4507
4614
  const at = `trigger at index ${index}`;
4508
4615
  const predicate = validatePredicate(on, index, path, true);
@@ -4511,7 +4618,12 @@ function normalizeLabel(on, run, index, path) {
4511
4618
  }
4512
4619
  const packages = validatePackagesFlag(run, at, path);
4513
4620
  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 } };
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
+ };
4515
4627
  }
4516
4628
  function normalizeComment(on, run, index, path, state) {
4517
4629
  const at = `trigger at index ${index}`;
@@ -4527,7 +4639,12 @@ function normalizeComment(on, run, index, path, state) {
4527
4639
  }
4528
4640
  const packages = validatePackagesFlag(run, at, path);
4529
4641
  const image = validateImageRef(run, at, path);
4530
- return { on: { type: "comment", phrase: on.phrase }, run: { kind: run.kind, flow: run.flow, packages, image } };
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
+ };
4531
4648
  }
4532
4649
  function normalizePullRequest(on, run, index, path) {
4533
4650
  const at = `trigger at index ${index}`;
@@ -4542,16 +4659,22 @@ function normalizePullRequest(on, run, index, path) {
4542
4659
  throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
4543
4660
  }
4544
4661
  }
4545
- const requirePositive = run.kind === "github" && 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
+ }
4546
4667
  const predicate = validatePredicate(on, index, path, requirePositive);
4547
4668
  if (!isNonEmptyString2(run.flow)) {
4548
4669
  throw configError(`${at}: pull_request trigger run.flow must be a non-empty string: ${path}`);
4549
4670
  }
4550
4671
  const packages = validatePackagesFlag(run, at, path);
4551
4672
  const image = validateImageRef(run, at, path);
4673
+ const resume = validateResumeFlag(run, at, path);
4674
+ validateRepository(run, "pull_request", at, path);
4552
4675
  return {
4553
4676
  on: { type: "pull_request", action: [...actions], any: predicate.any, all: predicate.all, none: predicate.none },
4554
- run: { kind: run.kind, flow: run.flow, packages, image }
4677
+ run: { kind: run.kind, flow: run.flow, packages, image, resume }
4555
4678
  };
4556
4679
  }
4557
4680
 
@@ -4793,9 +4916,9 @@ async function defaultGit(gitDir, args, { raw = false } = {}) {
4793
4916
 
4794
4917
  // ../worker/src/git-dirty.mjs
4795
4918
  import { execFileSync } from "node:child_process";
4796
- function gitDirty(folder, { exec: exec2 = execFileSync } = {}) {
4919
+ function gitDirty(folder, { exec: exec3 = execFileSync } = {}) {
4797
4920
  try {
4798
- const out = exec2("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
4921
+ const out = exec3("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
4799
4922
  return out.trim().length > 0;
4800
4923
  } catch {
4801
4924
  return null;
@@ -4814,6 +4937,60 @@ var execFileAsync = promisify2(execFile2);
4814
4937
 
4815
4938
  // ../worker/src/docker-run.mjs
4816
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
+ }
4817
4994
 
4818
4995
  // ../worker/src/packages.mjs
4819
4996
  var PACKAGES_SUBDIR = "packages";
@@ -4857,6 +5034,13 @@ function resolvePaths(env = process.env) {
4857
5034
  // normal deployment, in which no trigger can arm any package.
4858
5035
  globalPiDir: env.PI_GLOBAL_PI_DIR || null,
4859
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),
4860
5044
  // The two dispatch_run bounds the extension enforces producer-side, read DIRECTLY from env (never
4861
5045
  // loadConfig, which throws on unrelated GitHub-auth problems). `delimitedList`/`nonNegativeInt` are
4862
5046
  // private to the worker's config, so the same shapes are reimplemented here. Default roots [] fails
@@ -4904,9 +5088,9 @@ async function setQueuePaused({ url, paused, makeQueueFn = makeQueue, parseConne
4904
5088
  }
4905
5089
  }
4906
5090
  var DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
4907
- function revParseHead(folder, { exec: exec2 = execFileSync2 } = {}) {
5091
+ function revParseHead(folder, { exec: exec3 = execFileSync2 } = {}) {
4908
5092
  try {
4909
- const out = exec2("git", ["-C", folder, "rev-parse", "HEAD"], { encoding: "utf8" });
5093
+ const out = exec3("git", ["-C", folder, "rev-parse", "HEAD"], { encoding: "utf8" });
4910
5094
  const sha = out.trim();
4911
5095
  return sha.length > 0 ? sha : null;
4912
5096
  } catch {
@@ -5200,6 +5384,7 @@ function normalizeTriggerForDisplay(entry) {
5200
5384
  const flow = typeof run.flow === "string" ? run.flow : null;
5201
5385
  const packages = run.packages !== false;
5202
5386
  const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
5387
+ const resume = run.resume === true;
5203
5388
  const forge = typeof run.kind === "string" && run.kind.trim() !== "" ? run.kind : null;
5204
5389
  switch (on.type) {
5205
5390
  case "cron":
@@ -5213,12 +5398,13 @@ function normalizeTriggerForDisplay(entry) {
5213
5398
  // deployment default. Surfaced so the drill-in shows which schedules pin their own model.
5214
5399
  model: typeof run.model === "string" ? run.model : null,
5215
5400
  packages,
5216
- image
5401
+ image,
5402
+ resume
5217
5403
  };
5218
5404
  case "label":
5219
- return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, forge };
5405
+ return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, resume, forge };
5220
5406
  case "comment":
5221
- return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, forge };
5407
+ return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, resume, forge };
5222
5408
  case "pull_request":
5223
5409
  return {
5224
5410
  type: "pull_request",
@@ -5229,6 +5415,7 @@ function normalizeTriggerForDisplay(entry) {
5229
5415
  flow,
5230
5416
  packages,
5231
5417
  image,
5418
+ resume,
5232
5419
  forge
5233
5420
  };
5234
5421
  default:
@@ -5239,12 +5426,12 @@ function normalizeSelector(value) {
5239
5426
  if (!Array.isArray(value)) return [];
5240
5427
  return value.filter((member) => typeof member === "string");
5241
5428
  }
5242
- function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest = readStageManifest } = {}) {
5429
+ function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest: readManifest2 = readStageManifest } = {}) {
5243
5430
  const empty = { stagedAt: null, packages: [] };
5244
5431
  if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
5245
5432
  let manifest;
5246
5433
  try {
5247
- manifest = readManifest({
5434
+ manifest = readManifest2({
5248
5435
  globalPiDir,
5249
5436
  readFile: (path) => fs.readFileSync(path, "utf8"),
5250
5437
  fileExists: (path) => fs.existsSync(path)
@@ -5300,6 +5487,75 @@ function clampLines(lines) {
5300
5487
  return Math.min(2e3, Math.max(1, n));
5301
5488
  }
5302
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
+
5303
5559
  // src/render.mjs
5304
5560
  var SETTINGS_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyCap", "monthlyCap", "maxTokens", "dailyTokenCap", "concurrency", "softHoldPct"];
5305
5561
  var BUDGET_WINDOWS = [
@@ -5396,17 +5652,18 @@ function triggerLine(t) {
5396
5652
  const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
5397
5653
  const pkgs = t?.packages === true ? " [packages]" : "";
5398
5654
  const img = t?.image ? ` [image ${t.image}]` : "";
5655
+ const res = t?.resume === true ? " [resume]" : "";
5399
5656
  switch (t?.type) {
5400
5657
  case "cron":
5401
- return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}`;
5658
+ return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${res}`;
5402
5659
  case "label":
5403
- return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}`;
5660
+ return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${res}`;
5404
5661
  case "comment":
5405
- return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}`;
5662
+ return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${res}`;
5406
5663
  case "pull_request": {
5407
5664
  const clauses = ruleClauses(t);
5408
5665
  const action = `action[${(t.action ?? []).join(",")}]`;
5409
- return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}`;
5666
+ return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${res}`;
5410
5667
  }
5411
5668
  default:
5412
5669
  return "(unknown trigger)";
@@ -5732,6 +5989,7 @@ function makeDashboard({
5732
5989
  let view = "LIST";
5733
5990
  let selected = 0;
5734
5991
  let detailRun = null;
5992
+ let detailSandbox = null;
5735
5993
  let detailTrigger = null;
5736
5994
  let tailJobId = null;
5737
5995
  let tail = null;
@@ -5790,7 +6048,9 @@ function makeDashboard({
5790
6048
  tailJobId,
5791
6049
  tail,
5792
6050
  tailTop,
5793
- tailAvailable: typeof deps?.tailLog === "function"
6051
+ tailAvailable: typeof deps?.tailLog === "function",
6052
+ detailSandbox,
6053
+ sandboxAvailable: typeof deps?.launchSandbox === "function"
5794
6054
  }, styler);
5795
6055
  },
5796
6056
  invalidate() {
@@ -5799,7 +6059,24 @@ function makeDashboard({
5799
6059
  if (view === "RUN_DETAIL") {
5800
6060
  if (matchesKey(data, "escape")) {
5801
6061
  view = "LIST";
6062
+ detailSandbox = null;
5802
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;
5803
6080
  }
5804
6081
  return;
5805
6082
  }
@@ -5865,6 +6142,7 @@ function makeDashboard({
5865
6142
  void refresh();
5866
6143
  } else {
5867
6144
  detailRun = row.record;
6145
+ detailSandbox = typeof deps?.sandboxInfo === "function" ? deps.sandboxInfo({ jobId: row.record?.jobId }) : null;
5868
6146
  view = "RUN_DETAIL";
5869
6147
  tui?.requestRender?.();
5870
6148
  }
@@ -5925,7 +6203,7 @@ function budgetMeters(budget, settings2, width) {
5925
6203
  return out;
5926
6204
  }
5927
6205
  function renderPanel(snapshot, width, state, styler) {
5928
- const { view, selected, detailRun, detailTrigger, tailJobId, tail, tailTop, tailAvailable } = state;
6206
+ const { view, selected, detailRun, detailTrigger, tailJobId, tail, tailTop, tailAvailable, detailSandbox, sandboxAvailable } = state;
5929
6207
  const framed = Number.isFinite(width) && Math.trunc(width) >= MIN_WIDTH2;
5930
6208
  const inner = Math.trunc(width) - 4;
5931
6209
  const title = "pi-dispatch";
@@ -5943,9 +6221,10 @@ function renderPanel(snapshot, width, state, styler) {
5943
6221
  const detailTitle = `run ${detailRun?.jobId ?? "-"}`;
5944
6222
  const dw = framed ? Math.min(Math.trunc(width), DRILL_WIDTH) : Math.trunc(width);
5945
6223
  const allRuns = Array.isArray(snapshot?.runs) ? snapshot.runs : [];
5946
- const lines = renderRunDetail(detailRun, framed ? dw - 4 : 24, styler, allRuns);
5947
- if (!framed) return [detailTitle, "", ...lines.map((l) => styler.stripAnsi(l)), "", "esc back"];
5948
- 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) });
5949
6228
  return centerBlock(boxed, Math.trunc(width), dw);
5950
6229
  }
5951
6230
  if (view === "LIVE_TAIL") {
@@ -6106,10 +6385,10 @@ function triggerRow(t, sel, inner, styler) {
6106
6385
  const cursor = sel ? styler.fg("accent", "\u203A") : " ";
6107
6386
  const kind = t?.type ?? "?";
6108
6387
  const badge = styler.cell(kind, KIND_WIDTH, { color: KIND_COLOR[kind] ?? "muted" });
6109
- const forge = t?.forge && t.forge !== "github" ? " " + styler.fg("accent", `[${t.forge}]`) : "";
6110
6388
  const pkgs = t?.packages === true ? " " + styler.fg("warning", "[packages]") : "";
6111
6389
  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);
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);
6113
6392
  }
6114
6393
  function matchColored(t, styler) {
6115
6394
  switch (t?.type) {
@@ -6138,7 +6417,7 @@ function targetColored(t, styler) {
6138
6417
  const folderPart = base ? styler.fg("muted", base) + styler.fg("dim", "/") : "";
6139
6418
  return `${arrow} ${styler.fg("success", "local")} ${folderPart}${flow}`;
6140
6419
  }
6141
- return `${arrow} ${styler.fg("accent", "github")} ${flow}`;
6420
+ return `${arrow} ${styler.fg("accent", t?.forge ?? "github")} ${flow}`;
6142
6421
  }
6143
6422
  function pauseLines(pauseWindows, inner, styler) {
6144
6423
  const lines = [];
@@ -6248,10 +6527,11 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
6248
6527
  out.push(kv("model", t.model ?? "deployment default", t.model ? "accent" : "dim"));
6249
6528
  } else {
6250
6529
  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"));
6530
+ out.push(kv("target", forgeTargetLabel(t?.forge), "accent"));
6252
6531
  out.push(kv("model", "deployment default", "dim"));
6253
6532
  }
6254
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"));
6255
6535
  blank();
6256
6536
  section("trust model");
6257
6537
  for (const line of trustModel(t)) out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("text", line), inner, styler));
@@ -6260,35 +6540,58 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
6260
6540
  loads(`packages loaded \xB7 ${stagedNames(staged)}`);
6261
6541
  loads("third-party code on adversarial input, open network egress");
6262
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
+ }
6263
6549
  return out;
6264
6550
  }
6265
6551
  function stagedNames(staged) {
6266
6552
  const list = Array.isArray(staged?.packages) ? staged.packages : [];
6267
6553
  return list.length > 0 ? list.join(" \xB7 ") : "(none staged in the overlay)";
6268
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
+ }
6269
6560
  function trustModel(t) {
6270
- switch (t?.type) {
6271
- 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":
6272
6572
  return [
6273
- "authorized by the operator's triggers file, at boot",
6274
- "dedup by time \u2014 deterministic repeat:<id>:<millis> id",
6275
- "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
6276
6576
  ];
6277
- case "label":
6278
- case "pull_request":
6577
+ case "forgejo":
6279
6578
  return [
6280
- "authorized by a collaborator's label + HMAC webhook + author gate",
6281
- "dedup by X-GitHub-Delivery GUID (redelivery-safe)",
6282
- "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
6283
6582
  ];
6284
- case "comment":
6583
+ case "azure":
6285
6584
  return [
6286
- "authorized by a collaborator comment (author_association) + HMAC",
6287
- "dedup by X-GitHub-Delivery GUID",
6288
- "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
6289
6588
  ];
6290
6589
  default:
6291
- 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
+ ];
6292
6595
  }
6293
6596
  }
6294
6597
  function triggerDetailHints(inner, styler) {
@@ -6360,7 +6663,7 @@ function renderLiveTail({ snapshot, framed, width, tailJobId, tail, tailTop, tai
6360
6663
  if (!framed) return [boxTitle, "", ...lines, "", footer];
6361
6664
  return box({ title: boxTitle, footer, width, sections: [{ lines }] });
6362
6665
  }
6363
- function renderRunDetail(record, inner, styler, allRuns = []) {
6666
+ function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
6364
6667
  const r = record ?? {};
6365
6668
  const show = (v) => v === null || v === void 0 ? "-" : String(v);
6366
6669
  const out = [];
@@ -6393,6 +6696,10 @@ function renderRunDetail(record, inner, styler, allRuns = []) {
6393
6696
  if (children.length > 0) chainBits.push(`spawned ${children.length} \u2192 ${children.map((c) => c.jobId).join(", ")}`);
6394
6697
  if (r.chainRefused) chainBits.push(`${r.chainRefused} refused`);
6395
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
+ }
6396
6703
  out.push(styler.cell("", inner));
6397
6704
  out.push(styler.divider("post-mortem", null, inner));
6398
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));
@@ -6430,8 +6737,10 @@ function formatDuration(ms) {
6430
6737
  const h = Math.floor(m / 60);
6431
6738
  return `${h}h ${m % 60}m`;
6432
6739
  }
6433
- function runDetailHints(inner, styler) {
6434
- 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);
6435
6744
  }
6436
6745
 
6437
6746
  // src/index.ts
@@ -6814,6 +7123,13 @@ function triggerList(paths) {
6814
7123
  const t = readTriggers({ triggersPath: paths.triggersPath });
6815
7124
  return Array.isArray(t?.triggers) ? t.triggers : [];
6816
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
+ };
6817
7133
  function buildTriggerEntry(kind, f) {
6818
7134
  if (kind === "cron") {
6819
7135
  const run = { kind: "local", folder: f.folder, flow: f.flow, task: f.task };
@@ -6826,8 +7142,10 @@ function buildTriggerEntry(kind, f) {
6826
7142
  return { on: { type: "cron", id: f.id, pattern: f.pattern }, run };
6827
7143
  }
6828
7144
  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 } };
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 }) };
6831
7149
  if (kind === "pull_request") {
6832
7150
  const on = { type: "pull_request", action: asWords(f.action) };
6833
7151
  const any = asWords(f.labels ?? f.any);
@@ -6966,7 +7284,13 @@ async function openDashboard(paths, ctx, notify) {
6966
7284
  deps: {
6967
7285
  ...createDashboardDeps(paths),
6968
7286
  // log read stays here; overlay-only, returns readLogTail's result verbatim
6969
- 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)
6970
7294
  }
6971
7295
  });
6972
7296
  const opts = { overlay: true, overlayOptions: { width: "75%", maxHeight: "90%", anchor: "center" } };
@@ -6976,6 +7300,54 @@ async function openDashboard(paths, ctx, notify) {
6976
7300
  await handleDashboardAction(result, paths, ctx);
6977
7301
  }
6978
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
+ }
6979
7351
  function splitWords(s) {
6980
7352
  return String(s ?? "").split(/\s+/).map((w) => w.trim()).filter(Boolean);
6981
7353
  }
@@ -7106,28 +7478,32 @@ async function addTriggerViaDialogs(paths, ui, notify) {
7106
7478
  if (maxTurns === void 0) return;
7107
7479
  entry = buildTriggerEntry("cron", { id, pattern, folder, flow, task, model, provider, maxTurns });
7108
7480
  } else if (kind === "label") {
7109
- const forge = await ui.input("forge \u2014 github or gitlab", "github");
7481
+ const forge = await ui.input(FORGE_PROMPT, "github");
7110
7482
  if (forge === void 0) return;
7111
7483
  const labels = await ui.input("labels \u2014 space-separated, any-of (a project member applies one to fire)", "pi:fix");
7112
7484
  if (labels === void 0) return;
7113
7485
  const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the issue text is the task)", "fix");
7114
7486
  if (flow === void 0) return;
7115
- entry = buildTriggerEntry("label", { labels, flow, forge });
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 });
7116
7490
  } else if (kind === "comment") {
7117
- const forge = await ui.input("forge \u2014 github or gitlab", "github");
7491
+ const forge = await ui.input(FORGE_PROMPT, "github");
7118
7492
  if (forge === void 0) return;
7119
7493
  const phrase = await ui.input("trigger phrase \u2014 a comment containing this fires the flow (e.g. @pi)", "@pi");
7120
7494
  if (phrase === void 0) return;
7121
7495
  const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the comment/issue text is the task)", "fix");
7122
7496
  if (flow === void 0) return;
7123
- entry = buildTriggerEntry("comment", { phrase, flow, forge });
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 });
7124
7500
  } else {
7125
- const forge = await ui.input("forge \u2014 github or gitlab", "github");
7501
+ const forge = await ui.input(FORGE_PROMPT, "github");
7126
7502
  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");
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);
7129
7505
  if (action === void 0) return;
7130
- 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");
7131
7507
  if (labels === void 0) return;
7132
7508
  const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the PR text is the task)", "review");
7133
7509
  if (flow === void 0) return;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-admin",
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.",
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",
@@ -13,6 +13,9 @@
13
13
  "self-hosted",
14
14
  "github",
15
15
  "gitlab",
16
+ "forgejo",
17
+ "gitea",
18
+ "azure-devops",
16
19
  "job-queue",
17
20
  "cron",
18
21
  "tui",
@@ -39,7 +42,7 @@
39
42
  "skills": [
40
43
  "./skills"
41
44
  ],
42
- "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"
43
46
  },
44
47
  "scripts": {
45
48
  "build": "node build.mjs",