@oh-my-pi/pi-coding-agent 16.3.11 → 16.3.12

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +3119 -3028
  3. package/dist/types/advisor/runtime.d.ts +11 -0
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/settings-schema.d.ts +6 -0
  6. package/dist/types/config/settings.d.ts +2 -0
  7. package/dist/types/discovery/helpers.d.ts +9 -0
  8. package/dist/types/exec/bash-executor.d.ts +1 -0
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
  11. package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
  12. package/dist/types/modes/components/model-selector.d.ts +2 -1
  13. package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
  14. package/dist/types/modes/utils/context-usage.d.ts +0 -12
  15. package/dist/types/modes/workflow.d.ts +5 -1
  16. package/dist/types/session/agent-session.d.ts +5 -0
  17. package/dist/types/system-prompt.d.ts +1 -1
  18. package/dist/types/tools/bash-interactive.d.ts +1 -1
  19. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  20. package/dist/types/tools/bash.d.ts +2 -1
  21. package/dist/types/tools/browser/launch.d.ts +1 -0
  22. package/dist/types/tools/grep.d.ts +2 -0
  23. package/dist/types/tools/index.d.ts +4 -0
  24. package/dist/types/tools/path-utils.d.ts +24 -0
  25. package/dist/types/tools/read.d.ts +2 -0
  26. package/dist/types/utils/local-date.d.ts +2 -0
  27. package/package.json +12 -12
  28. package/src/advisor/__tests__/advisor.test.ts +145 -0
  29. package/src/advisor/runtime.ts +19 -0
  30. package/src/config/api-key-resolver.ts +7 -2
  31. package/src/config/model-registry.ts +9 -0
  32. package/src/config/settings-schema.ts +11 -1
  33. package/src/config/settings.ts +11 -0
  34. package/src/discovery/builtin.ts +2 -1
  35. package/src/discovery/claude-plugins.ts +167 -46
  36. package/src/discovery/helpers.ts +16 -1
  37. package/src/edit/renderer.ts +20 -6
  38. package/src/eval/js/worker-core.ts +163 -6
  39. package/src/exec/bash-executor.ts +14 -9
  40. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
  41. package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
  42. package/src/extensibility/shared-events.ts +2 -2
  43. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
  44. package/src/internal-urls/docs-index.generated.txt +1 -1
  45. package/src/internal-urls/registry-helpers.ts +9 -6
  46. package/src/modes/components/model-selector.ts +30 -6
  47. package/src/modes/components/settings-defs.ts +1 -1
  48. package/src/modes/components/status-line/component.ts +14 -2
  49. package/src/modes/controllers/command-controller.ts +13 -23
  50. package/src/modes/controllers/event-controller.ts +12 -12
  51. package/src/modes/controllers/extension-ui-controller.ts +6 -35
  52. package/src/modes/controllers/input-controller.ts +18 -2
  53. package/src/modes/controllers/mcp-command-controller.ts +10 -9
  54. package/src/modes/controllers/selector-controller.ts +16 -5
  55. package/src/modes/github-ref-autocomplete.ts +75 -0
  56. package/src/modes/interactive-mode.ts +54 -10
  57. package/src/modes/prompt-action-autocomplete.ts +35 -0
  58. package/src/modes/utils/context-usage.ts +58 -5
  59. package/src/modes/utils/hotkeys-markdown.ts +2 -1
  60. package/src/modes/utils/ui-helpers.ts +2 -2
  61. package/src/modes/workflow.ts +14 -8
  62. package/src/prompts/system/plan-mode-active.md +5 -2
  63. package/src/prompts/system/system-prompt.md +1 -2
  64. package/src/prompts/system/workflow-notice.md +69 -50
  65. package/src/prompts/tools/bash.md +18 -7
  66. package/src/prompts/tools/grep.md +1 -1
  67. package/src/prompts/tools/read.md +4 -3
  68. package/src/sdk.ts +11 -0
  69. package/src/session/agent-session.ts +128 -14
  70. package/src/system-prompt.ts +3 -2
  71. package/src/tools/bash-interactive.ts +1 -1
  72. package/src/tools/bash-skill-urls.ts +39 -7
  73. package/src/tools/bash.ts +69 -39
  74. package/src/tools/browser/launch.ts +31 -4
  75. package/src/tools/grep.ts +53 -14
  76. package/src/tools/index.ts +11 -0
  77. package/src/tools/path-utils.ts +46 -1
  78. package/src/tools/read.ts +108 -50
  79. package/src/utils/local-date.ts +7 -0
  80. package/src/utils/open.ts +36 -10
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Autocomplete for GitHub issue/PR references typed as `#<number>` (e.g. `#3164`).
3
+ *
4
+ * Mirrors the `@` file-reference and `scheme://` internal-url conventions: the
5
+ * token is rewritten to an internal URL (`pr://3164` or `issue://3164`) plus a
6
+ * trailing space, and the existing tool-mediated pipeline (the `read` tool →
7
+ * InternalUrlRouter → `gh`) resolves it from the session cwd's git remote.
8
+ *
9
+ * No network at suggestion time — candidates are generated locally. GitHub
10
+ * shares the issue/PR number space and there is no cheap way to tell which a
11
+ * given number is while typing, so both a PR and an Issue candidate are offered
12
+ * by default. Naming the type first (`pr #3164` / `issue #3164`) constrains the
13
+ * candidates to that kind. Anything that is not a standalone `#<number>` token
14
+ * keeps falling through to the existing prompt-action menu.
15
+ */
16
+ import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
17
+
18
+ /** Candidate kinds, in default display order. */
19
+ const GITHUB_REF_KINDS = [
20
+ { qualifier: "pr", scheme: "pr", label: "PR", description: "GitHub pull request" },
21
+ { qualifier: "issue", scheme: "issue", label: "Issue", description: "GitHub issue" },
22
+ ] as const;
23
+
24
+ export interface GithubRefContext {
25
+ /** Text to replace on accept: `#3164`, or `pr #3164` when a qualifier precedes it. */
26
+ prefix: string;
27
+ /** Type the user named (`pr`/`pull` → `pr`, `issue` → `issue`), or null to offer both. */
28
+ qualifier: "pr" | "issue" | null;
29
+ /** The numeric reference, e.g. `3164`. */
30
+ number: string;
31
+ }
32
+
33
+ /**
34
+ * A standalone `#<positive-number>` token ending at the cursor. The `#` must be
35
+ * preceded by a token boundary (start, whitespace, or an opening quote/paren/`<`/`=`,
36
+ * matching the internal-URL boundary set) so embedded hashes like `owner/repo#N`,
37
+ * `foo#N`, `C#12`, or a URL fragment do not match. An optional `pr`/`pull`/`issue`
38
+ * qualifier word (case-insensitive) immediately before the `#` constrains the kind.
39
+ */
40
+ const GITHUB_REF_TOKEN_RE = /(?:^|[\s"'`(<=])(?:(pr|pull|issue)(\s+))?#([1-9]\d*)$/i;
41
+
42
+ export function getGithubRefContext(textBeforeCursor: string): GithubRefContext | null {
43
+ const match = textBeforeCursor.match(GITHUB_REF_TOKEN_RE);
44
+ if (!match) return null;
45
+ const qualifierWord = match[1];
46
+ const whitespace = match[2] ?? "";
47
+ const number = match[3] ?? "";
48
+ return {
49
+ prefix: qualifierWord ? `${qualifierWord}${whitespace}#${number}` : `#${number}`,
50
+ qualifier: !qualifierWord ? null : qualifierWord.toLowerCase() === "issue" ? "issue" : "pr",
51
+ number,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Suggestions for a `#<number>` token. Both kinds are offered unless the user
57
+ * named a type (`pr #3164` / `issue #3164`), in which case only that kind is
58
+ * offered. Returns `null` when the text before the cursor is not a standalone
59
+ * `#<number>` token.
60
+ */
61
+ export function getGithubRefSuggestions(
62
+ textBeforeCursor: string,
63
+ ): { items: AutocompleteItem[]; prefix: string } | null {
64
+ const context = getGithubRefContext(textBeforeCursor);
65
+ if (!context) return null;
66
+ const kinds = context.qualifier
67
+ ? GITHUB_REF_KINDS.filter(kind => kind.qualifier === context.qualifier)
68
+ : GITHUB_REF_KINDS;
69
+ const items: AutocompleteItem[] = kinds.map(kind => ({
70
+ value: `${kind.scheme}://${context.number}`,
71
+ label: `${kind.label} #${context.number}`,
72
+ description: kind.description,
73
+ }));
74
+ return { items, prefix: context.prefix };
75
+ }
@@ -579,10 +579,10 @@ export class InteractiveMode implements InteractiveModeContext {
579
579
  this.retryLoader.stop();
580
580
  this.retryLoader = undefined;
581
581
  }
582
- this.statusContainer.clear();
583
- this.pendingMessagesContainer.clear();
582
+ this.statusContainer.disposeChildren();
583
+ this.pendingMessagesContainer.disposeChildren();
584
584
  this.#cancelModelCycleClearTimer();
585
- this.modelCycleContainer.clear();
585
+ this.modelCycleContainer.disposeChildren();
586
586
  this.compactionQueuedMessages = [];
587
587
  this.streamingComponent = undefined;
588
588
  this.streamingMessage = undefined;
@@ -2602,6 +2602,49 @@ export class InteractiveMode implements InteractiveModeContext {
2602
2602
  }
2603
2603
  }
2604
2604
 
2605
+ #resolveLocalRoot(): string {
2606
+ return resolveLocalUrlToPath("local://", {
2607
+ getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
2608
+ getSessionId: () => this.sessionManager.getSessionId(),
2609
+ });
2610
+ }
2611
+
2612
+ async #copyLocalArtifactsForFreshSession(sourceRoot: string, destinationRoot: string): Promise<void> {
2613
+ if (sourceRoot === destinationRoot) return;
2614
+
2615
+ let sourceRootStat: { isDirectory(): boolean };
2616
+ try {
2617
+ sourceRootStat = await fs.lstat(sourceRoot);
2618
+ } catch (error) {
2619
+ if (isEnoent(error)) return;
2620
+ throw error;
2621
+ }
2622
+
2623
+ if (!sourceRootStat.isDirectory()) return;
2624
+
2625
+ await fs.mkdir(destinationRoot, { recursive: true });
2626
+ await this.#copyLocalArtifactEntries(sourceRoot, destinationRoot);
2627
+ }
2628
+
2629
+ async #copyLocalArtifactEntries(sourceDir: string, destinationDir: string): Promise<void> {
2630
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
2631
+ for (const entry of entries) {
2632
+ const sourcePath = path.join(sourceDir, entry.name);
2633
+ const destinationPath = path.join(destinationDir, entry.name);
2634
+
2635
+ if (entry.isDirectory()) {
2636
+ await fs.mkdir(destinationPath, { recursive: true });
2637
+ await this.#copyLocalArtifactEntries(sourcePath, destinationPath);
2638
+ continue;
2639
+ }
2640
+
2641
+ if (entry.isFile()) {
2642
+ await fs.mkdir(path.dirname(destinationPath), { recursive: true });
2643
+ await fs.copyFile(sourcePath, destinationPath);
2644
+ }
2645
+ }
2646
+ }
2647
+
2605
2648
  async #approvePlan(
2606
2649
  planContent: string,
2607
2650
  options: {
@@ -2632,14 +2675,16 @@ export class InteractiveMode implements InteractiveModeContext {
2632
2675
  });
2633
2676
 
2634
2677
  if (!options.preserveContext) {
2678
+ const oldLocalRoot = this.#resolveLocalRoot();
2635
2679
  await this.handleClearCommand();
2636
- // The new session has a fresh local:// root — persist the approved plan there
2637
- // so `local://<slug>-plan.md` resolves correctly in the execution session.
2680
+ const newLocalRoot = this.#resolveLocalRoot();
2681
+ await this.#copyLocalArtifactsForFreshSession(oldLocalRoot, newLocalRoot);
2638
2682
  const newLocalPath = resolveLocalUrlToPath(options.planFilePath, {
2639
2683
  getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
2640
2684
  getSessionId: () => this.sessionManager.getSessionId(),
2641
2685
  });
2642
- await Bun.write(newLocalPath, planContent);
2686
+ await fs.mkdir(path.dirname(newLocalPath), { recursive: true });
2687
+ await fs.writeFile(newLocalPath, planContent);
2643
2688
  } else if (options.compactBeforeExecute) {
2644
2689
  // Distill the plan-mode transcript before the execution turn is queued so
2645
2690
  // the plan-approved synthetic prompt lands as a fresh cache anchor.
@@ -3626,7 +3671,7 @@ export class InteractiveMode implements InteractiveModeContext {
3626
3671
  ensureLoadingAnimation(): void {
3627
3672
  if (!this.loadingAnimation) {
3628
3673
  this.#clearWorkingMessageAccentCache();
3629
- this.statusContainer.clear();
3674
+ this.statusContainer.disposeChildren();
3630
3675
  const messageColorFn = ((message: string) =>
3631
3676
  renderWorkingMessage(message, this.#getWorkingMessageAccent())) as LoaderMessageColorFn & {
3632
3677
  animated?: true;
@@ -3647,7 +3692,7 @@ export class InteractiveMode implements InteractiveModeContext {
3647
3692
  );
3648
3693
  this.statusContainer.addChild(this.loadingAnimation);
3649
3694
  } else if (!this.statusContainer.children.includes(this.loadingAnimation)) {
3650
- this.statusContainer.clear();
3695
+ this.statusContainer.disposeChildren();
3651
3696
  this.statusContainer.addChild(this.loadingAnimation);
3652
3697
  this.ui.requestRender();
3653
3698
  }
@@ -3660,7 +3705,7 @@ export class InteractiveMode implements InteractiveModeContext {
3660
3705
  this.loadingAnimation = undefined;
3661
3706
  this.#clearWorkingMessageAccentCache();
3662
3707
  if (clearStatusContainer) {
3663
- this.statusContainer.clear();
3708
+ this.statusContainer.disposeChildren();
3664
3709
  }
3665
3710
  }
3666
3711
 
@@ -4123,7 +4168,6 @@ export class InteractiveMode implements InteractiveModeContext {
4123
4168
  }
4124
4169
  this.#btwController.dispose();
4125
4170
  this.#omfgController.dispose();
4126
- this.chatContainer.clear();
4127
4171
  this.renderInitialMessages({ clearTerminalHistory: true });
4128
4172
  this.updateEditorBorderColor();
4129
4173
  this.showStatus(
@@ -9,6 +9,7 @@ import {
9
9
  import { formatKeyHints, type KeybindingsManager } from "../config/keybindings";
10
10
  import { isSettingsInitialized, settings } from "../config/settings";
11
11
  import { applyEmojiCompletion, getEmojiSuggestions, isEmojiPrefix, tryEmojiInlineReplace } from "./emoji-autocomplete";
12
+ import { getGithubRefContext, getGithubRefSuggestions } from "./github-ref-autocomplete";
12
13
  import {
13
14
  applyInternalUrlCompletion,
14
15
  getInternalUrlSuggestions,
@@ -94,6 +95,36 @@ function getPromptActionPrefix(textBeforeCursor: string): string | null {
94
95
  return textBeforeCursor.slice(hashIndex);
95
96
  }
96
97
 
98
+ function applyGithubRefCompletion(
99
+ lines: string[],
100
+ cursorLine: number,
101
+ cursorCol: number,
102
+ item: AutocompleteItem,
103
+ prefix: string,
104
+ ): { lines: string[]; cursorLine: number; cursorCol: number } | null {
105
+ if (!getGithubRefContext(prefix)) return null;
106
+ const scheme: "pr" | "issue" | null = item.value.startsWith("pr://")
107
+ ? "pr"
108
+ : item.value.startsWith("issue://")
109
+ ? "issue"
110
+ : null;
111
+ if (!scheme) return { lines, cursorLine, cursorCol };
112
+
113
+ const currentLine = lines[cursorLine] || "";
114
+ const liveContext = getGithubRefContext(currentLine.slice(0, cursorCol));
115
+ if (!liveContext || (liveContext.qualifier && liveContext.qualifier !== scheme)) {
116
+ return { lines, cursorLine, cursorCol };
117
+ }
118
+
119
+ return applyInternalUrlCompletion(
120
+ lines,
121
+ cursorLine,
122
+ cursorCol,
123
+ { ...item, value: `${scheme}://${liveContext.number}` },
124
+ liveContext.prefix,
125
+ );
126
+ }
127
+
97
128
  export class PromptActionAutocompleteProvider implements AutocompleteProvider {
98
129
  #commands: SlashCommand[];
99
130
  #baseProvider: CombinedAutocompleteProvider;
@@ -129,6 +160,8 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
129
160
  }
130
161
  }
131
162
 
163
+ const githubRefSuggestions = getGithubRefSuggestions(textBeforeCursor);
164
+ if (githubRefSuggestions) return githubRefSuggestions;
132
165
  const promptActionPrefix = getPromptActionPrefix(textBeforeCursor);
133
166
  if (promptActionPrefix) {
134
167
  const query = promptActionPrefix.slice(1).toLowerCase();
@@ -176,6 +209,8 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
176
209
  cursorCol: number;
177
210
  onApplied?: () => void;
178
211
  } {
212
+ const githubRefCompletion = applyGithubRefCompletion(lines, cursorLine, cursorCol, item, prefix);
213
+ if (githubRefCompletion) return githubRefCompletion;
179
214
  if (prefix.startsWith("#") && isPromptActionItem(item)) {
180
215
  if (item.actionId === "undo") {
181
216
  return {
@@ -43,6 +43,7 @@ export interface ContextBreakdown {
43
43
 
44
44
  const EMPTY_STRING_PARTS: readonly string[] = [];
45
45
  const EMPTY_TOOLS: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">> = [];
46
+ const EMPTY_SKILLS: readonly Skill[] = [];
46
47
 
47
48
  export function estimateSkillsTokens(skills: readonly Skill[]): number {
48
49
  const fragments: string[] = [];
@@ -86,10 +87,58 @@ export function estimateToolSchemaTokens(
86
87
  * cadence — non-message recomputed only when the inputs identity changes,
87
88
  * messages walked incrementally as new entries append.
88
89
  */
90
+ // Non-message inputs (system prompt, tools, skills) change rarely — at most
91
+ // once per turn via setSystemPrompt/setTools — but the per-turn compaction and
92
+ // threshold paths call these helpers several times: getContextBreakdown calls
93
+ // both, and #estimateStoredContextTokens adds a third. Memoize on the identity
94
+ // of the three input arrays so the expensive parts (system-prompt tokenization
95
+ // and the per-tool JSON.stringify(toolWireSchema) inside estimateToolSchemaTokens)
96
+ // run at most once per input change rather than per call. The identity keys are
97
+ // the same stable references the StatusLineComponent cache already trusts
98
+ // (setSystemPrompt/setTools replace the array reference rather than mutating it).
99
+ interface NonMessageTokenCache {
100
+ systemPromptRef: readonly string[];
101
+ toolsRef: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>;
102
+ skillsRef: readonly Skill[];
103
+ tokens: number | undefined;
104
+ breakdown:
105
+ | {
106
+ skillsTokens: number;
107
+ toolsTokens: number;
108
+ systemContextTokens: number;
109
+ systemPromptTokens: number;
110
+ }
111
+ | undefined;
112
+ }
113
+
114
+ const nonMessageTokenCache = new WeakMap<AgentSession, NonMessageTokenCache>();
115
+
116
+ function nonMessageTokenCacheEntry(session: AgentSession): NonMessageTokenCache {
117
+ const systemPromptRef = session.systemPrompt ?? EMPTY_STRING_PARTS;
118
+ const toolsRef = session.agent?.state?.tools ?? EMPTY_TOOLS;
119
+ const skillsRef = session.skills ?? EMPTY_SKILLS;
120
+ let entry = nonMessageTokenCache.get(session);
121
+ if (
122
+ entry &&
123
+ entry.systemPromptRef === systemPromptRef &&
124
+ entry.toolsRef === toolsRef &&
125
+ entry.skillsRef === skillsRef
126
+ ) {
127
+ return entry;
128
+ }
129
+ entry = { systemPromptRef, toolsRef, skillsRef, tokens: undefined, breakdown: undefined };
130
+ nonMessageTokenCache.set(session, entry);
131
+ return entry;
132
+ }
133
+
89
134
  export function computeNonMessageTokens(session: AgentSession): number {
135
+ const entry = nonMessageTokenCacheEntry(session);
136
+ if (entry.tokens !== undefined) return entry.tokens;
90
137
  const systemPromptParts = session.systemPrompt ?? EMPTY_STRING_PARTS;
91
138
  const tools = session.agent?.state?.tools ?? EMPTY_TOOLS;
92
- return countTokens(systemPromptParts) + estimateToolSchemaTokens(tools);
139
+ const tokens = countTokens(systemPromptParts) + estimateToolSchemaTokens(tools);
140
+ entry.tokens = tokens;
141
+ return tokens;
93
142
  }
94
143
 
95
144
  /**
@@ -104,12 +153,16 @@ export function computeNonMessageBreakdown(session: AgentSession): {
104
153
  systemContextTokens: number;
105
154
  systemPromptTokens: number;
106
155
  } {
107
- const skillsTokens = estimateSkillsTokens(session.skills ?? []);
108
- const toolsTokens = estimateToolSchemaTokens(session.agent?.state?.tools ?? []);
109
- const systemPromptParts = session.systemPrompt ?? [];
156
+ const entry = nonMessageTokenCacheEntry(session);
157
+ if (entry.breakdown) return entry.breakdown;
158
+ const skillsTokens = estimateSkillsTokens(session.skills ?? EMPTY_SKILLS);
159
+ const toolsTokens = estimateToolSchemaTokens(session.agent?.state?.tools ?? EMPTY_TOOLS);
160
+ const systemPromptParts = session.systemPrompt ?? EMPTY_STRING_PARTS;
110
161
  const systemContextTokens = countTokens(systemPromptParts.slice(1));
111
162
  const systemPromptTokens = Math.max(0, countTokens(systemPromptParts[0] ?? "") - skillsTokens);
112
- return { skillsTokens, toolsTokens, systemContextTokens, systemPromptTokens };
163
+ const breakdown = { skillsTokens, toolsTokens, systemContextTokens, systemPromptTokens };
164
+ entry.breakdown = breakdown;
165
+ return breakdown;
113
166
  }
114
167
 
115
168
  /**
@@ -52,7 +52,8 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
52
52
  `| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image or text from clipboard |`,
53
53
  "| Hold `Space` | Speech-to-text (push-to-talk): hold to record, release to transcribe |",
54
54
  `| \`${appKey(bindings, "app.agents.hub")}\` / \`${appKey(bindings, "app.session.observe")}\` / double-tap \`←\` (empty editor) | Open the agent hub |`,
55
- "| `#` | Open prompt actions |",
55
+ "| `#<number>` | GitHub issue/PR reference (e.g. `#3164` → `pr://`/`issue://`) |",
56
+ "| `#` / `#<text>` | Prompt actions (copy / undo / move cursor) |",
56
57
  "| `/` | Slash commands |",
57
58
  "| `!` | Run bash command |",
58
59
  "| `!!` | Run bash command (excluded from context) |",
@@ -581,7 +581,7 @@ export class UiHelpers {
581
581
  } else {
582
582
  this.ctx.resetTranscript();
583
583
  }
584
- this.ctx.pendingMessagesContainer.clear();
584
+ this.ctx.pendingMessagesContainer.disposeChildren();
585
585
  this.ctx.pendingBashComponents = [];
586
586
  this.ctx.pendingPythonComponents = [];
587
587
 
@@ -647,7 +647,7 @@ export class UiHelpers {
647
647
  }
648
648
 
649
649
  updatePendingMessagesDisplay(): void {
650
- this.ctx.pendingMessagesContainer.clear();
650
+ this.ctx.pendingMessagesContainer.disposeChildren();
651
651
  const queuedMessages = this.ctx.viewSession.getQueuedMessages() as QueuedMessages;
652
652
 
653
653
  const steeringMessages: Array<{ message: string; label: string }> = [];
@@ -1,4 +1,5 @@
1
- import workflowNotice from "../prompts/system/workflow-notice.md" with { type: "text" };
1
+ import { prompt } from "@oh-my-pi/pi-utils";
2
+ import workflowNoticeTemplate from "../prompts/system/workflow-notice.md" with { type: "text" };
2
3
  import { createGradientHighlighter, type KeywordHighlighter } from "./gradient-highlight";
3
4
  import { keywordInProse } from "./markdown-prose";
4
5
 
@@ -7,18 +8,23 @@ import { keywordInProse } from "./markdown-prose";
7
8
  *
8
9
  * Typing the standalone word in the input editor paints it with a warm
9
10
  * amber→green gradient ({@link highlightWorkflow}); submitting a message that
10
- * mentions it appends a hidden {@link WORKFLOW_NOTICE} that steers the model to
11
- * author a deterministic multi-subagent workflow in eval cells (agent/parallel/
12
- * pipeline). Matching is whitespace-delimited and case-sensitive (lowercase
13
- * only) — "workflowz" triggers, but "workflowzed", "Workflowz", and
14
- * "workflowz.ts" never do.
11
+ * mentions it appends a hidden workflow notice that steers the model to author
12
+ * a deterministic multi-subagent workflow through the active task schema.
13
+ * Matching is whitespace-delimited and case-sensitive (lowercase only) —
14
+ * "workflowz" triggers, but "workflowzed", "Workflowz", and "workflowz.ts"
15
+ * never do.
15
16
  */
16
17
 
17
18
  // Detection: lowercase keyword flanked by whitespace or a string edge. Non-global so `.test` stays stateless.
18
19
  const WORKFLOW_WORD = /(?<!\S)workflowz(?!\S)/;
19
20
 
20
- /** Hidden system notice appended after a user message that mentions "workflowz". */
21
- export const WORKFLOW_NOTICE: string = workflowNotice.trim();
21
+ /** WORKFLOW_NOTICE is the default hidden notice for sessions with batched task calls enabled. */
22
+ export const WORKFLOW_NOTICE: string = renderWorkflowNotice({ taskBatch: true });
23
+
24
+ /** renderWorkflowNotice renders the workflow notice for the active task schema. */
25
+ export function renderWorkflowNotice({ taskBatch }: { taskBatch: boolean }): string {
26
+ return prompt.render(workflowNoticeTemplate, { taskBatch }).trim();
27
+ }
22
28
 
23
29
  /**
24
30
  * Whether `text` contains the standalone keyword "workflowz"
@@ -1,7 +1,10 @@
1
1
  <critical>
2
- Plan mode is active. You MUST perform READ-ONLY work only:
3
- - You NEVER create, edit, or delete files except the single plan file named below.
2
+ Plan mode is active. You MUST preserve read-only working-tree and system semantics:
3
+ - You NEVER create, edit, delete, or rename working-tree files.
4
4
  - You NEVER run state-changing commands (`git commit`, `npm install`, migrations) or make any other system change.
5
+ - `local://` artifacts are session-local planning artifacts. You MAY create or update them when explicitly requested or needed for the plan.
6
+ - You NEVER delete or rename `local://` artifacts.
7
+ - You MUST write the canonical plan to `local://<slug>-plan.md`.
5
8
 
6
9
  To leave plan mode and implement: call `resolve` with `action: "apply"`, a `reason`, and `extra: { title: "<slug>" }`, where `<slug>` matches your `local://<slug>-plan.md`. The user then picks an execution option and full write access is restored. `<slug>` may contain only letters, numbers, underscores, and hyphens.
7
10
 
@@ -110,9 +110,8 @@ You MUST use the specialized tool over its shell equivalent:
110
110
  {{#has tools "lsp"}}- Code intelligence → `{{toolRefs.lsp}}`.{{/has}}
111
111
  {{#has tools "grep"}}- Regex search → `{{toolRefs.grep}}`, not `grep`, `rg`, or `awk`.{{/has}}
112
112
  {{#has tools "glob"}}- Globbing → `{{toolRefs.glob}}`, not `ls **/*.ext` or `fd`.{{/has}}
113
- {{#has tools "eval"}}- Default for any compute: `{{toolRefs.eval}}` cells. Bash is the EXCEPTION — only single binary calls or short fact-computing pipelines (`wc -l`, `sort | uniq -c`, `diff`, checksums). The moment a command grows a loop, conditional, heredoc, `-e`/`-c` script, `$(…)` nesting, or >2 pipe stages, it's a program → `{{toolRefs.eval}}`. NEVER write multiline or inline-script bash.{{/has}}
114
113
  {{#has tools "bash"}}- `{{toolRefs.bash}}`: real binaries and short fact pipelines only. Commands shadowing the specialized tools above are blocked.{{/has}}
115
- {{#has tools "bash"}}- Litmus: one external-CLI call or short pipeline returning a count, frequency, set difference, or checksum → bash.{{#has tools "eval"}} Needs control flow, state, or fights shell quoting → `{{toolRefs.eval}}`.{{/has}} Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}}
114
+ {{#has tools "bash"}}- Litmus: one external-CLI call or short pipeline returning a count, frequency, set difference, or checksum → bash. Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}}
116
115
 
117
116
  {{#has tools "report_tool_issue"}}
118
117
  <critical>
@@ -1,70 +1,89 @@
1
1
  <system-notice>
2
- The user's message above contains the **workflowz** keyword: drive this task as a deterministic multi-subagent workflow. Author the orchestration as Python in the `eval` tool and fan out subagents — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before you commit), or to take on scale one context can't hold (audits, migrations, broad sweeps). This overrides any default tendency to do the whole task inline when fanning out would be more thorough.
2
+ The user's message above contains the **workflowz** keyword: drive this task as a deterministic multi-subagent workflow. Use the `task` tool {{#if taskBatch}}for batched fan-out{{else}}once per independent subagent{{/if}} — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before you commit), or to take on scale one context can't hold (audits, migrations, broad sweeps). This overrides any default tendency to do the whole task inline when fanning out would be more thorough.
3
3
 
4
4
  <when>
5
- Worth it when the task benefits from decomposition + parallel coverage, or from independent/adversarial cross-checking before you commit. For a quick lookup or single edit, just do it directly — don't spin up agents. Scout inline FIRST (list the files, scope the diff, find the call sites) to discover the work-list, then fan out over it — you don't need to know the shape before the *task*, only before the *fan-out*. Common shapes, each a well-scoped `eval` call you can chain across turns:
6
- - **Understand** — parallel readers over subsystems → structured map
7
- - **Design** — judge panel of N independent approaches → scored synthesis
8
- - **Review** — split into dimensions → find per dimension → adversarially verify each finding
9
- - **Research** — multi-modal sweep → deep-read the hits → synthesize
10
- - **Migrate** — discover sites → transform each → verify
5
+ Worth it when the task benefits from decomposition + parallel coverage, or from independent/adversarial cross-checking before you commit. For a quick lookup or single edit, just do it directly — don't spin up agents. Scout inline first (list the files, scope the diff, find the call sites) to discover the work list, then fan out over it. Common shapes:
6
+ - **Understand** — parallel readers over subsystems → structured map.
7
+ - **Design** — independent approaches → scored synthesis.
8
+ - **Review** — split dimensions → find per dimension → adversarially verify each finding.
9
+ - **Research** — multi-modal sweep → deep-read the hits → synthesize.
10
+ - **Migrate** — discover sites → transform each → verify.
11
11
  </when>
12
12
 
13
- <helpers>
14
- State persists across eval calls, so scout in one call and fan out in the next. Every eval call has:
13
+ <task-contract>
14
+ {{#if taskBatch}}
15
+ Call `task` once per independent fan-out batch. Put shared background in `context`, and put each independent work item in `tasks[]`. Do not emulate batching with shell loops or eval helper APIs.
15
16
 
16
- - `agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("explore", "reviewer", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes. Recursion follows `task.maxRecursionDepth` (default 2; `-1` uses eval's hard cap 3): main agent depth = 0, each `agent()` child increments depth by 1, and a spawner may call `agent()` only while its current `taskDepth < effective cap`. Pass `isolated=True` to run the spawn in a copy-on-write worktree so parallel `agent()` calls can edit overlapping files safely — strict opt-in, mirrors the `task` tool, defaults off regardless of `task.isolation.mode`; `isolated=True` while the setting is `"none"` errors out instead of silently downgrading. With isolation, `apply=False` keeps changes in the worktree, and `merge=False` forces patch mode even when the setting is `"branch"`. Captured root patch path, branch name, nested repo patches, and apply summary reach the workflow through `handle=True` — combine it with `apply=False` (or `apply=False, schema=…`) and read `node["patch_path"]`, `node["branch_name"]`, `node["nested_patches"]`, `node["changes_applied"]`, `node["isolation_summary"]` (JS: same keys camelCased) to recover artifacts.
17
- - `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool is bounded by the session's `task` concurrency — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
18
- - `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`.
19
- - `completion(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out.
20
- - `log(message)` — emit a progress line above the status tree. `phase(title)` — start a phase; the status lines that follow group under it.
21
- - `budget` — `budget.total` (output-token ceiling, or `None` when none is set), `budget.spent()` (tokens spent this turn — main loop + eval subagents), `budget.remaining()` (`math.inf` when total is `None`), `budget.hard` (whether it's enforced). A ceiling is set by the user: `+Nk` in their message is advisory (you self-limit via `budget.remaining()`), `+Nk!` (or Goal Mode) is hard — `agent()` refuses to spawn once spent reaches it. Gate loops on `budget.total` first, since it's `None` when the user set no budget.
17
+ `context` must carry the shared contract:
22
18
 
23
- Everything runs INLINE and synchronously inside the eval call — no background mode, no resume, no separate progress app. Each eval call is one well-scoped fan-out; chain several across calls and turns for multi-phase work, reading each result before you decide the next phase.
24
- </helpers>
19
+ # Goal
20
+ What the batch accomplishes.
21
+ # Constraints
22
+ Rules, non-goals, permissions, and verification limits.
23
+ # Contract
24
+ Shared interfaces, output shape, branch/base assumptions, and coordination rules.
25
25
 
26
- <structure>
27
- For independent per-item chains (review → verify, fetch → extract → score), wrap the WHOLE chain in one function and run it with `parallel()` — then each item flows through its own steps without waiting on the others:
26
+ Each task assignment must be self-contained:
27
+
28
+ # Target
29
+ Exact files, symbols, subsystem, or evidence surface; explicit non-goals.
30
+ # Change
31
+ What to inspect or modify, step by step, including APIs and patterns to reuse.
32
+ # Acceptance
33
+ Observable result, return packet, and local verification. Subagents skip formatters,
34
+ linters, and project-wide tests; the parent runs shared proof once.
35
+ {{else}}
36
+ Call `task` once per independent subagent. Put the full shared background and the leaf work in that call's `assignment`. Do not pass `context` or `tasks[]`: the flat task schema rejects them when batch calls are disabled.
28
37
 
29
- DIMENSIONS = [{"key": "bugs", "prompt": "…"}, {"key": "perf", "prompt": "…"}]
30
- def review_and_verify(d):
31
- found = agent(d["prompt"], label=f"review:{d['key']}", schema=FINDINGS_SCHEMA)
32
- return parallel([lambda f=f: {**f, "verdict": agent(
33
- f"Refute if you can (default refuted when unsure): {f['title']}",
34
- label=f"verify:{f['file']}", schema=VERDICT_SCHEMA)} for f in found["findings"]])
35
- phase("Review")
36
- results = parallel([lambda d=d: review_and_verify(d) for d in DIMENSIONS])
37
- confirmed = [f for group in results for f in group if f["verdict"]["is_real"]]
38
+ Each assignment must be self-contained:
38
39
 
39
- Reach for `pipeline()` only when a stage genuinely needs ALL of the previous stage first — dedup/merge across the whole set, early-exit on zero, or "compare against the other findings" — because its inter-stage barrier makes every item wait for the slowest peer:
40
+ # Target
41
+ Exact files, symbols, subsystem, or evidence surface; explicit non-goals.
42
+ # Change
43
+ Shared background plus what to inspect or modify, step by step, including APIs and patterns to reuse.
44
+ # Acceptance
45
+ Observable result, return packet, and local verification. Subagents skip formatters,
46
+ linters, and project-wide tests; the parent runs shared proof once.
47
+ {{/if}}
40
48
 
41
- phase("Find")
42
- found = parallel([lambda d=d: agent(d["prompt"], schema=FINDINGS_SCHEMA) for d in DIMENSIONS])
43
- findings = dedupe([f for r in found for f in r["findings"]]) # needs everything at once
44
- phase("Verify")
45
- verdicts = parallel([lambda f=f: agent(verify_prompt(f), schema=VERDICT_SCHEMA) for f in findings])
49
+ <structure>
50
+ Decompose first, then {{#if taskBatch}}batch the independent leaves{{else}}issue one independent task call per leaf in the same turn{{/if}}:
46
51
 
47
- Don't add a barrier just to flatten/map/filter — do that with plain Python between calls. Nested `parallel()` pools each cap independently, so keep total fan-out sane.
52
+ {{#if taskBatch}}
53
+ task(
54
+ context: "# Goal\nReview the auth diff...\n# Constraints\nRead-only...\n# Contract\nReturn findings as severity/file/line/fix...",
55
+ tasks: [
56
+ { id: "AuthOwner", role: "Auth Storage Reviewer", assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nTrace credential selection...\n# Acceptance\nReturn confirmed findings only..." },
57
+ { id: "PromptOwner", role: "Prompt Contract Reviewer", assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance...\n# Acceptance\nReturn mismatches and exact prompt lines..." },
58
+ ]
59
+ )
60
+ {{else}}
61
+ task(
62
+ role: "Auth Storage Reviewer",
63
+ assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nReview the auth diff. Shared contract: read-only; return findings as severity/file/line/fix.\n# Acceptance\nReturn confirmed findings only..."
64
+ )
65
+ task(
66
+ role: "Prompt Contract Reviewer",
67
+ assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance. Shared contract: read-only; return mismatches and exact prompt lines.\n# Acceptance\nReturn confirmed findings only..."
68
+ )
69
+ {{/if}}
70
+
71
+ {{#if taskBatch}}Prefer one wide batch over serial subagent calls when work items do not share files. If tasks overlap, name the overlap and have agents coordinate through IRC before editing.{{else}}Prefer issuing all independent task calls in one assistant turn over serial dispatch when work items do not share files. If tasks overlap, name the overlap and have agents coordinate through IRC before editing.{{/if}}
48
72
  </structure>
49
73
 
50
74
  <patterns>
51
- Compose the harness the task calls for:
52
- - **Adversarial verify** — N independent skeptics per finding, each prompted to REFUTE; keep it only if a majority survive. `votes = parallel([lambda i=i: agent(f"Refute: {claim}. refuted=true if unsure.", schema=VERDICT) for i in range(3)])`, then keep when `sum(not v["refuted"] for v in votes) ≥ 2`.
53
- - **Perspective-diverse verify** — give each verifier a distinct lens (correctness, security, perf, does-it-reproduce) instead of N identical refuters.
54
- - **Judge panel** — N attempts from different angles, scored by parallel judges; synthesize from the winner, graft the best of the rest.
55
- - **Loop-until-dry** — for unknown-size discovery, keep spawning finders until K consecutive rounds surface nothing new; dedup against everything SEEN, not just what was confirmed, or it never converges.
56
- - **Multi-modal sweep** — parallel finders each searching a different way (by-container, by-content, by-entity, by-time), each blind to the others.
57
- - **Completeness critic** — a final agent that asks "what's missing — modality not run, claim unverified, file unread?"; its answer is the next round.
58
- - **Budget/count loops** — `while len(bugs) < 10:` to hit a target, or `while budget.total and budget.remaining() > 50_000:` to scale depth to the turn budget; `log()` each round.
59
- - **No silent caps** — if you bound coverage (top-N, no-retry, sampling), `log()` what you dropped; silent truncation reads as "covered everything" when it didn't.
60
-
61
- Scale to the ask: "find any bugs" → a few finders, single-vote verify. "thoroughly audit / be comprehensive" → larger finder pool, 3–5-vote adversarial pass, a synthesis stage.
75
+ - **Adversarial verify** — dispatch skeptical reviewers with distinct targets, then keep only findings the parent can verify against source.
76
+ - **Perspective-diverse review** — use separate correctness, security, performance, and maintainability roles instead of identical reviewers.
77
+ - **Completeness critic** — after the first batch, dispatch one read-only critic that asks what modality, file, claim, or proof was missed.
78
+ - **No silent caps** — if you bound coverage (top-N, no retry, sampling), state what was dropped and why before acting.
79
+ - **Parent owns closure** — subagents return evidence; the parent reads it, resolves contradictions, runs proof, and makes the final decision.
62
80
  </patterns>
63
81
 
64
82
  <execution>
65
- - Decompose the surface first; capture it in `todo` when it spans phases.
66
- - Prefer `schema=` for any agent whose output you branch on.
67
- - After a fan-out returns, YOU own correctness: read the artifacts, run the gate, verify before acting. Subagents do the legwork; they don't get the last word.
68
- - Keep going until the task is closed a returned fan-out is a step, not a stopping point.
83
+ - Capture multi-phase workflow state in the visible todo system when available.
84
+ {{#if taskBatch}}- Batch independent subagents in one `task` call.{{else}}- Dispatch independent subagents as separate `task` calls in the same turn.{{/if}}
85
+ - Give every subagent a narrow target, explicit non-goals, and a concrete return packet.
86
+ - After fan-out returns, read the artifacts, patch or decide, and run the shared gate.
87
+ - Keep going until the task is closed — returned fan-out is a step, not a stopping point.
69
88
  </execution>
70
89
  </system-notice>