ytdb-slate 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ytdb-slate
2
2
 
3
- Slate is a thread-weaving orchestration extension for the [pi coding agent](https://github.com/earendil-works/pi-coding-agent).
3
+ Slate is a thread-weaving orchestration extension for the [pi coding agent](https://pi.dev).
4
4
 
5
5
  The orchestrator (your main pi session) dispatches **bounded actions** to persistent **worker threads**. Each completed action is compressed by an LLM into an **episode** — a durable, structured record (intent, actions, findings, artifacts, open issues, handoff notes) that the orchestrator composes into further dispatches instead of re-reading raw transcripts. On top of that, Slate injects a mandatory workflow doctrine: research before design, design review, adversarial review, and track review — with optional umbrella **draft-PR publishing** for tracks. An opt-in **model-failover** map adds high availability: when a model API fails, the orchestrator, worker threads, and episode compression each retry once on a configured equal-quality alternative.
6
6
 
@@ -47,6 +47,10 @@ The workflow scales with change size: multi-track changes get the full flow, sin
47
47
 
48
48
  This summary is orientation only; the shipped docs listed below are normative.
49
49
 
50
+ ## Model failover
51
+
52
+ Slate can ride through model API outages. The opt-in `modelFailover` map in `.pi/slate.json` (trusted projects only) maps a model (`provider/id`) to an equal-quality alternative; on an eligible model API failure — not an abort or a context overflow, and for the orchestrator and worker sites only after pi's own retries are exhausted — the affected site (orchestrator, worker thread, or episode compression) retries once on the mapped model (single hop, never chained). An orchestrator failover — like the handoff adoption that re-applies a parent session's model — would otherwise leave pi's global model defaults changed, so Slate restores them on a best-effort basis unless `preserveGlobalModelDefault` is `false`. The map is empty by default (feature off) and read once at session start. Full semantics: the `modelFailover` row in [Configuration](#configuration) and the shipped [`docs/model-failover.md`](docs/model-failover.md) — if they disagree, that document wins.
53
+
50
54
  ## Install
51
55
 
52
56
  Install into a project (pinned, project-scoped — recorded in `.pi/settings.json`):
@@ -74,6 +78,7 @@ Optional config file: `slate.json` in the project's pi config dir (`.pi/slate.js
74
78
  | `doctrineExtraPath` | string | — | Project markdown whose **content** is appended to the orchestrator doctrine (project-specific workflow additions). |
75
79
  | `reviewPerspectivesPath` | string | — | Project review charters, each declaring its own finding-ID prefix. The doctrine references this **path**; the orchestrator reads the file alongside the shipped review rules. |
76
80
  | `modelFailover` | object (string → string) | — (empty, failover off) | Map of `provider/id` → equal-quality alternative model; on a model API failure the affected site retries once on the mapped model (worker/orchestrator sites only after pi's own retries are exhausted; episode compression has no pi retry loop). Read once at session start — see [`docs/model-failover.md`](docs/model-failover.md). |
81
+ | `preserveGlobalModelDefault` | boolean | `true` | Put pi's global model defaults (`defaultProvider`, `defaultModel`, `defaultThinkingLevel`) back after a Slate-initiated model switch — orchestrator failover and handoff adoption — so the switch stays session-scoped. `false` turns that restore off at both sites (the switches themselves are unaffected), leaving each one persisted in pi's global settings. Best-effort: Slate reverts only a value its own switch produced, and can stand down or give up — limits in [`docs/model-failover.md`](docs/model-failover.md). |
77
82
 
78
83
  Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point them at markdown files that actually exist in **your** project):
79
84
 
@@ -38,13 +38,16 @@ rule below.
38
38
 
39
39
  The map is read ONCE, at session start. Editing `slate.json`
40
40
  mid-session — e.g. adding a mapping in the middle of an outage —
41
- takes effect only in a NEW pi session.
41
+ takes effect only in a NEW pi session. The same holds for
42
+ `preserveGlobalModelDefault` (see the caveats), which lives in the
43
+ same file under the same trust rule.
42
44
 
43
45
  Configuring the map is the whole opt-in: in a trusted project with a
44
46
  `modelFailover` entry, ORCHESTRATOR failover is active in every pi
45
47
  session regardless of whether Slate's orchestrator mode is on — a
46
- matching model failure switches the session's model (and pi's global
47
- default, see the caveats) even if you never use threads.
48
+ matching model failure switches the session's model (and, as a side
49
+ effect, pi's global model defaults, which Slate restores by default —
50
+ see the caveats) even if you never use threads.
48
51
 
49
52
  ## When failover fires
50
53
 
@@ -120,14 +123,100 @@ Failover applies at three sites:
120
123
  - **Episode compression:** no live signal — the episode header's
121
124
  `compressor:` line names whichever model actually produced the
122
125
  episode.
126
+ - **The global-default restore:** silent when it succeeds, which is
127
+ the normal case. Otherwise a warning naming the reason — e.g.
128
+ `slate: not restoring the global model defaults — …` when Slate
129
+ stood down without writing anything, or `slate: could not restore
130
+ the global model defaults within …` when it wrote but could not
131
+ confirm the result in time — printed on the console and, when a UI
132
+ is attached, also raised as a notification. Both states are
133
+ explained in the caveats.
123
134
 
124
135
  ## Caveats & accepted limitations
125
136
 
126
- - Orchestrator failover persists the mapped model as the user's
127
- **global default model** — that is pi's `setModel` semantics. Switch
128
- back with `/model` once the outage is over.
137
+ - Slate switches the live model at two sites: orchestrator failover,
138
+ and the handoff adoption that re-applies a parent session's model
139
+ and thinking level when a fresh session picks up a Slate handoff.
140
+ Adoption does NOT depend on the `modelFailover` map, so everything
141
+ in this part of the caveats applies even with the map empty and
142
+ failover off. Both switches persist into the user's **global**
143
+ settings — that is pi's `setModel` / `setThinkingLevel` semantics —
144
+ affecting `defaultProvider`, `defaultModel`, and, through pi's
145
+ thinking-level cascade, `defaultThinkingLevel`. (Global settings
146
+ live in `settings.json` inside pi's agent directory:
147
+ `~/.pi/agent/` by default, elsewhere when `PI_CODING_AGENT_DIR`
148
+ relocates it.) Slate puts them back, judging each switch by two
149
+ fresh reads of the global scope taken immediately before and
150
+ immediately after it. `defaultProvider` and `defaultModel` are
151
+ decided and written as ONE UNIT, because pi only ever writes that
152
+ pair together: the pair is restored only if every half the switch
153
+ changed also holds exactly what that switch should have produced,
154
+ and if either changed half holds something else the whole pair is
155
+ left alone. When the pair is restored, the pre-switch values are
156
+ written back exactly — including a half that had no value before,
157
+ which is restored to absence. `defaultThinkingLevel` is decided and
158
+ written on its own, by the same test. A key the switch did not
159
+ change keeps its value; the live session still runs the switched
160
+ model; and resume is unaffected — the restore rewrites the settings
161
+ file, not the running session.
162
+ - `preserveGlobalModelDefault` (boolean, default `true`) disables that
163
+ restore when set to `false`, at BOTH sites; with it off, the
164
+ switched values stay in the global settings as they did before the
165
+ mechanism existed.
166
+ - **Slate can decline to restore anything.** When it cannot trust what
167
+ it read — the settings file held under another process's lock,
168
+ unreadable, corrupt or empty, or an extension context gone stale —
169
+ it warns, writes nothing and deletes nothing: acting on a read it
170
+ cannot trust is the one way this mechanism could destroy settings
171
+ rather than merely fail to tidy up. The switch itself still
172
+ happened, so you are left where you were before the restore
173
+ existed: the session runs the switched model, and the global
174
+ defaults may hold Slate's pick. The warning names the file and the
175
+ reason; clear that condition (release the other process, repair or
176
+ remove the file) and put the defaults back by hand as below. A narrower
177
+ variant: if only the session's live thinking level cannot be read,
178
+ that one key is skipped and the provider/model pair is still
179
+ evaluated.
180
+ - **A restore can also be abandoned.** Slate verifies that its
181
+ restoring write landed and retries within a wall-clock budget; if
182
+ the restore is still unconfirmed when the budget runs out, Slate
183
+ gives up and does NOT retry later. The switched provider, model, or
184
+ thinking level then persists in the global settings for that session
185
+ and every session afterwards, exactly as it did before this
186
+ mechanism existed. You are told by a console warning plus, when a UI
187
+ is attached, a notification — though console output can be lost if
188
+ the process exits on a signal immediately afterwards. Putting it
189
+ back is manual, and only partly possible from inside pi: `/model`
190
+ rewrites the provider/model pair, the thinking level has no slash
191
+ command (cycle it with its keybinding — `/hotkeys` lists it), and
192
+ neither can restore a key to ABSENT. If a key had no value before
193
+ the switch, remove it from the settings file by hand.
194
+ - The restore takes pi's settings lock, and that lock is acquired
195
+ SYNCHRONOUSLY. Under contention — another pi process writing
196
+ settings at the same moment — a switch can stall the session for a
197
+ noticeable interval before Slate gives up: the wall-clock budget
198
+ gates whether a new attempt starts, it cannot interrupt one already
199
+ waiting.
200
+ - The test is "does this key now hold exactly what Slate's switch
201
+ should have produced?", and it cannot ask who wrote that value.
202
+ Both directions follow from that one rule. A write by anything else
203
+ is reverted when it happens to leave exactly that value — which
204
+ covers a third-party write that pi's own switch then lands on top
205
+ of, and one arriving after Slate's last read but before its
206
+ restoring write, which is overwritten outright. A write that leaves
207
+ anything else fails the test, so Slate stands off and leaves the key
208
+ alone (for the pair, both keys) — and its own residue then survives
209
+ in the global settings, which is what a second, concurrent pi
210
+ session typically causes. The trade is deliberate: a missed cleanup
211
+ over a reverted user change.
212
+ - Slate writes only those three keys, and only in the GLOBAL scope —
213
+ but any settings write re-serialises the whole file through pi's own
214
+ migration, which can rename or drop legacy keys. pi does that on its
215
+ own writes too; Slate's restore can only make it happen earlier.
129
216
  - Worker sessions use a read-only settings view, so worker failover
130
- persists nothing.
217
+ persists nothing; episode compression invokes the mapped model
218
+ directly for the one request instead of switching the session's
219
+ model, so it persists nothing either.
131
220
  - Orchestrator **preflight** auth failures (e.g. an OAuth token that
132
221
  expired before any run starts) bypass failover — pi surfaces the
133
222
  error itself; recover manually with `/model`.
@@ -26,6 +26,7 @@ import {
26
26
  type ExtensionAPI,
27
27
  type ExtensionContext,
28
28
  } from "@earendil-works/pi-coding-agent";
29
+ import { withGlobalModelDefaultRestored } from "./model-default.ts";
29
30
  import type { SlateConfig } from "./state.ts";
30
31
 
31
32
  type RegistryModel = NonNullable<ReturnType<ExtensionContext["modelRegistry"]["find"]>>;
@@ -38,6 +39,34 @@ function sanitizeForNotify(s: string, max = 120): string {
38
39
  return clean.length > max ? `${clean.slice(0, max)}…` : clean;
39
40
  }
40
41
 
42
+ // Console-first reporting, for FAILURES ONLY: the console line is
43
+ // unconditional, so a failover that could not happen still surfaces in
44
+ // headless runs and at teardown — the has-UI branch is exactly the one that
45
+ // vanishes there — and the UI notification is the extra. Success notices keep
46
+ // their has-UI gate: an unconditional stderr write during an interactive turn
47
+ // scribbles pi-tui's differentially rendered frame, and a failover that worked
48
+ // is not news worth that cost. ctx.hasUI is a getter that THROWS on a stale
49
+ // context, so it is guarded: an unguarded check would be a crash, not a test.
50
+ function reportFailure(ctx: ExtensionContext, message: string): void {
51
+ console.warn(message);
52
+ try {
53
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
54
+ } catch {
55
+ /* stale ctx — the console line above stands */
56
+ }
57
+ }
58
+
59
+ // UI-only notice, as before this module gained a restore mechanism: shown when
60
+ // a UI exists, silent otherwise — only the guard is new, since ctx.hasUI throws
61
+ // on a stale context.
62
+ function notifyUi(ctx: ExtensionContext, message: string): void {
63
+ try {
64
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
65
+ } catch {
66
+ /* stale ctx — a cosmetic notice is not worth unwinding the handler */
67
+ }
68
+ }
69
+
41
70
  /** "provider/id": slash at index > 0 with a non-empty id after it. */
42
71
  function isModelSpec(value: unknown): value is string {
43
72
  if (typeof value !== "string") return false;
@@ -228,7 +257,10 @@ export function registerOrchestratorFailover(pi: ExtensionAPI, getConfig: () =>
228
257
  // cancelled run must NEVER fail over. Non-retryable errors (quota,
229
258
  // billing, auth…) fail fast with no backoff to cancel, so they pass.
230
259
  // SettingsManager.create is a synchronous, lock-protected read of the
231
- // same merged settings pi uses.
260
+ // same merged settings pi uses. READ-ONLY, and it must stay that way: a
261
+ // setter call on this throwaway instance would write straight into the
262
+ // user's GLOBAL settings unmediated — every write goes through
263
+ // model-default.ts instead.
232
264
  if (isRetryableAssistantError(msg as AssistantMessage)) {
233
265
  try {
234
266
  const retry = SettingsManager.create(ctx.cwd, getAgentDir(), {
@@ -248,31 +280,54 @@ export function registerOrchestratorFailover(pi: ExtensionAPI, getConfig: () =>
248
280
  if (!mapped) return; // no mapping / unknown / unauthed → the original failure stands
249
281
  const from = `${current.provider}/${current.id}`;
250
282
  const to = `${mapped.provider}/${mapped.id}`;
251
- try {
252
- // pi.setModel returns false when no API key is available, but can ALSO
253
- // throw on a failed live auth check despite the Promise<boolean>
254
- // contract — handle both. NOTE: on success it persists the mapped model
255
- // as the user's GLOBAL default (pi semantics; documented caveat).
256
- if (!(await pi.setModel(mapped))) {
257
- if (ctx.hasUI) {
258
- ctx.ui.notify(`slate: model failover to ${to} skipped — no API key. Keeping ${from}.`, "warning");
283
+ // pi.setModel persists the switch into the user's GLOBAL settings
284
+ // (defaultProvider, defaultModel and, through its thinking cascade,
285
+ // defaultThinkingLevel) — a session-scoped failover must not leave that
286
+ // behind, so the switch runs inside the shared restore wrapper, which
287
+ // puts back exactly what THIS switch changed (model-default.ts).
288
+ // Sequencing matters: the wrapper completes the restore BEFORE the
289
+ // steer below, so settings are already clean even if the steer then
290
+ // fails on a stale context.
291
+ // Set when pi.setModel THREW: the throw can land after pi already wrote the
292
+ // pair (it persists before the thinking cascade and the model-select
293
+ // emission), so that case must still be treated as "may have persisted"
294
+ // even though the switch failed. Read by the predicate below, which runs
295
+ // after the callback has resolved.
296
+ let mayHaveWritten = false;
297
+ const switched = await withGlobalModelDefaultRestored(
298
+ pi,
299
+ ctx,
300
+ getConfig(),
301
+ mapped,
302
+ async () => {
303
+ try {
304
+ // pi.setModel returns false when no API key is available, but can ALSO
305
+ // throw on a failed live auth check despite the Promise<boolean>
306
+ // contract — handle both.
307
+ if (!(await pi.setModel(mapped))) {
308
+ reportFailure(ctx, `slate: model failover to ${to} skipped — no API key. Keeping ${from}.`);
309
+ return false;
310
+ }
311
+ } catch (error) {
312
+ mayHaveWritten = true;
313
+ reportFailure(
314
+ ctx,
315
+ `slate: model failover to ${to} failed — ${sanitizeForNotify(
316
+ error instanceof Error ? error.message : String(error),
317
+ )}. Keeping ${from}.`,
318
+ );
319
+ return false;
259
320
  }
260
- return;
261
- }
262
- } catch (error) {
263
- if (ctx.hasUI) {
264
- ctx.ui.notify(
265
- `slate: model failover to ${to} failed — ${sanitizeForNotify(
266
- error instanceof Error ? error.message : String(error),
267
- )}. Keeping ${from}.`,
268
- "warning",
269
- );
270
- }
271
- return;
272
- }
273
- if (ctx.hasUI) {
274
- ctx.ui.notify(`slate: model failover ${from} ⇒ ${to} — retrying the failed turn`, "warning");
275
- }
321
+ return true;
322
+ },
323
+ // A plain false return means pi.setModel bailed on its own auth check
324
+ // BEFORE touching settings (the extension-facing setModel returns false
325
+ // ahead of the session setter), so nothing can have been persisted and
326
+ // the whole post-switch phase is skipped — unless it failed by THROWING.
327
+ (ok) => ok || mayHaveWritten,
328
+ );
329
+ if (!switched) return;
330
+ notifyUi(ctx, `slate: model failover ${from} ⇒ ${to} — retrying the failed turn`);
276
331
  pi.sendMessage(
277
332
  {
278
333
  customType: "slate-failover",
@@ -41,6 +41,7 @@ import {
41
41
  type ExtensionCommandContext,
42
42
  type ExtensionContext,
43
43
  } from "@earendil-works/pi-coding-agent";
44
+ import { withGlobalModelDefaultRestored } from "./model-default.ts";
44
45
  import {
45
46
  orchestratorCostUsd,
46
47
  type ContextBudgetObject,
@@ -102,6 +103,23 @@ function sanitizeForNotify(s: string, max = 120): string {
102
103
  return clean.length > max ? `${clean.slice(0, max)}…` : clean;
103
104
  }
104
105
 
106
+ // Console-first reporting for the model-adoption block below, and for FAILURES
107
+ // ONLY: the console line is unconditional, so the failure surfaces in headless
108
+ // runs and at teardown too — the has-UI branch is exactly the one that vanishes
109
+ // there — and the UI notification is the extra. Informational notices (the
110
+ // "handoff state restored" line, the pause notices) keep their plain has-UI
111
+ // gate, since an unconditional stderr write scribbles pi-tui's differentially
112
+ // rendered frame. ctx.hasUI is a getter that THROWS on a stale context, so it
113
+ // is guarded: an unguarded check would be a crash, not a test.
114
+ function reportFailure(ctx: ExtensionContext, message: string): void {
115
+ console.warn(message);
116
+ try {
117
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
118
+ } catch {
119
+ /* stale ctx — the console line above stands */
120
+ }
121
+ }
122
+
105
123
  /**
106
124
  * Validate the raw `contextBudget` config value — eagerly, at session_start,
107
125
  * for the same reason modelFailover is (failover.ts): a malformed budget
@@ -283,7 +301,10 @@ export function registerSlateHandoff(
283
301
  // pi's compaction reserve — read ONCE, lazily, then cached: it feeds a
284
302
  // per-turn check and SettingsManager.create is a lock-protected disk read.
285
303
  // A mid-session settings edit is not picked up (acceptable: the clamp is a
286
- // safety margin, not an exact contract).
304
+ // safety margin, not an exact contract). READ-ONLY, and it must stay that
305
+ // way: a setter call on this throwaway instance would write straight into
306
+ // the user's GLOBAL settings unmediated — every write goes through
307
+ // model-default.ts instead.
287
308
  let cachedReserveTokens: number | undefined;
288
309
  const reserveTokens = (ctx: ExtensionContext): number => {
289
310
  if (cachedReserveTokens === undefined) {
@@ -489,40 +510,70 @@ export function registerSlateHandoff(
489
510
  ) {
490
511
  const { provider, id } = spec;
491
512
  const label = sanitizeForNotify(`${provider}/${id}`);
492
- try {
493
- // Equality guard: setModel persists the model as the user's GLOBAL
494
- // default as a side effect, so only call it when the fresh session
495
- // actually resolved to something else.
496
- let restored = ctx.model?.provider === provider && ctx.model?.id === id;
497
- if (!restored) {
498
- const model = ctx.modelRegistry.find(provider, id);
499
- restored = !!model && (await pi.setModel(model));
500
- }
501
- if (restored) {
502
- // Thinking level rides only on a matching/restored model, and only
503
- // AFTER setModel (which re-derives thinking internally). Like
504
- // setModel, setThinkingLevel persists to the user's ONE GLOBAL
505
- // default thinking level (not a per-model value), so clamping the
506
- // old level against an unrelated fallback model would persist
507
- // garbage there. Non-strings are never passed on; pi clamps
508
- // unknown string levels itself.
509
- if (typeof pending.thinkingLevel === "string") pi.setThinkingLevel(pending.thinkingLevel);
510
- } else if (ctx.hasUI) {
511
- ctx.ui.notify(
512
- `slate: could not restore model ${label} (unknown or no auth) — keeping the session default.`,
513
- "warning",
514
- );
515
- }
516
- } catch (error) {
517
- if (ctx.hasUI) {
518
- ctx.ui.notify(
519
- `slate: could not restore model ${label} — ${sanitizeForNotify(
520
- error instanceof Error ? error.message : String(error),
521
- )}. Keeping the session default.`,
522
- "warning",
523
- );
524
- }
525
- }
513
+ // The WHOLE adoption block runs inside the shared restore wrapper, not
514
+ // just the setModel call: both setters persist into the user's GLOBAL
515
+ // settings, and this path can persist the thinking-level key ALONE when
516
+ // the equality guard below short-circuits the model setter — so the
517
+ // wrapper's yield and post-switch read must follow the LAST write here
518
+ // (model-default.ts).
519
+ await withGlobalModelDefaultRestored(
520
+ pi,
521
+ ctx,
522
+ getConfig(),
523
+ { provider, id },
524
+ async () => {
525
+ // True once a pi setter has actually been CALLED — only then can pi
526
+ // have persisted anything, and only then does the wrapper need to do
527
+ // its post-switch work.
528
+ let calledSetter = false;
529
+ try {
530
+ // Equality guard: setModel persists the model as the user's GLOBAL
531
+ // default as a side effect, so only call it when the fresh session
532
+ // actually resolved to something else.
533
+ let restored = ctx.model?.provider === provider && ctx.model?.id === id;
534
+ if (!restored) {
535
+ const model = ctx.modelRegistry.find(provider, id);
536
+ if (model) {
537
+ calledSetter = true;
538
+ restored = await pi.setModel(model);
539
+ }
540
+ }
541
+ if (restored) {
542
+ // Thinking level rides only on a matching/restored model, and only
543
+ // AFTER setModel (which re-derives thinking internally). Like
544
+ // setModel, setThinkingLevel persists to the user's ONE GLOBAL
545
+ // default thinking level (not a per-model value), so clamping the
546
+ // old level against an unrelated fallback model would persist
547
+ // garbage there. Non-strings are never passed on; pi clamps
548
+ // unknown string levels itself.
549
+ if (typeof pending.thinkingLevel === "string") {
550
+ calledSetter = true;
551
+ pi.setThinkingLevel(pending.thinkingLevel);
552
+ }
553
+ } else {
554
+ reportFailure(
555
+ ctx,
556
+ `slate: could not restore model ${label} (unknown or no auth) — keeping the session default.`,
557
+ );
558
+ }
559
+ } catch (error) {
560
+ // A throw can land AFTER a completed write (pi persists the pair
561
+ // before the cascade that can throw), so this counts as "a setter
562
+ // ran" — the safe direction.
563
+ calledSetter = true;
564
+ reportFailure(
565
+ ctx,
566
+ `slate: could not restore model ${label} — ${sanitizeForNotify(
567
+ error instanceof Error ? error.message : String(error),
568
+ )}. Keeping the session default.`,
569
+ );
570
+ }
571
+ return calledSetter;
572
+ },
573
+ // No setter was called ⇒ pi cannot have written anything ⇒ skip the
574
+ // wrapper's post-switch reads, retries and reporting entirely.
575
+ (calledSetter) => calledSetter,
576
+ );
526
577
  }
527
578
  } catch {
528
579
  /* a broken pending file must never break session start */
@@ -26,6 +26,7 @@
26
26
  * "workerPromptDocs": ["docs/thread-guidelines.md"],
27
27
  * "workflow": { "draftPRs": false },
28
28
  * "modelFailover": { "provider/id": "provider/id" },
29
+ * "preserveGlobalModelDefault": true,
29
30
  * "doctrineExtraPath": "docs/project-doctrine.md",
30
31
  * "reviewPerspectivesPath": "docs/review-perspectives.md" }
31
32
  * contextBudget also takes { "tokens": N, "overrides": [{ "match": "regex",
@@ -0,0 +1,583 @@
1
+ /**
2
+ * Global model-default restore: put the user's GLOBAL pi defaults back after a
3
+ * slate-initiated model switch.
4
+ *
5
+ * pi persists every in-session model switch into the user's global settings
6
+ * file (<agent dir>/settings.json): setModel writes defaultProvider and
7
+ * defaultModel, and its internal thinking cascade can write
8
+ * defaultThinkingLevel too. Both slate switch sites — orchestrator failover
9
+ * (failover.ts) and handoff adoption (handoff.ts) — mean "make THIS session
10
+ * run that model", so the cross-project residue is a side effect nobody asked
11
+ * for: every later pi session, in every project, starts on slate's pick. This
12
+ * module wraps each such switch and undoes exactly that residue.
13
+ *
14
+ * TWO INDEPENDENT DECISIONS, NEVER ONE PER SWITCH AND NEVER ONE PER KEY:
15
+ * - the defaultProvider + defaultModel PAIR, decided and written as ONE UNIT;
16
+ * - defaultThinkingLevel, decided and written on its own.
17
+ * Each is judged from two fresh disk reads taken immediately before and after
18
+ * the switch: restore only what CHANGED, and only when what it changed to is
19
+ * exactly what slate's own switch should have produced. Anything unchanged is
20
+ * never touched (which also makes this a zero-write no-op in EVERY state on a
21
+ * future pi that stops persisting switches — upstream #5263); anything holding
22
+ * a different value belongs to somebody else and is left alone.
23
+ *
24
+ * Why the pair is ONE unit while the thinking level is separate: pi's ONLY
25
+ * writer of those two keys is setDefaultModelAndProvider, which writes them
26
+ * together, so any combination other than a pair that actually existed is a
27
+ * state pi cannot produce and no consumer expects (a provider that does not own
28
+ * the model, or a model with no provider at all). Deciding the halves
29
+ * separately would manufacture exactly that: pre-switch provider + a third
30
+ * party's model. So the pair is restored only when EVERY half that changed
31
+ * matches the switch's expectation, and then the pre-switch pair is written
32
+ * back EXACTLY, both halves, absence included — a state that demonstrably
33
+ * existed a moment ago. The thinking level is not part of that pair and stays
34
+ * independent, because the model setter's cascade can write it even when
35
+ * provider and model already name the target, and handoff adoption can write it
36
+ * ALONE when its equality guard skips the model setter.
37
+ *
38
+ * Invariants baked in here, each one a defect this shape had to reverse:
39
+ * - NO STATE OUTLIVES A SINGLE SWITCH. Everything consulted is read during the
40
+ * switch being handled; nothing is deferred, remembered, or re-applied later
41
+ * (no shutdown backstop: a pending repair can clobber a later change, and a
42
+ * pending ABSENCE re-applied at quit can delete real settings).
43
+ * - THROWAWAY READERS, one per read. A reused SettingsManager serves a clone
44
+ * taken at construction and never re-reads disk, so a comparison built on
45
+ * one reads stale state and its read-back is self-confirming.
46
+ * - GLOBAL SCOPE ONLY, PROJECT TRUST DISABLED. Reads go through
47
+ * getGlobalSettings, never the getDefault* convenience getters, which read
48
+ * the MERGED global+project view — sourcing a reference from them in a
49
+ * project that pins a default would copy that pinned value into the user's
50
+ * global file. Disabling project trust also skips the project lock and file
51
+ * entirely, so a corrupt PROJECT file cannot register an error in the
52
+ * channel this module uses as its write-success signal.
53
+ * - STAND DOWN ON AN UNTRUSTWORTHY READ. Held lock, unreadable, corrupt,
54
+ * EMPTY/zero-byte file, construction-time load failure: warn, write nothing,
55
+ * delete nothing. Absence is decided on the VALUE, never on key presence.
56
+ * Acting on a read slate cannot trust is the one way this code could destroy
57
+ * a user's settings rather than merely fail to tidy up.
58
+ * - MACROTASK YIELD before the post-switch read. The thinking-level setter is
59
+ * synchronous with no trailing await, so a straight-line read sees the
60
+ * pre-switch file; and a MICROtask yield is write-queue-depth dependent,
61
+ * missing the write entirely at depth four or more — the same defect, but
62
+ * only under load.
63
+ * - CONSOLE-FIRST REPORTING of FAILURES, past the call sites' catch-alls, with
64
+ * every extension-context accessor guarded (hasUI, cwd and the live thinking
65
+ * level are all getters that THROW on a stale context) and every displayed
66
+ * string sanitized (a corrupt settings file puts raw file bytes — escape
67
+ * sequences included — inside pi's JSON parse error, and pi-tui renders
68
+ * control codes verbatim).
69
+ *
70
+ * Residue, stated honestly: a restore that exhausts its retry budget is
71
+ * ABANDONED with a loud console warning and never repaired — the switched value
72
+ * then persists globally exactly as it does today.
73
+ */
74
+
75
+ import { existsSync, statSync } from "node:fs";
76
+ import { join } from "node:path";
77
+ import {
78
+ getAgentDir,
79
+ SettingsManager,
80
+ type ExtensionAPI,
81
+ type ExtensionContext,
82
+ } from "@earendil-works/pi-coding-agent";
83
+ import type { SlateConfig } from "./state.ts";
84
+
85
+ // pi-coding-agent does not re-export ThinkingLevel (it lives in the transitive
86
+ // pi-agent-core package, which is not one of our peer deps) — derive it, as
87
+ // handoff.ts does.
88
+ type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
89
+
90
+ /**
91
+ * Retries are bounded by ELAPSED TIME, not by attempt count: pi's settings lock
92
+ * busy-waits SYNCHRONOUSLY, so an attempt's cost is set by contention, not by
93
+ * slate — a count-based bound would leave the stall unbounded.
94
+ *
95
+ * WORST-CASE STALL, derived from this file as written (pi 0.82.1: a contended
96
+ * acquisition spins 10 × 20 ms ≈ 180 ms before it succeeds or throws):
97
+ * five lock acquisitions per attempt — post-switch read (construction IS a
98
+ * read), write-manager construction, the pair write, the separate
99
+ * thinking-level write, and the verifying read — so a fully contended attempt
100
+ * is ≈ 900 ms. The budget only gates whether a NEW attempt STARTS and cannot
101
+ * preempt a spin already in flight, and the pre-switch read sits OUTSIDE the
102
+ * budget entirely, so the honest ceiling per switch is
103
+ * 180 ms (pre-read) + 500 ms (budget) + 900 ms (one final attempt) ≈ 1.58 s.
104
+ */
105
+ const RESTORE_BUDGET_MS = 500;
106
+
107
+ /**
108
+ * Minimum gap between attempts. Without it a persistent-but-FAST failure (an
109
+ * unwritable file, or a write that lands while the verifying read keeps
110
+ * disagreeing) issues hundreds of real settings writes inside the budget —
111
+ * measured at 358 in 502 ms; with it the loop starts at most
112
+ * ⌈budget / pacing⌉ + 1 = 21 attempts (measured: 21 in 515 ms). It also
113
+ * guarantees every pass yields to the event loop.
114
+ */
115
+ const RETRY_PACING_MS = 25;
116
+
117
+ /**
118
+ * Cap for a whole composed report line (the per-fragment cap is
119
+ * sanitizeForNotify's). The report is the ONLY trace an abandoned restore
120
+ * leaves, so it is bounded but never cut mid-word: report lines are built
121
+ * diagnostics-first (what failed, which keys, which file) with the advisory
122
+ * tail last, so the cap eats boilerplate rather than evidence, and the cut is
123
+ * marked so a truncated line cannot be mistaken for a crash mid-sentence.
124
+ */
125
+ const REPORT_MAX_CHARS = 500;
126
+ /** Explicit end-marker for a truncated report line. */
127
+ const REPORT_TRUNCATION_MARK = " […truncated]";
128
+
129
+ /** The three global keys pi's model setter can write. */
130
+ interface GlobalDefaults {
131
+ provider: string | undefined;
132
+ model: string | undefined;
133
+ thinkingLevel: ThinkingLevel | undefined;
134
+ }
135
+
136
+ /** What this switch SHOULD have produced (the "and only to that value" clause). */
137
+ interface ExpectedValues {
138
+ provider: string;
139
+ model: string;
140
+ /** The session's LIVE level after the switch; undefined = unreadable ⇒ that key is skipped. */
141
+ thinkingLevel: ThinkingLevel | undefined;
142
+ }
143
+
144
+ /**
145
+ * What to write. The pair is one field, not two, so the unit decision cannot be
146
+ * re-split by accident; the wrapper objects distinguish "do not write this" from
147
+ * "write absence".
148
+ */
149
+ interface RestorePlan {
150
+ pair?: { provider: string | undefined; model: string | undefined };
151
+ thinkingLevel?: { value: ThinkingLevel | undefined };
152
+ /** Key names, for the failure report only. */
153
+ keys: string[];
154
+ }
155
+
156
+ type ReadResult = { ok: true; values: GlobalDefaults } | { ok: false; reason: string };
157
+ type WriteResult = { ok: true } | { ok: false; reason: string };
158
+
159
+ // Everything this module displays can carry attacker-influenced bytes: pi's
160
+ // JSON parse error embeds a raw snippet of the settings file, and pi-tui
161
+ // renders control/ANSI codes verbatim.
162
+ function stripControlChars(s: string): string {
163
+ return s.replace(/[\u0000-\u001f\u007f\u009b]/g, "");
164
+ }
165
+
166
+ // Per-FRAGMENT sanitizer for text spliced into a message — same pattern and
167
+ // same 120-char default as failover.ts / handoff.ts's sanitizeForNotify.
168
+ function sanitizeForNotify(s: string, max = 120): string {
169
+ const clean = stripControlChars(s);
170
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean;
171
+ }
172
+
173
+ /**
174
+ * Bound a whole composed report line. Unlike the per-fragment cap this cuts at
175
+ * a WORD boundary and marks the cut explicitly, so a truncated report reads as
176
+ * deliberately shortened rather than as a message that died mid-sentence. A
177
+ * single unbroken token longer than the tail allowance is cut mid-token rather
178
+ * than discarded wholesale — the marker still says so.
179
+ */
180
+ function truncateForReport(message: string): string {
181
+ const clean = stripControlChars(message);
182
+ if (clean.length <= REPORT_MAX_CHARS) return clean;
183
+ const room = REPORT_MAX_CHARS - REPORT_TRUNCATION_MARK.length;
184
+ const cut = clean.slice(0, room);
185
+ const lastSpace = cut.lastIndexOf(" ");
186
+ const kept = lastSpace >= room - 40 ? cut.slice(0, lastSpace) : cut;
187
+ // Drop trailing separators so the marker does not read as ", […truncated]".
188
+ return `${kept.replace(/[\s,;:—-]+$/u, "")}${REPORT_TRUNCATION_MARK}`;
189
+ }
190
+
191
+ /** Display text for a caught value — sanitized HERE, since every reason string below is built from it. */
192
+ function errorText(error: unknown): string {
193
+ return sanitizeForNotify(error instanceof Error ? error.message : String(error));
194
+ }
195
+
196
+ // pi does not re-export SettingsError — derive the drained-channel shape.
197
+ function settingsErrorText(errors: ReturnType<SettingsManager["drainErrors"]>): string {
198
+ return errors.map((e) => errorText(e.error)).join("; ");
199
+ }
200
+
201
+ /** MONOTONIC elapsed-time source: unlike Date.now() it cannot jump (NTP step, DST, manual clock set). */
202
+ function nowMs(): number {
203
+ return performance.now();
204
+ }
205
+
206
+ /**
207
+ * A MACROTASK yield, specified as such: `setTimeout` and never
208
+ * queueMicrotask/Promise.resolve, which drain only as far as pi's write queue
209
+ * has already progressed and miss the write entirely at queue depth ≥ 4. A
210
+ * single macrotask yield is depth-independent.
211
+ */
212
+ function macrotaskDelay(ms: number): Promise<void> {
213
+ return new Promise((resolve) => setTimeout(resolve, ms));
214
+ }
215
+
216
+ /** The same path pi's FileSettingsStorage computes for the global scope. */
217
+ function globalSettingsPath(): string {
218
+ return join(getAgentDir(), "settings.json");
219
+ }
220
+
221
+ /**
222
+ * FAILURE reporting: console-first and unconditional — the has-UI branch is
223
+ * exactly the branch that vanishes in headless runs and at teardown, where a
224
+ * restore failure most needs to surface — with the UI notification as the
225
+ * extra. Called directly rather than thrown, so the catch-all in the
226
+ * surrounding handler (both call sites have one) cannot swallow the report.
227
+ * Nothing else in this module writes to the console: a successful restore is
228
+ * silent.
229
+ */
230
+ function report(ctx: ExtensionContext, message: string): void {
231
+ // Belt and braces: every fragment is sanitized where it is built, and the
232
+ // composed line is stripped again — and bounded — before it can reach a
233
+ // terminal.
234
+ const text = truncateForReport(message);
235
+ console.warn(text);
236
+ try {
237
+ // ctx.hasUI is a getter that THROWS on a stale context — an unguarded
238
+ // check is a crash, not a test.
239
+ if (ctx.hasUI) ctx.ui.notify(text, "warning");
240
+ } catch {
241
+ /* stale ctx after session replacement — the console line above stands */
242
+ }
243
+ }
244
+
245
+ /** ctx.cwd throws on a stale context; without it no reader can be built. */
246
+ function readCwd(ctx: ExtensionContext): string | undefined {
247
+ try {
248
+ return ctx.cwd;
249
+ } catch {
250
+ return undefined;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * The session's LIVE thinking level — the level it is ACTUALLY running, which
256
+ * is what this switch "should have produced" for that key. Never derived by
257
+ * replicating pi's clamping: the clamp composes across the model setter's
258
+ * hidden cascade and the explicit setter, and when the outgoing model lacks
259
+ * reasoning support pi seeds it from the MERGED global+project view — so a
260
+ * replica reading global scope alone would disagree with pi wherever a project
261
+ * pins a level, and could write that pinned value into the user's global file.
262
+ * Guarded: the accessor throws on a stale context.
263
+ */
264
+ function readLiveThinkingLevel(pi: ExtensionAPI): { ok: true; level: ThinkingLevel } | { ok: false; reason: string } {
265
+ try {
266
+ return { ok: true, level: pi.getThinkingLevel() };
267
+ } catch (error) {
268
+ return { ok: false, reason: errorText(error) };
269
+ }
270
+ }
271
+
272
+ /**
273
+ * One fresh read of the GLOBAL scope through a throwaway reader. Any doubt
274
+ * about the result is reported as a failure — the caller then writes nothing.
275
+ */
276
+ function readGlobalDefaults(cwd: string): ReadResult {
277
+ const path = globalSettingsPath();
278
+ // An EMPTY / zero-byte file parses to "no settings" with NO recorded error,
279
+ // so every key would read as absent and the absence branch below would
280
+ // DELETE keys on the strength of a file that was never really read. A
281
+ // MISSING file is genuine absence (fresh install, or a user who never set a
282
+ // default); a zero-byte one is inconclusive.
283
+ try {
284
+ if (existsSync(path) && statSync(path).size === 0) {
285
+ return { ok: false, reason: "the settings file is empty" };
286
+ }
287
+ } catch (error) {
288
+ return { ok: false, reason: `the settings file is unreadable — ${errorText(error)}` };
289
+ }
290
+ let manager: SettingsManager;
291
+ try {
292
+ // Throwaway reader, global scope only with project trust disabled (see
293
+ // the module header): never reused, never reloaded, discarded below.
294
+ manager = SettingsManager.create(cwd, getAgentDir(), { projectTrusted: false });
295
+ } catch (error) {
296
+ return { ok: false, reason: `cannot read the settings file — ${errorText(error)}` };
297
+ }
298
+ // Construction IS the read: a held lock, an unreadable file or a parse error
299
+ // is RECORDED here rather than thrown, so the channel must be drained before
300
+ // the values are trusted.
301
+ const errors = manager.drainErrors();
302
+ if (errors.length > 0) return { ok: false, reason: `cannot read the settings file — ${settingsErrorText(errors)}` };
303
+ // getGlobalSettings, never getDefaultProvider/Model/ThinkingLevel: those read
304
+ // the MERGED view (module header). Absence is decided on the VALUE — the
305
+ // accessor returns a structured clone and cloning RETAINS keys whose value is
306
+ // undefined, so a key-presence test would report the opposite of the truth.
307
+ const settings = manager.getGlobalSettings();
308
+ return {
309
+ ok: true,
310
+ values: {
311
+ provider: settings.defaultProvider,
312
+ model: settings.defaultModel,
313
+ thinkingLevel: settings.defaultThinkingLevel,
314
+ },
315
+ };
316
+ }
317
+
318
+ /**
319
+ * The defaultProvider + defaultModel decision, taken as ONE UNIT (see the
320
+ * module header for why a per-half decision is unsafe). Returns the exact
321
+ * pre-switch pair to write back, or undefined to leave BOTH keys alone.
322
+ *
323
+ * Do not re-split this into two decisions: pi's only writer of these keys
324
+ * writes them together, so a pair mixing a pre-switch half with a post-switch
325
+ * half is a state pi can never produce.
326
+ */
327
+ function planPairRestore(
328
+ pre: GlobalDefaults,
329
+ post: GlobalDefaults,
330
+ expected: ExpectedValues,
331
+ ): { provider: string | undefined; model: string | undefined } | undefined {
332
+ const providerChanged = post.provider !== pre.provider;
333
+ const modelChanged = post.model !== pre.model;
334
+ // Nothing changed ⇒ this switch wrote no pair; there is nothing to undo.
335
+ if (!providerChanged && !modelChanged) return undefined;
336
+ // Any half that changed must hold exactly what slate's own switch should
337
+ // have produced. If even one does not, the pair as it stands is somebody
338
+ // else's and the WHOLE pair is left untouched.
339
+ if (providerChanged && post.provider !== expected.provider) return undefined;
340
+ if (modelChanged && post.model !== expected.model) return undefined;
341
+ // Restore the pre-switch pair EXACTLY — both halves, per-half absence
342
+ // included. That state genuinely existed a moment ago, so it is coherent by
343
+ // construction.
344
+ return { provider: pre.provider, model: pre.model };
345
+ }
346
+
347
+ /**
348
+ * The full plan: the pair decision above plus the independent thinking-level
349
+ * decision. Returns undefined when nothing diverged — the common case, in which
350
+ * nothing at all is written.
351
+ */
352
+ function planRestore(pre: GlobalDefaults, post: GlobalDefaults, expected: ExpectedValues): RestorePlan | undefined {
353
+ const pair = planPairRestore(pre, post, expected);
354
+ const restoreThinking =
355
+ // undefined = the live level could not be read (stale ctx) ⇒ skip this
356
+ // key; the pair's expectation does not depend on it.
357
+ expected.thinkingLevel !== undefined &&
358
+ post.thinkingLevel !== pre.thinkingLevel &&
359
+ post.thinkingLevel === expected.thinkingLevel;
360
+ if (!pair && !restoreThinking) return undefined;
361
+ const plan: RestorePlan = { keys: [] };
362
+ if (pair) {
363
+ plan.pair = pair;
364
+ plan.keys.push("defaultProvider+defaultModel");
365
+ }
366
+ if (restoreThinking) {
367
+ plan.thinkingLevel = { value: pre.thinkingLevel };
368
+ plan.keys.push("defaultThinkingLevel");
369
+ }
370
+ return plan;
371
+ }
372
+
373
+ /** One write attempt: write, flush, drain, verify. */
374
+ async function applyRestore(cwd: string, plan: RestorePlan): Promise<WriteResult> {
375
+ let manager: SettingsManager;
376
+ try {
377
+ manager = SettingsManager.create(cwd, getAgentDir(), { projectTrusted: false });
378
+ } catch (error) {
379
+ return { ok: false, reason: `cannot open the settings file for writing — ${errorText(error)}` };
380
+ }
381
+ // A CONSTRUCTION-TIME load error is itself an untrustworthy read, and it is
382
+ // checked BEFORE any write because the channel cannot reveal it afterwards:
383
+ // in that state pi's save() silently NO-OPS, so nothing is enqueued and no
384
+ // write error is ever recorded — and the channel is destructive and
385
+ // single-consumer, so a post-flush drain would report success for a write
386
+ // that never happened. A write issued after a read error is a failure
387
+ // regardless.
388
+ const loadErrors = manager.drainErrors();
389
+ if (loadErrors.length > 0) {
390
+ return { ok: false, reason: `cannot open the settings file for writing — ${settingsErrorText(loadErrors)}` };
391
+ }
392
+ if (plan.pair) {
393
+ // TYPE-UNSOUND ON PURPOSE, and nothing in this repo typechecks it (pi
394
+ // loads raw TypeScript via jiti): restoring ABSENCE means writing
395
+ // `undefined`, which this setter's `string` parameters do not admit. It
396
+ // relies on pi serialising the merged object with JSON.stringify, which
397
+ // OMITS undefined values and so drops the key from the file. The
398
+ // verifying re-read below turns a wrong assumption into a warning rather
399
+ // than silent corruption.
400
+ manager.setDefaultModelAndProvider(plan.pair.provider as string, plan.pair.model as string);
401
+ }
402
+ if (plan.thinkingLevel) {
403
+ // Same deliberate type-unsound absence write as the pair above.
404
+ manager.setDefaultThinkingLevel(plan.thinkingLevel.value as ThinkingLevel);
405
+ }
406
+ // flush() only awaits pi's write queue, which terminates in its own .catch —
407
+ // it neither rejects nor reports. Write failures surface ONLY through the
408
+ // error channel, drained below AFTER the flush, never before: draining first
409
+ // would report success on a write that has not been attempted yet. (An
410
+ // unexpected rejection from a future pi is caught by the wrapper's guard.)
411
+ await manager.flush();
412
+ const writeErrors = manager.drainErrors();
413
+ if (writeErrors.length > 0) return { ok: false, reason: `the write failed — ${settingsErrorText(writeErrors)}` };
414
+ // Verify against a FRESH read: this manager's own accessor would serve the
415
+ // object its setters just mutated, so a read-back through it is vacuous.
416
+ const check = readGlobalDefaults(cwd);
417
+ if (!check.ok) return { ok: false, reason: `cannot verify the restore — ${check.reason}` };
418
+ if (plan.pair && (check.values.provider !== plan.pair.provider || check.values.model !== plan.pair.model)) {
419
+ return { ok: false, reason: "the file does not hold the restored defaultProvider/defaultModel" };
420
+ }
421
+ if (plan.thinkingLevel && check.values.thinkingLevel !== plan.thinkingLevel.value) {
422
+ return { ok: false, reason: "the file does not hold the restored defaultThinkingLevel" };
423
+ }
424
+ return { ok: true };
425
+ }
426
+
427
+ /** Steps after the switch: yield, expectation, decisions, write, verify, bounded retry. */
428
+ async function restoreAfterSwitch(
429
+ pi: ExtensionAPI,
430
+ ctx: ExtensionContext,
431
+ cwd: string,
432
+ pre: GlobalDefaults,
433
+ target: { provider: string; id: string },
434
+ ): Promise<void> {
435
+ // One macrotask yield: pi's write is queued, and the thinking-level setter
436
+ // does not drain it (see the module header).
437
+ await macrotaskDelay(0);
438
+ const live = readLiveThinkingLevel(pi);
439
+ const expected: ExpectedValues = {
440
+ provider: target.provider,
441
+ model: target.id,
442
+ thinkingLevel: live.ok ? live.level : undefined,
443
+ };
444
+ const deadline = nowMs() + RESTORE_BUDGET_MS;
445
+ let failure = "";
446
+ /** Keys of the LAST attempt that got as far as deciding — "" when none did. */
447
+ let failureKeys = "";
448
+ /** True once a post-switch read succeeded — i.e. once divergence is KNOWN, either way. */
449
+ let sawPost = false;
450
+ let warnedThinking = false;
451
+ for (;;) {
452
+ // FIXED PRE, FRESH POST. The pre-switch reference above is captured once
453
+ // and never re-read: a retry that re-read it would adopt a failed
454
+ // attempt's partial write as "the pre-switch state" and faithfully
455
+ // preserve the very leak it was invoked to remove. The post-switch state
456
+ // is re-read on EVERY attempt: a cached one would revert a third-party
457
+ // write that landed between attempts.
458
+ const post = readGlobalDefaults(cwd);
459
+ if (post.ok) {
460
+ sawPost = true;
461
+ if (!live.ok && !warnedThinking && post.values.thinkingLevel !== pre.thinkingLevel) {
462
+ // Warn only once divergence is KNOWN: the accessor throwing is
463
+ // irrelevant while the key still holds its pre-switch value. Provider
464
+ // and model are evaluated and restored either way.
465
+ warnedThinking = true;
466
+ report(
467
+ ctx,
468
+ `slate: not restoring the global defaultThinkingLevel — it changed during this model switch, but the ` +
469
+ `session's live thinking level is unreadable (${live.reason}), so slate cannot tell whose change it is.`,
470
+ );
471
+ }
472
+ const plan = planRestore(pre, post.values, expected);
473
+ if (!plan) return; // nothing diverged ⇒ write nothing at all, say nothing
474
+ const outcome = await applyRestore(cwd, plan);
475
+ if (outcome.ok) return;
476
+ failure = outcome.reason;
477
+ failureKeys = plan.keys.join(", ");
478
+ } else {
479
+ // Unlike the pre-switch read, an inconclusive post-switch read is
480
+ // RETRYABLE while the budget lasts — lock contention is exactly the
481
+ // transient condition a retry exists for.
482
+ failure = post.reason;
483
+ failureKeys = "";
484
+ }
485
+ // The budget gates whether a NEW attempt starts; it cannot preempt a lock
486
+ // spin already in flight (see RESTORE_BUDGET_MS for the real ceiling).
487
+ if (nowMs() >= deadline) break;
488
+ await macrotaskDelay(RETRY_PACING_MS);
489
+ }
490
+ // Diagnostics FIRST (what failed, which keys, which file), advisory tail LAST:
491
+ // REPORT_MAX_CHARS then eats boilerplate rather than evidence.
492
+ const path = globalSettingsPath();
493
+ const keysClause = failureKeys === "" ? "" : ` (keys: ${failureKeys})`;
494
+ report(
495
+ ctx,
496
+ sawPost
497
+ ? `slate: could not restore the global model defaults${keysClause} in ${path} — ${failure}. ` +
498
+ `Retried for ${RESTORE_BUDGET_MS} ms; this switch's values stay there and will apply to later pi ` +
499
+ "sessions; slate will not retry."
500
+ : // Divergence was never established: claiming a leak here would be a
501
+ // guess, and when the switch wrote nothing it would be a false one.
502
+ `slate: could not check the global model defaults in ${path} — ${failure}. ` +
503
+ `Retried for ${RESTORE_BUDGET_MS} ms; if this switch changed them, the change stays there; ` +
504
+ "slate will not retry.",
505
+ );
506
+ }
507
+
508
+ /** A predicate throwing, or absent, means "assume the switch persisted" — the safe direction. */
509
+ function switchMayHavePersisted<T>(predicate: ((result: T) => boolean) | undefined, result: T): boolean {
510
+ if (!predicate) return true;
511
+ try {
512
+ return predicate(result) !== false;
513
+ } catch {
514
+ return true;
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Wrap ONE slate-initiated model switch so the user's global model defaults are
520
+ * left as slate found them. `performSwitch` must contain every setter call of
521
+ * that switch — the post-switch read has to follow the LAST write, since
522
+ * adoption can persist the thinking-level key alone.
523
+ *
524
+ * The restore runs even when `performSwitch` THROWS (pi's model setter persists
525
+ * the pair before the cascade and the model-select emission that can throw, so
526
+ * a throw can still leave residue); the original rejection is then re-thrown
527
+ * unchanged. Apart from that, the wrapper never throws and never alters what
528
+ * `performSwitch` returns: a restore failure is reported, not propagated.
529
+ *
530
+ * `mayHavePersisted` is an optional escape hatch for the opposite case: return
531
+ * false to state that the callback provably called NO pi setter, so nothing can
532
+ * have been written and the entire post-switch phase — yield, reads, retries,
533
+ * reporting — is skipped. Omitted, true, or throwing all mean "a setter may
534
+ * have run" and the restore proceeds.
535
+ */
536
+ export async function withGlobalModelDefaultRestored<T>(
537
+ pi: ExtensionAPI,
538
+ ctx: ExtensionContext,
539
+ config: SlateConfig,
540
+ target: { provider: string; id: string },
541
+ performSwitch: () => Promise<T>,
542
+ mayHavePersisted?: (result: T) => boolean,
543
+ ): Promise<T> {
544
+ // Lax, like slate's other boolean knobs: anything but an explicit false
545
+ // leaves the restore on.
546
+ if (config.preserveGlobalModelDefault === false) return performSwitch();
547
+ const cwd = readCwd(ctx);
548
+ // Read fresh IMMEDIATELY before the switch: this is the reference a restore
549
+ // writes back, and a long-lived one would be stale by construction.
550
+ const pre = cwd === undefined ? undefined : readGlobalDefaults(cwd);
551
+ // Conservative default: a switch that threw may already have persisted.
552
+ let persisted = true;
553
+ try {
554
+ const result = await performSwitch();
555
+ persisted = switchMayHavePersisted(mayHavePersisted, result);
556
+ return result;
557
+ } finally {
558
+ // A `finally` — so a throwing switch cannot skip the restore — and never a
559
+ // `return` inside it, which would swallow the caller's result or error.
560
+ if (persisted) {
561
+ if (cwd === undefined || pre === undefined) {
562
+ report(
563
+ ctx,
564
+ `slate: not restoring the global model defaults — the extension context is stale, so ${globalSettingsPath()} cannot be read.`,
565
+ );
566
+ } else if (!pre.ok) {
567
+ // Stand down: restore NOTHING, warn. A pre-switch read failure is
568
+ // terminal rather than retryable — there is no trustworthy reference
569
+ // to retry against — and acting on it could delete real settings.
570
+ // The path is named HERE rather than inside every reason: one mention,
571
+ // in the fixed prefix that truncation cannot reach.
572
+ report(ctx, `slate: not restoring the global model defaults in ${globalSettingsPath()} — ${pre.reason}.`);
573
+ } else {
574
+ try {
575
+ await restoreAfterSwitch(pi, ctx, cwd, pre.values, target);
576
+ } catch (error) {
577
+ // This mechanism must never unwind the switch handler that called it.
578
+ report(ctx, `slate: restoring the global model defaults failed — ${errorText(error)}.`);
579
+ }
580
+ }
581
+ }
582
+ }
583
+ }
@@ -64,6 +64,7 @@ export interface SlateConfig {
64
64
  workerPromptDocs?: string[]; // role-guideline docs appended to worker system prompts (cwd-relative paths, default none)
65
65
  workflow?: { draftPRs?: boolean }; // draftPRs: umbrella draft PRs are part of the pre-implementation gates (default false)
66
66
  modelFailover?: Record<string, string>; // model→model failover map ("provider/id" → "provider/id"); empty/absent = feature off
67
+ preserveGlobalModelDefault?: boolean; // restore the user's GLOBAL pi model defaults (defaultProvider/defaultModel/defaultThinkingLevel) after a slate-initiated model switch — failover and handoff adoption (default true; only an explicit false disables it) — see model-default.ts
67
68
  doctrineExtraPath?: string; // cwd-relative markdown appended to the orchestrator doctrine (project-doctrine section)
68
69
  reviewPerspectivesPath?: string; // cwd-relative markdown with additional project-specific review perspectives
69
70
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ytdb-slate",
3
- "version": "0.2.0",
4
- "description": "Slate: a thread-weaving orchestration extension for the pi coding agent — bounded worker actions, compressed episodes, mandatory research/design/adversarial/track review workflow, optional draft-PR publishing, a configurable model-failover map for high availability across orchestrator, workers, and episode compression, and an absolute orchestrator context-token budget with per-model overrides and orchestrator-mode compaction.",
3
+ "version": "0.3.0",
4
+ "description": "Slate: thread-weaving orchestration for the pi coding agent — bounded worker actions, compressed episodes, mandatory research/design/adversarial/track review workflow, configurable model failover, and orchestrator context-budget handoff.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",