ytdb-slate 0.1.2 → 0.2.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.
@@ -1,42 +1,87 @@
1
1
  /**
2
2
  * Auto-pause + handoff: context-budget discipline for the orchestrator.
3
3
  *
4
- * Monitoring: after every turn, when orchestrator mode is on and context usage
5
- * crosses pauseThresholdPercent (default 40), the store is paused — the thread
6
- * tool then rejects NEW dispatches (in-flight ones finish) — and the
7
- * orchestrator is steered to produce a handoff brief for the user.
4
+ * Monitoring: after every turn, when orchestrator mode is on and context
5
+ * usage crosses the absolute token budget (contextBudget; built-in defaults
6
+ * 256k tokens, 400k for anthropic/* models, clamped so the pause always
7
+ * lands with brief-writing room below pi's own compaction point), the store
8
+ * is paused — the thread tool then rejects NEW dispatches (in-flight ones
9
+ * finish) — and the orchestrator is steered to produce a handoff brief for
10
+ * the user. Threshold auto-compactions are intercepted the same way
11
+ * (session_before_compact → pause + cancel); once paused they pass through
12
+ * as the escape valve. The DEPRECATED pauseThresholdPercent keeps its exact
13
+ * legacy percent behavior (compaction untouched) when set WITHOUT
14
+ * contextBudget.
8
15
  *
9
16
  * /slate handoff [focus] → startHandoff(): captures the orchestrator's last
10
17
  * assistant message as the brief, writes <config dir>/slate/pending-handoff.json
11
- * (state snapshot + brief + parent session), and opens a fresh session.
18
+ * (state snapshot + brief + parent session + live model/thinking level), and
19
+ * opens a fresh session.
12
20
  *
13
21
  * Adoption: session replacement tears down this extension instance, so
14
22
  * in-memory state cannot cross over; and the fresh session's own file has no
15
23
  * slate-state entries for restore() to find (they live in the parent's file).
16
24
  * The pending file bridges the gap: the NEW instance's session_start handler
17
25
  * adopts the snapshot when the fresh session's parentSession header matches
18
- * (trusted projects only; stale files are reaped regardless of trust).
26
+ * (trusted projects only; stale files are reaped regardless of trust), and
27
+ * then restores the captured model/thinking level — the fresh session does
28
+ * not inherit them: startup CLI flags (-m/--thinking) are re-applied to the
29
+ * replacement runtime, enabledModels scoping picks its first entry, and a
30
+ * parent resumed with a non-default session-file model falls back to the
31
+ * settings default.
19
32
  */
20
33
 
21
34
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
35
  import { dirname, join } from "node:path";
23
36
  import {
24
37
  CONFIG_DIR_NAME,
38
+ getAgentDir,
39
+ SettingsManager,
25
40
  type ExtensionAPI,
26
41
  type ExtensionCommandContext,
27
42
  type ExtensionContext,
28
43
  } from "@earendil-works/pi-coding-agent";
29
- import { orchestratorCostUsd, type SlateConfig, type SlateSnapshot, type SlateStore } from "./state.ts";
44
+ import {
45
+ orchestratorCostUsd,
46
+ type ContextBudgetObject,
47
+ type ContextBudgetOverride,
48
+ type SlateConfig,
49
+ type SlateSnapshot,
50
+ type SlateStore,
51
+ } from "./state.ts";
30
52
 
53
+ /** Legacy percent mode only (DEPRECATED pauseThresholdPercent without contextBudget). */
31
54
  const DEFAULT_PAUSE_THRESHOLD_PERCENT = 40;
55
+ /** Budget-mode default for models without a configured or built-in override. */
56
+ const DEFAULT_CONTEXT_BUDGET_TOKENS = 256_000;
57
+ /** Built-in override: anthropic/* models get a larger default budget. */
58
+ const ANTHROPIC_DEFAULT_BUDGET_TOKENS = 400_000;
59
+ const ANTHROPIC_MODEL_RE = /^anthropic\/.*/;
60
+ /**
61
+ * Head-room subtracted (together with pi's compaction reserveTokens) from the
62
+ * context window when clamping the budget: the pause must land with enough
63
+ * room left for the orchestrator to WRITE the handoff brief before pi's own
64
+ * compaction point (contextWindow − reserveTokens).
65
+ */
66
+ const BRIEF_HEADROOM_TOKENS = 32_768;
67
+ /** Used when the merged settings cannot be read (mirrors pi's own default). */
68
+ const FALLBACK_RESERVE_TOKENS = 16_384;
32
69
  /** A pending-handoff file older than this cannot belong to an in-flight handoff. */
33
70
  const PENDING_MAX_AGE_MS = 15 * 60 * 1000;
34
71
  const BRIEF_MAX_CHARS = 6000;
35
72
 
73
+ // pi-coding-agent does not re-export ThinkingLevel (it lives in the transitive
74
+ // pi-agent-core package, which is not one of our peer deps) — derive it.
75
+ type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
76
+
36
77
  interface PendingHandoff {
37
78
  parentSession: string | undefined; // undefined = in-memory session; adoption then never matches
38
79
  createdAt: number;
39
80
  brief: string;
81
+ // Live model + thinking level at handoff time. Absent in old pending files
82
+ // and when the parent session had no model.
83
+ model?: { provider: string; id: string };
84
+ thinkingLevel?: ThinkingLevel;
40
85
  snapshot: SlateSnapshot;
41
86
  }
42
87
 
@@ -48,6 +93,124 @@ function pendingFile(cwd: string): string {
48
93
  return join(cwd, CONFIG_DIR_NAME, "slate", "pending-handoff.json");
49
94
  }
50
95
 
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;
103
+ }
104
+
105
+ /**
106
+ * Validate the raw `contextBudget` config value — eagerly, at session_start,
107
+ * for the same reason modelFailover is (failover.ts): a malformed budget
108
+ * would otherwise fail silently exactly when the auto-pause was supposed to
109
+ * save the orchestrator's context. Normalizes a bare number to { tokens }.
110
+ * Returns undefined for anything unusable — an INVALID contextBudget never
111
+ * disables a configured pauseThresholdPercent. `{}` is valid: an explicit
112
+ * opt-in to the built-in budget defaults.
113
+ */
114
+ export function sanitizeContextBudget(raw: unknown, warn: (msg: string) => void): ContextBudgetObject | undefined {
115
+ if (raw === undefined) return undefined;
116
+ const isBudgetTokens = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v) && v > 0;
117
+ if (typeof raw === "number") {
118
+ if (isBudgetTokens(raw)) return { tokens: raw };
119
+ warn(`slate: ignoring contextBudget — expected a positive integer token count, got ${raw}`);
120
+ return undefined;
121
+ }
122
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
123
+ warn('slate: ignoring contextBudget — expected a token count or { "tokens": N, "overrides": [...] }');
124
+ return undefined;
125
+ }
126
+ const obj = raw as { tokens?: unknown; overrides?: unknown };
127
+ const result: ContextBudgetObject = {};
128
+ // CQ1: a typo like {"token": 50000} would otherwise silently fall back to
129
+ // the built-in defaults — surface unknown keys (they are user-edited
130
+ // slate.json content headed for ctx.ui.notify, so sanitize for display).
131
+ const unknownKeys = Object.keys(obj).filter((k) => k !== "tokens" && k !== "overrides");
132
+ if (unknownKeys.length > 0) {
133
+ warn(
134
+ `slate: ignoring unknown contextBudget key(s): ${sanitizeForNotify(unknownKeys.join(", "))} (known: "tokens", "overrides")`,
135
+ );
136
+ }
137
+ if (obj.tokens !== undefined) {
138
+ if (isBudgetTokens(obj.tokens)) result.tokens = obj.tokens;
139
+ // Dropping just the scalar keeps the object valid — the user still opted
140
+ // into budget mode, so built-in defaults apply rather than legacy percent.
141
+ // BG1: the value reaches ctx.ui.notify — sanitize like the sibling
142
+ // overrides path (a crafted string could inject ANSI/CSI codes).
143
+ else {
144
+ warn(
145
+ `slate: dropping contextBudget.tokens — expected a positive integer, got ${sanitizeForNotify(
146
+ JSON.stringify(obj.tokens),
147
+ )}`,
148
+ );
149
+ }
150
+ }
151
+ if (obj.overrides !== undefined) {
152
+ if (!Array.isArray(obj.overrides)) {
153
+ warn("slate: dropping contextBudget.overrides — expected an array");
154
+ } else {
155
+ const kept: ContextBudgetOverride[] = [];
156
+ const dropped: string[] = [];
157
+ for (const entry of obj.overrides as unknown[]) {
158
+ const o = entry as { match?: unknown; tokens?: unknown } | null;
159
+ let ok = typeof o?.match === "string" && o.match !== "" && isBudgetTokens(o.tokens);
160
+ if (ok) {
161
+ // Compile the exact ANCHORED form resolution uses at match time —
162
+ // validating the bare pattern would not prove the wrapped one compiles.
163
+ try {
164
+ new RegExp(`^(?:${o?.match as string})$`);
165
+ } catch {
166
+ ok = false;
167
+ }
168
+ }
169
+ if (ok) kept.push({ match: o?.match as string, tokens: o?.tokens as number });
170
+ // Entries come from user-edited slate.json and reach ctx.ui.notify —
171
+ // strip control/ANSI codes before display (same rationale as
172
+ // failover.ts's CQ1 comment; this round's BG1 fixed the sibling
173
+ // tokens path above to match).
174
+ else dropped.push(sanitizeForNotify(JSON.stringify(entry)));
175
+ }
176
+ if (dropped.length > 0) {
177
+ warn(
178
+ `slate: dropped invalid contextBudget.overrides entries (need a regex string "match" + positive integer "tokens"):\n` +
179
+ dropped.join("\n"),
180
+ );
181
+ }
182
+ if (kept.length > 0) result.overrides = kept;
183
+ }
184
+ }
185
+ return result;
186
+ }
187
+
188
+ /**
189
+ * BUDGET mode is active unless the config is percent-only: LEGACY iff
190
+ * pauseThresholdPercent is set AND no valid contextBudget survived
191
+ * sanitization (both fields pass through session_start's eager sanitize
192
+ * before any config reaches this module via getConfig()).
193
+ */
194
+ function budgetModeActive(config: SlateConfig): boolean {
195
+ return config.contextBudget !== undefined || config.pauseThresholdPercent === undefined;
196
+ }
197
+
198
+ /**
199
+ * Resolve the budget for one "provider/id" spec. Lattice: first matching
200
+ * user override → user scalar → built-in anthropic rule → global default.
201
+ * Runs against the LIVE model on EVERY check — failover, /model, and handoff
202
+ * restore can all swap models mid-session, so the result is never cached.
203
+ */
204
+ function resolveBudgetTokens(budget: ContextBudgetObject | undefined, modelSpec: string): number {
205
+ for (const o of budget?.overrides ?? []) {
206
+ // The sanitizer verified this exact anchored form compiles.
207
+ if (new RegExp(`^(?:${o.match})$`).test(modelSpec)) return o.tokens;
208
+ }
209
+ if (budget?.tokens !== undefined) return budget.tokens;
210
+ if (ANTHROPIC_MODEL_RE.test(modelSpec)) return ANTHROPIC_DEFAULT_BUDGET_TOKENS;
211
+ return DEFAULT_CONTEXT_BUDGET_TOKENS;
212
+ }
213
+
51
214
  /** Last assistant message text on the current branch (the handoff brief). */
52
215
  function lastAssistantText(ctx: ExtensionCommandContext): string {
53
216
  let text = "";
@@ -117,45 +280,151 @@ export function registerSlateHandoff(
117
280
  store: SlateStore,
118
281
  getConfig: () => SlateConfig,
119
282
  ): SlateHandoffHooks {
283
+ // pi's compaction reserve — read ONCE, lazily, then cached: it feeds a
284
+ // per-turn check and SettingsManager.create is a lock-protected disk read.
285
+ // A mid-session settings edit is not picked up (acceptable: the clamp is a
286
+ // safety margin, not an exact contract).
287
+ let cachedReserveTokens: number | undefined;
288
+ const reserveTokens = (ctx: ExtensionContext): number => {
289
+ if (cachedReserveTokens === undefined) {
290
+ try {
291
+ cachedReserveTokens = SettingsManager.create(ctx.cwd, getAgentDir(), {
292
+ projectTrusted: ctx.isProjectTrusted(),
293
+ }).getCompactionReserveTokens();
294
+ } catch {
295
+ cachedReserveTokens = FALLBACK_RESERVE_TOKENS;
296
+ }
297
+ }
298
+ return cachedReserveTokens;
299
+ };
300
+
301
+ /** Handoff instructions shared by both pause sites (turn check + compaction intercept). */
302
+ const pauseInstructions = (headline: string) =>
303
+ [
304
+ headline,
305
+ "Finish nothing new. Reply to the user with:",
306
+ "(1) a concise HANDOFF BRIEF — overall goal, per-thread state with episode ids, immediate next actions;",
307
+ "(2) instructions: run /slate handoff [optional focus] to continue in a fresh session where all threads and episodes are restored automatically;",
308
+ `alternatively, start a new pi session manually, run /slate on, and have the new orchestrator read the episode files under ${CONFIG_DIR_NAME}/slate/episodes/.`,
309
+ ].join("\n");
310
+
120
311
  const checkBudget = (ctx: ExtensionContext) => {
121
312
  if (!store.orchestratorMode || store.paused) return;
122
- const percent = ctx.getContextUsage()?.percent;
123
- if (percent == null) return;
124
- // slate.json is user-edited: accept only finite (0, 100] thresholds.
125
- const configured = getConfig().pauseThresholdPercent;
126
- const threshold =
127
- typeof configured === "number" && Number.isFinite(configured) && configured > 0 && configured <= 100
128
- ? configured
129
- : DEFAULT_PAUSE_THRESHOLD_PERCENT;
130
- if (percent < threshold) return;
313
+ const config = getConfig();
314
+ let notifyText: string;
315
+ let headline: string;
316
+ if (!budgetModeActive(config)) {
317
+ // LEGACY percent mode (DEPRECATED pauseThresholdPercent, no contextBudget).
318
+ const percent = ctx.getContextUsage()?.percent;
319
+ if (percent == null) return;
320
+ // slate.json is user-edited: accept only finite (0, 100] thresholds.
321
+ const configured = config.pauseThresholdPercent;
322
+ const threshold =
323
+ typeof configured === "number" && Number.isFinite(configured) && configured > 0 && configured <= 100
324
+ ? configured
325
+ : DEFAULT_PAUSE_THRESHOLD_PERCENT;
326
+ if (percent < threshold) return;
327
+ const pct = Math.round(percent);
328
+ notifyText = `slate: context at ${pct}% (budget ${threshold}%) — paused. Run /slate handoff [focus] to continue in a fresh session.`;
329
+ headline = `[slate] Context is at ${pct}% — over the ${threshold}% budget. Slate auto-paused: the thread tool now REJECTS new dispatches.`;
330
+ } else {
331
+ // BUDGET mode: absolute token budget resolved against the LIVE model.
332
+ const usage = ctx.getContextUsage();
333
+ // tokens is null right after compaction, before the next LLM response.
334
+ if (usage?.tokens == null) return;
335
+ if (!ctx.model) return;
336
+ // sanitizeContextBudget (session_start) normalized any bare number to
337
+ // { tokens }; the inline conversion is belt-and-braces for the type.
338
+ const budget = config.contextBudget;
339
+ const configured = resolveBudgetTokens(
340
+ typeof budget === "number" ? { tokens: budget } : budget,
341
+ `${ctx.model.provider}/${ctx.model.id}`,
342
+ );
343
+ // Clamp to the window: the pause must fire BEFORE pi's compaction point
344
+ // (contextWindow − reserveTokens) with room to write the brief — but
345
+ // never below half the window, so a small-window model still gets a
346
+ // usable orchestrator instead of a near-zero effective budget.
347
+ // (usage.contextWindow > 0 is guaranteed: getContextUsage returns
348
+ // undefined when no window is known.)
349
+ const effective = Math.min(
350
+ configured,
351
+ Math.max(usage.contextWindow - reserveTokens(ctx) - BRIEF_HEADROOM_TOKENS, Math.ceil(usage.contextWindow / 2)),
352
+ );
353
+ if (usage.tokens < effective) return;
354
+ const used = usage.tokens.toLocaleString("en-US");
355
+ const cap = effective.toLocaleString("en-US");
356
+ // CQ2: when the clamp engaged, the effective number alone is
357
+ // untraceable to the configured budget — name both, so "budget
358
+ // 150,848" against a configured 500,000 explains itself. Phrasing is
359
+ // unchanged when no clamping occurred.
360
+ const clampNote =
361
+ effective < configured
362
+ ? ` (configured ${configured.toLocaleString("en-US")}, clamped for this model's context window)`
363
+ : "";
364
+ notifyText = `slate: context at ${used} tokens (budget ${cap}${clampNote}) — paused. Run /slate handoff [focus] to continue in a fresh session.`;
365
+ headline = `[slate] Context is at ${used} tokens — over the ${cap}-token budget${clampNote}. Slate auto-paused: the thread tool now REJECTS new dispatches.`;
366
+ }
367
+
368
+ store.paused = true;
369
+ store.save();
370
+ if (ctx.hasUI) ctx.ui.notify(notifyText, "warning");
371
+ pi.sendMessage(
372
+ { customType: "slate-pause", content: pauseInstructions(headline), display: true },
373
+ { deliverAs: "steer", triggerTurn: true },
374
+ );
375
+ };
376
+
377
+ pi.on("turn_end", async (_event, ctx) => checkBudget(ctx));
378
+ pi.on("agent_end", async (_event, ctx) => checkBudget(ctx));
131
379
 
380
+ // Threshold auto-compaction intercept: compaction pressure on an unpaused
381
+ // orchestrator means the budget failed to fire first (mis-sized override,
382
+ // low compaction threshold, …) — the right response is still pause +
383
+ // handoff, not a silent compaction that would shred the weave the episodes
384
+ // encode. BUDGET mode only: percent-only configs keep today's behavior
385
+ // end-to-end, compaction included. Manual /compact and overflow recovery
386
+ // always pass through (the user asked / pi is un-wedging a stuck turn).
387
+ pi.on("session_before_compact", async (event, ctx) => {
388
+ // !paused is the escape valve: once paused, later threshold compactions
389
+ // run normally — context that keeps growing must not wedge the session.
390
+ if (!store.orchestratorMode || store.paused) return;
391
+ if (!budgetModeActive(getConfig())) return;
392
+ if (event.reason !== "threshold") return;
132
393
  store.paused = true;
133
394
  store.save();
134
- const pct = Math.round(percent);
135
395
  if (ctx.hasUI) {
136
396
  ctx.ui.notify(
137
- `slate: context at ${pct}% (budget ${threshold}%) — paused. Run /slate handoff [focus] to continue in a fresh session.`,
397
+ "slate: auto-compaction intercepted — paused instead. Run /slate handoff [focus] to continue in a fresh session.",
138
398
  "warning",
139
399
  );
140
400
  }
401
+ // deliverAs "steer" WITHOUT triggerTurn (CN1/CN2/CN3) — traced against
402
+ // BOTH pi call sites of session_before_compact (agent-session.js):
403
+ // - post-run site (_handlePostAgentRun → _checkCompaction), streaming:
404
+ // the steer queues on the agent; _handlePostAgentRun then sees
405
+ // hasQueuedMessages() and continues the run, so the model writes the
406
+ // handoff brief THIS settle — before the escape-valve compaction can
407
+ // fire (CN2) and before a user steer slips in uninstructed (CN3).
408
+ // - idle pre-prompt site (prompt() preflight), not streaming: the
409
+ // message is appended directly to state/session and rides into the
410
+ // imminent turn — no turn starts. triggerTurn stays OFF: a
411
+ // triggering send at this site is what would start a nested agent
412
+ // run (the original hazard was triggerTurn specifically).
413
+ // Unlike "nextTurn" (an unretractable _pendingNextTurnMessages queue
414
+ // that survives /slate resume — CN1), a steer lands in the transcript
415
+ // at pause time; the disregard clause below is defense in depth.
141
416
  pi.sendMessage(
142
417
  {
143
418
  customType: "slate-pause",
144
- content: [
145
- `[slate] Context is at ${pct}% — over the ${threshold}% budget. Slate auto-paused: the thread tool now REJECTS new dispatches.`,
146
- "Finish nothing new. Reply to the user with:",
147
- "(1) a concise HANDOFF BRIEF — overall goal, per-thread state with episode ids, immediate next actions;",
148
- "(2) instructions: run /slate handoff [optional focus] to continue in a fresh session where all threads and episodes are restored automatically;",
149
- `alternatively, start a new pi session manually, run /slate on, and have the new orchestrator read the episode files under ${CONFIG_DIR_NAME}/slate/episodes/.`,
150
- ].join("\n"),
419
+ content: `${pauseInstructions(
420
+ "[slate] pi hit its auto-compaction threshold; slate cancelled the compaction and auto-paused instead: the thread tool now REJECTS new dispatches. (While paused, a repeat compaction passes through as the escape valve.)",
421
+ )}\n(If slate has since been resumed or unpaused, disregard this message.)`,
151
422
  display: true,
152
423
  },
153
- { deliverAs: "steer", triggerTurn: true },
424
+ { deliverAs: "steer" },
154
425
  );
155
- };
156
-
157
- pi.on("turn_end", async (_event, ctx) => checkBudget(ctx));
158
- pi.on("agent_end", async (_event, ctx) => checkBudget(ctx));
426
+ return { cancel: true };
427
+ });
159
428
 
160
429
  // Adopt a pending handoff into a fresh session. Registered AFTER index.ts's
161
430
  // restore handler (so a branch that already carries slate state wins) and
@@ -170,7 +439,10 @@ export function registerSlateHandoff(
170
439
  // STALE reap runs regardless of trust: it deletes an abandoned runtime
171
440
  // file without injecting any content into prompts or state, and keeps
172
441
  // the file from lingering forever in a never-trusted project.
173
- if (age >= PENDING_MAX_AGE_MS) {
442
+ // Out-of-range timestamps are stale too: a future createdAt (negative
443
+ // age) or a non-numeric one (NaN age) would otherwise dodge the reap
444
+ // forever — a real in-flight handoff is at most minutes old.
445
+ if (!(age >= 0 && age < PENDING_MAX_AGE_MS)) {
174
446
  rmSync(file, { force: true });
175
447
  return;
176
448
  }
@@ -194,6 +466,64 @@ export function registerSlateHandoff(
194
466
  "info",
195
467
  );
196
468
  }
469
+ // Restore the parent's live model + thinking level. The fresh session
470
+ // does not inherit them: startup CLI flags (-m/--thinking) are
471
+ // re-applied to the replacement runtime, enabledModels scoping picks
472
+ // its first entry, and a parent resumed with a non-default
473
+ // session-file model falls back to the settings default. This sits
474
+ // AFTER the adoption commit above, in its own try/catch, because
475
+ // pi.setModel can THROW on a failed live auth check despite its
476
+ // Promise<boolean> contract — a restore failure must never unwind a
477
+ // committed adoption.
478
+ // The pending file is disk JSON: honor the captured model only as an
479
+ // object with non-empty string provider/id — a malformed {"model":{}}
480
+ // must not count as "already live" via undefined === undefined.
481
+ const spec = pending.model;
482
+ if (
483
+ typeof spec === "object" &&
484
+ spec !== null &&
485
+ typeof spec.provider === "string" &&
486
+ spec.provider !== "" &&
487
+ typeof spec.id === "string" &&
488
+ spec.id !== ""
489
+ ) {
490
+ const { provider, id } = spec;
491
+ 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
+ }
526
+ }
197
527
  } catch {
198
528
  /* a broken pending file must never break session start */
199
529
  }
@@ -204,10 +534,16 @@ export function registerSlateHandoff(
204
534
 
205
535
  const brief = lastAssistantText(ctx);
206
536
  const parentSession = ctx.sessionManager.getSessionFile();
537
+ // ctx.model can be undefined (no-model session): capture neither field
538
+ // then — a thinking level is meaningless without a model to clamp it
539
+ // against, and adoption skips the whole restore when model is absent.
540
+ const model = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
207
541
  const pending: PendingHandoff = {
208
542
  parentSession,
209
543
  createdAt: Date.now(),
210
544
  brief,
545
+ model,
546
+ thinkingLevel: model ? pi.getThinkingLevel() : undefined,
211
547
  // The successor starts unpaused and in orchestrator mode regardless of
212
548
  // the current (paused) state.
213
549
  snapshot: {
@@ -14,24 +14,33 @@
14
14
  * episodes.ts — episode compression (Sonnet-default, D5)
15
15
  * threads.ts — ThreadManager: queueing, dispatch lifecycle
16
16
  * tools.ts — thread / threads / episode tools
17
- * handoff.ts — context-budget auto-pause + fresh-session handoff
17
+ * handoff.ts — absolute-token context-budget auto-pause (with threshold-
18
+ * compaction intercept) + fresh-session handoff
18
19
  *
19
20
  * Optional config at <config dir>/slate.json (config dir = CONFIG_DIR_NAME,
20
21
  * ".pi" by default), honored ONLY when the project is trusted — untrusted
21
22
  * projects run on built-in defaults with no project file injection:
22
23
  * { "episodeModel": "provider/id", "workerTools": [...], "maxConcurrent": 4,
23
- * "pauseThresholdPercent": 40, "orchestratorModeDefault": true,
24
+ * "contextBudget": 256000, "orchestratorModeDefault": true,
24
25
  * "orchestratorPromptDocs": ["docs/orchestrator-guidelines.md"],
25
26
  * "workerPromptDocs": ["docs/thread-guidelines.md"],
26
27
  * "workflow": { "draftPRs": false },
28
+ * "modelFailover": { "provider/id": "provider/id" },
27
29
  * "doctrineExtraPath": "docs/project-doctrine.md",
28
30
  * "reviewPerspectivesPath": "docs/review-perspectives.md" }
31
+ * contextBudget also takes { "tokens": N, "overrides": [{ "match": "regex",
32
+ * "tokens": N }] } (match is anchored against "provider/id"); absent, the
33
+ * built-in defaults apply (256k tokens; 400k for anthropic/*). The DEPRECATED
34
+ * pauseThresholdPercent keeps its legacy percent behavior only when it is set
35
+ * AND contextBudget is absent or entirely invalid (invalid sanitizes to
36
+ * absent — a partially invalid object stays budget mode).
29
37
  */
30
38
 
31
39
  import { existsSync, readFileSync } from "node:fs";
32
40
  import { join } from "node:path";
33
41
  import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
34
- import { registerSlateHandoff } from "./handoff.ts";
42
+ import { registerOrchestratorFailover, sanitizeModelFailover } from "./failover.ts";
43
+ import { registerSlateHandoff, sanitizeContextBudget } from "./handoff.ts";
35
44
  import { registerSlateMode } from "./mode.ts";
36
45
  import { SlateStore, type SlateConfig } from "./state.ts";
37
46
  import { ThreadManager } from "./threads.ts";
@@ -66,7 +75,17 @@ export default function (pi: ExtensionAPI) {
66
75
  manager.disposeAll();
67
76
  // Trust gate: project config steers prompts, models, and tool lists, so
68
77
  // it is honored only for trusted projects; untrusted → built-in defaults.
69
- manager = new ThreadManager(store, ctx.isProjectTrusted() ? loadConfig(ctx.cwd) : {});
78
+ const config = ctx.isProjectTrusted() ? loadConfig(ctx.cwd) : {};
79
+ const warn = (msg: string) => (ctx.hasUI ? ctx.ui.notify(msg, "warning") : console.warn(msg));
80
+ // modelFailover and contextBudget are validated eagerly: a malformed
81
+ // value would otherwise fail silently mid-dispatch / exactly when the
82
+ // auto-pause was supposed to save the orchestrator's context.
83
+ config.modelFailover = sanitizeModelFailover(config.modelFailover, warn);
84
+ config.contextBudget = sanitizeContextBudget(config.contextBudget, warn);
85
+ if (config.contextBudget !== undefined && config.pauseThresholdPercent !== undefined) {
86
+ warn("slate: contextBudget is set — the deprecated pauseThresholdPercent is ignored");
87
+ }
88
+ manager = new ThreadManager(store, config);
70
89
  store.restore(ctx);
71
90
  });
72
91
 
@@ -80,5 +99,9 @@ export default function (pi: ExtensionAPI) {
80
99
  // getConfig reads the CURRENT `manager` (reassigned on session_start).
81
100
  const handoff = registerSlateHandoff(pi, store, () => manager.getConfig());
82
101
 
102
+ // Orchestrator model failover (turn_end/agent_settled/input) — not
103
+ // order-critical relative to the handlers above (different trigger events).
104
+ registerOrchestratorFailover(pi, () => manager.getConfig());
105
+
83
106
  registerSlateMode(pi, store, handoff, () => manager.getConfig());
84
107
  }
@@ -42,15 +42,28 @@ export interface SlateSnapshot {
42
42
  carriedCostUsd: number; // orchestrator spend banked from ancestor sessions at handoff
43
43
  }
44
44
 
45
+ /** One contextBudget override. `match` is a regex tested ANCHORED (^(?:match)$) against "provider/id". */
46
+ export interface ContextBudgetOverride {
47
+ match: string;
48
+ tokens: number;
49
+ }
50
+
51
+ export interface ContextBudgetObject {
52
+ tokens?: number; // absolute token budget for models not caught by an override
53
+ overrides?: ContextBudgetOverride[]; // first matching entry wins
54
+ }
55
+
45
56
  export interface SlateConfig {
46
57
  episodeModel?: string; // "provider/id" for the episode compressor (D5)
47
58
  workerTools?: string[];
48
59
  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)
49
- pauseThresholdPercent?: number; // orchestrator context budget for auto-pause (default 40)
60
+ 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
+ contextBudget?: number | ContextBudgetObject; // absolute orchestrator token budget; bare number = { tokens: N }; {} opts into built-in defaults (256k, 400k for anthropic/*) — see handoff.ts
50
62
  orchestratorModeDefault?: boolean; // seed orchestrator mode ON for fresh interactive sessions (unsaved until first real mutation)
51
63
  orchestratorPromptDocs?: string[]; // role-guideline docs appended to the orchestrator prompt (cwd-relative paths, default none)
52
64
  workerPromptDocs?: string[]; // role-guideline docs appended to worker system prompts (cwd-relative paths, default none)
53
65
  workflow?: { draftPRs?: boolean }; // draftPRs: umbrella draft PRs are part of the pre-implementation gates (default false)
66
+ modelFailover?: Record<string, string>; // model→model failover map ("provider/id" → "provider/id"); empty/absent = feature off
54
67
  doctrineExtraPath?: string; // cwd-relative markdown appended to the orchestrator doctrine (project-doctrine section)
55
68
  reviewPerspectivesPath?: string; // cwd-relative markdown with additional project-specific review perspectives
56
69
  }