@benvargas/pi-model-sort 1.0.0 → 1.0.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.2] - 2026-09-11
11
+
12
+ ### Fixed
13
+ - Scoped Ctrl+P / Ctrl+Shift+P cycling no longer fails with "Cannot read
14
+ properties of undefined (reading 'persist')" on pi 0.84.3 and later. pi
15
+ 0.84.3 added a second `options` parameter to `AgentSession._cycleScopedModel`
16
+ and reads `options.persist` once a next model is selected; the patch
17
+ forwarded only the direction, so every cycle that actually changed model (two
18
+ or more available scoped models) threw after pi had already swapped the model
19
+ (no `model_select`, so last-used and thinking-level memory were not updated).
20
+ A single-model scope was unaffected (pi returns "Only one model in scope"
21
+ before reading the options). The patch now forwards every argument
22
+ untouched. Validated against pi 0.85.1, including a regression test that
23
+ drives pi's real `AgentSession` through the patched cycle.
24
+
25
+ ## [1.0.1] - 2026-09-02
26
+
27
+ ### Fixed
28
+ - An explicit `--model` on the command line now wins over the MRU startup
29
+ override. Previously a fresh start such as `pi --model provider/id` (or
30
+ bb's Pi provider, which launches `pi --mode rpc --model provider/id` and
31
+ aborts the thread with "Pi did not start with model ..." when pi reports a
32
+ different model back) was silently switched to the most recently used
33
+ model. The explicitly requested model is recorded as last-used on startup,
34
+ since pi sets it during construction without emitting `model_select`.
35
+ Detection inspects `process.argv` for `--model <value>` / `--model=value`;
36
+ `--models` (scope) is not treated as a selection.
37
+
10
38
  ## [1.0.0] - 2026-08-28
11
39
 
12
40
  ### Added
package/README.md CHANGED
@@ -5,6 +5,7 @@ Sorts pi's model picker by last usage and starts fresh sessions on your most rec
5
5
  - `/model` picker — both "Scope: all" and "Scope: scoped" views, including fuzzy-search results — is sorted by recency: current model first → most recently used → provider/id alphabetical
6
6
  - Ctrl+P / Ctrl+Shift+P **scoped** cycling follows last-used order (scoped models come from `enabledModels` or `--models`)
7
7
  - Fresh starts and `/new` begin on your most recently used model instead of `enabledModels[0]` or the hardcoded provider default
8
+ - An explicit `--model` on the command line always wins over MRU (so `pi --model provider/id`, scripts, and bb's Pi provider get the model they asked for); that model is recorded as last-used
8
9
  - Continued sessions (`pi -c`, `--session`, `/resume`, forks) keep the model saved in the session file, and that restored model is recorded as last-used (fresh `/new` sessions and `/reload` are excluded from that recording)
9
10
  - Remembers the thinking level you last used on each model and restores it on every switch, clamped to what each model supports
10
11
  - No configuration needed — tracking starts on first use and degrades to the default alphabetical order with no history
@@ -31,6 +32,7 @@ The extension works automatically — there are no commands to learn.
31
32
  /model # Most recently used models appear at the top
32
33
  Ctrl+P / Ctrl+Shift+P # Cycle through scoped models in last-used order
33
34
  pi # Fresh starts use MRU
35
+ pi --model provider/id # Explicit model wins over MRU
34
36
  pi -c # Continuations keep the session's model
35
37
  ```
36
38
 
@@ -38,7 +40,7 @@ pi -c # Continuations keep the session's model
38
40
 
39
41
  - Tracking uses pi's documented extension events: `/model` switches (`model_select`) and thinking-level changes (`thinking_level_select`) are timestamped into `~/.pi/agent/extensions/pi-model-sort.json`. Continued sessions restore their model during construction without emitting `model_select` (pi 0.84.3), so the extension records the restored model at `session_start` itself.
40
42
  - Sorting has no SDK hook, so the extension wraps (monkey-patches) internal methods: `ModelSelectorComponent.sortModels`, its scoped loader and `filterModels`, and `AgentSession._cycleScopedModel` for scoped cycling. All original methods are preserved and restored on shutdown/reload; the patches survive `modelRegistry.refresh()`.
41
- - The MRU startup override calls `pi.setModel()` on `session_start` for fresh starts and `/new` only. A continued session is detected by projecting its branch through pi's own context-message rules (`message`, `custom_message`, non-empty `branch_summary`, and `compaction` entries — exactly what `buildSessionContext()` counts) — pi seeds every new session with `model_change` + `thinking_level_change` entries before `session_start` fires, so raw branch length cannot distinguish fresh from continued.
43
+ - The MRU startup override calls `pi.setModel()` on `session_start` for fresh starts and `/new` only, and never when the process was launched with `--model` (pi exposes no "model came from the CLI" signal to extensions, so the extension inspects `process.argv`). A continued session is detected by projecting its branch through pi's own context-message rules (`message`, `custom_message`, non-empty `branch_summary`, and `compaction` entries — exactly what `buildSessionContext()` counts) — pi seeds every new session with `model_change` + `thinking_level_change` entries before `session_start` fires, so raw branch length cannot distinguish fresh from continued.
42
44
 
43
45
  ### Known limitations on pi 0.84.x
44
46
 
@@ -45,6 +45,7 @@ import {
45
45
  findMruModel,
46
46
  handleModelSelect,
47
47
  hasContextMessages,
48
+ hasExplicitModelArg,
48
49
  type ModelSortConfig,
49
50
  parseConfig,
50
51
  recordThinkingSelect,
@@ -315,18 +316,24 @@ function unpatchRegistry(registry: PatchedRegistry): void {
315
316
  // before cycling so Ctrl+P / Ctrl+Shift+P follows last-used order instead
316
317
  // of the configured order. Non-destructive: the session's stored order is
317
318
  // temporarily swapped and restored after the cycle lookup.
319
+ //
320
+ // Every argument is forwarded untouched: pi 0.84.3 added a second
321
+ // `options: ModelMutationOptions` parameter and reads `options.persist` once
322
+ // a next model is selected, so dropping it made every model-changing scoped
323
+ // cycle (two or more available scoped models) throw. A single-model scope
324
+ // returns early ("Only one model in scope") and never reached the crash.
318
325
 
319
326
  type ScopedModelEntry = { model: { provider: string; id: string }; thinkingLevel?: string };
320
327
 
321
- let origCycleScopedModel: ((direction: string) => Promise<unknown>) | null = null;
328
+ let origCycleScopedModel: ((...args: unknown[]) => Promise<unknown>) | null = null;
322
329
 
323
330
  function patchCycleScopedModel(getLastUsed: () => Record<string, number>): void {
324
331
  if (origCycleScopedModel !== null) return;
325
332
 
326
333
  const proto = AgentSession.prototype as unknown as Record<string, unknown>;
327
- origCycleScopedModel = proto._cycleScopedModel as (direction: string) => Promise<unknown>;
334
+ origCycleScopedModel = proto._cycleScopedModel as (...args: unknown[]) => Promise<unknown>;
328
335
 
329
- proto._cycleScopedModel = async function (this: Record<string, unknown>, direction: string) {
336
+ proto._cycleScopedModel = async function (this: Record<string, unknown>, ...args: unknown[]) {
330
337
  const orig = origCycleScopedModel;
331
338
  if (!orig) return undefined;
332
339
 
@@ -334,7 +341,7 @@ function patchCycleScopedModel(getLastUsed: () => Record<string, number>): void
334
341
  const origScoped = this._scopedModels as ScopedModelEntry[] | undefined;
335
342
 
336
343
  if (!origScoped || origScoped.length <= 1) {
337
- return orig.call(this, direction);
344
+ return orig.apply(this, args);
338
345
  }
339
346
 
340
347
  // Sort by last-used without mutating the session's stored order.
@@ -350,7 +357,7 @@ function patchCycleScopedModel(getLastUsed: () => Record<string, number>): void
350
357
  // Temporarily swap for the cycle lookup, restore afterward.
351
358
  this._scopedModels = sorted;
352
359
  try {
353
- return await orig.call(this, direction);
360
+ return await orig.apply(this, args);
354
361
  } finally {
355
362
  this._scopedModels = origScoped;
356
363
  }
@@ -398,8 +405,21 @@ export default function (pi: ExtensionAPI) {
398
405
  // branch_summary, compaction entries) — the same predicate pi core uses
399
406
  // for its continuation check, not raw branch length and not literal
400
407
  // message entries alone.
408
+ //
409
+ // An explicit `--model` on the command line always wins. Pi resolved
410
+ // that model during construction without emitting model_select, so on
411
+ // the initial startup record it as last-used instead of overriding it.
412
+ // This is what bb's Pi provider relies on: it launches
413
+ // `pi --mode rpc --model provider/id` and aborts the thread if pi
414
+ // reports a different model back.
401
415
  const hasSessionMessages = hasContextMessages(ctx.sessionManager.buildContextEntries());
402
- if (shouldApplyMruOverride(event.reason, hasSessionMessages) && Object.keys(lastUsed).length > 0) {
416
+ const explicitModel = hasExplicitModelArg(process.argv);
417
+ if (explicitModel && shouldApplyMruOverride(event.reason, hasSessionMessages)) {
418
+ if (event.reason === "startup" && ctx.model) {
419
+ lastUsed[buildModelKey(ctx.model.provider, ctx.model.id)] = Date.now();
420
+ writeConfig({ lastUsed, thinking: tracker.thinking });
421
+ }
422
+ } else if (shouldApplyMruOverride(event.reason, hasSessionMessages) && Object.keys(lastUsed).length > 0) {
403
423
  const mruModel = findMruModel(lastUsed, ctx.modelRegistry);
404
424
  const currentModel = ctx.model as { provider: string; id: string } | undefined;
405
425
  if (
@@ -101,6 +101,36 @@ export function shouldApplyMruOverride(reason: SessionStartReason, hasSessionMes
101
101
  return reason === "startup" || reason === "new";
102
102
  }
103
103
 
104
+ /**
105
+ * Whether the process was launched with an explicit `--model` argument.
106
+ *
107
+ * Pi does not tell extensions whether the starting model came from the CLI,
108
+ * so this inspects the argv pi was started with. An explicit model must win
109
+ * over the MRU override: callers such as bb's Pi provider (`pi --mode rpc
110
+ * --model provider/id`), scripts, and anyone typing `pi --model ...` expect
111
+ * the model they asked for, and bb aborts the thread when the model pi
112
+ * reports back differs from the one it requested.
113
+ *
114
+ * Only pi's own spelling counts: `--model <value>` (pi 0.84.x has no `-m`
115
+ * alias). `--model=value` is accepted too in case pi adds it. `--models` is
116
+ * a scope list, not a selection, and is ignored. A trailing `--model` with no
117
+ * value is ignored.
118
+ *
119
+ * @param argv process arguments to inspect (normally `process.argv`)
120
+ */
121
+ export function hasExplicitModelArg(argv: readonly string[]): boolean {
122
+ for (let i = 0; i < argv.length; i++) {
123
+ const arg = argv[i];
124
+ if (arg === "--model") {
125
+ const value = argv[i + 1];
126
+ if (value !== undefined && value !== "" && !value.startsWith("-")) return true;
127
+ continue;
128
+ }
129
+ if (arg.startsWith("--model=") && arg.length > "--model=".length) return true;
130
+ }
131
+ return false;
132
+ }
133
+
104
134
  /**
105
135
  * Whether the model pi restored for this session start should be recorded as
106
136
  * last-used. Pi 0.84.3 restores a continued session's model during
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@benvargas/pi-model-sort",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Sort models in pi by last usage and start fresh sessions on the most recently used model",
5
5
  "keywords": [
6
6
  "pi",