@edgehero/pi-dispatch-admin 1.3.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/dist/index.mjs CHANGED
@@ -7886,6 +7886,462 @@ var init_budget = __esm({
7886
7886
  }
7887
7887
  });
7888
7888
 
7889
+ // ../worker/src/pause-windows.mjs
7890
+ function isNonEmptyString2(value) {
7891
+ return typeof value === "string" && value.trim() !== "";
7892
+ }
7893
+ function parseHHMM(value, label, at, path) {
7894
+ const m = isNonEmptyString2(value) ? /^([0-9]{1,2}):([0-9]{2})$/.exec(value.trim()) : null;
7895
+ if (!m) throw configError(`${at}: ${label} must be "HH:MM": ${JSON.stringify(value)} (${path})`);
7896
+ const h = Number(m[1]);
7897
+ const min = Number(m[2]);
7898
+ if (h > 23 || min > 59) throw configError(`${at}: ${label} out of range 00:00-23:59: ${JSON.stringify(value)} (${path})`);
7899
+ return h * 60 + min;
7900
+ }
7901
+ function assertDate(value, label, at, path) {
7902
+ const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(String(value).trim());
7903
+ const mo = m && Number(m[2]);
7904
+ const d = m && Number(m[3]);
7905
+ if (!m || mo < 1 || mo > 12 || d < 1 || d > 31) {
7906
+ throw configError(`${at}: ${label} must be "YYYY-MM-DD": ${JSON.stringify(value)} (${path})`);
7907
+ }
7908
+ }
7909
+ function validateTz(tz, at, path) {
7910
+ try {
7911
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
7912
+ return tz;
7913
+ } catch {
7914
+ throw configError(`${at}: tz is not a valid IANA timezone: ${JSON.stringify(tz)} (${path})`);
7915
+ }
7916
+ }
7917
+ function parsePauseWindows(text, path) {
7918
+ let parsed;
7919
+ try {
7920
+ parsed = JSON.parse(text);
7921
+ } catch (error) {
7922
+ throw configError(`pause-windows file is not valid JSON: ${path} (${error.message})`);
7923
+ }
7924
+ const windows = parsed?.windows;
7925
+ if (!Array.isArray(windows)) {
7926
+ throw configError(`pause-windows file must have a "windows" array: ${path}`);
7927
+ }
7928
+ return windows.map((w, index) => normalizeWindow(w, index, path));
7929
+ }
7930
+ function normalizeWindow(w, index, path) {
7931
+ const at = `pause window at index ${index}`;
7932
+ if (w === null || typeof w !== "object") throw configError(`${at}: must be an object: ${path}`);
7933
+ if (!isNonEmptyString2(w.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
7934
+ const fromMin = parseHHMM(w.from, "from", at, path);
7935
+ const toMin = parseHHMM(w.to, "to", at, path);
7936
+ if (fromMin === toMin) {
7937
+ throw configError(`${at}: from and to must differ (a 24h pause is not expressible; remove the trigger): ${path}`);
7938
+ }
7939
+ const tz = w.tz === void 0 ? "UTC" : validateTz(w.tz, at, path);
7940
+ let days = null;
7941
+ if (w.days !== void 0) {
7942
+ if (!Array.isArray(w.days) || w.days.length === 0) {
7943
+ throw configError(`${at}: days must be a non-empty array of weekday names (mon..sun): ${path}`);
7944
+ }
7945
+ days = w.days.map((d) => {
7946
+ const key = String(d).trim().toLowerCase().slice(0, 3);
7947
+ if (!DAY_SET.has(key)) throw configError(`${at}: unknown weekday ${JSON.stringify(d)} (use mon..sun): ${path}`);
7948
+ return key;
7949
+ });
7950
+ }
7951
+ let dateFrom = null;
7952
+ let dateTo = null;
7953
+ if (w.dateFrom !== void 0) {
7954
+ assertDate(w.dateFrom, "dateFrom", at, path);
7955
+ dateFrom = String(w.dateFrom).trim();
7956
+ }
7957
+ if (w.dateTo !== void 0) {
7958
+ assertDate(w.dateTo, "dateTo", at, path);
7959
+ dateTo = String(w.dateTo).trim();
7960
+ }
7961
+ if (dateFrom && dateTo && dateFrom > dateTo) throw configError(`${at}: dateFrom must be <= dateTo: ${path}`);
7962
+ const norm = { scope: w.scope.trim(), from: w.from.trim(), to: w.to.trim(), fromMin, toMin, tz };
7963
+ if (days) norm.days = days;
7964
+ if (dateFrom) norm.dateFrom = dateFrom;
7965
+ if (dateTo) norm.dateTo = dateTo;
7966
+ return norm;
7967
+ }
7968
+ function zonedParts(tz, ms) {
7969
+ const fmt2 = new Intl.DateTimeFormat("en-US", {
7970
+ timeZone: tz,
7971
+ hour12: false,
7972
+ year: "numeric",
7973
+ month: "2-digit",
7974
+ day: "2-digit",
7975
+ hour: "2-digit",
7976
+ minute: "2-digit",
7977
+ weekday: "short"
7978
+ });
7979
+ const p = {};
7980
+ for (const part of fmt2.formatToParts(new Date(ms))) p[part.type] = part.value;
7981
+ let hh = Number(p.hour);
7982
+ if (hh === 24) hh = 0;
7983
+ return { y: Number(p.year), mo: Number(p.month), d: Number(p.day), hh, mi: Number(p.minute), dow: WEEKDAY_INDEX[p.weekday] };
7984
+ }
7985
+ function wallToMs(tz, y, mo, d, hh, mi) {
7986
+ const guess = Date.UTC(y, mo - 1, d, hh, mi);
7987
+ const shown = zonedParts(tz, guess);
7988
+ const shownAsUtc = Date.UTC(shown.y, shown.mo - 1, shown.d, shown.hh, shown.mi);
7989
+ return guess - (shownAsUtc - guess);
7990
+ }
7991
+ function shiftDay(tz, parts, delta) {
7992
+ const noon = wallToMs(tz, parts.y, parts.mo, parts.d, 12, 0);
7993
+ return zonedParts(tz, noon + delta * 864e5);
7994
+ }
7995
+ function ymd(parts) {
7996
+ return `${String(parts.y).padStart(4, "0")}-${String(parts.mo).padStart(2, "0")}-${String(parts.d).padStart(2, "0")}`;
7997
+ }
7998
+ function qualifies(w, parts) {
7999
+ if (w.days && !w.days.includes(DAYS[parts.dow])) return false;
8000
+ const date = ymd(parts);
8001
+ if (w.dateFrom && date < w.dateFrom) return false;
8002
+ if (w.dateTo && date > w.dateTo) return false;
8003
+ return true;
8004
+ }
8005
+ function windowEndAt(w, nowMs) {
8006
+ const p = zonedParts(w.tz, nowMs);
8007
+ const nowMin = p.hh * 60 + p.mi;
8008
+ const endAt = (dateParts) => wallToMs(w.tz, dateParts.y, dateParts.mo, dateParts.d, Math.floor(w.toMin / 60), w.toMin % 60);
8009
+ if (w.fromMin < w.toMin) {
8010
+ if (nowMin >= w.fromMin && nowMin < w.toMin && qualifies(w, p)) return endAt(p);
8011
+ return null;
8012
+ }
8013
+ if (nowMin >= w.fromMin && qualifies(w, p)) return endAt(shiftDay(w.tz, p, 1));
8014
+ if (nowMin < w.toMin && qualifies(w, shiftDay(w.tz, p, -1))) return endAt(p);
8015
+ return null;
8016
+ }
8017
+ var DAYS, DAY_SET, WEEKDAY_INDEX;
8018
+ var init_pause_windows = __esm({
8019
+ "../worker/src/pause-windows.mjs"() {
8020
+ init_config2();
8021
+ DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
8022
+ DAY_SET = new Set(DAYS);
8023
+ WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
8024
+ }
8025
+ });
8026
+
8027
+ // ../worker/src/scoped-limits.mjs
8028
+ import { createHash } from "node:crypto";
8029
+ import { isAbsolute, resolve } from "node:path";
8030
+ function isNonEmptyString3(value) {
8031
+ return typeof value === "string" && value.trim() !== "";
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
+ }
8121
+ function normalizeGlobs(value, label, at, path) {
8122
+ if (!Array.isArray(value) || value.length === 0) {
8123
+ throw configError(`${at}: ${label} must be a non-empty array of model globs: ${path}`);
8124
+ }
8125
+ return value.map((glob) => {
8126
+ if (typeof glob !== "string" || !MODEL_GLOB_RE.test(glob.trim())) {
8127
+ throw configError(`${at}: ${label} glob ${JSON.stringify(glob)} must match ${MODEL_GLOB_RE} (model-id charset plus "*", max 64 chars): ${path}`);
8128
+ }
8129
+ return glob.trim();
8130
+ });
8131
+ }
8132
+ function normalizeBool(value, label, at, path, fallback) {
8133
+ if (value === void 0) return fallback;
8134
+ if (typeof value !== "boolean") throw configError(`${at}: ${label} must be a boolean: ${path}`);
8135
+ return value;
8136
+ }
8137
+ function parseSubscriptions(text, path) {
8138
+ let parsed;
8139
+ try {
8140
+ parsed = JSON.parse(text);
8141
+ } catch (error) {
8142
+ throw configError(`subscriptions file is not valid JSON: ${path} (${error.message})`);
8143
+ }
8144
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
8145
+ throw configError(`subscriptions file must be a JSON object: ${path}`);
8146
+ }
8147
+ const version = parsed.version;
8148
+ if (!Number.isInteger(version) || version < 1) {
8149
+ throw configError(`subscriptions file must declare an integer "version" >= 1 (got ${JSON.stringify(version)}): ${path}`);
8150
+ }
8151
+ if (version > SUBSCRIPTIONS_VERSION) {
8152
+ throw configError(`subscriptions file written by a newer pi-dispatch (version ${version}; this build understands ${SUBSCRIPTIONS_VERSION}): ${path}`);
8153
+ }
8154
+ const entries = parsed.subscriptions;
8155
+ if (!Array.isArray(entries)) {
8156
+ throw configError(`subscriptions file must have a "subscriptions" array: ${path}`);
8157
+ }
8158
+ const seenIds = /* @__PURE__ */ new Map();
8159
+ return { version, subscriptions: entries.map((entry, index) => normalizeSubscription(entry, index, path, seenIds)) };
8160
+ }
8161
+ function normalizeSubscription(entry, index, path, seenIds) {
8162
+ const at = `subscription at index ${index}`;
8163
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
8164
+ throw configError(`${at}: must be an object: ${path}`);
8165
+ }
8166
+ if (!isNonEmptyString4(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
8167
+ const id = entry.id.trim();
8168
+ if (seenIds.has(id)) {
8169
+ throw configError(`${at}: id ${JSON.stringify(id)} is already used by the subscription at index ${seenIds.get(id)}: ${path}`);
8170
+ }
8171
+ seenIds.set(id, index);
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}`);
8174
+ const models = entry.models === void 0 ? ["*"] : normalizeGlobs(entry.models, "models", at, path);
8175
+ const price = entry.price;
8176
+ if (price === null || typeof price !== "object" || Array.isArray(price)) {
8177
+ throw configError(`${at}: price must be an object { amount, currency, per }: ${path}`);
8178
+ }
8179
+ if (typeof price.amount !== "number" || !Number.isFinite(price.amount) || price.amount <= 0) {
8180
+ throw configError(`${at}: price.amount must be a number > 0: ${path}`);
8181
+ }
8182
+ if (typeof price.currency !== "string" || !/^[A-Z]{3}$/.test(price.currency)) {
8183
+ throw configError(`${at}: price.currency must be a 3-letter uppercase code (e.g. "USD"): ${path}`);
8184
+ }
8185
+ if (price.per !== "month") {
8186
+ throw configError(`${at}: price.per must be "month" (got ${JSON.stringify(price.per)}): ${path}`);
8187
+ }
8188
+ const sharedWithOtherProducts = normalizeBool(entry.sharedWithOtherProducts, "sharedWithOtherProducts", at, path, false);
8189
+ const hypothetical = normalizeBool(entry.hypothetical, "hypothetical", at, path, false);
8190
+ let counterfactualModel = null;
8191
+ if (entry.counterfactualModel !== void 0 && entry.counterfactualModel !== null) {
8192
+ const cm = entry.counterfactualModel;
8193
+ if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString4(cm.provider) || !isNonEmptyString4(cm.id)) {
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}`);
8195
+ }
8196
+ counterfactualModel = { provider: cm.provider.trim(), id: cm.id.trim() };
8197
+ }
8198
+ let windows = [];
8199
+ if (entry.windows !== void 0) {
8200
+ if (!Array.isArray(entry.windows)) throw configError(`${at}: windows must be an array: ${path}`);
8201
+ windows = entry.windows.map((w, windowIndex) => normalizeWindow2(w, windowIndex, index, path));
8202
+ }
8203
+ return {
8204
+ id,
8205
+ vendor: entry.vendor.trim(),
8206
+ provider: entry.provider.trim(),
8207
+ models,
8208
+ price: { amount: price.amount, currency: price.currency, per: "month" },
8209
+ sharedWithOtherProducts,
8210
+ hypothetical,
8211
+ counterfactualModel,
8212
+ windows
8213
+ };
8214
+ }
8215
+ function normalizeWindow2(w, windowIndex, subIndex, path) {
8216
+ const at = `window at index ${windowIndex} of subscription at index ${subIndex}`;
8217
+ if (w === null || typeof w !== "object" || Array.isArray(w)) {
8218
+ throw configError(`${at}: must be an object: ${path}`);
8219
+ }
8220
+ if (!WINDOW_PERS.has(w.per)) {
8221
+ throw configError(`${at}: per must be one of 5h|7d|30d|month (got ${JSON.stringify(w.per)}): ${path}`);
8222
+ }
8223
+ const rolling = normalizeBool(w.rolling, "rolling", at, path, false);
8224
+ let unit = null;
8225
+ if (w.unit !== void 0 && w.unit !== null) {
8226
+ if (!WINDOW_UNITS.has(w.unit)) {
8227
+ throw configError(`${at}: unit must be one of tokens|requests|prompts|credits, or null (vendor undisclosed): ${path}`);
8228
+ }
8229
+ unit = w.unit;
8230
+ }
8231
+ let limit = null;
8232
+ if (w.limit !== void 0 && w.limit !== null) {
8233
+ if (typeof w.limit === "number") {
8234
+ if (!Number.isFinite(w.limit) || w.limit <= 0) throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
8235
+ limit = w.limit;
8236
+ } else if (Array.isArray(w.limit)) {
8237
+ const [min, max] = w.limit;
8238
+ if (w.limit.length !== 2 || typeof min !== "number" || typeof max !== "number" || !Number.isFinite(min) || !Number.isFinite(max) || min <= 0 || max <= 0 || min >= max) {
8239
+ throw configError(`${at}: limit range must be [min, max] with both > 0 and min < max: ${path}`);
8240
+ }
8241
+ limit = [min, max];
8242
+ } else {
8243
+ throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
8244
+ }
8245
+ }
8246
+ const scope = w.scope === void 0 || w.scope === null ? null : normalizeGlobs(w.scope, "scope", at, path);
8247
+ return { per: w.per, rolling, unit, limit, scope };
8248
+ }
8249
+ var SUBSCRIPTIONS_VERSION, WINDOW_PERS, WINDOW_UNITS, MODEL_GLOB_RE;
8250
+ var init_subscriptions = __esm({
8251
+ "../worker/src/subscriptions.mjs"() {
8252
+ init_config2();
8253
+ SUBSCRIPTIONS_VERSION = 1;
8254
+ WINDOW_PERS = /* @__PURE__ */ new Set(["5h", "7d", "30d", "month"]);
8255
+ WINDOW_UNITS = /* @__PURE__ */ new Set(["tokens", "requests", "prompts", "credits"]);
8256
+ MODEL_GLOB_RE = /^[A-Za-z0-9*._:/-]{1,64}$/;
8257
+ }
8258
+ });
8259
+
8260
+ // ../worker/src/connection.mjs
8261
+ import { Redis } from "ioredis";
8262
+ function parseConnection(url, { failFast = false } = {}) {
8263
+ const u = new URL(url);
8264
+ return {
8265
+ host: u.hostname || "127.0.0.1",
8266
+ port: Number(u.port || 6379),
8267
+ ...u.password ? { password: u.password } : {},
8268
+ ...u.username ? { username: u.username } : {},
8269
+ ...u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {},
8270
+ maxRetriesPerRequest: null,
8271
+ // required for BullMQ blocking connections
8272
+ ...failFast ? {
8273
+ connectTimeout: 2e3,
8274
+ enableOfflineQueue: false,
8275
+ // don't buffer commands while disconnected -- error now
8276
+ retryStrategy: (attempts) => attempts > 2 ? null : 200
8277
+ // give up after ~2 tries
8278
+ } : {}
8279
+ };
8280
+ }
8281
+ function makeRedisClient(url) {
8282
+ return new Redis(url, { maxRetriesPerRequest: null });
8283
+ }
8284
+ var init_connection = __esm({
8285
+ "../worker/src/connection.mjs"() {
8286
+ }
8287
+ });
8288
+
8289
+ // ../worker/src/job-id.mjs
8290
+ import { createHash as createHash2 } from "node:crypto";
8291
+ function localJobId({ folder, flow, task, minute }) {
8292
+ const digest = createHash2("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
8293
+ return `local-${digest.slice(0, 16)}`;
8294
+ }
8295
+ var init_job_id = __esm({
8296
+ "../worker/src/job-id.mjs"() {
8297
+ init_config2();
8298
+ init_forges();
8299
+ }
8300
+ });
8301
+
8302
+ // ../worker/src/flow-gate.mjs
8303
+ import { execFile } from "node:child_process";
8304
+ import { promisify } from "node:util";
8305
+ async function readFlowGate({ folder, flow, sha, git = defaultGit }) {
8306
+ if (typeof sha !== "string" || sha.trim() === "") return { gate: "deny" };
8307
+ if (typeof flow !== "string" || !SKILL_NAME_RE.test(flow)) return { gate: "deny" };
8308
+ const path = `.pi/skills/${flow}/SKILL.md`;
8309
+ try {
8310
+ const lsTreeZ = await git(folder, ["ls-tree", "-z", sha, path]);
8311
+ const record = lsTreeZ.split("\0").find((r) => r);
8312
+ if (!record) return { gate: "no-skill" };
8313
+ const tab = record.indexOf(" ");
8314
+ if (tab === -1) return { gate: "deny" };
8315
+ const [mode, type, oid] = record.slice(0, tab).split(/\s+/);
8316
+ if (mode !== "100644" || type !== "blob") return { gate: "deny" };
8317
+ const content = await git(folder, ["cat-file", "blob", oid], { raw: true });
8318
+ return { gate: aiTriggerAllows(content) ? "allow" : "deny" };
8319
+ } catch {
8320
+ return { gate: "deny" };
8321
+ }
8322
+ }
8323
+ function aiTriggerAllows(buf) {
8324
+ const text = buf.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
8325
+ const block = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text);
8326
+ if (!block) return false;
8327
+ return /^ai-trigger:\s*("?)allow\1\s*$/m.test(block[1]);
8328
+ }
8329
+ async function defaultGit(gitDir, args, { raw = false } = {}) {
8330
+ const hardened = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager", "-C", gitDir, ...args];
8331
+ const { stdout } = await exec("git", hardened, {
8332
+ encoding: raw ? "buffer" : "utf8",
8333
+ maxBuffer: 16 * 1024 * 1024
8334
+ });
8335
+ return stdout;
8336
+ }
8337
+ var exec, SKILL_NAME_RE;
8338
+ var init_flow_gate = __esm({
8339
+ "../worker/src/flow-gate.mjs"() {
8340
+ exec = promisify(execFile);
8341
+ SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
8342
+ }
8343
+ });
8344
+
7889
8345
  // ../worker/src/reserved-env.mjs
7890
8346
  var CONTAINER_ENV_NAMES;
7891
8347
  var init_reserved_env = __esm({
@@ -7910,7 +8366,7 @@ var init_reserved_env = __esm({
7910
8366
  });
7911
8367
 
7912
8368
  // ../worker/src/triggers.mjs
7913
- function isNonEmptyString2(value) {
8369
+ function isNonEmptyString5(value) {
7914
8370
  return typeof value === "string" && value.trim() !== "";
7915
8371
  }
7916
8372
  function parseTriggers(text, path) {
@@ -7940,7 +8396,7 @@ function normalizeTrigger(entry, index, path, state) {
7940
8396
  throw configError(`${at}: "run" must be an object: ${path}`);
7941
8397
  }
7942
8398
  if (!ON_TYPES.has(on.type)) {
7943
- throw configError(`${at}: on.type must be one of cron|label|comment|pull_request (got ${JSON.stringify(on.type)}): ${path}`);
8399
+ throw configError(`${at}: on.type must be one of ${[...ON_TYPES].join("|")} (got ${JSON.stringify(on.type)}): ${path}`);
7944
8400
  }
7945
8401
  if (!RUN_KINDS.includes(run.kind)) {
7946
8402
  throw configError(`${at}: run.kind must be one of ${RUN_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
@@ -7954,14 +8410,18 @@ function normalizeTrigger(entry, index, path, state) {
7954
8410
  if (!isForgeKind(run.kind)) {
7955
8411
  throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of ${FORGE_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
7956
8412
  }
7957
- if (on.type === "label") return normalizeLabel(on, run, index, path);
7958
- if (on.type === "comment") return normalizeComment(on, run, index, path, state);
7959
- return normalizePullRequest(on, run, index, path);
8413
+ let normalized;
8414
+ if (on.type === "label") normalized = normalizeLabel(on, run, index, path);
8415
+ else if (on.type === "comment") normalized = normalizeComment(on, run, index, path, state);
8416
+ else if (on.type === "issue") normalized = normalizeIssue(on, run, index, path);
8417
+ else normalized = normalizePullRequest(on, run, index, path);
8418
+ if (on.disarmed !== void 0) return { on: { type: DISARMED_TYPE }, run: {} };
8419
+ return normalized;
7960
8420
  }
7961
8421
  function normalizeCron(on, run, index, path, state) {
7962
8422
  const at = `trigger at index ${index}`;
7963
8423
  const id = on.id;
7964
- if (!isNonEmptyString2(id)) {
8424
+ if (!isNonEmptyString5(id)) {
7965
8425
  throw configError(`${at}: cron on.id must be a non-empty string: ${path}`);
7966
8426
  }
7967
8427
  if (id.includes(":")) {
@@ -7975,7 +8435,7 @@ function normalizeCron(on, run, index, path, state) {
7975
8435
  }
7976
8436
  state.seenCronIds.add(id);
7977
8437
  const pattern = on.pattern;
7978
- if (!isNonEmptyString2(pattern)) {
8438
+ if (!isNonEmptyString5(pattern)) {
7979
8439
  throw configError(`cron trigger "${id}": on.pattern must be a non-empty string: ${path}`);
7980
8440
  }
7981
8441
  const fieldCount = pattern.trim().split(/\s+/).length;
@@ -7983,13 +8443,13 @@ function normalizeCron(on, run, index, path, state) {
7983
8443
  throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
7984
8444
  }
7985
8445
  const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
7986
- if (!isNonEmptyString2(run.folder)) {
8446
+ if (!isNonEmptyString5(run.folder)) {
7987
8447
  throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
7988
8448
  }
7989
- if (command === void 0 && !isNonEmptyString2(run.flow)) {
8449
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
7990
8450
  throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
7991
8451
  }
7992
- if (command === void 0 && !isNonEmptyString2(run.task)) {
8452
+ if (command === void 0 && !isNonEmptyString5(run.task)) {
7993
8453
  throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
7994
8454
  }
7995
8455
  if (run.github !== void 0 && typeof run.github !== "boolean") {
@@ -8001,6 +8461,9 @@ function normalizeCron(on, run, index, path, state) {
8001
8461
  validateInstructions(run, `cron trigger "${id}"`, path, { cron: true });
8002
8462
  const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
8003
8463
  validateReplicas(run, `cron trigger "${id}"`, path);
8464
+ validateNumber(on, `cron trigger "${id}"`, path, { onType: "cron" });
8465
+ validateOnce(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
8466
+ validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
8004
8467
  const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
8005
8468
  const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
8006
8469
  return {
@@ -8070,7 +8533,7 @@ function validateCommand(run, at, path, { onType }) {
8070
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}`);
8071
8534
  }
8072
8535
  if (raw === void 0) return void 0;
8073
- if (!isNonEmptyString2(raw)) {
8536
+ if (!isNonEmptyString5(raw)) {
8074
8537
  throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
8075
8538
  }
8076
8539
  if (raw !== raw.trim()) {
@@ -8107,6 +8570,72 @@ function validatePredicate(on, index, path, requirePositive) {
8107
8570
  }
8108
8571
  return { any: on.any, all: on.all, none: on.none };
8109
8572
  }
8573
+ function validateNumber(on, at, path, { capable = false, onType } = {}) {
8574
+ const number = on.number;
8575
+ if (number === void 0) return void 0;
8576
+ if (!capable) {
8577
+ if (onType === "cron") {
8578
+ throw configError(`${at}: on.number is not available on a cron trigger -- a schedule fires on time, not on an item, so there is no delivery for a number to narrow: ${path}`);
8579
+ }
8580
+ throw configError(`${at}: on.number is not yet covered for ${onType} triggers here -- only the close routes read it, so on a rule they never serve it would sit in the file looking configured; narrowing labels, comments or non-close pull_request rules is a gap to close, not a limit: ${path}`);
8581
+ }
8582
+ if (!Number.isInteger(number) || number < 1) {
8583
+ throw configError(`${at}: on.number must be an integer >= 1 when present -- the item number the forge itself assigns (on GitLab, the iid) (got ${JSON.stringify(number)}): ${path}`);
8584
+ }
8585
+ return number;
8586
+ }
8587
+ function validateOnce(on, run, at, path, { capable = false, onType } = {}) {
8588
+ const once = on.once;
8589
+ if (once === void 0) return void 0;
8590
+ if (!capable) {
8591
+ if (onType === "cron") {
8592
+ throw configError(`${at}: on.once is not available on a cron trigger -- a one-shot that can re-arm is a schedule, and a cron job carries no matched delivery for the disarm to name; delete the entry when the work is done: ${path}`);
8593
+ }
8594
+ const closeWords = Object.entries(PR_CLOSE_ACTIONS).map(([k, w]) => `${k} has ${JSON.stringify(w)}`).join(", ");
8595
+ throw configError(`${at}: on.once is not yet covered here -- the disarm writes back to the one entry a delivery matched, and only a close delivery names the single item that spends it; use on.type "issue", or a pull_request rule whose only action is the close word (${closeWords}; azure has no close trigger yet): ${path}`);
8596
+ }
8597
+ if (typeof once !== "boolean") {
8598
+ throw configError(`${at}: on.once must be true or false when present -- a truthy string arming a one-shot is exactly the drift this validator exists to refuse (got ${JSON.stringify(once)}): ${path}`);
8599
+ }
8600
+ if (once === true && on.number === void 0) {
8601
+ throw configError(`${at}: on.once requires on.number -- a one-shot without a named item is spent by whichever close arrives first, and two different items closing inside one dedup window would race for it; the number is also the identity the disarm re-checks before it writes: ${path}`);
8602
+ }
8603
+ if (once === true && run.replicas !== void 0) {
8604
+ throw configError(`${at}: on.once and run.replicas cannot be combined -- "exactly one run" and "N sandboxes race" contradict, and with N run records the disarm no longer says which one spent the trigger: ${path}`);
8605
+ }
8606
+ return once;
8607
+ }
8608
+ function validateDisarmed(on, at, path, { capable = false, onType } = {}) {
8609
+ const disarmed = on.disarmed;
8610
+ if (disarmed === void 0) return void 0;
8611
+ if (!capable) {
8612
+ throw configError(`${at}: on.disarmed marks a spent one-shot, and on.once is not ${onType === "cron" ? "available on a cron trigger" : "yet covered here"}, so there is nothing this entry could have spent: ${path}`);
8613
+ }
8614
+ if (disarmed === null || typeof disarmed !== "object" || Array.isArray(disarmed)) {
8615
+ throw configError(`${at}: on.disarmed must be an object with a non-empty string "at" (and optionally a non-empty string "jobId") -- the worker writes it when the one-shot fires, and a hand-written one disarms the entry deliberately (got ${JSON.stringify(disarmed)}): ${path}`);
8616
+ }
8617
+ for (const key of Object.keys(disarmed)) {
8618
+ if (key !== "at" && key !== "jobId") {
8619
+ throw configError(`${at}: on.disarmed has an unsupported key ${JSON.stringify(key)} (expected at|jobId): ${path}`);
8620
+ }
8621
+ }
8622
+ if (!isNonEmptyString5(disarmed.at)) {
8623
+ throw configError(`${at}: on.disarmed.at must be a non-empty string (the time the one-shot fired): ${path}`);
8624
+ }
8625
+ if (disarmed.jobId !== void 0 && !isNonEmptyString5(disarmed.jobId)) {
8626
+ throw configError(`${at}: on.disarmed.jobId must be a non-empty string when present (the run record it joins to): ${path}`);
8627
+ }
8628
+ if (on.once !== true) {
8629
+ throw configError(`${at}: on.disarmed is only meaningful beside on.once: true -- an entry that was never a one-shot has nothing to spend: ${path}`);
8630
+ }
8631
+ return disarmed;
8632
+ }
8633
+ function validateFlowName(run, at, path) {
8634
+ if (run.flow === void 0) return;
8635
+ if (!SKILL_NAME_RE.test(run.flow)) {
8636
+ throw configError(`${at}: run.flow ${JSON.stringify(run.flow)} fails the skill-name charset (lowercase letters, digits, dash and underscore, 1-64 chars, starting and ending alphanumeric) -- a name outside it can never materialise, so this trigger could only ever fail after the budget slot was reserved: ${path}`);
8637
+ }
8638
+ }
8110
8639
  function validateRepository(run, onType, at, path) {
8111
8640
  const needed = run.kind === "azure" && (onType === "label" || onType === "comment");
8112
8641
  const raw = run.repository;
@@ -8117,7 +8646,7 @@ function validateRepository(run, onType, at, path) {
8117
8646
  if (!needed) {
8118
8647
  throw configError(`${at}: run.repository is only meaningful on an azure label or comment trigger (a pull request names its own repository): ${path}`);
8119
8648
  }
8120
- if (!isNonEmptyString2(raw) || raw !== raw.trim() || raw.includes("/")) {
8649
+ if (!isNonEmptyString5(raw) || raw !== raw.trim() || raw.includes("/")) {
8121
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}`);
8122
8651
  }
8123
8652
  return raw;
@@ -8157,7 +8686,7 @@ function validateSecrets(run, at, path) {
8157
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}`);
8158
8687
  }
8159
8688
  const reference = secrets[name];
8160
- if (!isNonEmptyString2(reference)) {
8689
+ if (!isNonEmptyString5(reference)) {
8161
8690
  throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
8162
8691
  }
8163
8692
  if (reference !== reference.trim()) {
@@ -8175,7 +8704,7 @@ function validateSecrets(run, at, path) {
8175
8704
  function validateSecretsProfile(run, at, path) {
8176
8705
  const profile = run.secretsProfile;
8177
8706
  if (profile === void 0) return void 0;
8178
- if (!isNonEmptyString2(profile)) {
8707
+ if (!isNonEmptyString5(profile)) {
8179
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}`);
8180
8709
  }
8181
8710
  if (!ID_CHARSET.test(profile)) {
@@ -8189,10 +8718,14 @@ function validateSecretsProfile(run, at, path) {
8189
8718
  function normalizeLabel(on, run, index, path) {
8190
8719
  const at = `trigger at index ${index}`;
8191
8720
  const predicate = validatePredicate(on, index, path, true);
8721
+ validateNumber(on, at, path, { onType: "label" });
8722
+ validateOnce(on, run, at, path, { onType: "label" });
8723
+ validateDisarmed(on, at, path, { onType: "label" });
8192
8724
  const command = validateCommand(run, at, path, { onType: "label" });
8193
- if (command === void 0 && !isNonEmptyString2(run.flow)) {
8725
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8194
8726
  throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8195
8727
  }
8728
+ validateFlowName(run, at, path);
8196
8729
  const packages = validatePackagesFlag(run, at, path);
8197
8730
  const image = validateImageRef(run, at, path);
8198
8731
  const skillsDir = validateSkillsDir(run, at, path);
@@ -8209,13 +8742,17 @@ function normalizeLabel(on, run, index, path) {
8209
8742
  }
8210
8743
  function normalizeComment(on, run, index, path, state) {
8211
8744
  const at = `trigger at index ${index}`;
8212
- if (!isNonEmptyString2(on.phrase)) {
8745
+ if (!isNonEmptyString5(on.phrase)) {
8213
8746
  throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
8214
8747
  }
8748
+ validateNumber(on, at, path, { onType: "comment" });
8749
+ validateOnce(on, run, at, path, { onType: "comment" });
8750
+ validateDisarmed(on, at, path, { onType: "comment" });
8215
8751
  const command = validateCommand(run, at, path, { onType: "comment" });
8216
- if (command === void 0 && !isNonEmptyString2(run.flow)) {
8752
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8217
8753
  throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
8218
8754
  }
8755
+ validateFlowName(run, at, path);
8219
8756
  state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
8220
8757
  if (state.commentCounts[run.kind] > 1) {
8221
8758
  throw configError(`${at}: at most one ${run.kind} comment trigger is allowed: ${path}`);
@@ -8234,6 +8771,54 @@ function normalizeComment(on, run, index, path, state) {
8234
8771
  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 } }
8235
8772
  };
8236
8773
  }
8774
+ function normalizeIssue(on, run, index, path) {
8775
+ const at = `trigger at index ${index}`;
8776
+ if (run.kind === "azure") {
8777
+ throw configError(`${at}: an issue trigger is not yet covered for azure -- a work item's close is a System.State transition whose terminal names vary by process template (Agile "Closed", Scrum "Done"), and the projected subset carries only System.Tags, so nothing in the delivery says the item closed; widening the payload subset is the gap to close, not a limit: ${path}`);
8778
+ }
8779
+ const actions = on.action;
8780
+ if (!Array.isArray(actions) || actions.length === 0) {
8781
+ throw configError(`${at}: issue on.action must be a non-empty array: ${path}`);
8782
+ }
8783
+ const allowed = ISSUE_ACTIONS[run.kind];
8784
+ const expected = [...allowed].join("|");
8785
+ for (const a of actions) {
8786
+ if (!allowed.has(a)) {
8787
+ throw configError(`${at}: issue on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
8788
+ }
8789
+ }
8790
+ if (on.any !== void 0 || on.all !== void 0 || on.none !== void 0) {
8791
+ throw configError(`${at}: an issue trigger cannot carry a label predicate -- it fires on a lifecycle action, not a label diff, so any/all/none could never match; a label-predicated rule is on.type "label": ${path}`);
8792
+ }
8793
+ const number = validateNumber(on, at, path, { capable: true, onType: "issue" });
8794
+ const once = validateOnce(on, run, at, path, { capable: true, onType: "issue" });
8795
+ validateDisarmed(on, at, path, { capable: true, onType: "issue" });
8796
+ const command = validateCommand(run, at, path, { onType: "issue" });
8797
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8798
+ throw configError(`${at}: issue trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8799
+ }
8800
+ validateFlowName(run, at, path);
8801
+ const packages = validatePackagesFlag(run, at, path);
8802
+ const image = validateImageRef(run, at, path);
8803
+ const skillsDir = validateSkillsDir(run, at, path);
8804
+ const instructions = validateInstructions(run, at, path);
8805
+ const resume = validateResumeFlag(run, at, path);
8806
+ validateRepository(run, "issue", at, path);
8807
+ const replicas = validateReplicas(run, at, path);
8808
+ const secrets = validateSecrets(run, at, path);
8809
+ const secretsProfile = validateSecretsProfile(run, at, path);
8810
+ return {
8811
+ on: {
8812
+ type: "issue",
8813
+ action: [...actions],
8814
+ // Absent rather than present-and-undefined, reviewState's rule below: an unnarrowed rule's
8815
+ // normalized shape must not grow keys.
8816
+ ...number !== void 0 && { number },
8817
+ ...once !== void 0 && { once }
8818
+ },
8819
+ 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 } }
8820
+ };
8821
+ }
8237
8822
  function normalizePullRequest(on, run, index, path) {
8238
8823
  const at = `trigger at index ${index}`;
8239
8824
  const actions = on.action;
@@ -8247,17 +8832,29 @@ function normalizePullRequest(on, run, index, path) {
8247
8832
  throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
8248
8833
  }
8249
8834
  }
8835
+ const closeWord = PR_CLOSE_ACTIONS[run.kind];
8836
+ const isClose = closeWord !== void 0 && actions.includes(closeWord);
8837
+ if (isClose && actions.some((a) => a !== closeWord)) {
8838
+ throw configError(`${at}: pull_request on.action cannot mix ${JSON.stringify(closeWord)} with other actions -- a close rule is gated on the closer's write access and every other action on the author's, and one rule cannot gate on two different actors; split it into two entries: ${path}`);
8839
+ }
8250
8840
  const reviewState = validateReviewState(on, actions, run, at, path);
8251
8841
  const labelAction = forgeSpec(run.kind)?.prLabelAction;
8252
8842
  const requirePositive = typeof labelAction === "string" && actions.includes(labelAction);
8253
8843
  if (run.kind === "azure" && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
8254
8844
  throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
8255
8845
  }
8846
+ if (isClose && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
8847
+ throw configError(`${at}: a close pull_request rule cannot carry a label predicate -- the close route matches the action and on.number alone, so any/all/none would sit in the file looking configured and never match anything: ${path}`);
8848
+ }
8256
8849
  const predicate = validatePredicate(on, index, path, requirePositive);
8850
+ const number = validateNumber(on, at, path, { capable: isClose, onType: "pull_request" });
8851
+ const once = validateOnce(on, run, at, path, { capable: isClose, onType: "pull_request" });
8852
+ validateDisarmed(on, at, path, { capable: isClose, onType: "pull_request" });
8257
8853
  const command = validateCommand(run, at, path, { onType: "pull_request" });
8258
- if (command === void 0 && !isNonEmptyString2(run.flow)) {
8854
+ if (command === void 0 && !isNonEmptyString5(run.flow)) {
8259
8855
  throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
8260
8856
  }
8857
+ validateFlowName(run, at, path);
8261
8858
  const packages = validatePackagesFlag(run, at, path);
8262
8859
  const image = validateImageRef(run, at, path);
8263
8860
  const skillsDir = validateSkillsDir(run, at, path);
@@ -8276,7 +8873,10 @@ function normalizePullRequest(on, run, index, path) {
8276
8873
  ...reviewState !== void 0 && { reviewState },
8277
8874
  any: predicate.any,
8278
8875
  all: predicate.all,
8279
- none: predicate.none
8876
+ none: predicate.none,
8877
+ // Same rule for the close-narrowing pair (issue #231): only a close-only rule can carry them.
8878
+ ...number !== void 0 && { number },
8879
+ ...once !== void 0 && { once }
8280
8880
  },
8281
8881
  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 } }
8282
8882
  };
@@ -8300,30 +8900,41 @@ function validateReviewState(on, actions, run, at, path) {
8300
8900
  }
8301
8901
  return [...on.reviewState];
8302
8902
  }
8303
- var ON_TYPES, PR_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, SECRETS_MAX, ENV_NAME, RESERVED_ENV_NAMES, INSTRUCTIONS_MAX;
8903
+ var ON_TYPES, DISARMED_TYPE, PR_ACTIONS, PR_CLOSE_ACTIONS, ISSUE_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, SECRETS_MAX, ENV_NAME, RESERVED_ENV_NAMES, INSTRUCTIONS_MAX;
8304
8904
  var init_triggers = __esm({
8305
8905
  "../worker/src/triggers.mjs"() {
8306
8906
  init_config2();
8907
+ init_flow_gate();
8307
8908
  init_forges();
8308
8909
  init_reserved_env();
8309
- ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request"]);
8910
+ ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request", "issue"]);
8911
+ DISARMED_TYPE = "disarmed";
8310
8912
  PR_ACTIONS = {
8311
- github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
8913
+ github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted", "closed"]),
8312
8914
  // GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
8313
8915
  // `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
8314
- // what `on.reviewState` below exists to narrow.
8315
- gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"]),
8916
+ // what `on.reviewState` below exists to narrow. `close` is GitLab's own spelling of the close action.
8917
+ gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved", "close"]),
8316
8918
  // Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
8317
8919
  // one-letter difference that an operator would otherwise discover as a trigger that loads clean and
8318
8920
  // never fires. `label_cleared` is deliberately ABSENT and always will be: REMOVING a label must never
8319
8921
  // start a paid run, and it has no GitHub counterpart to inherit that rule from.
8320
- forgejo: /* @__PURE__ */ new Set(["label_updated", "opened", "synchronized", "reopened"]),
8922
+ forgejo: /* @__PURE__ */ new Set(["label_updated", "opened", "synchronized", "reopened", "closed"]),
8321
8923
  // Azure's Service Hook events reduced to the two that leave something to act on. `git.pullrequest.merged`
8322
- // is omitted for the same reason GitLab's `merge` and `close` are: a job started by a merge has nothing
8323
- // left to do. There is no label action at all -- Azure attaches tags to WORK ITEMS, never to pull
8324
- // requests -- which is why azure's `prLabelAction` is null and a predicated PR rule is refused below.
8924
+ // is omitted for the reason the close words' own comment above records, and there is no close word here at
8925
+ // all: an abandon arrives as `git.pullrequest.updated` with nothing in the projected subset to tell it from
8926
+ // any other update, so an azure close rule could only ever fire on the wrong events or never. Widening
8927
+ // INT-AZURE-PAYLOAD-SUBSET (a PR status field) is the gap to close before this set can grow. There is no
8928
+ // label action either -- Azure attaches tags to WORK ITEMS, never to pull requests -- which is why azure's
8929
+ // `prLabelAction` is null and a predicated PR rule is refused below.
8325
8930
  azure: /* @__PURE__ */ new Set(["created", "updated"])
8326
8931
  };
8932
+ PR_CLOSE_ACTIONS = { github: "closed", gitlab: "close", forgejo: "closed" };
8933
+ ISSUE_ACTIONS = {
8934
+ github: /* @__PURE__ */ new Set(["closed"]),
8935
+ gitlab: /* @__PURE__ */ new Set(["close"]),
8936
+ forgejo: /* @__PURE__ */ new Set(["closed"])
8937
+ };
8327
8938
  REVIEW_STATES = /* @__PURE__ */ new Set(["approved", "changes_requested", "commented"]);
8328
8939
  REVIEW_ACTION = "review_submitted";
8329
8940
  ID_CHARSET = /^[A-Za-z0-9._-]+$/;
@@ -8335,329 +8946,6 @@ var init_triggers = __esm({
8335
8946
  }
8336
8947
  });
8337
8948
 
8338
- // ../worker/src/pause-windows.mjs
8339
- function isNonEmptyString3(value) {
8340
- return typeof value === "string" && value.trim() !== "";
8341
- }
8342
- function parseHHMM(value, label, at, path) {
8343
- const m = isNonEmptyString3(value) ? /^([0-9]{1,2}):([0-9]{2})$/.exec(value.trim()) : null;
8344
- if (!m) throw configError(`${at}: ${label} must be "HH:MM": ${JSON.stringify(value)} (${path})`);
8345
- const h = Number(m[1]);
8346
- const min = Number(m[2]);
8347
- if (h > 23 || min > 59) throw configError(`${at}: ${label} out of range 00:00-23:59: ${JSON.stringify(value)} (${path})`);
8348
- return h * 60 + min;
8349
- }
8350
- function assertDate(value, label, at, path) {
8351
- const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(String(value).trim());
8352
- const mo = m && Number(m[2]);
8353
- const d = m && Number(m[3]);
8354
- if (!m || mo < 1 || mo > 12 || d < 1 || d > 31) {
8355
- throw configError(`${at}: ${label} must be "YYYY-MM-DD": ${JSON.stringify(value)} (${path})`);
8356
- }
8357
- }
8358
- function validateTz(tz, at, path) {
8359
- try {
8360
- new Intl.DateTimeFormat("en-US", { timeZone: tz });
8361
- return tz;
8362
- } catch {
8363
- throw configError(`${at}: tz is not a valid IANA timezone: ${JSON.stringify(tz)} (${path})`);
8364
- }
8365
- }
8366
- function parsePauseWindows(text, path) {
8367
- let parsed;
8368
- try {
8369
- parsed = JSON.parse(text);
8370
- } catch (error) {
8371
- throw configError(`pause-windows file is not valid JSON: ${path} (${error.message})`);
8372
- }
8373
- const windows = parsed?.windows;
8374
- if (!Array.isArray(windows)) {
8375
- throw configError(`pause-windows file must have a "windows" array: ${path}`);
8376
- }
8377
- return windows.map((w, index) => normalizeWindow(w, index, path));
8378
- }
8379
- function normalizeWindow(w, index, path) {
8380
- const at = `pause window at index ${index}`;
8381
- if (w === null || typeof w !== "object") throw configError(`${at}: must be an object: ${path}`);
8382
- if (!isNonEmptyString3(w.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
8383
- const fromMin = parseHHMM(w.from, "from", at, path);
8384
- const toMin = parseHHMM(w.to, "to", at, path);
8385
- if (fromMin === toMin) {
8386
- throw configError(`${at}: from and to must differ (a 24h pause is not expressible; remove the trigger): ${path}`);
8387
- }
8388
- const tz = w.tz === void 0 ? "UTC" : validateTz(w.tz, at, path);
8389
- let days = null;
8390
- if (w.days !== void 0) {
8391
- if (!Array.isArray(w.days) || w.days.length === 0) {
8392
- throw configError(`${at}: days must be a non-empty array of weekday names (mon..sun): ${path}`);
8393
- }
8394
- days = w.days.map((d) => {
8395
- const key = String(d).trim().toLowerCase().slice(0, 3);
8396
- if (!DAY_SET.has(key)) throw configError(`${at}: unknown weekday ${JSON.stringify(d)} (use mon..sun): ${path}`);
8397
- return key;
8398
- });
8399
- }
8400
- let dateFrom = null;
8401
- let dateTo = null;
8402
- if (w.dateFrom !== void 0) {
8403
- assertDate(w.dateFrom, "dateFrom", at, path);
8404
- dateFrom = String(w.dateFrom).trim();
8405
- }
8406
- if (w.dateTo !== void 0) {
8407
- assertDate(w.dateTo, "dateTo", at, path);
8408
- dateTo = String(w.dateTo).trim();
8409
- }
8410
- if (dateFrom && dateTo && dateFrom > dateTo) throw configError(`${at}: dateFrom must be <= dateTo: ${path}`);
8411
- const norm = { scope: w.scope.trim(), from: w.from.trim(), to: w.to.trim(), fromMin, toMin, tz };
8412
- if (days) norm.days = days;
8413
- if (dateFrom) norm.dateFrom = dateFrom;
8414
- if (dateTo) norm.dateTo = dateTo;
8415
- return norm;
8416
- }
8417
- function zonedParts(tz, ms) {
8418
- const fmt2 = new Intl.DateTimeFormat("en-US", {
8419
- timeZone: tz,
8420
- hour12: false,
8421
- year: "numeric",
8422
- month: "2-digit",
8423
- day: "2-digit",
8424
- hour: "2-digit",
8425
- minute: "2-digit",
8426
- weekday: "short"
8427
- });
8428
- const p = {};
8429
- for (const part of fmt2.formatToParts(new Date(ms))) p[part.type] = part.value;
8430
- let hh = Number(p.hour);
8431
- if (hh === 24) hh = 0;
8432
- return { y: Number(p.year), mo: Number(p.month), d: Number(p.day), hh, mi: Number(p.minute), dow: WEEKDAY_INDEX[p.weekday] };
8433
- }
8434
- function wallToMs(tz, y, mo, d, hh, mi) {
8435
- const guess = Date.UTC(y, mo - 1, d, hh, mi);
8436
- const shown = zonedParts(tz, guess);
8437
- const shownAsUtc = Date.UTC(shown.y, shown.mo - 1, shown.d, shown.hh, shown.mi);
8438
- return guess - (shownAsUtc - guess);
8439
- }
8440
- function shiftDay(tz, parts, delta) {
8441
- const noon = wallToMs(tz, parts.y, parts.mo, parts.d, 12, 0);
8442
- return zonedParts(tz, noon + delta * 864e5);
8443
- }
8444
- function ymd(parts) {
8445
- return `${String(parts.y).padStart(4, "0")}-${String(parts.mo).padStart(2, "0")}-${String(parts.d).padStart(2, "0")}`;
8446
- }
8447
- function qualifies(w, parts) {
8448
- if (w.days && !w.days.includes(DAYS[parts.dow])) return false;
8449
- const date = ymd(parts);
8450
- if (w.dateFrom && date < w.dateFrom) return false;
8451
- if (w.dateTo && date > w.dateTo) return false;
8452
- return true;
8453
- }
8454
- function windowEndAt(w, nowMs) {
8455
- const p = zonedParts(w.tz, nowMs);
8456
- const nowMin = p.hh * 60 + p.mi;
8457
- const endAt = (dateParts) => wallToMs(w.tz, dateParts.y, dateParts.mo, dateParts.d, Math.floor(w.toMin / 60), w.toMin % 60);
8458
- if (w.fromMin < w.toMin) {
8459
- if (nowMin >= w.fromMin && nowMin < w.toMin && qualifies(w, p)) return endAt(p);
8460
- return null;
8461
- }
8462
- if (nowMin >= w.fromMin && qualifies(w, p)) return endAt(shiftDay(w.tz, p, 1));
8463
- if (nowMin < w.toMin && qualifies(w, shiftDay(w.tz, p, -1))) return endAt(p);
8464
- return null;
8465
- }
8466
- var DAYS, DAY_SET, WEEKDAY_INDEX;
8467
- var init_pause_windows = __esm({
8468
- "../worker/src/pause-windows.mjs"() {
8469
- init_config2();
8470
- DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
8471
- DAY_SET = new Set(DAYS);
8472
- WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
8473
- }
8474
- });
8475
-
8476
- // ../worker/src/subscriptions.mjs
8477
- function isNonEmptyString4(value) {
8478
- return typeof value === "string" && value.trim() !== "";
8479
- }
8480
- function normalizeGlobs(value, label, at, path) {
8481
- if (!Array.isArray(value) || value.length === 0) {
8482
- throw configError(`${at}: ${label} must be a non-empty array of model globs: ${path}`);
8483
- }
8484
- return value.map((glob) => {
8485
- if (typeof glob !== "string" || !MODEL_GLOB_RE.test(glob.trim())) {
8486
- throw configError(`${at}: ${label} glob ${JSON.stringify(glob)} must match ${MODEL_GLOB_RE} (model-id charset plus "*", max 64 chars): ${path}`);
8487
- }
8488
- return glob.trim();
8489
- });
8490
- }
8491
- function normalizeBool(value, label, at, path, fallback) {
8492
- if (value === void 0) return fallback;
8493
- if (typeof value !== "boolean") throw configError(`${at}: ${label} must be a boolean: ${path}`);
8494
- return value;
8495
- }
8496
- function parseSubscriptions(text, path) {
8497
- let parsed;
8498
- try {
8499
- parsed = JSON.parse(text);
8500
- } catch (error) {
8501
- throw configError(`subscriptions file is not valid JSON: ${path} (${error.message})`);
8502
- }
8503
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
8504
- throw configError(`subscriptions file must be a JSON object: ${path}`);
8505
- }
8506
- const version = parsed.version;
8507
- if (!Number.isInteger(version) || version < 1) {
8508
- throw configError(`subscriptions file must declare an integer "version" >= 1 (got ${JSON.stringify(version)}): ${path}`);
8509
- }
8510
- if (version > SUBSCRIPTIONS_VERSION) {
8511
- throw configError(`subscriptions file written by a newer pi-dispatch (version ${version}; this build understands ${SUBSCRIPTIONS_VERSION}): ${path}`);
8512
- }
8513
- const entries = parsed.subscriptions;
8514
- if (!Array.isArray(entries)) {
8515
- throw configError(`subscriptions file must have a "subscriptions" array: ${path}`);
8516
- }
8517
- const seenIds = /* @__PURE__ */ new Map();
8518
- return { version, subscriptions: entries.map((entry, index) => normalizeSubscription(entry, index, path, seenIds)) };
8519
- }
8520
- function normalizeSubscription(entry, index, path, seenIds) {
8521
- const at = `subscription at index ${index}`;
8522
- if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
8523
- throw configError(`${at}: must be an object: ${path}`);
8524
- }
8525
- if (!isNonEmptyString4(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
8526
- const id = entry.id.trim();
8527
- if (seenIds.has(id)) {
8528
- throw configError(`${at}: id ${JSON.stringify(id)} is already used by the subscription at index ${seenIds.get(id)}: ${path}`);
8529
- }
8530
- seenIds.set(id, index);
8531
- if (!isNonEmptyString4(entry.vendor)) throw configError(`${at}: vendor must be a non-empty string: ${path}`);
8532
- if (!isNonEmptyString4(entry.provider)) throw configError(`${at}: provider must be a non-empty string (the pi-ai provider id this plan covers): ${path}`);
8533
- const models = entry.models === void 0 ? ["*"] : normalizeGlobs(entry.models, "models", at, path);
8534
- const price = entry.price;
8535
- if (price === null || typeof price !== "object" || Array.isArray(price)) {
8536
- throw configError(`${at}: price must be an object { amount, currency, per }: ${path}`);
8537
- }
8538
- if (typeof price.amount !== "number" || !Number.isFinite(price.amount) || price.amount <= 0) {
8539
- throw configError(`${at}: price.amount must be a number > 0: ${path}`);
8540
- }
8541
- if (typeof price.currency !== "string" || !/^[A-Z]{3}$/.test(price.currency)) {
8542
- throw configError(`${at}: price.currency must be a 3-letter uppercase code (e.g. "USD"): ${path}`);
8543
- }
8544
- if (price.per !== "month") {
8545
- throw configError(`${at}: price.per must be "month" (got ${JSON.stringify(price.per)}): ${path}`);
8546
- }
8547
- const sharedWithOtherProducts = normalizeBool(entry.sharedWithOtherProducts, "sharedWithOtherProducts", at, path, false);
8548
- const hypothetical = normalizeBool(entry.hypothetical, "hypothetical", at, path, false);
8549
- let counterfactualModel = null;
8550
- if (entry.counterfactualModel !== void 0 && entry.counterfactualModel !== null) {
8551
- const cm = entry.counterfactualModel;
8552
- if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString4(cm.provider) || !isNonEmptyString4(cm.id)) {
8553
- 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}`);
8554
- }
8555
- counterfactualModel = { provider: cm.provider.trim(), id: cm.id.trim() };
8556
- }
8557
- let windows = [];
8558
- if (entry.windows !== void 0) {
8559
- if (!Array.isArray(entry.windows)) throw configError(`${at}: windows must be an array: ${path}`);
8560
- windows = entry.windows.map((w, windowIndex) => normalizeWindow2(w, windowIndex, index, path));
8561
- }
8562
- return {
8563
- id,
8564
- vendor: entry.vendor.trim(),
8565
- provider: entry.provider.trim(),
8566
- models,
8567
- price: { amount: price.amount, currency: price.currency, per: "month" },
8568
- sharedWithOtherProducts,
8569
- hypothetical,
8570
- counterfactualModel,
8571
- windows
8572
- };
8573
- }
8574
- function normalizeWindow2(w, windowIndex, subIndex, path) {
8575
- const at = `window at index ${windowIndex} of subscription at index ${subIndex}`;
8576
- if (w === null || typeof w !== "object" || Array.isArray(w)) {
8577
- throw configError(`${at}: must be an object: ${path}`);
8578
- }
8579
- if (!WINDOW_PERS.has(w.per)) {
8580
- throw configError(`${at}: per must be one of 5h|7d|30d|month (got ${JSON.stringify(w.per)}): ${path}`);
8581
- }
8582
- const rolling = normalizeBool(w.rolling, "rolling", at, path, false);
8583
- let unit = null;
8584
- if (w.unit !== void 0 && w.unit !== null) {
8585
- if (!WINDOW_UNITS.has(w.unit)) {
8586
- throw configError(`${at}: unit must be one of tokens|requests|prompts|credits, or null (vendor undisclosed): ${path}`);
8587
- }
8588
- unit = w.unit;
8589
- }
8590
- let limit = null;
8591
- if (w.limit !== void 0 && w.limit !== null) {
8592
- if (typeof w.limit === "number") {
8593
- if (!Number.isFinite(w.limit) || w.limit <= 0) throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
8594
- limit = w.limit;
8595
- } else if (Array.isArray(w.limit)) {
8596
- const [min, max] = w.limit;
8597
- if (w.limit.length !== 2 || typeof min !== "number" || typeof max !== "number" || !Number.isFinite(min) || !Number.isFinite(max) || min <= 0 || max <= 0 || min >= max) {
8598
- throw configError(`${at}: limit range must be [min, max] with both > 0 and min < max: ${path}`);
8599
- }
8600
- limit = [min, max];
8601
- } else {
8602
- throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
8603
- }
8604
- }
8605
- const scope = w.scope === void 0 || w.scope === null ? null : normalizeGlobs(w.scope, "scope", at, path);
8606
- return { per: w.per, rolling, unit, limit, scope };
8607
- }
8608
- var SUBSCRIPTIONS_VERSION, WINDOW_PERS, WINDOW_UNITS, MODEL_GLOB_RE;
8609
- var init_subscriptions = __esm({
8610
- "../worker/src/subscriptions.mjs"() {
8611
- init_config2();
8612
- SUBSCRIPTIONS_VERSION = 1;
8613
- WINDOW_PERS = /* @__PURE__ */ new Set(["5h", "7d", "30d", "month"]);
8614
- WINDOW_UNITS = /* @__PURE__ */ new Set(["tokens", "requests", "prompts", "credits"]);
8615
- MODEL_GLOB_RE = /^[A-Za-z0-9*._:/-]{1,64}$/;
8616
- }
8617
- });
8618
-
8619
- // ../worker/src/connection.mjs
8620
- import { Redis } from "ioredis";
8621
- function parseConnection(url, { failFast = false } = {}) {
8622
- const u = new URL(url);
8623
- return {
8624
- host: u.hostname || "127.0.0.1",
8625
- port: Number(u.port || 6379),
8626
- ...u.password ? { password: u.password } : {},
8627
- ...u.username ? { username: u.username } : {},
8628
- ...u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {},
8629
- maxRetriesPerRequest: null,
8630
- // required for BullMQ blocking connections
8631
- ...failFast ? {
8632
- connectTimeout: 2e3,
8633
- enableOfflineQueue: false,
8634
- // don't buffer commands while disconnected -- error now
8635
- retryStrategy: (attempts) => attempts > 2 ? null : 200
8636
- // give up after ~2 tries
8637
- } : {}
8638
- };
8639
- }
8640
- function makeRedisClient(url) {
8641
- return new Redis(url, { maxRetriesPerRequest: null });
8642
- }
8643
- var init_connection = __esm({
8644
- "../worker/src/connection.mjs"() {
8645
- }
8646
- });
8647
-
8648
- // ../worker/src/job-id.mjs
8649
- import { createHash } from "node:crypto";
8650
- function localJobId({ folder, flow, task, minute }) {
8651
- const digest = createHash("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
8652
- return `local-${digest.slice(0, 16)}`;
8653
- }
8654
- var init_job_id = __esm({
8655
- "../worker/src/job-id.mjs"() {
8656
- init_config2();
8657
- init_forges();
8658
- }
8659
- });
8660
-
8661
8949
  // ../worker/src/queue.mjs
8662
8950
  import { Queue } from "bullmq";
8663
8951
  function makeQueue(connection) {
@@ -8701,59 +8989,18 @@ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, m
8701
8989
  });
8702
8990
  return id;
8703
8991
  }
8704
- var QUEUE, SEMANTIC_WINDOW_MS;
8992
+ var PR_CLOSE_WORDS, QUEUE, SEMANTIC_WINDOW_MS;
8705
8993
  var init_queue = __esm({
8706
8994
  "../worker/src/queue.mjs"() {
8707
8995
  init_job_id();
8708
8996
  init_forges();
8997
+ init_triggers();
8998
+ PR_CLOSE_WORDS = new Set(Object.values(PR_CLOSE_ACTIONS));
8709
8999
  QUEUE = "pi-jobs";
8710
9000
  SEMANTIC_WINDOW_MS = 10 * 60 * 1e3;
8711
9001
  }
8712
9002
  });
8713
9003
 
8714
- // ../worker/src/flow-gate.mjs
8715
- import { execFile } from "node:child_process";
8716
- import { promisify } from "node:util";
8717
- async function readFlowGate({ folder, flow, sha, git = defaultGit }) {
8718
- if (typeof sha !== "string" || sha.trim() === "") return { gate: "deny" };
8719
- if (typeof flow !== "string" || !SKILL_NAME_RE.test(flow)) return { gate: "deny" };
8720
- const path = `.pi/skills/${flow}/SKILL.md`;
8721
- try {
8722
- const lsTreeZ = await git(folder, ["ls-tree", "-z", sha, path]);
8723
- const record = lsTreeZ.split("\0").find((r) => r);
8724
- if (!record) return { gate: "no-skill" };
8725
- const tab = record.indexOf(" ");
8726
- if (tab === -1) return { gate: "deny" };
8727
- const [mode, type, oid] = record.slice(0, tab).split(/\s+/);
8728
- if (mode !== "100644" || type !== "blob") return { gate: "deny" };
8729
- const content = await git(folder, ["cat-file", "blob", oid], { raw: true });
8730
- return { gate: aiTriggerAllows(content) ? "allow" : "deny" };
8731
- } catch {
8732
- return { gate: "deny" };
8733
- }
8734
- }
8735
- function aiTriggerAllows(buf) {
8736
- const text = buf.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
8737
- const block = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text);
8738
- if (!block) return false;
8739
- return /^ai-trigger:\s*("?)allow\1\s*$/m.test(block[1]);
8740
- }
8741
- async function defaultGit(gitDir, args, { raw = false } = {}) {
8742
- const hardened = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager", "-C", gitDir, ...args];
8743
- const { stdout } = await exec("git", hardened, {
8744
- encoding: raw ? "buffer" : "utf8",
8745
- maxBuffer: 16 * 1024 * 1024
8746
- });
8747
- return stdout;
8748
- }
8749
- var exec, SKILL_NAME_RE;
8750
- var init_flow_gate = __esm({
8751
- "../worker/src/flow-gate.mjs"() {
8752
- exec = promisify(execFile);
8753
- SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
8754
- }
8755
- });
8756
-
8757
9004
  // ../worker/src/git-dirty.mjs
8758
9005
  import { execFileSync } from "node:child_process";
8759
9006
  function gitDirty(folder, { exec: exec4 = execFileSync } = {}) {
@@ -8874,8 +9121,8 @@ var init_docker_run = __esm({
8874
9121
 
8875
9122
  // ../worker/src/packages.mjs
8876
9123
  import { existsSync, readdirSync, readFileSync } from "node:fs";
8877
- import { basename, dirname as dirname2, join, resolve } from "node:path";
8878
- function isNonEmptyString5(value) {
9124
+ import { basename, dirname as dirname2, join, resolve as resolve2 } from "node:path";
9125
+ function isNonEmptyString6(value) {
8879
9126
  return typeof value === "string" && value.trim() !== "";
8880
9127
  }
8881
9128
  function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists = existsSync } = {}) {
@@ -8889,7 +9136,7 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
8889
9136
  for (const entry of parsed.packages) {
8890
9137
  if (entry === null || typeof entry !== "object") return null;
8891
9138
  const { name, version, dir } = entry;
8892
- if (!isNonEmptyString5(name) || !isNonEmptyString5(version) || !isNonEmptyString5(dir)) return null;
9139
+ if (!isNonEmptyString6(name) || !isNonEmptyString6(version) || !isNonEmptyString6(dir)) return null;
8893
9140
  if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
8894
9141
  packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
8895
9142
  }
@@ -8914,7 +9161,7 @@ function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExists = e
8914
9161
  out.unenumerable.push(pkg.name);
8915
9162
  continue;
8916
9163
  }
8917
- 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));
8918
9165
  } else {
8919
9166
  sources = [join(root, "skills")];
8920
9167
  }
@@ -9331,6 +9578,16 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, o
9331
9578
  // (guarded on `typeof t.flow === "string"`) correctly draws nothing for it.
9332
9579
  command: t.command ?? null,
9333
9580
  replicas: t.replicas ?? null,
9581
+ // The one-shot facts (issue #231), carried the way `cost` is: node FACTS, never new vocabulary
9582
+ // -- the #188 rule, and GRAPH_FLAGS/GRAPH_NODE_KINDS are byte-unchanged by this issue too. A
9583
+ // spent entry still arrives here because the display normalizer reads the RAW file (the spent
9584
+ // row must stay visible), so the two states a renderer has to keep apart ride as one boolean
9585
+ // and one mark: `once` is true only while ARMED -- a spent one-shot is not "a rule that will
9586
+ // fire once", and letting both read true would ask every consumer to re-derive the difference
9587
+ // -- and `disarmed` is the worker's mark verbatim ({ at, jobId? }), null like every absent
9588
+ // fact on this literal.
9589
+ once: t.once === true && !t.disarmed,
9590
+ disarmed: t.disarmed !== null && typeof t.disarmed === "object" ? t.disarmed : null,
9334
9591
  folderKey: group?.key ?? null,
9335
9592
  runs: Number.isInteger(stats?.runs) ? stats.runs : 0,
9336
9593
  lastOutcome: stats?.lastOutcome ?? null,
@@ -9468,8 +9725,16 @@ function triggerMatchLabel(t) {
9468
9725
  return selectorLabel(t);
9469
9726
  case "comment":
9470
9727
  return `"${t.phrase ?? "-"}"`;
9471
- case "pull_request":
9472
- return `action[${(Array.isArray(t.action) ? t.action : []).join(",")}]`;
9728
+ case "pull_request": {
9729
+ const action = `action[${(Array.isArray(t.action) ? t.action : []).join(",")}]`;
9730
+ const num = Number.isInteger(t.number) ? ` #${t.number}` : "";
9731
+ return t.disarmed ? `${action}${num} (spent)` : `${action}${num}`;
9732
+ }
9733
+ case "issue": {
9734
+ const action = `action[${(Array.isArray(t.action) ? t.action : []).join(",")}]`;
9735
+ const base = Number.isInteger(t.number) ? `${action} #${t.number}` : action;
9736
+ return t.disarmed ? `${base} (spent)` : base;
9737
+ }
9473
9738
  default:
9474
9739
  return "(unknown)";
9475
9740
  }
@@ -10076,8 +10341,110 @@ var init_costs = __esm({
10076
10341
  }
10077
10342
  });
10078
10343
 
10344
+ // ../worker/src/triggers-file.mjs
10345
+ import nodeFs2 from "node:fs";
10346
+ function lockPathFor(triggersPath) {
10347
+ return `${triggersPath}.lock`;
10348
+ }
10349
+ function takeLock(triggersPath, fs, log) {
10350
+ const lock = lockPathFor(triggersPath);
10351
+ let sweptAgeMs = null;
10352
+ for (let attempt = 0; attempt < 2; attempt++) {
10353
+ try {
10354
+ const fd = fs.openSync(lock, "wx");
10355
+ if (sweptAgeMs !== null) log("triggers_lock_stale_taken", { ageMs: sweptAgeMs });
10356
+ return fd;
10357
+ } catch (err) {
10358
+ if (err?.code !== "EEXIST") throw err;
10359
+ let mtimeMs;
10360
+ try {
10361
+ mtimeMs = fs.statSync(lock).mtimeMs;
10362
+ } catch {
10363
+ continue;
10364
+ }
10365
+ if (Date.now() - mtimeMs <= LOCK_STALE_MS) return null;
10366
+ try {
10367
+ fs.unlinkSync(lock);
10368
+ } catch {
10369
+ }
10370
+ sweptAgeMs = Math.round(Date.now() - mtimeMs);
10371
+ }
10372
+ }
10373
+ return null;
10374
+ }
10375
+ function releaseLock(fd, triggersPath, fs, log) {
10376
+ fs.closeSync(fd);
10377
+ try {
10378
+ fs.unlinkSync(lockPathFor(triggersPath));
10379
+ } catch {
10380
+ log("triggers_lock_stuck", {});
10381
+ }
10382
+ }
10383
+ function serialize(triggersArray) {
10384
+ return `${JSON.stringify({ triggers: triggersArray }, null, 2)}
10385
+ `;
10386
+ }
10387
+ function renameIntoPlace(fs, tmp, dest) {
10388
+ try {
10389
+ fs.renameSync(tmp, dest);
10390
+ } catch (err) {
10391
+ if (err?.code !== "EPERM") throw err;
10392
+ fs.renameSync(tmp, dest);
10393
+ }
10394
+ }
10395
+ function tmpPathFor(triggersPath) {
10396
+ return `${triggersPath}.${process.pid}.${tmpSeq++}.tmp`;
10397
+ }
10398
+ function discardTmp(fs, tmp) {
10399
+ try {
10400
+ fs.unlinkSync(tmp);
10401
+ } catch {
10402
+ }
10403
+ }
10404
+ function writeTriggers({ triggersPath, mutate, fs = nodeFs2, log = () => {
10405
+ } }) {
10406
+ const fd = takeLock(triggersPath, fs, log);
10407
+ if (fd === null) {
10408
+ return { invalid: `triggers file locked (another write in progress): ${lockPathFor(triggersPath)}` };
10409
+ }
10410
+ try {
10411
+ let current = [];
10412
+ try {
10413
+ const raw = JSON.parse(fs.readFileSync(triggersPath, "utf8"));
10414
+ if (Array.isArray(raw?.triggers)) current = raw.triggers;
10415
+ } catch {
10416
+ }
10417
+ const next = mutate(current.map((t) => ({ ...t })));
10418
+ const text = serialize(next);
10419
+ try {
10420
+ parseTriggers(text, triggersPath);
10421
+ } catch (e) {
10422
+ return { invalid: e?.message ?? String(e) };
10423
+ }
10424
+ const tmp = tmpPathFor(triggersPath);
10425
+ try {
10426
+ fs.writeFileSync(tmp, text, { mode: 420 });
10427
+ renameIntoPlace(fs, tmp, triggersPath);
10428
+ } catch (err) {
10429
+ discardTmp(fs, tmp);
10430
+ throw err;
10431
+ }
10432
+ return { ok: true };
10433
+ } finally {
10434
+ releaseLock(fd, triggersPath, fs, log);
10435
+ }
10436
+ }
10437
+ var LOCK_STALE_MS, tmpSeq;
10438
+ var init_triggers_file = __esm({
10439
+ "../worker/src/triggers-file.mjs"() {
10440
+ init_triggers();
10441
+ LOCK_STALE_MS = 1e4;
10442
+ tmpSeq = 0;
10443
+ }
10444
+ });
10445
+
10079
10446
  // src/read-model.mjs
10080
- import * as nodeFs2 from "node:fs";
10447
+ import * as nodeFs3 from "node:fs";
10081
10448
  import { join as join2, delimiter, sep } from "node:path";
10082
10449
  import { execFileSync as execFileSync2 } from "node:child_process";
10083
10450
  function resolvePaths(env = process.env) {
@@ -10091,6 +10458,7 @@ function resolvePaths(env = process.env) {
10091
10458
  // root, and silently wrong (demo triggers) everywhere else.
10092
10459
  triggersPath: env.PI_TRIGGERS_FILE ?? "./triggers.json",
10093
10460
  pauseWindowsPath: env.PI_PAUSE_WINDOWS_FILE ?? "./pause-windows.json",
10461
+ scopedLimitsPath: env.PI_SCOPED_LIMITS_FILE ?? "./scoped-limits.json",
10094
10462
  subscriptionsPath: env.PI_SUBSCRIPTIONS_FILE ?? "./subscriptions.json",
10095
10463
  // The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY), where the staged third-party pi
10096
10464
  // packages live under `packages/`. `|| null` so unset AND empty both read as "no overlay" -- the
@@ -10250,14 +10618,14 @@ function folderUnderRoots(folder, roots) {
10250
10618
  if (!Array.isArray(roots) || roots.length === 0) return false;
10251
10619
  let realTarget;
10252
10620
  try {
10253
- realTarget = nodeFs2.realpathSync(folder);
10621
+ realTarget = nodeFs3.realpathSync(folder);
10254
10622
  } catch {
10255
10623
  return false;
10256
10624
  }
10257
10625
  for (const root of roots) {
10258
10626
  let realRoot;
10259
10627
  try {
10260
- realRoot = nodeFs2.realpathSync(root);
10628
+ realRoot = nodeFs3.realpathSync(root);
10261
10629
  } catch {
10262
10630
  continue;
10263
10631
  }
@@ -10268,7 +10636,7 @@ function folderUnderRoots(folder, roots) {
10268
10636
  function hourKey(nowMs) {
10269
10637
  return `dispatch-run:${new Date(nowMs).toISOString().slice(0, 13).replace("T", "-")}`;
10270
10638
  }
10271
- function writeSettings({ settingsFile, mutate, fs = nodeFs2 }) {
10639
+ function writeSettings({ settingsFile, mutate, fs = nodeFs3 }) {
10272
10640
  const res = readOverlay(settingsFile, { fs });
10273
10641
  const base = res.overlay ?? {};
10274
10642
  const next = mutate({ ...base });
@@ -10276,60 +10644,114 @@ function writeSettings({ settingsFile, mutate, fs = nodeFs2 }) {
10276
10644
  if (w.invalid) return { invalid: w.invalid };
10277
10645
  return res.invalid ? { ok: true, overlay: next, rebuiltFrom: res.invalid } : { ok: true, overlay: next };
10278
10646
  }
10279
- function writeTriggers({ triggersPath, mutate, fs = nodeFs2 }) {
10647
+ function readPauseWindows({ pauseWindowsPath, fs = nodeFs3 }) {
10648
+ let text;
10649
+ try {
10650
+ text = fs.readFileSync(pauseWindowsPath, "utf8");
10651
+ } catch {
10652
+ return { missing: true };
10653
+ }
10654
+ try {
10655
+ return { windows: parsePauseWindows(text, pauseWindowsPath) };
10656
+ } catch (e) {
10657
+ return { invalid: e?.message ?? String(e) };
10658
+ }
10659
+ }
10660
+ function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs3 }) {
10280
10661
  let current = [];
10281
10662
  try {
10282
- const raw = JSON.parse(fs.readFileSync(triggersPath, "utf8"));
10283
- if (Array.isArray(raw?.triggers)) current = raw.triggers;
10663
+ const raw = JSON.parse(fs.readFileSync(pauseWindowsPath, "utf8"));
10664
+ if (Array.isArray(raw?.windows)) current = raw.windows;
10284
10665
  } catch {
10285
10666
  }
10286
- const next = mutate(current.map((t) => ({ ...t })));
10287
- const text = `${JSON.stringify({ triggers: next }, null, 2)}
10667
+ const next = mutate(current.map((w) => ({ ...w })));
10668
+ const text = `${JSON.stringify({ windows: next }, null, 2)}
10288
10669
  `;
10289
10670
  try {
10290
- parseTriggers(text, triggersPath);
10671
+ parsePauseWindows(text, pauseWindowsPath);
10291
10672
  } catch (e) {
10292
10673
  return { invalid: e?.message ?? String(e) };
10293
10674
  }
10294
- const tmp = `${triggersPath}.tmp`;
10675
+ const tmp = `${pauseWindowsPath}.tmp`;
10295
10676
  fs.writeFileSync(tmp, text, { mode: 420 });
10296
- fs.renameSync(tmp, triggersPath);
10677
+ fs.renameSync(tmp, pauseWindowsPath);
10297
10678
  return { ok: true };
10298
10679
  }
10299
- function readPauseWindows({ pauseWindowsPath, fs = nodeFs2 }) {
10680
+ function readScopedLimits({ scopedLimitsPath, fs = nodeFs3 }) {
10300
10681
  let text;
10301
10682
  try {
10302
- text = fs.readFileSync(pauseWindowsPath, "utf8");
10683
+ text = fs.readFileSync(scopedLimitsPath, "utf8");
10303
10684
  } catch {
10304
10685
  return { missing: true };
10305
10686
  }
10306
10687
  try {
10307
- return { windows: parsePauseWindows(text, pauseWindowsPath) };
10688
+ return { limits: parseScopedLimits(text, scopedLimitsPath) };
10308
10689
  } catch (e) {
10309
10690
  return { invalid: e?.message ?? String(e) };
10310
10691
  }
10311
10692
  }
10312
- function writePauseWindows({ pauseWindowsPath, mutate, fs = nodeFs2 }) {
10693
+ function writeScopedLimits({ scopedLimitsPath, mutate, fs = nodeFs3 }) {
10313
10694
  let current = [];
10695
+ let existing = null;
10314
10696
  try {
10315
- const raw = JSON.parse(fs.readFileSync(pauseWindowsPath, "utf8"));
10316
- if (Array.isArray(raw?.windows)) current = raw.windows;
10697
+ existing = fs.readFileSync(scopedLimitsPath, "utf8");
10317
10698
  } catch {
10318
10699
  }
10319
- const next = mutate(current.map((w) => ({ ...w })));
10320
- const text = `${JSON.stringify({ windows: next }, null, 2)}
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)}
10321
10710
  `;
10322
10711
  try {
10323
- parsePauseWindows(text, pauseWindowsPath);
10712
+ parseScopedLimits(text, scopedLimitsPath);
10324
10713
  } catch (e) {
10325
10714
  return { invalid: e?.message ?? String(e) };
10326
10715
  }
10327
- const tmp = `${pauseWindowsPath}.tmp`;
10716
+ const tmp = `${scopedLimitsPath}.tmp`;
10328
10717
  fs.writeFileSync(tmp, text, { mode: 420 });
10329
- fs.renameSync(tmp, pauseWindowsPath);
10718
+ fs.renameSync(tmp, scopedLimitsPath);
10330
10719
  return { ok: true };
10331
10720
  }
10332
- function readSubscriptions({ subscriptionsPath, fs = nodeFs2 }) {
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
+ }
10754
+ function readSubscriptions({ subscriptionsPath, fs = nodeFs3 }) {
10333
10755
  if (subscriptionsPath === null || subscriptionsPath === void 0) return { missing: true };
10334
10756
  let text;
10335
10757
  try {
@@ -10405,13 +10827,13 @@ async function readBudget({ url, redisFn = makeRedisClient, timeoutMs = 2500 } =
10405
10827
  }
10406
10828
  function withTimeout(promise, ms, fallback) {
10407
10829
  let timer;
10408
- const timeout = new Promise((resolve2) => {
10409
- timer = setTimeout(() => resolve2(fallback), ms);
10830
+ const timeout = new Promise((resolve3) => {
10831
+ timer = setTimeout(() => resolve3(fallback), ms);
10410
10832
  if (typeof timer?.unref === "function") timer.unref();
10411
10833
  });
10412
10834
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
10413
10835
  }
10414
- function listRuns({ logsDir, limit = 10, fs = nodeFs2 }) {
10836
+ function listRuns({ logsDir, limit = 10, fs = nodeFs3 }) {
10415
10837
  const cap = clampLimit(limit);
10416
10838
  let names;
10417
10839
  try {
@@ -10429,7 +10851,7 @@ function listRuns({ logsDir, limit = 10, fs = nodeFs2 }) {
10429
10851
  records.sort(byEndedAtDesc);
10430
10852
  return records.slice(0, cap);
10431
10853
  }
10432
- function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs2 }) {
10854
+ function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs3 }) {
10433
10855
  const oldestMs = nowMs - SCAN_WINDOW_MAX_DAYS * 24 * 60 * 60 * 1e3;
10434
10856
  const cutoffMs = Math.max(Number.isFinite(sinceMs) ? sinceMs : oldestMs, oldestMs);
10435
10857
  let names;
@@ -10450,27 +10872,26 @@ function scanRunRecords({ logsDir, sinceMs, nowMs = Date.now(), fs = nodeFs2 })
10450
10872
  }
10451
10873
  return records;
10452
10874
  }
10453
- function readRun({ logsDir, jobId, fs = nodeFs2 }) {
10875
+ function readRun({ logsDir, jobId, fs = nodeFs3 }) {
10454
10876
  return readJsonFile(join2(logsDir, `${sanitizeJobId(jobId)}.json`), fs);
10455
10877
  }
10456
- function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs2 }) {
10878
+ function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs3 }) {
10457
10879
  let text;
10458
10880
  try {
10459
10881
  text = fs.readFileSync(join2(logsDir, `${sanitizeJobId(jobId)}.log`), "utf8");
10460
10882
  } catch {
10461
10883
  return { missing: true };
10462
10884
  }
10463
- const all = text.split("\n");
10464
- if (all.length > 0 && all[all.length - 1] === "") all.pop();
10885
+ const all = text.split("\n").filter((line) => line !== "");
10465
10886
  const cap = clampLines(lines);
10466
10887
  return { lines: all.slice(Math.max(0, all.length - cap)) };
10467
10888
  }
10468
- function readSettingsView({ settingsFile, fs = nodeFs2 }) {
10889
+ function readSettingsView({ settingsFile, fs = nodeFs3 }) {
10469
10890
  const result = readOverlay(settingsFile, { fs });
10470
10891
  if (result.invalid) return { path: settingsFile, invalid: result.invalid };
10471
10892
  return { path: settingsFile, overlay: result.overlay };
10472
10893
  }
10473
- function readTriggers({ triggersPath, fs = nodeFs2 }) {
10894
+ function readTriggers({ triggersPath, fs = nodeFs3 }) {
10474
10895
  let text;
10475
10896
  try {
10476
10897
  text = fs.readFileSync(triggersPath, "utf8");
@@ -10534,13 +10955,17 @@ function normalizeTriggerForDisplay(entry) {
10534
10955
  return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, command, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
10535
10956
  case "comment":
10536
10957
  return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
10537
- case "pull_request":
10958
+ case "pull_request": {
10959
+ const prDisarmed = on.disarmed !== null && typeof on.disarmed === "object" && !Array.isArray(on.disarmed) ? on.disarmed : null;
10538
10960
  return {
10539
10961
  type: "pull_request",
10540
10962
  action: normalizeSelector(on.action),
10541
10963
  any: normalizeSelector(on.any),
10542
10964
  all: normalizeSelector(on.all),
10543
10965
  none: normalizeSelector(on.none),
10966
+ ...Number.isInteger(on.number) && on.number >= 1 && { number: on.number },
10967
+ ...on.once === true && { once: true },
10968
+ ...prDisarmed !== null && { disarmed: prDisarmed },
10544
10969
  flow,
10545
10970
  command,
10546
10971
  packages,
@@ -10553,6 +10978,34 @@ function normalizeTriggerForDisplay(entry) {
10553
10978
  replicas,
10554
10979
  forge
10555
10980
  };
10981
+ }
10982
+ case "issue": {
10983
+ const disarmed = on.disarmed !== null && typeof on.disarmed === "object" && !Array.isArray(on.disarmed) ? on.disarmed : null;
10984
+ return {
10985
+ type: "issue",
10986
+ action: normalizeSelector(on.action),
10987
+ // `null` is the every-item sentinel, this function's own flow/model/phrase convention; the loader
10988
+ // refused anything but an integer >= 1, so the display mirrors rather than re-validates.
10989
+ number: Number.isInteger(on.number) && on.number >= 1 ? on.number : null,
10990
+ // An opt-IN, `=== true` like `resume` above and NOT `!== false`: the rendering mistake that costs
10991
+ // is a one-shot with no marker, never a standing rule with a spurious one.
10992
+ once: on.once === true,
10993
+ // Absent, not null, when unspent: `disarmed` is a mark the worker adds, and a key that is usually
10994
+ // missing reads truer than a fifth null sentinel -- consumers test presence, not value.
10995
+ ...disarmed !== null && { disarmed },
10996
+ flow,
10997
+ command,
10998
+ packages,
10999
+ image,
11000
+ skillsDir,
11001
+ instructions,
11002
+ resume,
11003
+ secrets,
11004
+ secretsProfile,
11005
+ replicas,
11006
+ forge
11007
+ };
11008
+ }
10556
11009
  default:
10557
11010
  return null;
10558
11011
  }
@@ -10561,7 +11014,7 @@ function normalizeSelector(value) {
10561
11014
  if (!Array.isArray(value)) return [];
10562
11015
  return value.filter((member) => typeof member === "string");
10563
11016
  }
10564
- function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest: readManifest2 = readStageManifest } = {}) {
11017
+ function readStagedPackages({ globalPiDir, fs = nodeFs3, readManifest: readManifest2 = readStageManifest } = {}) {
10565
11018
  const empty = { stagedAt: null, packages: [] };
10566
11019
  if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
10567
11020
  let manifest;
@@ -10769,7 +11222,7 @@ function readFolderSkills({ folder, exec: exec4 = execFileSync2 } = {}) {
10769
11222
  }
10770
11223
  return { head, skills, truncated: truncated || anyUnread, unreachable: null };
10771
11224
  }
10772
- function readInjectedSkills({ skillsDir, fs = nodeFs2 } = {}) {
11225
+ function readInjectedSkills({ skillsDir, fs = nodeFs3 } = {}) {
10773
11226
  if (typeof skillsDir !== "string" || skillsDir === "") return { skills: [], truncated: false, unreachable: null };
10774
11227
  let names;
10775
11228
  try {
@@ -10792,7 +11245,7 @@ function readInjectedSkills({ skillsDir, fs = nodeFs2 } = {}) {
10792
11245
  }
10793
11246
  return { skills, truncated, unreachable: null };
10794
11247
  }
10795
- function readOverlaySkills({ globalPiDir, fs = nodeFs2 } = {}) {
11248
+ function readOverlaySkills({ globalPiDir, fs = nodeFs3 } = {}) {
10796
11249
  if (typeof globalPiDir !== "string" || globalPiDir === "") return { skills: [], truncated: false, unreachable: null };
10797
11250
  const dir = join2(globalPiDir, "skills");
10798
11251
  let names;
@@ -10816,7 +11269,7 @@ function readOverlaySkills({ globalPiDir, fs = nodeFs2 } = {}) {
10816
11269
  }
10817
11270
  return { skills, truncated, unreachable: null };
10818
11271
  }
10819
- function readStagedSkillsList({ globalPiDir, fs = nodeFs2, readStaged = readStagedSkills } = {}) {
11272
+ function readStagedSkillsList({ globalPiDir, fs = nodeFs3, readStaged = readStagedSkills } = {}) {
10820
11273
  const empty = { skills: [], unenumerable: [], truncated: false };
10821
11274
  if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
10822
11275
  let result;
@@ -10891,7 +11344,7 @@ function forgeRepoTargets({ records } = {}) {
10891
11344
  function escapeRegExp2(s) {
10892
11345
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10893
11346
  }
10894
- function listRunIds({ logsDir, fs = nodeFs2 }) {
11347
+ function listRunIds({ logsDir, fs = nodeFs3 }) {
10895
11348
  let names;
10896
11349
  try {
10897
11350
  names = fs.readdirSync(logsDir);
@@ -10936,8 +11389,8 @@ var init_read_model = __esm({
10936
11389
  init_runtime_settings();
10937
11390
  init_run_history();
10938
11391
  init_budget();
10939
- init_triggers();
10940
11392
  init_pause_windows();
11393
+ init_scoped_limits();
10941
11394
  init_subscriptions();
10942
11395
  init_connection();
10943
11396
  init_queue();
@@ -10948,6 +11401,7 @@ var init_read_model = __esm({
10948
11401
  init_forges();
10949
11402
  init_graph_model();
10950
11403
  init_costs();
11404
+ init_triggers_file();
10951
11405
  DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
10952
11406
  SCAN_WINDOW_MAX_DAYS = 92;
10953
11407
  GRAPH_LIMITS = Object.freeze({
@@ -11066,6 +11520,12 @@ function normalizeModel(model) {
11066
11520
  package: typeof n.package === "string" ? clip(n.package, 80) : null,
11067
11521
  tiersUnknown: Array.isArray(n.tiersUnknown) ? [...new Set(n.tiersUnknown.filter((t) => typeof t === "string").map((t) => clip(t, 40)))].sort().slice(0, 4) : [],
11068
11522
  replicas: intOr(n.replicas, null),
11523
+ // One-shot facts (#231): `once` is the armed state, and the disarm mark is narrowed to its
11524
+ // instant alone. The mark's jobId stays server-side on purpose: the page answers "spent, and
11525
+ // when", and provenance beyond that belongs to the operator's own console -- a shareable
11526
+ // file:// page should carry no more of a deployment's run history than the question needs.
11527
+ once: n.once === true,
11528
+ disarmed: n.disarmed !== null && n.disarmed !== void 0 && typeof n.disarmed === "object" && !Array.isArray(n.disarmed) ? { at: typeof n.disarmed.at === "string" ? clip(n.disarmed.at, 40) : Number.isFinite(n.disarmed.at) ? n.disarmed.at : null } : null,
11069
11529
  folderKey: typeof n.folderKey === "string" ? n.folderKey : null,
11070
11530
  runs: intOr(n.runs, 0),
11071
11531
  lastOutcome: typeof n.lastOutcome === "string" ? n.lastOutcome : null,
@@ -11510,6 +11970,8 @@ function buildTip(n, flags, groupLabel, nowMs) {
11510
11970
  const nextMs = typeof n.next === "number" ? n.next : typeof n.next === "string" ? Date.parse(n.next) : NaN;
11511
11971
  if (Number.isFinite(nextMs) && Number.isFinite(nowMs) && nextMs > nowMs) lines.push(`next ${relSpan(nextMs - nowMs)}`);
11512
11972
  }
11973
+ if (n.disarmed !== null) lines.push(`one-shot, spent${n.disarmed.at !== null ? ` ${n.disarmed.at}` : ""}`);
11974
+ else if (n.once) lines.push("one-shot (armed)");
11513
11975
  }
11514
11976
  if (n.aiTrigger) lines.push("chainable: ai-trigger allow");
11515
11977
  if (n.kind === "skill" && n.loops.length > 0) {
@@ -11540,6 +12002,7 @@ function nodeState(n, flagNames) {
11540
12002
  }
11541
12003
  if (n.kind === "skill-unverified") return { stroke: PAGE_DIM, dash: "10,4", faded: false };
11542
12004
  if (n.kind === "skill-not-at-head") return { stroke: PAGE_AMBER, dash: "10,4", faded: false };
12005
+ if (n.kind === "trigger" && n.disarmed !== null) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
11543
12006
  if (flagNames.has("orphan")) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
11544
12007
  return { stroke: CHIP_STROKE, dash: null, faded: false };
11545
12008
  }
@@ -11784,6 +12247,7 @@ var init_graph_html = __esm({
11784
12247
  label: "#d8bfd8",
11785
12248
  comment: "#e7e7ae",
11786
12249
  pull_request: "#c0deed",
12250
+ issue: "#c7e9c0",
11787
12251
  skill: "#fdd0a2"
11788
12252
  });
11789
12253
  STATUS_COMPLETED = "#3fb950";
@@ -11813,6 +12277,7 @@ var init_graph_html = __esm({
11813
12277
  label: "\u25C8",
11814
12278
  comment: "\u275D",
11815
12279
  pull_request: "\u21C4",
12280
+ issue: "\u25C9",
11816
12281
  skill: "\u0192",
11817
12282
  "skill-missing": "!",
11818
12283
  "skill-unverified": "?",
@@ -12452,7 +12917,28 @@ function normBudget(v) {
12452
12917
  cap: Number.isInteger(t.cap) && t.cap > 0 ? t.cap : null,
12453
12918
  state: BUDGET_STATES.includes(t.state) ? t.state : "ok",
12454
12919
  maxTokens: Number.isInteger(t.maxTokens) && t.maxTokens > 0 ? t.maxTokens : null
12455
- }
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
12456
12942
  };
12457
12943
  }
12458
12944
  function normSpendMap(v) {
@@ -12824,6 +13310,24 @@ function budgetSectionHtml(nb) {
12824
13310
  if (nb.softHoldPct !== null) {
12825
13311
  rows.push(`<div class="dim small">soft-hold band: ${fmt(nb.softHoldPct)}% of each cap (the amber tick)</div>`);
12826
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
+ }
12827
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>');
12828
13332
  return `<div id="budget">${rows.join("")}</div>`;
12829
13333
  }
@@ -13227,9 +13731,9 @@ var init_open_browser = __esm({
13227
13731
  });
13228
13732
 
13229
13733
  // src/deployment-pointer.mjs
13230
- import * as nodeFs3 from "node:fs";
13734
+ import * as nodeFs4 from "node:fs";
13231
13735
  import { homedir } from "node:os";
13232
- import { join as join3, isAbsolute } from "node:path";
13736
+ import { join as join3, isAbsolute as isAbsolute2 } from "node:path";
13233
13737
  function pointerPath(env = process.env) {
13234
13738
  return env.PI_DISPATCH_DEPLOYMENT_FILE || join3(env.PI_CODING_AGENT_DIR || join3(homedir(), ".pi", "agent"), POINTER_BASENAME);
13235
13739
  }
@@ -13243,7 +13747,7 @@ function normalizePointer(raw) {
13243
13747
  if (raw.version > POINTER_VERSION) {
13244
13748
  return { ignored: `version ${raw.version} is newer than the supported version ${POINTER_VERSION}` };
13245
13749
  }
13246
- if (typeof raw.deploymentDir !== "string" || !isAbsolute(raw.deploymentDir)) {
13750
+ if (typeof raw.deploymentDir !== "string" || !isAbsolute2(raw.deploymentDir)) {
13247
13751
  return { ignored: "deploymentDir must be an absolute path" };
13248
13752
  }
13249
13753
  const rawEnv = raw.env === void 0 ? {} : raw.env;
@@ -13254,12 +13758,12 @@ function normalizePointer(raw) {
13254
13758
  for (const key of POINTER_ENV_ALLOWLIST) {
13255
13759
  const value = rawEnv[key];
13256
13760
  if (typeof value !== "string" || value === "") continue;
13257
- if (key !== "VALKEY_URL" && !isAbsolute(value)) continue;
13761
+ if (key !== "VALKEY_URL" && !isAbsolute2(value)) continue;
13258
13762
  env[key] = value;
13259
13763
  }
13260
13764
  return { pointer: { version: raw.version, deploymentDir: raw.deploymentDir, env } };
13261
13765
  }
13262
- function readPointer({ path = pointerPath(), fs = nodeFs3 } = {}) {
13766
+ function readPointer({ path = pointerPath(), fs = nodeFs4 } = {}) {
13263
13767
  let text;
13264
13768
  try {
13265
13769
  text = fs.readFileSync(path, "utf8");
@@ -13292,12 +13796,12 @@ function layerPointer(env, fs) {
13292
13796
  }
13293
13797
  return { applied };
13294
13798
  }
13295
- function applyDeploymentPointer(env = process.env, { fs = nodeFs3 } = {}) {
13799
+ function applyDeploymentPointer(env = process.env, { fs = nodeFs4 } = {}) {
13296
13800
  if (appliedOnce) return { applied: [] };
13297
13801
  appliedOnce = true;
13298
13802
  return layerPointer(env, fs);
13299
13803
  }
13300
- function reapplyDeploymentPointer(env = process.env, { fs = nodeFs3 } = {}) {
13804
+ function reapplyDeploymentPointer(env = process.env, { fs = nodeFs4 } = {}) {
13301
13805
  appliedOnce = true;
13302
13806
  return layerPointer(env, fs);
13303
13807
  }
@@ -13306,7 +13810,7 @@ function takePointerNotice() {
13306
13810
  notice = void 0;
13307
13811
  return n;
13308
13812
  }
13309
- function writePointer({ path = pointerPath(), pointer, fs = nodeFs3 } = {}) {
13813
+ function writePointer({ path = pointerPath(), pointer, fs = nodeFs4 } = {}) {
13310
13814
  const res = normalizePointer(pointer);
13311
13815
  if (res.ignored) return { invalid: res.ignored };
13312
13816
  const tmp = `${path}.tmp`;
@@ -13325,6 +13829,7 @@ var init_deployment_pointer = __esm({
13325
13829
  "PI_SETTINGS_FILE",
13326
13830
  "PI_TRIGGERS_FILE",
13327
13831
  "PI_PAUSE_WINDOWS_FILE",
13832
+ "PI_SCOPED_LIMITS_FILE",
13328
13833
  "PI_SUBSCRIPTIONS_FILE"
13329
13834
  ]);
13330
13835
  POINTER_BASENAME = "pi-dispatch-deployment.json";
@@ -38869,7 +39374,7 @@ var init_pricing = __esm({
38869
39374
 
38870
39375
  // ../worker/src/sandbox-store.mjs
38871
39376
  import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
38872
- import { isAbsolute as isAbsolute2, join as join5, relative } from "node:path";
39377
+ import { isAbsolute as isAbsolute3, join as join5, relative } from "node:path";
38873
39378
  function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
38874
39379
  if (!sandboxDir || jobId === void 0 || jobId === null) return null;
38875
39380
  const dir = join5(sandboxDir, sanitizeJobId(jobId));
@@ -38942,10 +39447,10 @@ function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, f
38942
39447
  return { manifest, name: sandboxContainerName(jobId), publish };
38943
39448
  }
38944
39449
  function launchSandbox({ args, spawnFn = spawn }) {
38945
- return new Promise((resolve2) => {
39450
+ return new Promise((resolve3) => {
38946
39451
  const child = spawnFn("docker", args, { stdio: "inherit" });
38947
- child.on("error", (err) => resolve2({ code: null, error: err }));
38948
- child.on("close", (code) => resolve2({ code }));
39452
+ child.on("error", (err) => resolve3({ code: null, error: err }));
39453
+ child.on("close", (code) => resolve3({ code }));
38949
39454
  });
38950
39455
  }
38951
39456
  var exec3, SANDBOX_NAME_PREFIX;
@@ -39000,6 +39505,24 @@ function renderBudget({ budget, settings: settings2 } = {}) {
39000
39505
  if (pct !== null) lines.push(` soft-hold band: ${pct}%`);
39001
39506
  return lines.join("\n");
39002
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
+ }
39003
39526
  function capLabel(cap, reserved, pct) {
39004
39527
  if (!Number.isInteger(cap)) return "unknown (worker env/default)";
39005
39528
  const state = windowState(reserved, cap, pct);
@@ -39039,6 +39562,7 @@ function triggerLine(t) {
39039
39562
  const res = t?.resume === true ? " [resume]" : "";
39040
39563
  const rep = t?.replicas > 1 ? ` [x${t.replicas}]` : "";
39041
39564
  const sec = t?.secrets > 0 ? ` [secrets ${t.secrets}${t.secretsProfile ? ` via ${t.secretsProfile}` : ""}]` : "";
39565
+ const shot = t?.disarmed ? " [spent]" : t?.once === true ? " [once]" : "";
39042
39566
  switch (t?.type) {
39043
39567
  case "cron":
39044
39568
  return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}`;
@@ -39049,7 +39573,13 @@ function triggerLine(t) {
39049
39573
  case "pull_request": {
39050
39574
  const clauses = ruleClauses(t);
39051
39575
  const action = `action[${(t.action ?? []).join(",")}]`;
39052
- return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}`;
39576
+ const num = Number.isInteger(t.number) ? ` #${t.number}` : "";
39577
+ return `pull_request ${action}${num}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}${shot}`;
39578
+ }
39579
+ case "issue": {
39580
+ const action = `action[${(t.action ?? []).join(",")}]`;
39581
+ const num = Number.isInteger(t.number) ? ` #${t.number}` : "";
39582
+ return `issue ${action}${num} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}${shot}`;
39053
39583
  }
39054
39584
  default:
39055
39585
  return "(unknown trigger)";
@@ -39360,7 +39890,25 @@ function createDashboardDeps(paths) {
39360
39890
  redis.hgetall(STALL_KEY).catch(() => ({}))
39361
39891
  ]);
39362
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
+ };
39363
39909
  return {
39910
+ scopedLimits,
39911
+ scopedBudget,
39364
39912
  queue: { pausedState, counts, workers },
39365
39913
  budget: { day: Number(dayRaw ?? 0), week: Number(weekRaw ?? 0), month: Number(monthRaw ?? 0), tokensToday: Number(tokenRaw ?? 0) },
39366
39914
  schedulers: mapSchedulers(schedulerList, Date.now()),
@@ -39721,6 +40269,10 @@ function makeDashboard({
39721
40269
  void dispose().finally(() => done({ action: "managePauses" }));
39722
40270
  return;
39723
40271
  }
40272
+ if (data === "m" || data === "M") {
40273
+ void dispose().finally(() => done({ action: "manageLimits" }));
40274
+ return;
40275
+ }
39724
40276
  if (matchesKey(data, "up")) {
39725
40277
  selected = Math.max(0, selected - 1);
39726
40278
  tui?.requestRender?.();
@@ -39824,6 +40376,8 @@ function renderPanel(snapshot, width, state, styler) {
39824
40376
  { title: "RUNS", lines: renderRunList(buildRows(snapshot, runSort), selected, 24) },
39825
40377
  { title: "SETTINGS", lines: toLines(renderSettingsView(snapshot.settings)) }
39826
40378
  ];
40379
+ const scopedText = renderScopedLimits({ limits: snapshot.scopedLimits, scopedBudget: snapshot.scopedBudget });
40380
+ if (scopedText) sections.splice(2, 0, { title: "SCOPED LIMITS", lines: toLines(scopedText) });
39827
40381
  const plain = [title];
39828
40382
  for (const section of sections) plain.push(section.title, ...section.lines);
39829
40383
  plain.push(KEY_HINTS);
@@ -39851,6 +40405,7 @@ function collapseKeys(sections, availableRows, focus, baseRows) {
39851
40405
  function buildListLines(snapshot, selected, inner, styler, runSort = "time", availableRows = null) {
39852
40406
  const trg = triggerLines(snapshot, selected, inner, styler);
39853
40407
  const pw = pauseLines(snapshot.pauseWindows, inner, styler);
40408
+ const sl = limitLines(snapshot.scopedLimits, snapshot.scopedBudget, inner, styler);
39854
40409
  const runRows = buildRows(snapshot, runSort).slice(trg.count);
39855
40410
  const runCount = Array.isArray(snapshot.runs) ? snapshot.runs.length : 0;
39856
40411
  const sections = [
@@ -39858,6 +40413,10 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
39858
40413
  { key: "spend", head: ["spend & limits", "jobs & tokens/day \xB7 s set"], body: spendLines(snapshot.budget, snapshot.settings, inner, styler), priority: 4, viewKey: "s" },
39859
40414
  { key: "triggers", head: ["triggers", `${trg.count} standing \xB7 a add \xB7 \u21B5 open`], body: trg.lines, priority: 3, viewKey: "tab" },
39860
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" },
39861
40420
  { key: "runs", head: ["runs", `last ${runCount} \xB7 o ${runSort}`], body: runLines(runRows, selected - trg.count, inner, styler) },
39862
40421
  { key: "settings", head: ["settings", "s edit"], body: settingsLines(snapshot.settings, inner, styler), priority: 2, viewKey: "s" }
39863
40422
  ];
@@ -39892,7 +40451,8 @@ function statusHeader(queue, inner, styler) {
39892
40451
  const dot = styler.fg(stateColor, "\u25CF");
39893
40452
  const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : "PAUSED"));
39894
40453
  const sep2 = styler.fg("dim", " \xB7 ");
39895
- 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`;
39896
40456
  const clock = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
39897
40457
  const left = `${dot} ${word} ${vitals}`;
39898
40458
  const gap = inner - styler.visibleLen(left) - clock.length;
@@ -39978,8 +40538,9 @@ function triggerRow(t, sel, inner, styler, sched = null) {
39978
40538
  const img = t?.image ? " " + styler.fg("accent", `[${t.image}]`) : "";
39979
40539
  const res = t?.resume === true ? " " + styler.fg("warning", "[resume]") : "";
39980
40540
  const rep = t?.replicas > 1 ? " " + styler.fg("warning", `[x${t.replicas}]`) : "";
40541
+ const shot = t?.disarmed ? " " + styler.fg("dim", "[spent]") : t?.once === true ? " " + styler.fg("accent", "[once]") : "";
39981
40542
  const health = sched && (sched.overdueMs || sched.stallMax > 0 && sched.stalls >= sched.stallMax) ? " " + styler.fg("warning", sched.overdueMs ? "\u26A0 overdue" : "\u26A0 stalled") : "";
39982
- return fitLine(`${cursor} ${badge} ${matchColored(t, styler)} ${targetColored(t, styler)}${pkgs}${img}${res}${rep}${health}`, inner, styler);
40543
+ return fitLine(`${cursor} ${badge} ${matchColored(t, styler)} ${targetColored(t, styler)}${pkgs}${img}${res}${rep}${shot}${health}`, inner, styler);
39983
40544
  }
39984
40545
  function matchColored(t, styler) {
39985
40546
  switch (t?.type) {
@@ -39987,10 +40548,15 @@ function matchColored(t, styler) {
39987
40548
  return styler.fg("text", `${t.id ?? "-"} ${t.pattern ?? "-"}`);
39988
40549
  case "comment":
39989
40550
  return styler.fg("text", `"${t.phrase ?? "-"}"`);
40551
+ case "issue": {
40552
+ const num = Number.isInteger(t.number) ? " " + styler.fg("success", `#${t.number}`) : "";
40553
+ return styler.fg("muted", `[${(t.action ?? []).join(",")}]`) + num;
40554
+ }
39990
40555
  case "label":
39991
40556
  case "pull_request": {
39992
40557
  const parts = [];
39993
40558
  if (t.type === "pull_request") parts.push(styler.fg("muted", `[${(t.action ?? []).join(",")}]`));
40559
+ if (t.type === "pull_request" && Number.isInteger(t.number)) parts.push(styler.fg("success", `#${t.number}`));
39994
40560
  for (const x of t.any ?? []) parts.push(styler.fg("success", x));
39995
40561
  for (const x of t.all ?? []) parts.push(styler.fg("success", `+${x}`));
39996
40562
  for (const x of t.none ?? []) parts.push(styler.fg("error", `!${x}`));
@@ -40041,6 +40607,39 @@ function pauseRow(w, now, inner, styler) {
40041
40607
  if (until) bits.push(styler.fg("warning", `resumes in ${humanizeMs(until - now) || "<1m"}`));
40042
40608
  return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
40043
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
+ }
40044
40643
  function runLines(rows, selected, inner, styler) {
40045
40644
  if (!Array.isArray(rows) || rows.length === 0) return [styler.cell("(no runs)", inner, { color: "dim" })];
40046
40645
  const { top, count } = runsWindow(rows.length, selected);
@@ -40125,11 +40724,17 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
40125
40724
  }
40126
40725
  } else if (t.type === "label" || t.type === "pull_request") {
40127
40726
  if (t.type === "pull_request") out.push(kv("PR actions", (t.action ?? []).join(", ") || "-"));
40727
+ if (t.type === "pull_request" && Number.isInteger(t.number)) out.push(kv("item", `#${t.number}`, "success"));
40728
+ if (t.once === true) out.push(kv("one-shot", t.disarmed ? `spent ${t.disarmed.at ?? "-"}${t.disarmed.jobId ? ` by ${t.disarmed.jobId}` : ""}` : "armed", t.disarmed ? "dim" : "accent"));
40128
40729
  out.push(kv("any of", (t.any ?? []).join(" \xB7 ") || "-", "success"));
40129
40730
  out.push(kv("all of", (t.all ?? []).join(" \xB7 ") || "-", "success"));
40130
40731
  out.push(kv("none of", (t.none ?? []).join(" \xB7 ") || "-", "error"));
40131
40732
  } else if (t.type === "comment") {
40132
40733
  out.push(kv("phrase", `"${t.phrase ?? "-"}"`));
40734
+ } else if (t.type === "issue") {
40735
+ out.push(kv("issue actions", (t.action ?? []).join(", ") || "-"));
40736
+ if (Number.isInteger(t.number)) out.push(kv("item", `#${t.number}`, "success"));
40737
+ if (t.once === true) out.push(kv("one-shot", t.disarmed ? `spent ${t.disarmed.at ?? "-"}${t.disarmed.jobId ? ` by ${t.disarmed.jobId}` : ""}` : "armed", t.disarmed ? "dim" : "accent"));
40133
40738
  }
40134
40739
  blank();
40135
40740
  section("runs");
@@ -40193,33 +40798,38 @@ function trustModel(t) {
40193
40798
  }
40194
40799
  const subject = t?.type === "comment" ? "adversarial comment text" : "adversarial issue/PR text";
40195
40800
  const where = `lives in the receiver \xB7 task is ${subject}`;
40801
+ const closeCapable = t?.type === "issue" || t?.type === "pull_request" && (t?.action ?? []).some((a) => a === "closed" || a === "close");
40802
+ const oneShot = (lines) => closeCapable && (t?.once === true || t?.disarmed) ? [...lines, "one-shot: the WORKER writes on.disarmed into triggers.json, strictly after the run record \u2014 spent always has its run"] : lines;
40196
40803
  switch (t?.forge) {
40197
40804
  case "gitlab":
40198
- return [
40805
+ return oneShot([
40199
40806
  "authorized by the actor's resolved project access level (>= Developer) \u2014 a label is NOT approval here",
40200
40807
  "HMAC over the body (19.0+) or a bare X-Gitlab-Token \xB7 dedup by webhook-id",
40201
40808
  where
40202
- ];
40809
+ ]);
40203
40810
  case "forgejo":
40204
- return [
40811
+ return oneShot([
40205
40812
  "authorized by the actor's resolved repository permission (admin|write), on every trigger type",
40206
40813
  "HMAC over the body (GitHub-compatible) \xB7 dedup by X-GitHub-Delivery GUID",
40207
40814
  where
40208
- ];
40815
+ ]);
40209
40816
  case "azure":
40210
- return [
40817
+ return oneShot([
40211
40818
  "authorized by the actor's resolved project membership \u2014 work items name the actor only by email",
40212
40819
  "NO HMAC: a shared secret that covers no bytes \xB7 dedup by the payload's own id (OQ-015)",
40213
40820
  where
40214
- ];
40821
+ ]);
40215
40822
  default:
40216
- return [
40823
+ return oneShot([
40217
40824
  // A review trigger reads the REVIEWER's author_association, not the PR author's (issue #66), so it
40218
40825
  // cannot share the generic line: an operator reading "author gate" would picture the wrong person.
40219
- t?.type === "comment" ? "authorized by a collaborator comment (author_association) + HMAC" : t?.type === "pull_request" && (t?.action ?? []).includes("review_submitted") ? "authorized by the REVIEWER's author_association (not the PR author's) + HMAC" : "authorized by a collaborator's label + HMAC webhook + author gate",
40826
+ // A close rule reads the CLOSER's resolved permission (issue #231), for the inversion's sharper
40827
+ // sibling: GitHub sends NO association for a close at all, so the collaborator-label line would
40828
+ // name a gate this rule never runs — and a wrong trust model is worse than an absent one.
40829
+ closeCapable ? "authorized by the CLOSER's resolved repository permission (admin|write) + HMAC \u2014 a close names no author_association" : t?.type === "comment" ? "authorized by a collaborator comment (author_association) + HMAC" : t?.type === "pull_request" && (t?.action ?? []).includes("review_submitted") ? "authorized by the REVIEWER's author_association (not the PR author's) + HMAC" : "authorized by a collaborator's label + HMAC webhook + author gate",
40220
40830
  "dedup by X-GitHub-Delivery GUID (redelivery-safe)",
40221
40831
  where
40222
- ];
40832
+ ]);
40223
40833
  }
40224
40834
  }
40225
40835
  function triggerDetailHints(inner, styler, pendingDelete = false) {
@@ -40447,6 +41057,7 @@ var init_dashboard = __esm({
40447
41057
  init_scheduler_stall_guard();
40448
41058
  init_pause_windows();
40449
41059
  init_read_model();
41060
+ init_scoped_limits();
40450
41061
  init_render();
40451
41062
  init_keys2();
40452
41063
  init_panel();
@@ -40460,7 +41071,7 @@ var init_dashboard = __esm({
40460
41071
  TAIL_VIEWPORT = 20;
40461
41072
  MIN_WIDTH2 = 8;
40462
41073
  DRILL_WIDTH = 70;
40463
- KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction" };
41074
+ KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction", issue: "syntaxString" };
40464
41075
  KIND_WIDTH = 13;
40465
41076
  FRAME_CHROME_ROWS = 4;
40466
41077
  }
@@ -40485,7 +41096,7 @@ __export(setup_wizard_exports, {
40485
41096
  runSetupWizard: () => runSetupWizard,
40486
41097
  runtimeDirFor: () => runtimeDirFor
40487
41098
  });
40488
- import * as nodeFs4 from "node:fs";
41099
+ import * as nodeFs5 from "node:fs";
40489
41100
  import { execFileSync as execFileSync3, spawn as nodeSpawn2 } from "node:child_process";
40490
41101
  import { homedir as homedir2 } from "node:os";
40491
41102
  import { join as join6 } from "node:path";
@@ -40501,7 +41112,7 @@ async function defaultProbeQueue(url) {
40501
41112
  async function detectDeployment({
40502
41113
  env = process.env,
40503
41114
  cwd = process.cwd(),
40504
- fs = nodeFs4,
41115
+ fs = nodeFs5,
40505
41116
  probeQueue = defaultProbeQueue
40506
41117
  } = {}) {
40507
41118
  const pointerRes = readPointer({ path: pointerPath(env), fs });
@@ -40535,16 +41146,16 @@ async function runAttached(ctx, { title, argv0, args, cwd, env, shell, spawnFn =
40535
41146
  ${title}
40536
41147
 
40537
41148
  `);
40538
- outcome = await new Promise((resolve2) => {
41149
+ outcome = await new Promise((resolve3) => {
40539
41150
  let child;
40540
41151
  try {
40541
41152
  child = spawnFn(argv0, args, { stdio: "inherit", cwd, env, ...shell ? { shell: true } : {} });
40542
41153
  } catch (err) {
40543
- resolve2({ code: null, error: err });
41154
+ resolve3({ code: null, error: err });
40544
41155
  return;
40545
41156
  }
40546
- child.on("error", (err) => resolve2({ code: null, error: err }));
40547
- child.on("close", (code) => resolve2({ code }));
41157
+ child.on("error", (err) => resolve3({ code: null, error: err }));
41158
+ child.on("close", (code) => resolve3({ code }));
40548
41159
  });
40549
41160
  } catch (err) {
40550
41161
  outcome = { code: null, error: err };
@@ -40628,7 +41239,7 @@ async function ensureDocker(ui, notify, { platform, probeDockerFn }) {
40628
41239
  function npmSpawnOptions(platform, dir) {
40629
41240
  return platform === "win32" ? { bin: "npm.cmd", options: { cwd: dir, shell: true } } : { bin: "npm", options: { cwd: dir } };
40630
41241
  }
40631
- function listRepoSkills(cwd, fs = nodeFs4) {
41242
+ function listRepoSkills(cwd, fs = nodeFs5) {
40632
41243
  try {
40633
41244
  const root = join6(cwd, ".pi", "skills");
40634
41245
  return fs.readdirSync(root).filter((name) => SKILL_NAME_RE.test(name) && fs.existsSync(join6(root, name, "SKILL.md")));
@@ -40656,7 +41267,7 @@ function ensureDeploymentPackageJson(fs, dir) {
40656
41267
  }
40657
41268
  async function runSetupWizard(paths, ctx, notify, deps = {}) {
40658
41269
  const {
40659
- fs = nodeFs4,
41270
+ fs = nodeFs5,
40660
41271
  env = process.env,
40661
41272
  platform = process.platform,
40662
41273
  execPath = process.execPath,
@@ -40763,6 +41374,7 @@ up shows each docker action and asks y/N before it \u2014 nothing is auto-accept
40763
41374
  env: {
40764
41375
  PI_TRIGGERS_FILE: join6(dir, "triggers.json"),
40765
41376
  PI_PAUSE_WINDOWS_FILE: join6(dir, "pause-windows.json"),
41377
+ PI_SCOPED_LIMITS_FILE: join6(dir, "scoped-limits.json"),
40766
41378
  PI_SUBSCRIPTIONS_FILE: join6(dir, "subscriptions.json")
40767
41379
  }
40768
41380
  };
@@ -40970,7 +41582,7 @@ async function offerFirstTrigger(repoCwd, deployDir, ui, notify, { fs, listRepoS
40970
41582
  );
40971
41583
  }
40972
41584
  function registerNudge(pi, deps = {}) {
40973
- const { fs = nodeFs4, env = process.env, homedirFn = homedir2 } = deps;
41585
+ const { fs = nodeFs5, env = process.env, homedirFn = homedir2 } = deps;
40974
41586
  pi.on("session_start", (event, ctx) => {
40975
41587
  try {
40976
41588
  if (event?.reason !== "startup" || !ctx?.hasUI) return;
@@ -40995,8 +41607,8 @@ var init_setup_wizard = __esm({
40995
41607
  init_deployment_pointer();
40996
41608
  init_read_model();
40997
41609
  init_index();
40998
- RUNTIME_VERSION = "1.3.0";
40999
- RECEIVER_VERSION = "1.2.0";
41610
+ RUNTIME_VERSION = "1.5.0";
41611
+ RECEIVER_VERSION = "1.3.0";
41000
41612
  RUNTIME_PKG = "@edgehero/pi-dispatch";
41001
41613
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
41002
41614
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -41112,7 +41724,7 @@ var init_secrets_command = __esm({
41112
41724
  // src/index.ts
41113
41725
  import { VERSION } from "@earendil-works/pi-coding-agent";
41114
41726
  import { fileURLToPath as fileURLToPath2, pathToFileURL } from "node:url";
41115
- import * as nodeFs5 from "node:fs";
41727
+ import * as nodeFs6 from "node:fs";
41116
41728
  function computePiVersionAdvisory(version, supported) {
41117
41729
  if (version === supported || version === "0.0.0") return void 0;
41118
41730
  return `pi-dispatch admin: running on pi ${version}, tested with pi ${supported} \u2014 things should work; report anything odd, and check for a newer @edgehero/pi-dispatch-admin`;
@@ -41190,15 +41802,15 @@ function registerTools(pi) {
41190
41802
  pi.registerTool({
41191
41803
  name: "dispatch_costs",
41192
41804
  label: "pi-dispatch costs",
41193
- 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.",
41194
- 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()) }),
41195
41807
  async execute(_toolCallId, params) {
41196
41808
  const window = params.window ?? "mtd";
41197
41809
  if (!COSTS_WINDOWS.includes(window)) {
41198
41810
  throw new Error(`unknown window '${window}' (7d|30d|mtd)`);
41199
41811
  }
41200
41812
  const paths = resolvePaths(process.env);
41201
- const res = assembleCosts(paths, window, params.flow);
41813
+ const res = assembleCosts(paths, window, params.flow, params.repo);
41202
41814
  if (res.unreachable) throw new Error(`could not read the run history: ${res.unreachable}`);
41203
41815
  return toolText(JSON.stringify({ window, fold: res.fold }));
41204
41816
  }
@@ -41254,7 +41866,7 @@ function registerTools(pi) {
41254
41866
  pi.registerTool({
41255
41867
  name: "dispatch_triggers",
41256
41868
  label: "pi-dispatch triggers",
41257
- description: "Read-only. Lists the configured triggers as `{ index, ...trigger }` entries. Use the `index` to target a specific trigger with dispatch_trigger_edit or dispatch_trigger_delete.",
41869
+ description: "Read-only. Lists the configured triggers as `{ index, ...trigger }` entries. Use the `index` to target a specific trigger with dispatch_trigger_edit or dispatch_trigger_delete. A close-capable entry (kind issue, or a pull_request on the forge's close word) may carry `on.number` (narrowed to one item), `on.once` (a one-shot), and `on.disarmed` { at, jobId? } \u2014 the worker's mark that the one-shot fired; a disarmed entry still lists here but matches nothing until an operator deletes the key to re-arm it.",
41258
41870
  parameters: typebox_exports.Object({}),
41259
41871
  async execute() {
41260
41872
  const paths = resolvePaths(process.env);
@@ -41299,7 +41911,7 @@ function registerTools(pi) {
41299
41911
  pi.registerTool({
41300
41912
  name: "dispatch_trigger_add",
41301
41913
  label: "pi-dispatch add trigger",
41302
- description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved, forgejo is label_updated|opened|synchronized|reopened, azure is created|updated. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
41914
+ description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request|issue. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. issue fires when an ISSUE closes: it needs flow, its action[] defaults to the forge's close word (github/forgejo closed, gitlab close; azure has no close trigger), and it may set `number` (the forge's own item number, narrowing the rule to that one issue) and `once: true` (a one-shot: it fires once, the run is recorded, then the worker disarms the entry by writing on.disarmed; once requires number). A close-only pull_request rule accepts the same number/once narrowing. Webhook triggers take an optional `forge` = github (default) | gitlab | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted|closed, gitlab is open|update|reopen|approved|close, forgejo is label_updated|opened|synchronized|reopened|closed, azure is created|updated (no close word). The close word rides alone: it cannot be mixed with other actions in one entry. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
41303
41915
  executionMode: "sequential",
41304
41916
  parameters: typebox_exports.Object({
41305
41917
  kind: typebox_exports.String(),
@@ -41316,13 +41928,18 @@ function registerTools(pi) {
41316
41928
  phrase: typebox_exports.Optional(typebox_exports.String()),
41317
41929
  labels: typebox_exports.Optional(typebox_exports.Array(typebox_exports.String())),
41318
41930
  action: typebox_exports.Optional(typebox_exports.Array(typebox_exports.String())),
41931
+ // The close-trigger narrowings (issue #231), read only by the close-capable arms of
41932
+ // buildTriggerEntry and refused fail-loud by the shared validator everywhere else -- the
41933
+ // `repository` posture above, restated for the two fields whose whole point is narrowing spend.
41934
+ number: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
41935
+ once: typebox_exports.Optional(typebox_exports.Boolean()),
41319
41936
  model: typebox_exports.Optional(typebox_exports.String()),
41320
41937
  provider: typebox_exports.Optional(typebox_exports.String()),
41321
41938
  maxTurns: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
41322
41939
  }),
41323
41940
  async execute(_id, params, _signal, _onUpdate, ctx) {
41324
41941
  const entry = buildTriggerEntry(params.kind, params);
41325
- if (!entry) throw new Error(`unknown trigger kind '${params.kind}' (cron|label|comment|pull_request)`);
41942
+ if (!entry) throw new Error(`unknown trigger kind '${params.kind}' (cron|label|comment|pull_request|issue)`);
41326
41943
  const result = await confirmedWrite(
41327
41944
  ctx,
41328
41945
  { title: `Add ${params.kind} trigger`, message: `Add to triggers.json:
@@ -41475,6 +42092,113 @@ ${JSON.stringify(before)}
41475
42092
  return toolText(JSON.stringify(result));
41476
42093
  }
41477
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
+ });
41478
42202
  pi.registerTool({
41479
42203
  name: "dispatch_trigger_delete",
41480
42204
  label: "pi-dispatch delete trigger",
@@ -41533,6 +42257,17 @@ function buildTriggerEntry(kind, f) {
41533
42257
  const on = { type: "pull_request", action: asWords(f.action) };
41534
42258
  const any = asWords(f.labels ?? f.any);
41535
42259
  if (any.length > 0) on.any = any;
42260
+ const prNumber = optInt(f.number);
42261
+ if (prNumber !== void 0) on.number = prNumber;
42262
+ if (typeof f.once === "boolean") on.once = f.once;
42263
+ return { on, run: { kind: forge, flow: f.flow } };
42264
+ }
42265
+ if (kind === "issue") {
42266
+ const action = asWords(f.action);
42267
+ const on = { type: "issue", action: action.length > 0 ? action : [ISSUE_CLOSE_WORD[forge] ?? "closed"] };
42268
+ const number = optInt(f.number);
42269
+ if (number !== void 0) on.number = number;
42270
+ if (typeof f.once === "boolean") on.once = f.once;
41536
42271
  return { on, run: { kind: forge, flow: f.flow } };
41537
42272
  }
41538
42273
  return null;
@@ -41549,6 +42284,22 @@ function buildPauseWindow(f) {
41549
42284
  if (dateTo) w.dateTo = dateTo;
41550
42285
  return w;
41551
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
+ }
41552
42303
  function asWords(x) {
41553
42304
  if (Array.isArray(x)) return x.map((s) => String(s).trim()).filter(Boolean);
41554
42305
  return splitWords(x);
@@ -41584,7 +42335,7 @@ async function dispatch(pi, args, ctx) {
41584
42335
  if (det.state === "pointer" && !runtimeSkewNoticed) {
41585
42336
  const ptrRes = readPointer({ path: pointerPath(process.env) });
41586
42337
  const deploymentDir = ptrRes.pointer?.deploymentDir;
41587
- const installed = deploymentDir ? readInstalledVersion2(nodeFs5, runtimeDirFor2(deploymentDir)) : void 0;
42338
+ const installed = deploymentDir ? readInstalledVersion2(nodeFs6, runtimeDirFor2(deploymentDir)) : void 0;
41588
42339
  if (typeof installed === "string" && installed !== RUNTIME_VERSION2) {
41589
42340
  runtimeSkewNoticed = true;
41590
42341
  notify?.(
@@ -41618,7 +42369,11 @@ ${renderBudget({ budget, settings: settings2 })}`);
41618
42369
  case "budget": {
41619
42370
  const budget = await readBudget({ url: paths.valkeyUrl });
41620
42371
  const settings2 = readSettingsView({ settingsFile: paths.settingsFile });
41621
- 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}` : ""}`);
41622
42377
  return;
41623
42378
  }
41624
42379
  case "triggers": {
@@ -41699,13 +42454,14 @@ ${renderBudget({ budget, settings: settings2 })}`);
41699
42454
  return;
41700
42455
  }
41701
42456
  }
41702
- function assembleCosts(paths, window, flow) {
42457
+ function assembleCosts(paths, window, flow, repo) {
41703
42458
  const nowMs = Date.now();
41704
42459
  const sinceMs = costsSinceMs(window, nowMs);
41705
42460
  const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs, nowMs });
41706
42461
  if (!Array.isArray(records)) return records;
41707
42462
  const subs = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
41708
- 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;
41709
42465
  const triggersView = readTriggers({ triggersPath: paths.triggersPath });
41710
42466
  const triggerJoin = attributeRunsToTriggers({ records: scoped, triggers: Array.isArray(triggersView?.triggers) ? triggersView.triggers : [] });
41711
42467
  const fold = foldCosts({
@@ -41743,7 +42499,7 @@ async function assembleGraph(paths) {
41743
42499
  });
41744
42500
  }
41745
42501
  function realArtifactDeps() {
41746
- return { fs: nodeFs5, openBrowser, env: process.env, now: () => Date.now(), platform: process.platform };
42502
+ return { fs: nodeFs6, openBrowser, env: process.env, now: () => Date.now(), platform: process.platform };
41747
42503
  }
41748
42504
  function isHeadlessEnv(env, platform) {
41749
42505
  if (env?.SSH_CONNECTION || env?.SSH_TTY) return "SSH session";
@@ -41768,13 +42524,27 @@ async function assembleBudgetView(paths) {
41768
42524
  const tokensSpent = unreachable === null ? Number(raw.tokensToday ?? 0) : null;
41769
42525
  const tokenCap = posIntOrNull(overlay.dailyTokenCap);
41770
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
+ });
41771
42539
  return {
41772
42540
  unreachable,
41773
42541
  softHoldPct: pct,
41774
42542
  day: slotWindow("day", "dailyCap"),
41775
42543
  week: slotWindow("week", "weeklyCap"),
41776
42544
  month: slotWindow("month", "monthlyCap"),
41777
- 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
41778
42548
  };
41779
42549
  }
41780
42550
  async function assembleInsights(paths, window) {
@@ -41941,7 +42711,7 @@ could not start docker: ${error.message}
41941
42711
  }
41942
42712
  }
41943
42713
  function pauseForMessage() {
41944
- return new Promise((resolve2) => setTimeout(resolve2, 2500));
42714
+ return new Promise((resolve3) => setTimeout(resolve3, 2500));
41945
42715
  }
41946
42716
  function splitWords(s) {
41947
42717
  return String(s ?? "").split(/\s+/).map((w) => w.trim()).filter(Boolean);
@@ -41967,7 +42737,83 @@ async function handleDashboardAction(result, paths, ctx) {
41967
42737
  return editSettingsViaDialogs(paths, ui, notify);
41968
42738
  case "managePauses":
41969
42739
  return managePausesViaDialogs(paths, ui, notify);
42740
+ case "manageLimits":
42741
+ return manageLimitsViaDialogs(paths, ui, notify);
42742
+ }
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;
41970
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");
41971
42817
  }
41972
42818
  async function managePausesViaDialogs(paths, ui, notify) {
41973
42819
  const action = await ui.select("Pause windows", ["Add a pause window", "Edit a pause window", "Delete a pause window"]);
@@ -42269,7 +43115,7 @@ function completeArguments(prefix) {
42269
43115
  }
42270
43116
  return null;
42271
43117
  }
42272
- var USED_API, SUPPORTED_PI_VERSION, piVersionAdvisory, piVersionCheckDone, runtimeSkewNoticed, CHANNEL, REBUILT_NOTICE, USAGE, KNOWN_SUBCOMMANDS, FORGE_PROMPT, PR_ACTION_VOCAB, PRICING, INSIGHTS_USAGE, VIEWPORT_LINES;
43118
+ var USED_API, SUPPORTED_PI_VERSION, piVersionAdvisory, piVersionCheckDone, runtimeSkewNoticed, CHANNEL, REBUILT_NOTICE, USAGE, KNOWN_SUBCOMMANDS, FORGE_PROMPT, ISSUE_CLOSE_WORD, PR_ACTION_VOCAB, PRICING, INSIGHTS_USAGE, VIEWPORT_LINES;
42273
43119
  var init_index = __esm({
42274
43120
  "src/index.ts"() {
42275
43121
  init_build();
@@ -42312,10 +43158,14 @@ var init_index = __esm({
42312
43158
  "secrets"
42313
43159
  ];
42314
43160
  FORGE_PROMPT = "forge \u2014 github, gitlab, forgejo or azure";
43161
+ ISSUE_CLOSE_WORD = { github: "closed", gitlab: "close", forgejo: "closed" };
42315
43162
  PR_ACTION_VOCAB = {
42316
- github: { hint: "labeled opened synchronize reopened review_submitted", dflt: "labeled" },
42317
- gitlab: { hint: "open update reopen approved", dflt: "update" },
42318
- forgejo: { hint: "label_updated opened synchronized reopened", dflt: "label_updated" },
43163
+ // The close words ride the hint too (issue #231): the dialog passes whatever is typed through the
43164
+ // shared validator, so a close-only rule IS authorable here, and a hint that omits the word reads
43165
+ // as the word not existing. The loader refuses a list mixing a close word with any other action.
43166
+ github: { hint: "labeled opened synchronize reopened review_submitted closed", dflt: "labeled" },
43167
+ gitlab: { hint: "open update reopen approved close", dflt: "update" },
43168
+ forgejo: { hint: "label_updated opened synchronized reopened closed", dflt: "label_updated" },
42319
43169
  azure: { hint: "created updated", dflt: "updated" }
42320
43170
  };
42321
43171
  PRICING = { listPricedModels, getPricedModel, isZeroRated, reprice, piAiVersion };