cornfield 0.8.0 → 0.9.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.
@@ -118,7 +118,8 @@ export const CREDENTIAL_KEY_SOURCE = `(?:${['token', 'secret', 'passw', 'credent
118
118
  const EDGE = '(?<![A-Za-z0-9])';
119
119
 
120
120
  /**
121
- * A value shaped like a credential: a forge or cloud token, a JWT, a private key, a bearer header, a user and password in a link.
121
+ * A value shaped like a credential: a forge or cloud token, cornfield's own API tokens (`cf_rt_…` review, `cf_at_…` agent), a JWT, a
122
+ * private key, a bearer header, a user and password in a link.
122
123
  * Prose that only talks about tokens and passwords passes; `sk-` keys and bearer values need a digit in their first 41
123
124
  * characters, as real ones have, so a long hyphenated word after "Bearer" or "sk-" is not taken for one. Each alternative tries a
124
125
  * bounded stretch from each place it may start, so a 20000 character brief is read in linear time whatever it holds.
@@ -128,6 +129,7 @@ export const CREDENTIAL_VALUE_SOURCE = [
128
129
  `${EDGE}github_pat_[A-Za-z0-9_]{20,}`,
129
130
  `${EDGE}glpat-[A-Za-z0-9_-]{15,}`,
130
131
  `${EDGE}gto-[A-Za-z0-9]{20,}`,
132
+ `${EDGE}cf_(?:rt|at)_[A-Za-z0-9_-]{20,}`,
131
133
  `${EDGE}sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]`,
132
134
  `${EDGE}AKIA[0-9A-Z]{16}`,
133
135
  `${EDGE}AIza[0-9A-Za-z_-]{35}`,
@@ -624,7 +626,8 @@ export function runBlocksOf(comments, { trust, profiles } = /** @type {any} */ (
624
626
  const by = c.author?.login ?? null;
625
627
  const comment = { id: String(c.id), url: c.url ?? null };
626
628
  for (const r of read.refused) out.refused.push({ ...r, by, comment });
627
- const edited = c.updatedAt !== undefined && Date.parse(c.updatedAt) - Date.parse(c.createdAt) > EDIT_GRACE_MS;
629
+ // A time that does not parse is not taken for "never edited": only a comment the forge says it left alone counts
630
+ const edited = c.updatedAt !== undefined && !(Date.parse(c.updatedAt) - Date.parse(c.createdAt) <= EDIT_GRACE_MS);
628
631
  for (const b of read.blocks) {
629
632
  const found = { ...b, by, at: c.createdAt, comment };
630
633
  if (by === null || !trust(by, b)) out.ignored.push({ ...found, why: 'author' });
@@ -57,7 +57,7 @@
57
57
  "type": "string",
58
58
  "maxLength": 64,
59
59
  "not": {
60
- "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
60
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])cf_(?:rt|at)_[A-Za-z0-9_-]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
61
61
  },
62
62
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
63
63
  "description": "A name the runner maps to a model, a machine and a budget. When the developer configured a list, only those names."
@@ -75,7 +75,7 @@
75
75
  "type": "string",
76
76
  "maxLength": 120,
77
77
  "not": {
78
- "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
78
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])cf_(?:rt|at)_[A-Za-z0-9_-]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
79
79
  },
80
80
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$"
81
81
  },
@@ -118,7 +118,7 @@
118
118
  "type": "string",
119
119
  "maxLength": 200,
120
120
  "not": {
121
- "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
121
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])cf_(?:rt|at)_[A-Za-z0-9_-]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
122
122
  },
123
123
  "minLength": 1,
124
124
  "description": "A hint: a safe git ref name.",
@@ -137,7 +137,7 @@
137
137
  "type": "string",
138
138
  "maxLength": 20000,
139
139
  "not": {
140
- "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
140
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])cf_(?:rt|at)_[A-Za-z0-9_-]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
141
141
  }
142
142
  },
143
143
  "limits": {
@@ -165,7 +165,7 @@
165
165
  "type": "string",
166
166
  "maxLength": 200,
167
167
  "not": {
168
- "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
168
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])cf_(?:rt|at)_[A-Za-z0-9_-]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
169
169
  },
170
170
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$"
171
171
  }
@@ -174,7 +174,7 @@
174
174
  "type": "string",
175
175
  "maxLength": 40,
176
176
  "not": {
177
- "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
177
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])cf_(?:rt|at)_[A-Za-z0-9_-]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
178
178
  },
179
179
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$"
180
180
  },
@@ -205,14 +205,14 @@
205
205
  "type": "string",
206
206
  "maxLength": 4000,
207
207
  "not": {
208
- "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
208
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])cf_(?:rt|at)_[A-Za-z0-9_-]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
209
209
  }
210
210
  },
211
211
  "url": {
212
212
  "type": "string",
213
213
  "maxLength": 2000,
214
214
  "not": {
215
- "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
215
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])cf_(?:rt|at)_[A-Za-z0-9_-]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
216
216
  },
217
217
  "pattern": "^https?://[^\\s/@?#]+([/?#]\\S*)?$",
218
218
  "description": "Where the runner keeps its logs.",
@@ -250,7 +250,7 @@
250
250
  "type": "string",
251
251
  "maxLength": 4000,
252
252
  "not": {
253
- "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
253
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])cf_(?:rt|at)_[A-Za-z0-9_-]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
254
254
  }
255
255
  }
256
256
  }
package/core/config.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * },
14
14
  * forge: { type: 'github', url: 'https://github.com', repo: 'owner/name' }, // or type 'forgejo'; mergeMethod: 'merge' | 'squash' | 'rebase'; baseBranch: 'main'
15
15
  * deploys: { workflows: ['*deploy*'], restarts: { 'Deploy web': ['stack-web'] } }, // which workflow runs are deploys, and what they restart (docs/deploys.md)
16
- * agents: { authors: [], labels: [], branchPrefixes: [], bots: true }, // what marks work as made by an agent (docs/agents.md)
16
+ * agents: { authors: [], labels: [], branchPrefixes: [], bots: true }, // what marks work as made by an agent (docs/agents.md); and how runs are asked for and followed: transport, maxOpen, profiles, runners, quietAfter, limits (docs/config.md#agents)
17
17
  * port: 3350,
18
18
  * host: '127.0.0.1', // loopback, unless sign-in is configured with an https publicUrl (server/bind.js)
19
19
  * publicUrl: null, // the origin people open it at; sign-in needs it (server/auth.js)
@@ -40,6 +40,7 @@ import fs from 'node:fs';
40
40
  import os from 'node:os';
41
41
  import path from 'node:path';
42
42
  import { pathToFileURL, fileURLToPath } from 'node:url';
43
+ import { PROFILE_SOURCE, MAX_NUMBER } from './agent-contract.js';
43
44
 
44
45
  export const CONFIG_FILE = 'cornfield.config.js';
45
46
  export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -67,7 +68,7 @@ export const FORGE_TYPES = ['github', 'forgejo'];
67
68
  /** How a pull request cornfield lands is merged (`forge.mergeMethod`): the way the repo lands its pull requests */
68
69
  export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
69
70
  /** The events a hook may ask for (`hooks[].events`), and what each one means: docs/config.md#hooks */
70
- export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring', 'thread.opened', 'thread.message', 'thread.linked']);
71
+ export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring', 'thread.opened', 'thread.message', 'thread.linked', 'agent.requested', 'run.claimed', 'run.state', 'run.stopRequested', 'run.finished']);
71
72
  /** The events that come from looking at threads (server/threads.js): a poll that wants any of them looks at threads too */
72
73
  export const THREAD_EVENTS = Object.freeze(['thread.opened', 'thread.message', 'thread.linked']);
73
74
  /** The keys of `threads` when it is an object, and the label's longest length */
@@ -115,6 +116,25 @@ export function specPollMs(config) {
115
116
  return wanted && every !== false ? parseDuration(every ?? DEFAULTS.hookPoll.specs) : null;
116
117
  }
117
118
 
119
+ /** How a request reaches a runner: the forge only for now (spec 0005 D2; push and pull come with #81) */
120
+ export const AGENT_TRANSPORTS = Object.freeze(['forge']);
121
+ /** `agents.maxOpen`: the most open runs per repo cornfield will ask for, by default and at most */
122
+ export const DEFAULT_MAX_OPEN = 3;
123
+ export const MAX_OPEN_CEILING = 100;
124
+ /** `agents.quietAfter`: how long a run may show no report, push or pull request activity before it is quiet; its bounds in milliseconds */
125
+ export const DEFAULT_QUIET_AFTER = '24h';
126
+ export const QUIET_MIN_MS = 5 * 60_000;
127
+ export const QUIET_MAX_MS = 30 * 24 * 3_600_000;
128
+ /** The most profiles and runners a config lists */
129
+ export const MAX_PROFILES = 20;
130
+ export const MAX_RUNNERS = 50;
131
+
132
+ /**
133
+ * How long a run may be silent before it is quiet, in milliseconds: `agents.quietAfter`, '24h' unless set.
134
+ * @param {{ agents?: { quietAfter?: string } }} config a loaded config
135
+ */
136
+ export const quietAfterMs = (config) => parseDuration(config.agents?.quietAfter ?? DEFAULT_QUIET_AFTER) ?? parseDuration(DEFAULT_QUIET_AFTER);
137
+
118
138
  export const DEFAULTS = Object.freeze({
119
139
  title: null,
120
140
  derivers: ['starter', 'personas', 'cloud'],
@@ -130,7 +150,7 @@ export const DEFAULTS = Object.freeze({
130
150
  personas: { roles: null, parent: 'root' },
131
151
  cloud: { parent: 'root' },
132
152
  releaseNotes: { breakingLabel: 'breaking' },
133
- agents: { authors: [], labels: [], branchPrefixes: [], bots: true },
153
+ agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {} },
134
154
  hooks: [],
135
155
  hookPoll: { specs: '5m' },
136
156
  threads: { label: 'idea' },
@@ -143,7 +163,8 @@ export const DEFAULTS = Object.freeze({
143
163
  const KEYS = Object.keys(DEFAULTS);
144
164
  /** The keys of `proposals` when it is an object: the folder, and whether an approval also lands the spec PR (opt-in, off unless true) */
145
165
  export const PROPOSALS_KEYS = Object.freeze(['dir', 'approveLands']);
146
- const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots'];
166
+ const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits'];
167
+ const PROFILE_NAME = new RegExp(PROFILE_SOURCE);
147
168
  const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
148
169
  const isText = (v) => typeof v === 'string' && v.trim() !== '';
149
170
  /** A path inside the repo: relative, forward slashes, no `..` segment */
@@ -203,7 +224,7 @@ export function configProblems(c) {
203
224
  }
204
225
  }
205
226
  if (c.agents !== undefined) {
206
- if (!isObject(c.agents)) out.push('"agents" is { authors, labels, branchPrefixes, bots }.');
227
+ if (!isObject(c.agents)) out.push(`"agents" is { ${AGENT_KEYS.join(', ')} }.`);
207
228
  else {
208
229
  for (const k of Object.keys(c.agents)) if (!AGENT_KEYS.includes(k)) out.push(`agents.${k}: the keys are ${AGENT_KEYS.join(', ')}.`);
209
230
  for (const k of ['authors', 'labels', 'branchPrefixes']) {
@@ -215,6 +236,7 @@ export function configProblems(c) {
215
236
  }
216
237
  }
217
238
  if (c.agents.bots !== undefined && typeof c.agents.bots !== 'boolean') out.push('agents.bots is true or false: whether a bot account ("[bot]" or the forge\'s Bot type) counts as an agent.');
239
+ out.push(...agentRunProblems(c.agents));
218
240
  }
219
241
  }
220
242
  if (c.deploys !== undefined && c.deploys !== null) {
@@ -306,6 +328,38 @@ export function configProblems(c) {
306
328
  return out;
307
329
  }
308
330
 
331
+ /** What is wrong with the keys of `agents` that ask for and follow runs (spec 0005), in words */
332
+ function agentRunProblems(a) {
333
+ const out = [];
334
+ if (a.transport !== undefined && !AGENT_TRANSPORTS.includes(a.transport)) out.push(`agents.transport is ${AGENT_TRANSPORTS.map((t) => JSON.stringify(t)).join(' or ')}: a request is posted on the forge (push and pull come later).`);
335
+ if (a.maxOpen !== undefined && !(Number.isInteger(a.maxOpen) && a.maxOpen >= 1 && a.maxOpen <= MAX_OPEN_CEILING)) out.push(`agents.maxOpen is a whole number from 1 to ${MAX_OPEN_CEILING}: the most open runs cornfield asks for at once (${DEFAULT_MAX_OPEN} unless set).`);
336
+ if (a.profiles !== undefined) {
337
+ if (!Array.isArray(a.profiles) || a.profiles.length > MAX_PROFILES || !a.profiles.every((p) => typeof p === 'string' && PROFILE_NAME.test(p)) || new Set(a.profiles).size !== a.profiles.length) {
338
+ out.push(`agents.profiles is a list of at most ${MAX_PROFILES} different names ("claude", "local-qwen"): letters, digits and . _ -, at most 64 characters each. The runner maps each to a model; cornfield only knows the name.`);
339
+ }
340
+ }
341
+ if (a.runners !== undefined) {
342
+ if (!Array.isArray(a.runners) || a.runners.length > MAX_RUNNERS || !a.runners.every((r) => isText(r) && r === r.trim() && !/[\s,]/.test(r))) {
343
+ out.push(`agents.runners is a list of at most ${MAX_RUNNERS} forge logins ("claude-bot"), the runners' own accounts whose reports count besides a maintainer's.`);
344
+ }
345
+ }
346
+ if (a.quietAfter !== undefined) {
347
+ const ms = parseDuration(a.quietAfter);
348
+ if (ms === null) out.push('agents.quietAfter is a duration like "90m", "24h" or "72h".');
349
+ else if (ms < QUIET_MIN_MS) out.push(`agents.quietAfter ${JSON.stringify(a.quietAfter)} is under the floor of 5m; use "5m" or more.`);
350
+ else if (ms > QUIET_MAX_MS) out.push(`agents.quietAfter ${JSON.stringify(a.quietAfter)} is over the ceiling of 30 days; use "720h" or less.`);
351
+ }
352
+ if (a.limits !== undefined) {
353
+ if (!isObject(a.limits)) out.push('agents.limits is { minutes?, turns?, cost? }: hints the runner may enforce.');
354
+ else {
355
+ for (const k of Object.keys(a.limits)) if (!['minutes', 'turns', 'cost'].includes(k)) out.push(`agents.limits.${k}: the keys are minutes, turns, cost.`);
356
+ for (const k of ['minutes', 'cost']) if (a.limits[k] !== undefined && !(typeof a.limits[k] === 'number' && Number.isFinite(a.limits[k]) && a.limits[k] > 0)) out.push(`agents.limits.${k} is a number above zero.`);
357
+ if (a.limits.turns !== undefined && !(Number.isInteger(a.limits.turns) && a.limits.turns > 0 && a.limits.turns <= MAX_NUMBER)) out.push('agents.limits.turns is a whole number above zero.');
358
+ }
359
+ }
360
+ return out;
361
+ }
362
+
309
363
  /** Whether a host is the machine itself: `localhost`, 127.x.x.x or ::1 */
310
364
  const isLoopbackHost = (host) => host === 'localhost' || host === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(host);
311
365
 
@@ -852,7 +852,7 @@ a.hx-forge-row:hover, button.hx-forge-row:hover { background: var(--hx-control-h
852
852
  .hx-forge-detail { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--hx-line); border-left: 4px solid var(--hx-indigo); border-radius: 10px; background: var(--hx-well-soft); }
853
853
  .hx-forge-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
854
854
  .hx-forge-detail-title { min-width: 0; font-size: 13px; font-weight: 700; color: var(--hx-ink); overflow-wrap: anywhere; }
855
- .hx-forge-sub, .hx-forge-subact, .hx-forge-confirm-title { margin: 4px 0 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--hx-slate-300); }
855
+ .hx-forge-sub, .hx-forge-subact, .hx-forge-runs-title, .hx-forge-confirm-title { margin: 4px 0 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--hx-slate-300); }
856
856
  .hx-forge-check { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; }
857
857
  .hx-forge-check .hx-link { display: inline-flex; align-items: center; min-height: 24px; }
858
858
  .hx-forge-lights { flex-basis: 100%; font-size: 11px; line-height: 1.5; }
@@ -913,6 +913,8 @@ a.hx-forge-row:hover, button.hx-forge-row:hover { background: var(--hx-control-h
913
913
  :root[data-theme="light"] .hx-forge-tab[aria-pressed="true"] { box-shadow: 0 1px 3px rgba(15, 23, 42, 0.25); }
914
914
  :root[data-theme="light"] .hx-forge { box-shadow: 10px 0 30px -12px rgba(15, 23, 42, 0.18); }
915
915
  :root[data-theme="light"] .hx-forge:not(.forge-open) { box-shadow: none; }
916
+ .hx-forge-runs { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 8px; }
917
+ .hx-forge-runs[hidden] { display: none; }
916
918
 
917
919
  /* Threads (forge-panel.js, docs/threads.md): messages as written, read-only; a node's "Mentioned in" opens one */
918
920
  .hx-thread-msgs { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
@@ -51,6 +51,12 @@
51
51
  * notes again itself and has the forge keep them as a DRAFT release; nothing is published and no tag is made. The
52
52
  * page never sends the text of the notes, only the tag. The result, or the forge's refusal, is a line in words.
53
53
  *
54
+ * - Agent runs (issue #80, spec 0005, explorer/runs-model.js): **Start an agent** on an open issue and on an issue or proposal group of the Agents
55
+ * tab opens a dialog (kind, a profile when the config lists any, a brief, and in words what it will do) and POSTs once to
56
+ * `/api/agent-runs`; the Agents tab lists the runs with their state, links and a **Stop**, which asks the runner to stop. cornfield only
57
+ * asks: the request is a comment on the forge, as the maintainer. A refusal (the limit, a run already open) is said in the dialog, with
58
+ * a way to ask on purpose where the rule allows it.
59
+ *
54
60
  * What is asked, which words and which nodes are forge-panel-model.js, tested without a page.
55
61
  */
56
62
 
@@ -62,6 +68,9 @@ import { isPhone, registerSheet, expandSheet, collapseSheet } from './sheets.js'
62
68
  import { relativeTime } from './review-model.js';
63
69
  import { LAYER_DEFS, toggleLayer } from './layers.js';
64
70
  import { WORK_URL, groupWork, filterGroups, groupWords, workStatus, markWords } from './agents-model.js';
71
+ import {
72
+ RUNS_URL, runsUrl, stopUrl, KINDS, aboutWords, statusWords, statusWhy, runMeta, canStop, runsStatus, startBody, startWords, stopWords, confirmStartWords,
73
+ } from './runs-model.js';
65
74
  import {
66
75
  STATES, defaultFilters, listUrl, pullUrl, serverState, filterItems, facetsOf, stateWords, metaWords, checkWords, checksWords, litFor, errorWords,
67
76
  landingUrl, cancelLandingUrl, landingWords, mergeBlock, rerunWhy, confirmMerge, mergeBody, actionUrl, actionWords,
@@ -105,6 +114,9 @@ const ui = {
105
114
  confirm: null, // { opener: selector, kind: 'merge' | 'spec' | 'build', plan?, n?, slug? } while the confirm dialog is open
106
115
  actNote: null, // { text, ok, url }: what the last pipeline step came to
107
116
  notes: freshNotes(), // the Release notes view
117
+ runs: null, // { data, error } of the Agents tab's runs: what /api/agent-runs answered
118
+ runNote: null, // { text, ok }: what the last Stop came to
119
+ agent: null, // { opener, refs, posting } while the Start an agent dialog is open
108
120
  };
109
121
 
110
122
  /** The Release notes view: the draft loaded for a tag, and where the confirm step is */
@@ -161,7 +173,7 @@ async function loadWork(refresh, token) {
161
173
  ui.loading = true;
162
174
  ui.error = '';
163
175
  paint();
164
- const res = await request(WORK_URL, refresh);
176
+ const [res] = await Promise.all([request(WORK_URL, refresh), loadRuns(refresh)]);
165
177
  if (token !== listToken) return;
166
178
  ui.loading = false;
167
179
  if (res.ok) {
@@ -175,6 +187,12 @@ async function loadWork(refresh, token) {
175
187
  paint();
176
188
  }
177
189
 
190
+ /** Loads the runs for the Agents tab (a refresh also has cornfield look at the forge for their reports first); a failure is a line, never a blank panel */
191
+ async function loadRuns(refresh) {
192
+ const res = await request(runsUrl(refresh), refresh);
193
+ ui.runs = res.ok && Array.isArray(res.body.runs) ? { data: res.body, error: '' } : { data: ui.runs && ui.runs.data, error: errorWords(res.status, res.body) };
194
+ }
195
+
178
196
  /** Loads the list the kind and state name (again, past the server's cache, on a refresh) */
179
197
  async function loadList(refresh = false, quiet = false) {
180
198
  const token = ++listToken;
@@ -732,9 +750,12 @@ function itemHtml(item, before = '', after = '') {
732
750
  const spec = specBlock(item) === null
733
751
  ? `<button type="button" class="hx-btn hx-forge-act hx-forge-startspec" data-forge-spec="${item.number}" aria-label="Start a spec for issue ${item.number}"${ui.busy ? ' disabled' : ''}>Start a spec …</button>`
734
752
  : '';
753
+ const agent = item.state === 'open'
754
+ ? `<button type="button" class="hx-btn hx-forge-act hx-forge-startspec" data-forge-agent="issue:${item.number}" aria-label="Start an agent on issue ${item.number}">Start an agent …</button>`
755
+ : '';
735
756
  return link
736
- ? `<li class="hx-forge-item"><a class="hx-forge-row" href="${escHtml(link)}" target="_blank" rel="noreferrer">${inner}<span class="hx-sr-only"> (opens ${escHtml(forgeName())} in a new tab)</span></a>${spec}</li>`
737
- : `<li class="hx-forge-item"><div class="hx-forge-row">${inner}</div>${spec}</li>`;
757
+ ? `<li class="hx-forge-item"><a class="hx-forge-row" href="${escHtml(link)}" target="_blank" rel="noreferrer">${inner}<span class="hx-sr-only"> (opens ${escHtml(forgeName())} in a new tab)</span></a>${spec}${agent}</li>`
758
+ : `<li class="hx-forge-item"><div class="hx-forge-row">${inner}</div>${spec}${agent}</li>`;
738
759
  }
739
760
 
740
761
  /** One piece of work in a group: a pull request (a row that selects it, as in the Pull requests tab) or a branch with none */
@@ -752,7 +773,9 @@ function workHtml(e) {
752
773
  function groupsHtml(groups) {
753
774
  return groups.map((g, i) => {
754
775
  const link = g.issue && safeUrl(g.issue.url) ? ` <a class="hx-link hx-forge-gout" href="${escHtml(g.issue.url)}" target="_blank" rel="noreferrer" aria-label="Open issue ${g.number} on ${escHtml(forgeName())} (new tab)">↗</a>` : '';
755
- return `<li class="hx-forge-group"><h3 class="hx-forge-gtitle" id="forge-group-${i}">${escHtml(g.title)}${link}</h3><p class="hx-forge-meta">${escHtml(groupWords(g))}</p><ul class="hx-forge-list" aria-labelledby="forge-group-${i}">${g.entries.map(workHtml).join('')}</ul></li>`;
776
+ const startRef = g.kind === 'issue' && g.issue && g.issue.state === 'open' ? `issue:${g.number}` : g.kind === 'proposal' ? `proposal:${g.slug}` : null;
777
+ const start = startRef ? ` <button type="button" class="hx-btn hx-forge-act" data-forge-agent="${escHtml(startRef)}" aria-label="Start an agent on ${escHtml(g.kind === 'issue' ? `issue ${g.number}` : `proposal ${g.slug}`)}">Start an agent …</button>` : '';
778
+ return `<li class="hx-forge-group"><h3 class="hx-forge-gtitle" id="forge-group-${i}">${escHtml(g.title)}${link}</h3>${start}<p class="hx-forge-meta">${escHtml(groupWords(g))}</p><ul class="hx-forge-list" aria-labelledby="forge-group-${i}">${g.entries.map(workHtml).join('')}</ul></li>`;
756
779
  }).join('');
757
780
  }
758
781
 
@@ -899,6 +922,8 @@ function findAgain(el) {
899
922
  if (el.dataset.forgeSpec) return `[data-forge-spec="${el.dataset.forgeSpec}"]`;
900
923
  if (el.dataset.forgeBuild) return `[data-forge-build="${el.dataset.forgeBuild}"]`;
901
924
  if (el.dataset.forgeRerun) return `[data-forge-rerun="${el.dataset.forgeRerun}"]`;
925
+ if (el.dataset.forgeAgent) return `[data-forge-agent="${el.dataset.forgeAgent}"]`;
926
+ if (el.dataset.forgeStop) return `[data-forge-stop="${el.dataset.forgeStop}"]`;
902
927
  return null;
903
928
  }
904
929
 
@@ -960,9 +985,41 @@ function draw(panel) {
960
985
  paintIdea();
961
986
  const rate = (ui.threadDetail && ui.threadDetail.rate) || (ui.detail && ui.detail.rate) || (list && list.rate) || '';
962
987
  $('forge-rate').textContent = rate;
988
+ paintRuns();
963
989
  paintButton();
964
990
  }
965
991
 
992
+ /** The runs of the Agents tab: state, what each is about, links, and Stop. A runner's words are escaped like any text from the forge. */
993
+ function paintRuns() {
994
+ const box = $('forge-runs');
995
+ if (!box) return;
996
+ box.hidden = ui.kind !== 'work';
997
+ if (box.hidden) return;
998
+ const data = ui.runs && ui.runs.data;
999
+ $('forge-runs-status').textContent = data ? runsStatus(data) : ui.loading ? 'Loading the runs …' : '';
1000
+ const note = $('forge-runs-note');
1001
+ const words = (ui.runs && ui.runs.error) || (ui.runNote ? ui.runNote.text : '');
1002
+ note.hidden = !words;
1003
+ note.className = `hx-forge-notice${!(ui.runs && ui.runs.error) && ui.runNote && ui.runNote.ok ? ' hx-forge-notice--ok' : ''}`;
1004
+ note.textContent = words;
1005
+ const list = $('forge-runs-list');
1006
+ if (!data) { list.innerHTML = ''; return; }
1007
+ list.innerHTML = data.runs.length ? data.runs.map(runHtml).join('') : '<li class="hx-forge-empty hx-faint">No agent has been asked for yet. Start one from an issue or a proposal.</li>';
1008
+ }
1009
+
1010
+ function runHtml(run) {
1011
+ const links = [
1012
+ safeUrl(run.comment && run.comment.url) ? `<a class="hx-link" href="${escHtml(run.comment.url)}" target="_blank" rel="noreferrer">Request on ${escHtml(forgeName())} <span class="hx-sr-only">(new tab)</span></a>` : '',
1013
+ run.pull && safeUrl(run.pull.url) ? `<a class="hx-link" href="${escHtml(run.pull.url)}" target="_blank" rel="noreferrer">Pull request #${run.pull.number} <span class="hx-sr-only">(new tab)</span></a>${run.pull.trusted ? '' : ' <span class="hx-faint">(not from a runner, so it moves nothing)</span>'}` : '',
1014
+ run.report && safeUrl(run.report.url) ? `<a class="hx-link" href="${escHtml(run.report.url)}" target="_blank" rel="noreferrer">Runner\'s logs <span class="hx-sr-only">(new tab)</span></a>` : '',
1015
+ ].filter(Boolean).join(' · ');
1016
+ const why = statusWhy(run);
1017
+ const note = run.report && run.report.note ? `<p class="hx-forge-meta">Runner: ${escHtml(run.report.note)}</p>` : '';
1018
+ const stop = canStop(run) ? `<button type="button" class="hx-btn hx-forge-act" data-forge-stop="${escHtml(run.runId)}" aria-label="Stop run ${escHtml(run.runId)}"${ui.busy ? ' disabled' : ''}>Stop</button>` : '';
1019
+ return `<li class="hx-forge-file"><span class="hx-forge-state hx-forge-state--${escHtml(run.status === 'done' || run.status === 'merged' ? 'success' : run.status === 'failed' ? 'failure' : run.status === 'quiet' || run.status === 'stop-requested' ? 'queued' : 'running')}">${escHtml(statusWords(run))}</span> <span class="hx-name">${escHtml(run.kind)} on ${escHtml(aboutWords(run.refs))}</span> ${stop}
1020
+ <p class="hx-forge-meta">${escHtml(runMeta(run, now()))}</p>${why ? `<p class="hx-forge-why hx-faint">${escHtml(why)}</p>` : ''}${note}${links ? `<p class="hx-forge-meta">${links}</p>` : ''}</li>`;
1021
+ }
1022
+
966
1023
  /** The panel's lines for the Release notes view: the status, a failure, the rate limit; the rest is paintNotes */
967
1024
  function drawNotes() {
968
1025
  const n = ui.notes;
@@ -1191,6 +1248,136 @@ function onConfirmClick(e) {
1191
1248
  if (action.dataset.action === 'forge-confirm-cancel') { if (!ui.busy) closeConfirm(); } else if (action.dataset.action === 'forge-confirm-go') { if (ui.confirm && ui.confirm.kind === 'merge') confirmedMerge(); else confirmedStep(); }
1192
1249
  }
1193
1250
 
1251
+ // ---------------------------------------------------------------------------
1252
+ // Agent runs: Start an agent, and Stop
1253
+ // ---------------------------------------------------------------------------
1254
+
1255
+ const agentEl = () => $('agent-start');
1256
+
1257
+ /** While the dialog is open the focus cannot leave it */
1258
+ function keepFocusInAgent(e) {
1259
+ const el = agentEl();
1260
+ if (ui.agent && el && !el.contains(e.target)) $('agent-kind').focus();
1261
+ }
1262
+
1263
+ /** Opens the Start an agent dialog for `issue:<n>` or `proposal:<slug>`; the focus goes to the first field */
1264
+ async function openAgent(ref, opener) {
1265
+ if (!agentEl() || ui.agent) return;
1266
+ const at = ref.indexOf(':');
1267
+ const type = ref.slice(0, at);
1268
+ const value = ref.slice(at + 1);
1269
+ const refs = type === 'proposal' ? { proposal: value } : { issue: Number(value) };
1270
+ ui.agent = { opener, refs, posting: false };
1271
+ // The profiles the config lists come with the runs: asked for if the Agents tab has not been opened yet
1272
+ if (!ui.runs) { await loadRuns(false); if (ui.kind === 'work') paint(); }
1273
+ if (!ui.agent) return;
1274
+ const profiles = (ui.runs && ui.runs.data && ui.runs.data.profiles) || [];
1275
+ $('agent-start-about').textContent = `About ${aboutWords(refs)}`;
1276
+ $('agent-kind').innerHTML = KINDS.map(([v, l]) => option(v, l, v === 'code')).join('');
1277
+ $('agent-profile').innerHTML = option('', 'No profile', true) + profiles.map((p) => option(p, p, false)).join('');
1278
+ $('agent-profile-field').hidden = !profiles.length;
1279
+ $('agent-brief').value = '';
1280
+ $('agent-force').checked = false;
1281
+ $('agent-force-field').hidden = true;
1282
+ $('agent-start-msg').hidden = true;
1283
+ $('agent-start-msg').textContent = '';
1284
+ $('agent-start-words').textContent = confirmStartWords(refs, forgeName());
1285
+ const go = $('agent-start-go');
1286
+ go.disabled = false;
1287
+ go.removeAttribute('aria-busy');
1288
+ go.textContent = 'Ask the runner';
1289
+ document.addEventListener('focusin', keepFocusInAgent);
1290
+ agentEl().hidden = false;
1291
+ $('agent-kind').focus();
1292
+ announce(`Start an agent on ${aboutWords(refs)}. ${confirmStartWords(refs, forgeName())}`);
1293
+ }
1294
+
1295
+ /** Closes the dialog; the focus goes back to the button that opened it unless told not to */
1296
+ function closeAgent({ restoreFocus = true } = {}) {
1297
+ const el = agentEl();
1298
+ if (!el || !ui.agent) return;
1299
+ const { opener } = ui.agent;
1300
+ ui.agent = null;
1301
+ el.hidden = true;
1302
+ document.removeEventListener('focusin', keepFocusInAgent);
1303
+ if (!restoreFocus) return;
1304
+ const back = $('forge-panel').querySelector(opener) || $('forge-title');
1305
+ if (back && !back.disabled) back.focus();
1306
+ }
1307
+
1308
+ /** The one write: posts the choices once; a refusal stays in the dialog, in the server's words */
1309
+ async function submitAgent(e) {
1310
+ e.preventDefault();
1311
+ if (!ui.agent || ui.agent.posting) return;
1312
+ ui.agent.posting = true;
1313
+ const go = $('agent-start-go');
1314
+ go.disabled = true;
1315
+ go.setAttribute('aria-busy', 'true');
1316
+ go.textContent = 'Asking …';
1317
+ $('agent-start-msg').hidden = true;
1318
+ const res = await post(RUNS_URL, startBody({
1319
+ kind: $('agent-kind').value, profile: $('agent-profile-field').hidden ? '' : $('agent-profile').value, brief: $('agent-brief').value, refs: ui.agent.refs, force: $('agent-force').checked,
1320
+ }));
1321
+ if (!ui.agent) return;
1322
+ ui.agent.posting = false;
1323
+ const text = startWords(res.status, res.body);
1324
+ if (res.ok) {
1325
+ closeAgent();
1326
+ ui.runNote = { text, ok: true };
1327
+ announce(text);
1328
+ await loadRuns(false);
1329
+ paint();
1330
+ return;
1331
+ }
1332
+ go.disabled = false;
1333
+ go.removeAttribute('aria-busy');
1334
+ go.textContent = 'Ask the runner';
1335
+ const msg = $('agent-start-msg');
1336
+ msg.textContent = text;
1337
+ msg.hidden = false;
1338
+ // The one-per-issue rule can be set aside on purpose; the cap cannot
1339
+ if (res.body && res.body.code === 'run-open') $('agent-force-field').hidden = false;
1340
+ announce(text);
1341
+ }
1342
+
1343
+ /** Keys inside the dialog: Escape cancels, Tab stays inside, nothing reaches the map behind it */
1344
+ function onAgentKey(e) {
1345
+ e.stopPropagation();
1346
+ if (e.key === 'Escape') {
1347
+ e.preventDefault();
1348
+ if (!ui.agent || !ui.agent.posting) closeAgent();
1349
+ return;
1350
+ }
1351
+ if (e.key !== 'Tab') return;
1352
+ const fields = [...agentEl().querySelectorAll('select, textarea, input, button')].filter((el) => !el.disabled && !el.closest('[hidden]'));
1353
+ if (!fields.length) { e.preventDefault(); return; }
1354
+ const at = fields.indexOf(document.activeElement);
1355
+ const next = e.shiftKey ? (at <= 0 ? fields.length - 1 : at - 1) : (at === -1 || at === fields.length - 1 ? 0 : at + 1);
1356
+ e.preventDefault();
1357
+ fields[next].focus();
1358
+ }
1359
+
1360
+ function onAgentClick(e) {
1361
+ const t = e.target;
1362
+ if (t === agentEl()) { if (ui.agent && !ui.agent.posting) closeAgent(); return; } // the scrim
1363
+ if (t.closest && t.closest('[data-action="agent-start-cancel"]') && ui.agent && !ui.agent.posting) closeAgent();
1364
+ }
1365
+
1366
+ /** Asks a run to stop: one POST, then the runs are read again */
1367
+ async function stopRun(runId) {
1368
+ if (ui.busy) return;
1369
+ ui.busy = 'stop';
1370
+ ui.runNote = null;
1371
+ announce(`Asking ${runId} to stop`);
1372
+ paint();
1373
+ const res = await post(stopUrl(runId), {});
1374
+ ui.busy = '';
1375
+ ui.runNote = { text: stopWords(res.status, res.body, runId), ok: res.ok };
1376
+ announce(ui.runNote.text);
1377
+ await loadRuns(false);
1378
+ paint();
1379
+ }
1380
+
1194
1381
  // ---------------------------------------------------------------------------
1195
1382
  // Opening and closing
1196
1383
  // ---------------------------------------------------------------------------
@@ -1317,6 +1504,10 @@ function onClick(e) {
1317
1504
  announce(`${node.label}, ${node.type}`);
1318
1505
  return;
1319
1506
  }
1507
+ const agent = t.closest('[data-forge-agent]');
1508
+ if (agent) { openAgent(agent.dataset.forgeAgent, `[data-forge-agent="${agent.dataset.forgeAgent}"]`); return; }
1509
+ const stop = t.closest('[data-forge-stop]');
1510
+ if (stop) { stopRun(stop.dataset.forgeStop); return; }
1320
1511
  const unland = t.closest('[data-forge-landing-cancel]');
1321
1512
  if (unland) { cancelLanding(Number(unland.dataset.forgeLandingCancel)); return; }
1322
1513
  const merge = t.closest('[data-forge-merge]');
@@ -1420,6 +1611,12 @@ export function initForgePanel(config, opts = {}) {
1420
1611
  panel.addEventListener('change', onChange);
1421
1612
  panel.addEventListener('input', onInput);
1422
1613
  panel.addEventListener('keydown', onKey);
1614
+ const agent = agentEl();
1615
+ if (agent) {
1616
+ agent.addEventListener('click', onAgentClick);
1617
+ agent.addEventListener('keydown', onAgentKey);
1618
+ agent.querySelector('form').addEventListener('submit', submitAgent);
1619
+ }
1423
1620
  const confirm = confirmEl();
1424
1621
  if (confirm) {
1425
1622
  confirm.addEventListener('click', onConfirmClick);
@@ -1439,6 +1636,7 @@ export function initForgePanel(config, opts = {}) {
1439
1636
  /** For tests: forget the page this was wired to, and everything shown */
1440
1637
  export function resetForgePanelForTests(clock = () => Date.now()) {
1441
1638
  if (ui.confirm) closeConfirm({ restoreFocus: false });
1639
+ if (ui.agent) closeAgent({ restoreFocus: false });
1442
1640
  if (offScene) offScene();
1443
1641
  offScene = null;
1444
1642
  clearTimeout(searchTimer);
@@ -1453,5 +1651,5 @@ export function resetForgePanelForTests(clock = () => Date.now()) {
1453
1651
  indexCache = null;
1454
1652
  state.forgeLit = null;
1455
1653
  state.threadIndex = null;
1456
- Object.assign(ui, { thread: null, threadDetail: null, threadError: '', litThread: null, idea: freshIdea(), config: null, open: false, kind: 'pulls', filters: defaultFilters(), lists: {}, work: null, workFilters: { agentsOnly: false, finished: false }, loading: false, error: '', selected: null, detail: null, landing: null, detailError: '', lit: null, deploy: null, deployItem: null, litDeploy: null, nodesSeen: null, busy: '', note: null, confirm: null, actNote: null, notes: freshNotes() });
1654
+ Object.assign(ui, { thread: null, threadDetail: null, threadError: '', litThread: null, idea: freshIdea(), config: null, open: false, kind: 'pulls', filters: defaultFilters(), lists: {}, work: null, workFilters: { agentsOnly: false, finished: false }, loading: false, error: '', selected: null, detail: null, landing: null, detailError: '', lit: null, deploy: null, deployItem: null, litDeploy: null, nodesSeen: null, busy: '', note: null, confirm: null, actNote: null, notes: freshNotes(), runs: null, runNote: null, agent: null });
1457
1655
  }
@@ -265,6 +265,13 @@
265
265
  </div>
266
266
  </div>
267
267
  </section>
268
+ <!-- Agent runs (forge-panel.js, runs-model.js): shown on the Agents tab; each run's state, what it is about, links, and Stop -->
269
+ <section id="forge-runs" class="hx-forge-runs" aria-labelledby="forge-runs-title" hidden>
270
+ <h3 id="forge-runs-title" class="hx-forge-runs-title">Agent runs</h3>
271
+ <p id="forge-runs-status" class="hx-forge-meta hx-faint"></p>
272
+ <p id="forge-runs-note" class="hx-forge-notice" hidden></p>
273
+ <ul id="forge-runs-list" class="hx-forge-files" aria-label="Runs"></ul>
274
+ </section>
268
275
  <section id="forge-detail" class="hx-forge-detail" aria-label="Selected pull request" hidden></section>
269
276
  <ul id="forge-list" class="hx-forge-list" aria-label="Results"></ul>
270
277
  <p id="forge-rate" class="hx-forge-rate hx-faint"></p>
@@ -339,6 +346,26 @@
339
346
  </div>
340
347
  </div>
341
348
 
349
+ <!-- Start an agent (forge-panel.js): kind, profile, a brief, and a confirm step; a modal dialog over a scrim, the focus stays in it -->
350
+ <div id="agent-start" class="hx-confirm-scrim" hidden>
351
+ <div class="hx-confirm glass" role="dialog" aria-modal="true" aria-labelledby="agent-start-title" aria-describedby="agent-start-words">
352
+ <h2 id="agent-start-title" class="hx-confirm-title">Start an agent</h2>
353
+ <p id="agent-start-about" class="hx-forge-meta"></p>
354
+ <form id="agent-start-form" class="hx-forge-confirm" novalidate>
355
+ <label class="hx-forge-field">Kind of run<select id="agent-kind" class="hx-forge-select"></select></label>
356
+ <label id="agent-profile-field" class="hx-forge-field" hidden>Profile<select id="agent-profile" class="hx-forge-select"></select></label>
357
+ <label class="hx-forge-field">Brief for the runner<textarea id="agent-brief" class="hx-forge-preview" rows="6" maxlength="20000" spellcheck="true"></textarea></label>
358
+ <label id="agent-force-field" class="hx-forge-toggle" hidden><input type="checkbox" id="agent-force" /> Ask for another run on purpose</label>
359
+ <p id="agent-start-words" class="hx-confirm-note"></p>
360
+ <p id="agent-start-msg" class="hx-forge-msg hx-forge-msg--bad" role="alert" hidden></p>
361
+ <div class="hx-confirm-actions">
362
+ <button type="button" class="hx-btn" data-action="agent-start-cancel">Cancel</button>
363
+ <button type="submit" class="hx-btn hx-btn--approve" id="agent-start-go">Ask the runner</button>
364
+ </div>
365
+ </form>
366
+ </div>
367
+ </div>
368
+
342
369
  <!-- Review tokens (review-tokens.js): make one (shown once), list them, revoke; a modal dialog over a scrim, the focus stays in it -->
343
370
  <div id="tokens-dialog" class="hx-confirm-scrim" hidden>
344
371
  <div class="hx-confirm glass" role="dialog" aria-modal="true" aria-labelledby="tokens-title" aria-describedby="tokens-intro">
@@ -354,6 +381,7 @@
354
381
  </div>
355
382
  <ul id="tokens-list" class="hx-tokens-list" aria-label="Your review tokens"></ul>
356
383
  <form id="tokens-form" class="hx-tokens-form" autocomplete="off">
384
+ <label class="hx-forge-field">Kind of token<select id="tokens-scope" class="hx-forge-select"><option value="review">Review: approve or send back proposals</option><option value="agent">Agent: ask for agent runs (POST /api/agent-requests)</option></select></label>
357
385
  <label class="hx-forge-field">Label<input id="tokens-label" class="hx-forge-input" type="text" maxlength="60" placeholder="chat bot" /></label>
358
386
  <label class="hx-forge-field">Days<input id="tokens-days" class="hx-forge-input" type="text" inputmode="numeric" maxlength="2" aria-describedby="tokens-days-hint" /></label>
359
387
  <span id="tokens-days-hint" class="hx-sr-only"></span>