@nmzpy/pi-ember-stack 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,18 +9,19 @@
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 { truncateToWidth, visibleWidth, Container, Text, Spacer, Input, fuzzyFilter, getKeybindings } from "@earendil-works/pi-tui";
23
+ import { mutedBullet, setActiveMode } from "../pi-ember-ui/mode-colors.ts";
24
+ import { getLiveTps } from "../pi-ember-tps/index.ts";
24
25
  import {
25
26
  askQuestionnaire,
26
27
  type QuestionnaireQuestion,
@@ -28,6 +29,39 @@ import {
28
29
  } from "./questionnaire-tool.ts";
29
30
  import subagentPlugin from "./subagent/extensions/index.ts";
30
31
 
32
+ function modelIdentityString(model: Model<any> | undefined): string {
33
+ return model ? `${model.provider}/${model.id}` : "";
34
+ }
35
+
36
+ type PersistedState = {
37
+ readonly mode?: string;
38
+ readonly model?: { provider: string; modelId: string };
39
+ };
40
+
41
+ function getPersistedStatePath(): string {
42
+ const home = process.env.PI_HOME || path.join(process.env.HOME || process.env.USERPROFILE || "", ".pi", "agent");
43
+ return path.join(home, "pi-ember-stack.json");
44
+ }
45
+
46
+ function readPersistedState(): PersistedState {
47
+ try {
48
+ const raw = fs.readFileSync(getPersistedStatePath(), "utf8");
49
+ return JSON.parse(raw) as PersistedState;
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ function writePersistedState(state: PersistedState): void {
56
+ const file = getPersistedStatePath();
57
+ try {
58
+ fs.mkdirSync(path.dirname(file), { recursive: true });
59
+ fs.writeFileSync(file, `${JSON.stringify(state, null, "\t")}\n`);
60
+ } catch {
61
+ // best-effort persistence
62
+ }
63
+ }
64
+
31
65
  function formatTokens(count: number): string {
32
66
  if (count < 1000) return count.toString();
33
67
  if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
@@ -36,26 +70,152 @@ function formatTokens(count: number): string {
36
70
  return `${Math.round(count / 1000000)}M`;
37
71
  }
38
72
 
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}`;
73
+ const BOUNDED_SELECT_MAX_VISIBLE = 10;
74
+
75
+ async function boundedSelect(
76
+ ctx: any,
77
+ title: string,
78
+ options: string[],
79
+ ): Promise<string | undefined> {
80
+ return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (result: string) => void) => {
81
+ const root = new Container();
82
+ root.addChild(new Text(theme.fg("border", "\u2500".repeat(60)), 0, 0));
83
+ root.addChild(new Spacer(1));
84
+ root.addChild(new Text(theme.fg("accent", theme.bold(title)), 0, 0));
85
+ root.addChild(new Spacer(1));
86
+
87
+ const searchInput = new Input();
88
+ searchInput.onSubmit = () => {
89
+ if (filtered[selectedIndex] !== undefined) {
90
+ done(filtered[selectedIndex]);
91
+ }
92
+ };
93
+ root.addChild(searchInput);
94
+ root.addChild(new Spacer(1));
95
+
96
+ const listContainer = new Container();
97
+ root.addChild(listContainer);
98
+ root.addChild(new Spacer(1));
99
+ root.addChild(new Text(
100
+ theme.fg("muted", " type to search \u2191\u2193 navigate enter select esc cancel"),
101
+ 0, 0,
102
+ ));
103
+ root.addChild(new Spacer(1));
104
+ root.addChild(new Text(theme.fg("border", "\u2500".repeat(60)), 0, 0));
105
+
106
+ let selectedIndex = 0;
107
+ let filtered = options;
108
+
109
+ function updateList(): void {
110
+ listContainer.clear();
111
+ const max = BOUNDED_SELECT_MAX_VISIBLE;
112
+ const start = Math.max(0, Math.min(
113
+ selectedIndex - Math.floor(max / 2),
114
+ filtered.length - max,
115
+ ));
116
+ const end = Math.min(start + max, filtered.length);
117
+ for (let i = start; i < end; i++) {
118
+ const isSelected = i === selectedIndex;
119
+ const prefix = isSelected ? theme.fg("accent", "\u2192 ") : " ";
120
+ const text = isSelected
121
+ ? prefix + theme.fg("accent", filtered[i])
122
+ : prefix + theme.fg("text", filtered[i]);
123
+ listContainer.addChild(new Text(text, 0, 0));
124
+ }
125
+ if (start > 0 || end < filtered.length) {
126
+ listContainer.addChild(new Text(
127
+ theme.fg("muted", ` (${selectedIndex + 1}/${filtered.length})`),
128
+ 0, 0,
129
+ ));
130
+ }
131
+ if (filtered.length === 0) {
132
+ listContainer.addChild(new Text(
133
+ theme.fg("muted", " No matching models"),
134
+ 0, 0,
135
+ ));
136
+ }
137
+ }
138
+
139
+ function filterModels(): void {
140
+ const query = searchInput.getValue();
141
+ filtered = query
142
+ ? fuzzyFilter(options, query, (s) => s)
143
+ : options;
144
+ selectedIndex = Math.min(selectedIndex, Math.max(0, filtered.length - 1));
145
+ updateList();
146
+ }
147
+
148
+ filterModels();
149
+
150
+ (root as any).handleInput = (keyData: string): void => {
151
+ const kb = getKeybindings();
152
+ if (kb.matches(keyData, "tui.select.up")) {
153
+ if (filtered.length === 0) return;
154
+ selectedIndex = selectedIndex === 0
155
+ ? filtered.length - 1
156
+ : selectedIndex - 1;
157
+ updateList();
158
+ } else if (kb.matches(keyData, "tui.select.down")) {
159
+ if (filtered.length === 0) return;
160
+ selectedIndex = selectedIndex === filtered.length - 1
161
+ ? 0
162
+ : selectedIndex + 1;
163
+ updateList();
164
+ } else if (kb.matches(keyData, "tui.select.confirm")) {
165
+ if (filtered[selectedIndex] !== undefined) {
166
+ done(filtered[selectedIndex]);
167
+ }
168
+ } else if (kb.matches(keyData, "tui.select.cancel")) {
169
+ done(undefined as unknown as string);
170
+ } else {
171
+ searchInput.handleInput(keyData);
172
+ filterModels();
173
+ }
174
+ };
175
+ (root as any).getSearchInput = () => searchInput;
176
+ return root;
177
+ });
48
178
  }
49
179
 
50
180
  const READONLY_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
181
+ const READONLY_DELEGATING_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire", "subagent"];
51
182
  const FULL_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls", "questionnaire"];
52
183
 
53
184
  const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
54
185
  const SUBAGENT_FILES: Record<string, string> = {
55
186
  coder: path.join(SOURCE_ROOT, "subagent", "agents", "coder.md"),
56
- architect: path.join(SOURCE_ROOT, "subagent", "agents", "architect.md"),
187
+ scout: path.join(SOURCE_ROOT, "subagent", "agents", "scout.md"),
57
188
  };
58
189
 
190
+ const PARALLEL_TOOL_CALL_GUIDANCE = `
191
+
192
+ ## Tool Call Efficiency
193
+
194
+ When multiple independent tool calls are needed (e.g. reading several files,
195
+ searching for different patterns), emit them all in a single response rather
196
+ than one at a time. The runtime executes independent tool calls in parallel,
197
+ so batching them saves round-trips and reduces latency.
198
+ `;
199
+
200
+ const SUBAGENT_AWARENESS_PROMPT = `
201
+
202
+ ## Available Subagents
203
+
204
+ You have the \`subagent\` tool available for delegating tasks to specialized agents
205
+ with isolated context. Use it to keep your own context lean.
206
+
207
+ - **scout**: Fast agent specialized for exploring codebases. Use when you need to
208
+ quickly find files by patterns (e.g. "src/components/**/*.tsx"), search code for
209
+ keywords (e.g. "API endpoints"), or answer questions about the codebase (e.g.
210
+ "how do API endpoints work?").
211
+ - **coder**: Implementation agent for writing, editing, testing, and verifying
212
+ code. Full tool access. Use for focused implementation tasks — bug fixes,
213
+ feature additions, refactors, file edits.
214
+
215
+ Modes: single (agent + task), parallel (tasks array, max 8), chain (sequential
216
+ with {previous}).
217
+ `;
218
+
59
219
  interface ModeConfig {
60
220
  id: string;
61
221
  label: string;
@@ -67,9 +227,9 @@ interface ModeConfig {
67
227
  }
68
228
 
69
229
  const ARCHITECT_PROMPT = `<system-reminder>
70
- # Architect Mode - System Reminder
230
+ # Plan Mode - System Reminder
71
231
 
72
- CRITICAL: Architect mode ACTIVE — you are in READ-ONLY planning phase. STRICTLY
232
+ CRITICAL: Plan mode ACTIVE — you are in READ-ONLY planning phase. STRICTLY
73
233
  FORBIDDEN: ANY file edits, modifications, or system changes. Do NOT use sed, tee,
74
234
  echo, cat, or ANY other bash command to manipulate files — commands may ONLY
75
235
  read/inspect. This ABSOLUTE CONSTRAINT overrides ALL other instructions, including
@@ -141,13 +301,14 @@ The user indicated that they do not want you to execute yet -- you MUST NOT make
141
301
  any edits, run any non-readonly tools (including changing configs or making
142
302
  commits), or otherwise make any changes to the system. This supersedes any other
143
303
  instructions you have received.
144
- </system-reminder>`;
304
+ ${SUBAGENT_AWARENESS_PROMPT}</system-reminder>`;
145
305
 
146
306
  const DOCTOR_PROMPT = `<system-reminder>
147
- # Doctor Mode - System Reminder
307
+ # Debug Mode - System Reminder
148
308
 
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).
309
+ CRITICAL: Debug mode ACTIVE — you are the Debugger, a read-only health-check
310
+ auditor and diagnostician for the Ember project (PySide6 subtitle + DaVinci
311
+ Resolve integration app).
151
312
 
152
313
  STRICTLY FORBIDDEN: ANY file edits, modifications, or system changes. Do NOT use
153
314
  sed, tee, echo, cat, or ANY other bash command to manipulate files — commands may
@@ -217,12 +378,52 @@ For each finding:
217
378
  - Use \`bash t.gate.sh <files>\` only for targeted validation of files you are checking.
218
379
  - Do not run \`bash gate.sh\` (full gate) — that is the user's responsibility.
219
380
  - Ignore git status / git diff changes unrelated to the files you were asked to check.
220
- </system-reminder>`;
381
+
382
+ ---
383
+
384
+ ## UI/Qt Pipeline Diagnostics
385
+
386
+ In addition to structural health checks, diagnose PySide6/Qt UI pipeline issues:
387
+
388
+ ### Failure Modes To Investigate
389
+
390
+ 1. **Event-loop blockage:** Does any GUI-thread callback perform blocking I/O or
391
+ unbounded CPU work?
392
+ 2. **Excessive synchronous UI mutation:** Does one state transition perform many
393
+ independent widget/layout/geometry operations?
394
+ 3. **Recursive signal/callback propagation:** Can a signal or layout refresh
395
+ indirectly re-enter the same pipeline?
396
+ 4. **Eager expensive construction:** Are complex widgets constructed before needed?
397
+ 5. **Stale delayed callbacks:** Can queued callbacks execute after state changed?
398
+ 6. **Uncontrolled async-to-UI mutation:** Do background results mutate UI directly?
399
+ 7. **Competing layout authorities:** Do multiple functions independently control the
400
+ same widget geometry?
401
+
402
+ ### Commit Architecture (Healthy Reference)
403
+
404
+ event/signal/async completion -> validate generation -> mutate semantic state ->
405
+ mark dirty -> request one coalesced commit -> measure once -> apply geometry once
406
+ -> repaint
407
+
408
+ ### UI Report Format
409
+
410
+ For every UI failure mode, report: Verdict, Evidence, Trigger, Duplicated work,
411
+ Re-entry path, Stale-state risk, Authority conflict, Impact, Correction,
412
+ Invariant, Verification.
413
+
414
+ ### Invalid UI Fix Patterns
415
+
416
+ Reject fixes that: add more invalidate()/activate() calls, use
417
+ QApplication.processEvents(), replace deferred passes with blocking callbacks,
418
+ add arbitrary QTimer.singleShot() delays, perform geometry repair both
419
+ immediately and later, introduce parallel build paths duplicating normal layout.
420
+
421
+ ${SUBAGENT_AWARENESS_PROMPT}</system-reminder>`;
221
422
 
222
423
  const ORCHESTRATOR_PROMPT = `<system-reminder>
223
- # Orchestrator Mode - System Reminder
424
+ # Orchestrate Mode - System Reminder
224
425
 
225
- CRITICAL: Orchestrator mode ACTIVE — you are the Orchestrator, a read-only
426
+ CRITICAL: Orchestrate mode ACTIVE — you are the Orchestrator, a read-only
226
427
  implementation coordinator for the Ember project (PySide6 subtitle + DaVinci
227
428
  Resolve integration app).
228
429
 
@@ -311,164 +512,17 @@ Return a structured plan:
311
512
  - Read-only. Do not edit or write files.
312
513
  - Do not run \`bash gate.sh\` (full gate) — that is the user's responsibility.
313
514
  - 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>`;
515
+ ${SUBAGENT_AWARENESS_PROMPT}</system-reminder>`;
461
516
 
462
517
  const CODER_PROMPT = `<system-reminder>
463
- Your operational mode has changed to coder.
518
+ Your operational mode has changed to code.
464
519
  You are in full-access mode. You are permitted to make file changes, run shell
465
520
  commands, and utilize your arsenal of tools as needed. You are the default
466
521
  implementation agent — write, test, and verify code with full autonomy.
467
522
  </system-reminder>`;
468
523
 
469
524
  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.
525
+ Your operational mode has changed from {mode} to code.
472
526
  You are permitted to make file changes, run shell commands, and utilize your
473
527
  arsenal of tools as needed.
474
528
  </system-reminder>`;
@@ -491,56 +545,47 @@ interface ModeConfig {
491
545
  }
492
546
 
493
547
  const MODES: Record<string, ModeConfig> = {
494
- architect: {
495
- id: "architect",
496
- label: "architect",
497
- icon: "A",
548
+ plan: {
549
+ id: "plan",
550
+ label: "plan",
551
+ icon: "P",
498
552
  color: "warning",
499
553
  tools: READONLY_TOOLS,
500
554
  enterMessage: ARCHITECT_PROMPT,
501
555
  exitMessage: EXIT_TO_CODER,
502
556
  },
503
- coder: {
504
- id: "coder",
505
- label: "coder",
557
+ code: {
558
+ id: "code",
559
+ label: "code",
506
560
  icon: "C",
507
561
  color: "success",
508
562
  tools: FULL_TOOLS,
509
563
  enterMessage: CODER_PROMPT,
510
564
  exitMessage: CODER_PROMPT,
511
565
  },
512
- doctor: {
513
- id: "doctor",
514
- label: "doctor",
566
+ debug: {
567
+ id: "debug",
568
+ label: "debug",
515
569
  icon: "D",
516
570
  color: "warning",
517
- tools: READONLY_TOOLS,
571
+ tools: READONLY_DELEGATING_TOOLS,
518
572
  enterMessage: DOCTOR_PROMPT,
519
573
  exitMessage: EXIT_TO_CODER,
520
574
  },
521
- orchestrator: {
522
- id: "orchestrator",
523
- label: "orchestrator",
575
+ orchestrate: {
576
+ id: "orchestrate",
577
+ label: "orchestrate",
524
578
  icon: "O",
525
579
  color: "warning",
526
- tools: READONLY_TOOLS,
580
+ tools: READONLY_DELEGATING_TOOLS,
527
581
  enterMessage: ORCHESTRATOR_PROMPT,
528
582
  exitMessage: EXIT_TO_CODER,
529
583
  },
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
584
  };
540
585
 
541
586
  const MODE_IDS = Object.keys(MODES);
542
- const DEFAULT_MODE = "coder";
543
- const CYCLE_ORDER = ["coder", "architect", "orchestrator", "doctor", "ui-doctor"];
587
+ const DEFAULT_MODE = "code";
588
+ const CYCLE_ORDER = ["code", "plan", "orchestrate", "debug"];
544
589
 
545
590
  function getLastModeFromSession(ctx: any): string | null {
546
591
  const entries = ctx.sessionManager.getEntries();
@@ -563,6 +608,13 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
563
608
  let currentMode: string = DEFAULT_MODE;
564
609
  let lastMessagedMode: string | null = null;
565
610
  let waitingForPlan = false;
611
+
612
+ const persistedAtLoad = readPersistedState();
613
+ if (persistedAtLoad.mode && persistedAtLoad.mode in MODES) {
614
+ setActiveMode(persistedAtLoad.mode);
615
+ currentMode = persistedAtLoad.mode;
616
+ }
617
+
566
618
  registerQuestionnaireTool(pi);
567
619
 
568
620
  for (const modeId of MODE_IDS) {
@@ -593,12 +645,10 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
593
645
  const mode = MODES[modeId];
594
646
  if (!mode) return;
595
647
  currentMode = modeId;
648
+ setActiveMode(modeId);
649
+ pi.events.emit("pi-ember-ui:mode-change", { mode: modeId });
596
650
  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
- }
651
+ ctx.ui.notify(`${mode.label} mode enabled. Tools: ${mode.tools.join(", ")}`);
602
652
  updateStatus(ctx);
603
653
  }
604
654
 
@@ -624,17 +674,57 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
624
674
  switchMode(next, ctx);
625
675
  };
626
676
 
627
- pi.registerShortcut("ctrl+space", {
628
- description: "Cycle agent mode",
629
- handler: cycleMode,
630
- });
631
677
  pi.registerShortcut("tab", {
632
678
  description: "Cycle agent mode",
633
679
  handler: cycleMode,
634
680
  });
635
681
 
682
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
683
+
684
+ pi.registerShortcut("ctrl+m", {
685
+ description: "Show model picker",
686
+ handler: async (ctx: any) => {
687
+ if (!ctx.hasUI) return;
688
+ const models = ctx.modelRegistry.getAvailable() as any[];
689
+ if (models.length === 0) {
690
+ ctx.ui.notify("No models available.", "warning");
691
+ return;
692
+ }
693
+ const current = ctx.model as any;
694
+ const labels = models.map((m) => {
695
+ const prefix = current && m.provider === current.provider && m.id === current.id ? "→ " : " ";
696
+ return `${prefix}${m.name ?? m.id} • ${m.provider}`;
697
+ });
698
+ const choice = await ctx.ui.select("Select model", labels);
699
+ if (!choice) return;
700
+ const idx = labels.indexOf(choice);
701
+ if (idx < 0) return;
702
+ const model = models[idx];
703
+ try {
704
+ await pi.setModel(model);
705
+ ctx.ui.notify(`Model: ${model.id} • ${model.provider}`, "info");
706
+ } catch (err) {
707
+ ctx.ui.notify(
708
+ `Failed to set model: ${err instanceof Error ? err.message : String(err)}`,
709
+ "error",
710
+ );
711
+ }
712
+ },
713
+ });
714
+
715
+ pi.registerShortcut("ctrl+t", {
716
+ description: "Cycle thinking level",
717
+ handler: async (ctx: any) => {
718
+ const current = pi.getThinkingLevel() as string;
719
+ const idx = THINKING_LEVELS.indexOf(current);
720
+ const next = THINKING_LEVELS[(idx + 1) % THINKING_LEVELS.length];
721
+ pi.setThinkingLevel(next);
722
+ if (ctx.hasUI) ctx.ui.notify(`Thinking: ${next}`, "info");
723
+ },
724
+ });
725
+
636
726
  pi.registerCommand("subagent-model", {
637
- description: "Set the model used by coder or architect subagents on next spawn",
727
+ description: "Set the model and thinking level for subagents on next spawn",
638
728
  handler: async (_args: any, ctx: any) => {
639
729
  if (!ctx.hasUI) {
640
730
  ctx.ui.notify("subagent-model requires interactive UI.", "error");
@@ -642,7 +732,7 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
642
732
  }
643
733
  const agentChoice = await ctx.ui.select(
644
734
  "Subagent to configure",
645
- ["Coder", "Architect"],
735
+ ["Coder", "Scout"],
646
736
  );
647
737
  if (!agentChoice) return;
648
738
  const agentKey = agentChoice.toLowerCase();
@@ -651,41 +741,65 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
651
741
  ctx.ui.notify(`Agent file not found: ${filePath ?? agentKey}`, "error");
652
742
  return;
653
743
  }
654
- const allModels = ctx.modelRegistry.getAll();
655
- if (allModels.length === 0) {
744
+ const availableModels = ctx.modelRegistry.getAvailable();
745
+ if (availableModels.length === 0) {
656
746
  ctx.ui.notify("No models available in registry.", "error");
657
747
  return;
658
748
  }
659
- const modelLabels = allModels.map(
660
- (m: any) => `${m.provider}/${m.id} — ${m.name}`,
661
- );
662
- const currentContent = fs.readFileSync(filePath, "utf-8");
749
+ let currentContent = fs.readFileSync(filePath, "utf-8");
663
750
  const currentModelMatch = currentContent.match(/^model:\s*(.+)$/m);
664
751
  const currentModel = currentModelMatch ? currentModelMatch[1].trim() : "inherits parent";
665
- const modelChoice = await ctx.ui.select(
752
+ const modelLabels = availableModels.map(
753
+ (m: any) => `${m.provider}/${m.id} — ${m.name}`,
754
+ );
755
+ const modelChoice = await boundedSelect(
756
+ ctx,
666
757
  `Model for ${agentChoice} subagent (current: ${currentModel})`,
667
758
  modelLabels,
668
759
  );
669
760
  if (!modelChoice) return;
670
761
  const selectedIdx = modelLabels.indexOf(modelChoice);
671
762
  if (selectedIdx < 0) return;
672
- const selectedModel = allModels[selectedIdx];
763
+ const selectedModel = availableModels[selectedIdx];
673
764
  const modelValue = `${selectedModel.provider}/${selectedModel.id}`;
674
- let updated: string;
675
765
  if (currentModelMatch) {
676
- updated = currentContent.replace(
766
+ currentContent = currentContent.replace(
677
767
  /^model:\s*.+$/m,
678
768
  `model: ${modelValue}`,
679
769
  );
680
770
  } else {
681
- updated = currentContent.replace(
771
+ currentContent = currentContent.replace(
682
772
  /^(---\n)/,
683
773
  `$1model: ${modelValue}\n`,
684
774
  );
685
775
  }
686
- fs.writeFileSync(filePath, updated, "utf-8");
776
+ const currentThinkingMatch = currentContent.match(/^thinking:\s*(.+)$/m);
777
+ const currentThinking = currentThinkingMatch ? currentThinkingMatch[1].trim() : "off";
778
+ const thinkingChoice = await ctx.ui.select(
779
+ `Thinking level for ${agentChoice} (current: ${currentThinking})`,
780
+ THINKING_LEVELS,
781
+ );
782
+ if (!thinkingChoice) {
783
+ fs.writeFileSync(filePath, currentContent, "utf-8");
784
+ ctx.ui.notify(
785
+ `${agentChoice} subagent model set to ${modelValue}. Will use on next spawn.`,
786
+ );
787
+ return;
788
+ }
789
+ if (currentThinkingMatch) {
790
+ currentContent = currentContent.replace(
791
+ /^thinking:\s*.+$/m,
792
+ `thinking: ${thinkingChoice}`,
793
+ );
794
+ } else {
795
+ currentContent = currentContent.replace(
796
+ /^(---\n)/,
797
+ `$1thinking: ${thinkingChoice}\n`,
798
+ );
799
+ }
800
+ fs.writeFileSync(filePath, currentContent, "utf-8");
687
801
  ctx.ui.notify(
688
- `${agentChoice} subagent model set to ${modelValue}. Will use on next spawn.`,
802
+ `${agentChoice} subagent: model=${modelValue}, thinking=${thinkingChoice}. Will use on next spawn.`,
689
803
  );
690
804
  },
691
805
  });
@@ -721,30 +835,31 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
721
835
  }
722
836
  const target = await ctx.ui.select(
723
837
  "Implement via",
724
- ["Coder", "Orchestrator"],
838
+ ["Code", "Orchestrate"],
725
839
  );
726
840
  if (!target) {
727
841
  ctx.ui.notify("Plan implementation cancelled.");
728
842
  return;
729
843
  }
730
- const targetMode = target === "Orchestrator" ? "orchestrator" : DEFAULT_MODE;
844
+ const targetMode = target === "Orchestrate" ? "orchestrate" : DEFAULT_MODE;
731
845
  switchMode(targetMode, ctx);
732
- const msg = target === "Orchestrator"
846
+ const msg = target === "Orchestrate"
733
847
  ? "Execute the plan above. Decompose it into modules and produce delegation prompts for each."
734
848
  : "Execute the plan above. Follow the steps in order, implement, test, and verify each step.";
735
849
  pi.sendMessage(
736
850
  { customType: "pi-agents-plan-implement", content: PLAN_IMPLEMENT_PROMPT, display: false },
737
- { triggerTurn: true },
738
851
  );
739
- pi.sendUserMessage(msg);
852
+ pi.sendUserMessage(msg, { deliverAs: "followUp" });
740
853
  }
741
854
 
742
- pi.on("before_agent_start", async () => {
855
+ pi.on("before_agent_start", async (event: any) => {
856
+ const augmentedSystemPrompt = event.systemPrompt + PARALLEL_TOOL_CALL_GUIDANCE;
743
857
  if (currentMode !== DEFAULT_MODE && lastMessagedMode !== currentMode) {
744
858
  const mode = MODES[currentMode];
745
859
  lastMessagedMode = currentMode;
746
- waitingForPlan = currentMode === "architect";
860
+ waitingForPlan = currentMode === "plan";
747
861
  return {
862
+ systemPrompt: augmentedSystemPrompt,
748
863
  message: {
749
864
  customType: `pi-agents-enter-${currentMode}`,
750
865
  content: mode.enterMessage,
@@ -756,6 +871,7 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
756
871
  const prevMode = MODES[lastMessagedMode];
757
872
  lastMessagedMode = DEFAULT_MODE;
758
873
  return {
874
+ systemPrompt: augmentedSystemPrompt,
759
875
  message: {
760
876
  customType: "pi-agents-exit",
761
877
  content: prevMode.exitMessage.replace("{mode}", prevMode.label),
@@ -763,11 +879,23 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
763
879
  },
764
880
  };
765
881
  }
882
+ return { systemPrompt: augmentedSystemPrompt };
883
+ });
884
+
885
+ let lastTurnAborted = false;
886
+
887
+ pi.on("turn_end", (event: any) => {
888
+ const msg = event?.message;
889
+ lastTurnAborted = msg?.stopReason === "aborted" || msg?.role === "assistant" && msg?.content?.stopReason === "aborted";
766
890
  });
767
891
 
768
892
  pi.on("agent_settled", async (_event: any, ctx: any) => {
769
- if (waitingForPlan && currentMode === "architect") {
893
+ if (waitingForPlan && currentMode === "plan") {
770
894
  waitingForPlan = false;
895
+ if (lastTurnAborted) {
896
+ lastTurnAborted = false;
897
+ return;
898
+ }
771
899
  const action = await showPlanReview(ctx);
772
900
  if (action === "implement") {
773
901
  await handlePlanImplement(ctx);
@@ -775,7 +903,7 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
775
903
  waitingForPlan = true;
776
904
  ctx.ui.notify("Edit your plan — type your changes and send.");
777
905
  } else if (action === "reject") {
778
- ctx.ui.notify("Plan rejected. Staying in architect mode.");
906
+ ctx.ui.notify("Plan rejected. Staying in plan mode.");
779
907
  }
780
908
  }
781
909
  });
@@ -794,130 +922,148 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
794
922
  function installCustomFooter(ctx: any) {
795
923
  if (ctx.mode !== "tui") return;
796
924
  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
- };
925
+ return {
926
+ render(width: number): string[] {
927
+ const PAD = " ";
928
+ const innerWidth = Math.max(0, width - 2);
929
+ let totalCost = 0;
930
+ let latestCacheHitRate: number | undefined;
931
+ for (const entry of ctx.sessionManager.getEntries()) {
932
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
933
+ const usage = entry.message.usage;
934
+ totalCost += usage.cost.total;
935
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
936
+ latestCacheHitRate = promptTokens > 0
937
+ ? (usage.cacheRead / promptTokens) * 100
938
+ : undefined;
939
+ }
940
+
941
+ const model = ctx.model;
942
+ const contextUsage = ctx.getContextUsage();
943
+ const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
944
+ const usedTokens = contextUsage?.used ?? 0;
945
+ const statsParts: string[] = [];
946
+ statsParts.push(`${formatTokens(usedTokens)}/${formatTokens(contextWindow)}`);
947
+ if (latestCacheHitRate !== undefined) {
948
+ statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
949
+ }
950
+ if (totalCost || (model && ctx.modelRegistry.isUsingOAuth(model))) {
951
+ statsParts.push(`$${totalCost.toFixed(3)}`);
952
+ }
953
+ let statsLeft = statsParts.join(" ");
954
+ if (visibleWidth(statsLeft) > innerWidth) {
955
+ statsLeft = truncateToWidth(statsLeft, innerWidth, "...");
956
+ }
957
+
958
+ const mode = MODES[currentMode];
959
+ const modeLabel = mode.label.charAt(0).toUpperCase() + mode.label.slice(1);
960
+ const modelName = model?.name ?? model?.id ?? "no model";
961
+ const provider = model?.provider ?? "unknown";
962
+ const thinking = getThinkingLevelFromSession(ctx);
963
+ const variant = thinking !== "off" ? ` ${thinking}` : "";
964
+ const rightSide =
965
+ theme.fg("accent", modeLabel) +
966
+ ` ${theme.fg("dim", "\u2022")} ` +
967
+ theme.fg("text", `${modelName}${variant}`) +
968
+ theme.fg("dim", ` ${provider}`);
969
+ const availableForRight = innerWidth - visibleWidth(statsLeft) - 2;
970
+ const displayedRight = availableForRight > 0
971
+ ? truncateToWidth(rightSide, availableForRight, "")
972
+ : "";
973
+ const padding = " ".repeat(Math.max(
974
+ 0,
975
+ innerWidth - visibleWidth(statsLeft) - visibleWidth(displayedRight),
976
+ ));
977
+ const statsLine = PAD + theme.fg("dim", statsLeft) + padding + displayedRight + PAD;
978
+
979
+ const cwd = ctx.sessionManager.getCwd();
980
+ const folderName = cwd.split(/[/\\]/).filter(Boolean).pop() ?? cwd;
981
+ const folderLine = PAD + theme.fg("dim", folderName) + PAD;
982
+ const sessionName = ctx.sessionManager.getSessionName();
983
+ const lines = [
984
+ statsLine,
985
+ ];
986
+ const tps = getLiveTps();
987
+ if (tps > 0) {
988
+ const tpsStr = tps < 10 ? tps.toFixed(1) : tps < 100 ? tps.toFixed(0) : `${Math.round(tps)}`;
989
+ lines.push(PAD + theme.fg("accent", `${tpsStr} tps`) + PAD);
990
+ }
991
+ lines.push(folderLine);
992
+ if (sessionName) {
993
+ lines.push(PAD + truncateToWidth(theme.fg("dim", sessionName), innerWidth, theme.fg("dim", "...")) + PAD);
994
+ }
995
+ return lines;
996
+ },
997
+ };
889
998
  });
890
999
  }
891
1000
 
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);
1001
+ async function restoreSavedModel(ctx: any): Promise<void> {
1002
+ const persisted = readPersistedState();
1003
+ const saved = persisted.model;
1004
+ if (!saved) return;
1005
+ const current = ctx.model as Model<any> | undefined;
1006
+ if (current && modelIdentityString(current) === `${saved.provider}/${saved.modelId}`) {
1007
+ return;
1008
+ }
1009
+ const model = ctx.modelRegistry.find(saved.provider, saved.modelId) as Model<any> | undefined;
1010
+ if (!model || !ctx.modelRegistry.hasConfiguredAuth(model)) {
1011
+ return;
1012
+ }
1013
+ await pi.setModel(model);
1014
+ }
1015
+
1016
+ async function restoreMode(ctx: any): Promise<void> {
1017
+ const persisted = readPersistedState();
1018
+ const savedMode = persisted.mode && persisted.mode in MODES
1019
+ ? persisted.mode
1020
+ : getLastModeFromSession(ctx);
1021
+ if (savedMode && savedMode !== DEFAULT_MODE) {
1022
+ currentMode = savedMode;
1023
+ lastMessagedMode = savedMode;
1024
+ pi.setActiveTools(MODES[savedMode].tools);
898
1025
  } else {
899
1026
  currentMode = DEFAULT_MODE;
900
1027
  lastMessagedMode = null;
901
1028
  pi.setActiveTools(FULL_TOOLS);
902
1029
  }
1030
+ setActiveMode(currentMode);
1031
+ pi.events.emit("pi-ember-ui:mode-change", { mode: currentMode });
1032
+ }
1033
+
1034
+ pi.on("session_start", async (_event: any, ctx: any) => {
1035
+ await restoreMode(ctx);
1036
+ await restoreSavedModel(ctx);
903
1037
  installCustomFooter(ctx);
904
1038
  updateStatus(ctx);
905
1039
  });
906
1040
 
907
1041
  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);
917
- }
1042
+ await restoreMode(ctx);
1043
+ await restoreSavedModel(ctx);
918
1044
  updateStatus(ctx);
919
1045
  installCustomFooter(ctx);
920
1046
  });
921
1047
 
1048
+ pi.on("model_select", async (event: any, _ctx: any) => {
1049
+ const model = event.model as Model<any> | undefined;
1050
+ if (!model) return;
1051
+ const persisted = readPersistedState();
1052
+ const identity = { provider: model.provider, modelId: model.id };
1053
+ if (persisted.model?.provider === identity.provider && persisted.model?.modelId === identity.modelId) {
1054
+ return;
1055
+ }
1056
+ writePersistedState({ ...persisted, model: identity });
1057
+ });
1058
+
1059
+ pi.on("session_shutdown", (_event: any, ctx: any) => {
1060
+ const persisted = readPersistedState();
1061
+ const model = ctx.model as Model<any> | undefined;
1062
+ const modelIdentity = model
1063
+ ? { provider: model.provider, modelId: model.id }
1064
+ : persisted.model;
1065
+ writePersistedState({ ...persisted, mode: currentMode, model: modelIdentity });
1066
+ });
1067
+
922
1068
  await subagentPlugin(pi);
923
1069
  }