ytdb-slate 0.2.1 → 0.4.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`):
@@ -66,6 +70,7 @@ Optional config file: `slate.json` in the project's pi config dir (`.pi/slate.js
66
70
  | `orchestratorModeDefault` | boolean | `false` | Start fresh interactive sessions with orchestrator mode ON. |
67
71
  | `episodeModel` | string | newest available Anthropic Sonnet, else the worker's own model | Model (`provider/id`) used to compress a finished action into an episode. |
68
72
  | `workerTools` | string[] | `["read", "bash", "edit", "write", "grep", "find", "ls"]` | Tools available to worker threads (an empty list also falls back to the default). |
73
+ | `workerExtensions` | string[] | `[]` | Regex patterns (matched **unanchored**) selecting which of the host session's already-loaded extensions also load into every worker thread; each matched extension's tools are added **on top of** `workerTools`. Empty (default) means workers load no extensions. The orchestrator keeps its restricted tool set but its doctrine is told what was whitelisted. Invalid patterns are dropped with a warning at session start. |
69
74
  | `maxConcurrent` | number | `4` | Maximum number of worker actions running concurrently (must be ≥ 1 — unenforced: a value of 0 or less silently hangs all dispatches). Excess dispatches wait in a queue; actions on the same thread always run in dispatch order. Default rationale: shipped `docs/design-principles.md` §5 (repo-local note). |
70
75
  | `contextBudget` | number \| object | `256000` (Anthropic models: `400000`) | Absolute orchestrator context budget (tokens) at which Slate auto-pauses and prepares a fresh-session handoff — semantics, defaults, per-model overrides, and rationale in [`docs/context-budget.md`](docs/context-budget.md). |
71
76
  | `orchestratorPromptDocs` | string[] | `[]` | Project markdown files (paths relative to the project root) whose **contents** are appended to the orchestrator system prompt. |
@@ -74,6 +79,7 @@ Optional config file: `slate.json` in the project's pi config dir (`.pi/slate.js
74
79
  | `doctrineExtraPath` | string | — | Project markdown whose **content** is appended to the orchestrator doctrine (project-specific workflow additions). |
75
80
  | `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
81
  | `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). |
82
+ | `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
83
 
78
84
  Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point them at markdown files that actually exist in **your** project):
79
85
 
@@ -93,6 +99,27 @@ Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point
93
99
 
94
100
  > **Silent skip:** the project-file keys fail silently — no error is shown. For the content-injected keys (`orchestratorPromptDocs`, `workerPromptDocs`, `doctrineExtraPath`) a missing, unreadable, or empty file is skipped and nothing is injected. For `reviewPerspectivesPath` the pointer is omitted only when the file is missing — the file is not read at injection time, so an unreadable or empty file is still cited. Verify your paths after copying the example.
95
101
 
102
+ **Worker extensions (`workerExtensions`).** By default worker threads load no extensions. This key is a list of regex patterns that select extensions the **host session has already loaded** and load them into every worker too. Each pattern is matched **unanchored** (unlike `contextBudget.overrides`, which is anchored) against a load unit's recorded source spec (e.g. `npm:pi-web-search@1.3.1`), its load-unit path, or the entry path of any tool that unit contributes, so a bare package name matches:
103
+
104
+ ```json
105
+ {
106
+ "workerExtensions": ["pi-smart-fetch", "pi-web-search"]
107
+ }
108
+ ```
109
+
110
+ Every worker then gets the fetch and web-search tools **on top of** `workerTools` (and on top of the per-dispatch `tools` argument of the `thread` tool — those two govern the built-in tools only). The orchestrator itself does **not** gain these tools — orchestrator mode keeps its restricted set — but its doctrine gains a rule naming each whitelisted extension and its tools, so it knows what it can delegate. pi's discovery, project-trust gating, and dedup remain the only ingress: a worker can never load an extension the host is not running, an extension that registers no tools cannot be whitelisted, and a host started with extensions disabled offers nothing to whitelist.
111
+
112
+ **What to know before whitelisting** — it reaches past Slate's isolation, so it is an operator decision:
113
+
114
+ - **Delegation is unbounded.** Slate guarantees only that no worker obtains Slate's own `thread`/`threads`/`episode` tools. A whitelisted extension that ships its own sub-agent or delegation tool under any other name gives workers delegation Slate can neither detect, bound, nor account for.
115
+ - **Credential and filesystem reach.** Inside a worker the extension has the same filesystem and credential access it has in the host; Slate's read-only settings snapshot blocks pi-settings writes and nothing else.
116
+ - **Unsynced model-scoped tools.** Worker sessions never fire the session-start event, so an extension whose tool set is model-scoped stays unsynced and may offer a tool the worker's model cannot serve (e.g. pi-web-search's `url_context` on a non-Google worker model) — the call simply fails and the episode records it.
117
+ - **Abort.** A third-party extension may ignore the abort signal, so its network activity can outlive an abort or a context-budget pause.
118
+ - **Cost.** Provider-native tool billing can escape Slate's worker cost accounting.
119
+ - **Pathological patterns.** The patterns are regexes from your own trusted config — the same file that already steers models, prompts, and tool lists — matched with no time bound while the extension set is resolved once per session. A pattern with catastrophic backtracking can stall that resolution; treat it as a footgun to avoid, not a privilege boundary.
120
+
121
+ The load-time recursion guard behind this — and the risks it does and does not cover — is in [`docs/design-principles.md`](docs/design-principles.md).
122
+
96
123
  ## Trust
97
124
 
98
125
  Slate reads project configuration (`.pi/slate.json`) and injects project files (`orchestratorPromptDocs`, `workerPromptDocs`, `doctrineExtraPath`, `reviewPerspectivesPath`) **only in trusted projects**. In untrusted projects Slate runs with built-in defaults and injects nothing from the working tree.
@@ -125,8 +125,11 @@ or two problems for the others:
125
125
  not blind retry.
126
126
  - **P6 — Make desired behavior the natural behavior.** In orchestrator
127
127
  mode, tactical tools are removed, so delegation is the only way to act.
128
- - **P7 — Guard against over-decomposition.** Workers cannot spawn threads
129
- (depth-1); recursion is structurally impossible.
128
+ - **P7 — Guard against over-decomposition.** Workers never receive
129
+ Slate's dispatch tools (`thread`/`threads`/`episode`), so no worker can
130
+ spawn a Slate thread — Slate's own recursion stays depth-1. That is a
131
+ narrow load-time invariant, not a general delegation bound (see the
132
+ recursion-guard note in §5).
130
133
  - **P8 — Per-episode feedback beats blind N-step execution.** Bounded
131
134
  actions return before the next decision, so course correction is always
132
135
  possible.
@@ -146,7 +149,7 @@ or two problems for the others:
146
149
  | P4 context by reference | `tools.ts` `context` parameter injects prior episodes by id |
147
150
  | P5 adaptive decomposition | doctrine rule 6 in `mode.ts`; no plan structure imposed anywhere |
148
151
  | P6 natural behavior | `mode.ts` `ORCHESTRATOR_TOOLS` restriction (read-only + slate tools) |
149
- | P7 over-decomposition guard | `worker.ts` recursion guard — workers load no extensions, so the `thread` tool never exists for them |
152
+ | P7 over-decomposition guard | `worker.ts` recursion guard — load-scoped barriers keep Slate's `thread`/`threads`/`episode` tools out of every worker (recursion-guard note below) |
150
153
  | P8 per-episode feedback | `threads.ts` synchronous dispatch; episode returned to orchestrator |
151
154
  | P9 parallelism | `threads.ts` `maxConcurrent` queueing; doctrine rule 2 in `mode.ts` |
152
155
  | P10 context as RAM | `handoff.ts` context-budget auto-pause + fresh-session handoff |
@@ -164,6 +167,97 @@ while staying safe on common consumer API tiers; wider fan-outs only
164
167
  pay tail latency, and projects with higher-tier keys raise the cap in
165
168
  `slate.json`.
166
169
 
170
+ Repo-local note (not from the report): the P7 guard was originally
171
+ absolute — workers loaded no extensions, so Slate's `thread` tool simply
172
+ never existed for them. The optional `workerExtensions` key (`slate.json`,
173
+ trusted projects only — see the README config reference) relaxes that: it
174
+ whitelists extensions the host session has ALREADY loaded and loads them
175
+ into every worker. The guard therefore no longer rests on "workers load
176
+ nothing" but on a narrower, more precise invariant: no worker can ever
177
+ obtain SLATE's dispatch tools (`thread`/`threads`/`episode`).
178
+
179
+ Three load-scoped barriers buy that invariant, all applied per LOAD UNIT
180
+ — the owning package directory when the extension is package-originated
181
+ and the package's declared entries provably match what the host loaded
182
+ (BG20, below), otherwise the extension's own entry file:
183
+
184
+ 1. Only whitelisted units load, through pi's resource loader in allowlist
185
+ mode; a worker's extension set is exactly the allowlist and no
186
+ non-whitelisted module is ever imported (load errors surface as
187
+ warnings). Candidates are only what the host session already loaded,
188
+ enumerated from its tool registry — so an extension registering no
189
+ tools is invisible, and a host started with extensions disabled yields
190
+ an empty candidate set.
191
+ 2. Any unit that contains Slate's own package root is dropped whatever the
192
+ patterns say.
193
+ 3. Any unit whose tools collide with Slate's `thread`/`threads`/`episode`
194
+ or with pi's built-ins (`read`/`bash`/`edit`/`write`/`grep`/`find`/`ls`)
195
+ is dropped WHOLE — pi's registry lets an extension tool overwrite a
196
+ same-named built-in, so a partial load could shadow the very tools the
197
+ guard protects.
198
+
199
+ Exclusion has to happen at load time, at unit granularity, rather than by
200
+ loading everything and filtering the tool list afterwards: a
201
+ worker-loaded extension is not tools-only. Its event handlers, tools, and
202
+ flags all go live on load, so once a unit has loaded its side effects have
203
+ already run and dropping its tools afterwards would leave its handlers and
204
+ flags active. Dropping the whole unit before it loads is the only clean
205
+ cut — which is also why barrier 3 rejects a colliding unit entirely
206
+ instead of suppressing just the offending tool. (A package directory is
207
+ handed to pi whole — letting pi's own manifest resolution expand it, so
208
+ no-tool companion entries load alongside the tool-bearing ones — only when
209
+ the manifest's declared entries are ALL literal relative paths AND the
210
+ host loaded every one of them. A glob or override-form entry, or a host
211
+ running only a filtered subset, fails that equivalence check and drops the
212
+ unit to exactly the entry files the host loaded; that fallback loses the
213
+ package's no-tool companion entries, since an entry that registered no
214
+ tool cannot be shown to be running.)
215
+
216
+ That collision barrier is best-effort and load-time only: it runs before a
217
+ unit loads, against its tools as the HOST registry reports them, and once
218
+ more after the load, against the tools the units actually registered — a
219
+ post-load collision fails the dispatch CLOSED rather than opening the
220
+ worker. Neither pass can see a tool an extension registers only LATER, on
221
+ the worker's first turn (a `before_agent_start` handler, say). Slate's own
222
+ dispatch tools do not depend on that scan: `createAgentSession` is given
223
+ an `excludeTools` denylist of `thread`/`threads`/`episode` that pi applies
224
+ AFTER the tool allowlist and re-applies on every tool-registry refresh, so
225
+ they can never be active in a worker no matter when or by whom they are
226
+ registered — a deferred registration included. A deferred registration
227
+ that shadows a pi BUILT-IN (`read`, `bash`, …) has no such gap-closer —
228
+ the worker needs the real built-in — so it stays outside Slate's control,
229
+ exactly as available to any extension the host session runs. The guard
230
+ bounds Slate's dispatch tools, not every tool an extension might register
231
+ at runtime.
232
+
233
+ The orchestrator does NOT get the whitelisted tools; orchestrator mode
234
+ keeps its restricted set. Instead its doctrine gains a rule listing each
235
+ whitelisted extension with its tool names and descriptions, so it knows
236
+ what it can delegate. That doctrine rule and the worker allowlists come
237
+ from a single memoized resolution per session, so the two cannot drift.
238
+
239
+ The invariant bought is exactly that: no worker gets Slate's dispatch
240
+ tools. It is NOT a general recursion or delegation bound, and the accepted
241
+ risks that follow are the operator's to weigh:
242
+
243
+ - A whitelisted extension that registers its OWN sub-agent or delegation
244
+ tool under any other name gives workers unbounded delegation that Slate
245
+ can neither detect nor bound, outside Slate's episode and cost
246
+ accounting. Whitelisting such an extension is a deliberate operator
247
+ decision.
248
+ - Inside a worker a whitelisted extension has the same filesystem and
249
+ credential reach it has in the host; Slate's read-only settings snapshot
250
+ blocks pi-settings writes and nothing else.
251
+ - Worker sessions never fire the session-start event, so an extension
252
+ whose model-scoped tool manager depends on it stays unsynced and may
253
+ offer a tool the worker's model cannot serve — the call simply fails and
254
+ the episode records it.
255
+ - Third-party extensions may ignore the abort signal, so their network
256
+ activity can outlive an abort or a context-budget pause.
257
+ - Host and worker copies of the same extension share module-level state
258
+ through pi's process-global module cache.
259
+ - Provider-native tool billing may escape Slate's worker cost accounting.
260
+
167
261
  ## 6. Runtime knowledge: what the orchestrator knows, and when
168
262
 
169
263
  The orchestrator's knowledge of these principles is two-tier, following
@@ -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,16 +26,38 @@ import {
26
26
  type ExtensionAPI,
27
27
  type ExtensionContext,
28
28
  } from "@earendil-works/pi-coding-agent";
29
+ import { withGlobalModelDefaultRestored } from "./model-default.ts";
30
+ import { sanitizeForNotify } from "./notify.ts";
29
31
  import type { SlateConfig } from "./state.ts";
30
32
 
31
33
  type RegistryModel = NonNullable<ReturnType<ExtensionContext["modelRegistry"]["find"]>>;
32
34
 
33
- // Error messages reach ctx.ui.notify, and pi-tui renders control/ANSI codes
34
- // verbatim — strip control characters and cap length before display (same
35
- // pattern as handoff.ts's sanitizeForNotify).
36
- function sanitizeForNotify(s: string, max = 120): string {
37
- const clean = s.replace(/[\u0000-\u001f\u007f\u009b]/g, "");
38
- return clean.length > max ? `${clean.slice(0, max)}…` : clean;
35
+ // Console-first reporting, for FAILURES ONLY: the console line is
36
+ // unconditional, so a failover that could not happen still surfaces in
37
+ // headless runs and at teardown — the has-UI branch is exactly the one that
38
+ // vanishes there — and the UI notification is the extra. Success notices keep
39
+ // their has-UI gate: an unconditional stderr write during an interactive turn
40
+ // scribbles pi-tui's differentially rendered frame, and a failover that worked
41
+ // is not news worth that cost. ctx.hasUI is a getter that THROWS on a stale
42
+ // context, so it is guarded: an unguarded check would be a crash, not a test.
43
+ function reportFailure(ctx: ExtensionContext, message: string): void {
44
+ console.warn(message);
45
+ try {
46
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
47
+ } catch {
48
+ /* stale ctx — the console line above stands */
49
+ }
50
+ }
51
+
52
+ // UI-only notice, as before this module gained a restore mechanism: shown when
53
+ // a UI exists, silent otherwise — only the guard is new, since ctx.hasUI throws
54
+ // on a stale context.
55
+ function notifyUi(ctx: ExtensionContext, message: string): void {
56
+ try {
57
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
58
+ } catch {
59
+ /* stale ctx — a cosmetic notice is not worth unwinding the handler */
60
+ }
39
61
  }
40
62
 
41
63
  /** "provider/id": slash at index > 0 with a non-empty id after it. */
@@ -228,7 +250,10 @@ export function registerOrchestratorFailover(pi: ExtensionAPI, getConfig: () =>
228
250
  // cancelled run must NEVER fail over. Non-retryable errors (quota,
229
251
  // billing, auth…) fail fast with no backoff to cancel, so they pass.
230
252
  // SettingsManager.create is a synchronous, lock-protected read of the
231
- // same merged settings pi uses.
253
+ // same merged settings pi uses. READ-ONLY, and it must stay that way: a
254
+ // setter call on this throwaway instance would write straight into the
255
+ // user's GLOBAL settings unmediated — every write goes through
256
+ // model-default.ts instead.
232
257
  if (isRetryableAssistantError(msg as AssistantMessage)) {
233
258
  try {
234
259
  const retry = SettingsManager.create(ctx.cwd, getAgentDir(), {
@@ -248,31 +273,54 @@ export function registerOrchestratorFailover(pi: ExtensionAPI, getConfig: () =>
248
273
  if (!mapped) return; // no mapping / unknown / unauthed → the original failure stands
249
274
  const from = `${current.provider}/${current.id}`;
250
275
  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");
276
+ // pi.setModel persists the switch into the user's GLOBAL settings
277
+ // (defaultProvider, defaultModel and, through its thinking cascade,
278
+ // defaultThinkingLevel) — a session-scoped failover must not leave that
279
+ // behind, so the switch runs inside the shared restore wrapper, which
280
+ // puts back exactly what THIS switch changed (model-default.ts).
281
+ // Sequencing matters: the wrapper completes the restore BEFORE the
282
+ // steer below, so settings are already clean even if the steer then
283
+ // fails on a stale context.
284
+ // Set when pi.setModel THREW: the throw can land after pi already wrote the
285
+ // pair (it persists before the thinking cascade and the model-select
286
+ // emission), so that case must still be treated as "may have persisted"
287
+ // even though the switch failed. Read by the predicate below, which runs
288
+ // after the callback has resolved.
289
+ let mayHaveWritten = false;
290
+ const switched = await withGlobalModelDefaultRestored(
291
+ pi,
292
+ ctx,
293
+ getConfig(),
294
+ mapped,
295
+ async () => {
296
+ try {
297
+ // pi.setModel returns false when no API key is available, but can ALSO
298
+ // throw on a failed live auth check despite the Promise<boolean>
299
+ // contract — handle both.
300
+ if (!(await pi.setModel(mapped))) {
301
+ reportFailure(ctx, `slate: model failover to ${to} skipped — no API key. Keeping ${from}.`);
302
+ return false;
303
+ }
304
+ } catch (error) {
305
+ mayHaveWritten = true;
306
+ reportFailure(
307
+ ctx,
308
+ `slate: model failover to ${to} failed — ${sanitizeForNotify(
309
+ error instanceof Error ? error.message : String(error),
310
+ )}. Keeping ${from}.`,
311
+ );
312
+ return false;
259
313
  }
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
- }
314
+ return true;
315
+ },
316
+ // A plain false return means pi.setModel bailed on its own auth check
317
+ // BEFORE touching settings (the extension-facing setModel returns false
318
+ // ahead of the session setter), so nothing can have been persisted and
319
+ // the whole post-switch phase is skipped — unless it failed by THROWING.
320
+ (ok) => ok || mayHaveWritten,
321
+ );
322
+ if (!switched) return;
323
+ notifyUi(ctx, `slate: model failover ${from} ⇒ ${to} — retrying the failed turn`);
276
324
  pi.sendMessage(
277
325
  {
278
326
  customType: "slate-failover",
@@ -41,6 +41,8 @@ import {
41
41
  type ExtensionCommandContext,
42
42
  type ExtensionContext,
43
43
  } from "@earendil-works/pi-coding-agent";
44
+ import { withGlobalModelDefaultRestored } from "./model-default.ts";
45
+ import { sanitizeForNotify } from "./notify.ts";
44
46
  import {
45
47
  orchestratorCostUsd,
46
48
  type ContextBudgetObject,
@@ -93,13 +95,21 @@ function pendingFile(cwd: string): string {
93
95
  return join(cwd, CONFIG_DIR_NAME, "slate", "pending-handoff.json");
94
96
  }
95
97
 
96
- // Strings from the pending file (and error messages derived from them) reach
97
- // ctx.ui.notify, and pi-tui renders control/ANSI codes verbatim — a seeded
98
- // pending file could inject terminal escapes. Strip control characters and
99
- // cap the length before display.
100
- function sanitizeForNotify(s: string, max = 120): string {
101
- const clean = s.replace(/[\u0000-\u001f\u007f\u009b]/g, "");
102
- return clean.length > max ? `${clean.slice(0, max)}…` : clean;
98
+ // Console-first reporting for the model-adoption block below, and for FAILURES
99
+ // ONLY: the console line is unconditional, so the failure surfaces in headless
100
+ // runs and at teardown too — the has-UI branch is exactly the one that vanishes
101
+ // there — and the UI notification is the extra. Informational notices (the
102
+ // "handoff state restored" line, the pause notices) keep their plain has-UI
103
+ // gate, since an unconditional stderr write scribbles pi-tui's differentially
104
+ // rendered frame. ctx.hasUI is a getter that THROWS on a stale context, so it
105
+ // is guarded: an unguarded check would be a crash, not a test.
106
+ function reportFailure(ctx: ExtensionContext, message: string): void {
107
+ console.warn(message);
108
+ try {
109
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
110
+ } catch {
111
+ /* stale ctx — the console line above stands */
112
+ }
103
113
  }
104
114
 
105
115
  /**
@@ -283,7 +293,10 @@ export function registerSlateHandoff(
283
293
  // pi's compaction reserve — read ONCE, lazily, then cached: it feeds a
284
294
  // per-turn check and SettingsManager.create is a lock-protected disk read.
285
295
  // A mid-session settings edit is not picked up (acceptable: the clamp is a
286
- // safety margin, not an exact contract).
296
+ // safety margin, not an exact contract). READ-ONLY, and it must stay that
297
+ // way: a setter call on this throwaway instance would write straight into
298
+ // the user's GLOBAL settings unmediated — every write goes through
299
+ // model-default.ts instead.
287
300
  let cachedReserveTokens: number | undefined;
288
301
  const reserveTokens = (ctx: ExtensionContext): number => {
289
302
  if (cachedReserveTokens === undefined) {
@@ -489,40 +502,70 @@ export function registerSlateHandoff(
489
502
  ) {
490
503
  const { provider, id } = spec;
491
504
  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
- }
505
+ // The WHOLE adoption block runs inside the shared restore wrapper, not
506
+ // just the setModel call: both setters persist into the user's GLOBAL
507
+ // settings, and this path can persist the thinking-level key ALONE when
508
+ // the equality guard below short-circuits the model setter — so the
509
+ // wrapper's yield and post-switch read must follow the LAST write here
510
+ // (model-default.ts).
511
+ await withGlobalModelDefaultRestored(
512
+ pi,
513
+ ctx,
514
+ getConfig(),
515
+ { provider, id },
516
+ async () => {
517
+ // True once a pi setter has actually been CALLED — only then can pi
518
+ // have persisted anything, and only then does the wrapper need to do
519
+ // its post-switch work.
520
+ let calledSetter = false;
521
+ try {
522
+ // Equality guard: setModel persists the model as the user's GLOBAL
523
+ // default as a side effect, so only call it when the fresh session
524
+ // actually resolved to something else.
525
+ let restored = ctx.model?.provider === provider && ctx.model?.id === id;
526
+ if (!restored) {
527
+ const model = ctx.modelRegistry.find(provider, id);
528
+ if (model) {
529
+ calledSetter = true;
530
+ restored = await pi.setModel(model);
531
+ }
532
+ }
533
+ if (restored) {
534
+ // Thinking level rides only on a matching/restored model, and only
535
+ // AFTER setModel (which re-derives thinking internally). Like
536
+ // setModel, setThinkingLevel persists to the user's ONE GLOBAL
537
+ // default thinking level (not a per-model value), so clamping the
538
+ // old level against an unrelated fallback model would persist
539
+ // garbage there. Non-strings are never passed on; pi clamps
540
+ // unknown string levels itself.
541
+ if (typeof pending.thinkingLevel === "string") {
542
+ calledSetter = true;
543
+ pi.setThinkingLevel(pending.thinkingLevel);
544
+ }
545
+ } else {
546
+ reportFailure(
547
+ ctx,
548
+ `slate: could not restore model ${label} (unknown or no auth) — keeping the session default.`,
549
+ );
550
+ }
551
+ } catch (error) {
552
+ // A throw can land AFTER a completed write (pi persists the pair
553
+ // before the cascade that can throw), so this counts as "a setter
554
+ // ran" — the safe direction.
555
+ calledSetter = true;
556
+ reportFailure(
557
+ ctx,
558
+ `slate: could not restore model ${label} — ${sanitizeForNotify(
559
+ error instanceof Error ? error.message : String(error),
560
+ )}. Keeping the session default.`,
561
+ );
562
+ }
563
+ return calledSetter;
564
+ },
565
+ // No setter was called ⇒ pi cannot have written anything ⇒ skip the
566
+ // wrapper's post-switch reads, retries and reporting entirely.
567
+ (calledSetter) => calledSetter,
568
+ );
526
569
  }
527
570
  } catch {
528
571
  /* a broken pending file must never break session start */