@edgehero/pi-dispatch 4.2.0 → 4.3.1

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.
@@ -162,6 +162,7 @@ const UNREACHABLE_BY_SCAN = ["AWS_ENDPOINT_URL_BEDROCK_RUNTIME", "ALL_PROXY", "h
162
162
  */
163
163
  const PI_OWN_READS = [
164
164
  "PI_CLEAR_ON_SHRINK",
165
+ "PI_CLIPBOARD_OSC52",
165
166
  "PI_CODING_AGENT_DIR",
166
167
  "PI_CODING_AGENT_SESSION_DIR",
167
168
  "PI_EXPERIMENTAL",
package/src/queue.mjs CHANGED
@@ -247,6 +247,38 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
247
247
  // window only needs to absorb burst re-labels, not the full redelivery window.
248
248
  const SEMANTIC_WINDOW_MS = 10 * 60 * 1000;
249
249
 
250
+ /**
251
+ * Close the semantic window of a run that PARKED (REQ-AWAIT-INPUT, DES-AWAIT-INPUT-ENDS-THE-JOB). Never throws.
252
+ *
253
+ * The window exists to coalesce rapid duplicate deliveries into one ACTIVE job. A ttl-mode key outlives the job it
254
+ * was set for (bullmq 5.80.4, `removeDeduplicationKeyIfNeededOnFinalization.lua`: a finished job deletes its key only
255
+ * when it has no ttl), so without this a person's answer that lands within ten minutes of the delivery that STARTED
256
+ * the parked run is swallowed against that finished run: the receiver answers `deduplicated`, nothing runs, and the
257
+ * marker stays. Once a run has parked, the next delivery on its key is a deliberate answer, never a duplicate.
258
+ *
259
+ * It asks the BullMQ job itself, rather than rebuilding the key from the job data: `job.deduplicationId` is the id
260
+ * the add stored with the job (`deid` in its hash), so there is no second spelling of the key format to drift from
261
+ * `enqueueForgeJob`'s, and it covers every suffix (the `closed:`/`cmd:` slot, a replica's `:r<n>`) by construction.
262
+ * And `job.removeDeduplicationKey()` is a compare-and-delete (`removeDeduplicationKey-1.lua`: DEL only when the key
263
+ * still holds THIS job's id), where `queue.removeDeduplicationKey(id)` is a bare DEL that could free a key a later
264
+ * job now holds.
265
+ *
266
+ * Fails open with a named line: a failure only leaves today's behaviour (the answer waits out the window).
267
+ * `released` is false, logged as a release and not as a failure, when the key is already gone (the window lapsed
268
+ * during a long run) or now names another job.
269
+ */
270
+ export async function releaseSemanticWindow(job, log = () => {}) {
271
+ if (!job?.deduplicationId || typeof job.removeDeduplicationKey !== "function") return false;
272
+ try {
273
+ const released = (await job.removeDeduplicationKey()) === true;
274
+ log("parked_window_released", { jobId: job.id ?? null, released });
275
+ return released;
276
+ } catch (error) {
277
+ log("parked_window_release_failed", { jobId: job.id ?? null, code: typeof error?.code === "string" ? error.code : "error" });
278
+ return false;
279
+ }
280
+ }
281
+
250
282
  /**
251
283
  * Enqueue a GitHub-triggered job. Returns `{ jobId, deduplicated, survivingJobId? }` -- the computed
252
284
  * per-delivery id, plus whether the SEMANTIC window swallowed this delivery (issue #289; the GUID
@@ -311,7 +343,7 @@ export async function enqueueGitLabJob(queue, fields) {
311
343
  * window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
312
344
  * has always been.
313
345
  */
314
- export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, models, maxCostUsd, packages, image, backend, excludeTools, mcp, skillsDir, instructions, resume, secrets, secretsProfile, waitFor, replica, replicas }) {
346
+ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, models, maxCostUsd, packages, image, backend, excludeTools, mcp, skillsDir, instructions, resume, awaitInput, parkedReply, secrets, secretsProfile, waitFor, replica, replicas }) {
315
347
  const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
316
348
  // `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
317
349
  // come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
@@ -366,6 +398,12 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
366
398
  // delivery, and `trigger` is what /job/event.json is built from.
367
399
  ...(instructions !== undefined && { instructions }),
368
400
  ...(resume !== undefined && { resume }),
401
+ // REQ-AWAIT-INPUT (issue #625): only when armed, so an unflagged job's data is byte-identical, and at JOB level
402
+ // for resume's reason: `trigger` is copied verbatim into /job/event.json.
403
+ ...(awaitInput === true && { awaitInput: true }),
404
+ // A phrase-free reply to a parked issue (issue #625), gated by the worker on the key's `parked` mark. Never part
405
+ // of an id: it says how the delivery was routed, not what it is.
406
+ ...(parkedReply === true && { parkedReply: true }),
369
407
  // REQ-TRIGGER-SECRETS. The env variables this trigger binds and the profile whose resolver reads
370
408
  // them. REFERENCES only: no value is ever enqueued, because a queued job is durable and a resolved
371
409
  // credential in Redis would outlive the container it was scoped to. The worker resolves them at job
@@ -43,6 +43,7 @@ export const CONTAINER_ENV_NAMES = new Set([
43
43
  "PI_FLOW",
44
44
  "PI_COMMAND",
45
45
  "PI_EXCLUDE_TOOLS",
46
+ "PI_AWAIT_INPUT",
46
47
  "PI_MCP_SERVERS",
47
48
  // Issues #501, #502: the per-job dollar cap (written by the worker since #501, from the trigger's and the
48
49
  // deployment's caps) and the allowed-model list (reserved until #502 writes it). Neither can be bound through
@@ -148,6 +148,10 @@ export function makeRunContainer({
148
148
  excludeTools: Array.isArray(job.excludeTools) && job.excludeTools.length > 0 ? job.excludeTools : undefined,
149
149
  // REQ-MCP-OPERATOR-SERVERS: the trigger's MCP server names, off `job` like excludeTools.
150
150
  mcpServers: Array.isArray(job.mcp) && job.mcp.length > 0 ? job.mcp : undefined,
151
+ // Issue #625: whether the runner offers the tool a parked run ends on. Only beside a staged transcript: the
152
+ // runner refuses the flag without one (await-input-without-session), so a job whose resume degraded to no
153
+ // mount is not armed into that refusal.
154
+ awaitInput: job.awaitInput === true && prepared.session ? true : undefined,
151
155
  // Issue #502: the effective allowed-model list, off `job` like excludeTools. index.mjs `effectiveJobOf` folds
152
156
  // the deployment's PI_ALLOWED_MODELS in under the trigger's own, so `job.models` is already the one list.
153
157
  allowedModels: Array.isArray(job.models) && job.models.length > 0 ? job.models : null,
@@ -172,11 +172,30 @@ export function parseExitTurns(text) {
172
172
  * Live from the first image that declares `mcp`: an image built before the worker named them prints the same line.
173
173
  */
174
174
  export const MCP_RUNNER_REASONS = Object.freeze(["mcp-unavailable", "mcp-config-invalid", "mcp-config-mismatch", "mcp-secret-missing", "mcp-command-missing", "mcp-shadowed"]);
175
- export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap", "model-not-allowed", "cost-cap-unenforceable", "model-policy-unenforceable", ...MCP_RUNNER_REASONS]);
175
+ /**
176
+ * The runner's exit-2 refusals of a job armed with `run.awaitInput` (issue #625), each before the prompt: another
177
+ * extension registers the tool a parked run ends on (`await-input-shadowed`), or the job has no session file to park
178
+ * a transcript in (`await-input-without-session`, a worker regression, since the worker arms the flag only beside
179
+ * `run.resume` and refuses that with no store). Named for MCP_RUNNER_REASONS' reason: the operator's fix is not a budget.
180
+ */
181
+ export const AWAIT_INPUT_RUNNER_REASONS = Object.freeze(["await-input-shadowed", "await-input-without-session"]);
182
+ export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap", "model-not-allowed", "cost-cap-unenforceable", "model-policy-unenforceable", ...MCP_RUNNER_REASONS, ...AWAIT_INPUT_RUNNER_REASONS]);
183
+
184
+ /**
185
+ * The runner's reasons that ride a COMPLETED exit (`code: 0`) and that the worker acts on, closed (issue #625). One so
186
+ * far: `awaiting-input`, a run that asked a person a question and ended so the next run resumes with the answer. Every
187
+ * other exit-0 reason (`stop`, `length`, `command-completed`, `tool-terminated`) is still dropped, as before.
188
+ *
189
+ * Read for the same bounded reason RUNNER_POLICY_REASONS is: it cannot change the retry class. The processor consults it
190
+ * only inside its exit-0 branch, so the worst a forged line can do on a real exit 0 is mark a completed run parked: the
191
+ * worker then posts one fixed sentence and writes a marker beside a transcript the job already promoted.
192
+ */
193
+ export const RUNNER_COMPLETED_REASONS = new Set(["awaiting-input"]);
176
194
 
177
195
  /**
178
196
  * The reason off the decisive exit line (`decisiveExitLine`), or `null`: a member of `RUNNER_POLICY_REASONS`
179
- * and only when that same line says `code: 2`. Read off the same line as `parseExitTurns`, and NEVER throws.
197
+ * and only when that same line says `code: 2`, or a member of `RUNNER_COMPLETED_REASONS` and only when it says
198
+ * `code: 0` (issue #625). Read off the same line as `parseExitTurns`, and NEVER throws.
180
199
  *
181
200
  * Why this may reach the outcome when its five siblings may not: it cannot change the retry class. The
182
201
  * processor consults it only inside its container-exit-2 branch, so a container that exits 1 while
@@ -189,6 +208,7 @@ export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap
189
208
  export function parseExitReason(text) {
190
209
  const parsed = decisiveExitLine(text);
191
210
  if (parsed === null) return null;
211
+ if (parsed?.code === 0 && RUNNER_COMPLETED_REASONS.has(parsed?.reason)) return parsed.reason;
192
212
  return parsed?.code === 2 && RUNNER_POLICY_REASONS.has(parsed?.reason) ? parsed.reason : null;
193
213
  }
194
214
 
@@ -90,6 +90,15 @@ const CONTEXT_FILE = "context";
90
90
  * stamp existed ran on `local`, and the default is a setting that can move under it.
91
91
  */
92
92
  const VENUE_FILE = "venue";
93
+ /**
94
+ * The run that wrote the transcript beside it ended `awaiting-input` (REQ-AWAIT-INPUT, issue #625): it asked a person
95
+ * a question and ended so that the next run on this key resumes with the answer. One line, the promotion's
96
+ * `now()` in milliseconds. Keyed state written where the key already is, like `resume-chain`, so it is not the
97
+ * index `DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED` refuses: it answers one question about this key and nothing
98
+ * looks it up by anything else. Written and REMOVED by promotion alone, under the promotion lock: a parked run
99
+ * writes it, any other completed run on the key removes it, so it never outlives the transcript it describes.
100
+ */
101
+ export const PARKED_FILE = "parked";
93
102
  /**
94
103
  * What the venue stamp reads while a promotion is swapping the transcript under it. It matches no venue:
95
104
  * parentheses are outside the charset every backend name is validated against (a test pins every name this
@@ -540,7 +549,7 @@ export function makeSessionStore({
540
549
  return st.isDirectory() ? `${st.dev}:${st.ino}` : null;
541
550
  }
542
551
 
543
- function promoteSession(session, { piVersion = null, context = null } = {}) {
552
+ function promoteSession(session, { piVersion = null, context = null, parked = false } = {}) {
544
553
  // The second DI-seam backstop, and unreachable for the same reason as the `!sessionsDir` return
545
554
  // above: sessionKeyFor is total and binary (null, or 32 hex chars), so resolveSession returns null
546
555
  // rather than a keyless session, and processor.mjs only calls this when prepare handed it one. Kept
@@ -711,6 +720,7 @@ export function makeSessionStore({
711
720
  // it is reporting.
712
721
  writeSidecar(dir, RESUME_CHAIN_FILE, session.key, chainValue(session));
713
722
  writeContextSidecar(dir, session, context);
723
+ writeParkedSidecar(dir, session, parked);
714
724
  } finally {
715
725
  fs.closeSync(fd);
716
726
  try {
@@ -840,6 +850,20 @@ export function makeSessionStore({
840
850
  writeSidecar(dir, CONTEXT_FILE, session.key, `${context.tokens} ${context.window}${stamp}`);
841
851
  }
842
852
 
853
+ // Issue #625: `parked` is set by a run that ended awaiting-input, and cleared by every other promotion, so the
854
+ // marker always describes the transcript beside it. Caught like its siblings: the promotion already happened.
855
+ function writeParkedSidecar(dir, session, parked) {
856
+ if (parked === true) {
857
+ writeSidecar(dir, PARKED_FILE, session.key, String(now()));
858
+ return;
859
+ }
860
+ try {
861
+ fs.unlinkSync(join(dir, PARKED_FILE));
862
+ } catch {
863
+ // Absent is the desired state, so failing to remove what is not there is success.
864
+ }
865
+ }
866
+
843
867
  function keyDir(key) {
844
868
  return join(sessionsDir, key);
845
869
  }
@@ -1289,5 +1313,24 @@ export function makeSessionStore({
1289
1313
  }
1290
1314
  }
1291
1315
 
1292
- return { resolveSession, promoteSession, reapSessions };
1316
+ /**
1317
+ * Whether the key a job resolves to holds a `parked` mark (issue #625): the TRUTH the worker gates a phrase-free reply
1318
+ * on, where the forge label was only the receiver's hint. Free, read-only and never throws, so it can answer before
1319
+ * the mint. An issue's key is a pure function of the job (`pi/issue-<n>`); a pull request's needs its head branch,
1320
+ * which costs a token to read, which is why a phrase-free reply is routed on issues only and this resolves no PR.
1321
+ * The key's own name must `lstat` as a real directory, resolveSession's first gate, so a planted link answers no.
1322
+ */
1323
+ function isParked(job) {
1324
+ if (!sessionsDir || job?.target?.type === "pull_request") return false;
1325
+ const key = sessionKeyFor(job);
1326
+ if (!key) return false;
1327
+ try {
1328
+ if (!fs.lstatSync(keyDir(key)).isDirectory()) return false;
1329
+ } catch {
1330
+ return false;
1331
+ }
1332
+ return readSidecar(key, PARKED_FILE) !== null;
1333
+ }
1334
+
1335
+ return { resolveSession, promoteSession, reapSessions, isParked };
1293
1336
  }
package/src/start.mjs CHANGED
@@ -2002,6 +2002,23 @@ export async function startWorker(
2002
2002
  log("comment", { jobId: job?.id, text });
2003
2003
  };
2004
2004
 
2005
+ // Issue #625: the forge's parked marker, set on a park and cleared when it no longer applies. comment()'s shape and its
2006
+ // rule: it NEVER throws, because the marker decides nothing (the session store's sidecar does) and a forge that refuses
2007
+ // the label must not flip an outcome. A local job has no forge and no marker. Issues only: a pull request's reply is
2008
+ // routed on the phrase (parked.mjs).
2009
+ const parkedLabel = async (job, on) => {
2010
+ const forge = forgeFor(job);
2011
+ if (!forge?.host?.setParkedLabel || job?.target?.type !== "issue") return;
2012
+ try {
2013
+ const auth = forge.auth ?? (await ensureAuth(job.kind));
2014
+ if (!auth) return;
2015
+ const token = await auth.mintToken(job);
2016
+ await forge.host.setParkedLabel(job, job.target, on, token);
2017
+ } catch (err) {
2018
+ log("parked_label_failed", { jobId: job?.id, on, reason: err?.message });
2019
+ }
2020
+ };
2021
+
2005
2022
  // The operator's failure hook (issue #288, INT-ON-FAILURE-HOOK-CONTRACT). Constructed ONLY when the
2006
2023
  // knob is set: an unset PI_ON_FAILURE builds nothing, spawns nothing, and logs nothing -- the
2007
2024
  // byte-identical guarantee. Fired from the two terminal listeners below, never from the processor:
@@ -2241,6 +2258,8 @@ export async function startWorker(
2241
2258
  // Completed-only, so a policy or infra exit leaves the canonical transcript byte-identical and a
2242
2259
  // retry starts from what the first attempt did (CONST-RETRY-INFRA-ONLY).
2243
2260
  promoteSession: sessionStore.promoteSession,
2261
+ isParked: sessionStore.isParked,
2262
+ parkedLabel,
2244
2263
  // The same value the store above was built from, passed explicitly so the processor's fail-closed
2245
2264
  // `run.resume` gate answers from THIS config rather than from its own env default. Identical on the
2246
2265
  // real path; the difference shows under an injected env, where the store would be built from the
@@ -478,6 +478,10 @@ export const AUTHORED_NARROWING_FIELDS = Object.freeze([
478
478
  // REQ-MCP-OPERATOR-SERVERS: the MCP servers a trigger's jobs connect. Not a widening when dropped but the same
479
479
  // silent outcome: the job runs its flow without the servers it was written against, on a clean record.
480
480
  Object.freeze({ key: "mcp", authored: (v) => Array.isArray(v) && v.length > 0 }),
481
+ // REQ-AWAIT-INPUT: the tool a run ends on to wait for a person. Dropped by a receiver from before #625 (which tolerates
482
+ // the key as unknown), the job runs without it: the agent cannot ask, and the record reads like a clean run. Only
483
+ // `true` arms it, and the job data carries it only then, so `false` is nothing authored.
484
+ Object.freeze({ key: "awaitInput", authored: (v) => v === true }),
481
485
  ]);
482
486
 
483
487
  export function makeCheckWaitSkew({ triggersPath, fs = nodeFs }) {
package/src/triggers.mjs CHANGED
@@ -30,7 +30,7 @@ import { imageRefProblem } from "./image-ref.mjs";
30
30
  // materialize.mjs and the admin already import it, and a keep-in-sync copy would drift where a
31
31
  // traversal guard cannot). flow-gate's module body is import-inert, so this keeps parseTriggers pure.
32
32
  import { SKILL_NAME_RE } from "./flow-gate.mjs";
33
- import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
33
+ import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RECEIVER_ONLY_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
34
34
  import { findDuplicateKey } from "./json-duplicates.mjs";
35
35
  // The server-name rule, from the one module that parses the overlay's list (its body is import-inert).
36
36
  import { FORGE_MCP_KINDS, FORGE_MCP_SERVER_NAME, MCP_SERVER_NAME_RE } from "./mcp-config.mjs";
@@ -261,7 +261,7 @@ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
261
261
  * Every set is IMPORTED, never retyped, which is the rule `sandbox.test.mjs` keeps for the same reason: a
262
262
  * forge added to the table later must not need a second edit here to stay covered.
263
263
  */
264
- export const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES, ...RUNNER_ENV_NAMES, ...PROVIDER_STEERING_VARS]);
264
+ export const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...RECEIVER_ONLY_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES, ...RUNNER_ENV_NAMES, ...PROVIDER_STEERING_VARS]);
265
265
 
266
266
  function isNonEmptyString(value) {
267
267
  return typeof value === "string" && value.trim() !== "";
@@ -425,6 +425,9 @@ function normalizeCron(on, run, index, path, state) {
425
425
  // `false` or absent. Both must keep reaching the job payload unchanged -- an operator who wrote down
426
426
  // today's default must not get a `data` that disagrees with the file they reviewed.
427
427
  const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
428
+ // Called for its refusal only (issue #625): `true` needs run.resume, which cron refuses, so the returned `run`
429
+ // grows no `awaitInput` key, exactly as it grows no `replicas` one.
430
+ validateAwaitInputFlag(run, `cron trigger "${id}"`, path);
428
431
  // Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
429
432
  // returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
430
433
  validateReplicas(run, `cron trigger "${id}"`, path);
@@ -557,6 +560,28 @@ function validateResumeFlag(run, at, path) {
557
560
  return run.resume;
558
561
  }
559
562
 
563
+ /**
564
+ * Validate the per-trigger `run.awaitInput` flag (REQ-AWAIT-INPUT, issue #625): whether the job is offered the tool
565
+ * that ends a run to wait for a person's answer. Strictly boolean, validateResumeFlag's rule and for its reason: a
566
+ * truthy `"false"` would arm what the operator meant to leave off.
567
+ *
568
+ * `true` needs `run.resume: true` beside it, and that one rule carries every other refusal: a parked run is only
569
+ * worth anything if the next run resumes its transcript, and `run.resume` is already refused on cron and beside
570
+ * `run.command`, `run.replicas` and `run.secrets`, each for a reason that holds here too. So this adds no list of
571
+ * its own, which is one place fewer for the two to disagree.
572
+ *
573
+ * Returns the flag, undefined when absent, so an unflagged trigger normalizes byte-identically to today's.
574
+ */
575
+ function validateAwaitInputFlag(run, at, path) {
576
+ if (run.awaitInput !== undefined && typeof run.awaitInput !== "boolean") {
577
+ throw configError(`${at}: run.awaitInput must be true or false when present: ${path}`);
578
+ }
579
+ if (run.awaitInput === true && run.resume !== true) {
580
+ throw configError(`${at}: run.awaitInput needs run.resume: true beside it -- a run that stops to wait for an answer is continued by the next run on the same issue or pull request, and only run.resume keeps the transcript it would continue from: ${path}`);
581
+ }
582
+ return run.awaitInput;
583
+ }
584
+
560
585
  /**
561
586
  * Validate the per-trigger `run.image` reference, shared by all four normalizers. It selects the Docker image
562
587
  * this trigger's job containers run in, overriding the deployment-wide `PI_JOB_IMAGE` for this trigger only;
@@ -1649,6 +1674,7 @@ function normalizeLabel(on, run, index, path) {
1649
1674
  const skillsDir = validateSkillsDir(run, at, path);
1650
1675
  const instructions = validateInstructions(run, at, path);
1651
1676
  const resume = validateResumeFlag(run, at, path);
1677
+ const awaitInput = validateAwaitInputFlag(run, at, path);
1652
1678
  const repository = validateRepository(run, "label", at, path);
1653
1679
  const replicas = validateReplicas(run, at, path);
1654
1680
  const secrets = validateSecrets(run, at, path);
@@ -1660,7 +1686,7 @@ function normalizeLabel(on, run, index, path) {
1660
1686
  const ref = validateRunModel(on, run, at, path);
1661
1687
  return {
1662
1688
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
1663
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1689
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1664
1690
  };
1665
1691
  }
1666
1692
 
@@ -1692,6 +1718,7 @@ function normalizeComment(on, run, index, path, state) {
1692
1718
  const skillsDir = validateSkillsDir(run, at, path);
1693
1719
  const instructions = validateInstructions(run, at, path);
1694
1720
  const resume = validateResumeFlag(run, at, path);
1721
+ const awaitInput = validateAwaitInputFlag(run, at, path);
1695
1722
  const repository = validateRepository(run, "comment", at, path);
1696
1723
  const replicas = validateReplicas(run, at, path);
1697
1724
  const secrets = validateSecrets(run, at, path);
@@ -1703,7 +1730,7 @@ function normalizeComment(on, run, index, path, state) {
1703
1730
  const ref = validateRunModel(on, run, at, path);
1704
1731
  return {
1705
1732
  on: { type: "comment", phrase: on.phrase },
1706
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1733
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1707
1734
  };
1708
1735
  }
1709
1736
 
@@ -1762,6 +1789,7 @@ function normalizeIssue(on, run, index, path) {
1762
1789
  const skillsDir = validateSkillsDir(run, at, path);
1763
1790
  const instructions = validateInstructions(run, at, path);
1764
1791
  const resume = validateResumeFlag(run, at, path);
1792
+ const awaitInput = validateAwaitInputFlag(run, at, path);
1765
1793
  validateRepository(run, "issue", at, path);
1766
1794
  const replicas = validateReplicas(run, at, path);
1767
1795
  const secrets = validateSecrets(run, at, path);
@@ -1780,7 +1808,7 @@ function normalizeIssue(on, run, index, path) {
1780
1808
  ...(number !== undefined && { number }),
1781
1809
  ...(once !== undefined && { once }),
1782
1810
  },
1783
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1811
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1784
1812
  };
1785
1813
  }
1786
1814
 
@@ -1850,6 +1878,7 @@ function normalizePullRequest(on, run, index, path) {
1850
1878
  const skillsDir = validateSkillsDir(run, at, path);
1851
1879
  const instructions = validateInstructions(run, at, path);
1852
1880
  const resume = validateResumeFlag(run, at, path);
1881
+ const awaitInput = validateAwaitInputFlag(run, at, path);
1853
1882
  validateRepository(run, "pull_request", at, path);
1854
1883
  const replicas = validateReplicas(run, at, path);
1855
1884
  const secrets = validateSecrets(run, at, path);
@@ -1873,7 +1902,7 @@ function normalizePullRequest(on, run, index, path) {
1873
1902
  ...(number !== undefined && { number }),
1874
1903
  ...(once !== undefined && { once }),
1875
1904
  },
1876
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1905
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1877
1906
  };
1878
1907
  }
1879
1908