@edgehero/pi-dispatch-admin 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # pi-dispatch: run the pi coding agent as a self-hosted service
6
6
 
7
- **Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine.** [pi](https://github.com/earendil-works/pi) is a superb coding agent, but it has no job queue, no spend limit, and, by its own README, no permission system. pi-dispatch adds exactly those, so you can run pi unattended and safely: on a cron schedule, on your repo's issues and PRs, or straight from the CLI. Every job runs in an isolated container, spend is bounded before a single token is spent, and everything the agent did (and what it cost) is recorded, graphed and priced for you. On a forge you stay in the loop at both ends: a job starts only when someone with write access to the repo asks for one, by a label, an `@pi` comment or a review, and what comes back is a pull request you review and land, because pi-dispatch never merges anything. This is not another agent to weigh against the one you already use; it is the queue, the budget and the box for the pi you already run.
7
+ **Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine.** [pi](https://github.com/earendil-works/pi) is a superb coding agent, but it has no job queue, no spend limit, and, by its own README, no permission system. pi-dispatch adds exactly those, so you can run pi unattended and safely: on a cron schedule, on your repo's issues and PRs, or straight from the CLI. Every job runs in an isolated container, spend is bounded before a single token is spent (per deployment and per-repo or per-folder), and everything the agent did (and what it cost) is recorded, graphed and priced for you. On a forge you stay in the loop at both ends: a job starts only when someone with write access to the repo asks for one, by a label, an `@pi` comment or a review, and what comes back is a pull request you review and land, because pi-dispatch never merges anything. This is not another agent to weigh against the one you already use; it is the queue, the budget and the box for the pi you already run.
8
8
 
9
9
  > This npm package, **`@edgehero/pi-dispatch-admin`**, is the **operator console** (a pi extension). The service itself is `@edgehero/pi-dispatch` (worker + CLI) and `@edgehero/pi-dispatch-receiver` (the webhook edge); the [main repo](https://github.com/edgehero/pi-dispatch) has the container image, docs, and SECURITY.md.
10
10
 
@@ -56,7 +56,7 @@ Four forges: GitHub, GitLab, Forgejo (and Gitea), Azure DevOps. **Who may fire a
56
56
  One command puts a live TUI over the whole deployment:
57
57
 
58
58
  <p align="center">
59
- <img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png?v=1.0.0" alt="The /dispatch panel: status, spend meters, triggers (including a command trigger shown as its /name), pause windows, runs, and settings" width="820">
59
+ <img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png?v=1.0.0" alt="The /dispatch panel: status, spend meters, triggers (including a command trigger shown as its /name), pause windows, scoped limits, runs, and settings" width="820">
60
60
  </p>
61
61
 
62
62
  - **Status and spend.** Queue and worker state, day/week/month spend meters, a daily token counter, and a run-history table with per-job tokens and cost.
@@ -68,7 +68,8 @@ One command puts a live TUI over the whole deployment:
68
68
 
69
69
  - **Triggers, editable live.** cron, label, comment, pull_request and issue triggers with colored drill-ins showing what fires each one, what it runs, and its trust model. Added, edited and deleted without a restart. A one-shot (`once: true`) shows armed or spent in its drill-in: a spent entry stays in the list, matches nothing, and re-arms when the operator deletes its `on.disarmed` mark from the file. Triggers that run third-party code or a custom image are badged; opting in or out of either stays an edit to the reviewed `triggers.json`, which neither the console nor a model-callable tool will make for you.
70
70
  - **Quiet hours.** Scheduled pause windows per folder or repo: defer runs between certain times, timezone-aware, and resume automatically. Deferred, never dropped, at zero budget cost.
71
- - **AI-operable, with a human gate.** Model-callable tools let an agent change limits and manage triggers and pause windows, and every **config** write pops an operator confirmation the model cannot answer, refusing outright when no operator is present. Two tools sit outside that gate on purpose: `dispatch_pause` and `dispatch_resume` write durable queue state but are reversible and spend nothing, so they carry no confirm. One more sits outside it and is **not** money-safe: `dispatch_run` enqueues a **paid** run that edits a local folder in place with no undo, bounded instead by six independent limits (the `PI_DISPATCH_RUN_ROOTS` folder allowlist, a committed per-flow `ai-trigger: allow` opt-in read at a pre-agent SHA, a dirty-tree refusal with no force option, no spend knobs on the tool, a per-hour rate limit, and the worker's daily cap). Commands sit outside its reach entirely: `dispatch_run` speaks flows only, and a chained job's request naming a `command` is refused outright, with no opt-in. Read [`SECURITY.md`](https://github.com/edgehero/pi-dispatch/blob/main/SECURITY.md) on that one before you enable it. The bundled `operate-pi-dispatch` skill teaches the agent those gates.
71
+ - **Scoped limits.** Per-repo or per-folder job caps (day, week, month) refused before any spend, plus a per-scope concurrency ceiling enforced by deferral. Local jobs also hold a one-job-per-folder guard that is always on and has no switch (it lives in the worker process, and one worker per docker daemon is the supported shape), because two agents editing one working tree race each other with no gate and no undo.
72
+ - **AI-operable, with a human gate.** Model-callable tools let an agent change limits (global and scoped) and manage triggers and pause windows, and every **config** write pops an operator confirmation the model cannot answer, refusing outright when no operator is present. Two tools sit outside that gate on purpose: `dispatch_pause` and `dispatch_resume` write durable queue state but are reversible and spend nothing, so they carry no confirm. One more sits outside it and is **not** money-safe: `dispatch_run` enqueues a **paid** run that edits a local folder in place with no undo, bounded instead by six independent limits (the `PI_DISPATCH_RUN_ROOTS` folder allowlist, a committed per-flow `ai-trigger: allow` opt-in read at a pre-agent SHA, a dirty-tree refusal with no force option, no spend knobs on the tool, a per-hour rate limit, and the worker's daily cap). Commands sit outside its reach entirely: `dispatch_run` speaks flows only, and a chained job's request naming a `command` is refused outright, with no opt-in. Read [`SECURITY.md`](https://github.com/edgehero/pi-dispatch/blob/main/SECURITY.md) on that one before you enable it. The bundled `operate-pi-dispatch` skill teaches the agent those gates.
72
73
  - **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
73
74
 
74
75
  ## Install
package/dist/index.mjs CHANGED
@@ -8024,10 +8024,100 @@ var init_pause_windows = __esm({
8024
8024
  }
8025
8025
  });
8026
8026
 
8027
- // ../worker/src/subscriptions.mjs
8027
+ // ../worker/src/scoped-limits.mjs
8028
+ import { createHash } from "node:crypto";
8029
+ import { isAbsolute, resolve } from "node:path";
8028
8030
  function isNonEmptyString3(value) {
8029
8031
  return typeof value === "string" && value.trim() !== "";
8030
8032
  }
8033
+ function parseScopedLimits(text, path) {
8034
+ let parsed;
8035
+ try {
8036
+ parsed = JSON.parse(text);
8037
+ } catch (error) {
8038
+ throw configError(`scoped-limits file is not valid JSON: ${path} (${error.message})`);
8039
+ }
8040
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
8041
+ throw configError(`scoped-limits file must be an object with "version" and "limits": ${path}`);
8042
+ }
8043
+ const version = parsed.version;
8044
+ if (!Number.isInteger(version) || version < 1) {
8045
+ throw configError(`scoped-limits file must have "version": 1 (an integer >= 1): ${path}`);
8046
+ }
8047
+ if (version > SCOPED_LIMITS_VERSION) {
8048
+ throw configError(`scoped-limits file written by a newer pi-dispatch (version ${version}; this build understands ${SCOPED_LIMITS_VERSION}): ${path}`);
8049
+ }
8050
+ if (!Array.isArray(parsed.limits)) {
8051
+ throw configError(`scoped-limits file must have a "limits" array: ${path}`);
8052
+ }
8053
+ const rows = parsed.limits.map((row, index) => normalizeLimit(row, index, path));
8054
+ const seen = /* @__PURE__ */ new Map();
8055
+ rows.forEach((row, index) => {
8056
+ if (seen.has(row.scope)) {
8057
+ throw configError(`scoped limit at index ${index}: duplicate scope ${JSON.stringify(row.scope)} (first at index ${seen.get(row.scope)}): ${path}`);
8058
+ }
8059
+ seen.set(row.scope, index);
8060
+ });
8061
+ return rows;
8062
+ }
8063
+ function normalizeLimit(row, index, path) {
8064
+ const at = `scoped limit at index ${index}`;
8065
+ if (row === null || typeof row !== "object" || Array.isArray(row)) {
8066
+ throw configError(`${at}: must be an object: ${path}`);
8067
+ }
8068
+ if (!isNonEmptyString3(row.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
8069
+ const trimmed = row.scope.trim().normalize("NFC");
8070
+ if (trimmed === "*") {
8071
+ throw configError(`${at}: "*" is not supported -- add one row per scope (a per-scope default may adopt "*" later): ${path}`);
8072
+ }
8073
+ if (trimmed.includes("*")) {
8074
+ throw configError(`${at}: scopes match exactly; a scope containing "*" is refused (no globs): ${path}`);
8075
+ }
8076
+ const norm = {
8077
+ // An absolute path is stored resolved so a `/srv/site/` row governs `/srv/site` jobs -- the same
8078
+ // collapse canonicalScope applies on the job side. isAbsolute is PLATFORM-NATIVE on purpose, so a
8079
+ // foreign-platform row (a windows drive path on a POSIX worker) stays verbatim and is inert here;
8080
+ // the doctor's unreferenced-scope advisory names it. Resolving it instead would "work" only by
8081
+ // both sides mangling into the same cwd-prefixed string -- a match by accident, not by contract.
8082
+ scope: isAbsolute(trimmed) ? resolve(trimmed) : trimmed,
8083
+ day: null,
8084
+ week: null,
8085
+ month: null,
8086
+ concurrent: null
8087
+ };
8088
+ let any = false;
8089
+ for (const field of LIMIT_FIELDS) {
8090
+ const value = row[field];
8091
+ if (value === void 0 || value === null) continue;
8092
+ if (!Number.isSafeInteger(value) || value < 1) {
8093
+ throw configError(`${at}: ${field} must be an integer >= 1: ${path}`);
8094
+ }
8095
+ norm[field] = value;
8096
+ any = true;
8097
+ }
8098
+ if (!any) {
8099
+ throw configError(`${at}: at least one of day, week, month, concurrent is required (a row that limits nothing is a row an operator sets and then trusts): ${path}`);
8100
+ }
8101
+ return norm;
8102
+ }
8103
+ function scopeKeyPrefix(scope) {
8104
+ const h = createHash("sha256").update(String(scope)).digest("hex").slice(0, 16);
8105
+ return `budget:s:${h}`;
8106
+ }
8107
+ var SCOPED_LIMITS_VERSION, LIMIT_FIELDS;
8108
+ var init_scoped_limits = __esm({
8109
+ "../worker/src/scoped-limits.mjs"() {
8110
+ init_config2();
8111
+ init_pause_windows();
8112
+ SCOPED_LIMITS_VERSION = 1;
8113
+ LIMIT_FIELDS = ["day", "week", "month", "concurrent"];
8114
+ }
8115
+ });
8116
+
8117
+ // ../worker/src/subscriptions.mjs
8118
+ function isNonEmptyString4(value) {
8119
+ return typeof value === "string" && value.trim() !== "";
8120
+ }
8031
8121
  function normalizeGlobs(value, label, at, path) {
8032
8122
  if (!Array.isArray(value) || value.length === 0) {
8033
8123
  throw configError(`${at}: ${label} must be a non-empty array of model globs: ${path}`);
@@ -8073,14 +8163,14 @@ function normalizeSubscription(entry, index, path, seenIds) {
8073
8163
  if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
8074
8164
  throw configError(`${at}: must be an object: ${path}`);
8075
8165
  }
8076
- if (!isNonEmptyString3(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
8166
+ if (!isNonEmptyString4(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
8077
8167
  const id = entry.id.trim();
8078
8168
  if (seenIds.has(id)) {
8079
8169
  throw configError(`${at}: id ${JSON.stringify(id)} is already used by the subscription at index ${seenIds.get(id)}: ${path}`);
8080
8170
  }
8081
8171
  seenIds.set(id, index);
8082
- if (!isNonEmptyString3(entry.vendor)) throw configError(`${at}: vendor must be a non-empty string: ${path}`);
8083
- if (!isNonEmptyString3(entry.provider)) throw configError(`${at}: provider must be a non-empty string (the pi-ai provider id this plan covers): ${path}`);
8172
+ if (!isNonEmptyString4(entry.vendor)) throw configError(`${at}: vendor must be a non-empty string: ${path}`);
8173
+ if (!isNonEmptyString4(entry.provider)) throw configError(`${at}: provider must be a non-empty string (the pi-ai provider id this plan covers): ${path}`);
8084
8174
  const models = entry.models === void 0 ? ["*"] : normalizeGlobs(entry.models, "models", at, path);
8085
8175
  const price = entry.price;
8086
8176
  if (price === null || typeof price !== "object" || Array.isArray(price)) {
@@ -8100,7 +8190,7 @@ function normalizeSubscription(entry, index, path, seenIds) {
8100
8190
  let counterfactualModel = null;
8101
8191
  if (entry.counterfactualModel !== void 0 && entry.counterfactualModel !== null) {
8102
8192
  const cm = entry.counterfactualModel;
8103
- if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString3(cm.provider) || !isNonEmptyString3(cm.id)) {
8193
+ if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString4(cm.provider) || !isNonEmptyString4(cm.id)) {
8104
8194
  throw configError(`${at}: counterfactualModel must be { provider, id } with non-empty strings (a PRICED pi-ai model to re-price covered runs at API rates): ${path}`);
8105
8195
  }
8106
8196
  counterfactualModel = { provider: cm.provider.trim(), id: cm.id.trim() };
@@ -8197,9 +8287,9 @@ var init_connection = __esm({
8197
8287
  });
8198
8288
 
8199
8289
  // ../worker/src/job-id.mjs
8200
- import { createHash } from "node:crypto";
8290
+ import { createHash as createHash2 } from "node:crypto";
8201
8291
  function localJobId({ folder, flow, task, minute }) {
8202
- const digest = createHash("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
8292
+ const digest = createHash2("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
8203
8293
  return `local-${digest.slice(0, 16)}`;
8204
8294
  }
8205
8295
  var init_job_id = __esm({
@@ -8276,7 +8366,7 @@ var init_reserved_env = __esm({
8276
8366
  });
8277
8367
 
8278
8368
  // ../worker/src/triggers.mjs
8279
- function isNonEmptyString4(value) {
8369
+ function isNonEmptyString5(value) {
8280
8370
  return typeof value === "string" && value.trim() !== "";
8281
8371
  }
8282
8372
  function parseTriggers(text, path) {
@@ -8331,7 +8421,7 @@ function normalizeTrigger(entry, index, path, state) {
8331
8421
  function normalizeCron(on, run, index, path, state) {
8332
8422
  const at = `trigger at index ${index}`;
8333
8423
  const id = on.id;
8334
- if (!isNonEmptyString4(id)) {
8424
+ if (!isNonEmptyString5(id)) {
8335
8425
  throw configError(`${at}: cron on.id must be a non-empty string: ${path}`);
8336
8426
  }
8337
8427
  if (id.includes(":")) {
@@ -8345,7 +8435,7 @@ function normalizeCron(on, run, index, path, state) {
8345
8435
  }
8346
8436
  state.seenCronIds.add(id);
8347
8437
  const pattern = on.pattern;
8348
- if (!isNonEmptyString4(pattern)) {
8438
+ if (!isNonEmptyString5(pattern)) {
8349
8439
  throw configError(`cron trigger "${id}": on.pattern must be a non-empty string: ${path}`);
8350
8440
  }
8351
8441
  const fieldCount = pattern.trim().split(/\s+/).length;
@@ -8353,13 +8443,13 @@ function normalizeCron(on, run, index, path, state) {
8353
8443
  throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
8354
8444
  }
8355
8445
  const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
8356
- if (!isNonEmptyString4(run.folder)) {
8446
+ if (!isNonEmptyString5(run.folder)) {
8357
8447
  throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
8358
8448
  }
8359
- if (command === void 0 && !isNonEmptyString4(run.flow)) {
8449
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8360
8450
  throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
8361
8451
  }
8362
- if (command === void 0 && !isNonEmptyString4(run.task)) {
8452
+ if (command === void 0 && !isNonEmptyString5(run.task)) {
8363
8453
  throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
8364
8454
  }
8365
8455
  if (run.github !== void 0 && typeof run.github !== "boolean") {
@@ -8443,7 +8533,7 @@ function validateCommand(run, at, path, { onType }) {
8443
8533
  throw configError(`${at}: exactly one of run.flow or run.command must be set -- a trigger dispatches either a flow or a registered command, and with both present the file does not say which one runs: ${path}`);
8444
8534
  }
8445
8535
  if (raw === void 0) return void 0;
8446
- if (!isNonEmptyString4(raw)) {
8536
+ if (!isNonEmptyString5(raw)) {
8447
8537
  throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
8448
8538
  }
8449
8539
  if (raw !== raw.trim()) {
@@ -8529,10 +8619,10 @@ function validateDisarmed(on, at, path, { capable = false, onType } = {}) {
8529
8619
  throw configError(`${at}: on.disarmed has an unsupported key ${JSON.stringify(key)} (expected at|jobId): ${path}`);
8530
8620
  }
8531
8621
  }
8532
- if (!isNonEmptyString4(disarmed.at)) {
8622
+ if (!isNonEmptyString5(disarmed.at)) {
8533
8623
  throw configError(`${at}: on.disarmed.at must be a non-empty string (the time the one-shot fired): ${path}`);
8534
8624
  }
8535
- if (disarmed.jobId !== void 0 && !isNonEmptyString4(disarmed.jobId)) {
8625
+ if (disarmed.jobId !== void 0 && !isNonEmptyString5(disarmed.jobId)) {
8536
8626
  throw configError(`${at}: on.disarmed.jobId must be a non-empty string when present (the run record it joins to): ${path}`);
8537
8627
  }
8538
8628
  if (on.once !== true) {
@@ -8556,7 +8646,7 @@ function validateRepository(run, onType, at, path) {
8556
8646
  if (!needed) {
8557
8647
  throw configError(`${at}: run.repository is only meaningful on an azure label or comment trigger (a pull request names its own repository): ${path}`);
8558
8648
  }
8559
- if (!isNonEmptyString4(raw) || raw !== raw.trim() || raw.includes("/")) {
8649
+ if (!isNonEmptyString5(raw) || raw !== raw.trim() || raw.includes("/")) {
8560
8650
  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}`);
8561
8651
  }
8562
8652
  return raw;
@@ -8596,7 +8686,7 @@ function validateSecrets(run, at, path) {
8596
8686
  throw configError(`${at}: run.secrets key ${JSON.stringify(name)} is a variable the worker sets itself -- the job container would receive the worker's value, not this trigger's, and the trigger would look like it worked: ${path}`);
8597
8687
  }
8598
8688
  const reference = secrets[name];
8599
- if (!isNonEmptyString4(reference)) {
8689
+ if (!isNonEmptyString5(reference)) {
8600
8690
  throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
8601
8691
  }
8602
8692
  if (reference !== reference.trim()) {
@@ -8614,7 +8704,7 @@ function validateSecrets(run, at, path) {
8614
8704
  function validateSecretsProfile(run, at, path) {
8615
8705
  const profile = run.secretsProfile;
8616
8706
  if (profile === void 0) return void 0;
8617
- if (!isNonEmptyString4(profile)) {
8707
+ if (!isNonEmptyString5(profile)) {
8618
8708
  throw configError(`${at}: run.secretsProfile must be a non-empty string naming one of the resolver profiles this deployment declares (got ${JSON.stringify(profile)}): ${path}`);
8619
8709
  }
8620
8710
  if (!ID_CHARSET.test(profile)) {
@@ -8632,7 +8722,7 @@ function normalizeLabel(on, run, index, path) {
8632
8722
  validateOnce(on, run, at, path, { onType: "label" });
8633
8723
  validateDisarmed(on, at, path, { onType: "label" });
8634
8724
  const command = validateCommand(run, at, path, { onType: "label" });
8635
- if (command === void 0 && !isNonEmptyString4(run.flow)) {
8725
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8636
8726
  throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8637
8727
  }
8638
8728
  validateFlowName(run, at, path);
@@ -8652,14 +8742,14 @@ function normalizeLabel(on, run, index, path) {
8652
8742
  }
8653
8743
  function normalizeComment(on, run, index, path, state) {
8654
8744
  const at = `trigger at index ${index}`;
8655
- if (!isNonEmptyString4(on.phrase)) {
8745
+ if (!isNonEmptyString5(on.phrase)) {
8656
8746
  throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
8657
8747
  }
8658
8748
  validateNumber(on, at, path, { onType: "comment" });
8659
8749
  validateOnce(on, run, at, path, { onType: "comment" });
8660
8750
  validateDisarmed(on, at, path, { onType: "comment" });
8661
8751
  const command = validateCommand(run, at, path, { onType: "comment" });
8662
- if (command === void 0 && !isNonEmptyString4(run.flow)) {
8752
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8663
8753
  throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
8664
8754
  }
8665
8755
  validateFlowName(run, at, path);
@@ -8704,7 +8794,7 @@ function normalizeIssue(on, run, index, path) {
8704
8794
  const once = validateOnce(on, run, at, path, { capable: true, onType: "issue" });
8705
8795
  validateDisarmed(on, at, path, { capable: true, onType: "issue" });
8706
8796
  const command = validateCommand(run, at, path, { onType: "issue" });
8707
- if (command === void 0 && !isNonEmptyString4(run.flow)) {
8797
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8708
8798
  throw configError(`${at}: issue trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8709
8799
  }
8710
8800
  validateFlowName(run, at, path);
@@ -8761,7 +8851,7 @@ function normalizePullRequest(on, run, index, path) {
8761
8851
  const once = validateOnce(on, run, at, path, { capable: isClose, onType: "pull_request" });
8762
8852
  validateDisarmed(on, at, path, { capable: isClose, onType: "pull_request" });
8763
8853
  const command = validateCommand(run, at, path, { onType: "pull_request" });
8764
- if (command === void 0 && !isNonEmptyString4(run.flow)) {
8854
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8765
8855
  throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8766
8856
  }
8767
8857
  validateFlowName(run, at, path);
@@ -9031,8 +9121,8 @@ var init_docker_run = __esm({
9031
9121
 
9032
9122
  // ../worker/src/packages.mjs
9033
9123
  import { existsSync, readdirSync, readFileSync } from "node:fs";
9034
- import { basename, dirname as dirname2, join, resolve } from "node:path";
9035
- function isNonEmptyString5(value) {
9124
+ import { basename, dirname as dirname2, join, resolve as resolve2 } from "node:path";
9125
+ function isNonEmptyString6(value) {
9036
9126
  return typeof value === "string" && value.trim() !== "";
9037
9127
  }
9038
9128
  function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists = existsSync } = {}) {
@@ -9046,7 +9136,7 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
9046
9136
  for (const entry of parsed.packages) {
9047
9137
  if (entry === null || typeof entry !== "object") return null;
9048
9138
  const { name, version, dir } = entry;
9049
- if (!isNonEmptyString5(name) || !isNonEmptyString5(version) || !isNonEmptyString5(dir)) return null;
9139
+ if (!isNonEmptyString6(name) || !isNonEmptyString6(version) || !isNonEmptyString6(dir)) return null;
9050
9140
  if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
9051
9141
  packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
9052
9142
  }
@@ -9071,7 +9161,7 @@ function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExists = e
9071
9161
  out.unenumerable.push(pkg.name);
9072
9162
  continue;
9073
9163
  }
9074
- sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve(root, e));
9164
+ sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve2(root, e));
9075
9165
  } else {
9076
9166
  sources = [join(root, "skills")];
9077
9167
  }
@@ -10368,6 +10458,7 @@ function resolvePaths(env = process.env) {
10368
10458
  // root, and silently wrong (demo triggers) everywhere else.
10369
10459
  triggersPath: env.PI_TRIGGERS_FILE ?? "./triggers.json",
10370
10460
  pauseWindowsPath: env.PI_PAUSE_WINDOWS_FILE ?? "./pause-windows.json",
10461
+ scopedLimitsPath: env.PI_SCOPED_LIMITS_FILE ?? "./scoped-limits.json",
10371
10462
  subscriptionsPath: env.PI_SUBSCRIPTIONS_FILE ?? "./subscriptions.json",
10372
10463
  // The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY), where the staged third-party pi
10373
10464
  // packages live under `packages/`. `|| null` so unset AND empty both read as "no overlay" -- the
@@ -10586,6 +10677,80 @@ function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs3 }) {
10586
10677
  fs.renameSync(tmp, pauseWindowsPath);
10587
10678
  return { ok: true };
10588
10679
  }
10680
+ function readScopedLimits({ scopedLimitsPath, fs = nodeFs3 }) {
10681
+ let text;
10682
+ try {
10683
+ text = fs.readFileSync(scopedLimitsPath, "utf8");
10684
+ } catch {
10685
+ return { missing: true };
10686
+ }
10687
+ try {
10688
+ return { limits: parseScopedLimits(text, scopedLimitsPath) };
10689
+ } catch (e) {
10690
+ return { invalid: e?.message ?? String(e) };
10691
+ }
10692
+ }
10693
+ function writeScopedLimits({ scopedLimitsPath, mutate, fs = nodeFs3 }) {
10694
+ let current = [];
10695
+ let existing = null;
10696
+ try {
10697
+ existing = fs.readFileSync(scopedLimitsPath, "utf8");
10698
+ } catch {
10699
+ }
10700
+ if (existing !== null) {
10701
+ try {
10702
+ current = parseScopedLimits(existing, scopedLimitsPath);
10703
+ } catch (e) {
10704
+ return { invalid: e?.message ?? String(e) };
10705
+ }
10706
+ }
10707
+ const next = mutate(current.map((l) => ({ ...l })));
10708
+ const rows = next.map((l) => Object.fromEntries(Object.entries(l).filter(([, v]) => v !== null && v !== void 0)));
10709
+ const text = `${JSON.stringify({ version: SCOPED_LIMITS_VERSION, limits: rows }, null, 2)}
10710
+ `;
10711
+ try {
10712
+ parseScopedLimits(text, scopedLimitsPath);
10713
+ } catch (e) {
10714
+ return { invalid: e?.message ?? String(e) };
10715
+ }
10716
+ const tmp = `${scopedLimitsPath}.tmp`;
10717
+ fs.writeFileSync(tmp, text, { mode: 420 });
10718
+ fs.renameSync(tmp, scopedLimitsPath);
10719
+ return { ok: true };
10720
+ }
10721
+ async function readScopedBudget({ url, limits, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
10722
+ if (!Array.isArray(limits) || limits.length === 0) return { rows: [] };
10723
+ let redis;
10724
+ try {
10725
+ parseConnection(url, { failFast: true });
10726
+ redis = redisFn(url);
10727
+ const now = /* @__PURE__ */ new Date();
10728
+ const settled = Promise.all(
10729
+ limits.map(async (l) => {
10730
+ const prefix = scopeKeyPrefix(l.scope);
10731
+ const cell2 = async (key) => Number(await redis.get(key) ?? 0);
10732
+ const row = {};
10733
+ if (l.day !== null && l.day !== void 0) row.day = await cell2(dayKey(now, prefix));
10734
+ if (l.week !== null && l.week !== void 0) row.week = await cell2(weekKey(now, prefix));
10735
+ if (l.month !== null && l.month !== void 0) row.month = await cell2(monthKey(now, prefix));
10736
+ return row;
10737
+ })
10738
+ ).then(
10739
+ (rows) => ({ rows }),
10740
+ (err) => ({ unreachable: err?.message ?? String(err) })
10741
+ );
10742
+ return await withTimeout(settled, timeoutMs, { unreachable: "timed out reaching the queue" });
10743
+ } catch (err) {
10744
+ return { unreachable: err?.message ?? String(err) };
10745
+ } finally {
10746
+ if (redis) {
10747
+ try {
10748
+ redis.disconnect();
10749
+ } catch {
10750
+ }
10751
+ }
10752
+ }
10753
+ }
10589
10754
  function readSubscriptions({ subscriptionsPath, fs = nodeFs3 }) {
10590
10755
  if (subscriptionsPath === null || subscriptionsPath === void 0) return { missing: true };
10591
10756
  let text;
@@ -10662,8 +10827,8 @@ async function readBudget({ url, redisFn = makeRedisClient, timeoutMs = 2500 } =
10662
10827
  }
10663
10828
  function withTimeout(promise, ms, fallback) {
10664
10829
  let timer;
10665
- const timeout = new Promise((resolve2) => {
10666
- timer = setTimeout(() => resolve2(fallback), ms);
10830
+ const timeout = new Promise((resolve3) => {
10831
+ timer = setTimeout(() => resolve3(fallback), ms);
10667
10832
  if (typeof timer?.unref === "function") timer.unref();
10668
10833
  });
10669
10834
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
@@ -11225,6 +11390,7 @@ var init_read_model = __esm({
11225
11390
  init_run_history();
11226
11391
  init_budget();
11227
11392
  init_pause_windows();
11393
+ init_scoped_limits();
11228
11394
  init_subscriptions();
11229
11395
  init_connection();
11230
11396
  init_queue();
@@ -12751,7 +12917,28 @@ function normBudget(v) {
12751
12917
  cap: Number.isInteger(t.cap) && t.cap > 0 ? t.cap : null,
12752
12918
  state: BUDGET_STATES.includes(t.state) ? t.state : "ok",
12753
12919
  maxTokens: Number.isInteger(t.maxTokens) && t.maxTokens > 0 ? t.maxTokens : null
12754
- }
12920
+ },
12921
+ // The scoped rows (issue #242): a pre-1.5 payload simply lacks the key and renders nothing.
12922
+ scoped: Array.isArray(v.scoped) ? v.scoped.map(normScopedRow).filter((r) => r !== null) : [],
12923
+ scopedInvalid: typeof v.scopedInvalid === "string" && v.scopedInvalid !== "" ? clip3(v.scopedInvalid, 160) : null
12924
+ };
12925
+ }
12926
+ function normScopedRow(v) {
12927
+ if (v === null || typeof v !== "object" || typeof v.scope !== "string" || v.scope === "") return null;
12928
+ const win = (w) => {
12929
+ if (w === null || w === void 0 || typeof w !== "object") return null;
12930
+ return {
12931
+ used: Number.isInteger(w.used) && w.used >= 0 ? w.used : null,
12932
+ cap: Number.isInteger(w.cap) && w.cap > 0 ? w.cap : null,
12933
+ state: BUDGET_STATES.includes(w.state) ? w.state : "ok"
12934
+ };
12935
+ };
12936
+ return {
12937
+ scope: clip3(v.scope, 60),
12938
+ day: win(v.day),
12939
+ week: win(v.week),
12940
+ month: win(v.month),
12941
+ concurrent: Number.isInteger(v.concurrent) && v.concurrent > 0 ? v.concurrent : null
12755
12942
  };
12756
12943
  }
12757
12944
  function normSpendMap(v) {
@@ -13123,6 +13310,24 @@ function budgetSectionHtml(nb) {
13123
13310
  if (nb.softHoldPct !== null) {
13124
13311
  rows.push(`<div class="dim small">soft-hold band: ${fmt(nb.softHoldPct)}% of each cap (the amber tick)</div>`);
13125
13312
  }
13313
+ if (nb.scopedInvalid !== null) {
13314
+ rows.push(`<div class="dim small">scoped limits: file invalid (${escapeHtml(nb.scopedInvalid)})</div>`);
13315
+ } else if (nb.scoped.length > 0) {
13316
+ rows.push('<div class="dim small">scoped limits (scoped-limits.json)</div>');
13317
+ for (const s of nb.scoped) {
13318
+ const bits = [];
13319
+ let worst = "ok";
13320
+ for (const key of ["day", "week", "month"]) {
13321
+ const w = s[key];
13322
+ if (w === null || w.cap === null) continue;
13323
+ bits.push(`${key} used ${w.used !== null ? fmt(w.used) : "?"} / cap ${fmt(w.cap)}`);
13324
+ if (w.state !== "ok") worst = w.state;
13325
+ }
13326
+ if (s.concurrent !== null) bits.push(`concurrent \u2264${fmt(s.concurrent)} (config; in-flight not shown)`);
13327
+ rows.push(`<div class="row"><span class="wl">${escapeHtml(s.scope)}</span><span>${escapeHtml(bits.join(" \xB7 "))}</span>${worst !== "ok" ? stateWord(worst) : ""}</div>`);
13328
+ }
13329
+ rows.push('<div class="lever">scoped: dispatch_limit_add/edit/delete \xB7 or press m in the /dispatch panel</div>');
13330
+ }
13126
13331
  rows.push('<div class="lever">adjust: /dispatch set dailyCap|weeklyCap|monthlyCap|dailyTokenCap|softHoldPct &lt;n&gt; \xB7 or press s in the /dispatch panel</div>');
13127
13332
  return `<div id="budget">${rows.join("")}</div>`;
13128
13333
  }
@@ -13528,7 +13733,7 @@ var init_open_browser = __esm({
13528
13733
  // src/deployment-pointer.mjs
13529
13734
  import * as nodeFs4 from "node:fs";
13530
13735
  import { homedir } from "node:os";
13531
- import { join as join3, isAbsolute } from "node:path";
13736
+ import { join as join3, isAbsolute as isAbsolute2 } from "node:path";
13532
13737
  function pointerPath(env = process.env) {
13533
13738
  return env.PI_DISPATCH_DEPLOYMENT_FILE || join3(env.PI_CODING_AGENT_DIR || join3(homedir(), ".pi", "agent"), POINTER_BASENAME);
13534
13739
  }
@@ -13542,7 +13747,7 @@ function normalizePointer(raw) {
13542
13747
  if (raw.version > POINTER_VERSION) {
13543
13748
  return { ignored: `version ${raw.version} is newer than the supported version ${POINTER_VERSION}` };
13544
13749
  }
13545
- if (typeof raw.deploymentDir !== "string" || !isAbsolute(raw.deploymentDir)) {
13750
+ if (typeof raw.deploymentDir !== "string" || !isAbsolute2(raw.deploymentDir)) {
13546
13751
  return { ignored: "deploymentDir must be an absolute path" };
13547
13752
  }
13548
13753
  const rawEnv = raw.env === void 0 ? {} : raw.env;
@@ -13553,7 +13758,7 @@ function normalizePointer(raw) {
13553
13758
  for (const key of POINTER_ENV_ALLOWLIST) {
13554
13759
  const value = rawEnv[key];
13555
13760
  if (typeof value !== "string" || value === "") continue;
13556
- if (key !== "VALKEY_URL" && !isAbsolute(value)) continue;
13761
+ if (key !== "VALKEY_URL" && !isAbsolute2(value)) continue;
13557
13762
  env[key] = value;
13558
13763
  }
13559
13764
  return { pointer: { version: raw.version, deploymentDir: raw.deploymentDir, env } };
@@ -13624,6 +13829,7 @@ var init_deployment_pointer = __esm({
13624
13829
  "PI_SETTINGS_FILE",
13625
13830
  "PI_TRIGGERS_FILE",
13626
13831
  "PI_PAUSE_WINDOWS_FILE",
13832
+ "PI_SCOPED_LIMITS_FILE",
13627
13833
  "PI_SUBSCRIPTIONS_FILE"
13628
13834
  ]);
13629
13835
  POINTER_BASENAME = "pi-dispatch-deployment.json";
@@ -39168,7 +39374,7 @@ var init_pricing = __esm({
39168
39374
 
39169
39375
  // ../worker/src/sandbox-store.mjs
39170
39376
  import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
39171
- import { isAbsolute as isAbsolute2, join as join5, relative } from "node:path";
39377
+ import { isAbsolute as isAbsolute3, join as join5, relative } from "node:path";
39172
39378
  function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
39173
39379
  if (!sandboxDir || jobId === void 0 || jobId === null) return null;
39174
39380
  const dir = join5(sandboxDir, sanitizeJobId(jobId));
@@ -39241,10 +39447,10 @@ function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, f
39241
39447
  return { manifest, name: sandboxContainerName(jobId), publish };
39242
39448
  }
39243
39449
  function launchSandbox({ args, spawnFn = spawn }) {
39244
- return new Promise((resolve2) => {
39450
+ return new Promise((resolve3) => {
39245
39451
  const child = spawnFn("docker", args, { stdio: "inherit" });
39246
- child.on("error", (err) => resolve2({ code: null, error: err }));
39247
- child.on("close", (code) => resolve2({ code }));
39452
+ child.on("error", (err) => resolve3({ code: null, error: err }));
39453
+ child.on("close", (code) => resolve3({ code }));
39248
39454
  });
39249
39455
  }
39250
39456
  var exec3, SANDBOX_NAME_PREFIX;
@@ -39299,6 +39505,24 @@ function renderBudget({ budget, settings: settings2 } = {}) {
39299
39505
  if (pct !== null) lines.push(` soft-hold band: ${pct}%`);
39300
39506
  return lines.join("\n");
39301
39507
  }
39508
+ function renderScopedLimits({ limits, scopedBudget } = {}) {
39509
+ if (limits?.invalid) return `Scoped limits: file invalid (${limits.invalid})`;
39510
+ const list = Array.isArray(limits?.limits) ? limits.limits : [];
39511
+ if (list.length === 0) return null;
39512
+ const lines = ["Scoped limits:"];
39513
+ list.forEach((l, i) => {
39514
+ const used = scopedBudget?.rows?.[i] ?? null;
39515
+ const bits = [];
39516
+ for (const key of ["day", "week", "month"]) {
39517
+ if (!Number.isInteger(l[key])) continue;
39518
+ const u = used && Number.isFinite(used[key]) ? used[key] : "-";
39519
+ bits.push(`${key} ${u}/${l[key]}`);
39520
+ }
39521
+ if (Number.isInteger(l.concurrent)) bits.push(`<=${l.concurrent} at once`);
39522
+ lines.push(` ${l.scope}: ${bits.join(" \xB7 ")}`);
39523
+ });
39524
+ return lines.join("\n");
39525
+ }
39302
39526
  function capLabel(cap, reserved, pct) {
39303
39527
  if (!Number.isInteger(cap)) return "unknown (worker env/default)";
39304
39528
  const state = windowState(reserved, cap, pct);
@@ -39666,7 +39890,25 @@ function createDashboardDeps(paths) {
39666
39890
  redis.hgetall(STALL_KEY).catch(() => ({}))
39667
39891
  ]);
39668
39892
  const workers = Array.isArray(workerList) && workerList.length > 0 ? workerList.length : "unknown";
39893
+ const scopedLimits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
39894
+ const limitRows = Array.isArray(scopedLimits?.limits) ? scopedLimits.limits : [];
39895
+ const scopedNow = /* @__PURE__ */ new Date();
39896
+ const scopedBudget = {
39897
+ rows: await Promise.all(
39898
+ limitRows.map(async (l) => {
39899
+ const prefix = scopeKeyPrefix(l.scope);
39900
+ const cell2 = (key) => redis.get(key).then((v) => Number(v ?? 0), () => null);
39901
+ const row = {};
39902
+ if (Number.isInteger(l.day)) row.day = await cell2(dayKey(scopedNow, prefix));
39903
+ if (Number.isInteger(l.week)) row.week = await cell2(weekKey(scopedNow, prefix));
39904
+ if (Number.isInteger(l.month)) row.month = await cell2(monthKey(scopedNow, prefix));
39905
+ return row;
39906
+ })
39907
+ )
39908
+ };
39669
39909
  return {
39910
+ scopedLimits,
39911
+ scopedBudget,
39670
39912
  queue: { pausedState, counts, workers },
39671
39913
  budget: { day: Number(dayRaw ?? 0), week: Number(weekRaw ?? 0), month: Number(monthRaw ?? 0), tokensToday: Number(tokenRaw ?? 0) },
39672
39914
  schedulers: mapSchedulers(schedulerList, Date.now()),
@@ -40027,6 +40269,10 @@ function makeDashboard({
40027
40269
  void dispose().finally(() => done({ action: "managePauses" }));
40028
40270
  return;
40029
40271
  }
40272
+ if (data === "m" || data === "M") {
40273
+ void dispose().finally(() => done({ action: "manageLimits" }));
40274
+ return;
40275
+ }
40030
40276
  if (matchesKey(data, "up")) {
40031
40277
  selected = Math.max(0, selected - 1);
40032
40278
  tui?.requestRender?.();
@@ -40130,6 +40376,8 @@ function renderPanel(snapshot, width, state, styler) {
40130
40376
  { title: "RUNS", lines: renderRunList(buildRows(snapshot, runSort), selected, 24) },
40131
40377
  { title: "SETTINGS", lines: toLines(renderSettingsView(snapshot.settings)) }
40132
40378
  ];
40379
+ const scopedText = renderScopedLimits({ limits: snapshot.scopedLimits, scopedBudget: snapshot.scopedBudget });
40380
+ if (scopedText) sections.splice(2, 0, { title: "SCOPED LIMITS", lines: toLines(scopedText) });
40133
40381
  const plain = [title];
40134
40382
  for (const section of sections) plain.push(section.title, ...section.lines);
40135
40383
  plain.push(KEY_HINTS);
@@ -40157,6 +40405,7 @@ function collapseKeys(sections, availableRows, focus, baseRows) {
40157
40405
  function buildListLines(snapshot, selected, inner, styler, runSort = "time", availableRows = null) {
40158
40406
  const trg = triggerLines(snapshot, selected, inner, styler);
40159
40407
  const pw = pauseLines(snapshot.pauseWindows, inner, styler);
40408
+ const sl = limitLines(snapshot.scopedLimits, snapshot.scopedBudget, inner, styler);
40160
40409
  const runRows = buildRows(snapshot, runSort).slice(trg.count);
40161
40410
  const runCount = Array.isArray(snapshot.runs) ? snapshot.runs.length : 0;
40162
40411
  const sections = [
@@ -40164,6 +40413,10 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
40164
40413
  { key: "spend", head: ["spend & limits", "jobs & tokens/day \xB7 s set"], body: spendLines(snapshot.budget, snapshot.settings, inner, styler), priority: 4, viewKey: "s" },
40165
40414
  { key: "triggers", head: ["triggers", `${trg.count} standing \xB7 a add \xB7 \u21B5 open`], body: trg.lines, priority: 3, viewKey: "tab" },
40166
40415
  { key: "pauses", head: ["pause windows", `${pw.count} \xB7 w manage`], body: pw.lines, priority: 1, viewKey: "w" },
40416
+ // Priority 0: the section an operator acts on least often from the panel folds FIRST under the
40417
+ // collapse budget. The key hint lives in this divider, not the footer -- the footer's width
40418
+ // arithmetic has no headroom for another hint (its own comment), the s/o/Tab precedent.
40419
+ { key: "limits", head: ["scoped limits", `${sl.count} \xB7 m manage`], body: sl.lines, priority: 0, viewKey: "m" },
40167
40420
  { key: "runs", head: ["runs", `last ${runCount} \xB7 o ${runSort}`], body: runLines(runRows, selected - trg.count, inner, styler) },
40168
40421
  { key: "settings", head: ["settings", "s edit"], body: settingsLines(snapshot.settings, inner, styler), priority: 2, viewKey: "s" }
40169
40422
  ];
@@ -40198,7 +40451,8 @@ function statusHeader(queue, inner, styler) {
40198
40451
  const dot = styler.fg(stateColor, "\u25CF");
40199
40452
  const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : "PAUSED"));
40200
40453
  const sep2 = styler.fg("dim", " \xB7 ");
40201
- const vitals = `${c.waiting ?? 0} waiting` + sep2 + `${c.active ?? 0} active` + sep2 + (failed > 0 ? styler.fg("error", `${failed} failed`) : `${failed} failed`) + sep2 + `${queue.workers ?? "?"} workers`;
40454
+ const delayed = Number(c.delayed ?? 0);
40455
+ const vitals = `${c.waiting ?? 0} waiting` + sep2 + `${c.active ?? 0} active` + sep2 + (delayed > 0 ? `${delayed} delayed` + sep2 : "") + (failed > 0 ? styler.fg("error", `${failed} failed`) : `${failed} failed`) + sep2 + `${queue.workers ?? "?"} workers`;
40202
40456
  const clock = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
40203
40457
  const left = `${dot} ${word} ${vitals}`;
40204
40458
  const gap = inner - styler.visibleLen(left) - clock.length;
@@ -40353,6 +40607,39 @@ function pauseRow(w, now, inner, styler) {
40353
40607
  if (until) bits.push(styler.fg("warning", `resumes in ${humanizeMs(until - now) || "<1m"}`));
40354
40608
  return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
40355
40609
  }
40610
+ function limitLines(scopedLimits, scopedBudget, inner, styler) {
40611
+ const lines = [];
40612
+ if (scopedLimits && scopedLimits.missing) {
40613
+ lines.push(styler.cell("(no scoped limits \xB7 m to manage)", inner, { color: "dim" }));
40614
+ return { count: 0, lines };
40615
+ }
40616
+ if (scopedLimits && scopedLimits.invalid) {
40617
+ lines.push(styler.cell(`(scoped-limits file invalid: ${scopedLimits.invalid})`, inner, { color: "error" }));
40618
+ return { count: 0, lines };
40619
+ }
40620
+ const list = (scopedLimits && scopedLimits.limits) ?? [];
40621
+ if (list.length === 0) {
40622
+ lines.push(styler.cell("(no scoped limits \xB7 m to manage)", inner, { color: "dim" }));
40623
+ return { count: 0, lines };
40624
+ }
40625
+ list.forEach((l, i) => lines.push(limitRow(l, scopedBudget?.rows?.[i] ?? null, inner, styler)));
40626
+ return { count: list.length, lines };
40627
+ }
40628
+ function limitRow(l, used, inner, styler) {
40629
+ let atCap = false;
40630
+ const windows = [];
40631
+ for (const key of ["day", "week", "month"]) {
40632
+ if (!Number.isInteger(l[key])) continue;
40633
+ const u = used && Number.isFinite(used[key]) ? used[key] : null;
40634
+ const over = u !== null && u >= l[key];
40635
+ if (over) atCap = true;
40636
+ windows.push(styler.fg(over ? "warning" : "text", `${key} ${u ?? "-"}/${l[key]}`));
40637
+ }
40638
+ const dot = atCap ? styler.fg("warning", "\u25CF") : styler.fg("dim", "\u25CB");
40639
+ const bits = [`${dot} ${styler.fg("accent", l.scope ?? "-")}`, ...windows];
40640
+ if (Number.isInteger(l.concurrent)) bits.push(styler.fg("muted", `\u2264${l.concurrent} at once`));
40641
+ return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
40642
+ }
40356
40643
  function runLines(rows, selected, inner, styler) {
40357
40644
  if (!Array.isArray(rows) || rows.length === 0) return [styler.cell("(no runs)", inner, { color: "dim" })];
40358
40645
  const { top, count } = runsWindow(rows.length, selected);
@@ -40770,6 +41057,7 @@ var init_dashboard = __esm({
40770
41057
  init_scheduler_stall_guard();
40771
41058
  init_pause_windows();
40772
41059
  init_read_model();
41060
+ init_scoped_limits();
40773
41061
  init_render();
40774
41062
  init_keys2();
40775
41063
  init_panel();
@@ -40858,16 +41146,16 @@ async function runAttached(ctx, { title, argv0, args, cwd, env, shell, spawnFn =
40858
41146
  ${title}
40859
41147
 
40860
41148
  `);
40861
- outcome = await new Promise((resolve2) => {
41149
+ outcome = await new Promise((resolve3) => {
40862
41150
  let child;
40863
41151
  try {
40864
41152
  child = spawnFn(argv0, args, { stdio: "inherit", cwd, env, ...shell ? { shell: true } : {} });
40865
41153
  } catch (err) {
40866
- resolve2({ code: null, error: err });
41154
+ resolve3({ code: null, error: err });
40867
41155
  return;
40868
41156
  }
40869
- child.on("error", (err) => resolve2({ code: null, error: err }));
40870
- child.on("close", (code) => resolve2({ code }));
41157
+ child.on("error", (err) => resolve3({ code: null, error: err }));
41158
+ child.on("close", (code) => resolve3({ code }));
40871
41159
  });
40872
41160
  } catch (err) {
40873
41161
  outcome = { code: null, error: err };
@@ -41086,6 +41374,7 @@ up shows each docker action and asks y/N before it \u2014 nothing is auto-accept
41086
41374
  env: {
41087
41375
  PI_TRIGGERS_FILE: join6(dir, "triggers.json"),
41088
41376
  PI_PAUSE_WINDOWS_FILE: join6(dir, "pause-windows.json"),
41377
+ PI_SCOPED_LIMITS_FILE: join6(dir, "scoped-limits.json"),
41089
41378
  PI_SUBSCRIPTIONS_FILE: join6(dir, "subscriptions.json")
41090
41379
  }
41091
41380
  };
@@ -41318,7 +41607,7 @@ var init_setup_wizard = __esm({
41318
41607
  init_deployment_pointer();
41319
41608
  init_read_model();
41320
41609
  init_index();
41321
- RUNTIME_VERSION = "1.4.0";
41610
+ RUNTIME_VERSION = "1.5.0";
41322
41611
  RECEIVER_VERSION = "1.3.0";
41323
41612
  RUNTIME_PKG = "@edgehero/pi-dispatch";
41324
41613
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
@@ -41513,15 +41802,15 @@ function registerTools(pi) {
41513
41802
  pi.registerTool({
41514
41803
  name: "dispatch_costs",
41515
41804
  label: "pi-dispatch costs",
41516
- description: "Read-only. Folds the PII-free run history against the operator's declared subscriptions and pi-ai's rate tables into the costs read-model: window totals, daily buckets, per-flow and per-model rollups, per-plan verdicts, and provenance. window = 7d | 30d | mtd (default mtd); flow filters to one flow's runs.",
41517
- parameters: typebox_exports.Object({ window: typebox_exports.Optional(typebox_exports.String()), flow: typebox_exports.Optional(typebox_exports.String()) }),
41805
+ description: `Read-only. Folds the PII-free run history against the operator's declared subscriptions and pi-ai's rate tables into the costs read-model: window totals, daily buckets, per-flow and per-model rollups, per-plan verdicts, and provenance. window = 7d | 30d | mtd (default mtd); flow filters to one flow's runs; repo filters to one repo's runs by the by-repo rollup's key (e.g. "acme/web" or "local:site").`,
41806
+ parameters: typebox_exports.Object({ window: typebox_exports.Optional(typebox_exports.String()), flow: typebox_exports.Optional(typebox_exports.String()), repo: typebox_exports.Optional(typebox_exports.String()) }),
41518
41807
  async execute(_toolCallId, params) {
41519
41808
  const window = params.window ?? "mtd";
41520
41809
  if (!COSTS_WINDOWS.includes(window)) {
41521
41810
  throw new Error(`unknown window '${window}' (7d|30d|mtd)`);
41522
41811
  }
41523
41812
  const paths = resolvePaths(process.env);
41524
- const res = assembleCosts(paths, window, params.flow);
41813
+ const res = assembleCosts(paths, window, params.flow, params.repo);
41525
41814
  if (res.unreachable) throw new Error(`could not read the run history: ${res.unreachable}`);
41526
41815
  return toolText(JSON.stringify({ window, fold: res.fold }));
41527
41816
  }
@@ -41803,6 +42092,113 @@ ${JSON.stringify(before)}
41803
42092
  return toolText(JSON.stringify(result));
41804
42093
  }
41805
42094
  });
42095
+ pi.registerTool({
42096
+ name: "dispatch_limits",
42097
+ label: "pi-dispatch scoped limits",
42098
+ description: "Read-only. Lists the scoped limits (per repo/folder budget caps and concurrency, scoped-limits.json) with their array index and, when the queue is reachable, each scope's used day/week/month run counts. Use the index for dispatch_limit_edit / dispatch_limit_delete. The always-on one-job-per-folder mutex for local jobs is separate: it has no entry here and no switch anywhere.",
42099
+ parameters: typebox_exports.Object({}),
42100
+ async execute() {
42101
+ const paths = resolvePaths(process.env);
42102
+ const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
42103
+ if (!Array.isArray(p?.limits)) return toolText(JSON.stringify(p));
42104
+ const counters = await readScopedBudget({ url: paths.valkeyUrl, limits: p.limits });
42105
+ const rows = p.limits.map((l, index) => ({ index, ...l, used: counters.rows?.[index] ?? null }));
42106
+ return toolText(JSON.stringify(rows));
42107
+ }
42108
+ });
42109
+ pi.registerTool({
42110
+ name: "dispatch_limit_add",
42111
+ label: "pi-dispatch add scoped limit",
42112
+ description: 'Adds a scoped limit and applies it live: jobs whose scope (a repo "owner/name" or a local folder path, ABSOLUTE, matched exactly like pause windows \u2014 no globs) exceed the `day`/`week`/`month` job-count caps are REFUSED before any spend (reason scope-cap, never retried); jobs over `concurrent` are DEFERRED, never dropped, and run when a slot frees. At least one of day/week/month/concurrent is required, each an integer >= 1. The operator MUST approve a confirm dialog showing the entry; refused with no interactive operator.',
42113
+ executionMode: "sequential",
42114
+ parameters: typebox_exports.Object({
42115
+ scope: typebox_exports.String(),
42116
+ day: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
42117
+ week: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
42118
+ month: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
42119
+ concurrent: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
42120
+ }),
42121
+ async execute(_id, params, _signal, _onUpdate, ctx) {
42122
+ const paths = resolvePaths(process.env);
42123
+ const l = buildScopedLimit(params);
42124
+ const result = await confirmedWrite(
42125
+ ctx,
42126
+ { title: "Add scoped limit", message: `Add to scoped-limits.json:
42127
+ ${JSON.stringify(l)}` },
42128
+ () => {
42129
+ const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (list) => [...list, l] });
42130
+ if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
42131
+ return { applied: true, added: l };
42132
+ }
42133
+ );
42134
+ return toolText(JSON.stringify(result));
42135
+ }
42136
+ });
42137
+ pi.registerTool({
42138
+ name: "dispatch_limit_delete",
42139
+ label: "pi-dispatch delete scoped limit",
42140
+ description: "Removes a scoped limit (by array index from dispatch_limits) and applies it live. The operator MUST approve a confirm dialog showing the limit; refused with no interactive operator.",
42141
+ executionMode: "sequential",
42142
+ parameters: typebox_exports.Object({ index: typebox_exports.Integer({ minimum: 0 }) }),
42143
+ async execute(_id, params, _signal, _onUpdate, ctx) {
42144
+ const paths = resolvePaths(process.env);
42145
+ const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
42146
+ const list = Array.isArray(p?.limits) ? p.limits : [];
42147
+ const cur = list[params.index];
42148
+ if (!cur) throw new Error(`no scoped limit at index ${params.index} (have ${list.length})`);
42149
+ const result = await confirmedWrite(
42150
+ ctx,
42151
+ { title: `Delete scoped limit #${params.index + 1}`, message: `Remove scoped limit #${params.index + 1}: ${cur.scope} ${limitSummary(cur)}` },
42152
+ () => {
42153
+ const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.filter((_, i) => i !== params.index) });
42154
+ if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
42155
+ return { applied: true, deletedIndex: params.index };
42156
+ }
42157
+ );
42158
+ return toolText(JSON.stringify(result));
42159
+ }
42160
+ });
42161
+ pi.registerTool({
42162
+ name: "dispatch_limit_edit",
42163
+ label: "pi-dispatch edit scoped limit",
42164
+ description: "Changes fields of an existing scoped limit (by array index from dispatch_limits) and applies it live. Provide only the fields to change (scope/day/week/month/concurrent); the rest keep their current value (to drop one cap from an entry, delete it and re-add without that field). The operator MUST approve a confirm dialog showing the before->after; refused with no interactive operator.",
42165
+ executionMode: "sequential",
42166
+ parameters: typebox_exports.Object({
42167
+ index: typebox_exports.Integer({ minimum: 0 }),
42168
+ scope: typebox_exports.Optional(typebox_exports.String()),
42169
+ day: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
42170
+ week: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
42171
+ month: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
42172
+ concurrent: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
42173
+ }),
42174
+ async execute(_id, params, _signal, _onUpdate, ctx) {
42175
+ const paths = resolvePaths(process.env);
42176
+ const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
42177
+ const list = Array.isArray(p?.limits) ? p.limits : [];
42178
+ const cur = list[params.index];
42179
+ if (!cur) throw new Error(`no scoped limit at index ${params.index} (have ${list.length})`);
42180
+ const before = buildScopedLimit(cur);
42181
+ const merged = buildScopedLimit({
42182
+ scope: params.scope ?? cur.scope,
42183
+ day: params.day ?? cur.day,
42184
+ week: params.week ?? cur.week,
42185
+ month: params.month ?? cur.month,
42186
+ concurrent: params.concurrent ?? cur.concurrent
42187
+ });
42188
+ const result = await confirmedWrite(
42189
+ ctx,
42190
+ { title: `Edit scoped limit #${params.index + 1}`, message: `scoped limit #${params.index + 1}:
42191
+ ${JSON.stringify(before)}
42192
+ \u2192 ${JSON.stringify(merged)}` },
42193
+ () => {
42194
+ const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.map((w, i) => i === params.index ? merged : w) });
42195
+ if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
42196
+ return { applied: true, index: params.index, limit: merged };
42197
+ }
42198
+ );
42199
+ return toolText(JSON.stringify(result));
42200
+ }
42201
+ });
41806
42202
  pi.registerTool({
41807
42203
  name: "dispatch_trigger_delete",
41808
42204
  label: "pi-dispatch delete trigger",
@@ -41888,6 +42284,22 @@ function buildPauseWindow(f) {
41888
42284
  if (dateTo) w.dateTo = dateTo;
41889
42285
  return w;
41890
42286
  }
42287
+ function buildScopedLimit(f) {
42288
+ const l = { scope: String(f.scope ?? "").trim() };
42289
+ for (const k of ["day", "week", "month", "concurrent"]) {
42290
+ const v = optInt(f[k]);
42291
+ if (v !== void 0) l[k] = v;
42292
+ }
42293
+ return l;
42294
+ }
42295
+ function limitSummary(l) {
42296
+ const bits = [];
42297
+ if (Number.isInteger(l?.day)) bits.push(`day ${l.day}`);
42298
+ if (Number.isInteger(l?.week)) bits.push(`week ${l.week}`);
42299
+ if (Number.isInteger(l?.month)) bits.push(`month ${l.month}`);
42300
+ if (Number.isInteger(l?.concurrent)) bits.push(`\u2264${l.concurrent} at once`);
42301
+ return bits.join(" \xB7 ");
42302
+ }
41891
42303
  function asWords(x) {
41892
42304
  if (Array.isArray(x)) return x.map((s) => String(s).trim()).filter(Boolean);
41893
42305
  return splitWords(x);
@@ -41957,7 +42369,11 @@ ${renderBudget({ budget, settings: settings2 })}`);
41957
42369
  case "budget": {
41958
42370
  const budget = await readBudget({ url: paths.valkeyUrl });
41959
42371
  const settings2 = readSettingsView({ settingsFile: paths.settingsFile });
41960
- send(pi, renderBudget({ budget, settings: settings2 }));
42372
+ const limits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
42373
+ const scopedBudget = Array.isArray(limits?.limits) && limits.limits.length > 0 ? await readScopedBudget({ url: paths.valkeyUrl, limits: limits.limits }) : { rows: [] };
42374
+ const scopedText = renderScopedLimits({ limits, scopedBudget });
42375
+ send(pi, `${renderBudget({ budget, settings: settings2 })}${scopedText ? `
42376
+ ${scopedText}` : ""}`);
41961
42377
  return;
41962
42378
  }
41963
42379
  case "triggers": {
@@ -42038,13 +42454,14 @@ ${renderBudget({ budget, settings: settings2 })}`);
42038
42454
  return;
42039
42455
  }
42040
42456
  }
42041
- function assembleCosts(paths, window, flow) {
42457
+ function assembleCosts(paths, window, flow, repo) {
42042
42458
  const nowMs = Date.now();
42043
42459
  const sinceMs = costsSinceMs(window, nowMs);
42044
42460
  const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs, nowMs });
42045
42461
  if (!Array.isArray(records)) return records;
42046
42462
  const subs = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
42047
- const scoped = typeof flow === "string" && flow !== "" ? records.filter((r) => (r?.flow ?? null) === flow) : records;
42463
+ const flowScoped = typeof flow === "string" && flow !== "" ? records.filter((r) => (r?.flow ?? null) === flow) : records;
42464
+ const scoped = typeof repo === "string" && repo !== "" ? flowScoped.filter((r) => repoOfTarget(r?.target) === repo) : flowScoped;
42048
42465
  const triggersView = readTriggers({ triggersPath: paths.triggersPath });
42049
42466
  const triggerJoin = attributeRunsToTriggers({ records: scoped, triggers: Array.isArray(triggersView?.triggers) ? triggersView.triggers : [] });
42050
42467
  const fold = foldCosts({
@@ -42107,13 +42524,27 @@ async function assembleBudgetView(paths) {
42107
42524
  const tokensSpent = unreachable === null ? Number(raw.tokensToday ?? 0) : null;
42108
42525
  const tokenCap = posIntOrNull(overlay.dailyTokenCap);
42109
42526
  const tokenState = tokenCap !== null && tokensSpent !== null ? tokensSpent >= tokenCap ? "over" : pct !== null && tokensSpent > Math.floor(tokenCap * pct / 100) ? "soft-hold" : "ok" : "ok";
42527
+ const limitsView = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
42528
+ const limits = Array.isArray(limitsView?.limits) ? limitsView.limits : [];
42529
+ const scopedCounters = limits.length > 0 ? await readScopedBudget({ url: paths.valkeyUrl, limits }) : { rows: [] };
42530
+ const scoped = limits.map((l, i) => {
42531
+ const used = scopedCounters?.rows?.[i] ?? null;
42532
+ const win = (cap, key) => {
42533
+ if (cap === null) return null;
42534
+ const u = used && Number.isFinite(used[key]) ? used[key] : null;
42535
+ return { used: u, cap, state: u !== null ? windowState(u, cap, null) : "ok" };
42536
+ };
42537
+ return { scope: l.scope, day: win(l.day, "day"), week: win(l.week, "week"), month: win(l.month, "month"), concurrent: l.concurrent };
42538
+ });
42110
42539
  return {
42111
42540
  unreachable,
42112
42541
  softHoldPct: pct,
42113
42542
  day: slotWindow("day", "dailyCap"),
42114
42543
  week: slotWindow("week", "weeklyCap"),
42115
42544
  month: slotWindow("month", "monthlyCap"),
42116
- tokens: { spent: tokensSpent, cap: tokenCap, state: tokenState, maxTokens: posIntOrNull(overlay.maxTokens) }
42545
+ tokens: { spent: tokensSpent, cap: tokenCap, state: tokenState, maxTokens: posIntOrNull(overlay.maxTokens) },
42546
+ scoped,
42547
+ scopedInvalid: limitsView?.invalid ? String(limitsView.invalid) : null
42117
42548
  };
42118
42549
  }
42119
42550
  async function assembleInsights(paths, window) {
@@ -42280,7 +42711,7 @@ could not start docker: ${error.message}
42280
42711
  }
42281
42712
  }
42282
42713
  function pauseForMessage() {
42283
- return new Promise((resolve2) => setTimeout(resolve2, 2500));
42714
+ return new Promise((resolve3) => setTimeout(resolve3, 2500));
42284
42715
  }
42285
42716
  function splitWords(s) {
42286
42717
  return String(s ?? "").split(/\s+/).map((w) => w.trim()).filter(Boolean);
@@ -42306,8 +42737,84 @@ async function handleDashboardAction(result, paths, ctx) {
42306
42737
  return editSettingsViaDialogs(paths, ui, notify);
42307
42738
  case "managePauses":
42308
42739
  return managePausesViaDialogs(paths, ui, notify);
42740
+ case "manageLimits":
42741
+ return manageLimitsViaDialogs(paths, ui, notify);
42309
42742
  }
42310
42743
  }
42744
+ async function manageLimitsViaDialogs(paths, ui, notify) {
42745
+ const action = await ui.select("Scoped limits", ["Add a scoped limit", "Edit a scoped limit", "Delete a scoped limit"]);
42746
+ if (!action) return;
42747
+ if (action.startsWith("Add")) return addScopedLimitViaDialogs(paths, ui, notify);
42748
+ if (action.startsWith("Edit")) return editScopedLimitViaDialogs(paths, ui, notify);
42749
+ return deleteScopedLimitViaDialogs(paths, ui, notify);
42750
+ }
42751
+ async function addScopedLimitViaDialogs(paths, ui, notify) {
42752
+ const scope = await ui.input('scope \u2014 a repo "owner/name" or an ABSOLUTE local folder path (exact match, no globs)', "");
42753
+ if (scope === void 0 || scope.trim() === "") return;
42754
+ const day = await ui.input("day \u2014 max jobs per UTC day for this scope (blank = no day cap)", "");
42755
+ if (day === void 0) return;
42756
+ const week = await ui.input("week \u2014 max jobs per Mon-Sun UTC week (blank = no week cap)", "");
42757
+ if (week === void 0) return;
42758
+ const month = await ui.input("month \u2014 max jobs per calendar month (blank = no month cap)", "");
42759
+ if (month === void 0) return;
42760
+ const concurrent = await ui.input("concurrent \u2014 max jobs in flight for this scope, extras deferred (blank = no limit)", "");
42761
+ if (concurrent === void 0) return;
42762
+ const l = buildScopedLimit({ scope, day, week, month, concurrent });
42763
+ const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (list) => [...list, l] });
42764
+ notify?.(res.ok ? `scoped limit added (live) \u2014 ${l.scope} ${limitSummary(l)}` : `add rejected: ${res.invalid}`, res.ok ? "info" : "error");
42765
+ }
42766
+ async function editScopedLimitViaDialogs(paths, ui, notify) {
42767
+ const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
42768
+ const list = Array.isArray(p?.limits) ? p.limits : [];
42769
+ if (list.length === 0) {
42770
+ notify?.("no scoped limits to edit", "info");
42771
+ return;
42772
+ }
42773
+ const labels = list.map((l, i) => `#${i + 1} ${l.scope} ${limitSummary(l)}`);
42774
+ const picked = await ui.select("Edit which scoped limit", labels);
42775
+ if (!picked) return;
42776
+ const index = labels.indexOf(picked);
42777
+ if (index < 0) return;
42778
+ const cur = list[index];
42779
+ const ask = async (label, current) => ui.input(`${label} \u2014 blank keeps "${current}"`, current);
42780
+ const keep = (v, current) => v === void 0 || v.trim() === "" ? current : v;
42781
+ const scope = await ask("scope", cur.scope ?? "");
42782
+ if (scope === void 0) return;
42783
+ const day = await ask("day cap", String(cur.day ?? ""));
42784
+ if (day === void 0) return;
42785
+ const week = await ask("week cap", String(cur.week ?? ""));
42786
+ if (week === void 0) return;
42787
+ const month = await ask("month cap", String(cur.month ?? ""));
42788
+ if (month === void 0) return;
42789
+ const concurrent = await ask("concurrent (max in flight)", String(cur.concurrent ?? ""));
42790
+ if (concurrent === void 0) return;
42791
+ const merged = buildScopedLimit({
42792
+ scope: keep(scope, cur.scope),
42793
+ day: keep(day, cur.day),
42794
+ week: keep(week, cur.week),
42795
+ month: keep(month, cur.month),
42796
+ concurrent: keep(concurrent, cur.concurrent)
42797
+ });
42798
+ const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.map((w, i) => i === index ? merged : w) });
42799
+ notify?.(res.ok ? `scoped limit #${index + 1} updated (live) \u2014 ${merged.scope} ${limitSummary(merged)}` : `edit rejected: ${res.invalid}`, res.ok ? "info" : "error");
42800
+ }
42801
+ async function deleteScopedLimitViaDialogs(paths, ui, notify) {
42802
+ const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
42803
+ const list = Array.isArray(p?.limits) ? p.limits : [];
42804
+ if (list.length === 0) {
42805
+ notify?.("no scoped limits to delete", "info");
42806
+ return;
42807
+ }
42808
+ const labels = list.map((l, i) => `#${i + 1} ${l.scope} ${limitSummary(l)}`);
42809
+ const picked = await ui.select("Delete which scoped limit", labels);
42810
+ if (!picked) return;
42811
+ const index = labels.indexOf(picked);
42812
+ if (index < 0) return;
42813
+ const ok = await ui.confirm("Delete scoped limit", `Remove ${labels[index]}?`);
42814
+ if (!ok) return;
42815
+ const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.filter((_, i) => i !== index) });
42816
+ notify?.(res.ok ? `scoped limit #${index + 1} deleted (live)` : `delete rejected: ${res.invalid}`, res.ok ? "info" : "error");
42817
+ }
42311
42818
  async function managePausesViaDialogs(paths, ui, notify) {
42312
42819
  const action = await ui.select("Pause windows", ["Add a pause window", "Edit a pause window", "Delete a pause window"]);
42313
42820
  if (!action) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-admin",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Operator console (a pi extension) + skill for pi-dispatch: run the pi coding agent as a self-hosted service. A /dispatch TUI for the queue, spend caps, run history, editable GitHub, GitLab, Forgejo and Azure DevOps triggers (cron/label/comment/PR/MR/work item), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -41,6 +41,12 @@ dialog before it takes effect**:
41
41
  (per folder/repo "quiet hours": runs for a scope are deferred between certain times and auto-resume after;
42
42
  `dispatch_pauses` lists them with their index). `dispatch_pause_edit` is a partial change — pass the index
43
43
  plus only the fields to alter. Deferring never drops a job and costs no budget.
44
+ - `dispatch_limit_add` / `dispatch_limit_edit` / `dispatch_limit_delete` — manage scoped limits (per
45
+ repo/folder budget caps and concurrency: `day`/`week`/`month` job-count caps refuse a job pre-spend with
46
+ reason `scope-cap`, never retried; `concurrent` defers the excess, never drops it; `dispatch_limits`
47
+ lists them with their index and used counts). `dispatch_limit_edit` is a partial change — pass the index
48
+ plus only the fields to alter. Scopes match exactly (a repo `owner/name` or an ABSOLUTE folder path, no
49
+ globs).
44
50
 
45
51
  Use them like this:
46
52
 
@@ -235,3 +241,28 @@ If you are asked why a GitLab trigger did not fire, the usual answers in order:
235
241
  2. **No label was added by that event.** GitLab has no `labeled` action; the trigger fires on the labels
236
242
  an event *added*, so editing an already-labelled issue does nothing. This is deliberate.
237
243
  3. **The action word belongs to the other forge.** See above.
244
+
245
+ ## Scoped limits — and the folder mutex you cannot turn off
246
+
247
+ `scoped-limits.json` holds per-scope bounds beside the deployment-global ones: `day`/`week`/`month` cap
248
+ how many jobs a repo or folder may run per window (refused pre-spend, reason `scope-cap`, never retried,
249
+ the scope's own counter still counts the refusal), and `concurrent` caps how many run at once (the excess
250
+ is deferred to the delayed set and runs when a slot frees — never dropped, no budget spent while waiting).
251
+ Edits apply live: the worker hot-reloads the file and keeps the last good version on a bad edit.
252
+
253
+ Separate from all of that, **local jobs carry a built-in one-job-per-folder mutex: at most one job per
254
+ folder at a time, always on, with NO configuration, NO tool, and NO panel key.** If an operator asks to
255
+ disable it, say plainly that there is no switch, deliberately: two agents editing one working tree race
256
+ each other with no gate and no undo, and an off switch's only use is re-opening that race. A `concurrent`
257
+ value on a folder scope can never raise the mutex's one-at-a-time (the lower bound always wins).
258
+
259
+ Three more things to say when asked:
260
+
261
+ - **A `scope-cap` refusal is final for that window.** The counter is not resettable from any tool; the
262
+ window rolls over on its own (day/week/month, UTC). Raising the cap via `dispatch_limit_edit` takes
263
+ effect at the next job.
264
+ - **Deferrals are visible only as the queue's delayed count** (the panel's status line shows it when it
265
+ is nonzero). That count also includes cron next-occurrences and retry backoff — a nonzero number is
266
+ normal on any deployment with schedules.
267
+ - **Per-scope in-flight is not displayed anywhere.** The panel and `dispatch_limits` show `concurrent` as
268
+ configuration only; the live count lives inside the worker process and no reader can see it.