@edgehero/pi-dispatch-admin 1.4.0 → 1.6.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/dist/index.mjs CHANGED
@@ -7687,6 +7687,33 @@ var init_secret_profiles = __esm({
7687
7687
  }
7688
7688
  });
7689
7689
 
7690
+ // ../worker/src/wait-for.mjs
7691
+ function parseAfterInstant(text) {
7692
+ if (typeof text !== "string" || !AFTER_INSTANT.test(text)) return { error: "shape" };
7693
+ const ms = Date.parse(text);
7694
+ if (!Number.isFinite(ms)) return { error: "calendar" };
7695
+ const [year, month, day] = text.slice(0, 10).split("-").map(Number);
7696
+ const probe = new Date(Date.UTC(year, month - 1, day));
7697
+ if (year < 100) probe.setUTCFullYear(year);
7698
+ if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) {
7699
+ return { error: "calendar" };
7700
+ }
7701
+ return { ms };
7702
+ }
7703
+ function afterInstantMs(text) {
7704
+ const parsed = parseAfterInstant(text);
7705
+ return parsed.error ? null : parsed.ms;
7706
+ }
7707
+ var WAIT_CONDITION_KEYS, WAIT_CONDITION_MAX, WAIT_AFTER_MAX_DEFAULT_MS, AFTER_INSTANT;
7708
+ var init_wait_for = __esm({
7709
+ "../worker/src/wait-for.mjs"() {
7710
+ WAIT_CONDITION_KEYS = ["after", "profile"];
7711
+ WAIT_CONDITION_MAX = 4;
7712
+ WAIT_AFTER_MAX_DEFAULT_MS = 30 * 24 * 3600 * 1e3;
7713
+ AFTER_INSTANT = /^\d{4}-\d{2}-\d{2}[Tt]([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?([Zz]|[+-]\d{2}:\d{2})$/;
7714
+ }
7715
+ });
7716
+
7690
7717
  // ../worker/src/config.mjs
7691
7718
  function configError(message) {
7692
7719
  const error = new Error(message);
@@ -7714,6 +7741,7 @@ var init_config2 = __esm({
7714
7741
  init_egress();
7715
7742
  init_forges();
7716
7743
  init_secret_profiles();
7744
+ init_wait_for();
7717
7745
  CHAIN_DEPTH_MAX_DEFAULT = 1;
7718
7746
  CHAIN_MAX_PER_JOB_DEFAULT = 2;
7719
7747
  WORKER_ONLY_SECRET_VARS = /* @__PURE__ */ new Set(["GITHUB_APP_PRIVATE_KEY"]);
@@ -8024,10 +8052,112 @@ var init_pause_windows = __esm({
8024
8052
  }
8025
8053
  });
8026
8054
 
8027
- // ../worker/src/subscriptions.mjs
8055
+ // ../worker/src/scoped-limits.mjs
8056
+ import { createHash } from "node:crypto";
8057
+ import { isAbsolute, resolve } from "node:path";
8028
8058
  function isNonEmptyString3(value) {
8029
8059
  return typeof value === "string" && value.trim() !== "";
8030
8060
  }
8061
+ function parseScopedLimits(text, path) {
8062
+ let parsed;
8063
+ try {
8064
+ parsed = JSON.parse(text);
8065
+ } catch (error) {
8066
+ throw configError(`scoped-limits file is not valid JSON: ${path} (${error.message})`);
8067
+ }
8068
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
8069
+ throw configError(`scoped-limits file must be an object with "version" and "limits": ${path}`);
8070
+ }
8071
+ const version = parsed.version;
8072
+ if (!Number.isInteger(version) || version < 1) {
8073
+ throw configError(`scoped-limits file must have "version": 1 (an integer >= 1): ${path}`);
8074
+ }
8075
+ if (version > SCOPED_LIMITS_VERSION) {
8076
+ throw configError(`scoped-limits file written by a newer pi-dispatch (version ${version}; this build understands ${SCOPED_LIMITS_VERSION}): ${path}`);
8077
+ }
8078
+ if (!Array.isArray(parsed.limits)) {
8079
+ throw configError(`scoped-limits file must have a "limits" array: ${path}`);
8080
+ }
8081
+ const rows = parsed.limits.map((row, index) => normalizeLimit(row, index, path));
8082
+ const seen = /* @__PURE__ */ new Map();
8083
+ rows.forEach((row, index) => {
8084
+ if (seen.has(row.scope)) {
8085
+ throw configError(`scoped limit at index ${index}: duplicate scope ${JSON.stringify(row.scope)} (first at index ${seen.get(row.scope)}): ${path}`);
8086
+ }
8087
+ seen.set(row.scope, index);
8088
+ });
8089
+ return rows;
8090
+ }
8091
+ function normalizeLimit(row, index, path) {
8092
+ const at = `scoped limit at index ${index}`;
8093
+ if (row === null || typeof row !== "object" || Array.isArray(row)) {
8094
+ throw configError(`${at}: must be an object: ${path}`);
8095
+ }
8096
+ if (!isNonEmptyString3(row.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
8097
+ const trimmed = row.scope.trim().normalize("NFC");
8098
+ if (trimmed === "*") {
8099
+ throw configError(`${at}: "*" is not supported -- add one row per scope (a per-scope default may adopt "*" later): ${path}`);
8100
+ }
8101
+ if (trimmed.includes("*")) {
8102
+ throw configError(`${at}: scopes match exactly; a scope containing "*" is refused (no globs): ${path}`);
8103
+ }
8104
+ const norm = {
8105
+ // An absolute path is stored resolved so a `/srv/site/` row governs `/srv/site` jobs -- the same
8106
+ // collapse canonicalScope applies on the job side. isAbsolute is PLATFORM-NATIVE on purpose, so a
8107
+ // foreign-platform row (a windows drive path on a POSIX worker) stays verbatim and is inert here;
8108
+ // the doctor's unreferenced-scope advisory names it. Resolving it instead would "work" only by
8109
+ // both sides mangling into the same cwd-prefixed string -- a match by accident, not by contract.
8110
+ scope: isAbsolute(trimmed) ? resolve(trimmed) : trimmed,
8111
+ day: null,
8112
+ week: null,
8113
+ month: null,
8114
+ concurrent: null
8115
+ };
8116
+ let any = false;
8117
+ for (const field of LIMIT_FIELDS) {
8118
+ const value = row[field];
8119
+ if (value === void 0 || value === null) continue;
8120
+ if (!Number.isSafeInteger(value) || value < 1) {
8121
+ throw configError(`${at}: ${field} must be an integer >= 1: ${path}`);
8122
+ }
8123
+ norm[field] = value;
8124
+ any = true;
8125
+ }
8126
+ if (!any) {
8127
+ 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}`);
8128
+ }
8129
+ return norm;
8130
+ }
8131
+ function scopeKeyPrefix(scope) {
8132
+ const h = createHash("sha256").update(String(scope)).digest("hex").slice(0, 16);
8133
+ return `budget:s:${h}`;
8134
+ }
8135
+ var SCOPED_LIMITS_VERSION, LIMIT_FIELDS;
8136
+ var init_scoped_limits = __esm({
8137
+ "../worker/src/scoped-limits.mjs"() {
8138
+ init_config2();
8139
+ init_pause_windows();
8140
+ SCOPED_LIMITS_VERSION = 1;
8141
+ LIMIT_FIELDS = ["day", "week", "month", "concurrent"];
8142
+ }
8143
+ });
8144
+
8145
+ // ../worker/src/wait-state.mjs
8146
+ var LEASE_SLACK_MS, LEASE_MIN_MS, HELD_SET, jobKey, SATISFIED_MS;
8147
+ var init_wait_state = __esm({
8148
+ "../worker/src/wait-state.mjs"() {
8149
+ LEASE_SLACK_MS = 60 * 60 * 1e3;
8150
+ LEASE_MIN_MS = 5 * 60 * 1e3;
8151
+ HELD_SET = "wait:held";
8152
+ jobKey = (jobId) => `wait:job:${jobId}`;
8153
+ SATISFIED_MS = 15 * 60 * 1e3;
8154
+ }
8155
+ });
8156
+
8157
+ // ../worker/src/subscriptions.mjs
8158
+ function isNonEmptyString4(value) {
8159
+ return typeof value === "string" && value.trim() !== "";
8160
+ }
8031
8161
  function normalizeGlobs(value, label, at, path) {
8032
8162
  if (!Array.isArray(value) || value.length === 0) {
8033
8163
  throw configError(`${at}: ${label} must be a non-empty array of model globs: ${path}`);
@@ -8073,14 +8203,14 @@ function normalizeSubscription(entry, index, path, seenIds) {
8073
8203
  if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
8074
8204
  throw configError(`${at}: must be an object: ${path}`);
8075
8205
  }
8076
- if (!isNonEmptyString3(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
8206
+ if (!isNonEmptyString4(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
8077
8207
  const id = entry.id.trim();
8078
8208
  if (seenIds.has(id)) {
8079
8209
  throw configError(`${at}: id ${JSON.stringify(id)} is already used by the subscription at index ${seenIds.get(id)}: ${path}`);
8080
8210
  }
8081
8211
  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}`);
8212
+ if (!isNonEmptyString4(entry.vendor)) throw configError(`${at}: vendor must be a non-empty string: ${path}`);
8213
+ if (!isNonEmptyString4(entry.provider)) throw configError(`${at}: provider must be a non-empty string (the pi-ai provider id this plan covers): ${path}`);
8084
8214
  const models = entry.models === void 0 ? ["*"] : normalizeGlobs(entry.models, "models", at, path);
8085
8215
  const price = entry.price;
8086
8216
  if (price === null || typeof price !== "object" || Array.isArray(price)) {
@@ -8100,7 +8230,7 @@ function normalizeSubscription(entry, index, path, seenIds) {
8100
8230
  let counterfactualModel = null;
8101
8231
  if (entry.counterfactualModel !== void 0 && entry.counterfactualModel !== null) {
8102
8232
  const cm = entry.counterfactualModel;
8103
- if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString3(cm.provider) || !isNonEmptyString3(cm.id)) {
8233
+ if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString4(cm.provider) || !isNonEmptyString4(cm.id)) {
8104
8234
  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
8235
  }
8106
8236
  counterfactualModel = { provider: cm.provider.trim(), id: cm.id.trim() };
@@ -8197,9 +8327,9 @@ var init_connection = __esm({
8197
8327
  });
8198
8328
 
8199
8329
  // ../worker/src/job-id.mjs
8200
- import { createHash } from "node:crypto";
8330
+ import { createHash as createHash2 } from "node:crypto";
8201
8331
  function localJobId({ folder, flow, task, minute }) {
8202
- const digest = createHash("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
8332
+ const digest = createHash2("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
8203
8333
  return `local-${digest.slice(0, 16)}`;
8204
8334
  }
8205
8335
  var init_job_id = __esm({
@@ -8276,7 +8406,7 @@ var init_reserved_env = __esm({
8276
8406
  });
8277
8407
 
8278
8408
  // ../worker/src/triggers.mjs
8279
- function isNonEmptyString4(value) {
8409
+ function isNonEmptyString5(value) {
8280
8410
  return typeof value === "string" && value.trim() !== "";
8281
8411
  }
8282
8412
  function parseTriggers(text, path) {
@@ -8331,7 +8461,7 @@ function normalizeTrigger(entry, index, path, state) {
8331
8461
  function normalizeCron(on, run, index, path, state) {
8332
8462
  const at = `trigger at index ${index}`;
8333
8463
  const id = on.id;
8334
- if (!isNonEmptyString4(id)) {
8464
+ if (!isNonEmptyString5(id)) {
8335
8465
  throw configError(`${at}: cron on.id must be a non-empty string: ${path}`);
8336
8466
  }
8337
8467
  if (id.includes(":")) {
@@ -8345,7 +8475,7 @@ function normalizeCron(on, run, index, path, state) {
8345
8475
  }
8346
8476
  state.seenCronIds.add(id);
8347
8477
  const pattern = on.pattern;
8348
- if (!isNonEmptyString4(pattern)) {
8478
+ if (!isNonEmptyString5(pattern)) {
8349
8479
  throw configError(`cron trigger "${id}": on.pattern must be a non-empty string: ${path}`);
8350
8480
  }
8351
8481
  const fieldCount = pattern.trim().split(/\s+/).length;
@@ -8353,13 +8483,13 @@ function normalizeCron(on, run, index, path, state) {
8353
8483
  throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
8354
8484
  }
8355
8485
  const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
8356
- if (!isNonEmptyString4(run.folder)) {
8486
+ if (!isNonEmptyString5(run.folder)) {
8357
8487
  throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
8358
8488
  }
8359
- if (command === void 0 && !isNonEmptyString4(run.flow)) {
8489
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8360
8490
  throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
8361
8491
  }
8362
- if (command === void 0 && !isNonEmptyString4(run.task)) {
8492
+ if (command === void 0 && !isNonEmptyString5(run.task)) {
8363
8493
  throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
8364
8494
  }
8365
8495
  if (run.github !== void 0 && typeof run.github !== "boolean") {
@@ -8376,6 +8506,7 @@ function normalizeCron(on, run, index, path, state) {
8376
8506
  validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
8377
8507
  const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
8378
8508
  const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
8509
+ validateWaitFor(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
8379
8510
  return {
8380
8511
  on: { type: "cron", id, pattern },
8381
8512
  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, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
@@ -8443,7 +8574,7 @@ function validateCommand(run, at, path, { onType }) {
8443
8574
  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
8575
  }
8445
8576
  if (raw === void 0) return void 0;
8446
- if (!isNonEmptyString4(raw)) {
8577
+ if (!isNonEmptyString5(raw)) {
8447
8578
  throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
8448
8579
  }
8449
8580
  if (raw !== raw.trim()) {
@@ -8529,10 +8660,10 @@ function validateDisarmed(on, at, path, { capable = false, onType } = {}) {
8529
8660
  throw configError(`${at}: on.disarmed has an unsupported key ${JSON.stringify(key)} (expected at|jobId): ${path}`);
8530
8661
  }
8531
8662
  }
8532
- if (!isNonEmptyString4(disarmed.at)) {
8663
+ if (!isNonEmptyString5(disarmed.at)) {
8533
8664
  throw configError(`${at}: on.disarmed.at must be a non-empty string (the time the one-shot fired): ${path}`);
8534
8665
  }
8535
- if (disarmed.jobId !== void 0 && !isNonEmptyString4(disarmed.jobId)) {
8666
+ if (disarmed.jobId !== void 0 && !isNonEmptyString5(disarmed.jobId)) {
8536
8667
  throw configError(`${at}: on.disarmed.jobId must be a non-empty string when present (the run record it joins to): ${path}`);
8537
8668
  }
8538
8669
  if (on.once !== true) {
@@ -8556,7 +8687,7 @@ function validateRepository(run, onType, at, path) {
8556
8687
  if (!needed) {
8557
8688
  throw configError(`${at}: run.repository is only meaningful on an azure label or comment trigger (a pull request names its own repository): ${path}`);
8558
8689
  }
8559
- if (!isNonEmptyString4(raw) || raw !== raw.trim() || raw.includes("/")) {
8690
+ if (!isNonEmptyString5(raw) || raw !== raw.trim() || raw.includes("/")) {
8560
8691
  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
8692
  }
8562
8693
  return raw;
@@ -8596,7 +8727,7 @@ function validateSecrets(run, at, path) {
8596
8727
  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
8728
  }
8598
8729
  const reference = secrets[name];
8599
- if (!isNonEmptyString4(reference)) {
8730
+ if (!isNonEmptyString5(reference)) {
8600
8731
  throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
8601
8732
  }
8602
8733
  if (reference !== reference.trim()) {
@@ -8614,7 +8745,7 @@ function validateSecrets(run, at, path) {
8614
8745
  function validateSecretsProfile(run, at, path) {
8615
8746
  const profile = run.secretsProfile;
8616
8747
  if (profile === void 0) return void 0;
8617
- if (!isNonEmptyString4(profile)) {
8748
+ if (!isNonEmptyString5(profile)) {
8618
8749
  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
8750
  }
8620
8751
  if (!ID_CHARSET.test(profile)) {
@@ -8625,6 +8756,81 @@ function validateSecretsProfile(run, at, path) {
8625
8756
  }
8626
8757
  return profile;
8627
8758
  }
8759
+ function validateWaitFor(on, run, at, path, { onType }) {
8760
+ for (const [label, source, exactIsLegal] of [
8761
+ ["run", run, true],
8762
+ ["on", on, false]
8763
+ ]) {
8764
+ for (const key of Object.keys(source ?? {})) {
8765
+ if (exactIsLegal && key === "waitFor") continue;
8766
+ if (key.replace(/[^a-z0-9]/gi, "").toLowerCase() !== "waitfor") continue;
8767
+ throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.waitFor? A wait condition the loader drops holds nothing while the file reads as though it does, so a near miss is refused rather than dropped: ${path}`);
8768
+ }
8769
+ }
8770
+ const waitFor = run.waitFor;
8771
+ if (waitFor === void 0) return void 0;
8772
+ if (onType === "cron") {
8773
+ throw configError(`${at}: run.waitFor is not yet covered on a cron trigger -- the scheduler advances at pickup, so a held occurrence outlives the teardown that would delete it and still pays, and its surviving job hash can fail worker boot with a scheduler id collision; bounding that needs delayed-set-aware teardown, which is a gap to close, not a limit: ${path}`);
8774
+ }
8775
+ if (on?.once === true) {
8776
+ throw configError(`${at}: run.waitFor and on.once cannot be combined -- the one-shot disarms on every run record, so a wait that never cleared would spend the one-shot with no container ever started and no way to re-arm it but editing this file: ${path}`);
8777
+ }
8778
+ if (run.replicas !== void 0) {
8779
+ throw configError(`${at}: run.waitFor and run.replicas cannot be combined -- replicas fan out at enqueue, so each one would hold and poll the same external condition independently, multiplying the checks by ${run.replicas} for one answer: ${path}`);
8780
+ }
8781
+ if (!Array.isArray(waitFor) || waitFor.length === 0) {
8782
+ throw configError(`${at}: run.waitFor must be a non-empty array of conditions when present (got ${JSON.stringify(waitFor)}): ${path}`);
8783
+ }
8784
+ if (waitFor.length > WAIT_CONDITION_MAX) {
8785
+ throw configError(`${at}: run.waitFor names ${waitFor.length} conditions, over the ${WAIT_CONDITION_MAX} cap -- each profile condition is a subprocess run before the container, holding a concurrency slot while it runs: ${path}`);
8786
+ }
8787
+ const expected = WAIT_CONDITION_KEYS.join("|");
8788
+ const normalized = [];
8789
+ const profiles = /* @__PURE__ */ new Set();
8790
+ let seenAfter = false;
8791
+ for (let i = 0; i < waitFor.length; i += 1) {
8792
+ const condition = waitFor[i];
8793
+ const where = `${at}: run.waitFor[${i}]`;
8794
+ if (condition === null || typeof condition !== "object" || Array.isArray(condition)) {
8795
+ throw configError(`${where} must be an object naming exactly one condition (got ${JSON.stringify(condition)}): ${path}`);
8796
+ }
8797
+ const keys = Object.keys(condition);
8798
+ if (keys.length !== 1) {
8799
+ throw configError(`${where} must name exactly one condition (expected ${expected}), got ${keys.length === 0 ? "an empty object" : JSON.stringify(keys)}: ${path}`);
8800
+ }
8801
+ const key = keys[0];
8802
+ if (key === "exclusive") {
8803
+ throw configError(`${where}: exclusive is no longer a condition you write -- the worker already holds at most one local job per folder, always on, with no configuration and no off switch, and scoped-limits.json bounds any scope further: ${path}`);
8804
+ }
8805
+ if (!WAIT_CONDITION_KEYS.includes(key)) {
8806
+ throw configError(`${where} has an unsupported condition ${JSON.stringify(key)} (expected ${expected}) -- an unknown condition here is a term of the gate that would silently do nothing, so it is refused rather than dropped: ${path}`);
8807
+ }
8808
+ if (key === "after") {
8809
+ if (seenAfter) {
8810
+ throw configError(`${where}: run.waitFor names a second "after" -- the conditions are a conjunction, so two instants mean the later one and the file would read as though both mattered: ${path}`);
8811
+ }
8812
+ if (afterInstantMs(condition.after) === null) {
8813
+ throw configError(`${where}: after must be an ISO-8601 instant carrying its own zone, like "2026-09-01T09:00:00Z" or "2026-09-01T11:00:00+02:00" (got ${JSON.stringify(condition.after)}) -- without a zone the same file would hold for a different instant on a worker in a different timezone: ${path}`);
8814
+ }
8815
+ seenAfter = true;
8816
+ normalized.push({ after: condition.after });
8817
+ continue;
8818
+ }
8819
+ const profile = condition.profile;
8820
+ if (!isNonEmptyString5(profile)) {
8821
+ throw configError(`${where}: profile must be a non-empty string naming one of the wait profiles this deployment declares (got ${JSON.stringify(profile)}): ${path}`);
8822
+ }
8823
+ if (!ID_CHARSET.test(profile)) {
8824
+ throw configError(`${where}: profile ${JSON.stringify(profile)} may use letters, digits, dot, dash and underscore only -- PI_WAIT_PROFILES is a comma-separated list of name:path pairs, so a name carrying either separator cannot be declared: ${path}`);
8825
+ }
8826
+ if (profiles.has(profile)) {
8827
+ throw configError(`${where}: run.waitFor names the profile ${JSON.stringify(profile)} twice -- the conditions are a conjunction, so the second one can never change the answer while it doubles the subprocesses: ${path}`);
8828
+ }
8829
+ profiles.add(profile);
8830
+ normalized.push({ profile });
8831
+ }
8832
+ return normalized;
8833
+ }
8628
8834
  function normalizeLabel(on, run, index, path) {
8629
8835
  const at = `trigger at index ${index}`;
8630
8836
  const predicate = validatePredicate(on, index, path, true);
@@ -8632,7 +8838,7 @@ function normalizeLabel(on, run, index, path) {
8632
8838
  validateOnce(on, run, at, path, { onType: "label" });
8633
8839
  validateDisarmed(on, at, path, { onType: "label" });
8634
8840
  const command = validateCommand(run, at, path, { onType: "label" });
8635
- if (command === void 0 && !isNonEmptyString4(run.flow)) {
8841
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8636
8842
  throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8637
8843
  }
8638
8844
  validateFlowName(run, at, path);
@@ -8645,21 +8851,22 @@ function normalizeLabel(on, run, index, path) {
8645
8851
  const replicas = validateReplicas(run, at, path);
8646
8852
  const secrets = validateSecrets(run, at, path);
8647
8853
  const secretsProfile = validateSecretsProfile(run, at, path);
8854
+ const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
8648
8855
  return {
8649
8856
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
8650
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
8857
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
8651
8858
  };
8652
8859
  }
8653
8860
  function normalizeComment(on, run, index, path, state) {
8654
8861
  const at = `trigger at index ${index}`;
8655
- if (!isNonEmptyString4(on.phrase)) {
8862
+ if (!isNonEmptyString5(on.phrase)) {
8656
8863
  throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
8657
8864
  }
8658
8865
  validateNumber(on, at, path, { onType: "comment" });
8659
8866
  validateOnce(on, run, at, path, { onType: "comment" });
8660
8867
  validateDisarmed(on, at, path, { onType: "comment" });
8661
8868
  const command = validateCommand(run, at, path, { onType: "comment" });
8662
- if (command === void 0 && !isNonEmptyString4(run.flow)) {
8869
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8663
8870
  throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
8664
8871
  }
8665
8872
  validateFlowName(run, at, path);
@@ -8676,9 +8883,10 @@ function normalizeComment(on, run, index, path, state) {
8676
8883
  const replicas = validateReplicas(run, at, path);
8677
8884
  const secrets = validateSecrets(run, at, path);
8678
8885
  const secretsProfile = validateSecretsProfile(run, at, path);
8886
+ const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
8679
8887
  return {
8680
8888
  on: { type: "comment", phrase: on.phrase },
8681
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
8889
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
8682
8890
  };
8683
8891
  }
8684
8892
  function normalizeIssue(on, run, index, path) {
@@ -8704,7 +8912,7 @@ function normalizeIssue(on, run, index, path) {
8704
8912
  const once = validateOnce(on, run, at, path, { capable: true, onType: "issue" });
8705
8913
  validateDisarmed(on, at, path, { capable: true, onType: "issue" });
8706
8914
  const command = validateCommand(run, at, path, { onType: "issue" });
8707
- if (command === void 0 && !isNonEmptyString4(run.flow)) {
8915
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8708
8916
  throw configError(`${at}: issue trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8709
8917
  }
8710
8918
  validateFlowName(run, at, path);
@@ -8717,6 +8925,7 @@ function normalizeIssue(on, run, index, path) {
8717
8925
  const replicas = validateReplicas(run, at, path);
8718
8926
  const secrets = validateSecrets(run, at, path);
8719
8927
  const secretsProfile = validateSecretsProfile(run, at, path);
8928
+ const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
8720
8929
  return {
8721
8930
  on: {
8722
8931
  type: "issue",
@@ -8726,7 +8935,7 @@ function normalizeIssue(on, run, index, path) {
8726
8935
  ...number !== void 0 && { number },
8727
8936
  ...once !== void 0 && { once }
8728
8937
  },
8729
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
8938
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
8730
8939
  };
8731
8940
  }
8732
8941
  function normalizePullRequest(on, run, index, path) {
@@ -8761,7 +8970,7 @@ function normalizePullRequest(on, run, index, path) {
8761
8970
  const once = validateOnce(on, run, at, path, { capable: isClose, onType: "pull_request" });
8762
8971
  validateDisarmed(on, at, path, { capable: isClose, onType: "pull_request" });
8763
8972
  const command = validateCommand(run, at, path, { onType: "pull_request" });
8764
- if (command === void 0 && !isNonEmptyString4(run.flow)) {
8973
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8765
8974
  throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8766
8975
  }
8767
8976
  validateFlowName(run, at, path);
@@ -8774,6 +8983,7 @@ function normalizePullRequest(on, run, index, path) {
8774
8983
  const replicas = validateReplicas(run, at, path);
8775
8984
  const secrets = validateSecrets(run, at, path);
8776
8985
  const secretsProfile = validateSecretsProfile(run, at, path);
8986
+ const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
8777
8987
  return {
8778
8988
  on: {
8779
8989
  type: "pull_request",
@@ -8788,7 +8998,7 @@ function normalizePullRequest(on, run, index, path) {
8788
8998
  ...number !== void 0 && { number },
8789
8999
  ...once !== void 0 && { once }
8790
9000
  },
8791
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
9001
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
8792
9002
  };
8793
9003
  }
8794
9004
  function validateReviewState(on, actions, run, at, path) {
@@ -8817,6 +9027,7 @@ var init_triggers = __esm({
8817
9027
  init_flow_gate();
8818
9028
  init_forges();
8819
9029
  init_reserved_env();
9030
+ init_wait_for();
8820
9031
  ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request", "issue"]);
8821
9032
  DISARMED_TYPE = "disarmed";
8822
9033
  PR_ACTIONS = {
@@ -9031,8 +9242,8 @@ var init_docker_run = __esm({
9031
9242
 
9032
9243
  // ../worker/src/packages.mjs
9033
9244
  import { existsSync, readdirSync, readFileSync } from "node:fs";
9034
- import { basename, dirname as dirname2, join, resolve } from "node:path";
9035
- function isNonEmptyString5(value) {
9245
+ import { basename, dirname as dirname2, join, resolve as resolve2 } from "node:path";
9246
+ function isNonEmptyString6(value) {
9036
9247
  return typeof value === "string" && value.trim() !== "";
9037
9248
  }
9038
9249
  function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists = existsSync } = {}) {
@@ -9046,7 +9257,7 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
9046
9257
  for (const entry of parsed.packages) {
9047
9258
  if (entry === null || typeof entry !== "object") return null;
9048
9259
  const { name, version, dir } = entry;
9049
- if (!isNonEmptyString5(name) || !isNonEmptyString5(version) || !isNonEmptyString5(dir)) return null;
9260
+ if (!isNonEmptyString6(name) || !isNonEmptyString6(version) || !isNonEmptyString6(dir)) return null;
9050
9261
  if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
9051
9262
  packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
9052
9263
  }
@@ -9071,7 +9282,7 @@ function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExists = e
9071
9282
  out.unenumerable.push(pkg.name);
9072
9283
  continue;
9073
9284
  }
9074
- sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve(root, e));
9285
+ sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve2(root, e));
9075
9286
  } else {
9076
9287
  sources = [join(root, "skills")];
9077
9288
  }
@@ -10368,6 +10579,7 @@ function resolvePaths(env = process.env) {
10368
10579
  // root, and silently wrong (demo triggers) everywhere else.
10369
10580
  triggersPath: env.PI_TRIGGERS_FILE ?? "./triggers.json",
10370
10581
  pauseWindowsPath: env.PI_PAUSE_WINDOWS_FILE ?? "./pause-windows.json",
10582
+ scopedLimitsPath: env.PI_SCOPED_LIMITS_FILE ?? "./scoped-limits.json",
10371
10583
  subscriptionsPath: env.PI_SUBSCRIPTIONS_FILE ?? "./subscriptions.json",
10372
10584
  // The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY), where the staged third-party pi
10373
10585
  // packages live under `packages/`. `|| null` so unset AND empty both read as "no overlay" -- the
@@ -10586,6 +10798,172 @@ function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs3 }) {
10586
10798
  fs.renameSync(tmp, pauseWindowsPath);
10587
10799
  return { ok: true };
10588
10800
  }
10801
+ function readScopedLimits({ scopedLimitsPath, fs = nodeFs3 }) {
10802
+ let text;
10803
+ try {
10804
+ text = fs.readFileSync(scopedLimitsPath, "utf8");
10805
+ } catch {
10806
+ return { missing: true };
10807
+ }
10808
+ try {
10809
+ return { limits: parseScopedLimits(text, scopedLimitsPath) };
10810
+ } catch (e) {
10811
+ return { invalid: e?.message ?? String(e) };
10812
+ }
10813
+ }
10814
+ function writeScopedLimits({ scopedLimitsPath, mutate, fs = nodeFs3 }) {
10815
+ let current = [];
10816
+ let existing = null;
10817
+ try {
10818
+ existing = fs.readFileSync(scopedLimitsPath, "utf8");
10819
+ } catch {
10820
+ }
10821
+ if (existing !== null) {
10822
+ try {
10823
+ current = parseScopedLimits(existing, scopedLimitsPath);
10824
+ } catch (e) {
10825
+ return { invalid: e?.message ?? String(e) };
10826
+ }
10827
+ }
10828
+ const next = mutate(current.map((l) => ({ ...l })));
10829
+ const rows = next.map((l) => Object.fromEntries(Object.entries(l).filter(([, v]) => v !== null && v !== void 0)));
10830
+ const text = `${JSON.stringify({ version: SCOPED_LIMITS_VERSION, limits: rows }, null, 2)}
10831
+ `;
10832
+ try {
10833
+ parseScopedLimits(text, scopedLimitsPath);
10834
+ } catch (e) {
10835
+ return { invalid: e?.message ?? String(e) };
10836
+ }
10837
+ const tmp = `${scopedLimitsPath}.tmp`;
10838
+ fs.writeFileSync(tmp, text, { mode: 420 });
10839
+ fs.renameSync(tmp, scopedLimitsPath);
10840
+ return { ok: true };
10841
+ }
10842
+ async function readScopedBudget({ url, limits, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
10843
+ if (!Array.isArray(limits) || limits.length === 0) return { rows: [] };
10844
+ let redis;
10845
+ try {
10846
+ parseConnection(url, { failFast: true });
10847
+ redis = redisFn(url);
10848
+ const now = /* @__PURE__ */ new Date();
10849
+ const settled = Promise.all(
10850
+ limits.map(async (l) => {
10851
+ const prefix = scopeKeyPrefix(l.scope);
10852
+ const cell2 = async (key) => Number(await redis.get(key) ?? 0);
10853
+ const row = {};
10854
+ if (l.day !== null && l.day !== void 0) row.day = await cell2(dayKey(now, prefix));
10855
+ if (l.week !== null && l.week !== void 0) row.week = await cell2(weekKey(now, prefix));
10856
+ if (l.month !== null && l.month !== void 0) row.month = await cell2(monthKey(now, prefix));
10857
+ return row;
10858
+ })
10859
+ ).then(
10860
+ (rows) => ({ rows }),
10861
+ (err) => ({ unreachable: err?.message ?? String(err) })
10862
+ );
10863
+ return await withTimeout(settled, timeoutMs, { unreachable: "timed out reaching the queue" });
10864
+ } catch (err) {
10865
+ return { unreachable: err?.message ?? String(err) };
10866
+ } finally {
10867
+ if (redis) {
10868
+ try {
10869
+ redis.disconnect();
10870
+ } catch {
10871
+ }
10872
+ }
10873
+ }
10874
+ }
10875
+ async function readHeldJobs({ url, limit = 20, redisFn = makeRedisClient, timeoutMs = 2500, now = () => Date.now() } = {}) {
10876
+ let redis;
10877
+ try {
10878
+ parseConnection(url, { failFast: true });
10879
+ redis = redisFn(url);
10880
+ const settled = (async () => {
10881
+ const ids = await redis.smembers(HELD_SET);
10882
+ const hydrated = await Promise.all(
10883
+ ids.slice(0, HELD_HYDRATE_MAX).map(async (jobId) => {
10884
+ try {
10885
+ const h = await redis.hgetall(jobKey(jobId));
10886
+ if (!h || !h.since) {
10887
+ await redis.srem(HELD_SET, jobId).catch(() => {
10888
+ });
10889
+ return null;
10890
+ }
10891
+ const since = Number(h.since);
10892
+ return {
10893
+ jobId,
10894
+ target: h.target || null,
10895
+ label: h.label || null,
10896
+ waitedMs: Number.isFinite(since) && since > 0 ? Math.max(0, now() - since) : null,
10897
+ checks: Number(h.checks) || 0
10898
+ };
10899
+ } catch {
10900
+ return null;
10901
+ }
10902
+ })
10903
+ );
10904
+ const rows = hydrated.filter(Boolean);
10905
+ rows.sort((a, b) => (b.waitedMs ?? -1) - (a.waitedMs ?? -1));
10906
+ return {
10907
+ rows: rows.slice(0, limit),
10908
+ more: Math.max(0, rows.length - limit),
10909
+ // True when the index itself was longer than this reader will hydrate: `more` is then a floor, and
10910
+ // a caller that renders a total must say so rather than state a number it cannot know.
10911
+ truncated: ids.length > HELD_HYDRATE_MAX
10912
+ };
10913
+ })();
10914
+ return await withTimeout(settled, timeoutMs, { unreachable: "timed out reaching the queue" });
10915
+ } catch (err) {
10916
+ return { unreachable: err?.message ?? String(err) };
10917
+ } finally {
10918
+ if (redis) {
10919
+ try {
10920
+ redis.disconnect();
10921
+ } catch {
10922
+ }
10923
+ }
10924
+ }
10925
+ }
10926
+ async function cancelHeldJob({ url, jobId, redisFn = makeRedisClient, queueFn = makeQueue, timeoutMs = 2500 } = {}) {
10927
+ if (typeof jobId !== "string" || jobId === "") return { invalid: "a job id is required" };
10928
+ let redis;
10929
+ let queue;
10930
+ try {
10931
+ parseConnection(url, { failFast: true });
10932
+ redis = redisFn(url);
10933
+ queue = queueFn(parseConnection(url, { failFast: true }));
10934
+ const settled = (async () => {
10935
+ const hash = await redis.hgetall(jobKey(jobId));
10936
+ if (!hash || !hash.since) return { invalid: `job ${jobId} is not waiting on a condition` };
10937
+ const job = await queue.getJob(jobId);
10938
+ if (!job) return { invalid: `job ${jobId} is no longer in the queue` };
10939
+ const state = await job.getState().catch(() => null);
10940
+ if (state !== "delayed" && state !== "waiting" && state !== "prioritized") {
10941
+ return { invalid: `job ${jobId} is ${state ?? "in an unknown state"}, not waiting -- it has already left the hold` };
10942
+ }
10943
+ await redis.del(jobKey(jobId));
10944
+ await redis.srem(HELD_SET, jobId).catch(() => {
10945
+ });
10946
+ if (hash.dedupId) {
10947
+ const holder = await redis.get(`wait:key:${hash.dedupId}`);
10948
+ if (holder === jobId) await redis.del(`wait:key:${hash.dedupId}`);
10949
+ }
10950
+ await job.remove();
10951
+ return { ok: true, jobId };
10952
+ })();
10953
+ return await withTimeout(settled, timeoutMs, { invalid: "timed out reaching the queue" });
10954
+ } catch (err) {
10955
+ return { invalid: err?.message ?? String(err) };
10956
+ } finally {
10957
+ try {
10958
+ redis?.disconnect?.();
10959
+ } catch {
10960
+ }
10961
+ try {
10962
+ await queue?.close?.();
10963
+ } catch {
10964
+ }
10965
+ }
10966
+ }
10589
10967
  function readSubscriptions({ subscriptionsPath, fs = nodeFs3 }) {
10590
10968
  if (subscriptionsPath === null || subscriptionsPath === void 0) return { missing: true };
10591
10969
  let text;
@@ -10662,8 +11040,8 @@ async function readBudget({ url, redisFn = makeRedisClient, timeoutMs = 2500 } =
10662
11040
  }
10663
11041
  function withTimeout(promise, ms, fallback) {
10664
11042
  let timer;
10665
- const timeout = new Promise((resolve2) => {
10666
- timer = setTimeout(() => resolve2(fallback), ms);
11043
+ const timeout = new Promise((resolve3) => {
11044
+ timer = setTimeout(() => resolve3(fallback), ms);
10667
11045
  if (typeof timer?.unref === "function") timer.unref();
10668
11046
  });
10669
11047
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
@@ -11217,7 +11595,7 @@ function clampLines(lines) {
11217
11595
  const n = Number.isFinite(lines) ? Math.floor(lines) : 200;
11218
11596
  return Math.min(2e3, Math.max(1, n));
11219
11597
  }
11220
- var DISPATCH_RUN_TTL_SECONDS, SCAN_WINDOW_MAX_DAYS, GRAPH_LIMITS, GRAPH_LS_TREE_MAX_BYTES;
11598
+ var DISPATCH_RUN_TTL_SECONDS, HELD_HYDRATE_MAX, SCAN_WINDOW_MAX_DAYS, GRAPH_LIMITS, GRAPH_LS_TREE_MAX_BYTES;
11221
11599
  var init_read_model = __esm({
11222
11600
  "src/read-model.mjs"() {
11223
11601
  init_config2();
@@ -11225,6 +11603,8 @@ var init_read_model = __esm({
11225
11603
  init_run_history();
11226
11604
  init_budget();
11227
11605
  init_pause_windows();
11606
+ init_scoped_limits();
11607
+ init_wait_state();
11228
11608
  init_subscriptions();
11229
11609
  init_connection();
11230
11610
  init_queue();
@@ -11237,6 +11617,7 @@ var init_read_model = __esm({
11237
11617
  init_costs();
11238
11618
  init_triggers_file();
11239
11619
  DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
11620
+ HELD_HYDRATE_MAX = 200;
11240
11621
  SCAN_WINDOW_MAX_DAYS = 92;
11241
11622
  GRAPH_LIMITS = Object.freeze({
11242
11623
  maxFoldersScanned: 16,
@@ -12751,7 +13132,28 @@ function normBudget(v) {
12751
13132
  cap: Number.isInteger(t.cap) && t.cap > 0 ? t.cap : null,
12752
13133
  state: BUDGET_STATES.includes(t.state) ? t.state : "ok",
12753
13134
  maxTokens: Number.isInteger(t.maxTokens) && t.maxTokens > 0 ? t.maxTokens : null
12754
- }
13135
+ },
13136
+ // The scoped rows (issue #242): a pre-1.5 payload simply lacks the key and renders nothing.
13137
+ scoped: Array.isArray(v.scoped) ? v.scoped.map(normScopedRow).filter((r) => r !== null) : [],
13138
+ scopedInvalid: typeof v.scopedInvalid === "string" && v.scopedInvalid !== "" ? clip3(v.scopedInvalid, 160) : null
13139
+ };
13140
+ }
13141
+ function normScopedRow(v) {
13142
+ if (v === null || typeof v !== "object" || typeof v.scope !== "string" || v.scope === "") return null;
13143
+ const win = (w) => {
13144
+ if (w === null || w === void 0 || typeof w !== "object") return null;
13145
+ return {
13146
+ used: Number.isInteger(w.used) && w.used >= 0 ? w.used : null,
13147
+ cap: Number.isInteger(w.cap) && w.cap > 0 ? w.cap : null,
13148
+ state: BUDGET_STATES.includes(w.state) ? w.state : "ok"
13149
+ };
13150
+ };
13151
+ return {
13152
+ scope: clip3(v.scope, 60),
13153
+ day: win(v.day),
13154
+ week: win(v.week),
13155
+ month: win(v.month),
13156
+ concurrent: Number.isInteger(v.concurrent) && v.concurrent > 0 ? v.concurrent : null
12755
13157
  };
12756
13158
  }
12757
13159
  function normSpendMap(v) {
@@ -13123,6 +13525,24 @@ function budgetSectionHtml(nb) {
13123
13525
  if (nb.softHoldPct !== null) {
13124
13526
  rows.push(`<div class="dim small">soft-hold band: ${fmt(nb.softHoldPct)}% of each cap (the amber tick)</div>`);
13125
13527
  }
13528
+ if (nb.scopedInvalid !== null) {
13529
+ rows.push(`<div class="dim small">scoped limits: file invalid (${escapeHtml(nb.scopedInvalid)})</div>`);
13530
+ } else if (nb.scoped.length > 0) {
13531
+ rows.push('<div class="dim small">scoped limits (scoped-limits.json)</div>');
13532
+ for (const s of nb.scoped) {
13533
+ const bits = [];
13534
+ let worst = "ok";
13535
+ for (const key of ["day", "week", "month"]) {
13536
+ const w = s[key];
13537
+ if (w === null || w.cap === null) continue;
13538
+ bits.push(`${key} used ${w.used !== null ? fmt(w.used) : "?"} / cap ${fmt(w.cap)}`);
13539
+ if (w.state !== "ok") worst = w.state;
13540
+ }
13541
+ if (s.concurrent !== null) bits.push(`concurrent \u2264${fmt(s.concurrent)} (config; in-flight not shown)`);
13542
+ rows.push(`<div class="row"><span class="wl">${escapeHtml(s.scope)}</span><span>${escapeHtml(bits.join(" \xB7 "))}</span>${worst !== "ok" ? stateWord(worst) : ""}</div>`);
13543
+ }
13544
+ rows.push('<div class="lever">scoped: dispatch_limit_add/edit/delete \xB7 or press m in the /dispatch panel</div>');
13545
+ }
13126
13546
  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
13547
  return `<div id="budget">${rows.join("")}</div>`;
13128
13548
  }
@@ -13528,7 +13948,7 @@ var init_open_browser = __esm({
13528
13948
  // src/deployment-pointer.mjs
13529
13949
  import * as nodeFs4 from "node:fs";
13530
13950
  import { homedir } from "node:os";
13531
- import { join as join3, isAbsolute } from "node:path";
13951
+ import { join as join3, isAbsolute as isAbsolute2 } from "node:path";
13532
13952
  function pointerPath(env = process.env) {
13533
13953
  return env.PI_DISPATCH_DEPLOYMENT_FILE || join3(env.PI_CODING_AGENT_DIR || join3(homedir(), ".pi", "agent"), POINTER_BASENAME);
13534
13954
  }
@@ -13542,7 +13962,7 @@ function normalizePointer(raw) {
13542
13962
  if (raw.version > POINTER_VERSION) {
13543
13963
  return { ignored: `version ${raw.version} is newer than the supported version ${POINTER_VERSION}` };
13544
13964
  }
13545
- if (typeof raw.deploymentDir !== "string" || !isAbsolute(raw.deploymentDir)) {
13965
+ if (typeof raw.deploymentDir !== "string" || !isAbsolute2(raw.deploymentDir)) {
13546
13966
  return { ignored: "deploymentDir must be an absolute path" };
13547
13967
  }
13548
13968
  const rawEnv = raw.env === void 0 ? {} : raw.env;
@@ -13553,7 +13973,7 @@ function normalizePointer(raw) {
13553
13973
  for (const key of POINTER_ENV_ALLOWLIST) {
13554
13974
  const value = rawEnv[key];
13555
13975
  if (typeof value !== "string" || value === "") continue;
13556
- if (key !== "VALKEY_URL" && !isAbsolute(value)) continue;
13976
+ if (key !== "VALKEY_URL" && !isAbsolute2(value)) continue;
13557
13977
  env[key] = value;
13558
13978
  }
13559
13979
  return { pointer: { version: raw.version, deploymentDir: raw.deploymentDir, env } };
@@ -13624,6 +14044,7 @@ var init_deployment_pointer = __esm({
13624
14044
  "PI_SETTINGS_FILE",
13625
14045
  "PI_TRIGGERS_FILE",
13626
14046
  "PI_PAUSE_WINDOWS_FILE",
14047
+ "PI_SCOPED_LIMITS_FILE",
13627
14048
  "PI_SUBSCRIPTIONS_FILE"
13628
14049
  ]);
13629
14050
  POINTER_BASENAME = "pi-dispatch-deployment.json";
@@ -39168,7 +39589,7 @@ var init_pricing = __esm({
39168
39589
 
39169
39590
  // ../worker/src/sandbox-store.mjs
39170
39591
  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";
39592
+ import { isAbsolute as isAbsolute3, join as join5, relative } from "node:path";
39172
39593
  function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
39173
39594
  if (!sandboxDir || jobId === void 0 || jobId === null) return null;
39174
39595
  const dir = join5(sandboxDir, sanitizeJobId(jobId));
@@ -39241,10 +39662,10 @@ function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, f
39241
39662
  return { manifest, name: sandboxContainerName(jobId), publish };
39242
39663
  }
39243
39664
  function launchSandbox({ args, spawnFn = spawn }) {
39244
- return new Promise((resolve2) => {
39665
+ return new Promise((resolve3) => {
39245
39666
  const child = spawnFn("docker", args, { stdio: "inherit" });
39246
- child.on("error", (err) => resolve2({ code: null, error: err }));
39247
- child.on("close", (code) => resolve2({ code }));
39667
+ child.on("error", (err) => resolve3({ code: null, error: err }));
39668
+ child.on("close", (code) => resolve3({ code }));
39248
39669
  });
39249
39670
  }
39250
39671
  var exec3, SANDBOX_NAME_PREFIX;
@@ -39299,6 +39720,43 @@ function renderBudget({ budget, settings: settings2 } = {}) {
39299
39720
  if (pct !== null) lines.push(` soft-hold band: ${pct}%`);
39300
39721
  return lines.join("\n");
39301
39722
  }
39723
+ function renderHeldJobs({ held } = {}) {
39724
+ if (!held) return null;
39725
+ if (held.unreachable) return `unreadable (${held.unreachable})`;
39726
+ const rows = Array.isArray(held.rows) ? held.rows : [];
39727
+ if (rows.length === 0) return null;
39728
+ const more = Number(held.more) || 0;
39729
+ const lines = rows.map((r) => `${r.target ?? r.jobId ?? "-"} ${r.label ?? "-"} waited ${plainDuration(r.waitedMs)}`);
39730
+ if (more > 0) lines.push(`... and ${more} more`);
39731
+ return lines.join("\n");
39732
+ }
39733
+ function plainDuration(ms) {
39734
+ if (!Number.isFinite(ms) || ms < 0) return "?";
39735
+ const s = Math.floor(ms / 1e3);
39736
+ if (s < 60) return `${s}s`;
39737
+ const m = Math.floor(s / 60);
39738
+ if (m < 60) return `${m}m`;
39739
+ const h = Math.floor(m / 60);
39740
+ return h < 24 ? `${h}h${m % 60}m` : `${Math.floor(h / 24)}d${h % 24}h`;
39741
+ }
39742
+ function renderScopedLimits({ limits, scopedBudget } = {}) {
39743
+ if (limits?.invalid) return `Scoped limits: file invalid (${limits.invalid})`;
39744
+ const list = Array.isArray(limits?.limits) ? limits.limits : [];
39745
+ if (list.length === 0) return null;
39746
+ const lines = ["Scoped limits:"];
39747
+ list.forEach((l, i) => {
39748
+ const used = scopedBudget?.rows?.[i] ?? null;
39749
+ const bits = [];
39750
+ for (const key of ["day", "week", "month"]) {
39751
+ if (!Number.isInteger(l[key])) continue;
39752
+ const u = used && Number.isFinite(used[key]) ? used[key] : "-";
39753
+ bits.push(`${key} ${u}/${l[key]}`);
39754
+ }
39755
+ if (Number.isInteger(l.concurrent)) bits.push(`<=${l.concurrent} at once`);
39756
+ lines.push(` ${l.scope}: ${bits.join(" \xB7 ")}`);
39757
+ });
39758
+ return lines.join("\n");
39759
+ }
39302
39760
  function capLabel(cap, reserved, pct) {
39303
39761
  if (!Number.isInteger(cap)) return "unknown (worker env/default)";
39304
39762
  const state = windowState(reserved, cap, pct);
@@ -39645,6 +40103,34 @@ function centerBlock(lines, overlayWidth, blockWidth) {
39645
40103
  const prefix = " ".repeat(pad2);
39646
40104
  return lines.map((l) => prefix + l);
39647
40105
  }
40106
+ async function heldJobs(redis) {
40107
+ const ids = await redis.smembers("wait:held");
40108
+ const at = Date.now();
40109
+ const hydrated = await Promise.all(
40110
+ ids.slice(0, HELD_HYDRATE_MAX2).map(async (jobId) => {
40111
+ try {
40112
+ const h = await redis.hgetall(`wait:job:${jobId}`);
40113
+ if (!h || !h.since) {
40114
+ await redis.srem("wait:held", jobId).catch(() => {
40115
+ });
40116
+ return null;
40117
+ }
40118
+ const since = Number(h.since);
40119
+ return {
40120
+ jobId,
40121
+ target: h.target || null,
40122
+ label: h.label || null,
40123
+ waitedMs: Number.isFinite(since) && since > 0 ? Math.max(0, at - since) : null
40124
+ };
40125
+ } catch {
40126
+ return null;
40127
+ }
40128
+ })
40129
+ );
40130
+ const rows = hydrated.filter(Boolean);
40131
+ rows.sort((a, b) => (b.waitedMs ?? -1) - (a.waitedMs ?? -1));
40132
+ return { rows: rows.slice(0, HELD_LIMIT), more: Math.max(0, rows.length - HELD_LIMIT), truncated: ids.length > HELD_HYDRATE_MAX2 };
40133
+ }
39648
40134
  function createDashboardDeps(paths) {
39649
40135
  const queue = makeQueue(parseConnection(paths.valkeyUrl, { failFast: true }));
39650
40136
  const redis = makeRedisClient(paths.valkeyUrl);
@@ -39666,7 +40152,27 @@ function createDashboardDeps(paths) {
39666
40152
  redis.hgetall(STALL_KEY).catch(() => ({}))
39667
40153
  ]);
39668
40154
  const workers = Array.isArray(workerList) && workerList.length > 0 ? workerList.length : "unknown";
40155
+ const scopedLimits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
40156
+ const limitRows = Array.isArray(scopedLimits?.limits) ? scopedLimits.limits : [];
40157
+ const scopedNow = /* @__PURE__ */ new Date();
40158
+ const scopedBudget = {
40159
+ rows: await Promise.all(
40160
+ limitRows.map(async (l) => {
40161
+ const prefix = scopeKeyPrefix(l.scope);
40162
+ const cell2 = (key) => redis.get(key).then((v) => Number(v ?? 0), () => null);
40163
+ const row = {};
40164
+ if (Number.isInteger(l.day)) row.day = await cell2(dayKey(scopedNow, prefix));
40165
+ if (Number.isInteger(l.week)) row.week = await cell2(weekKey(scopedNow, prefix));
40166
+ if (Number.isInteger(l.month)) row.month = await cell2(monthKey(scopedNow, prefix));
40167
+ return row;
40168
+ })
40169
+ )
40170
+ };
40171
+ const held = await heldJobs(redis).catch((err) => ({ unreachable: err?.message ?? String(err) }));
39669
40172
  return {
40173
+ held,
40174
+ scopedLimits,
40175
+ scopedBudget,
39670
40176
  queue: { pausedState, counts, workers },
39671
40177
  budget: { day: Number(dayRaw ?? 0), week: Number(weekRaw ?? 0), month: Number(monthRaw ?? 0), tokensToday: Number(tokenRaw ?? 0) },
39672
40178
  schedulers: mapSchedulers(schedulerList, Date.now()),
@@ -40027,6 +40533,10 @@ function makeDashboard({
40027
40533
  void dispose().finally(() => done({ action: "managePauses" }));
40028
40534
  return;
40029
40535
  }
40536
+ if (data === "m" || data === "M") {
40537
+ void dispose().finally(() => done({ action: "manageLimits" }));
40538
+ return;
40539
+ }
40030
40540
  if (matchesKey(data, "up")) {
40031
40541
  selected = Math.max(0, selected - 1);
40032
40542
  tui?.requestRender?.();
@@ -40130,6 +40640,10 @@ function renderPanel(snapshot, width, state, styler) {
40130
40640
  { title: "RUNS", lines: renderRunList(buildRows(snapshot, runSort), selected, 24) },
40131
40641
  { title: "SETTINGS", lines: toLines(renderSettingsView(snapshot.settings)) }
40132
40642
  ];
40643
+ const scopedText = renderScopedLimits({ limits: snapshot.scopedLimits, scopedBudget: snapshot.scopedBudget });
40644
+ if (scopedText) sections.splice(2, 0, { title: "SCOPED LIMITS", lines: toLines(scopedText) });
40645
+ const heldText = renderHeldJobs({ held: snapshot.held });
40646
+ if (heldText) sections.push({ title: "HELD", lines: toLines(heldText) });
40133
40647
  const plain = [title];
40134
40648
  for (const section of sections) plain.push(section.title, ...section.lines);
40135
40649
  plain.push(KEY_HINTS);
@@ -40157,6 +40671,7 @@ function collapseKeys(sections, availableRows, focus, baseRows) {
40157
40671
  function buildListLines(snapshot, selected, inner, styler, runSort = "time", availableRows = null) {
40158
40672
  const trg = triggerLines(snapshot, selected, inner, styler);
40159
40673
  const pw = pauseLines(snapshot.pauseWindows, inner, styler);
40674
+ const sl = limitLines(snapshot.scopedLimits, snapshot.scopedBudget, inner, styler);
40160
40675
  const runRows = buildRows(snapshot, runSort).slice(trg.count);
40161
40676
  const runCount = Array.isArray(snapshot.runs) ? snapshot.runs.length : 0;
40162
40677
  const sections = [
@@ -40164,6 +40679,11 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
40164
40679
  { key: "spend", head: ["spend & limits", "jobs & tokens/day \xB7 s set"], body: spendLines(snapshot.budget, snapshot.settings, inner, styler), priority: 4, viewKey: "s" },
40165
40680
  { key: "triggers", head: ["triggers", `${trg.count} standing \xB7 a add \xB7 \u21B5 open`], body: trg.lines, priority: 3, viewKey: "tab" },
40166
40681
  { key: "pauses", head: ["pause windows", `${pw.count} \xB7 w manage`], body: pw.lines, priority: 1, viewKey: "w" },
40682
+ // Priority 0: the section an operator acts on least often from the panel folds FIRST under the
40683
+ // collapse budget. The key hint lives in this divider, not the footer -- the footer's width
40684
+ // arithmetic has no headroom for another hint (its own comment), the s/o/Tab precedent.
40685
+ { key: "limits", head: ["scoped limits", `${sl.count} \xB7 m manage`], body: sl.lines, priority: 0, viewKey: "m" },
40686
+ ...heldSection(snapshot.held, inner, styler),
40167
40687
  { key: "runs", head: ["runs", `last ${runCount} \xB7 o ${runSort}`], body: runLines(runRows, selected - trg.count, inner, styler) },
40168
40688
  { key: "settings", head: ["settings", "s edit"], body: settingsLines(snapshot.settings, inner, styler), priority: 2, viewKey: "s" }
40169
40689
  ];
@@ -40179,7 +40699,7 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
40179
40699
  sections.forEach((s, i) => {
40180
40700
  if (i > 0) lines.push(RULE);
40181
40701
  if (collapsed.has(s.key)) {
40182
- lines.push(styler.divider(s.head[0], `(${s.body.length} hidden \u2014 ${s.viewKey} to view)`, inner));
40702
+ lines.push(styler.divider(s.head[0], s.viewKey ? `(${s.body.length} hidden \u2014 ${s.viewKey} to view)` : `(${s.body.length} hidden)`, inner));
40183
40703
  return;
40184
40704
  }
40185
40705
  if (s.head) lines.push(styler.divider(s.head[0], s.head[1], inner));
@@ -40187,6 +40707,37 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
40187
40707
  });
40188
40708
  return lines;
40189
40709
  }
40710
+ function heldSection(held, inner, styler) {
40711
+ if (!held) return [];
40712
+ if (held.unreachable) {
40713
+ return [{ key: "held", priority: 5, head: ["held", "waiting on conditions"], body: [styler.cell(`unreadable (${held.unreachable})`, inner, { color: "error" })] }];
40714
+ }
40715
+ const rows = Array.isArray(held.rows) ? held.rows : [];
40716
+ if (rows.length === 0) return [];
40717
+ const shown = rows.slice(0, HELD_ON_DASHBOARD);
40718
+ const hidden = rows.length - shown.length + (Number(held.more) || 0);
40719
+ const body = shown.map((r) => heldRow(r, inner, styler));
40720
+ if (hidden > 0) body.push(styler.cell(styler.fg("dim", `\u2193 ${hidden} more`), inner));
40721
+ const total = rows.length + (Number(held.more) || 0);
40722
+ return [{ key: "held", priority: 5, head: ["held", `${total}${held.truncated ? "+" : ""} waiting on conditions`], body }];
40723
+ }
40724
+ function heldRow(r, inner, styler) {
40725
+ const bits = [
40726
+ `${styler.fg("dim", "\u25CB")} ${styler.fg("accent", r.target ?? r.jobId ?? "-")}`,
40727
+ styler.fg("text", r.label ?? "-"),
40728
+ styler.fg("dim", `waited ${fmtDuration(r.waitedMs)}`)
40729
+ ];
40730
+ return fitLine(bits.join(styler.fg("dim", " \xB7 ")), inner, styler);
40731
+ }
40732
+ function fmtDuration(ms) {
40733
+ if (!Number.isFinite(ms) || ms < 0) return "?";
40734
+ const s = Math.floor(ms / 1e3);
40735
+ if (s < 60) return `${s}s`;
40736
+ const m = Math.floor(s / 60);
40737
+ if (m < 60) return `${m}m`;
40738
+ const h = Math.floor(m / 60);
40739
+ return h < 24 ? `${h}h${m % 60}m` : `${Math.floor(h / 24)}d${h % 24}h`;
40740
+ }
40190
40741
  function statusHeader(queue, inner, styler) {
40191
40742
  if (!queue || queue.unreachable) {
40192
40743
  return styler.cell(`queue unreachable (${queue?.unreachable ?? "?"})`, inner, { color: "error" });
@@ -40198,7 +40749,8 @@ function statusHeader(queue, inner, styler) {
40198
40749
  const dot = styler.fg(stateColor, "\u25CF");
40199
40750
  const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : "PAUSED"));
40200
40751
  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`;
40752
+ const delayed = Number(c.delayed ?? 0);
40753
+ 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
40754
  const clock = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
40203
40755
  const left = `${dot} ${word} ${vitals}`;
40204
40756
  const gap = inner - styler.visibleLen(left) - clock.length;
@@ -40353,6 +40905,39 @@ function pauseRow(w, now, inner, styler) {
40353
40905
  if (until) bits.push(styler.fg("warning", `resumes in ${humanizeMs(until - now) || "<1m"}`));
40354
40906
  return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
40355
40907
  }
40908
+ function limitLines(scopedLimits, scopedBudget, inner, styler) {
40909
+ const lines = [];
40910
+ if (scopedLimits && scopedLimits.missing) {
40911
+ lines.push(styler.cell("(no scoped limits \xB7 m to manage)", inner, { color: "dim" }));
40912
+ return { count: 0, lines };
40913
+ }
40914
+ if (scopedLimits && scopedLimits.invalid) {
40915
+ lines.push(styler.cell(`(scoped-limits file invalid: ${scopedLimits.invalid})`, inner, { color: "error" }));
40916
+ return { count: 0, lines };
40917
+ }
40918
+ const list = (scopedLimits && scopedLimits.limits) ?? [];
40919
+ if (list.length === 0) {
40920
+ lines.push(styler.cell("(no scoped limits \xB7 m to manage)", inner, { color: "dim" }));
40921
+ return { count: 0, lines };
40922
+ }
40923
+ list.forEach((l, i) => lines.push(limitRow(l, scopedBudget?.rows?.[i] ?? null, inner, styler)));
40924
+ return { count: list.length, lines };
40925
+ }
40926
+ function limitRow(l, used, inner, styler) {
40927
+ let atCap = false;
40928
+ const windows = [];
40929
+ for (const key of ["day", "week", "month"]) {
40930
+ if (!Number.isInteger(l[key])) continue;
40931
+ const u = used && Number.isFinite(used[key]) ? used[key] : null;
40932
+ const over = u !== null && u >= l[key];
40933
+ if (over) atCap = true;
40934
+ windows.push(styler.fg(over ? "warning" : "text", `${key} ${u ?? "-"}/${l[key]}`));
40935
+ }
40936
+ const dot = atCap ? styler.fg("warning", "\u25CF") : styler.fg("dim", "\u25CB");
40937
+ const bits = [`${dot} ${styler.fg("accent", l.scope ?? "-")}`, ...windows];
40938
+ if (Number.isInteger(l.concurrent)) bits.push(styler.fg("muted", `\u2264${l.concurrent} at once`));
40939
+ return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
40940
+ }
40356
40941
  function runLines(rows, selected, inner, styler) {
40357
40942
  if (!Array.isArray(rows) || rows.length === 0) return [styler.cell("(no runs)", inner, { color: "dim" })];
40358
40943
  const { top, count } = runsWindow(rows.length, selected);
@@ -40761,7 +41346,7 @@ function runDetailHints(inner, styler, canOpen = false, canCopy = false, copiedN
40761
41346
  if (copiedNote) return fitLine(styler.fg("success", copiedNote) + styler.fg("dim", " \xB7 ") + line, inner, styler);
40762
41347
  return fitLine(line, inner, styler);
40763
41348
  }
40764
- var KEY_HINTS, RUNS_ON_DASHBOARD, RUNS_VIEWPORT, RUN_SORTS, REFRESH_MS, TAIL_LINES, TAIL_VIEWPORT, MIN_WIDTH2, DRILL_WIDTH, KIND_COLOR, KIND_WIDTH, FRAME_CHROME_ROWS;
41349
+ var KEY_HINTS, RUNS_ON_DASHBOARD, RUNS_VIEWPORT, RUN_SORTS, REFRESH_MS, TAIL_LINES, TAIL_VIEWPORT, MIN_WIDTH2, DRILL_WIDTH, HELD_LIMIT, HELD_HYDRATE_MAX2, KIND_COLOR, KIND_WIDTH, FRAME_CHROME_ROWS, HELD_ON_DASHBOARD;
40765
41350
  var init_dashboard = __esm({
40766
41351
  "src/dashboard.ts"() {
40767
41352
  init_budget();
@@ -40770,6 +41355,7 @@ var init_dashboard = __esm({
40770
41355
  init_scheduler_stall_guard();
40771
41356
  init_pause_windows();
40772
41357
  init_read_model();
41358
+ init_scoped_limits();
40773
41359
  init_render();
40774
41360
  init_keys2();
40775
41361
  init_panel();
@@ -40783,9 +41369,12 @@ var init_dashboard = __esm({
40783
41369
  TAIL_VIEWPORT = 20;
40784
41370
  MIN_WIDTH2 = 8;
40785
41371
  DRILL_WIDTH = 70;
41372
+ HELD_LIMIT = 20;
41373
+ HELD_HYDRATE_MAX2 = 200;
40786
41374
  KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction", issue: "syntaxString" };
40787
41375
  KIND_WIDTH = 13;
40788
41376
  FRAME_CHROME_ROWS = 4;
41377
+ HELD_ON_DASHBOARD = 3;
40789
41378
  }
40790
41379
  });
40791
41380
 
@@ -40858,16 +41447,16 @@ async function runAttached(ctx, { title, argv0, args, cwd, env, shell, spawnFn =
40858
41447
  ${title}
40859
41448
 
40860
41449
  `);
40861
- outcome = await new Promise((resolve2) => {
41450
+ outcome = await new Promise((resolve3) => {
40862
41451
  let child;
40863
41452
  try {
40864
41453
  child = spawnFn(argv0, args, { stdio: "inherit", cwd, env, ...shell ? { shell: true } : {} });
40865
41454
  } catch (err) {
40866
- resolve2({ code: null, error: err });
41455
+ resolve3({ code: null, error: err });
40867
41456
  return;
40868
41457
  }
40869
- child.on("error", (err) => resolve2({ code: null, error: err }));
40870
- child.on("close", (code) => resolve2({ code }));
41458
+ child.on("error", (err) => resolve3({ code: null, error: err }));
41459
+ child.on("close", (code) => resolve3({ code }));
40871
41460
  });
40872
41461
  } catch (err) {
40873
41462
  outcome = { code: null, error: err };
@@ -41086,6 +41675,7 @@ up shows each docker action and asks y/N before it \u2014 nothing is auto-accept
41086
41675
  env: {
41087
41676
  PI_TRIGGERS_FILE: join6(dir, "triggers.json"),
41088
41677
  PI_PAUSE_WINDOWS_FILE: join6(dir, "pause-windows.json"),
41678
+ PI_SCOPED_LIMITS_FILE: join6(dir, "scoped-limits.json"),
41089
41679
  PI_SUBSCRIPTIONS_FILE: join6(dir, "subscriptions.json")
41090
41680
  }
41091
41681
  };
@@ -41318,8 +41908,8 @@ var init_setup_wizard = __esm({
41318
41908
  init_deployment_pointer();
41319
41909
  init_read_model();
41320
41910
  init_index();
41321
- RUNTIME_VERSION = "1.4.0";
41322
- RECEIVER_VERSION = "1.3.0";
41911
+ RUNTIME_VERSION = "1.6.0";
41912
+ RECEIVER_VERSION = "1.4.0";
41323
41913
  RUNTIME_PKG = "@edgehero/pi-dispatch";
41324
41914
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
41325
41915
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -41513,15 +42103,15 @@ function registerTools(pi) {
41513
42103
  pi.registerTool({
41514
42104
  name: "dispatch_costs",
41515
42105
  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()) }),
42106
+ 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").`,
42107
+ 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
42108
  async execute(_toolCallId, params) {
41519
42109
  const window = params.window ?? "mtd";
41520
42110
  if (!COSTS_WINDOWS.includes(window)) {
41521
42111
  throw new Error(`unknown window '${window}' (7d|30d|mtd)`);
41522
42112
  }
41523
42113
  const paths = resolvePaths(process.env);
41524
- const res = assembleCosts(paths, window, params.flow);
42114
+ const res = assembleCosts(paths, window, params.flow, params.repo);
41525
42115
  if (res.unreachable) throw new Error(`could not read the run history: ${res.unreachable}`);
41526
42116
  return toolText(JSON.stringify({ window, fold: res.fold }));
41527
42117
  }
@@ -41803,6 +42393,148 @@ ${JSON.stringify(before)}
41803
42393
  return toolText(JSON.stringify(result));
41804
42394
  }
41805
42395
  });
42396
+ pi.registerTool({
42397
+ name: "dispatch_limits",
42398
+ label: "pi-dispatch scoped limits",
42399
+ 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.",
42400
+ parameters: typebox_exports.Object({}),
42401
+ async execute() {
42402
+ const paths = resolvePaths(process.env);
42403
+ const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
42404
+ if (!Array.isArray(p?.limits)) return toolText(JSON.stringify(p));
42405
+ const counters = await readScopedBudget({ url: paths.valkeyUrl, limits: p.limits });
42406
+ const rows = p.limits.map((l, index) => ({ index, ...l, used: counters.rows?.[index] ?? null }));
42407
+ return toolText(JSON.stringify(rows));
42408
+ }
42409
+ });
42410
+ pi.registerTool({
42411
+ name: "dispatch_waits",
42412
+ label: "pi-dispatch held jobs",
42413
+ description: "Read-only. Lists the jobs the worker is currently HOLDING on a trigger's run.waitFor condition: the job id, an id-only target, the operator's own words for what it is waiting on, and how long it has waited. A held job has reserved no budget slot and started no container. This is a different set from the queue's `delayed` count, which also mixes cron next-occurrences, retry backoff and quiet hours; only waits appear here. Use the job id for dispatch_wait_cancel.",
42414
+ parameters: typebox_exports.Object({}),
42415
+ async execute() {
42416
+ const paths = resolvePaths(process.env);
42417
+ const held = await readHeldJobs({ url: paths.valkeyUrl });
42418
+ return toolText(JSON.stringify(held));
42419
+ }
42420
+ });
42421
+ pi.registerTool({
42422
+ name: "dispatch_wait_cancel",
42423
+ label: "pi-dispatch cancel a held job",
42424
+ description: "Removes a job that is waiting on a run.waitFor condition, so it never runs. The operator MUST approve a confirm dialog naming the job and what it was waiting on; refused with no interactive operator. Use dispatch_waits for the job id. This is the ONLY way to stop a held job short of editing redis: a held job has spent nothing, so no budget cap will ever stop it, and deleting the trigger does not reach a job already enqueued. Cancelling writes no run record, because the job never ran.",
42425
+ executionMode: "sequential",
42426
+ parameters: typebox_exports.Object({ jobId: typebox_exports.String({ minLength: 1 }) }),
42427
+ async execute(_id, params, _signal, _onUpdate, ctx) {
42428
+ const paths = resolvePaths(process.env);
42429
+ const held = await readHeldJobs({ url: paths.valkeyUrl });
42430
+ if (held.unreachable) throw new Error(`queue unreachable: ${held.unreachable}`);
42431
+ const row = (held.rows ?? []).find((r) => r.jobId === params.jobId);
42432
+ if (!row) throw new Error(`no held job ${params.jobId} (dispatch_waits lists the ids that are waiting)`);
42433
+ const result = await confirmedWrite(
42434
+ ctx,
42435
+ { title: `Cancel held job ${row.jobId}`, message: `Cancel ${row.target ?? row.jobId}, waiting on ${row.label ?? "a condition"}. It will never run.` },
42436
+ async () => {
42437
+ const res = await cancelHeldJob({ url: paths.valkeyUrl, jobId: params.jobId });
42438
+ if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
42439
+ return { applied: true, jobId: params.jobId };
42440
+ }
42441
+ );
42442
+ return toolText(JSON.stringify(result));
42443
+ }
42444
+ });
42445
+ pi.registerTool({
42446
+ name: "dispatch_limit_add",
42447
+ label: "pi-dispatch add scoped limit",
42448
+ 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.',
42449
+ executionMode: "sequential",
42450
+ parameters: typebox_exports.Object({
42451
+ scope: typebox_exports.String(),
42452
+ day: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
42453
+ week: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
42454
+ month: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
42455
+ concurrent: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
42456
+ }),
42457
+ async execute(_id, params, _signal, _onUpdate, ctx) {
42458
+ const paths = resolvePaths(process.env);
42459
+ const l = buildScopedLimit(params);
42460
+ const result = await confirmedWrite(
42461
+ ctx,
42462
+ { title: "Add scoped limit", message: `Add to scoped-limits.json:
42463
+ ${JSON.stringify(l)}` },
42464
+ () => {
42465
+ const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (list) => [...list, l] });
42466
+ if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
42467
+ return { applied: true, added: l };
42468
+ }
42469
+ );
42470
+ return toolText(JSON.stringify(result));
42471
+ }
42472
+ });
42473
+ pi.registerTool({
42474
+ name: "dispatch_limit_delete",
42475
+ label: "pi-dispatch delete scoped limit",
42476
+ 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.",
42477
+ executionMode: "sequential",
42478
+ parameters: typebox_exports.Object({ index: typebox_exports.Integer({ minimum: 0 }) }),
42479
+ async execute(_id, params, _signal, _onUpdate, ctx) {
42480
+ const paths = resolvePaths(process.env);
42481
+ const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
42482
+ const list = Array.isArray(p?.limits) ? p.limits : [];
42483
+ const cur = list[params.index];
42484
+ if (!cur) throw new Error(`no scoped limit at index ${params.index} (have ${list.length})`);
42485
+ const result = await confirmedWrite(
42486
+ ctx,
42487
+ { title: `Delete scoped limit #${params.index + 1}`, message: `Remove scoped limit #${params.index + 1}: ${cur.scope} ${limitSummary(cur)}` },
42488
+ () => {
42489
+ const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.filter((_, i) => i !== params.index) });
42490
+ if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
42491
+ return { applied: true, deletedIndex: params.index };
42492
+ }
42493
+ );
42494
+ return toolText(JSON.stringify(result));
42495
+ }
42496
+ });
42497
+ pi.registerTool({
42498
+ name: "dispatch_limit_edit",
42499
+ label: "pi-dispatch edit scoped limit",
42500
+ 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.",
42501
+ executionMode: "sequential",
42502
+ parameters: typebox_exports.Object({
42503
+ index: typebox_exports.Integer({ minimum: 0 }),
42504
+ scope: typebox_exports.Optional(typebox_exports.String()),
42505
+ day: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
42506
+ week: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
42507
+ month: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
42508
+ concurrent: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
42509
+ }),
42510
+ async execute(_id, params, _signal, _onUpdate, ctx) {
42511
+ const paths = resolvePaths(process.env);
42512
+ const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
42513
+ const list = Array.isArray(p?.limits) ? p.limits : [];
42514
+ const cur = list[params.index];
42515
+ if (!cur) throw new Error(`no scoped limit at index ${params.index} (have ${list.length})`);
42516
+ const before = buildScopedLimit(cur);
42517
+ const merged = buildScopedLimit({
42518
+ scope: params.scope ?? cur.scope,
42519
+ day: params.day ?? cur.day,
42520
+ week: params.week ?? cur.week,
42521
+ month: params.month ?? cur.month,
42522
+ concurrent: params.concurrent ?? cur.concurrent
42523
+ });
42524
+ const result = await confirmedWrite(
42525
+ ctx,
42526
+ { title: `Edit scoped limit #${params.index + 1}`, message: `scoped limit #${params.index + 1}:
42527
+ ${JSON.stringify(before)}
42528
+ \u2192 ${JSON.stringify(merged)}` },
42529
+ () => {
42530
+ const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.map((w, i) => i === params.index ? merged : w) });
42531
+ if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
42532
+ return { applied: true, index: params.index, limit: merged };
42533
+ }
42534
+ );
42535
+ return toolText(JSON.stringify(result));
42536
+ }
42537
+ });
41806
42538
  pi.registerTool({
41807
42539
  name: "dispatch_trigger_delete",
41808
42540
  label: "pi-dispatch delete trigger",
@@ -41888,6 +42620,22 @@ function buildPauseWindow(f) {
41888
42620
  if (dateTo) w.dateTo = dateTo;
41889
42621
  return w;
41890
42622
  }
42623
+ function buildScopedLimit(f) {
42624
+ const l = { scope: String(f.scope ?? "").trim() };
42625
+ for (const k of ["day", "week", "month", "concurrent"]) {
42626
+ const v = optInt(f[k]);
42627
+ if (v !== void 0) l[k] = v;
42628
+ }
42629
+ return l;
42630
+ }
42631
+ function limitSummary(l) {
42632
+ const bits = [];
42633
+ if (Number.isInteger(l?.day)) bits.push(`day ${l.day}`);
42634
+ if (Number.isInteger(l?.week)) bits.push(`week ${l.week}`);
42635
+ if (Number.isInteger(l?.month)) bits.push(`month ${l.month}`);
42636
+ if (Number.isInteger(l?.concurrent)) bits.push(`\u2264${l.concurrent} at once`);
42637
+ return bits.join(" \xB7 ");
42638
+ }
41891
42639
  function asWords(x) {
41892
42640
  if (Array.isArray(x)) return x.map((s) => String(s).trim()).filter(Boolean);
41893
42641
  return splitWords(x);
@@ -41957,7 +42705,11 @@ ${renderBudget({ budget, settings: settings2 })}`);
41957
42705
  case "budget": {
41958
42706
  const budget = await readBudget({ url: paths.valkeyUrl });
41959
42707
  const settings2 = readSettingsView({ settingsFile: paths.settingsFile });
41960
- send(pi, renderBudget({ budget, settings: settings2 }));
42708
+ const limits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
42709
+ const scopedBudget = Array.isArray(limits?.limits) && limits.limits.length > 0 ? await readScopedBudget({ url: paths.valkeyUrl, limits: limits.limits }) : { rows: [] };
42710
+ const scopedText = renderScopedLimits({ limits, scopedBudget });
42711
+ send(pi, `${renderBudget({ budget, settings: settings2 })}${scopedText ? `
42712
+ ${scopedText}` : ""}`);
41961
42713
  return;
41962
42714
  }
41963
42715
  case "triggers": {
@@ -42038,13 +42790,14 @@ ${renderBudget({ budget, settings: settings2 })}`);
42038
42790
  return;
42039
42791
  }
42040
42792
  }
42041
- function assembleCosts(paths, window, flow) {
42793
+ function assembleCosts(paths, window, flow, repo) {
42042
42794
  const nowMs = Date.now();
42043
42795
  const sinceMs = costsSinceMs(window, nowMs);
42044
42796
  const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs, nowMs });
42045
42797
  if (!Array.isArray(records)) return records;
42046
42798
  const subs = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
42047
- const scoped = typeof flow === "string" && flow !== "" ? records.filter((r) => (r?.flow ?? null) === flow) : records;
42799
+ const flowScoped = typeof flow === "string" && flow !== "" ? records.filter((r) => (r?.flow ?? null) === flow) : records;
42800
+ const scoped = typeof repo === "string" && repo !== "" ? flowScoped.filter((r) => repoOfTarget(r?.target) === repo) : flowScoped;
42048
42801
  const triggersView = readTriggers({ triggersPath: paths.triggersPath });
42049
42802
  const triggerJoin = attributeRunsToTriggers({ records: scoped, triggers: Array.isArray(triggersView?.triggers) ? triggersView.triggers : [] });
42050
42803
  const fold = foldCosts({
@@ -42107,13 +42860,27 @@ async function assembleBudgetView(paths) {
42107
42860
  const tokensSpent = unreachable === null ? Number(raw.tokensToday ?? 0) : null;
42108
42861
  const tokenCap = posIntOrNull(overlay.dailyTokenCap);
42109
42862
  const tokenState = tokenCap !== null && tokensSpent !== null ? tokensSpent >= tokenCap ? "over" : pct !== null && tokensSpent > Math.floor(tokenCap * pct / 100) ? "soft-hold" : "ok" : "ok";
42863
+ const limitsView = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
42864
+ const limits = Array.isArray(limitsView?.limits) ? limitsView.limits : [];
42865
+ const scopedCounters = limits.length > 0 ? await readScopedBudget({ url: paths.valkeyUrl, limits }) : { rows: [] };
42866
+ const scoped = limits.map((l, i) => {
42867
+ const used = scopedCounters?.rows?.[i] ?? null;
42868
+ const win = (cap, key) => {
42869
+ if (cap === null) return null;
42870
+ const u = used && Number.isFinite(used[key]) ? used[key] : null;
42871
+ return { used: u, cap, state: u !== null ? windowState(u, cap, null) : "ok" };
42872
+ };
42873
+ return { scope: l.scope, day: win(l.day, "day"), week: win(l.week, "week"), month: win(l.month, "month"), concurrent: l.concurrent };
42874
+ });
42110
42875
  return {
42111
42876
  unreachable,
42112
42877
  softHoldPct: pct,
42113
42878
  day: slotWindow("day", "dailyCap"),
42114
42879
  week: slotWindow("week", "weeklyCap"),
42115
42880
  month: slotWindow("month", "monthlyCap"),
42116
- tokens: { spent: tokensSpent, cap: tokenCap, state: tokenState, maxTokens: posIntOrNull(overlay.maxTokens) }
42881
+ tokens: { spent: tokensSpent, cap: tokenCap, state: tokenState, maxTokens: posIntOrNull(overlay.maxTokens) },
42882
+ scoped,
42883
+ scopedInvalid: limitsView?.invalid ? String(limitsView.invalid) : null
42117
42884
  };
42118
42885
  }
42119
42886
  async function assembleInsights(paths, window) {
@@ -42280,7 +43047,7 @@ could not start docker: ${error.message}
42280
43047
  }
42281
43048
  }
42282
43049
  function pauseForMessage() {
42283
- return new Promise((resolve2) => setTimeout(resolve2, 2500));
43050
+ return new Promise((resolve3) => setTimeout(resolve3, 2500));
42284
43051
  }
42285
43052
  function splitWords(s) {
42286
43053
  return String(s ?? "").split(/\s+/).map((w) => w.trim()).filter(Boolean);
@@ -42306,8 +43073,84 @@ async function handleDashboardAction(result, paths, ctx) {
42306
43073
  return editSettingsViaDialogs(paths, ui, notify);
42307
43074
  case "managePauses":
42308
43075
  return managePausesViaDialogs(paths, ui, notify);
43076
+ case "manageLimits":
43077
+ return manageLimitsViaDialogs(paths, ui, notify);
42309
43078
  }
42310
43079
  }
43080
+ async function manageLimitsViaDialogs(paths, ui, notify) {
43081
+ const action = await ui.select("Scoped limits", ["Add a scoped limit", "Edit a scoped limit", "Delete a scoped limit"]);
43082
+ if (!action) return;
43083
+ if (action.startsWith("Add")) return addScopedLimitViaDialogs(paths, ui, notify);
43084
+ if (action.startsWith("Edit")) return editScopedLimitViaDialogs(paths, ui, notify);
43085
+ return deleteScopedLimitViaDialogs(paths, ui, notify);
43086
+ }
43087
+ async function addScopedLimitViaDialogs(paths, ui, notify) {
43088
+ const scope = await ui.input('scope \u2014 a repo "owner/name" or an ABSOLUTE local folder path (exact match, no globs)', "");
43089
+ if (scope === void 0 || scope.trim() === "") return;
43090
+ const day = await ui.input("day \u2014 max jobs per UTC day for this scope (blank = no day cap)", "");
43091
+ if (day === void 0) return;
43092
+ const week = await ui.input("week \u2014 max jobs per Mon-Sun UTC week (blank = no week cap)", "");
43093
+ if (week === void 0) return;
43094
+ const month = await ui.input("month \u2014 max jobs per calendar month (blank = no month cap)", "");
43095
+ if (month === void 0) return;
43096
+ const concurrent = await ui.input("concurrent \u2014 max jobs in flight for this scope, extras deferred (blank = no limit)", "");
43097
+ if (concurrent === void 0) return;
43098
+ const l = buildScopedLimit({ scope, day, week, month, concurrent });
43099
+ const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (list) => [...list, l] });
43100
+ notify?.(res.ok ? `scoped limit added (live) \u2014 ${l.scope} ${limitSummary(l)}` : `add rejected: ${res.invalid}`, res.ok ? "info" : "error");
43101
+ }
43102
+ async function editScopedLimitViaDialogs(paths, ui, notify) {
43103
+ const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
43104
+ const list = Array.isArray(p?.limits) ? p.limits : [];
43105
+ if (list.length === 0) {
43106
+ notify?.("no scoped limits to edit", "info");
43107
+ return;
43108
+ }
43109
+ const labels = list.map((l, i) => `#${i + 1} ${l.scope} ${limitSummary(l)}`);
43110
+ const picked = await ui.select("Edit which scoped limit", labels);
43111
+ if (!picked) return;
43112
+ const index = labels.indexOf(picked);
43113
+ if (index < 0) return;
43114
+ const cur = list[index];
43115
+ const ask = async (label, current) => ui.input(`${label} \u2014 blank keeps "${current}"`, current);
43116
+ const keep = (v, current) => v === void 0 || v.trim() === "" ? current : v;
43117
+ const scope = await ask("scope", cur.scope ?? "");
43118
+ if (scope === void 0) return;
43119
+ const day = await ask("day cap", String(cur.day ?? ""));
43120
+ if (day === void 0) return;
43121
+ const week = await ask("week cap", String(cur.week ?? ""));
43122
+ if (week === void 0) return;
43123
+ const month = await ask("month cap", String(cur.month ?? ""));
43124
+ if (month === void 0) return;
43125
+ const concurrent = await ask("concurrent (max in flight)", String(cur.concurrent ?? ""));
43126
+ if (concurrent === void 0) return;
43127
+ const merged = buildScopedLimit({
43128
+ scope: keep(scope, cur.scope),
43129
+ day: keep(day, cur.day),
43130
+ week: keep(week, cur.week),
43131
+ month: keep(month, cur.month),
43132
+ concurrent: keep(concurrent, cur.concurrent)
43133
+ });
43134
+ const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.map((w, i) => i === index ? merged : w) });
43135
+ notify?.(res.ok ? `scoped limit #${index + 1} updated (live) \u2014 ${merged.scope} ${limitSummary(merged)}` : `edit rejected: ${res.invalid}`, res.ok ? "info" : "error");
43136
+ }
43137
+ async function deleteScopedLimitViaDialogs(paths, ui, notify) {
43138
+ const p = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
43139
+ const list = Array.isArray(p?.limits) ? p.limits : [];
43140
+ if (list.length === 0) {
43141
+ notify?.("no scoped limits to delete", "info");
43142
+ return;
43143
+ }
43144
+ const labels = list.map((l, i) => `#${i + 1} ${l.scope} ${limitSummary(l)}`);
43145
+ const picked = await ui.select("Delete which scoped limit", labels);
43146
+ if (!picked) return;
43147
+ const index = labels.indexOf(picked);
43148
+ if (index < 0) return;
43149
+ const ok = await ui.confirm("Delete scoped limit", `Remove ${labels[index]}?`);
43150
+ if (!ok) return;
43151
+ const res = writeScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath, mutate: (l) => l.filter((_, i) => i !== index) });
43152
+ notify?.(res.ok ? `scoped limit #${index + 1} deleted (live)` : `delete rejected: ${res.invalid}`, res.ok ? "info" : "error");
43153
+ }
42311
43154
  async function managePausesViaDialogs(paths, ui, notify) {
42312
43155
  const action = await ui.select("Pause windows", ["Add a pause window", "Edit a pause window", "Delete a pause window"]);
42313
43156
  if (!action) return;