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.
- package/README.md +7 -4
- package/docs/context-budget.md +176 -0
- package/docs/model-failover.md +153 -0
- package/extension/episodes.ts +107 -37
- package/extension/failover.ts +287 -0
- package/extension/handoff.ts +367 -31
- package/extension/index.ts +27 -4
- package/extension/state.ts +14 -1
- package/extension/threads.ts +155 -9
- package/extension/tools.ts +13 -1
- package/extension/worker.ts +40 -3
- package/package.json +7 -2
package/extension/handoff.ts
CHANGED
|
@@ -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
|
|
5
|
-
* crosses
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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 {
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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"
|
|
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
|
-
|
|
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: {
|
package/extension/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
* "
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|
package/extension/state.ts
CHANGED
|
@@ -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; //
|
|
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
|
}
|