@edgehero/pi-dispatch 4.2.0 → 4.3.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/.env.example CHANGED
@@ -33,15 +33,16 @@ ANTHROPIC_API_KEY=
33
33
 
34
34
  # --- Which provider/model to run by default (override per job with --provider / --model) ---
35
35
  PI_PROVIDER=anthropic
36
- # A DATED model id is deterministic; a floating alias (e.g. claude-sonnet-4-5) can change cost.
37
- PI_MODEL=claude-sonnet-4-5-20250929
36
+ # Name an exact model version (claude-sonnet-5-5, or a dated id such as claude-sonnet-4-5-20250929); an alias
37
+ # that moves to a newer model can change cost.
38
+ PI_MODEL=claude-sonnet-5-5
38
39
  # optional: the only models a job may call, comma-separated provider/model (at most 16). A trigger's own
39
40
  # run.models replaces this list for its jobs. The default model must be on it, or every job that uses the
40
41
  # default is refused before it spends. Env only: the settings overlay cannot set or widen it.
41
42
  # Unset = any model. Needs a job image that declares the modelPolicy capability.
42
43
  # No spaces, not even after a comma: a shell that sources this file cuts the value at the first space
43
44
  # and leaves it unset, so jobs would run with no list (the worker refuses a spaced value at boot).
44
- # PI_ALLOWED_MODELS=anthropic/claude-sonnet-4-5-20250929,anthropic/claude-haiku-4-5-20251001
45
+ # PI_ALLOWED_MODELS=anthropic/claude-sonnet-5-5,anthropic/claude-haiku-5-5
45
46
 
46
47
  # --- Spend + concurrency guards (money bounds; all have conservative defaults) ---
47
48
  # per-job turn cap -- pi has none of its own, so the harness imposes one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "type": "module",
5
5
  "description": "The pi-dispatch worker and CLI: runs the pi coding agent as a self-hosted service, one locked down Docker or Podman container per job, with spend caps checked before anything is spent, plus init, up, doctor and service install.",
6
6
  "keywords": [
@@ -55,6 +55,7 @@
55
55
  "./git-dirty": "./src/git-dirty.mjs",
56
56
  "./queue": "./src/queue.mjs",
57
57
  "./capabilities": "./src/capabilities.mjs",
58
+ "./parked": "./src/parked.mjs",
58
59
  "./connection": "./src/connection.mjs",
59
60
  "./valkey-auth": "./src/valkey-auth.mjs",
60
61
  "./job-id": "./src/job-id.mjs",
@@ -114,7 +115,7 @@
114
115
  "start": "node src/cli.mjs worker"
115
116
  },
116
117
  "dependencies": {
117
- "@earendil-works/pi-ai": "1.1.0",
118
+ "@earendil-works/pi-ai": "1.2.0",
118
119
  "@octokit/auth-app": "8.2.0",
119
120
  "@octokit/rest": "22.0.1",
120
121
  "bullmq": "5.80.4",
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { configError } from "./config.mjs";
25
+ import { PARKED_LABEL } from "./parked.mjs";
25
26
  import { InfraRetry } from "./processor.mjs";
26
27
  import { fetchFailureReason } from "./gitlab-identity.mjs";
27
28
  import { isDeterminateFetchFailure } from "./transient.mjs";
@@ -201,7 +202,33 @@ export function makeAzureHost({ orgUrl, fetchFn = fetch } = {}) {
201
202
  return { headRef: stripRefsHeads(pr?.sourceRefName), headRepo: sameRepo ? job?.repo : null };
202
203
  }
203
204
 
204
- return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
205
+ /**
206
+ * Add or remove the parked marker on a work item, as a TAG (issue #625, parked.mjs): Azure work items have
207
+ * tags, not labels, and the receiver reads `System.Tags` into the same `[{ name }]` shape. A tags field is written
208
+ * whole, so this reads it, edits the one tag and writes the result; Azure creates a tag the project lacks. The caller treats any throw as fail-open:
209
+ * the session store's sidecar, not this label, decides whether a reply resumes anything.
210
+ */
211
+ async function setParkedLabel(ref, target, on, token) {
212
+ const { project } = scopeOf(ref);
213
+ const path = `/${encodeURIComponent(project)}/_apis/wit/workitems/${encodeURIComponent(target?.number)}`;
214
+ const item = await get(`${path}?fields=System.Tags&api-version=${API_VERSION}`, token);
215
+ const tags = String(item?.fields?.["System.Tags"] ?? "").split(";").map((tag) => tag.trim()).filter((tag) => tag !== "" && tag !== PARKED_LABEL);
216
+ const value = (on ? [...tags, PARKED_LABEL] : tags).join("; ");
217
+ let res;
218
+ try {
219
+ res = await fetchFn(`${root}${path}?api-version=${API_VERSION}`, {
220
+ method: "PATCH",
221
+ headers: { Authorization: authHeader(token), "content-type": "application/json-patch+json" },
222
+ body: JSON.stringify([{ op: "add", path: "/fields/System.Tags", value }]),
223
+ redirect: "error",
224
+ });
225
+ } catch (err) {
226
+ throw new InfraRetry(`azure-host: PATCH ${path} failed (${fetchFailureReason(err)})`);
227
+ }
228
+ if (!res.ok) throw new InfraRetry(`azure-host: PATCH ${path} returned ${res.status}`);
229
+ }
230
+
231
+ return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
205
232
  }
206
233
 
207
234
  /**
@@ -68,6 +68,31 @@ function prReplicaLines(replica, replicas) {
68
68
  ];
69
69
  }
70
70
 
71
+ /**
72
+ * What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
73
+ * request exist, so the words are the ones this envelope always had. On a work item neither is certain: the
74
+ * previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
75
+ * "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
76
+ * human who asked waits on the work item for a reply. So the issue form names the issue branch, checks for its pull
77
+ * request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
78
+ */
79
+ function resumedActionLines(target, n) {
80
+ if (target?.type === "pull_request") {
81
+ return [
82
+ "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
83
+ "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
84
+ "not. Do not open a second pull request -- your push updates the existing one.",
85
+ ];
86
+ }
87
+ const branch = issueBranch(n);
88
+ return [
89
+ "Address the activity quoted below, then reply on the work item saying what you did or why you could not.",
90
+ `If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
91
+ `branch only. Check first for an active pull request from it (\`az repos pr list --source-branch ${branch} --status active\`):`,
92
+ `if one exists your push updates it, so do not create another; only if none exists, run \`az repos pr create --source-branch ${branch}\`.`,
93
+ ];
94
+ }
95
+
71
96
  function buildResumedPrompt(flow, target, comment, instructions) {
72
97
  const n = normalizeNumber(target?.number);
73
98
  const noun = target?.type === "pull_request" ? "pull request" : "work item";
@@ -81,9 +106,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
81
106
  `\`az repos pr show --id ${n}\` (or \`az boards work-item show --id ${n}\`) before doing anything,`,
82
107
  `then follow the "${flow}" skill from the top.`,
83
108
  "",
84
- "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
85
- "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
86
- "not. Do not open a second pull request -- your push updates the existing one.",
109
+ ...resumedActionLines(target, n),
87
110
  "",
88
111
  // Above the never-merge paragraph, so the harness has the last word before the data region.
89
112
  ...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
package/src/config.mjs CHANGED
@@ -31,7 +31,7 @@ import { DOLLAR_ENV_NAMES, DOLLAR_WINDOW_KEYS, checkDollarInvariant, optionalUsd
31
31
  */
32
32
  export const DEFAULT_VALKEY_URL = "redis://127.0.0.1:6379";
33
33
  /** The model a job runs on when nothing names one (`PI_MODEL` unset): one copy, for `loadConfig` and doctor. */
34
- export const DEFAULT_MODEL = "claude-sonnet-4-5-20250929";
34
+ export const DEFAULT_MODEL = "claude-sonnet-5-5";
35
35
  /** The provider a job runs on when nothing names one (`PI_PROVIDER` unset). */
36
36
  export const DEFAULT_PROVIDER = "anthropic";
37
37
 
@@ -394,7 +394,7 @@ export function globalExtensionsEnabled(env) {
394
394
  * spend caps above, the per-job token scoping, or the admin-extension recursion block.
395
395
  */
396
396
  export function loadConfig(env = process.env, { fileExists = existsSync } = {}) {
397
- const model = env.PI_MODEL ?? DEFAULT_MODEL; // dated snapshot; deterministic per CONST-PI-VERSION-PINNED
397
+ const model = env.PI_MODEL ?? DEFAULT_MODEL; // an exact model version, not a moving alias; deterministic per CONST-PI-VERSION-PINNED
398
398
  // #227. Hoisted above the object because `defaultBackend` INDEXES `backends`, and a property cannot read
399
399
  // a sibling of the literal it is in. (Calling the parser twice would be harmless -- `egressEnabled` is
400
400
  // called twice a few properties down for the same reason -- so this is about the index, not the throw.)
@@ -367,7 +367,7 @@ function resolveEnvName(provider) {
367
367
  * `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
368
368
  * caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
369
369
  */
370
- export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
370
+ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], awaitInput = false, allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
371
371
  // The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
372
372
  // PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
373
373
  // neither source yields one, which the processor turns into a policy refusal that refunds any reserve
@@ -433,6 +433,9 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
433
433
  // runner can hold `/job/mcp.json` to the names the worker meant. Comma-safe for excludeTools' reason (the loader
434
434
  // admits only lowercase letters, digits and `-`). Absent means none, never an empty string.
435
435
  PI_MCP_SERVERS: mcpServers.length > 0 ? mcpServers.join(",") : undefined,
436
+ // The trigger's run.awaitInput (issue #625): "1" offers the job the tool a parked run ends on. Absent means not
437
+ // armed, never "0" or an empty string, for PI_PACKAGES' reason.
438
+ PI_AWAIT_INPUT: awaitInput === true ? "1" : undefined,
436
439
  // The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
437
440
  // PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
438
441
  // (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { configError } from "./config.mjs";
24
+ import { PARKED_LABEL } from "./parked.mjs";
24
25
  import { InfraRetry } from "./processor.mjs";
25
26
  import { fetchFailureReason } from "./gitlab-identity.mjs";
26
27
  import { matchesBranch } from "./gitlab-host.mjs";
@@ -153,7 +154,38 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
153
154
  return { headRef: pr?.head?.ref, headRepo: pr?.head?.repo?.full_name };
154
155
  }
155
156
 
156
- return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
157
+ /**
158
+ * Add or remove the parked marker on an issue (issue #625, parked.mjs). Forgejo adds and removes a
159
+ * label by NAME (its IssueLabelsOption and the `{identifier}` path take a name or an id), but only one the repository
160
+ * already has, so adding first creates the repository label; a label that already exists answers 409 or 422 there. The caller treats any throw as fail-open:
161
+ * the session store's sidecar, not this label, decides whether a reply resumes anything.
162
+ */
163
+ async function setParkedLabel(ref, target, on, token) {
164
+ const [owner, name] = splitRepo(ref);
165
+ const send = async (method, path, body, accept = []) => {
166
+ let res;
167
+ try {
168
+ res = await fetchFn(`${root}${path}`, {
169
+ method,
170
+ headers: { Authorization: `token ${token}`, "content-type": "application/json" },
171
+ ...(body !== undefined && { body: JSON.stringify(body) }),
172
+ redirect: "error",
173
+ });
174
+ } catch (err) {
175
+ throw new InfraRetry(`forgejo-host: ${method} ${path} failed (${fetchFailureReason(err)})`);
176
+ }
177
+ if (!res.ok && !accept.includes(res.status)) throw new InfraRetry(`forgejo-host: ${method} ${path} returned ${res.status}`);
178
+ };
179
+ const issue = `/repos/${owner}/${name}/issues/${encodeURIComponent(target?.number)}/labels`;
180
+ if (on) {
181
+ await send("POST", `/repos/${owner}/${name}/labels`, { name: PARKED_LABEL, color: "#fbca04", description: "pi-dispatch: the last run is waiting for an answer" }, [409, 422]);
182
+ await send("POST", issue, { labels: [PARKED_LABEL] });
183
+ return;
184
+ }
185
+ await send("DELETE", `${issue}/${encodeURIComponent(PARKED_LABEL)}`, undefined, [404]);
186
+ }
187
+
188
+ return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
157
189
  }
158
190
 
159
191
  /**
@@ -74,6 +74,31 @@ function prReplicaLines(replica, replicas) {
74
74
  * toward the full envelope, and a bare "address the feedback" over a cold session is an agent with no idea
75
75
  * what it was asked to do. `tea`, never `gh`: this envelope's whole reason for existing.
76
76
  */
77
+ /**
78
+ * What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
79
+ * request exist, so the words are the ones this envelope always had. On an issue neither is certain: the
80
+ * previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
81
+ * "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
82
+ * human who asked waits on the issue for a reply. So the issue form names the issue branch, checks for its pull
83
+ * request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
84
+ */
85
+ function resumedActionLines(target, n) {
86
+ if (target?.type === "pull_request") {
87
+ return [
88
+ "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
89
+ "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
90
+ "not. Do not open a second pull request -- your push updates the existing one.",
91
+ ];
92
+ }
93
+ const branch = issueBranch(n);
94
+ return [
95
+ "Address the activity quoted below, then reply on the issue saying what you did or why you could not.",
96
+ `If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
97
+ `branch only. Check first for an open pull request from it (\`tea pr list --state open\`, look for \`${branch}\`):`,
98
+ `if one exists your push updates it, so do not open a second; only if none exists, run \`tea pr create --head ${branch}\`.`,
99
+ ];
100
+ }
101
+
77
102
  function buildResumedPrompt(flow, target, comment, instructions) {
78
103
  const n = normalizeNumber(target?.number);
79
104
  const noun = target?.type === "pull_request" ? "pull request" : "issue";
@@ -86,9 +111,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
86
111
  `If you do not recognise this ${noun}, treat this as a fresh start: read it with \`tea pr ${n}\``,
87
112
  `(or \`tea issue ${n}\`) before doing anything, then follow the "${flow}" skill from the top.`,
88
113
  "",
89
- "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
90
- "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
91
- "not. Do not open a second pull request -- your push updates the existing one.",
114
+ ...resumedActionLines(target, n),
92
115
  "",
93
116
  // Above the never-merge paragraph, so the harness has the last word before the data region.
94
117
  ...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
@@ -26,6 +26,7 @@
26
26
  import { Octokit } from "@octokit/rest";
27
27
  import { configError } from "./config.mjs";
28
28
  import { octokitLogOptions } from "./octokit-log.mjs";
29
+ import { PARKED_LABEL } from "./parked.mjs";
29
30
  import { InfraRetry } from "./processor.mjs";
30
31
 
31
32
  /**
@@ -134,7 +135,26 @@ export function makeGitHubHost({ log, octokitFor = (token) => new Octokit({ auth
134
135
  return { headRef: data?.head?.ref, headRepo: data?.head?.repo?.full_name };
135
136
  }
136
137
 
137
- return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
138
+ /**
139
+ * Add or remove the parked marker on an issue (issue #625, parked.mjs). GitHub creates a label the repository lacks
140
+ * when it is added, so there is nothing to create first. Removing a label the issue does not carry is a 404, which is
141
+ * the state asked for. The caller treats any throw as fail-open: the session store's sidecar, not this label, decides.
142
+ */
143
+ async function setParkedLabel(repo, target, on, token) {
144
+ const [owner, name] = splitRepo(repo);
145
+ const octokit = octokitFor(token);
146
+ if (on) {
147
+ await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/labels", { owner, repo: name, issue_number: target?.number, labels: [PARKED_LABEL] });
148
+ return;
149
+ }
150
+ try {
151
+ await octokit.request("DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}", { owner, repo: name, issue_number: target?.number, name: PARKED_LABEL });
152
+ } catch (error) {
153
+ if (error?.status !== 404) throw error;
154
+ }
155
+ }
156
+
157
+ return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
138
158
  }
139
159
 
140
160
  /**
@@ -155,6 +155,31 @@ function prReplicaLines(replica, replicas) {
155
155
  * The safety paragraph is repeated verbatim rather than assumed inherited. It is cheap, and the whole
156
156
  * premise of a long-lived transcript is that early turns get compacted away.
157
157
  */
158
+ /**
159
+ * What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
160
+ * request exist, so the words are the ones this envelope always had. On an issue neither is certain: the
161
+ * previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
162
+ * "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
163
+ * human who asked waits on the issue for a reply. So the issue form names the issue branch, checks for its pull
164
+ * request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
165
+ */
166
+ function resumedActionLines(target, n) {
167
+ if (target?.type === "pull_request") {
168
+ return [
169
+ "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
170
+ "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
171
+ "not. Do not open a second pull request -- your push updates the existing one.",
172
+ ];
173
+ }
174
+ const branch = issueBranch(n);
175
+ return [
176
+ "Address the activity quoted below, then reply on the issue saying what you did or why you could not.",
177
+ `If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
178
+ `branch only. Check first for an open pull request from it (\`gh pr list --head ${branch} --state open\`):`,
179
+ "if one exists your push updates it, so do not open a second; only if none exists, run `gh pr create`.",
180
+ ];
181
+ }
182
+
158
183
  function buildResumedPrompt(flow, target, comment, review, instructions) {
159
184
  const n = normalizeNumber(target?.number);
160
185
  const noun = target?.type === "pull_request" ? "pull request" : "issue";
@@ -168,9 +193,7 @@ function buildResumedPrompt(flow, target, comment, review, instructions) {
168
193
  `and \`gh pr diff ${n}\` (or \`gh issue view ${n}\`) before doing anything, then follow the`,
169
194
  `"${flow}" skill from the top.`,
170
195
  "",
171
- "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
172
- "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
173
- "not. Do not open a second pull request -- your push updates the existing one.",
196
+ ...resumedActionLines(target, n),
174
197
  "",
175
198
  // The operator block sits HERE and not after: later text reads as more specific, and the harness's
176
199
  // non-negotiables must be the last thing before the data region rather than something an operator
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { configError } from "./config.mjs";
27
+ import { PARKED_LABEL } from "./parked.mjs";
27
28
  import { InfraRetry } from "./processor.mjs";
28
29
  import { fetchFailureReason } from "./gitlab-identity.mjs";
29
30
  import { isDeterminateFetchFailure } from "./transient.mjs";
@@ -164,7 +165,29 @@ export function makeGitLabHost({ apiUrl = "https://gitlab.com", fetchFn = fetch
164
165
  return { headRef: mr?.source_branch, headRepo: sameProject ? job?.repo : null };
165
166
  }
166
167
 
167
- return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
168
+ /**
169
+ * Add or remove the parked marker on an issue (issue #625, parked.mjs). `add_labels` creates a
170
+ * project label the project lacks, and `remove_labels` of one the issue does not carry changes nothing. The caller treats any throw as fail-open:
171
+ * the session store's sidecar, not this label, decides whether a reply resumes anything.
172
+ */
173
+ async function setParkedLabel(projectRef, target, on, token) {
174
+ const id = projectId(projectRef);
175
+ const path = `/projects/${id}/issues/${encodeURIComponent(target?.number)}`;
176
+ let res;
177
+ try {
178
+ res = await fetchFn(`${root}${path}`, {
179
+ method: "PUT",
180
+ headers: { "PRIVATE-TOKEN": token, "content-type": "application/json" },
181
+ body: JSON.stringify(on ? { add_labels: PARKED_LABEL } : { remove_labels: PARKED_LABEL }),
182
+ redirect: "error",
183
+ });
184
+ } catch (err) {
185
+ throw new InfraRetry(`gitlab-host: PUT ${path} failed (${fetchFailureReason(err)})`);
186
+ }
187
+ if (!res.ok) throw new InfraRetry(`gitlab-host: PUT ${path} returned ${res.status}`);
188
+ }
189
+
190
+ return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
168
191
  }
169
192
 
170
193
  /**
@@ -76,6 +76,31 @@ function mrReplicaLines(replica, replicas) {
76
76
  * toward the full envelope, and a bare "address the feedback" over a cold session is an agent with no
77
77
  * idea what it was asked to do. `glab`, never `gh`: this envelope's whole reason for existing.
78
78
  */
79
+ /**
80
+ * What a resumed run is told to do with the activity (issue #624). On a merge request the branch and the pull
81
+ * request exist, so the words are the ones this envelope always had. On an issue neither is certain: the
82
+ * previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
83
+ * "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
84
+ * human who asked waits on the issue for a reply. So the issue form names the issue branch, checks for its pull
85
+ * request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
86
+ */
87
+ function resumedActionLines(target, n) {
88
+ if (target?.type === "pull_request") {
89
+ return [
90
+ "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
91
+ "`git push --force-with-lease`, and reply on the merge request saying what you did or why you could",
92
+ "not. Do not open a second merge request -- your push updates the existing one.",
93
+ ];
94
+ }
95
+ const branch = issueBranch(n);
96
+ return [
97
+ "Address the activity quoted below, then reply on the issue saying what you did or why you could not.",
98
+ `If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
99
+ `branch only. Check first for an open merge request from it (\`glab mr list --source-branch ${branch}\`):`,
100
+ "if one exists your push updates it, so do not open a second; only if none exists, run `glab mr create`.",
101
+ ];
102
+ }
103
+
79
104
  function buildResumedPrompt(flow, target, comment, instructions) {
80
105
  const n = normalizeNumber(target?.number);
81
106
  const noun = target?.type === "pull_request" ? "merge request" : "issue";
@@ -89,9 +114,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
89
114
  `and \`glab mr diff ${n}\` (or \`glab issue view ${n}\`) before doing anything, then follow the`,
90
115
  `"${flow}" skill from the top.`,
91
116
  "",
92
- "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
93
- "`git push --force-with-lease`, and reply on the merge request saying what you did or why you could",
94
- "not. Do not open a second merge request -- your push updates the existing one.",
117
+ ...resumedActionLines(target, n),
95
118
  "",
96
119
  // Above the never-merge paragraph, so the harness has the last word before the data region.
97
120
  ...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
package/src/host-pi.mjs CHANGED
@@ -72,11 +72,14 @@ export const PINNED_PI_NEEDLES = {
72
72
  "const path = `${BUILTIN_PATH_PREFIX}${name}`;",
73
73
  ],
74
74
  "dist/core/source-info.js": ['export const BUILTIN_PATH_PREFIX = "builtin:";'],
75
- "dist/core/settings-manager.d.ts": [
76
- "export type PackageSource = string | {",
77
- " autoload?: boolean;",
78
- " packages?: PackageSource[];",
79
- " extensions?: string[];",
75
+ // The settings shape parseHostSettings reads. Since pi 1.2.0 it is a TypeBox schema in settings-schema (the same
76
+ // union: a string, or an object with `source` and an optional `autoload`), and pi still loads the file without
77
+ // validating it, which is why the parse here stays lenient.
78
+ "dist/core/settings-schema.d.ts": [
79
+ "declare const PackageSourceSchema: Type.TUnion<[Type.TString, Type.TObject<{",
80
+ " autoload: Type.TOptional<Type.TBoolean>;",
81
+ " packages: Type.TOptional<Type.TArray<Type.TUnion<[Type.TString, Type.TObject<{",
82
+ " extensions: Type.TOptional<Type.TArray<Type.TString>>;",
80
83
  ],
81
84
  };
82
85
 
@@ -215,6 +215,16 @@ export const CAPABILITY_GATES = Object.freeze([
215
215
  event: "refused_image_forge_mcp_unsupported",
216
216
  comment: (image, declared) => `Refused: the job image "${image}" does not declare the forge's own MCP server (\`dev.pi-dispatch.capabilities\` ${declared}), so it does not carry the server \`run.mcp\` names as \`forge\`. Rebuild the image from a version that has this feature. Not run.`,
217
217
  }),
218
+ // Issue #625: a runner that predates run.awaitInput reads no PI_AWAIT_INPUT, so the agent is never offered the tool a
219
+ // parked run ends on: it guesses or gives up, and the run ends without the question the trigger was armed for.
220
+ Object.freeze({
221
+ token: "awaitInput",
222
+ needed: (job) => job?.awaitInput === true,
223
+ result: "awaitInputUnsupported",
224
+ reason: "job-image-await-input-unsupported",
225
+ event: "refused_image_await_input_unsupported",
226
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare support for stopping to wait for an answer (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.awaitInput\`. Rebuild the image from a version that has this feature. Not run.`,
227
+ }),
218
228
  ]);
219
229
 
220
230
  /**
@@ -125,6 +125,8 @@ export function overlayProviderProblem(config, providerId) {
125
125
  if (!api) return "no-api";
126
126
  const baseUrl = def.baseUrl ?? config.baseUrl ?? defaults?.baseUrl;
127
127
  if (!baseUrl) return "no-baseUrl";
128
+ // pi 1.2.0's schema refuses both first (a token count must be above zero), so a file reaching here holds none; kept
129
+ // because provider-composer.js still checks them, and the mirror follows that file, not the schema in front of it.
128
130
  if (def.contextWindow !== undefined && def.contextWindow <= 0) return "contextWindow";
129
131
  if (def.maxTokens !== undefined && def.maxTokens <= 0) return "maxTokens";
130
132
  const composed = { id: def.id, api, baseUrl };
@@ -121,8 +121,13 @@ const T = {
121
121
  const opt = (check) => [check, true];
122
122
  const req = (check) => [check, false];
123
123
 
124
- // The schema, transcribed from model-config.js at the pin (1.0.3; worker/test/models-json.test.mjs holds the file's
125
- // content hash), in its order.
124
+ // The schema, transcribed at the pin (1.2.0; worker/test/models-json.test.mjs holds each file's content hash). Since
125
+ // 1.2.0 model-config.js builds it from two pi-ai modules, `providers/model-schema.js` (cost, prompt cache, input
126
+ // limits, thinking map, modalities) and `providers/compat-schema.js` (compat), so all three are pinned.
127
+ //
128
+ // The change that matters in 1.2.0: `compat` was a UNION of three object schemas that each admit extra keys, so a value
129
+ // one api's schema refused passed through another (`supportsStore: "x"` loaded). It is now ONE object, the superset of
130
+ // five apis' properties, so every known compat key is type-checked wherever `compat` appears.
126
131
  const PercentileCutoffs = T.obj({ p50: opt(T.num()), p75: opt(T.num()), p90: opt(T.num()), p99: opt(T.num()) });
127
132
  const numOrStr = T.union(T.num(), T.str());
128
133
  const OpenRouterRouting = T.obj({
@@ -141,70 +146,76 @@ const OpenRouterRouting = T.obj({
141
146
  preferred_max_latency: opt(T.union(T.num(), PercentileCutoffs)),
142
147
  });
143
148
  const VercelGatewayRouting = T.obj({ only: opt(T.arr(T.str())), order: opt(T.arr(T.str())) });
149
+ // model-schema.js: `Partial(Record(Enum(levels), String | Null))`, which checks the seven level keys and admits others.
144
150
  const ThinkingValue = T.union(T.str(), T.nul());
145
151
  const ThinkingLevelMap = T.obj({ off: opt(ThinkingValue), minimal: opt(ThinkingValue), low: opt(ThinkingValue), medium: opt(ThinkingValue), high: opt(ThinkingValue), xhigh: opt(ThinkingValue), max: opt(ThinkingValue) });
146
152
  // pi 1.0.2 (issue #587): a model's sampling parameters, and the same per thinking level, merged per call over them.
147
153
  const SamplingParams = T.rec(T.unknown());
148
154
  const SamplingParamsByThinkingLevel = T.obj({ off: opt(SamplingParams), minimal: opt(SamplingParams), low: opt(SamplingParams), medium: opt(SamplingParams), high: opt(SamplingParams), xhigh: opt(SamplingParams), max: opt(SamplingParams) });
149
- const KwargScalar = T.union(T.str(), T.num(), T.bool(), T.nul());
150
- const KwargVariable = T.obj({ $var: req(T.union(T.lit("thinking.enabled"), T.lit("thinking.effort"))), omitWhenOff: opt(T.bool()) });
151
- const Kwarg = T.union(KwargScalar, KwargVariable);
155
+ const KwargValue = T.union(T.str(), T.num(), T.bool(), T.nul(), T.obj({ $var: req(T.union(T.lit("thinking.enabled"), T.lit("thinking.effort"), T.lit("thinking.budget"))), omitWhenOff: opt(T.bool()) }));
152
156
  const affinity = T.union(T.lit("openai"), T.lit("openai-nosession"), T.lit("openrouter"));
153
- const OpenAICompletionsCompat = T.obj({
154
- supportsStore: opt(T.bool()),
155
- supportsDeveloperRole: opt(T.bool()),
156
- supportsReasoningEffort: opt(T.bool()),
157
- supportsUsageInStreaming: opt(T.bool()),
158
- supportsFinishReason: opt(T.bool()),
159
- maxTokensField: opt(T.union(T.lit("max_completion_tokens"), T.lit("max_tokens"))),
160
- requiresToolResultName: opt(T.bool()),
161
- requiresAssistantAfterToolResult: opt(T.bool()),
162
- requiresThinkingAsText: opt(T.bool()),
163
- requiresReasoningContentOnAssistantMessages: opt(T.bool()),
164
- thinkingFormat: opt(T.union(...["openai", "openrouter", "together", "baseten", "deepseek", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"].map((x) => T.lit(x)))),
165
- chatTemplateKwargs: opt(T.rec(Kwarg)),
166
- chatTemplateArgs: opt(T.rec(Kwarg)),
167
- cacheControlFormat: opt(T.lit("anthropic")),
168
- openRouterRouting: opt(OpenRouterRouting),
169
- vercelGatewayRouting: opt(VercelGatewayRouting),
170
- supportsOpenAIGrammarTools: opt(T.bool()),
171
- supportsStrictMode: opt(T.bool()),
172
- sendSessionAffinityHeaders: opt(T.bool()),
173
- sessionAffinityFormat: opt(affinity),
174
- supportsLongCacheRetention: opt(T.bool()),
175
- vllmPriority: opt(T.num()),
176
- });
177
- const OpenAIResponsesCompat = T.obj({
178
- supportsDeveloperRole: opt(T.bool()),
179
- sessionAffinityFormat: opt(affinity),
180
- supportsLongCacheRetention: opt(T.bool()),
181
- supportsStrictMode: opt(T.bool()),
182
- supportsOpenAIGrammarTools: opt(T.bool()),
183
- supportsMaxOutputTokens: opt(T.bool()),
184
- });
185
157
  const costRates = { input: req(T.num()), output: req(T.num()), cacheRead: req(T.num()), cacheWrite: req(T.num()) };
186
158
  const ModelCostTier = T.obj({ inputTokensAbove: req(T.num()), ...costRates });
187
159
  const ModelCost = T.obj({ ...costRates, tiers: opt(T.arr(ModelCostTier)) });
160
+ // `Partial(Record(Enum(["short", "long"]), Number > 0))`, like the thinking map.
188
161
  const ModelPromptCache = T.obj({ short: opt(T.num({ exclusiveMinimum: 0 })), long: opt(T.num({ exclusiveMinimum: 0 })) });
189
162
  const posInt = T.int({ minimum: 1 });
190
163
  const ImageResize = T.obj({ maxWidth: opt(posInt), maxHeight: opt(posInt), maxBytes: opt(posInt), jpegQuality: opt(T.int({ minimum: 1, maximum: 100 })) });
191
164
  const ModelInputLimits = T.obj({ maxRequestBytes: opt(posInt), images: opt(T.obj({ resize: opt(ImageResize), maxPerMessage: opt(posInt), maxPerRequest: opt(posInt) })) });
192
- const AnthropicMessagesCompat = T.obj({
193
- supportsEagerToolInputStreaming: opt(T.bool()),
194
- supportsLongCacheRetention: opt(T.bool()),
195
- sendSessionAffinityHeaders: opt(T.bool()),
196
- supportsCacheControlOnTools: opt(T.bool()),
197
- supportsTemperature: opt(T.bool()),
198
- forceAdaptiveThinking: opt(T.bool()),
199
- allowEmptySignature: opt(T.bool()),
200
- supportsStrictTools: opt(T.bool()),
201
- supportsMidConvoEffort: opt(T.bool()),
165
+ // compat-schema.js's ProviderCompatSchema: the merged properties of the OpenAI completions, OpenAI responses, Anthropic
166
+ // messages, Bedrock and Mistral schemas. A key two of them share takes pi's generic override, which for every shared
167
+ // key here is the same type as each api's own (a boolean, or the three-way affinity format), so the superset is exact.
168
+ const bool = opt(T.bool());
169
+ const ProviderCompat = T.obj({
170
+ // OpenAI completions
171
+ supportsStore: bool,
172
+ supportsDeveloperRole: bool,
173
+ supportsReasoningEffort: bool,
174
+ supportsUsageInStreaming: bool,
175
+ supportsFinishReason: bool,
176
+ maxTokensField: opt(T.union(T.lit("max_completion_tokens"), T.lit("max_tokens"))),
177
+ requiresToolResultName: bool,
178
+ requiresAssistantAfterToolResult: bool,
179
+ requiresThinkingAsText: bool,
180
+ requiresReasoningContentOnAssistantMessages: bool,
181
+ thinkingFormat: opt(T.union(...["openai", "openrouter", "deepseek", "together", "baseten", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"].map((x) => T.lit(x)))),
182
+ chatTemplateKwargs: opt(T.rec(KwargValue)),
183
+ chatTemplateArgs: opt(T.rec(KwargValue)),
184
+ openRouterRouting: opt(OpenRouterRouting),
185
+ vercelGatewayRouting: opt(VercelGatewayRouting),
186
+ zaiToolStream: bool,
187
+ thinkingTokenBudgetField: opt(T.union(T.lit("thinking_token_budget"), T.lit("thinking_budget"), T.lit("thinking_budget_tokens"))),
188
+ supportsThinkingTokenBudget: bool,
189
+ supportsOpenAIGrammarTools: bool,
190
+ supportsMidConvoSystemMessages: bool,
191
+ supportsMidConvoToolAdditions: bool,
192
+ supportsStrictMode: bool,
193
+ cacheControlFormat: opt(T.lit("anthropic")),
194
+ sendSessionAffinityHeaders: bool,
195
+ sessionAffinityFormat: opt(affinity),
196
+ supportsLongCacheRetention: bool,
197
+ vllmPriority: opt(T.num()),
198
+ // OpenAI responses
199
+ supportsAdditionalTools: bool,
200
+ supportsToolSearch: bool,
201
+ supportsExplicitPromptCacheMode: bool,
202
+ supportsMaxOutputTokens: bool,
203
+ // Anthropic messages
204
+ supportsEagerToolInputStreaming: bool,
205
+ supportsCacheControlOnTools: bool,
206
+ supportsTemperature: bool,
207
+ forceAdaptiveThinking: bool,
208
+ allowEmptySignature: bool,
209
+ supportsStrictTools: bool,
210
+ supportsMidConvoEffort: bool,
211
+ supportsMidConvoToolChanges: bool,
202
212
  allowedFallbackModels: opt(T.arr(T.obj({ provider: req(T.str({ minLength: 1 })), model: req(T.str({ minLength: 1 })), cost: req(ModelCost) }), { maxItems: 3 })),
203
213
  });
204
- const ProviderCompat = T.union(OpenAICompletionsCompat, OpenAIResponsesCompat, AnthropicMessagesCompat);
205
214
  const nonEmpty = T.str({ minLength: 1 });
206
215
  const inputKinds = T.arr(T.union(T.lit("text"), T.lit("image")));
207
216
  const headers = T.rec(T.str());
217
+ // 1.2.0: a token count must be above zero, where any number passed before.
218
+ const tokenCount = T.num({ exclusiveMinimum: 0 });
208
219
  const ModelDefinition = T.obj({
209
220
  id: req(nonEmpty),
210
221
  name: opt(nonEmpty),
@@ -216,8 +227,8 @@ const ModelDefinition = T.obj({
216
227
  inputLimits: opt(ModelInputLimits),
217
228
  cost: opt(ModelCost),
218
229
  promptCache: opt(ModelPromptCache),
219
- contextWindow: opt(T.num()),
220
- maxTokens: opt(T.num()),
230
+ contextWindow: opt(tokenCount),
231
+ maxTokens: opt(tokenCount),
221
232
  samplingParams: opt(SamplingParams),
222
233
  samplingParamsByThinkingLevel: opt(SamplingParamsByThinkingLevel),
223
234
  headers: opt(headers),
@@ -231,8 +242,8 @@ const ModelOverride = T.obj({
231
242
  inputLimits: opt(ModelInputLimits),
232
243
  cost: opt(T.obj({ input: opt(T.num()), output: opt(T.num()), cacheRead: opt(T.num()), cacheWrite: opt(T.num()), tiers: opt(T.arr(ModelCostTier)) })),
233
244
  promptCache: opt(ModelPromptCache),
234
- contextWindow: opt(T.num()),
235
- maxTokens: opt(T.num()),
245
+ contextWindow: opt(tokenCount),
246
+ maxTokens: opt(tokenCount),
236
247
  samplingParams: opt(SamplingParams),
237
248
  samplingParamsByThinkingLevel: opt(SamplingParamsByThinkingLevel),
238
249
  headers: opt(headers),
@@ -250,7 +261,8 @@ const ProviderConfig = T.obj({
250
261
  models: opt(T.arr(ModelDefinition)),
251
262
  modelOverrides: opt(T.rec(ModelOverride)),
252
263
  });
253
- const ModelsConfig = T.obj({ providers: req(T.rec(ProviderConfig)) });
264
+ // 1.2.0: a `$schema` key (the published https://pi.dev/schemas/models.schema.json) is declared, as a string.
265
+ const ModelsConfig = T.obj({ $schema: opt(T.str()), providers: req(T.rec(ProviderConfig)) });
254
266
 
255
267
  /**
256
268
  * Parse overlay `models.json` TEXT as pi does. `{ value }` when pi would load it, else `{ error }` with a fixed
package/src/parked.mjs ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * REQ-AWAIT-INPUT, the reply half (issue #625): the forge marker on an issue whose last run is waiting for an answer.
3
+ *
4
+ * Imported by both services, like capabilities.mjs, because the receiver routes on it and the worker writes it: two
5
+ * spellings of one label would be two places for one to drift while every test stayed green.
6
+ *
7
+ * THE LABEL IS A HINT, THE SIDECAR IS THE TRUTH. The receiver cannot read the session store (on a fleet it may not
8
+ * even run beside one), so a label the forge already sends in every comment payload is what lets it route a reply
9
+ * that carries no trigger phrase. Anyone who may label an issue can set it, so it decides nothing on its own: the
10
+ * worker refuses such a reply before any spend unless the key's `parked` sidecar exists (`not-parked`). And it
11
+ * widens WHICH comments start a job, never WHO may: the comment trigger's author gate still runs first.
12
+ */
13
+ export const PARKED_LABEL = "pi-awaiting-input";
14
+
15
+ /** Whether a subset's `[{ name }]` label list (or Azure's tag list, the same shape) carries the marker. */
16
+ export function hasParkedLabel(labels) {
17
+ return Array.isArray(labels) && labels.some((label) => label?.name === PARKED_LABEL);
18
+ }
package/src/processor.mjs CHANGED
@@ -140,11 +140,25 @@ export const TERMINAL_COMMENTS = {
140
140
  "mcp-secret-missing": "Stopped: a credential an MCP server this trigger names needs was not set inside the job, so the run ended before the agent started. The operator needs to check the secrets this trigger passes. Not retried.",
141
141
  "mcp-command-missing": "Stopped: the job image does not have the program an MCP server this trigger names runs, so the run ended before the agent started. The operator needs to install that server in the job image or the overlay. Not retried.",
142
142
  "mcp-shadowed": "Stopped: another extension in this job registers a tool or command that only the job's MCP servers may, so the run ended before the agent started. The operator needs to remove that extension or this trigger's MCP servers. Not retried.",
143
+ // Issue #625. The runner's two refusals of a job armed with run.awaitInput, each before the prompt.
144
+ "await-input-shadowed": "Stopped: another extension in this job registers the tool a run uses to stop and wait for an answer, so the run ended before the agent started. The operator needs to remove that extension or this trigger's run.awaitInput. Not retried.",
145
+ "await-input-without-session": "Stopped: this run may stop to wait for an answer but had nowhere to keep its work, so it ended before the agent started. The operator needs to run a worker and a job image of the same release. Not retried.",
143
146
  // Issue #596. Never the size or the project: both are operator configuration, and the reader may be an issue author
144
147
  // who can act on neither. The worker log and the run record carry them.
145
148
  [EXIT_OOM_KILLED]: "Stopped: the job's container ran out of memory and was stopped. Partial work may exist. Not retried, because the same size would stop the same way. The operator can raise this job's memory size.",
146
149
  };
147
150
 
151
+ // Issue #625: the one comment the COMPLETED path posts, on a run that ended awaiting-input. The agent posted its own
152
+ // question (the tool tells it to, before it ends); this says how the run continues. Fixed and path-free, the
153
+ // TERMINAL_COMMENTS rule, and it carries no model text: the question never crosses the exit line.
154
+ // On an issue any reply continues it (the receiver routes it on the marker); a pull request's reply keeps the phrase,
155
+ // since its session key needs a head branch that costs a token to read (parked.mjs).
156
+ export const PARKED_COMMENT = "This run stopped to wait for an answer to the question above. Reply here, and the next run continues from here.";
157
+ export const PARKED_COMMENT_PR = "This run stopped to wait for an answer to the question above. Reply in a comment that starts with this repository's pi-dispatch comment phrase, and the next run continues from here.";
158
+ export function parkedComment(target) {
159
+ return target?.type === "pull_request" ? PARKED_COMMENT_PR : PARKED_COMMENT;
160
+ }
161
+
148
162
  // Issue #502: the `model-unknown` refusal's comment. Names no model: the reader may be an issue author.
149
163
  export const MODEL_UNKNOWN_COMMENT = "Refused: this job names an AI model that this deployment does not know (it is in neither pi's model catalog nor the overlay models.json), so no container was started and nothing was spent. Ask the operator to check the trigger's model settings. Not run.";
150
164
 
@@ -282,6 +296,12 @@ export async function runJob(job, deps) {
282
296
  // the store, on a COMPLETED exit only. Never throws. The default is a no-op so a wiring that omits
283
297
  // it behaves exactly as before -- no store, no promotion, no session in the record.
284
298
  promoteSession = () => null,
299
+ // (job) => boolean (issue #625): whether the job's session key holds a `parked` mark. Free and never throws. The
300
+ // default answers no, so a wiring without a store refuses every phrase-free reply rather than running one.
301
+ isParked = () => false,
302
+ // (job, on) => Promise<void> (issue #625): add or remove the forge's parked marker. Never throws; the marker is a
303
+ // hint for the receiver, and the sidecar decides. The default does nothing.
304
+ parkedLabel = async () => {},
285
305
  // The session store's root, or null when the feature is unavailable (REQ-RESUMABLE-SESSION). Read
286
306
  // ONLY to answer the fail-closed gate below -- nothing here opens it, and it never reaches the
287
307
  // container env (docker-run.mjs mounts a per-job COPY, never the store).
@@ -345,7 +365,8 @@ export async function runJob(job, deps) {
345
365
  // runContainer({ job, token, prepared, secrets, name, signal, user, home, modelEndpoints?, size?, hostCpus?, unenforced? }) => { code, aborted, abortReason, turns, tokens, session, usage, context, exitReason }.
346
366
  // `size` (issue #596) is `jobSize` below; `hostCpus` the runtime's CPU count off the job-user gate's own facts read.
347
367
  // `unenforced` (issue #596) is the size flags that same read says the runtime drops, passed only when there are some.
348
- // `exitReason` (issue #437) is parseExitReason's closed-set label, read only inside the exit-2 branch.
368
+ // `exitReason` (issue #437) is parseExitReason's closed-set label, read only inside the exit-2 branch, and in the
369
+ // exit-0 branch for `awaiting-input` alone (issue #625).
349
370
  // `resources` and `exitOomKilled` (issue #596) are the exit line's cgroup block and the supervisor's OOM report.
350
371
  // `user`/`home` are the job-user gate's answer (issue #341), null for the image's own USER.
351
372
  // `secrets` is the resolved map from the gate above: values, already fetched, host-side. It MUST honour
@@ -960,6 +981,16 @@ export async function runJob(job, deps) {
960
981
  // purpose: only forge jobs can arm the flag today (triggers.mjs refuses it on cron, and a CLI or
961
982
  // chained job has no trigger entry that could set it), but a gate written as an enumeration of kinds
962
983
  // is a gate the next kind skips silently.
984
+ // A reply that reached this job WITHOUT the trigger phrase (issue #625) was routed only because the forge said the
985
+ // issue carries the parked marker, and anyone who may label an issue can set that. The truth is the key's `parked`
986
+ // mark beside its transcript, and it is free to read, so it is asked here, before the mint and before any spend.
987
+ // Without it the reply is ordinary conversation on an issue: refused, silently (no comment, since the person was
988
+ // talking to people), and the stale marker is cleared so the next comment is not routed here again.
989
+ if (job.parkedReply === true && !isParked(job)) {
990
+ log("refused_not_parked", { kind: job.kind ?? null });
991
+ await parkedLabel(job, false);
992
+ return { outcome: "policy", reason: "not-parked", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
993
+ }
963
994
  if (job.resume === true && !sessionsDir) {
964
995
  await comment(job, "Refused: this trigger set `run.resume` but PI_SESSIONS_DIR is unset, so there is nowhere to persist the transcript -- the job would run with no session and still report success. Set PI_SESSIONS_DIR to a private directory outside every repo, or drop `run.resume` from this trigger. Not run.");
965
996
  // The variable NAME, never a value: there is no path to print here (its absence IS the refusal),
@@ -1482,9 +1513,21 @@ export async function runJob(job, deps) {
1482
1513
  // (CONST-RETRY-INFRA-ONLY). Same completed-only rule INT-OUTBOX-CONTRACT already uses, and
1483
1514
  // it sits beside the chain collection for the same reason: both must happen before the
1484
1515
  // `finally` deletes jobDir. Never throws.
1485
- const promoted = prepared.session ? promoteSession(prepared.session, { piVersion, context }) : null;
1516
+ // A run that asked a person a question and ended (issue #625, REQ-AWAIT-INPUT) is still a completed run: it is
1517
+ // promoted like any other, with the key marked parked, and the one fixed comment says how it continues.
1518
+ // exitReason is RUNNER_COMPLETED_REASONS-filtered and read only here, on code 0, so it cannot move a job
1519
+ // between retry classes. Only a job armed with run.awaitInput may park: a line that says so on any other
1520
+ // job is ignored, since that runner was never offered the tool.
1521
+ const parked = exitReason === "awaiting-input" && job.awaitInput === true;
1522
+ const promoted = prepared.session ? promoteSession(prepared.session, { piVersion, context, parked }) : null;
1523
+ if (parked) await comment(job, parkedComment(job.target));
1524
+ // The forge marker the receiver routes a phrase-free reply on: set on a park, cleared when a run on a key that
1525
+ // could park ends without parking. Never throws, and decides nothing: the sidecar promotion just wrote does.
1526
+ if (parked) await parkedLabel(job, true);
1527
+ else if (job.awaitInput === true || job.parkedReply === true) await parkedLabel(job, false);
1486
1528
  return {
1487
1529
  outcome: "completed",
1530
+ ...(parked ? { reason: "awaiting-input" } : {}),
1488
1531
  exitCode: code,
1489
1532
  turns,
1490
1533
  tokens,
@@ -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
@@ -311,7 +311,7 @@ export async function enqueueGitLabJob(queue, fields) {
311
311
  * window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
312
312
  * has always been.
313
313
  */
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 }) {
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, awaitInput, parkedReply, secrets, secretsProfile, waitFor, replica, replicas }) {
315
315
  const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
316
316
  // `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
317
317
  // come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
@@ -366,6 +366,12 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
366
366
  // delivery, and `trigger` is what /job/event.json is built from.
367
367
  ...(instructions !== undefined && { instructions }),
368
368
  ...(resume !== undefined && { resume }),
369
+ // REQ-AWAIT-INPUT (issue #625): only when armed, so an unflagged job's data is byte-identical, and at JOB level
370
+ // for resume's reason: `trigger` is copied verbatim into /job/event.json.
371
+ ...(awaitInput === true && { awaitInput: true }),
372
+ // A phrase-free reply to a parked issue (issue #625), gated by the worker on the key's `parked` mark. Never part
373
+ // of an id: it says how the delivery was routed, not what it is.
374
+ ...(parkedReply === true && { parkedReply: true }),
369
375
  // REQ-TRIGGER-SECRETS. The env variables this trigger binds and the profile whose resolver reads
370
376
  // them. REFERENCES only: no value is ever enqueued, because a queued job is durable and a resolved
371
377
  // 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
@@ -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