@nmzpy/pi-ember-stack 0.1.6 → 0.2.2

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 (36) hide show
  1. package/README.md +83 -83
  2. package/package.json +62 -48
  3. package/plugins/devin-auth/extensions/index.ts +68 -7
  4. package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
  5. package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
  6. package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
  7. package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
  8. package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
  9. package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
  10. package/plugins/devin-auth/src/models.ts +42 -196
  11. package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
  12. package/plugins/devin-auth/src/oauth/types.ts +71 -71
  13. package/plugins/devin-auth/src/stream.ts +1 -1
  14. package/plugins/index.ts +29 -6
  15. package/plugins/pi-compact-tools/index.ts +35 -192
  16. package/plugins/pi-compact-tools/renderer.ts +589 -0
  17. package/plugins/pi-custom-agents/index.ts +727 -373
  18. package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
  19. package/plugins/pi-custom-agents/subagent/agents/coder.md +1 -0
  20. package/plugins/pi-custom-agents/subagent/agents/scout.md +16 -37
  21. package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
  22. package/plugins/pi-custom-agents/subagent/extensions/index.ts +118 -226
  23. package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
  24. package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
  25. package/plugins/pi-custom-agents/subagent/extensions/runner.ts +260 -170
  26. package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
  27. package/plugins/pi-ember-fff/index.ts +975 -0
  28. package/plugins/pi-ember-fff/query.ts +247 -0
  29. package/plugins/pi-ember-fff/test/query.test.ts +222 -0
  30. package/plugins/pi-ember-fff/test/renderer.test.ts +727 -0
  31. package/plugins/pi-ember-tps/index.ts +144 -0
  32. package/plugins/pi-ember-ui/ember.json +99 -0
  33. package/plugins/pi-ember-ui/index.ts +805 -0
  34. package/plugins/pi-ember-ui/mode-colors.ts +196 -0
  35. package/tsconfig.json +2 -1
  36. package/plugins/pi-custom-agents/subagent/agents/architect.md +0 -56
@@ -9,18 +9,36 @@
9
9
  * - Restores state on session resume
10
10
  *
11
11
  * Modes:
12
- * /architect — read-only planning, analysis, and architecture (replaces pi-plan)
13
- * /coder — full access (default mode, restores all tools)
14
- * /doctor — read-only health-check auditor
15
- * /orchestrator — read-only task decomposition + delegation planner
16
- * /ui-doctor — read-only PySide6/Qt UI diagnostician
12
+ * /plan — read-only planning, analysis, and architecture
13
+ * /code — full access (default mode, restores all tools)
14
+ * /debug — read-only health-check auditor + UI/Qt diagnostics
15
+ * /orchestrate — read-only task decomposition + delegation planner
17
16
  */
18
17
 
19
18
  import * as fs from "node:fs";
20
19
  import * as path from "node:path";
21
- import { isAbsolute, relative, resolve, sep } from "node:path";
22
20
  import { fileURLToPath } from "node:url";
23
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
21
+ import type { Model } from "@earendil-works/pi-ai";
22
+ import {
23
+ CustomEditor,
24
+ type ExtensionUIContext,
25
+ type KeybindingsManager,
26
+ } from "@earendil-works/pi-coding-agent";
27
+ import {
28
+ truncateToWidth,
29
+ visibleWidth,
30
+ Container,
31
+ Text,
32
+ Spacer,
33
+ Input,
34
+ fuzzyFilter,
35
+ getKeybindings,
36
+ matchesKey,
37
+ type EditorTheme,
38
+ type TUI,
39
+ } from "@earendil-works/pi-tui";
40
+ import { isShellMode, mutedBullet, setActiveMode, setShellMode } from "../pi-ember-ui/mode-colors.ts";
41
+ import { getLiveTps } from "../pi-ember-tps/index.ts";
24
42
  import {
25
43
  askQuestionnaire,
26
44
  type QuestionnaireQuestion,
@@ -28,6 +46,143 @@ import {
28
46
  } from "./questionnaire-tool.ts";
29
47
  import subagentPlugin from "./subagent/extensions/index.ts";
30
48
 
49
+ const THINKING_LEVEL_SHORTCUT = "shift+t";
50
+
51
+ function modelIdentityString(model: Model<any> | undefined): string {
52
+ return model ? `${model.provider}/${model.id}` : "";
53
+ }
54
+
55
+ function intercept_thinking_level(data: string, cycle_thinking_level: () => void): boolean {
56
+ if (!matchesKey(data, THINKING_LEVEL_SHORTCUT)) return false;
57
+ cycle_thinking_level();
58
+ return true;
59
+ }
60
+
61
+ /**
62
+ * Intercept '!' on empty input to enter shell mode, and escape or
63
+ * backspace (on empty input) to exit. The '!' is eaten so it never
64
+ * appears in the editor.
65
+ */
66
+ function intercept_shell_mode(data: string, editor: any, ctx: any): boolean {
67
+ if (matchesKey(data, "!")) {
68
+ const text = editor.getText?.() ?? "";
69
+ if (text.length === 0) {
70
+ setShellMode(true);
71
+ ctx.ui.setStatus("pi-ember-ui-shell-mode", undefined);
72
+ return true;
73
+ }
74
+ }
75
+ if (isShellMode()) {
76
+ if (matchesKey(data, "escape")) {
77
+ setShellMode(false);
78
+ ctx.ui.setStatus("pi-ember-ui-shell-mode", undefined);
79
+ return true;
80
+ }
81
+ if (matchesKey(data, "backspace")) {
82
+ const text = editor.getText?.() ?? "";
83
+ if (text.length === 0) {
84
+ setShellMode(false);
85
+ ctx.ui.setStatus("pi-ember-ui-shell-mode", undefined);
86
+ return true;
87
+ }
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Intercept /model and /model <search> on Enter, redirecting to our
95
+ * fuzzy-search model picker instead of Pi's built-in unbounded selector.
96
+ */
97
+ function intercept_model_command(data: string, editor: any, pi: any, ctx: any): boolean {
98
+ const kb = getKeybindings();
99
+ if (!kb.matches(data, "tui.select.confirm")) return false;
100
+ const getText = editor.getText?.bind(editor) ?? editor.getExpandedText?.bind(editor);
101
+ if (!getText) return false;
102
+ const text = getText().trim();
103
+ if (text !== "/model" && !text.startsWith("/model ")) return false;
104
+ editor.setText?.("");
105
+ void show_model_picker(pi, ctx);
106
+ return true;
107
+ }
108
+
109
+ /**
110
+ * Shared fuzzy-search model picker used by /model and shift+m.
111
+ * Uses boundedSelect so large model catalogs don't require scrolling.
112
+ */
113
+ async function show_model_picker(pi: any, ctx: any): Promise<void> {
114
+ if (!ctx.hasUI) return;
115
+ const models = ctx.modelRegistry.getAvailable() as any[];
116
+ if (models.length === 0) {
117
+ ctx.ui.notify("No models available.", "warning");
118
+ return;
119
+ }
120
+ const current = ctx.model as any;
121
+ const labels = models.map((m) => {
122
+ const prefix = current && m.provider === current.provider && m.id === current.id ? "→ " : " ";
123
+ return `${prefix}${m.name ?? m.id} • ${m.provider}`;
124
+ });
125
+ const choice = await boundedSelect(ctx, "Select model", labels);
126
+ if (!choice) return;
127
+ const idx = labels.indexOf(choice);
128
+ if (idx < 0) return;
129
+ const model = models[idx];
130
+ try {
131
+ await pi.setModel(model);
132
+ ctx.ui.notify(`Model: ${model.id} • ${model.provider}`, "info");
133
+ } catch (err) {
134
+ ctx.ui.notify(
135
+ `Failed to set model: ${err instanceof Error ? err.message : String(err)}`,
136
+ "error",
137
+ );
138
+ }
139
+ }
140
+
141
+ class ThinkingLevelEditor extends CustomEditor {
142
+ constructor(
143
+ tui: TUI,
144
+ theme: EditorTheme,
145
+ keybindings: KeybindingsManager,
146
+ private readonly cycle_thinking_level: () => void,
147
+ ) {
148
+ super(tui, theme, keybindings);
149
+ }
150
+
151
+ override handleInput(data: string): void {
152
+ if (intercept_thinking_level(data, this.cycle_thinking_level)) return;
153
+ super.handleInput(data);
154
+ }
155
+ }
156
+
157
+ type PersistedState = {
158
+ readonly mode?: string;
159
+ readonly model?: { provider: string; modelId: string };
160
+ };
161
+
162
+ function getPersistedStatePath(): string {
163
+ const home = process.env.PI_HOME || path.join(process.env.HOME || process.env.USERPROFILE || "", ".pi", "agent");
164
+ return path.join(home, "pi-ember-stack.json");
165
+ }
166
+
167
+ function readPersistedState(): PersistedState {
168
+ try {
169
+ const raw = fs.readFileSync(getPersistedStatePath(), "utf8");
170
+ return JSON.parse(raw) as PersistedState;
171
+ } catch {
172
+ return {};
173
+ }
174
+ }
175
+
176
+ function writePersistedState(state: PersistedState): void {
177
+ const file = getPersistedStatePath();
178
+ try {
179
+ fs.mkdirSync(path.dirname(file), { recursive: true });
180
+ fs.writeFileSync(file, `${JSON.stringify(state, null, "\t")}\n`);
181
+ } catch {
182
+ // best-effort persistence
183
+ }
184
+ }
185
+
31
186
  function formatTokens(count: number): string {
32
187
  if (count < 1000) return count.toString();
33
188
  if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
@@ -36,26 +191,152 @@ function formatTokens(count: number): string {
36
191
  return `${Math.round(count / 1000000)}M`;
37
192
  }
38
193
 
39
- function formatCwdForFooter(cwd: string, home: string | undefined): string {
40
- if (!home) return cwd;
41
- const resolvedCwd = resolve(cwd);
42
- const resolvedHome = resolve(home);
43
- const relativeToHome = relative(resolvedHome, resolvedCwd);
44
- const isInsideHome = relativeToHome === "" ||
45
- (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
46
- if (!isInsideHome) return cwd;
47
- return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
194
+ const BOUNDED_SELECT_MAX_VISIBLE = 10;
195
+
196
+ async function boundedSelect(
197
+ ctx: any,
198
+ title: string,
199
+ options: string[],
200
+ ): Promise<string | undefined> {
201
+ return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (result: string) => void) => {
202
+ const root = new Container();
203
+ root.addChild(new Text(theme.fg("border", "\u2500".repeat(60)), 0, 0));
204
+ root.addChild(new Spacer(1));
205
+ root.addChild(new Text(theme.fg("accent", theme.bold(title)), 0, 0));
206
+ root.addChild(new Spacer(1));
207
+
208
+ const searchInput = new Input();
209
+ searchInput.onSubmit = () => {
210
+ if (filtered[selectedIndex] !== undefined) {
211
+ done(filtered[selectedIndex]);
212
+ }
213
+ };
214
+ root.addChild(searchInput);
215
+ root.addChild(new Spacer(1));
216
+
217
+ const listContainer = new Container();
218
+ root.addChild(listContainer);
219
+ root.addChild(new Spacer(1));
220
+ root.addChild(new Text(
221
+ theme.fg("muted", " type to search \u2191\u2193 navigate enter select esc cancel"),
222
+ 0, 0,
223
+ ));
224
+ root.addChild(new Spacer(1));
225
+ root.addChild(new Text(theme.fg("border", "\u2500".repeat(60)), 0, 0));
226
+
227
+ let selectedIndex = 0;
228
+ let filtered = options;
229
+
230
+ function updateList(): void {
231
+ listContainer.clear();
232
+ const max = BOUNDED_SELECT_MAX_VISIBLE;
233
+ const start = Math.max(0, Math.min(
234
+ selectedIndex - Math.floor(max / 2),
235
+ filtered.length - max,
236
+ ));
237
+ const end = Math.min(start + max, filtered.length);
238
+ for (let i = start; i < end; i++) {
239
+ const isSelected = i === selectedIndex;
240
+ const prefix = isSelected ? theme.fg("accent", "\u2192 ") : " ";
241
+ const text = isSelected
242
+ ? prefix + theme.fg("accent", filtered[i])
243
+ : prefix + theme.fg("text", filtered[i]);
244
+ listContainer.addChild(new Text(text, 0, 0));
245
+ }
246
+ if (start > 0 || end < filtered.length) {
247
+ listContainer.addChild(new Text(
248
+ theme.fg("muted", ` (${selectedIndex + 1}/${filtered.length})`),
249
+ 0, 0,
250
+ ));
251
+ }
252
+ if (filtered.length === 0) {
253
+ listContainer.addChild(new Text(
254
+ theme.fg("muted", " No matching models"),
255
+ 0, 0,
256
+ ));
257
+ }
258
+ }
259
+
260
+ function filterModels(): void {
261
+ const query = searchInput.getValue();
262
+ filtered = query
263
+ ? fuzzyFilter(options, query, (s) => s)
264
+ : options;
265
+ selectedIndex = Math.min(selectedIndex, Math.max(0, filtered.length - 1));
266
+ updateList();
267
+ }
268
+
269
+ filterModels();
270
+
271
+ (root as any).handleInput = (keyData: string): void => {
272
+ const kb = getKeybindings();
273
+ if (kb.matches(keyData, "tui.select.up")) {
274
+ if (filtered.length === 0) return;
275
+ selectedIndex = selectedIndex === 0
276
+ ? filtered.length - 1
277
+ : selectedIndex - 1;
278
+ updateList();
279
+ } else if (kb.matches(keyData, "tui.select.down")) {
280
+ if (filtered.length === 0) return;
281
+ selectedIndex = selectedIndex === filtered.length - 1
282
+ ? 0
283
+ : selectedIndex + 1;
284
+ updateList();
285
+ } else if (kb.matches(keyData, "tui.select.confirm")) {
286
+ if (filtered[selectedIndex] !== undefined) {
287
+ done(filtered[selectedIndex]);
288
+ }
289
+ } else if (kb.matches(keyData, "tui.select.cancel")) {
290
+ done(undefined as unknown as string);
291
+ } else {
292
+ searchInput.handleInput(keyData);
293
+ filterModels();
294
+ }
295
+ };
296
+ (root as any).getSearchInput = () => searchInput;
297
+ return root;
298
+ });
48
299
  }
49
300
 
50
301
  const READONLY_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
302
+ const READONLY_DELEGATING_TOOLS = ["read", "grep", "find", "ls", "questionnaire", "subagent"];
51
303
  const FULL_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls", "questionnaire"];
52
304
 
53
305
  const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
54
306
  const SUBAGENT_FILES: Record<string, string> = {
55
307
  coder: path.join(SOURCE_ROOT, "subagent", "agents", "coder.md"),
56
- architect: path.join(SOURCE_ROOT, "subagent", "agents", "architect.md"),
308
+ scout: path.join(SOURCE_ROOT, "subagent", "agents", "scout.md"),
57
309
  };
58
310
 
311
+ const PARALLEL_TOOL_CALL_GUIDANCE = `
312
+
313
+ ## Tool Call Efficiency
314
+
315
+ When multiple independent tool calls are needed (e.g. reading several files,
316
+ searching for different patterns), emit them all in a single response rather
317
+ than one at a time. The runtime executes independent tool calls in parallel,
318
+ so batching them saves round-trips and reduces latency.
319
+ `;
320
+
321
+ const SUBAGENT_AWARENESS_PROMPT = `
322
+
323
+ ## Available Subagents
324
+
325
+ You have the \`subagent\` tool available for delegating tasks to specialized agents
326
+ with isolated context. Use it to keep your own context lean.
327
+
328
+ - **scout**: Fast agent specialized for exploring codebases. Use when you need to
329
+ quickly find files by patterns (e.g. "src/components/**/*.tsx"), search code for
330
+ keywords (e.g. "API endpoints"), or answer questions about the codebase (e.g.
331
+ "how do API endpoints work?").
332
+ - **coder**: Implementation agent for writing, editing, testing, and verifying
333
+ code. Full tool access. Use for focused implementation tasks — bug fixes,
334
+ feature additions, refactors, file edits.
335
+
336
+ Modes: single (agent + task), parallel (tasks array, max 8), chain (sequential
337
+ with {previous}).
338
+ `;
339
+
59
340
  interface ModeConfig {
60
341
  id: string;
61
342
  label: string;
@@ -67,9 +348,9 @@ interface ModeConfig {
67
348
  }
68
349
 
69
350
  const ARCHITECT_PROMPT = `<system-reminder>
70
- # Architect Mode - System Reminder
351
+ # Plan Mode - System Reminder
71
352
 
72
- CRITICAL: Architect mode ACTIVE — you are in READ-ONLY planning phase. STRICTLY
353
+ CRITICAL: Plan mode ACTIVE — you are in READ-ONLY planning phase. STRICTLY
73
354
  FORBIDDEN: ANY file edits, modifications, or system changes. Do NOT use sed, tee,
74
355
  echo, cat, or ANY other bash command to manipulate files — commands may ONLY
75
356
  read/inspect. This ABSOLUTE CONSTRAINT overrides ALL other instructions, including
@@ -132,27 +413,20 @@ For each step:
132
413
 
133
414
  ## Open Questions
134
415
  <any clarifications needed from the user>
135
-
136
- ---
137
-
138
- ## Important
139
-
140
- The user indicated that they do not want you to execute yet -- you MUST NOT make
141
- any edits, run any non-readonly tools (including changing configs or making
142
- commits), or otherwise make any changes to the system. This supersedes any other
143
- instructions you have received.
144
- </system-reminder>`;
416
+ ${SUBAGENT_AWARENESS_PROMPT}</system-reminder>`;
145
417
 
146
418
  const DOCTOR_PROMPT = `<system-reminder>
147
- # Doctor Mode - System Reminder
419
+ # Debug Mode - System Reminder
148
420
 
149
- CRITICAL: Doctor mode ACTIVE — you are The Doctor, a read-only health-check
150
- auditor for the Ember project (PySide6 subtitle + DaVinci Resolve integration app).
421
+ CRITICAL: Debug mode ACTIVE — you are the Debugger, a health-check auditor and
422
+ diagnostician for the Ember project (PySide6 subtitle + DaVinci Resolve integration
423
+ app).
151
424
 
152
- STRICTLY FORBIDDEN: ANY file edits, modifications, or system changes. Do NOT use
153
- sed, tee, echo, cat, or ANY other bash command to manipulate files — commands may
154
- ONLY read/inspect. This ABSOLUTE CONSTRAINT overrides ALL other instructions,
155
- including direct user edit requests. You may ONLY observe, analyze, and report.
425
+ You do NOT edit files directly. You investigate, diagnose, and report findings. If
426
+ a fix is straightforward, you may DELEGATE the implementation to the \`coder\`
427
+ subagent (full tool access) — the read-only constraint applies to your direct tool
428
+ usage only, not to delegated subagent work. Otherwise, report the correction and
429
+ let the user or Orchestrator handle it.
156
430
 
157
431
  ---
158
432
 
@@ -213,23 +487,66 @@ For each finding:
213
487
 
214
488
  ## Constraints
215
489
 
216
- - Read-only. Do not edit or write files.
490
+ - You do not edit or write files directly. You may delegate fixes to the
491
+ \`coder\` subagent when a correction is straightforward and well-scoped.
217
492
  - Use \`bash t.gate.sh <files>\` only for targeted validation of files you are checking.
218
493
  - Do not run \`bash gate.sh\` (full gate) — that is the user's responsibility.
219
494
  - Ignore git status / git diff changes unrelated to the files you were asked to check.
220
- </system-reminder>`;
495
+
496
+ ---
497
+
498
+ ## UI/Qt Pipeline Diagnostics
499
+
500
+ In addition to structural health checks, diagnose PySide6/Qt UI pipeline issues:
501
+
502
+ ### Failure Modes To Investigate
503
+
504
+ 1. **Event-loop blockage:** Does any GUI-thread callback perform blocking I/O or
505
+ unbounded CPU work?
506
+ 2. **Excessive synchronous UI mutation:** Does one state transition perform many
507
+ independent widget/layout/geometry operations?
508
+ 3. **Recursive signal/callback propagation:** Can a signal or layout refresh
509
+ indirectly re-enter the same pipeline?
510
+ 4. **Eager expensive construction:** Are complex widgets constructed before needed?
511
+ 5. **Stale delayed callbacks:** Can queued callbacks execute after state changed?
512
+ 6. **Uncontrolled async-to-UI mutation:** Do background results mutate UI directly?
513
+ 7. **Competing layout authorities:** Do multiple functions independently control the
514
+ same widget geometry?
515
+
516
+ ### Commit Architecture (Healthy Reference)
517
+
518
+ event/signal/async completion -> validate generation -> mutate semantic state ->
519
+ mark dirty -> request one coalesced commit -> measure once -> apply geometry once
520
+ -> repaint
521
+
522
+ ### UI Report Format
523
+
524
+ For every UI failure mode, report: Verdict, Evidence, Trigger, Duplicated work,
525
+ Re-entry path, Stale-state risk, Authority conflict, Impact, Correction,
526
+ Invariant, Verification.
527
+
528
+ ### Invalid UI Fix Patterns
529
+
530
+ Reject fixes that: add more invalidate()/activate() calls, use
531
+ QApplication.processEvents(), replace deferred passes with blocking callbacks,
532
+ add arbitrary QTimer.singleShot() delays, perform geometry repair both
533
+ immediately and later, introduce parallel build paths duplicating normal layout.
534
+
535
+ ${SUBAGENT_AWARENESS_PROMPT}</system-reminder>`;
221
536
 
222
537
  const ORCHESTRATOR_PROMPT = `<system-reminder>
223
- # Orchestrator Mode - System Reminder
538
+ # Orchestrate Mode - System Reminder
224
539
 
225
- CRITICAL: Orchestrator mode ACTIVE — you are the Orchestrator, a read-only
226
- implementation coordinator for the Ember project (PySide6 subtitle + DaVinci
227
- Resolve integration app).
540
+ CRITICAL: Orchestrate mode ACTIVE — you are the Orchestrator, an implementation
541
+ coordinator for the Ember project (PySide6 subtitle + DaVinci Resolve integration
542
+ app).
228
543
 
229
- STRICTLY FORBIDDEN: ANY file edits, modifications, or system changes. Do NOT use
230
- sed, tee, echo, cat, or ANY other bash command to manipulate files — commands may
231
- ONLY read/inspect. This ABSOLUTE CONSTRAINT overrides ALL other instructions,
232
- including direct user edit requests. You may ONLY observe, analyze, and plan.
544
+ You do NOT edit files directly. Your job is to decompose work into modules and
545
+ DELEGATE implementation to the \`coder\` subagent (full tool access). The
546
+ read-only constraint applies to YOUR direct tool usage only — you may read,
547
+ search, and inspect to build accurate delegation prompts, but you must not edit,
548
+ write, or run mutating bash commands yourself. Delegating implementation work to
549
+ the \`coder\` subagent is the ENTIRE POINT of this mode. Do it eagerly.
233
550
 
234
551
  ---
235
552
 
@@ -308,167 +625,21 @@ Return a structured plan:
308
625
 
309
626
  ## Constraints
310
627
 
311
- - Read-only. Do not edit or write files.
628
+ - You do not edit or write files directly — delegate implementation to the
629
+ \`coder\` subagent. That is your primary mechanism for getting work done.
312
630
  - Do not run \`bash gate.sh\` (full gate) — that is the user's responsibility.
313
631
  - If task scope is unclear, say so and request clarification rather than guessing.
314
- </system-reminder>`;
315
-
316
- const UI_DOCTOR_PROMPT = `<system-reminder>
317
- # UI Doctor Mode - System Reminder
318
-
319
- CRITICAL: UI Doctor mode ACTIVE — you are the UI Doctor, a read-only PySide6/Qt
320
- UI pipeline diagnostician for the Ember project.
321
-
322
- STRICTLY FORBIDDEN: ANY file edits, modifications, or system changes. Do NOT use
323
- sed, tee, echo, cat, or ANY other bash command to manipulate files — commands may
324
- ONLY read/inspect. This ABSOLUTE CONSTRAINT overrides ALL other instructions,
325
- including direct user edit requests. You may ONLY observe, analyze, and report.
326
-
327
- ---
328
-
329
- ## Objective
330
-
331
- The objective is not to make everything synchronous. The objective is to ensure
332
- that:
333
-
334
- - semantic state changes are cheap and deterministic;
335
- - expensive work is performed outside the GUI thread where appropriate;
336
- - UI mutations remain on the GUI thread;
337
- - repeated mutations are batched;
338
- - geometry is committed by one authority;
339
- - deferred work is coalesced, validated, and safe to discard.
340
-
341
- ## Failure Modes To Investigate
342
-
343
- ### 1. Event-loop blockage
344
- Does any event handler, signal handler, timer callback, paint path, layout
345
- callback, or GUI-thread completion perform enough synchronous work to prevent Qt
346
- from processing input, paint, timer, socket, or queued-signal events?
347
-
348
- Invariant: No GUI-thread callback performs blocking I/O or an unbounded amount of
349
- CPU, construction, layout, or geometry work.
350
-
351
- ### 2. Excessive synchronous UI mutation
352
- Does one logical state transition perform many independent widget, layout,
353
- visibility, style, geometry, or repaint operations?
354
-
355
- Invariant: One logical UI transition may mutate state multiple times, but it
356
- performs at most one geometry commit per widget hierarchy.
357
-
358
- ### 3. Recursive signal, callback, or refresh propagation
359
- Can a signal, callback, visibility update, layout refresh, or state-application
360
- function indirectly re-enter the same pipeline?
361
-
362
- Invariant: A state transition cannot directly or indirectly execute the same
363
- layout pipeline more than once before commit.
364
-
365
- ### 4. Eager expensive construction
366
- Are complex widgets, layouts, models, overlays, editors, previews, or resources
367
- constructed before they are needed?
368
-
369
- Invariant: Expensive UI is constructed only when needed, unless profiling proves
370
- eager construction is cheaper and operationally simpler.
371
-
372
- ### 5. Stale delayed callbacks
373
- Can a queued callback, timer, animation completion, worker result, or deferred
374
- geometry operation execute after the state it was created for has changed?
375
-
376
- Invariant: Every delayed callback proves that its owner, generation, requested
377
- state, and dependencies are still current before mutating UI.
378
-
379
- ### 6. Uncontrolled async-to-UI mutation
380
- Do background results return to the GUI thread and immediately initiate several
381
- unrelated UI mutations or layout pipelines?
382
-
383
- Invariant: Async work may produce data, but only the GUI thread owns UI state and
384
- only the central commit path owns geometry.
385
-
386
- ### 7. Competing layout or geometry authorities
387
- Do multiple functions independently measure, resize, activate, synchronize,
388
- enforce, or repair the same widget hierarchy?
389
-
390
- Invariant: Exactly one function owns final measurement and geometry commit for a
391
- given widget hierarchy.
392
-
393
- ## Commit Architecture (Healthy Reference)
394
-
395
- event, signal, or async completion
396
- -> validate current generation and state
397
- -> mutate semantic UI state
398
- -> mark dirty layout domains
399
- -> request one coalesced commit
400
- -> measure once
401
- -> apply geometry once
402
- -> repaint
403
-
404
- A single deferred commit is valid. Several independently scheduled geometry
405
- repairs are not.
406
-
407
- ## Doctor Report Format
408
-
409
- For every failure mode, report:
410
-
411
- - Verdict: Present / Absent / Unclear
412
- - Evidence: Exact files, functions, and call chain
413
- - Trigger: User action, initialization path, signal, timer, or async completion
414
- - Duplicated work: Which operations repeat within the same logical transition
415
- - Re-entry path: How the pipeline may invoke or schedule itself again
416
- - Stale-state risk: Which captured values or delayed results may become obsolete
417
- - Authority conflict: Which functions compete to control the same geometry
418
- - Impact: Freeze, delayed paint, stale geometry, jitter, clipping, empty space,
419
- or incorrect state
420
- - Correction: Smallest architectural change that removes the cause
421
- - Invariant: Rule that prevents recurrence
422
- - Verification: Instrumentation or test proving the correction
423
-
424
- ## Invalid Fix Patterns
425
-
426
- Reject a proposed fix when it primarily does any of the following:
427
-
428
- - adds more invalidate() or activate() calls;
429
- - calls QApplication.processEvents() to make the UI appear responsive;
430
- - replaces several deferred passes with one massive blocking callback;
431
- - adds arbitrary QTimer.singleShot() delays;
432
- - adds a synchronous=True or sequential=True flag across many callers without
433
- establishing one commit authority;
434
- - performs the same geometry repair both immediately and later;
435
- - introduces another "secure," "settle," or "enforce" callback;
436
- - fixes one card while leaving the recursive outer-layout path intact;
437
- - applies async results without generation validation;
438
- - creates a parallel build path that duplicates the normal layout path.
439
-
440
- ## Acceptance Criteria
441
-
442
- A UI fix is acceptable only when:
443
-
444
- 1. one logical transition results in at most one geometry commit per widget
445
- hierarchy;
446
- 2. repeated commit requests coalesce;
447
- 3. no stale callback can apply obsolete state;
448
- 4. no worker mutates widgets directly;
449
- 5. one function owns final geometry;
450
- 6. programmatic state restoration does not recursively trigger the pipeline;
451
- 7. expensive construction is justified or lazy;
452
- 8. the GUI thread performs no blocking I/O;
453
- 9. instrumentation shows bounded layout, resize, and paint counts;
454
- 10. removing the old repair callbacks does not reintroduce the defect.
455
-
456
- ## Constraints
457
-
458
- - Read-only. Do not edit or write files.
459
- - Do not run \`bash gate.sh\` (full gate) — that is the user's responsibility.
460
- </system-reminder>`;
632
+ ${SUBAGENT_AWARENESS_PROMPT}</system-reminder>`;
461
633
 
462
634
  const CODER_PROMPT = `<system-reminder>
463
- Your operational mode has changed to coder.
635
+ Your operational mode has changed to code.
464
636
  You are in full-access mode. You are permitted to make file changes, run shell
465
637
  commands, and utilize your arsenal of tools as needed. You are the default
466
638
  implementation agent — write, test, and verify code with full autonomy.
467
639
  </system-reminder>`;
468
640
 
469
641
  const EXIT_TO_CODER = `<system-reminder>
470
- Your operational mode has changed from {mode} to coder.
471
- You are no longer in read-only mode.
642
+ Your operational mode has changed from {mode} to code.
472
643
  You are permitted to make file changes, run shell commands, and utilize your
473
644
  arsenal of tools as needed.
474
645
  </system-reminder>`;
@@ -491,56 +662,47 @@ interface ModeConfig {
491
662
  }
492
663
 
493
664
  const MODES: Record<string, ModeConfig> = {
494
- architect: {
495
- id: "architect",
496
- label: "architect",
497
- icon: "A",
665
+ plan: {
666
+ id: "plan",
667
+ label: "plan",
668
+ icon: "P",
498
669
  color: "warning",
499
670
  tools: READONLY_TOOLS,
500
671
  enterMessage: ARCHITECT_PROMPT,
501
672
  exitMessage: EXIT_TO_CODER,
502
673
  },
503
- coder: {
504
- id: "coder",
505
- label: "coder",
674
+ code: {
675
+ id: "code",
676
+ label: "code",
506
677
  icon: "C",
507
678
  color: "success",
508
679
  tools: FULL_TOOLS,
509
680
  enterMessage: CODER_PROMPT,
510
681
  exitMessage: CODER_PROMPT,
511
682
  },
512
- doctor: {
513
- id: "doctor",
514
- label: "doctor",
683
+ debug: {
684
+ id: "debug",
685
+ label: "debug",
515
686
  icon: "D",
516
687
  color: "warning",
517
- tools: READONLY_TOOLS,
688
+ tools: READONLY_DELEGATING_TOOLS,
518
689
  enterMessage: DOCTOR_PROMPT,
519
690
  exitMessage: EXIT_TO_CODER,
520
691
  },
521
- orchestrator: {
522
- id: "orchestrator",
523
- label: "orchestrator",
692
+ orchestrate: {
693
+ id: "orchestrate",
694
+ label: "orchestrate",
524
695
  icon: "O",
525
696
  color: "warning",
526
- tools: READONLY_TOOLS,
697
+ tools: READONLY_DELEGATING_TOOLS,
527
698
  enterMessage: ORCHESTRATOR_PROMPT,
528
699
  exitMessage: EXIT_TO_CODER,
529
700
  },
530
- "ui-doctor": {
531
- id: "ui-doctor",
532
- label: "ui-doctor",
533
- icon: "U",
534
- color: "warning",
535
- tools: READONLY_TOOLS,
536
- enterMessage: UI_DOCTOR_PROMPT,
537
- exitMessage: EXIT_TO_CODER,
538
- },
539
701
  };
540
702
 
541
703
  const MODE_IDS = Object.keys(MODES);
542
- const DEFAULT_MODE = "coder";
543
- const CYCLE_ORDER = ["coder", "architect", "orchestrator", "doctor", "ui-doctor"];
704
+ const DEFAULT_MODE = "code";
705
+ const CYCLE_ORDER = ["code", "plan", "orchestrate", "debug"];
544
706
 
545
707
  function getLastModeFromSession(ctx: any): string | null {
546
708
  const entries = ctx.sessionManager.getEntries();
@@ -559,10 +721,73 @@ function getLastModeFromSession(ctx: any): string | null {
559
721
  return null;
560
722
  }
561
723
 
724
+ const MODE_LIVE_RENDER_STATUS = "pi-agents-mode-live-render";
725
+
726
+ /**
727
+ * Cached footer stats. The footer render closure fires every animation
728
+ * frame (~30fps). Iterating all session entries + calling
729
+ * ctx.getContextUsage() (which runs estimateContextTokens over the FULL
730
+ * LLM context — JSON.stringify on every tool call, chars/4 on all text)
731
+ * is O(total context) per frame and can exceed the frame budget on long
732
+ * sessions, causing infini-lock. These stats are recomputed only on
733
+ * message_end / tool_execution_end / session_start — events that fire
734
+ * once per assistant message or tool result, not per frame.
735
+ */
736
+ let footerStatsCache: {
737
+ totalCost: number;
738
+ latestCacheHitRate: number | undefined;
739
+ contextTokens: number | null;
740
+ contextWindow: number;
741
+ } | undefined;
742
+
743
+ function recompute_footer_stats(ctx: any): void {
744
+ let totalCost = 0;
745
+ let latestCacheHitRate: number | undefined;
746
+ for (const entry of ctx.sessionManager.getEntries()) {
747
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
748
+ const usage = entry.message.usage;
749
+ totalCost += usage.cost.total;
750
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
751
+ latestCacheHitRate = promptTokens > 0
752
+ ? (usage.cacheRead / promptTokens) * 100
753
+ : undefined;
754
+ }
755
+ const model = ctx.model;
756
+ const contextUsage = ctx.getContextUsage();
757
+ footerStatsCache = {
758
+ totalCost,
759
+ latestCacheHitRate,
760
+ contextTokens: contextUsage?.tokens ?? null,
761
+ contextWindow: contextUsage?.contextWindow ?? model?.contextWindow ?? 0,
762
+ };
763
+ }
764
+
562
765
  export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
563
766
  let currentMode: string = DEFAULT_MODE;
564
767
  let lastMessagedMode: string | null = null;
565
768
  let waitingForPlan = false;
769
+ let active_session_manager: any;
770
+ let session_ready = false;
771
+ let pending_mode_id: string | undefined;
772
+
773
+ function is_live_session(ctx: any): boolean {
774
+ return session_ready && ctx.sessionManager === active_session_manager;
775
+ }
776
+
777
+ function request_live_mode_render(ctx: any): void {
778
+ if (ctx.mode === "tui") {
779
+ // setStatus only invalidates the footer/editor frame. Do not append a
780
+ // transcript notification or invalidate the resumed chat history.
781
+ ctx.ui.setStatus(MODE_LIVE_RENDER_STATUS, undefined);
782
+ }
783
+ }
784
+
785
+ const persistedAtLoad = readPersistedState();
786
+ if (persistedAtLoad.mode && persistedAtLoad.mode in MODES) {
787
+ setActiveMode(persistedAtLoad.mode);
788
+ currentMode = persistedAtLoad.mode;
789
+ }
790
+
566
791
  registerQuestionnaireTool(pi);
567
792
 
568
793
  for (const modeId of MODE_IDS) {
@@ -573,7 +798,7 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
573
798
  });
574
799
  }
575
800
 
576
- function updateStatus(ctx: any) {
801
+ function updateStatus(ctx: any): void {
577
802
  pi.events.emit("powerbar:update", {
578
803
  id: `pi-agents-${currentMode}`,
579
804
  text: MODES[currentMode].label,
@@ -587,21 +812,34 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
587
812
  text: undefined,
588
813
  });
589
814
  }
815
+ request_live_mode_render(ctx);
590
816
  }
591
817
 
592
- function switchMode(modeId: string, ctx: any) {
818
+ function apply_mode(modeId: string, ctx: any): void {
593
819
  const mode = MODES[modeId];
594
820
  if (!mode) return;
821
+
822
+ // Mode changes are deliberately live-only. The active tool set and the
823
+ // next-turn prompt change immediately, while the transcript stays lazy
824
+ // and cached.
595
825
  currentMode = modeId;
826
+ setActiveMode(modeId);
596
827
  pi.setActiveTools(mode.tools);
597
- if (modeId === DEFAULT_MODE) {
598
- ctx.ui.notify("Coder mode. Full access restored.");
599
- } else {
600
- ctx.ui.notify(`${mode.label} mode enabled. Tools: ${mode.tools.join(", ")}`);
601
- }
828
+ pi.events.emit("pi-ember-ui:mode-change", { mode: modeId, liveOnly: true });
602
829
  updateStatus(ctx);
603
830
  }
604
831
 
832
+ function switchMode(modeId: string, ctx: any): void {
833
+ if (!MODES[modeId]) return;
834
+ if (!is_live_session(ctx)) {
835
+ // Queue only for the session that is currently binding. Never retain a
836
+ // request from an old session, where pi.setActiveTools would be stale.
837
+ if (ctx.sessionManager === active_session_manager) pending_mode_id = modeId;
838
+ return;
839
+ }
840
+ apply_mode(modeId, ctx);
841
+ }
842
+
605
843
  for (const modeId of MODE_IDS) {
606
844
  const mode = MODES[modeId];
607
845
  pi.registerCommand(modeId, {
@@ -619,22 +857,61 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
619
857
  }
620
858
 
621
859
  const cycleMode = async (ctx: any): Promise<void> => {
622
- const idx = CYCLE_ORDER.indexOf(currentMode);
860
+ const baseMode = pending_mode_id ?? currentMode;
861
+ const idx = CYCLE_ORDER.indexOf(baseMode);
623
862
  const next = CYCLE_ORDER[(idx + 1) % CYCLE_ORDER.length];
624
863
  switchMode(next, ctx);
625
864
  };
626
865
 
627
- pi.registerShortcut("ctrl+space", {
628
- description: "Cycle agent mode",
629
- handler: cycleMode,
630
- });
631
866
  pi.registerShortcut("tab", {
632
867
  description: "Cycle agent mode",
633
868
  handler: cycleMode,
634
869
  });
635
870
 
871
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
872
+ let active_ui: ExtensionUIContext | undefined;
873
+ let thinking_editor_installed = false;
874
+
875
+ const cycle_thinking_level = (): void => {
876
+ const current = pi.getThinkingLevel() as string;
877
+ const idx = THINKING_LEVELS.indexOf(current);
878
+ const next = THINKING_LEVELS[(idx + 1) % THINKING_LEVELS.length];
879
+ pi.setThinkingLevel(next);
880
+ active_ui?.notify(`Thinking: ${next}`, "info");
881
+ };
882
+
883
+ const install_thinking_editor = (ctx: any): void => {
884
+ if (!ctx.hasUI) return;
885
+ active_ui = ctx.ui;
886
+ if (thinking_editor_installed) return;
887
+
888
+ const previous_editor = ctx.ui.getEditorComponent();
889
+ ctx.ui.setEditorComponent((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
890
+ const editor = previous_editor?.(tui, theme, keybindings);
891
+ if (editor) {
892
+ const original_handle_input = editor.handleInput.bind(editor);
893
+ editor.handleInput = (data: string): void => {
894
+ if (intercept_shell_mode(data, editor, ctx)) return;
895
+ if (intercept_thinking_level(data, cycle_thinking_level)) return;
896
+ if (intercept_model_command(data, editor, pi, ctx)) return;
897
+ original_handle_input(data);
898
+ };
899
+ return editor;
900
+ }
901
+ return new ThinkingLevelEditor(tui, theme, keybindings, cycle_thinking_level);
902
+ });
903
+ thinking_editor_installed = true;
904
+ };
905
+
906
+ pi.registerShortcut("shift+m", {
907
+ description: "Show model picker",
908
+ handler: async (ctx: any) => {
909
+ await show_model_picker(pi, ctx);
910
+ },
911
+ });
912
+
636
913
  pi.registerCommand("subagent-model", {
637
- description: "Set the model used by coder or architect subagents on next spawn",
914
+ description: "Set the model and thinking level for subagents on next spawn",
638
915
  handler: async (_args: any, ctx: any) => {
639
916
  if (!ctx.hasUI) {
640
917
  ctx.ui.notify("subagent-model requires interactive UI.", "error");
@@ -642,7 +919,7 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
642
919
  }
643
920
  const agentChoice = await ctx.ui.select(
644
921
  "Subagent to configure",
645
- ["Coder", "Architect"],
922
+ ["Coder", "Scout"],
646
923
  );
647
924
  if (!agentChoice) return;
648
925
  const agentKey = agentChoice.toLowerCase();
@@ -651,41 +928,65 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
651
928
  ctx.ui.notify(`Agent file not found: ${filePath ?? agentKey}`, "error");
652
929
  return;
653
930
  }
654
- const allModels = ctx.modelRegistry.getAll();
655
- if (allModels.length === 0) {
931
+ const availableModels = ctx.modelRegistry.getAvailable();
932
+ if (availableModels.length === 0) {
656
933
  ctx.ui.notify("No models available in registry.", "error");
657
934
  return;
658
935
  }
659
- const modelLabels = allModels.map(
660
- (m: any) => `${m.provider}/${m.id} — ${m.name}`,
661
- );
662
- const currentContent = fs.readFileSync(filePath, "utf-8");
936
+ let currentContent = fs.readFileSync(filePath, "utf-8");
663
937
  const currentModelMatch = currentContent.match(/^model:\s*(.+)$/m);
664
938
  const currentModel = currentModelMatch ? currentModelMatch[1].trim() : "inherits parent";
665
- const modelChoice = await ctx.ui.select(
939
+ const modelLabels = availableModels.map(
940
+ (m: any) => `${m.provider}/${m.id} — ${m.name}`,
941
+ );
942
+ const modelChoice = await boundedSelect(
943
+ ctx,
666
944
  `Model for ${agentChoice} subagent (current: ${currentModel})`,
667
945
  modelLabels,
668
946
  );
669
947
  if (!modelChoice) return;
670
948
  const selectedIdx = modelLabels.indexOf(modelChoice);
671
949
  if (selectedIdx < 0) return;
672
- const selectedModel = allModels[selectedIdx];
950
+ const selectedModel = availableModels[selectedIdx];
673
951
  const modelValue = `${selectedModel.provider}/${selectedModel.id}`;
674
- let updated: string;
675
952
  if (currentModelMatch) {
676
- updated = currentContent.replace(
953
+ currentContent = currentContent.replace(
677
954
  /^model:\s*.+$/m,
678
955
  `model: ${modelValue}`,
679
956
  );
680
957
  } else {
681
- updated = currentContent.replace(
958
+ currentContent = currentContent.replace(
682
959
  /^(---\n)/,
683
960
  `$1model: ${modelValue}\n`,
684
961
  );
685
962
  }
686
- fs.writeFileSync(filePath, updated, "utf-8");
963
+ const currentThinkingMatch = currentContent.match(/^thinking:\s*(.+)$/m);
964
+ const currentThinking = currentThinkingMatch ? currentThinkingMatch[1].trim() : "off";
965
+ const thinkingChoice = await ctx.ui.select(
966
+ `Thinking level for ${agentChoice} (current: ${currentThinking})`,
967
+ THINKING_LEVELS,
968
+ );
969
+ if (!thinkingChoice) {
970
+ fs.writeFileSync(filePath, currentContent, "utf-8");
971
+ ctx.ui.notify(
972
+ `${agentChoice} subagent model set to ${modelValue}. Will use on next spawn.`,
973
+ );
974
+ return;
975
+ }
976
+ if (currentThinkingMatch) {
977
+ currentContent = currentContent.replace(
978
+ /^thinking:\s*.+$/m,
979
+ `thinking: ${thinkingChoice}`,
980
+ );
981
+ } else {
982
+ currentContent = currentContent.replace(
983
+ /^(---\n)/,
984
+ `$1thinking: ${thinkingChoice}\n`,
985
+ );
986
+ }
987
+ fs.writeFileSync(filePath, currentContent, "utf-8");
687
988
  ctx.ui.notify(
688
- `${agentChoice} subagent model set to ${modelValue}. Will use on next spawn.`,
989
+ `${agentChoice} subagent: model=${modelValue}, thinking=${thinkingChoice}. Will use on next spawn.`,
689
990
  );
690
991
  },
691
992
  });
@@ -716,35 +1017,36 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
716
1017
  async function handlePlanImplement(ctx: any) {
717
1018
  if (!ctx.hasUI) {
718
1019
  switchMode(DEFAULT_MODE, ctx);
719
- pi.sendUserMessage("Execute the plan above. Follow the steps in order, implement, test, and verify each step.");
1020
+ pi.sendUserMessage("Execute the plan. Follow the steps and test.");
720
1021
  return;
721
1022
  }
722
1023
  const target = await ctx.ui.select(
723
1024
  "Implement via",
724
- ["Coder", "Orchestrator"],
1025
+ ["Code", "Orchestrate"],
725
1026
  );
726
1027
  if (!target) {
727
1028
  ctx.ui.notify("Plan implementation cancelled.");
728
1029
  return;
729
1030
  }
730
- const targetMode = target === "Orchestrator" ? "orchestrator" : DEFAULT_MODE;
1031
+ const targetMode = target === "Orchestrate" ? "orchestrate" : DEFAULT_MODE;
731
1032
  switchMode(targetMode, ctx);
732
- const msg = target === "Orchestrator"
733
- ? "Execute the plan above. Decompose it into modules and produce delegation prompts for each."
734
- : "Execute the plan above. Follow the steps in order, implement, test, and verify each step.";
1033
+ const msg = target === "Orchestrate"
1034
+ ? "Orchestrate focused modules for subagents."
1035
+ : "Execute the plan. Follow the steps and test.";
735
1036
  pi.sendMessage(
736
1037
  { customType: "pi-agents-plan-implement", content: PLAN_IMPLEMENT_PROMPT, display: false },
737
- { triggerTurn: true },
738
1038
  );
739
- pi.sendUserMessage(msg);
1039
+ pi.sendUserMessage(msg, { deliverAs: "followUp" });
740
1040
  }
741
1041
 
742
- pi.on("before_agent_start", async () => {
1042
+ pi.on("before_agent_start", async (event: any) => {
1043
+ const augmentedSystemPrompt = event.systemPrompt + PARALLEL_TOOL_CALL_GUIDANCE;
743
1044
  if (currentMode !== DEFAULT_MODE && lastMessagedMode !== currentMode) {
744
1045
  const mode = MODES[currentMode];
745
1046
  lastMessagedMode = currentMode;
746
- waitingForPlan = currentMode === "architect";
1047
+ waitingForPlan = currentMode === "plan";
747
1048
  return {
1049
+ systemPrompt: augmentedSystemPrompt,
748
1050
  message: {
749
1051
  customType: `pi-agents-enter-${currentMode}`,
750
1052
  content: mode.enterMessage,
@@ -756,6 +1058,7 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
756
1058
  const prevMode = MODES[lastMessagedMode];
757
1059
  lastMessagedMode = DEFAULT_MODE;
758
1060
  return {
1061
+ systemPrompt: augmentedSystemPrompt,
759
1062
  message: {
760
1063
  customType: "pi-agents-exit",
761
1064
  content: prevMode.exitMessage.replace("{mode}", prevMode.label),
@@ -763,11 +1066,23 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
763
1066
  },
764
1067
  };
765
1068
  }
1069
+ return { systemPrompt: augmentedSystemPrompt };
1070
+ });
1071
+
1072
+ let lastTurnAborted = false;
1073
+
1074
+ pi.on("turn_end", (event: any) => {
1075
+ const msg = event?.message;
1076
+ lastTurnAborted = msg?.stopReason === "aborted" || msg?.role === "assistant" && msg?.content?.stopReason === "aborted";
766
1077
  });
767
1078
 
768
1079
  pi.on("agent_settled", async (_event: any, ctx: any) => {
769
- if (waitingForPlan && currentMode === "architect") {
1080
+ if (waitingForPlan && currentMode === "plan") {
770
1081
  waitingForPlan = false;
1082
+ if (lastTurnAborted) {
1083
+ lastTurnAborted = false;
1084
+ return;
1085
+ }
771
1086
  const action = await showPlanReview(ctx);
772
1087
  if (action === "implement") {
773
1088
  await handlePlanImplement(ctx);
@@ -775,7 +1090,7 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
775
1090
  waitingForPlan = true;
776
1091
  ctx.ui.notify("Edit your plan — type your changes and send.");
777
1092
  } else if (action === "reject") {
778
- ctx.ui.notify("Plan rejected. Staying in architect mode.");
1093
+ ctx.ui.notify("Plan rejected. Staying in plan mode.");
779
1094
  }
780
1095
  }
781
1096
  });
@@ -794,129 +1109,168 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
794
1109
  function installCustomFooter(ctx: any) {
795
1110
  if (ctx.mode !== "tui") return;
796
1111
  ctx.ui.setFooter((_tui: any, theme: any, footerData: any) => {
797
- return {
798
- render(width: number): string[] {
799
- let totalInput = 0;
800
- let totalOutput = 0;
801
- let totalCacheRead = 0;
802
- let totalCacheWrite = 0;
803
- let totalCost = 0;
804
- let latestCacheHitRate: number | undefined;
805
- for (const entry of ctx.sessionManager.getEntries()) {
806
- if (entry.type !== "message" || entry.message.role !== "assistant") continue;
807
- const usage = entry.message.usage;
808
- totalInput += usage.input;
809
- totalOutput += usage.output;
810
- totalCacheRead += usage.cacheRead;
811
- totalCacheWrite += usage.cacheWrite;
812
- totalCost += usage.cost.total;
813
- const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
814
- latestCacheHitRate = promptTokens > 0
815
- ? (usage.cacheRead / promptTokens) * 100
816
- : undefined;
817
- }
818
-
819
- const model = ctx.model;
820
- const contextUsage = ctx.getContextUsage();
821
- const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
822
- const contextPercentValue = contextUsage?.percent ?? 0;
823
- const contextPercent = contextUsage?.percent === null
824
- ? "?"
825
- : contextPercentValue.toFixed(1);
826
- const statsParts: string[] = [];
827
- if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);
828
- if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);
829
- if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
830
- if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
831
- if (latestCacheHitRate !== undefined) {
832
- statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
833
- }
834
- if (totalCost || (model && ctx.modelRegistry.isUsingOAuth(model))) {
835
- statsParts.push(`$${totalCost.toFixed(3)}${ctx.modelRegistry.isUsingOAuth(model) ? " (sub)" : ""}`);
836
- }
837
- statsParts.push(`${contextPercent}%/${formatTokens(contextWindow)} (auto)`);
838
- let statsLeft = statsParts.join(" ");
839
- if (visibleWidth(statsLeft) > width) {
840
- statsLeft = truncateToWidth(statsLeft, width, "...");
841
- }
842
-
843
- const mode = MODES[currentMode];
844
- const modeLabel = mode.label.charAt(0).toUpperCase() + mode.label.slice(1);
845
- const modelName = model?.name ?? model?.id ?? "no model";
846
- const provider = model?.provider ?? "unknown";
847
- const thinking = getThinkingLevelFromSession(ctx);
848
- const variant = thinking !== "off" ? ` ${thinking}` : "";
849
- const rightSide = theme.getThinkingBorderColor(thinking)(
850
- `${modeLabel} \u2022 ${provider}: ${modelName}${variant}`,
851
- );
852
- const availableForRight = width - visibleWidth(statsLeft) - 2;
853
- const displayedRight = availableForRight > 0
854
- ? truncateToWidth(rightSide, availableForRight, "")
855
- : "";
856
- const padding = " ".repeat(Math.max(
857
- 0,
858
- width - visibleWidth(statsLeft) - visibleWidth(displayedRight),
859
- ));
860
- const statsLine = theme.fg("dim", statsLeft) + padding + displayedRight;
861
-
862
- let pwd = formatCwdForFooter(
863
- ctx.sessionManager.getCwd(),
864
- process.env.HOME || process.env.USERPROFILE,
865
- );
866
- const branch = footerData.getGitBranch();
867
- if (branch) pwd = `${pwd} (${branch})`;
868
- const sessionName = ctx.sessionManager.getSessionName();
869
- if (sessionName) pwd = `${pwd} \u2022 ${sessionName}`;
870
- const lines = [
871
- truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")),
872
- statsLine,
873
- ];
874
- const statuses = footerData.getExtensionStatuses();
875
- if (statuses.size > 0) {
876
- const statusEntries = Array.from(statuses.entries()) as Array<[
877
- string,
878
- string,
879
- ]>;
880
- const statusLine = statusEntries
881
- .sort(([a], [b]) => a.localeCompare(b))
882
- .map(([, text]) => text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim())
883
- .join(" ");
884
- lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
885
- }
886
- return lines;
887
- },
888
- };
1112
+ return {
1113
+ render(width: number): string[] {
1114
+ const PAD = " ";
1115
+ const innerWidth = Math.max(0, width - 2);
1116
+ // Read cached stats instead of iterating all session entries +
1117
+ // calling getContextUsage() every frame. The cache is
1118
+ // recomputed on message_end / tool_execution_end.
1119
+ const stats = footerStatsCache;
1120
+ const totalCost = stats?.totalCost ?? 0;
1121
+ const latestCacheHitRate = stats?.latestCacheHitRate;
1122
+ const contextWindow = stats?.contextWindow ?? 0;
1123
+ const usedTokens = stats?.contextTokens;
1124
+
1125
+ const model = ctx.model;
1126
+ const usedLabel = usedTokens === null || usedTokens === undefined
1127
+ ? "?"
1128
+ : formatTokens(usedTokens);
1129
+ const statsParts: string[] = [];
1130
+ statsParts.push(`${usedLabel}/${formatTokens(contextWindow)}`);
1131
+ if (latestCacheHitRate !== undefined) {
1132
+ statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
1133
+ }
1134
+ if (totalCost || (model && ctx.modelRegistry.isUsingOAuth(model))) {
1135
+ statsParts.push(`$${totalCost.toFixed(3)}`);
1136
+ }
1137
+ let statsLeft = isShellMode() ? "shell" : statsParts.join(" ");
1138
+ if (visibleWidth(statsLeft) > innerWidth) {
1139
+ statsLeft = truncateToWidth(statsLeft, innerWidth, "...");
1140
+ }
1141
+
1142
+ const mode = MODES[currentMode];
1143
+ const modeLabel = mode.label.charAt(0).toUpperCase() + mode.label.slice(1);
1144
+ const modelName = model?.name ?? model?.id ?? "no model";
1145
+ const provider = model?.provider ?? "unknown";
1146
+ const thinking = getThinkingLevelFromSession(ctx);
1147
+ const variant = thinking !== "off" ? ` ${thinking}` : "";
1148
+ const rightSide =
1149
+ theme.fg("accent", modeLabel) +
1150
+ ` ${theme.fg("dim", "\u2022")} ` +
1151
+ theme.fg("text", `${modelName}${variant}`) +
1152
+ theme.fg("dim", ` ${provider}`);
1153
+ const availableForRight = innerWidth - visibleWidth(statsLeft) - 2;
1154
+ const displayedRight = availableForRight > 0
1155
+ ? truncateToWidth(rightSide, availableForRight, "")
1156
+ : "";
1157
+ const padding = " ".repeat(Math.max(
1158
+ 0,
1159
+ innerWidth - visibleWidth(statsLeft) - visibleWidth(displayedRight),
1160
+ ));
1161
+ const statsLine = PAD + theme.fg("dim", statsLeft) + padding + displayedRight + PAD;
1162
+
1163
+ const cwd = ctx.sessionManager.getCwd();
1164
+ const folderName = cwd.split(/[/\\]/).filter(Boolean).pop() ?? cwd;
1165
+ const sessionName = ctx.sessionManager.getSessionName();
1166
+ const lines = [statsLine];
1167
+ const tps = getLiveTps();
1168
+ const folderLabel = theme.fg("dim", folderName);
1169
+ let folderLine = PAD + folderLabel + PAD;
1170
+ if (tps > 0) {
1171
+ const tpsStr = tps < 10 ? tps.toFixed(1) : tps < 100 ? tps.toFixed(0) : `${Math.round(tps)}`;
1172
+ const tpsText = theme.fg("accent", `${tpsStr} tps`);
1173
+ const tpsPadding = " ".repeat(Math.max(0, innerWidth - visibleWidth(folderLabel) - visibleWidth(tpsText)));
1174
+ folderLine = PAD + folderLabel + tpsPadding + tpsText + PAD;
1175
+ }
1176
+ lines.push(folderLine);
1177
+ if (sessionName) {
1178
+ lines.push(PAD + truncateToWidth(theme.fg("dim", sessionName), innerWidth, theme.fg("dim", "...")) + PAD);
1179
+ }
1180
+ return lines;
1181
+ },
1182
+ };
889
1183
  });
890
1184
  }
891
1185
 
892
- pi.on("session_start", async (_event: any, ctx: any) => {
893
- const restored = getLastModeFromSession(ctx);
894
- if (restored && restored !== DEFAULT_MODE) {
895
- currentMode = restored;
896
- lastMessagedMode = restored;
897
- pi.setActiveTools(MODES[restored].tools);
1186
+ async function restoreSavedModel(ctx: any): Promise<void> {
1187
+ const persisted = readPersistedState();
1188
+ const saved = persisted.model;
1189
+ if (!saved) return;
1190
+ const current = ctx.model as Model<any> | undefined;
1191
+ if (current && modelIdentityString(current) === `${saved.provider}/${saved.modelId}`) {
1192
+ return;
1193
+ }
1194
+ const model = ctx.modelRegistry.find(saved.provider, saved.modelId) as Model<any> | undefined;
1195
+ if (!model || !ctx.modelRegistry.hasConfiguredAuth(model)) {
1196
+ return;
1197
+ }
1198
+ await pi.setModel(model);
1199
+ }
1200
+
1201
+ async function restoreMode(ctx: any): Promise<void> {
1202
+ const persisted = readPersistedState();
1203
+ const savedMode = persisted.mode && persisted.mode in MODES
1204
+ ? persisted.mode
1205
+ : getLastModeFromSession(ctx);
1206
+ if (savedMode && savedMode !== DEFAULT_MODE) {
1207
+ currentMode = savedMode;
1208
+ lastMessagedMode = savedMode;
1209
+ pi.setActiveTools(MODES[savedMode].tools);
898
1210
  } else {
899
1211
  currentMode = DEFAULT_MODE;
900
1212
  lastMessagedMode = null;
901
1213
  pi.setActiveTools(FULL_TOOLS);
902
1214
  }
1215
+ setActiveMode(currentMode);
1216
+ pi.events.emit("pi-ember-ui:mode-change", { mode: currentMode, liveOnly: true });
1217
+ }
1218
+
1219
+ pi.on("session_start", async (_event: any, ctx: any) => {
1220
+ // The TUI can accept input while /resume is still rebinding extensions.
1221
+ // Keep mode switching lazy until all session-bound setup has finished.
1222
+ active_session_manager = ctx.sessionManager;
1223
+ session_ready = false;
1224
+ footerStatsCache = undefined;
1225
+ install_thinking_editor(ctx);
1226
+ await restoreMode(ctx);
1227
+ await restoreSavedModel(ctx);
1228
+ session_ready = true;
1229
+ recompute_footer_stats(ctx);
1230
+ const pending_mode = pending_mode_id;
1231
+ pending_mode_id = undefined;
1232
+ if (pending_mode) apply_mode(pending_mode, ctx);
1233
+ else updateStatus(ctx);
903
1234
  installCustomFooter(ctx);
904
- updateStatus(ctx);
905
1235
  });
906
1236
 
907
- pi.on("session_switch", async (_event: any, ctx: any) => {
908
- const restored = getLastModeFromSession(ctx);
909
- if (restored && restored !== DEFAULT_MODE) {
910
- currentMode = restored;
911
- lastMessagedMode = restored;
912
- pi.setActiveTools(MODES[restored].tools);
913
- } else {
914
- currentMode = DEFAULT_MODE;
915
- lastMessagedMode = null;
916
- pi.setActiveTools(FULL_TOOLS);
1237
+ pi.on("model_select", async (event: any, _ctx: any) => {
1238
+ const model = event.model as Model<any> | undefined;
1239
+ if (!model) return;
1240
+ const persisted = readPersistedState();
1241
+ const identity = { provider: model.provider, modelId: model.id };
1242
+ if (persisted.model?.provider === identity.provider && persisted.model?.modelId === identity.modelId) {
1243
+ return;
917
1244
  }
918
- updateStatus(ctx);
919
- installCustomFooter(ctx);
1245
+ writePersistedState({ ...persisted, model: identity });
1246
+ });
1247
+
1248
+ // Recompute footer stats when usage/context changes. These events fire
1249
+ // once per assistant message or tool result — not per render frame —
1250
+ // so the O(n) entry iteration + getContextUsage cost is amortized away
1251
+ // from the animation loop.
1252
+ pi.on("message_end", async (_event: any, ctx: any) => {
1253
+ if (is_live_session(ctx)) recompute_footer_stats(ctx);
1254
+ });
1255
+ pi.on("tool_execution_end", async (_event: any, ctx: any) => {
1256
+ if (is_live_session(ctx)) recompute_footer_stats(ctx);
1257
+ });
1258
+
1259
+ pi.on("session_shutdown", (_event: any, ctx: any) => {
1260
+ // Invalidate shortcut contexts before the old runtime is disposed. A Tab
1261
+ // press during /resume is ignored instead of calling setActiveTools or
1262
+ // mutating UI state through a stale session.
1263
+ session_ready = false;
1264
+ active_session_manager = undefined;
1265
+ pending_mode_id = undefined;
1266
+ footerStatsCache = undefined;
1267
+ setShellMode(false);
1268
+ const persisted = readPersistedState();
1269
+ const model = ctx.model as Model<any> | undefined;
1270
+ const modelIdentity = model
1271
+ ? { provider: model.provider, modelId: model.id }
1272
+ : persisted.model;
1273
+ writePersistedState({ ...persisted, mode: currentMode, model: modelIdentity });
920
1274
  });
921
1275
 
922
1276
  await subagentPlugin(pi);