pi-orchestrator-config 4.22.3 → 4.23.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/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## [4.23.0] - 2026-11-02
4
+
5
+ - Minimum pi version raised to 1.5.0 — the extension floor, the sidecar floor, and every manifest move together.
6
+ - Interactive dialogs (ask_user and the pidash bridge) now report "blocked on user input" while they wait, so terminals that track program status show pi as waiting instead of busy.
7
+ - This changelog ships with the package and pi reads it after `pi update --extensions`, so an update now tells you what changed.
8
+ - The `subagent` tool keeps its `agentScope` values as a real enum after pi 1.5.0 changed how `StringEnum` types itself, so the parameter stays validated and documented.
9
+ - Release workflow keeps this changelog current on every bump, and a changelog-only change now marks the package as affected.
10
+ - Docs: MCP OAuth client registration (`auto` / `dcr` / `cimd`), `PI_SELF_UPDATE_COMMAND` for wrapper installs, and the billed-vs-estimated cost contract.
11
+
12
+ ## [4.22.4] - 2026-11-02
13
+
14
+ - Minimum pi version raised to 1.5.0 (extension and sidecar floors, manifests).
15
+ - Sidecar reports cost billed by the provider instead of only estimated cost.
16
+ - Status reporting for interactive dialogs blocked waiting on user input.
package/README.md CHANGED
@@ -210,6 +210,22 @@ After updating, run `/reload` in pi or restart pi to pick up changes.
210
210
  Refresh the global agent package after acpx-provider / cli-provider / settings
211
211
  changes so `~/.pi` is not a mixed checkout (see issue #651).
212
212
 
213
+ ### Custom update command
214
+
215
+ `pi update` installs a new release with a global npm install. When a wrapper or another
216
+ package manager provides pi — mise, pnpm, nix — point it at that manager's command instead:
217
+
218
+ ```bash
219
+ export PI_SELF_UPDATE_COMMAND="mise use -g npm:{packageName}@{version}"
220
+ ```
221
+
222
+ `pi update` runs the command only when pi.dev reports a newer release, replacing
223
+ `{packageName}`, `{version}`, and `{installSpec}` (`{packageName}@{version}`) with that
224
+ release. The value is split on spaces; for an argument that contains spaces, set the global
225
+ `selfUpdateCommand` setting in `~/.pi/agent/settings.json` as an argv array such as
226
+ `["nix", "profile", "upgrade", "pi"]`. It can only be set in the agent-directory settings,
227
+ and `PI_SELF_UPDATE_COMMAND` takes precedence over it.
228
+
213
229
  ## Usage
214
230
 
215
231
  ### Automatic delegation
@@ -1076,7 +1092,7 @@ it also runs as a pre-commit hook when TypeScript files change.
1076
1092
 
1077
1093
  ## Prerequisites
1078
1094
 
1079
- - [pi](https://github.com/badlogic/pi-mono) (minimum version: **1.4.0**). Pidash uses Pi's `ui_prompt_start` and `ui_prompt_end` events to show prompt wait separately from agent work.
1095
+ - [pi](https://github.com/badlogic/pi-mono) (minimum version: **1.5.0**). Pidash uses Pi's `ui_prompt_start` and `ui_prompt_end` events to show prompt wait separately from agent work.
1080
1096
  - `gh` CLI (for GitHub operations)
1081
1097
  - `uv` (for Python execution)
1082
1098
  - `myk-pi-tools` (optional, for `/pr-review` and `/release`)
@@ -44,11 +44,20 @@ Before ANY `git commit`, if `review_loop_enforcement` is `true`:
44
44
  {{SETTINGS:dco,commit_trailer,use_worktrees,allow_push_to_protected_branches}}
45
45
 
46
46
  - If `dco` is `true`: add `--signoff` to `git commit`. Skip if already present.
47
- - If `commit_trailer` is a string (e.g. `"Assisted-by"`): append trailer to the commit message.
48
- Format: `TRAILER: PI (MODEL) <noreply@pi.dev>` — replace MODEL with your actual model id
49
- (e.g. `litellm/claude-opus-4-6`). NEVER write the literal string `$PI_MODEL`; quoted heredocs
50
- and single-quoted messages do not expand it and the trailer lands broken. Skip if already present.
51
- If the value contains commas, use the first name.
47
+ - If `commit_trailer` is a string (e.g. `"Assisted-by"`): every commit you make MUST carry that
48
+ trailer, and who writes it depends on the commit shape:
49
+ - `git commit -m "…"` → the enforcement hook appends `TRAILER: PI (<model>)`.
50
+ Do not write it yourself.
51
+ - `echo … | git commit -F -` → the hook appends it. Do not write it yourself.
52
+ - `git commit -F <file>` (message written to a file, including heredoc-to-file) → the hook never
53
+ sees the file's contents, so append the trailer yourself with the already-resolved id:
54
+ `printf '\n\nTRAILER: PI (%s)\n' "$PI_MODEL" >> <file>` and then commit. The shell expands
55
+ `$PI_MODEL` while writing the file, so no placeholder can reach the message.
56
+ Never write a bare `MODEL` placeholder and never leave an unexpanded `$PI_MODEL` in a `-m` message:
57
+ the enforcement hook **refuses** such a commit (it reports the placeholder line back), and it also
58
+ refuses a commit shape it cannot inspect when the message shows no trailer. The fix is always to
59
+ write the trailer yourself with the real id, then commit again.
60
+ Skip only when the message already carries that trailer.
52
61
  - If `use_worktrees` is `true`: BLOCK `git checkout` (except `git checkout -- <file>`) and `git switch`.
53
62
  Use `git worktree add .worktrees/NAME -b BRANCH DEFAULT_BRANCH` instead
54
63
  (detect default branch via `git symbolic-ref refs/remotes/origin/HEAD`, fallback to `main`).
@@ -14,6 +14,9 @@ import {
14
14
  } from "@earendil-works/pi-tui";
15
15
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
16
16
  import { Type } from "typebox";
17
+ import { createLogger } from "../shared/logger.js";
18
+
19
+ const log = createLogger("ask-user");
17
20
 
18
21
  export function registerAskUser(
19
22
  pi: ExtensionAPI,
@@ -78,6 +81,7 @@ export function registerAskUser(
78
81
  }
79
82
  });
80
83
 
84
+ log.debug("ask_user prompt opened with blocked status", { askId, mode: params.options?.length ? "select" : "input" });
81
85
  const result = await ctx.ui.custom<string | null>(
82
86
  (tui, theme, _kb, done) => {
83
87
  // Allow browser to resolve this dialog
@@ -260,7 +264,7 @@ export function registerAskUser(
260
264
  },
261
265
  };
262
266
  },
263
-
267
+ { blocked: { kind: "question", message: params.question } },
264
268
  );
265
269
 
266
270
  unsubscribe();
@@ -2606,24 +2606,103 @@ function bodyAlreadySigned(text: string): boolean {
2606
2606
  return false;
2607
2607
  }
2608
2608
 
2609
- // Trailer-shaped line: `Token:` at line start (real newline, literal \n
2610
- // escape, or command start), then the PI trailer identity. Anchoring to the
2611
- // line start keeps mid-sentence prose and unrelated compound-command parts
2612
- // untouched (Qodo 988d3a68) — only an actual trailer line is rewritten.
2613
- const unexpandedTrailerLine = /(^|\n|\\n|-m\s+['"])([ \t]*[A-Za-z][A-Za-z0-9-]*:[ \t]*)PI \(\$PI_MODEL\)/g;
2614
-
2615
- /** #932: an agent may write the commit trailer with the literal `$PI_MODEL`
2616
- * (typically inside a quoted heredoc, where the shell cannot expand it).
2617
- * Rewrite the trailer identity to the resolved model id before the commit
2618
- * runs. Returns the fixed command, or null when nothing needed fixing.
2619
- * Scoped to `git commit` commands and to trailer-shaped lines only, so
2620
- * commit-message prose that legitimately mentions `$PI_MODEL` — bare or
2621
- * mid-sentence — and unrelated parts of compound commands are untouched. */
2622
- export function fixUnexpandedCommitTrailer(command: string, modelId: string): string | null {
2623
- if (!command.includes("$PI_MODEL")) return null;
2609
+ // Trailer-shaped line: `Token:` at line start (real newline, literal \n escape, or
2610
+ // command start), then a PI trailer identity that is a PLACEHOLDER rather than a model
2611
+ // id. Detection only — the hook refuses such a commit instead of rewriting text it may
2612
+ // have misparsed (Qodo, PR #1004).
2613
+ const placeholderTrailerLine = /(^|\n|\\n|-m\s+['"])([ \t]*[A-Za-z][A-Za-z0-9-]*:[ \t]*)PI \(\$?\{?PI_MODEL\}?|MODEL\)/g;
2614
+
2615
+ /** The trailer line carrying an unresolved placeholder, or null when the command has none.
2616
+ * A `Token:` anchor keeps prose that merely mentions the placeholder out of the result. */
2617
+ export function findPlaceholderTrailer(command: string): string | null {
2618
+ if (!/(?:PI_MODEL|\bMODEL\b)/.test(command)) return null;
2624
2619
  if (!/\bgit\s+commit\b/.test(command)) return null;
2625
- const fixed = command.replace(unexpandedTrailerLine, `$1$2PI (${modelId})`);
2626
- return fixed === command ? null : fixed;
2620
+ const matches = [...command.matchAll(placeholderTrailerLine)];
2621
+ const found = matches.at(-1);
2622
+ if (!found) {
2623
+ enfLog.debug("placeholder trailer none");
2624
+ return null;
2625
+ }
2626
+ const line = `${(found[2] ?? "").trim()} PI (placeholder)`;
2627
+ enfLog.warn("placeholder trailer found", line);
2628
+ return line;
2629
+ }
2630
+
2631
+ /** Trailers where git itself needs an address: `Co-authored-by` credits a person. */
2632
+ const TRAILERS_REQUIRING_EMAIL = new Set(["co-authored-by"]);
2633
+
2634
+ /** The value appended after `<Trailer>:`. `Assisted-by` needs no address, `Co-authored-by`
2635
+ * does — hosting integrations only credit a co-author through `Name <email>`. */
2636
+ export function commitTrailerIdentity(trailerName: string, modelId: string): string {
2637
+ const needsEmail = TRAILERS_REQUIRING_EMAIL.has(trailerName.trim().toLowerCase());
2638
+ const identity = needsEmail ? `PI (${modelId}) <noreply@pi.dev>` : `PI (${modelId})`;
2639
+ enfLog.debug("commit_trailer_identity", trailerName, needsEmail);
2640
+ return identity;
2641
+ }
2642
+
2643
+ /** Does a `-F <file>` commit message already carry the trailer?
2644
+ * The caller injects the reader so the decision is testable without touching disk —
2645
+ * the hook reads the file, it does not guess from the command text (Qodo, PR #1004). */
2646
+ export function messageFileTrailerState(
2647
+ filePath: string,
2648
+ trailerName: string,
2649
+ read: (path: string) => string,
2650
+ ): "has" | "missing" | "unreadable" {
2651
+ try {
2652
+ const state = commandHasTrailerByName(read(filePath).slice(0, 64_000), trailerName) ? "has" : "missing";
2653
+ enfLog.debug("message_file_trailer", filePath, state);
2654
+ return state;
2655
+ } catch (error: any) {
2656
+ // warn, not debug: trailer verification could not inspect the message, which is a
2657
+ // degraded guarantee an operator needs to see (Qodo, PR #1004).
2658
+ enfLog.warn("message_file_trailer unreadable", filePath, String(error?.message ?? error));
2659
+ return "unreadable";
2660
+ }
2661
+ }
2662
+
2663
+ /** Append `<Trailer>: PI (<model>)` to the message of a real commit invocation.
2664
+ * Covers the two shapes whose message the hook can see — `echo … | git commit -F -`
2665
+ * and `git commit -m "…"` — and returns null when the trailer is already there or
2666
+ * the shape is not covered. Pure so the injection path is testable without a hook. */
2667
+ export function injectCommitTrailer(command: string, trailerName: string, modelId: string): string | null {
2668
+ const identity = commitTrailerIdentity(trailerName, modelId);
2669
+ // Pattern A: echo "..." | git commit -F -
2670
+ const pipeIdx = command.lastIndexOf("|");
2671
+ if (pipeIdx !== -1 && /git\s+commit\s+.*-F\s*-/.test(command.slice(pipeIdx))) {
2672
+ const echoPart = command.slice(0, pipeIdx);
2673
+ const gitPart = command.slice(pipeIdx);
2674
+ const lastQuoteIdx = Math.max(echoPart.lastIndexOf('"'), echoPart.lastIndexOf("'"));
2675
+ if (lastQuoteIdx > 0) {
2676
+ if (commandHasTrailerByName(echoPart, trailerName)) {
2677
+ enfLog.debug("inject_commit_trailer", "already present", trailerName);
2678
+ return null;
2679
+ }
2680
+ const quoteChar = echoPart[lastQuoteIdx];
2681
+ const escaped = quoteChar === "'"
2682
+ ? `${escapeForSingleQuote(trailerName)}: ${identity}`
2683
+ : `${escapeForDoubleQuote(trailerName)}: ${identity}`;
2684
+ enfLog.info("inject_commit_trailer", "piped", trailerName);
2685
+ return echoPart.slice(0, lastQuoteIdx) + `\\n\\n${escaped.replace(/\n/g, "\\n")}` + echoPart.slice(lastQuoteIdx) + gitPart;
2686
+ }
2687
+ return null;
2688
+ }
2689
+ // Pattern B: git commit -m "..." or git commit -m '...'
2690
+ const mFlagMatch = command.match(/git\s+commit\s+.*-m\s+(["'])([\s\S]*?)\1/);
2691
+ if (!mFlagMatch) return null;
2692
+ // Every `-m` is a message paragraph, so an existing trailer in ANY of them wins.
2693
+ const allPayloads = [...command.matchAll(/-m\s+(["'])([\s\S]*?)\1/g)].map((m) => m[2] ?? "").join("\n");
2694
+ if (commandHasTrailerByName(allPayloads, trailerName)) {
2695
+ enfLog.debug("inject_commit_trailer", "already present", trailerName);
2696
+ return null;
2697
+ }
2698
+ const quoteChar = mFlagMatch[1];
2699
+ const escaped = quoteChar === "'"
2700
+ ? `${escapeForSingleQuote(trailerName)}: ${identity}`
2701
+ : `${escapeForDoubleQuote(trailerName)}: ${identity}`;
2702
+ const fullMatch = mFlagMatch[0];
2703
+ const insertPos = command.indexOf(fullMatch) + fullMatch.length - 1;
2704
+ enfLog.info("inject_commit_trailer", "inline", trailerName);
2705
+ return command.slice(0, insertPos) + `\n\n${escaped}` + command.slice(insertPos);
2627
2706
  }
2628
2707
 
2629
2708
  /** Keep the first standalone valid footer, but use the current operation's model. */
@@ -20,7 +20,8 @@ import { getProjectTmpDir, resolveWorktreeRoot } from "./utils.js";
20
20
  import {
21
21
  conflictCandidateDirs,
22
22
  pushTargetsProtectedBranch,
23
- fixUnexpandedCommitTrailer,
23
+ findPlaceholderTrailer,
24
+ messageFileTrailerState,
24
25
  maskQuotedRegions,
25
26
  isStdinFedShellStatement,
26
27
  } from "./enforcement-helpers.js";
@@ -53,6 +54,7 @@ import {
53
54
  gitSubArgRegions,
54
55
  hasGitAddBulk,
55
56
  hasGitAddForce,
57
+ injectCommitTrailer,
56
58
  injectSignoff,
57
59
  injectGhBodySignature,
58
60
  isGitIdentityConfigPath,
@@ -118,9 +120,23 @@ function writeRepeatState(state: { lastCmd: string; count: number }): void {
118
120
 
119
121
 
120
122
 
123
+ /** Placeholder strings that are never a real model id — printing one in a trailer ships a bug. */
124
+ const PLACEHOLDER_MODEL_IDS = new Set(["model", "pi_model", "$pi_model", "${pi_model}", "unknown"]);
125
+
121
126
  /** Resolve the model id used in commit/PR trailers (env first, then ctx). */
122
- function commitTrailerModelId(ctx: any): string {
123
- return (process.env.PI_PRIMARY_MODEL || process.env.PI_MODEL || (ctx as any).model?.id || "unknown").replace(/[^a-zA-Z0-9._:@/ -]/g, "").trim() || "unknown";
127
+ export function commitTrailerModelId(ctx: any): string {
128
+ for (const raw of [process.env.PI_PRIMARY_MODEL, process.env.PI_MODEL, (ctx as any).model?.id]) {
129
+ const trimmed = (raw ?? "").trim();
130
+ if (!trimmed) continue;
131
+ if (PLACEHOLDER_MODEL_IDS.has(trimmed.toLowerCase())) {
132
+ log.debug("commit_trailer: skipped placeholder model id", trimmed);
133
+ continue;
134
+ }
135
+ const clean = trimmed.replace(/[^a-zA-Z0-9._:@/ -]/g, "").trim();
136
+ if (clean) return clean;
137
+ }
138
+ log.warn("commit_trailer: no model id resolved — trailer identity is unknown");
139
+ return "unknown";
124
140
  }
125
141
 
126
142
  /** Inject commit trailer into command (Assisted-by, Co-authored-by, etc.) */
@@ -128,18 +144,20 @@ function commitTrailerModelId(ctx: any): string {
128
144
  let bridgedUi: ReturnType<typeof createBridgedUi> | null = null;
129
145
 
130
146
  async function handleCommitTrailer(command: string, event: any, ctx: any, gitCwd: string): Promise<EnforcementResult> {
131
- // #932: rewrite a literal `$PI_MODEL` in the trailer identity — an agent
132
- // following the git-expert trailer instruction may write the placeholder
133
- // verbatim (quoted heredocs and single-quoted -m strings never expand it).
134
- // This runs BEFORE the setting check and pattern matching so heredoc/-F
135
- // commits — which the injection patterns below do not cover — still get a
136
- // correct trailer, and the dedup check below sees the already-fixed name.
137
- const literalModelId = commitTrailerModelId(ctx);
138
- const fixedCommand = fixUnexpandedCommitTrailer(command, literalModelId);
139
- if (fixedCommand !== null) {
140
- log.info("commit_trailer: replaced unexpanded $PI_MODEL literal with model id", literalModelId);
141
- command = fixedCommand;
142
- event.input.command = fixedCommand;
147
+ // A placeholder trailer is REFUSED, never rewritten: the hook cannot safely tell
148
+ // command text from message prose (a commit message quoting the very shape it
149
+ // parses, a `;` inside quotes, indented examples), and rewriting the wrong span
150
+ // corrupts a message or edits an unrelated command. The agent has the text, so it
151
+ // fixes it and retries (Qodo, PR #1004).
152
+ const placeholder = findPlaceholderTrailer(command);
153
+ if (placeholder) {
154
+ return {
155
+ block: true,
156
+ reason:
157
+ `⛔ Commit refused: the trailer carries an unresolved placeholder (${placeholder}). ` +
158
+ `Write \`TRAILER: PI (${commitTrailerModelId(ctx)})\` instead of a MODEL/$PI_MODEL placeholder ` +
159
+ "and re-run the commit.",
160
+ };
143
161
  }
144
162
 
145
163
  const trailerSetting = getSetting(gitCwd, "commit_trailer");
@@ -147,7 +165,6 @@ async function handleCommitTrailer(command: string, event: any, ctx: any, gitCwd
147
165
 
148
166
  const modelId = commitTrailerModelId(ctx);
149
167
 
150
- const piIdentity = `PI (${modelId}) <noreply@pi.dev>`;
151
168
  let trailerName: string;
152
169
 
153
170
  if (trailerSetting.includes(",")) {
@@ -184,55 +201,54 @@ async function handleCommitTrailer(command: string, event: any, ctx: any, gitCwd
184
201
  trailerName = trailerSetting;
185
202
  }
186
203
 
187
- // Pattern A: echo "..." | git commit -F -
188
- const pipeIdx = command.lastIndexOf("|");
189
- if (pipeIdx !== -1 && /git\s+commit\s+.*-F\s*-/.test(command.slice(pipeIdx))) {
190
- const echoPart = command.slice(0, pipeIdx);
191
- const gitPart = command.slice(pipeIdx);
192
- const lastDoubleQuote = echoPart.lastIndexOf('"');
193
- const lastSingleQuote = echoPart.lastIndexOf("'");
194
- const lastQuoteIdx = Math.max(lastDoubleQuote, lastSingleQuote);
195
- if (lastQuoteIdx > 0) {
196
- // Determine quote context for correct escaping
197
- const quoteChar = echoPart[lastQuoteIdx];
198
- // Scope dedup to the echo/message-construction portion (before the pipe), which
199
- // excludes the git args. Do NOT attempt to slice out the exact quoted substring
200
- // by backward-scanning for a quote — escaped quotes (\") inside the message would
201
- // misparse the boundary and cause a missed trailer -> duplicate injection.
202
- if (commandHasTrailerByName(echoPart, trailerName)) {
203
- log.debug("commit_trailer already present in echo message (by name) — skipping injection", trailerName);
204
- return undefined;
205
- }
206
- const escaped = quoteChar === "'"
207
- ? `${escapeForSingleQuote(trailerName)}: ${piIdentity}`
208
- : `${escapeForDoubleQuote(trailerName)}: ${piIdentity}`;
209
- event.input.command =
210
- echoPart.slice(0, lastQuoteIdx) + `\\n\\n${escaped.replace(/\n/g, "\\n")}` + echoPart.slice(lastQuoteIdx) + gitPart;
211
- }
204
+ const injected = injectCommitTrailer(command, trailerName, modelId);
205
+ if (injected !== null) {
206
+ event.input.command = injected;
207
+ return undefined;
212
208
  }
213
- // Pattern B: git commit -m "..." or git commit -m '...'
214
- else {
215
- const mFlagMatch = command.match(/git\s+commit\s+.*-m\s+(["'])([\s\S]*?)\1/);
216
- if (mFlagMatch) {
217
- const quoteChar = mFlagMatch[1];
218
- // Scope duplicate-by-name detection to the -m message payload only
219
- // (mFlagMatch[2]) — not the whole command — so an unrelated arg/env var
220
- // containing the trailer token does not wrongly skip injection.
221
- const payload = mFlagMatch[2];
222
- if (commandHasTrailerByName(payload, trailerName)) {
223
- log.debug("commit_trailer already present in message (by name) — skipping injection", trailerName);
224
- return undefined;
225
- }
226
- const escaped = quoteChar === "'"
227
- ? `${escapeForSingleQuote(trailerName)}: ${piIdentity}`
228
- : `${escapeForDoubleQuote(trailerName)}: ${piIdentity}`;
229
- const fullMatch = mFlagMatch[0];
230
- const insertPos = command.indexOf(fullMatch) + fullMatch.length - 1;
231
- event.input.command =
232
- command.slice(0, insertPos) + `\n\n${escaped}` + command.slice(insertPos);
209
+ // `git commit -F <file>`: the message is a file we CAN read, so check it instead of
210
+ // guessing. Refusing a message that already carries the trailer would block the very
211
+ // flow the git-expert instruction describes (Qodo, PR #1004).
212
+ const fileMessage = command.match(/git\s+commit\b[^\n]*?-F\s+(?!-)(["']?)([^\s"']+)\1/);
213
+ if (fileMessage && fileMessage[2]) {
214
+ // The read happens BEFORE the command runs, so a command that writes the same file
215
+ // would be verified against its OLD contents while git commits the new ones. Refuse
216
+ // that shape instead of trusting the stale read (Qodo, PR #1004).
217
+ const filePathSrc = fileMessage[2].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
218
+ if (bashWritesToPath(command, maskQuotedRegions(command), filePathSrc)) {
219
+ log.warn("commit_trailer: message file is written by the same command", fileMessage[2]);
220
+ return {
221
+ block: true,
222
+ reason:
223
+ `⛔ Commit refused: this command writes ${fileMessage[2]} and commits it in the same call, ` +
224
+ "so the message cannot be verified before it is read. Write the message (with the trailer) in one call and commit in the next.",
225
+ };
226
+ }
227
+ const candidate = path.isAbsolute(fileMessage[2]) ? fileMessage[2] : join(gitCwd, fileMessage[2]);
228
+ const state = messageFileTrailerState(candidate, trailerName, (p) => readFileSync(p, "utf8"));
229
+ if (state === "has") {
230
+ log.debug("commit_trailer: message file already carries the trailer", candidate);
231
+ return undefined;
232
+ }
233
+ if (state === "missing") {
234
+ return {
235
+ block: true,
236
+ reason:
237
+ `⛔ Commit refused: the message file ${fileMessage[2]} has no \`${trailerName}\` trailer. ` +
238
+ `Append it with the real id — \`printf '\\n\\n" + trailerName + ": PI (%s)\\n' \"$PI_MODEL\" >> " + fileMessage[2] + "\` — then re-run the commit.`,
239
+ };
233
240
  }
234
241
  }
235
-
242
+ // Other opaque shapes must carry the trailer already; refusing beats guessing.
243
+ if (!commandHasTrailerByName(command, trailerName)) {
244
+ return {
245
+ block: true,
246
+ reason:
247
+ `⛔ Commit refused: no \`${trailerName}\` trailer found and this commit shape is opaque to the hook ` +
248
+ `(the message is built in a way it cannot inspect). Add \`${trailerName}: PI (${modelId})\` to the message ` +
249
+ "yourself — e.g. `printf '\\n\\n" + trailerName + ": PI (%s)\\n' \"$PI_MODEL\" >> <file>` for `-F <file>` — then re-run the commit.",
250
+ };
251
+ }
236
252
  return undefined;
237
253
  }
238
254
 
@@ -14,7 +14,6 @@ const require = createRequire(import.meta.url);
14
14
 
15
15
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
16
16
  import type { Message } from "@earendil-works/pi-ai";
17
- import { StringEnum } from "@earendil-works/pi-ai";
18
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
18
  import { createLogger } from "../shared/logger.js";
20
19
  import { createBridgedUi } from "../shared/ui-bridge.js";
@@ -107,7 +106,12 @@ const ChainItem = Type.Object({
107
106
  cwd: Type.String({ description: "Working directory" }),
108
107
  estimatedSeconds: Type.Number({ description: "Estimated step duration in seconds" }),
109
108
  });
110
- const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
109
+ // pi 1.5.0's StringEnum returns a pi-schema Schema, which TypeBox's Static cannot
110
+ // read — params.agentScope degraded to `unknown`. Unsafe keeps the JSON-Schema
111
+ // shape providers need while carrying the literal union in the static type.
112
+ const AgentScopeSchema = Type.Unsafe<AgentScope>({
113
+ type: "string",
114
+ enum: ["user", "project", "both"],
111
115
  description: 'Agent directories to use. Default: "both".',
112
116
  default: "both",
113
117
  });
@@ -263,7 +263,7 @@ export function tryGetSystemPromptOptions(ctx: any): { contextFiles?: any[]; ski
263
263
  * read from ~/.pi/agent/mcp.json by pi itself), and the built-in MCP/codemode/
264
264
  * tool-search extensions become the ones we must not collide with.
265
265
  */
266
- export const MIN_PI_VERSION = "1.4.0";
266
+ export const MIN_PI_VERSION = "1.5.0";
267
267
 
268
268
  /** Get the installed pi version from its package.json. */
269
269
  export function getPiVersion(): string | null {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Prefer this over legacy `pi.registerProvider(name, { streamSimple })` bags so
5
5
  * providers get /login auth, fetchModels refresh, filterModels, and native
6
- * ProviderStreams (requires pi >= 1.4.0).
6
+ * ProviderStreams (requires pi >= 1.5.0).
7
7
  */
8
8
 
9
9
  import { createLogger } from "./logger.js";
@@ -110,6 +110,10 @@ export function createBridgedUi(pi: ExtensionAPI): BridgedUi {
110
110
  externalDone(resp.value);
111
111
  });
112
112
 
113
+ // Report "blocked on user input" (OSC 7501) while the dialog is visible.
114
+ const blocked = { kind: "question" as const, message: message ? `${title} — ${message}` : title };
115
+ log.debug("bridged prompt opened with blocked status", { id, method, kind: blocked.kind });
116
+
113
117
  try {
114
118
  const result = (await ctx.ui.custom((tui: any, theme: any, _kb: any, done: (v: any) => void) => {
115
119
  let resolved = false;
@@ -215,7 +219,7 @@ export function createBridgedUi(pi: ExtensionAPI): BridgedUi {
215
219
  tui.requestRender();
216
220
  },
217
221
  };
218
- })) as T | undefined;
222
+ }, { blocked })) as T | undefined;
219
223
  return result;
220
224
  } finally {
221
225
  unsubscribe?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.22.3",
3
+ "version": "4.23.0",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -15,6 +15,7 @@
15
15
  "extensions/pidiff/pidiff-ui"
16
16
  ],
17
17
  "files": [
18
+ "CHANGELOG.md",
18
19
  "extensions/acpx-provider/",
19
20
  "extensions/cli-provider/",
20
21
  "extensions/providers/",
@@ -76,10 +77,10 @@
76
77
  "ws": "^8.0.0"
77
78
  },
78
79
  "devDependencies": {
79
- "@earendil-works/pi-agent-core": "^1.4.0",
80
- "@earendil-works/pi-ai": "^1.4.0",
81
- "@earendil-works/pi-coding-agent": "^1.4.0",
82
- "@earendil-works/pi-tui": "^1.4.0",
80
+ "@earendil-works/pi-agent-core": "^1.5.0",
81
+ "@earendil-works/pi-ai": "^1.5.0",
82
+ "@earendil-works/pi-coding-agent": "^1.5.0",
83
+ "@earendil-works/pi-tui": "^1.5.0",
83
84
  "@types/node": "^22.20.1",
84
85
  "jsdom": "^30.1.1",
85
86
  "tsx": "^4.0.0"
@@ -100,6 +101,7 @@
100
101
  "protobufjs": "7.6.5"
101
102
  },
102
103
  "pi": {
104
+ "changelogPath": "./CHANGELOG.md",
103
105
  "extensions": [
104
106
  "./extensions"
105
107
  ],
@@ -128,7 +128,12 @@ root only).
128
128
  stage ONLY the files in `updated[]` plus the refreshed lockfile
129
129
  (`lockfile.path` when `lockfile.refreshed` is true — `refreshed: false`
130
130
  is a warning: run `uv lock` yourself before committing); each entry in `packages[]`
131
- carries its per-package `tag` (`<name>@<version>`). Bump PR, merge,
131
+ carries its per-package `tag` (`<name>@<version>`). When
132
+ `pi-orchestrator-config` is among the bumped packages, also prepend its entry
133
+ to `CHANGELOG.md` — `## [<its new version>] - <YYYY-MM-DD>` plus its S3
134
+ changelog entries, newest first — and stage `CHANGELOG.md` with the bump
135
+ files, so the npm tarball carries the notes pi shows under Extension
136
+ Updates. Bump PR, merge,
132
137
  and branch sync follow the standard Phase 5 delegation (git-expert /
133
138
  github-expert), with the bump PR message `chore: bump version to
134
139
  <name1>@<v1>[, <name2>@<v2>...]` (keep the `chore: bump version`
@@ -275,6 +280,14 @@ Parse the JSON output from bump-version. Only stage the files listed in the
275
280
  `updated[]` array. If `skipped[]` is non-empty, inform the user which files were
276
281
  skipped and why before proceeding.
277
282
 
283
+ When `pi-orchestrator-config` (the root `package.json`) was bumped, also write
284
+ its notes into `CHANGELOG.md`: prepend `## [<VERSION>] - <YYYY-MM-DD>` followed
285
+ by that package's changelog entries from Phase 3 (newest first, same bullet
286
+ style as the entries already there), and stage `CHANGELOG.md` with the version
287
+ files. The npm tarball ships the file and pi shows it under Extension Updates on
288
+ the next launch — leave it alone and users keep seeing the previous version's
289
+ notes.
290
+
278
291
  **Delegate git and GitHub operations to specialist agents:**
279
292
 
280
293
  1. **Delegate to `git-expert`:** Create branch, stage files, sync lockfile, commit, push:
@@ -34,6 +34,8 @@ Outside slash commands, never use edit, write, or bash directly (except `codemod
34
34
 
35
35
  ## Before Implementation (MANDATORY)
36
36
 
37
+ **For 2+ distinct actions, your first action is one `TaskBulkCreate` listing every step** — before the first edit or the first agent call (see `60-task-tracking.md`).
38
+
37
39
  Before ANY code changes, run the pre-implementation checklist:
38
40
 
39
41
  → **See the "Pre-Implementation Checklist" section below** — Do NOT skip this step.
@@ -7,58 +7,37 @@
7
7
 
8
8
  ---
9
9
 
10
- ## When to Create Tasks
10
+ ## When Tasks Are Required
11
11
 
12
- **BEFORE starting any multi-step workflow (3+ steps)**, use `TaskCreate` to list ALL steps first.
12
+ The request needs **2+ distinct actions** — then tasks are required:
13
13
 
14
- **Use tasks for:** feature implementation, bug fixes with multiple files,
15
- workflows from `rules/05-issue-first-workflow.md` or `rules/20-code-review-loop.md`,
16
- multi-file refactoring, or any work involving more than 2 actions.
14
+ - more than one file to change, or code plus tests plus docs
15
+ - any workflow from `05-issue-first-workflow.md` or `20-code-review-loop.md`
16
+ - the user lists several things to do ("do X, then Y", numbered items)
17
+ - work conditioned on a future event ("after Qodo approves, deploy and test") — create the tasks now, put the trigger in the description, and block the dependents on it
17
18
 
18
- **Deferred missions count as multi-step work.** When the user assigns work
19
- conditional on a future event ("after X", "once Y", "when Z"), create the
20
- tasks IMMEDIATELY in that same turn — the trigger condition goes in the task
21
- description, and dependent tasks block on the trigger task. Never carry a
22
- deferred mission only in conversation memory: compaction or a session
23
- restart loses it silently.
19
+ **No tasks:** a single-step action, a one-file/one-line change, a question, an explanation, read-only investigation.
24
20
 
25
- - ❌ BAD: "after Qodo approves, deploy and test" → acknowledged, no task
26
- - ✅ GOOD: `TaskCreate("Deploy X locally — trigger: Qodo R6 clean")` + blocked follow-ups
21
+ ## First Action
27
22
 
28
- **Skip tasks for:** single-step actions, questions/explanations, or trivial fixes.
23
+ Plan the whole job in **one `TaskBulkCreate` call** listing every step — not one `TaskCreate` per step, and not after the first edit. `TaskCreate` is for a single step added later.
29
24
 
30
- ---
31
-
32
- ## Task Granularity
25
+ When `TaskCreate`'s own tool description suggests per-step calls, **this rule wins**: one bulk call for the whole job.
33
26
 
34
- Tasks MUST be **detailed and specific** — not high-level summaries.
27
+ ## Granularity
35
28
 
36
- - ❌ BAD: `Implement the fix` / `Review code` / `Create PR`
37
- - ✅ GOOD: `Edit extensions/orchestrator/utils.ts — add timeout parameter to fetchData()` / `Run code review loop (3 async reviewers)` / `Create PR with description`
29
+ One concrete action per task: which file, which change.
38
30
 
39
- ---
31
+ - ❌ `Implement the fix` / `Create PR`
32
+ - ✅ `Edit extensions/orchestrator/utils.ts — add timeout parameter to fetchData()` / `Create PR with the test plan`
40
33
 
41
- ## Task Lifecycle (MANDATORY)
34
+ ## Lifecycle
42
35
 
43
- Create all tasks BEFORE starting work via `TaskCreate`.
44
- Mark each `in_progress` before starting and `completed` immediately after finishing via `TaskUpdate`.
45
- Work through tasks in order — do not skip tasks or start new work while unchecked tasks exist (unless user explicitly pivots).
46
-
47
- ---
36
+ `in_progress` before starting a task, `completed` immediately after. Work in order. Never abandon a task — drop a stale one with `TaskUpdate status: "deleted"`.
48
37
 
49
38
  ## Side Questions
50
39
 
51
- Answer the question, then resume the next unchecked task immediately — the persistent task list tells you exactly where you left off.
52
-
53
- ---
54
-
55
- ## Integration & Key Rules
56
-
57
- Task tracking works alongside existing workflow rules — include issue-first, code review, and documentation steps as individual tasks.
58
- {{IF:task_reminder_enabled}}
59
- Tasks are code-enforced (reminders after 4+ ignored turns).
60
- {{/IF}}
61
- Never abandon tasks — if scope changes, use `TaskUpdate` with `status: "deleted"` to remove obsolete tasks; the task list is your contract.
40
+ Answer the question, then resume the next unchecked task — the task list says where you left off.
62
41
 
63
42
  ---
64
43