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.
@@ -0,0 +1,587 @@
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: the same logic as the
167
+ // shared helper in notify.ts (control-char strip + 120-char cap), kept LOCAL on
168
+ // purpose. This module also needs the bare stripControlChars primitive above for
169
+ // truncateForReport's word-boundary report cap, which notify.ts does not
170
+ // provide; keeping the fragment cap here lets both caps share that one strip
171
+ // primitive rather than importing one function and re-deriving its dependency.
172
+ function sanitizeForNotify(s: string, max = 120): string {
173
+ const clean = stripControlChars(s);
174
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean;
175
+ }
176
+
177
+ /**
178
+ * Bound a whole composed report line. Unlike the per-fragment cap this cuts at
179
+ * a WORD boundary and marks the cut explicitly, so a truncated report reads as
180
+ * deliberately shortened rather than as a message that died mid-sentence. A
181
+ * single unbroken token longer than the tail allowance is cut mid-token rather
182
+ * than discarded wholesale — the marker still says so.
183
+ */
184
+ function truncateForReport(message: string): string {
185
+ const clean = stripControlChars(message);
186
+ if (clean.length <= REPORT_MAX_CHARS) return clean;
187
+ const room = REPORT_MAX_CHARS - REPORT_TRUNCATION_MARK.length;
188
+ const cut = clean.slice(0, room);
189
+ const lastSpace = cut.lastIndexOf(" ");
190
+ const kept = lastSpace >= room - 40 ? cut.slice(0, lastSpace) : cut;
191
+ // Drop trailing separators so the marker does not read as ", […truncated]".
192
+ return `${kept.replace(/[\s,;:—-]+$/u, "")}${REPORT_TRUNCATION_MARK}`;
193
+ }
194
+
195
+ /** Display text for a caught value — sanitized HERE, since every reason string below is built from it. */
196
+ function errorText(error: unknown): string {
197
+ return sanitizeForNotify(error instanceof Error ? error.message : String(error));
198
+ }
199
+
200
+ // pi does not re-export SettingsError — derive the drained-channel shape.
201
+ function settingsErrorText(errors: ReturnType<SettingsManager["drainErrors"]>): string {
202
+ return errors.map((e) => errorText(e.error)).join("; ");
203
+ }
204
+
205
+ /** MONOTONIC elapsed-time source: unlike Date.now() it cannot jump (NTP step, DST, manual clock set). */
206
+ function nowMs(): number {
207
+ return performance.now();
208
+ }
209
+
210
+ /**
211
+ * A MACROTASK yield, specified as such: `setTimeout` and never
212
+ * queueMicrotask/Promise.resolve, which drain only as far as pi's write queue
213
+ * has already progressed and miss the write entirely at queue depth ≥ 4. A
214
+ * single macrotask yield is depth-independent.
215
+ */
216
+ function macrotaskDelay(ms: number): Promise<void> {
217
+ return new Promise((resolve) => setTimeout(resolve, ms));
218
+ }
219
+
220
+ /** The same path pi's FileSettingsStorage computes for the global scope. */
221
+ function globalSettingsPath(): string {
222
+ return join(getAgentDir(), "settings.json");
223
+ }
224
+
225
+ /**
226
+ * FAILURE reporting: console-first and unconditional — the has-UI branch is
227
+ * exactly the branch that vanishes in headless runs and at teardown, where a
228
+ * restore failure most needs to surface — with the UI notification as the
229
+ * extra. Called directly rather than thrown, so the catch-all in the
230
+ * surrounding handler (both call sites have one) cannot swallow the report.
231
+ * Nothing else in this module writes to the console: a successful restore is
232
+ * silent.
233
+ */
234
+ function report(ctx: ExtensionContext, message: string): void {
235
+ // Belt and braces: every fragment is sanitized where it is built, and the
236
+ // composed line is stripped again — and bounded — before it can reach a
237
+ // terminal.
238
+ const text = truncateForReport(message);
239
+ console.warn(text);
240
+ try {
241
+ // ctx.hasUI is a getter that THROWS on a stale context — an unguarded
242
+ // check is a crash, not a test.
243
+ if (ctx.hasUI) ctx.ui.notify(text, "warning");
244
+ } catch {
245
+ /* stale ctx after session replacement — the console line above stands */
246
+ }
247
+ }
248
+
249
+ /** ctx.cwd throws on a stale context; without it no reader can be built. */
250
+ function readCwd(ctx: ExtensionContext): string | undefined {
251
+ try {
252
+ return ctx.cwd;
253
+ } catch {
254
+ return undefined;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * The session's LIVE thinking level — the level it is ACTUALLY running, which
260
+ * is what this switch "should have produced" for that key. Never derived by
261
+ * replicating pi's clamping: the clamp composes across the model setter's
262
+ * hidden cascade and the explicit setter, and when the outgoing model lacks
263
+ * reasoning support pi seeds it from the MERGED global+project view — so a
264
+ * replica reading global scope alone would disagree with pi wherever a project
265
+ * pins a level, and could write that pinned value into the user's global file.
266
+ * Guarded: the accessor throws on a stale context.
267
+ */
268
+ function readLiveThinkingLevel(pi: ExtensionAPI): { ok: true; level: ThinkingLevel } | { ok: false; reason: string } {
269
+ try {
270
+ return { ok: true, level: pi.getThinkingLevel() };
271
+ } catch (error) {
272
+ return { ok: false, reason: errorText(error) };
273
+ }
274
+ }
275
+
276
+ /**
277
+ * One fresh read of the GLOBAL scope through a throwaway reader. Any doubt
278
+ * about the result is reported as a failure — the caller then writes nothing.
279
+ */
280
+ function readGlobalDefaults(cwd: string): ReadResult {
281
+ const path = globalSettingsPath();
282
+ // An EMPTY / zero-byte file parses to "no settings" with NO recorded error,
283
+ // so every key would read as absent and the absence branch below would
284
+ // DELETE keys on the strength of a file that was never really read. A
285
+ // MISSING file is genuine absence (fresh install, or a user who never set a
286
+ // default); a zero-byte one is inconclusive.
287
+ try {
288
+ if (existsSync(path) && statSync(path).size === 0) {
289
+ return { ok: false, reason: "the settings file is empty" };
290
+ }
291
+ } catch (error) {
292
+ return { ok: false, reason: `the settings file is unreadable — ${errorText(error)}` };
293
+ }
294
+ let manager: SettingsManager;
295
+ try {
296
+ // Throwaway reader, global scope only with project trust disabled (see
297
+ // the module header): never reused, never reloaded, discarded below.
298
+ manager = SettingsManager.create(cwd, getAgentDir(), { projectTrusted: false });
299
+ } catch (error) {
300
+ return { ok: false, reason: `cannot read the settings file — ${errorText(error)}` };
301
+ }
302
+ // Construction IS the read: a held lock, an unreadable file or a parse error
303
+ // is RECORDED here rather than thrown, so the channel must be drained before
304
+ // the values are trusted.
305
+ const errors = manager.drainErrors();
306
+ if (errors.length > 0) return { ok: false, reason: `cannot read the settings file — ${settingsErrorText(errors)}` };
307
+ // getGlobalSettings, never getDefaultProvider/Model/ThinkingLevel: those read
308
+ // the MERGED view (module header). Absence is decided on the VALUE — the
309
+ // accessor returns a structured clone and cloning RETAINS keys whose value is
310
+ // undefined, so a key-presence test would report the opposite of the truth.
311
+ const settings = manager.getGlobalSettings();
312
+ return {
313
+ ok: true,
314
+ values: {
315
+ provider: settings.defaultProvider,
316
+ model: settings.defaultModel,
317
+ thinkingLevel: settings.defaultThinkingLevel,
318
+ },
319
+ };
320
+ }
321
+
322
+ /**
323
+ * The defaultProvider + defaultModel decision, taken as ONE UNIT (see the
324
+ * module header for why a per-half decision is unsafe). Returns the exact
325
+ * pre-switch pair to write back, or undefined to leave BOTH keys alone.
326
+ *
327
+ * Do not re-split this into two decisions: pi's only writer of these keys
328
+ * writes them together, so a pair mixing a pre-switch half with a post-switch
329
+ * half is a state pi can never produce.
330
+ */
331
+ function planPairRestore(
332
+ pre: GlobalDefaults,
333
+ post: GlobalDefaults,
334
+ expected: ExpectedValues,
335
+ ): { provider: string | undefined; model: string | undefined } | undefined {
336
+ const providerChanged = post.provider !== pre.provider;
337
+ const modelChanged = post.model !== pre.model;
338
+ // Nothing changed ⇒ this switch wrote no pair; there is nothing to undo.
339
+ if (!providerChanged && !modelChanged) return undefined;
340
+ // Any half that changed must hold exactly what slate's own switch should
341
+ // have produced. If even one does not, the pair as it stands is somebody
342
+ // else's and the WHOLE pair is left untouched.
343
+ if (providerChanged && post.provider !== expected.provider) return undefined;
344
+ if (modelChanged && post.model !== expected.model) return undefined;
345
+ // Restore the pre-switch pair EXACTLY — both halves, per-half absence
346
+ // included. That state genuinely existed a moment ago, so it is coherent by
347
+ // construction.
348
+ return { provider: pre.provider, model: pre.model };
349
+ }
350
+
351
+ /**
352
+ * The full plan: the pair decision above plus the independent thinking-level
353
+ * decision. Returns undefined when nothing diverged — the common case, in which
354
+ * nothing at all is written.
355
+ */
356
+ function planRestore(pre: GlobalDefaults, post: GlobalDefaults, expected: ExpectedValues): RestorePlan | undefined {
357
+ const pair = planPairRestore(pre, post, expected);
358
+ const restoreThinking =
359
+ // undefined = the live level could not be read (stale ctx) ⇒ skip this
360
+ // key; the pair's expectation does not depend on it.
361
+ expected.thinkingLevel !== undefined &&
362
+ post.thinkingLevel !== pre.thinkingLevel &&
363
+ post.thinkingLevel === expected.thinkingLevel;
364
+ if (!pair && !restoreThinking) return undefined;
365
+ const plan: RestorePlan = { keys: [] };
366
+ if (pair) {
367
+ plan.pair = pair;
368
+ plan.keys.push("defaultProvider+defaultModel");
369
+ }
370
+ if (restoreThinking) {
371
+ plan.thinkingLevel = { value: pre.thinkingLevel };
372
+ plan.keys.push("defaultThinkingLevel");
373
+ }
374
+ return plan;
375
+ }
376
+
377
+ /** One write attempt: write, flush, drain, verify. */
378
+ async function applyRestore(cwd: string, plan: RestorePlan): Promise<WriteResult> {
379
+ let manager: SettingsManager;
380
+ try {
381
+ manager = SettingsManager.create(cwd, getAgentDir(), { projectTrusted: false });
382
+ } catch (error) {
383
+ return { ok: false, reason: `cannot open the settings file for writing — ${errorText(error)}` };
384
+ }
385
+ // A CONSTRUCTION-TIME load error is itself an untrustworthy read, and it is
386
+ // checked BEFORE any write because the channel cannot reveal it afterwards:
387
+ // in that state pi's save() silently NO-OPS, so nothing is enqueued and no
388
+ // write error is ever recorded — and the channel is destructive and
389
+ // single-consumer, so a post-flush drain would report success for a write
390
+ // that never happened. A write issued after a read error is a failure
391
+ // regardless.
392
+ const loadErrors = manager.drainErrors();
393
+ if (loadErrors.length > 0) {
394
+ return { ok: false, reason: `cannot open the settings file for writing — ${settingsErrorText(loadErrors)}` };
395
+ }
396
+ if (plan.pair) {
397
+ // TYPE-UNSOUND ON PURPOSE, and nothing in this repo typechecks it (pi
398
+ // loads raw TypeScript via jiti): restoring ABSENCE means writing
399
+ // `undefined`, which this setter's `string` parameters do not admit. It
400
+ // relies on pi serialising the merged object with JSON.stringify, which
401
+ // OMITS undefined values and so drops the key from the file. The
402
+ // verifying re-read below turns a wrong assumption into a warning rather
403
+ // than silent corruption.
404
+ manager.setDefaultModelAndProvider(plan.pair.provider as string, plan.pair.model as string);
405
+ }
406
+ if (plan.thinkingLevel) {
407
+ // Same deliberate type-unsound absence write as the pair above.
408
+ manager.setDefaultThinkingLevel(plan.thinkingLevel.value as ThinkingLevel);
409
+ }
410
+ // flush() only awaits pi's write queue, which terminates in its own .catch —
411
+ // it neither rejects nor reports. Write failures surface ONLY through the
412
+ // error channel, drained below AFTER the flush, never before: draining first
413
+ // would report success on a write that has not been attempted yet. (An
414
+ // unexpected rejection from a future pi is caught by the wrapper's guard.)
415
+ await manager.flush();
416
+ const writeErrors = manager.drainErrors();
417
+ if (writeErrors.length > 0) return { ok: false, reason: `the write failed — ${settingsErrorText(writeErrors)}` };
418
+ // Verify against a FRESH read: this manager's own accessor would serve the
419
+ // object its setters just mutated, so a read-back through it is vacuous.
420
+ const check = readGlobalDefaults(cwd);
421
+ if (!check.ok) return { ok: false, reason: `cannot verify the restore — ${check.reason}` };
422
+ if (plan.pair && (check.values.provider !== plan.pair.provider || check.values.model !== plan.pair.model)) {
423
+ return { ok: false, reason: "the file does not hold the restored defaultProvider/defaultModel" };
424
+ }
425
+ if (plan.thinkingLevel && check.values.thinkingLevel !== plan.thinkingLevel.value) {
426
+ return { ok: false, reason: "the file does not hold the restored defaultThinkingLevel" };
427
+ }
428
+ return { ok: true };
429
+ }
430
+
431
+ /** Steps after the switch: yield, expectation, decisions, write, verify, bounded retry. */
432
+ async function restoreAfterSwitch(
433
+ pi: ExtensionAPI,
434
+ ctx: ExtensionContext,
435
+ cwd: string,
436
+ pre: GlobalDefaults,
437
+ target: { provider: string; id: string },
438
+ ): Promise<void> {
439
+ // One macrotask yield: pi's write is queued, and the thinking-level setter
440
+ // does not drain it (see the module header).
441
+ await macrotaskDelay(0);
442
+ const live = readLiveThinkingLevel(pi);
443
+ const expected: ExpectedValues = {
444
+ provider: target.provider,
445
+ model: target.id,
446
+ thinkingLevel: live.ok ? live.level : undefined,
447
+ };
448
+ const deadline = nowMs() + RESTORE_BUDGET_MS;
449
+ let failure = "";
450
+ /** Keys of the LAST attempt that got as far as deciding — "" when none did. */
451
+ let failureKeys = "";
452
+ /** True once a post-switch read succeeded — i.e. once divergence is KNOWN, either way. */
453
+ let sawPost = false;
454
+ let warnedThinking = false;
455
+ for (;;) {
456
+ // FIXED PRE, FRESH POST. The pre-switch reference above is captured once
457
+ // and never re-read: a retry that re-read it would adopt a failed
458
+ // attempt's partial write as "the pre-switch state" and faithfully
459
+ // preserve the very leak it was invoked to remove. The post-switch state
460
+ // is re-read on EVERY attempt: a cached one would revert a third-party
461
+ // write that landed between attempts.
462
+ const post = readGlobalDefaults(cwd);
463
+ if (post.ok) {
464
+ sawPost = true;
465
+ if (!live.ok && !warnedThinking && post.values.thinkingLevel !== pre.thinkingLevel) {
466
+ // Warn only once divergence is KNOWN: the accessor throwing is
467
+ // irrelevant while the key still holds its pre-switch value. Provider
468
+ // and model are evaluated and restored either way.
469
+ warnedThinking = true;
470
+ report(
471
+ ctx,
472
+ `slate: not restoring the global defaultThinkingLevel — it changed during this model switch, but the ` +
473
+ `session's live thinking level is unreadable (${live.reason}), so slate cannot tell whose change it is.`,
474
+ );
475
+ }
476
+ const plan = planRestore(pre, post.values, expected);
477
+ if (!plan) return; // nothing diverged ⇒ write nothing at all, say nothing
478
+ const outcome = await applyRestore(cwd, plan);
479
+ if (outcome.ok) return;
480
+ failure = outcome.reason;
481
+ failureKeys = plan.keys.join(", ");
482
+ } else {
483
+ // Unlike the pre-switch read, an inconclusive post-switch read is
484
+ // RETRYABLE while the budget lasts — lock contention is exactly the
485
+ // transient condition a retry exists for.
486
+ failure = post.reason;
487
+ failureKeys = "";
488
+ }
489
+ // The budget gates whether a NEW attempt starts; it cannot preempt a lock
490
+ // spin already in flight (see RESTORE_BUDGET_MS for the real ceiling).
491
+ if (nowMs() >= deadline) break;
492
+ await macrotaskDelay(RETRY_PACING_MS);
493
+ }
494
+ // Diagnostics FIRST (what failed, which keys, which file), advisory tail LAST:
495
+ // REPORT_MAX_CHARS then eats boilerplate rather than evidence.
496
+ const path = globalSettingsPath();
497
+ const keysClause = failureKeys === "" ? "" : ` (keys: ${failureKeys})`;
498
+ report(
499
+ ctx,
500
+ sawPost
501
+ ? `slate: could not restore the global model defaults${keysClause} in ${path} — ${failure}. ` +
502
+ `Retried for ${RESTORE_BUDGET_MS} ms; this switch's values stay there and will apply to later pi ` +
503
+ "sessions; slate will not retry."
504
+ : // Divergence was never established: claiming a leak here would be a
505
+ // guess, and when the switch wrote nothing it would be a false one.
506
+ `slate: could not check the global model defaults in ${path} — ${failure}. ` +
507
+ `Retried for ${RESTORE_BUDGET_MS} ms; if this switch changed them, the change stays there; ` +
508
+ "slate will not retry.",
509
+ );
510
+ }
511
+
512
+ /** A predicate throwing, or absent, means "assume the switch persisted" — the safe direction. */
513
+ function switchMayHavePersisted<T>(predicate: ((result: T) => boolean) | undefined, result: T): boolean {
514
+ if (!predicate) return true;
515
+ try {
516
+ return predicate(result) !== false;
517
+ } catch {
518
+ return true;
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Wrap ONE slate-initiated model switch so the user's global model defaults are
524
+ * left as slate found them. `performSwitch` must contain every setter call of
525
+ * that switch — the post-switch read has to follow the LAST write, since
526
+ * adoption can persist the thinking-level key alone.
527
+ *
528
+ * The restore runs even when `performSwitch` THROWS (pi's model setter persists
529
+ * the pair before the cascade and the model-select emission that can throw, so
530
+ * a throw can still leave residue); the original rejection is then re-thrown
531
+ * unchanged. Apart from that, the wrapper never throws and never alters what
532
+ * `performSwitch` returns: a restore failure is reported, not propagated.
533
+ *
534
+ * `mayHavePersisted` is an optional escape hatch for the opposite case: return
535
+ * false to state that the callback provably called NO pi setter, so nothing can
536
+ * have been written and the entire post-switch phase — yield, reads, retries,
537
+ * reporting — is skipped. Omitted, true, or throwing all mean "a setter may
538
+ * have run" and the restore proceeds.
539
+ */
540
+ export async function withGlobalModelDefaultRestored<T>(
541
+ pi: ExtensionAPI,
542
+ ctx: ExtensionContext,
543
+ config: SlateConfig,
544
+ target: { provider: string; id: string },
545
+ performSwitch: () => Promise<T>,
546
+ mayHavePersisted?: (result: T) => boolean,
547
+ ): Promise<T> {
548
+ // Lax, like slate's other boolean knobs: anything but an explicit false
549
+ // leaves the restore on.
550
+ if (config.preserveGlobalModelDefault === false) return performSwitch();
551
+ const cwd = readCwd(ctx);
552
+ // Read fresh IMMEDIATELY before the switch: this is the reference a restore
553
+ // writes back, and a long-lived one would be stale by construction.
554
+ const pre = cwd === undefined ? undefined : readGlobalDefaults(cwd);
555
+ // Conservative default: a switch that threw may already have persisted.
556
+ let persisted = true;
557
+ try {
558
+ const result = await performSwitch();
559
+ persisted = switchMayHavePersisted(mayHavePersisted, result);
560
+ return result;
561
+ } finally {
562
+ // A `finally` — so a throwing switch cannot skip the restore — and never a
563
+ // `return` inside it, which would swallow the caller's result or error.
564
+ if (persisted) {
565
+ if (cwd === undefined || pre === undefined) {
566
+ report(
567
+ ctx,
568
+ `slate: not restoring the global model defaults — the extension context is stale, so ${globalSettingsPath()} cannot be read.`,
569
+ );
570
+ } else if (!pre.ok) {
571
+ // Stand down: restore NOTHING, warn. A pre-switch read failure is
572
+ // terminal rather than retryable — there is no trustworthy reference
573
+ // to retry against — and acting on it could delete real settings.
574
+ // The path is named HERE rather than inside every reason: one mention,
575
+ // in the fixed prefix that truncation cannot reach.
576
+ report(ctx, `slate: not restoring the global model defaults in ${globalSettingsPath()} — ${pre.reason}.`);
577
+ } else {
578
+ try {
579
+ await restoreAfterSwitch(pi, ctx, cwd, pre.values, target);
580
+ } catch (error) {
581
+ // This mechanism must never unwind the switch handler that called it.
582
+ report(ctx, `slate: restoring the global model defaults failed — ${errorText(error)}.`);
583
+ }
584
+ }
585
+ }
586
+ }
587
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared notify-string sanitizer (CQ20).
3
+ *
4
+ * Strings that reach ctx.ui.notify / the console can carry user- or
5
+ * extension-supplied content: config values, regex patterns, extension load
6
+ * paths, loader error messages, pending-handoff fields. pi-tui renders
7
+ * control/ANSI codes verbatim, so a raw value could inject terminal escapes —
8
+ * strip control characters and cap the length before display.
9
+ *
10
+ * Imported by failover.ts, handoff.ts, worker.ts and worker-extensions.ts, which
11
+ * previously each carried a verbatim copy. model-default.ts deliberately does
12
+ * NOT import this: it also needs the bare control-char strip as a standalone
13
+ * primitive (for its word-boundary report truncation, which this helper does
14
+ * not do), so it keeps that primitive and its own fragment cap together locally
15
+ * — see the note there.
16
+ */
17
+ export function sanitizeForNotify(s: string, max = 120): string {
18
+ const clean = s.replace(/[\u0000-\u001f\u007f\u009b]/g, "");
19
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean;
20
+ }
@@ -56,6 +56,7 @@ export interface ContextBudgetObject {
56
56
  export interface SlateConfig {
57
57
  episodeModel?: string; // "provider/id" for the episode compressor (D5)
58
58
  workerTools?: string[];
59
+ workerExtensions?: string[]; // regex patterns selecting which of the HOST session's pi extensions worker threads may load (default [] = none); see worker-extensions.ts
59
60
  maxConcurrent?: number; // global cap on concurrently running worker actions (default 4; must be ≥ 1 — unenforced, ≤ 0 silently hangs all dispatches; rationale: docs/design-principles.md §5 repo-local note)
60
61
  pauseThresholdPercent?: number; // DEPRECATED: legacy percent-based auto-pause (default 40); applies only when set AND contextBudget is absent or entirely invalid (invalid sanitizes to absent — a partially invalid object stays budget mode)
61
62
  contextBudget?: number | ContextBudgetObject; // absolute orchestrator token budget; bare number = { tokens: N }; {} opts into built-in defaults (256k, 400k for anthropic/*) — see handoff.ts
@@ -64,6 +65,7 @@ export interface SlateConfig {
64
65
  workerPromptDocs?: string[]; // role-guideline docs appended to worker system prompts (cwd-relative paths, default none)
65
66
  workflow?: { draftPRs?: boolean }; // draftPRs: umbrella draft PRs are part of the pre-implementation gates (default false)
66
67
  modelFailover?: Record<string, string>; // model→model failover map ("provider/id" → "provider/id"); empty/absent = feature off
68
+ 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
69
  doctrineExtraPath?: string; // cwd-relative markdown appended to the orchestrator doctrine (project-doctrine section)
68
70
  reviewPerspectivesPath?: string; // cwd-relative markdown with additional project-specific review perspectives
69
71
  }
@@ -16,6 +16,7 @@ import { compressEpisode } from "./episodes.ts";
16
16
  import { isAuthFailure, isFailoverCandidate, resolveMappedModel } from "./failover.ts";
17
17
  import type { EpisodeRecord, SlateConfig, SlateStore, ThreadRecord } from "./state.ts";
18
18
  import { openWorkerSession, type WorkerSession } from "./worker.ts";
19
+ import { EMPTY_WORKER_EXTENSION_SET, type WorkerExtensionSet } from "./worker-extensions.ts";
19
20
 
20
21
  export interface DispatchOptions {
21
22
  threadId?: string;
@@ -113,6 +114,12 @@ export class ThreadManager {
113
114
  constructor(
114
115
  private store: SlateStore,
115
116
  private config: SlateConfig,
117
+ // This session's frozen worker-extension resolver (AD41), bound BY VALUE at
118
+ // construction (CN20) so a manager orphaned by a session swap keeps its own
119
+ // session's set instead of resolving against a later one's. Read lazily per
120
+ // new worker. Defaults to the empty-set function so existing construction
121
+ // sites and test harnesses keep working with the feature off.
122
+ private resolveExtensions: () => WorkerExtensionSet = () => EMPTY_WORKER_EXTENSION_SET,
116
123
  ) {
117
124
  this.semaphore = new Semaphore(config.maxConcurrent ?? 4); // default rationale: docs/design-principles.md §5 repo-local note
118
125
  }
@@ -272,12 +279,17 @@ export class ThreadManager {
272
279
 
273
280
  session = this.live.get(thread.id);
274
281
  if (!session) {
282
+ // A NEW session gets the CURRENT frozen worker-extension set. A LIVE
283
+ // cached session (reused when present) keeps whatever set it was opened
284
+ // with — which is precisely why the resolution is frozen per session
285
+ // (AD41): every worker in this session then shares one extension set.
275
286
  session = await openWorkerSession({
276
287
  ctx,
277
288
  sessionFile: thread.sessionFile || undefined,
278
289
  model: thread.model,
279
290
  tools: opts.tools ?? this.config.workerTools,
280
291
  promptDocs: this.config.workerPromptDocs,
292
+ extensionPaths: this.resolveExtensions().paths,
281
293
  });
282
294
  this.live.set(thread.id, session);
283
295
  // A freshly opened session starts on its configured model — drop any