pi-tuck 1.0.26 → 1.0.28

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,5 +1,6 @@
1
1
  import { textOf } from "./tuck/core/content.js";
2
- import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath } from "./request-capacity.js";
2
+ import { PendingCut, StartingPrompt, createRun } from "./run.js";
3
+ import { requestReplyFloor, replyFit, carriesPrompt, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath } from "./request-capacity.js";
3
4
  import { BUILD_ID } from "./build-id.js";
4
5
  import { createSemanticRecall, budgetSemantic } from "./semantic-recall.js";
5
6
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry } from "./diagnostics.js";
@@ -11,7 +12,7 @@ import { projectedMessages } from "./omissions.js";
11
12
  import { applyClips, clipBudgets, clipFor } from "./clip.js";
12
13
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt } from "./budget.js";
13
14
  import { getCurrentSystemMessage, isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
14
- import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
15
+ import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SettingsManager } from "@earendil-works/pi-coding-agent";
15
16
  import { Type } from "typebox";
16
17
  import { Check } from "typebox/value";
17
18
  import { MAX_RECALL_CHARACTERS, MAX_RECALL_ENTRY_ID_CHARACTERS, MAX_RECALL_QUERY_CHARACTERS } from "./recall.js";
@@ -19,7 +20,7 @@ import { tuck, tuckDraft, tuckObservedDraft, tuckRecall } from "./tuck.js";
19
20
  import { LOADED_VERSION, statusText } from "./status.js";
20
21
  import { ImageRecallInput, historyImage, historyAround } from "./history-image.js";
21
22
  import { eligibilityKey, eligibilityKeyMoved, memoizedEligibleSources } from "./eligibility-cache.js";
22
- import { applyHistoryTools, hasTuckCut, historyToolDeclarations, historyToolNames, stageHistoryTools } from "./history-tools.js";
23
+ import { applyHistoryTools, historyToolDeclarations, historyToolNames, stageHistoryTools } from "./history-tools.js";
23
24
  const query = Type.String({ minLength: 1, maxLength: MAX_RECALL_QUERY_CHARACTERS });
24
25
  const entryId = Type.String({ minLength: 1, maxLength: MAX_RECALL_ENTRY_ID_CHARACTERS });
25
26
  const paging = {
@@ -43,7 +44,7 @@ const RecallInput = Type.Union([
43
44
  const SearchInput = Type.Object({ query, priorResults: Type.Optional(Type.Array(Type.Object({ sourceSha: Type.String({ minLength: 64, maxLength: 64 }), read: Type.Object({ entryId, offset: Type.Integer({ minimum: 0 }), limit: Type.Integer({ minimum: 0 }) }, { additionalProperties: false }) }, { additionalProperties: false }), { maxItems: 20 })) }, { additionalProperties: false });
44
45
  const reply = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], details: {} });
45
46
  /** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit. Like every failure but
46
- * a `CommitFault`, it ends with its attempt (`forgetFailure`), and the next attempt plans afresh from the session as it is. */
47
+ * a `CommitFault`, it ends with its attempt (`resetRun`), and the next attempt plans afresh from the session as it is. */
47
48
  class Refusal extends Error {
48
49
  }
49
50
  /** A cut this compactor supplied that Pi then failed to commit. Pi appends a cut to the session in memory before it writes the file, so the
@@ -122,11 +123,16 @@ export async function installCompaction(pi, options = {}) {
122
123
  // Semantic indexing outlives any one attempt, so it checks only that the host still grants access: a compactor failure, which ends with its
123
124
  // attempt, must not read as a revocation (that disables semantic recall and deletes its index for the rest of the session).
124
125
  // A prompt's cut reaches the requests at once but the session only at the end of the first turn: until then search sees the history as the model does.
125
- const semanticProvider = (ctx) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess, promptCut ? promptCut.firstKeptEntryId || ctx.sessionManager.getBranch().findLast(e => e.type === "message" && e.message.role === "user")?.id : undefined);
126
+ const view = (ctx, threshold = false) => (threshold && run?.expired ? liveCut : run?.cut ?? liveCut).view(ctx);
127
+ const liveCut = new PendingCut();
128
+ const semanticProvider = (ctx) => () => {
129
+ const current = view(ctx);
130
+ return memoizedEligibleSources(ctx, options.recallPolicy, hostAccess, current.pending ? current.keptFromId : undefined);
131
+ };
126
132
  const recorder = new IncidentRecorder(options.diagnostics);
127
133
  let proposalId;
128
134
  const observe = (ctx) => recorder.measureInline(() => {
129
- if (!active() || failure)
135
+ if (!active() || failure())
130
136
  return;
131
137
  if (!proposalId)
132
138
  return;
@@ -218,14 +224,18 @@ export async function installCompaction(pi, options = {}) {
218
224
  ctx.ui.notify(JSON.stringify({ ...semanticStatus(), ...(errors.length ? { errors } : {}) }), errors.length ? "warning" : "info");
219
225
  } });
220
226
  pi.registerCommand("tuck", { description: "pi-tuck's state in this session: version, cuts, cut point, search", handler: async (_args, ctx) => {
221
- const { state, lastError } = semantic.status();
222
- ctx.ui.notify(statusText({ version: LOADED_VERSION, active: active(), branch: ctx.sessionManager.getBranch(), cutAt: options.cutAt, search, semantic: { state, lastError } }), "info");
227
+ const state = semantic.status();
228
+ ctx.ui.notify(statusText({ version: LOADED_VERSION, active: active(), branch: ctx.sessionManager.getBranch(), cutAt: options.cutAt, search, semantic: state, pending: view(ctx).pending }), "info");
223
229
  } });
224
230
  // Pi's native start/settled events bracket the run and its post-turn cuts.
225
- let admittedRun = false;
226
- let runPrompt;
227
- let thinkingRetryPending = false;
228
- const protectedPrompt = (ctx) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry => entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt))?.id : undefined;
231
+ let run;
232
+ // A native append may have mutated memory before its disk write failed. This session fault outlives the run.
233
+ let commitFault;
234
+ const failure = () => commitFault ?? run?.failure;
235
+ const protectedPrompt = (ctx) => run?.prompt?.id(ctx.sessionManager.getBranch());
236
+ const clips = () => run?.clips ?? emptyClips;
237
+ const emptyClips = new Map();
238
+ const resetRun = () => { run = undefined; pending = undefined; };
229
239
  const promptRefusal = () => new Refusal("Prompt check failed: the whole starting prompt cannot fit after cutting to the band and room, shrinking the summary and clipping fresh results. Shorten the prompt or choose a model with a larger context window, then retry");
230
240
  let estimatedTokens = 0;
231
241
  // One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
@@ -272,7 +282,7 @@ export async function installCompaction(pi, options = {}) {
272
282
  pending = undefined;
273
283
  const key = identityFor(ctx), message = event.message;
274
284
  const overflow = message.stopReason === "error" && isContextOverflow(message);
275
- if (!paired || !key || paired.identity !== key || !active() || failure || ctx.signal?.aborted || (message.errorMessage && !overflow) ||
285
+ if (!paired || !key || paired.identity !== key || !active() || failure() || ctx.signal?.aborted || (message.errorMessage && !overflow) ||
276
286
  message.provider !== ctx.model?.provider || message.model !== ctx.model.id || message.api !== ctx.model.api ||
277
287
  (!overflow && !["stop", "toolUse", "length"].includes(message.stopReason)) ||
278
288
  !isDeepStrictEqual(convertToLlm(paired.messages), paired.snapshot))
@@ -302,8 +312,30 @@ export async function installCompaction(pi, options = {}) {
302
312
  if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio))
303
313
  learn(ratio);
304
314
  });
305
- pi.on("agent_start", () => { admittedRun = true; thinkingRetryPending = false; pending = undefined; phase = "idle"; });
306
- pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; thinkingRetryPending = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
315
+ // The operator's length-stop notice is decided at settle and is not attempt state: a run reset before then (a tree move inside another
316
+ // extension's agent_settled, a failure) must not erase it. Only the final assistant reply decides it; a successful retry clears it.
317
+ let lengthStop = false;
318
+ pi.on("agent_start", () => { run ??= createRun("admitted"); run.phase = "admitted"; run.thinkingRetryPending = false; lengthStop = false; pending = undefined; });
319
+ pi.on("message_end", event => { if (event.message.role === "assistant")
320
+ lengthStop = event.message.stopReason === "length"; });
321
+ pi.on("agent_settled", (_event, ctx) => {
322
+ try {
323
+ if (!lengthStop || !active())
324
+ return;
325
+ lengthStop = false;
326
+ const message = "pi-tuck: the last reply stopped at the model's output limit and may be cut off. Ask the agent to continue.";
327
+ try {
328
+ if (ctx.hasUI === false)
329
+ process.stderr.write(message + "\n");
330
+ else
331
+ ctx.ui.notify(message, "warning");
332
+ }
333
+ catch { /* ended */ }
334
+ }
335
+ finally {
336
+ resetRun();
337
+ }
338
+ });
307
339
  const active = () => options.active?.() ?? true;
308
340
  const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
309
341
  // The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
@@ -316,7 +348,7 @@ export async function installCompaction(pi, options = {}) {
316
348
  const budget = host ?? { response: settingsFor().getCompactionSettings(ctx.model).reserveTokens };
317
349
  if (!Number.isSafeInteger(budget.response) || budget.response < 0)
318
350
  throw new Error("Invalid host request reserve");
319
- const floor = host ? host.responseExact !== true : ctx.sessionManager.getBranch().some(entry => entry.type === "message" && needsFullReplyReserve(entry.message));
351
+ const floor = host ? host.responseExact !== true : replyFit(ctx.sessionManager.getBranch()).failed;
320
352
  return { response: floor ? Math.max(budget.response, ctx.model?.maxTokens ?? 0) : budget.response };
321
353
  };
322
354
  const inputRoom = (response, ctx) => {
@@ -334,18 +366,18 @@ export async function installCompaction(pi, options = {}) {
334
366
  const hardLine = Math.min(Math.max(room, (ctx.model?.contextWindow ?? 0) - piMargin - floor), lastCeiling ?? Infinity);
335
367
  return { response, room, hardLine, replyFloor: floor };
336
368
  };
337
- const decisionBand = (ctx, room, system) => {
338
- const last = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
369
+ const decisionBand = (ctx, room, system, current = view(ctx)) => {
370
+ const last = current.session.getBranch().findLast(entry => entry.type === "compaction");
339
371
  const after = last?.type === "compaction" ? last.details?.tokensAfter : undefined;
340
372
  return bandOf(room, cutAt, options.cutAt === undefined && typeof after === "number" && Number.isFinite(after) && after >= 0
341
373
  ? { system, tokensAfter: after, factor } : undefined);
342
374
  };
343
375
  const fits = (tokens, limit, ctx) => corrected(tokens, ctx) < limit;
344
376
  // The saved conversation and a cut's quantities are counted by the one function the guard counts with (strict, as the context hook is).
345
- const measureFor = (ctx) => (messages) => projectedConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy);
377
+ const measureFor = (ctx) => (messages) => projectedConversationTokens(applyClips(messages, clips()), ctx, options.recallPolicy);
346
378
  // The planner counts with the same function, except that it does not fail on a summary in another format: a cut can replace one
347
379
  // (an operator's /compact repairs a session whose saved summary is foreign). Identical to the strict one for this compactor's summaries.
348
- const plannerMeasureFor = (ctx) => Object.assign((messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy), { messageTokens: (message) => conversationTokens(applyClips([message], clips)) });
380
+ const plannerMeasureFor = (ctx) => Object.assign((messages) => measurableConversationTokens(applyClips(messages, clips()), ctx, options.recallPolicy), { messageTokens: (message) => conversationTokens(applyClips([message], clips())) });
349
381
  const hostMessages = (ctx) => options.hostMessages?.(ctx) ?? [];
350
382
  const toolShapes = (names) => {
351
383
  const selected = new Set(names);
@@ -373,19 +405,19 @@ export async function installCompaction(pi, options = {}) {
373
405
  const saved = messages.some(message => message.role === "system") ? messages :
374
406
  [{ role: "system", content: prompt, toolsAdded: shapes, timestamp: 0 }, ...messages];
375
407
  const declared = enabled ? historyToolDeclarations(saved, shapes, true) : saved;
376
- return applyClips([...host, ...(enabled ? projectedMessages(declared, ctx, options.recallPolicy) : declared), ...incoming], clips);
408
+ return applyClips([...host, ...(enabled ? projectedMessages(declared, ctx, options.recallPolicy) : declared), ...incoming], clips());
377
409
  };
378
410
  const clampAboveRoom = (decision, ctx, room, target, total, project, replan, deepest) => {
379
411
  if (options.requestReserve || !piClamp)
380
412
  return decision;
381
413
  const lastAttempt = () => {
382
- const previousClips = new Map(clips);
414
+ const previousClips = new Map(clips());
383
415
  const candidate = deepest();
384
416
  if (candidate.kind === "cut" && fits(total(candidate), limits(ctx).hardLine, ctx) && requestClamp(project(candidate), ctx).leavesFloor)
385
417
  return candidate;
386
- clips.clear();
418
+ clips().clear();
387
419
  for (const [id, clip] of previousClips)
388
- clips.set(id, clip);
420
+ clips().set(id, clip);
389
421
  return { kind: "fault", capacity: true, message: clampMessage };
390
422
  };
391
423
  for (let retry = 0;; retry++) {
@@ -410,18 +442,6 @@ export async function installCompaction(pi, options = {}) {
410
442
  const isNoWork = (message) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
411
443
  // A missing model is configuration, not capacity.
412
444
  const missingModel = () => new Error("No context model for the request budget");
413
- let promptCut;
414
- // Where the branch ended when the prompt's cut was planned: the prompt is the entry Pi appends there. A cut that keeps nothing of the conversation
415
- // before it names no entry to keep from, and read as it stands that drops the prompt too, so the model never sees it.
416
- let promptCutAt = 0;
417
- const keptFrom = (cut, ctx) => cut.firstKeptEntryId ||
418
- ctx.sessionManager.getBranch().slice(promptCutAt).find(entry => entry.type !== "custom" || entry.customType !== "pi-tuck-capacity")?.id || "";
419
- let failure;
420
- let refusalReceipted = false;
421
- let pendingBoundaryCommit = false;
422
- // Fresh tool results the room cannot hold, by call id: each request of the run carries the clip in their place; the session keeps them
423
- // whole. A clip ends with its run like any failure would: the next run plans from the session as it is, where the result is no longer fresh.
424
- const clips = new Map();
425
445
  const receipt = (kind, reason, ctx) => {
426
446
  let room = null, hardLine = null;
427
447
  try {
@@ -434,51 +454,43 @@ export async function installCompaction(pi, options = {}) {
434
454
  recorder.stop("native-attempt-failed");
435
455
  // Every refusal says why, once per attempt: a capacity error with what the user can change, a cut Pi failed to commit with what that
436
456
  // means for the session, anything else as it is (an attempt that ended with no word looked like a hang).
437
- const notify = !failure;
438
- // A CommitFault stays whatever fails after it.
439
- if (!(failure instanceof CommitFault))
440
- failure = error instanceof Error ? error : new Error(String(error));
441
- pendingBoundaryCommit = false;
442
- pending = undefined;
443
- promptCut = undefined;
457
+ const previous = failure(), notify = !previous;
458
+ const fault = previous instanceof CommitFault ? previous : error instanceof Error ? error : new Error(String(error));
459
+ const receipted = run?.refusalReceipted;
460
+ resetRun();
461
+ // Keep only a terminal attempt record until Pi settles: its fresh abort controller must still see the refusal.
462
+ run = createRun("failed");
463
+ run.failure = fault;
464
+ run.refusalReceipted = true;
465
+ if (fault instanceof CommitFault)
466
+ commitFault = fault;
444
467
  try {
445
- if (!refusalReceipted) {
446
- refusalReceipted = true;
447
- receipt("refusal", failure.message, ctx);
448
- }
468
+ if (!receipted)
469
+ receipt("refusal", fault.message, ctx);
449
470
  options.fail?.(error);
450
471
  }
451
472
  finally {
452
473
  try {
453
474
  if (notify)
454
- ctx.ui.notify(failure instanceof CapacityError ? refusalNotice(failure.message, factor) : failure instanceof CommitFault
455
- ? `pi-tuck: ${failure.message}; the session holds a cut its file may lack, so start a new session.`
456
- : `pi-tuck could not plan this request (${failure.message}); the next prompt is planned afresh.`, "error");
475
+ ctx.ui.notify(fault instanceof CapacityError ? refusalNotice(fault.message, factor) : fault instanceof CommitFault
476
+ ? `pi-tuck: ${fault.message}; the session holds a cut its file may lack, so start a new session.`
477
+ : `pi-tuck could not plan this request (${fault.message}); the next prompt is planned afresh.`, "error");
457
478
  }
458
479
  finally {
459
480
  ctx.abort();
460
481
  }
461
482
  }
462
483
  };
463
- // The one place a failure is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
464
- // input started (a custom message) has only the first of these. No failure but a CommitFault outlives its attempt.
465
- const forgetFailure = () => { refusalReceipted = false; if (!(failure instanceof CommitFault))
466
- failure = undefined; };
467
484
  const access = () => {
468
- if (failure)
469
- throw failure;
485
+ if (failure())
486
+ throw failure();
470
487
  hostAccess();
471
488
  };
472
- // Abort alone is insufficient: Pi starts a fresh abort controller after
473
- // before_agent_start. Keep this attempt blocked, then recheck on new idle input.
474
- // "decided": `input` has decided for the run it starts, so that run's before_agent_start does not decide again. "starting": a run is
475
- // between before_agent_start and agent_start, where Pi 1.1 still reports idle but a message joins that run.
476
- let phase = "idle";
477
489
  pi.on("input", async (event, ctx) => {
478
- if (!ctx.isIdle() || phase === "starting")
479
- return; // a steering or follow-up message joins the running request
480
- forgetFailure();
481
- phase = "decided";
490
+ if (!ctx.isIdle() || run?.phase === "starting")
491
+ return; // steering joins the existing run
492
+ run = createRun("decided");
493
+ const decision = run;
482
494
  try {
483
495
  syncHistoryTools(ctx);
484
496
  await preflight(ctx, incomingTokens(event.text, event.images), [{ role: "user", content: [{ type: "text", text: event.text }, ...(event.images ?? [])], timestamp: 0 }], ctx.getSystemPrompt(), pi.getActiveTools());
@@ -487,6 +499,10 @@ export async function installCompaction(pi, options = {}) {
487
499
  catch (error) {
488
500
  fail(error, ctx);
489
501
  }
502
+ // Pi normally reaches its pre-run threshold check within microtasks. Auth I/O or a later extension's input I/O can make a
503
+ // real prompt expire too; safely fall back to carrying its cut in the request and saving it at the first boundary.
504
+ setImmediate(() => { if (run === decision && !decision.claimed)
505
+ decision.expired = true; });
490
506
  });
491
507
  pi.registerTool({
492
508
  name: "tuck_recall", label: "pi-tuck history", parameters: RecallInput,
@@ -564,28 +580,30 @@ export async function installCompaction(pi, options = {}) {
564
580
  }
565
581
  // Pi checks its own threshold after a prompt's input and before the run starts: the prompt's cut, planned with the prompt counted,
566
582
  // is the answer, and it is saved before the first request.
567
- if (promptCut && !admittedRun && event.reason === "threshold") {
568
- access();
569
- const { type: _, ...compaction } = promptCut;
570
- promptCut = undefined;
571
- return { compaction };
583
+ if (run?.phase === "decided" && !run.expired && event.reason === "threshold") {
584
+ run.claimed = true;
585
+ if (view(ctx).pending) {
586
+ access();
587
+ const current = view(ctx), { type: _, ...compaction } = run.cut.take();
588
+ return { compaction: { ...compaction, firstKeptEntryId: current.keptFromId } };
589
+ }
572
590
  }
573
591
  const operatorManual = event.reason === "manual";
574
- if (operatorManual && !admittedRun)
575
- forgetFailure();
592
+ if (operatorManual && run?.phase !== "admitted")
593
+ resetRun();
576
594
  access();
577
595
  // pi-tuck's summary is built from the session itself, so text after /compact cannot steer it. The operator asked for a cut: make the one
578
596
  // plain /compact makes and say the text was not used, rather than cancel (which Pi shows as an error, with no cut).
579
597
  if (event.customInstructions?.trim())
580
598
  ctx.ui.notify("pi-tuck: /compact ignores the text after it, since pi-tuck builds its summary from the session itself. Cutting as plain /compact does.", "info");
581
599
  const { response, room } = limits(ctx);
582
- const retain = operatorManual || admittedRun;
600
+ const retain = operatorManual || run?.phase === "admitted";
583
601
  // A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
584
602
  // cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
585
603
  const saved = ctx.sessionManager.buildSessionProjection().messages;
586
604
  const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
587
605
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
588
- const mandatory = system + host, band = decisionBand(ctx, room, system);
606
+ const mandatory = system + host, band = decisionBand(ctx, room, system, view(ctx, event.reason === "threshold"));
589
607
  const before = plannerMeasureFor(ctx)(saved);
590
608
  observeBudget(before + mandatory, ctx);
591
609
  // Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
@@ -608,9 +626,9 @@ export async function installCompaction(pi, options = {}) {
608
626
  const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
609
627
  // Every cut asked while no run is admitted (an operator's cut) is planned for a run that has not begun: the session has moved on
610
628
  // from any run that ended. A retry is inside its run.
611
- const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun, admittedRun ? protectedPrompt(ctx) : undefined);
629
+ const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || run?.phase !== "admitted", run?.phase === "admitted" ? protectedPrompt(ctx) : undefined);
612
630
  return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve,
613
- messages: previewCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).buildSessionProjection().messages }
631
+ messages: plannedView(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).session.buildSessionProjection().messages }
614
632
  : { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
615
633
  },
616
634
  });
@@ -619,7 +637,7 @@ export async function installCompaction(pi, options = {}) {
619
637
  const { hardLine } = limits(ctx);
620
638
  if (!operatorManual && hardLine > room && decision.kind === "fault") {
621
639
  decision = fits(before + mandatory, hardLine, ctx) ? { kind: "none" } : decide(hardLine, hardLine);
622
- decision = clampAboveRoom(decision, ctx, room, hardLine, candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory, candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, [], ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"), target => target <= room ? decide() : decide(target, target), () => deepestDecision(ctx, operatorManual || !admittedRun, retain));
640
+ decision = clampAboveRoom(decision, ctx, room, hardLine, candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory, candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, [], ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"), target => target <= room ? decide() : decide(target, target), () => deepestDecision(ctx, operatorManual || run?.phase !== "admitted", retain));
623
641
  }
624
642
  if (decision.kind === "cut") {
625
643
  return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore, retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) };
@@ -645,49 +663,23 @@ export async function installCompaction(pi, options = {}) {
645
663
  }
646
664
  });
647
665
  pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
648
- pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; refusalReceipted = false; admittedRun = false; runPrompt = undefined; estimatedTokens = 0; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
649
- const historyToolsEnabled = (ctx) => Boolean(promptCut) || hasTuckCut(ctx.sessionManager.getBranch());
666
+ pi.on("session_start", () => { resetRun(); recorder.stop("session-transition"); proposalId = undefined; commitFault = undefined; estimatedTokens = 0; identity = ""; factor = 1; });
667
+ const historyToolsEnabled = (ctx) => view(ctx).hasTuckCut;
650
668
  const syncHistoryTools = (ctx) => { if (active())
651
669
  applyHistoryTools(pi, historyToolsEnabled(ctx)); };
652
670
  pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
653
671
  // A plan decided on `input` is for the branch it was decided on: moving in the tree leaves it planned for another branch, whose kept entry
654
672
  // may not be on this one.
655
- pi.on("session_tree", (_event, ctx) => { promptCut = undefined; phase = "idle"; syncHistoryTools(ctx); });
656
- pi.on("session_compact", (_event, ctx) => { if (!admittedRun)
673
+ pi.on("session_tree", (_event, ctx) => { resetRun(); syncHistoryTools(ctx); });
674
+ pi.on("session_compact", (_event, ctx) => { if (run?.phase !== "admitted")
657
675
  syncHistoryTools(ctx); });
658
676
  pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
659
677
  pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
660
- // A reply that stopped at the output limit may be cut off, and Pi says nothing. Tell the operator once per run, on the operator's side only:
661
- // the notice never enters the model's context. Decided at the end of the run, so a later reply in the same run (a retry or a continuation)
662
- // that ends normally clears it.
663
- let lengthStop = false;
664
- pi.on("agent_start", () => { lengthStop = false; });
665
- pi.on("message_end", event => { if (event.message.role === "assistant")
666
- lengthStop = event.message.stopReason === "length"; });
667
- pi.on("agent_settled", (_event, ctx) => {
668
- if (!lengthStop || !active())
669
- return;
670
- lengthStop = false;
671
- const message = "pi-tuck: the last reply stopped at the model's output limit and may be cut off. Ask the agent to continue.";
672
- try {
673
- if (ctx.hasUI === false)
674
- process.stderr.write(message + "\n");
675
- else
676
- ctx.ui.notify(message, "warning");
677
- }
678
- catch { /* ended */ } // with no UI (pi -p), stderr is the operator's
679
- });
680
- // The session as it will be once a cut that is not saved yet is saved, and its conversation as the next request carries it.
681
- const previewCut = (ctx, cut) => {
682
- const header = ctx.sessionManager.getHeader();
683
- if (!header)
684
- throw new Error("Missing native session header for the prompt's cut");
685
- const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
686
- preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
687
- return preview;
678
+ const plannedView = (ctx, cut) => {
679
+ const pending = new PendingCut();
680
+ pending.plan(cut, ctx.sessionManager.getBranch().length);
681
+ return pending.view(ctx);
688
682
  };
689
- const cutSession = (ctx, cut) => previewCut(ctx, { ...cut, firstKeptEntryId: keptFrom(cut, ctx) });
690
- const withCut = (ctx, cut) => cutSession(ctx, cut).buildSessionProjection().messages;
691
683
  // The shared final planner: preserve the compiler's prompt/protocol floor and spend nothing on optional history. Fresh text results
692
684
  // get the smallest existing head/tail + recall-locator representation; their originals remain in the session, as for ordinary clips.
693
685
  const deepestDecision = (ctx, newPrompt, retain) => {
@@ -710,7 +702,7 @@ export async function installCompaction(pi, options = {}) {
710
702
  low = middle + 1;
711
703
  }
712
704
  for (const clip of clipFor([result], entryIdOf, low))
713
- clips.set(clip.toolCallId, clip);
705
+ clips().set(clip.toolCallId, clip);
714
706
  }
715
707
  }
716
708
  const before = measureFor(ctx)(saved);
@@ -719,7 +711,7 @@ export async function installCompaction(pi, options = {}) {
719
711
  protectedPromptEntryId: newPrompt ? undefined : protectedPrompt(ctx), deepest: true }, ctx, 0, plannerMeasureFor(ctx), options.recallPolicy, retain);
720
712
  if (!draft.compaction)
721
713
  return { kind: "fault", capacity: Boolean(draft.capacityRejected), message: draft.unavailable };
722
- const messages = previewCut(ctx, { type: "compaction", ...draft.compaction }).buildSessionProjection().messages;
714
+ const messages = plannedView(ctx, { type: "compaction", ...draft.compaction }).session.buildSessionProjection().messages;
723
715
  assertNativeToolRounds(messages);
724
716
  return { kind: "cut", target: "room", plan: { draft: true, after: measureFor(ctx)(messages), mode: draft.mode,
725
717
  compaction: draft.compaction, reserve: 0, messages } };
@@ -729,8 +721,8 @@ export async function installCompaction(pi, options = {}) {
729
721
  // keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
730
722
  // Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
731
723
  const preflight = (ctx, incomingPart, incomingMessages, observedPrompt, tools, hostSnapshot) => {
732
- promptCut = undefined;
733
- promptCutAt = ctx.sessionManager.getBranch().length;
724
+ run.cut.clear();
725
+ const branchLength = ctx.sessionManager.getBranch().length;
734
726
  if (!active())
735
727
  return;
736
728
  access();
@@ -765,7 +757,9 @@ export async function installCompaction(pi, options = {}) {
765
757
  const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
766
758
  if (!draft.compaction)
767
759
  return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
768
- const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
760
+ const candidate = new PendingCut();
761
+ candidate.plan({ type: "compaction", ...draft.compaction, tokensBefore: before }, branchLength);
762
+ const messages = candidate.view(ctx).session.buildSessionProjection().messages;
769
763
  assertNativeToolRounds(messages);
770
764
  return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
771
765
  },
@@ -785,14 +779,19 @@ export async function installCompaction(pi, options = {}) {
785
779
  if (decision.kind === "fault")
786
780
  throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
787
781
  const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "prompt", before, false, false, previousSummary, { response, host, mandatory, target: decision.target, margin: room - band });
788
- promptCut = { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details };
782
+ run.cut.plan({ type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details }, branchLength);
789
783
  };
790
784
  pi.on("before_agent_start", async (event, ctx) => {
791
785
  // A decided plan is for the prompt `input` saw. A run that a custom message starts is not that run (another extension handled the
792
786
  // input, so no run started for it): plan afresh.
793
- const prompted = phase === "decided" && event.messageType !== "custom";
794
- phase = "starting";
795
- runPrompt = event.messageType === "custom" ? undefined : event.prompt;
787
+ const prompted = run?.phase === "decided" && event.messageType !== "custom";
788
+ if (!prompted && run?.phase !== "failed")
789
+ run = createRun("starting");
790
+ run ??= createRun("starting");
791
+ run.claimed = true;
792
+ run.phase = "starting";
793
+ const prompt = event.messageType === "custom" ? textOf(event.message.content) : event.prompt;
794
+ run.prompt = prompt ? new StartingPrompt(prompt, ctx.sessionManager.getBranch().length, event.messageType) : undefined;
796
795
  try {
797
796
  syncHistoryTools(ctx);
798
797
  if (prompted)
@@ -817,7 +816,7 @@ export async function installCompaction(pi, options = {}) {
817
816
  // handlers have run. Pi's prepareRequest has not yet snapshotted executable tools. A first cut discovered in context_with_system
818
817
  // would be too late for that snapshot, even if its declarations were repaired there, so make the late prompt decision here.
819
818
  pi.on("message_end", (event, ctx) => {
820
- if (event.message.role !== "user" || !active() || !admittedRun || promptCut)
819
+ if (event.message.role !== "user" || !active() || run?.phase !== "admitted" || view(ctx).pending)
821
820
  return;
822
821
  try {
823
822
  access();
@@ -910,12 +909,7 @@ export async function installCompaction(pi, options = {}) {
910
909
  }
911
910
  return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
912
911
  }
913
- const header = ctx.sessionManager.getHeader();
914
- if (!header)
915
- throw new Error("Missing native session header at tool-loop boundary");
916
- const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
917
- preview.appendCompaction(draft.compaction.summary, draft.compaction.firstKeptEntryId, before, draft.compaction.details, true);
918
- const messages = preview.buildSessionProjection().messages;
912
+ const messages = plannedView(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).session.buildSessionProjection().messages;
919
913
  assertNativeToolRounds(messages);
920
914
  previews.push(messages);
921
915
  return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
@@ -940,11 +934,11 @@ export async function installCompaction(pi, options = {}) {
940
934
  if (!round.length)
941
935
  continue;
942
936
  for (const clip of round)
943
- clips.set(clip.toolCallId, clip);
937
+ run.clips.set(clip.toolCallId, clip);
944
938
  const clippedDecision = decide(clipRoom, targetBand, required);
945
939
  if (clippedDecision.kind === "fault")
946
940
  for (const clip of round)
947
- clips.delete(clip.toolCallId);
941
+ run.clips.delete(clip.toolCallId);
948
942
  else {
949
943
  decision = clippedDecision;
950
944
  break;
@@ -982,15 +976,15 @@ export async function installCompaction(pi, options = {}) {
982
976
  firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
983
977
  };
984
978
  const atBoundary = (event, ctx, reason, decide) => {
985
- if (!promptCut)
979
+ const current = view(ctx);
980
+ if (!current.pending)
986
981
  return decide ? boundaryCut(event, ctx, reason) : undefined;
987
- const entry = promptCut;
988
- promptCut = undefined;
989
- const preview = cutSession(ctx, entry), view = Object.create(ctx, { sessionManager: { value: preview } });
982
+ const entry = run.cut.take();
983
+ const preview = current.session, context = Object.create(ctx, { sessionManager: { value: preview } });
990
984
  const more = decide && !event.entries.length
991
- ? boundaryCut({ ...event, context: { ...event.context, contextMessages: preview.buildSessionProjection().messages } }, view, reason) : undefined;
985
+ ? boundaryCut({ ...event, context: { ...event.context, contextMessages: preview.buildSessionProjection().messages } }, context, reason) : undefined;
992
986
  const { tokensBefore: _, ...cut } = entry;
993
- const saved = { ...cut, firstKeptEntryId: keptFrom(entry, ctx) };
987
+ const saved = { ...cut, firstKeptEntryId: current.keptFromId };
994
988
  // As for a cut Pi makes (session_compact): the history the cut leaves behind is indexed, once Pi has committed the entries.
995
989
  setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
996
990
  return { entries: [...event.entries, saved, ...(more?.entries ?? [])] };
@@ -1000,37 +994,24 @@ export async function installCompaction(pi, options = {}) {
1000
994
  // Pi's later native length recovery from retrying it independently. The stored message and its output usage stay on the branch.
1001
995
  const entries = [...(atBoundary(event, ctx, "run-end", false)?.entries ?? event.entries),
1002
996
  { type: "context_edit", targetId: event.messageEntryId, replacement: null }];
1003
- if (thinkingRetryPending)
997
+ if (run.thinkingRetryPending)
1004
998
  return { entries, continue: false };
1005
- const header = ctx.sessionManager.getHeader();
1006
- if (!header)
1007
- throw new Error("Missing native session header for the thinking-only retry");
1008
- const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
1009
- for (const entry of entries) {
1010
- if (entry.type === "context_edit")
1011
- preview.appendContextEdit(entry.targetId, entry.replacement);
1012
- else if (entry.type === "compaction")
1013
- preview.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
1014
- else if (entry.type === "custom")
1015
- preview.appendCustomEntry(entry.customType, entry.data);
1016
- else
1017
- preview.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
1018
- }
999
+ const preview = liveCut.view(ctx, entries).session;
1019
1000
  const view = Object.create(ctx, { sessionManager: { value: preview } });
1020
1001
  const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
1021
1002
  // The deepest cut only when it buys reply room: where Pi's cap on the request as it stands (the stopped reply left out) is already
1022
1003
  // as large as the deepest cut's, the turn is retried uncut, and no history is given up for nothing.
1023
1004
  const asIs = projectedRequest(preview.buildSessionProjection().messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), false);
1024
1005
  const asIsCheck = requestClamp(asIs, view);
1025
- const previousClips = new Map(clips);
1006
+ const previousClips = new Map(clips());
1026
1007
  const decision = deepestDecision(view, false, true);
1027
1008
  if (decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
1028
1009
  requestClamp(projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true), view).cap <= asIsCheck.cap) {
1029
- clips.clear();
1010
+ clips().clear();
1030
1011
  for (const [id, clip] of previousClips)
1031
- clips.set(id, clip);
1012
+ clips().set(id, clip);
1032
1013
  const { room, hardLine } = limits(view);
1033
- thinkingRetryPending = true;
1014
+ run.thinkingRetryPending = true;
1034
1015
  return { entries: [...entries, { type: "custom", customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
1035
1016
  stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: false,
1036
1017
  piEstimateTokens: asIsCheck.estimate, piCap: asIsCheck.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
@@ -1038,7 +1019,7 @@ export async function installCompaction(pi, options = {}) {
1038
1019
  if (decision.kind !== "cut")
1039
1020
  throw new CapacityError(clampMessage);
1040
1021
  const messages = projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true);
1041
- if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
1022
+ if (run?.prompt && !carriesPrompt(messages, run.prompt.text(ctx.sessionManager.getBranch()), run.prompt.role))
1042
1023
  throw promptRefusal();
1043
1024
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
1044
1025
  const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
@@ -1046,8 +1027,8 @@ export async function installCompaction(pi, options = {}) {
1046
1027
  if (!fits(tokens, hardLine, view) || check.cap === undefined || !check.leavesFloor)
1047
1028
  throw new CapacityError(clampMessage);
1048
1029
  const compaction = captureDraft(decision.plan.compaction, view, decision.plan.reserve, "length-retry", tokens, true, false, undefined, { response, host, mandatory: system + host + conversationTokens(event.context.pendingMessages), target: decision.target, margin: room - decisionBand(view, room, system) });
1049
- thinkingRetryPending = true;
1050
- pendingBoundaryCommit = true;
1030
+ run.thinkingRetryPending = true;
1031
+ run.pendingBoundaryCommit = true;
1051
1032
  return { entries: [...entries, { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
1052
1033
  { type: "custom", customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
1053
1034
  stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: true,
@@ -1055,18 +1036,18 @@ export async function installCompaction(pi, options = {}) {
1055
1036
  };
1056
1037
  pi.on("turn_end", (event, ctx) => {
1057
1038
  try {
1058
- if (!active() || !admittedRun)
1039
+ if (!active() || run?.phase !== "admitted")
1059
1040
  return;
1060
- if (thinkingOnlyLength(event.message) && !failure && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
1041
+ if (thinkingOnlyLength(event.message) && !failure() && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
1061
1042
  return retryThinking(event, ctx);
1062
- thinkingRetryPending = false;
1043
+ run.thinkingRetryPending = false;
1063
1044
  const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
1064
1045
  settingsFor().getCompactionSettings(ctx.model).enabled;
1065
- if (!promptCut && !decide)
1046
+ if (!view(ctx).pending && !decide)
1066
1047
  return;
1067
1048
  const result = atBoundary(event, ctx, "tool-loop", decide);
1068
1049
  if (result) {
1069
- pendingBoundaryCommit = true;
1050
+ run.pendingBoundaryCommit = true;
1070
1051
  }
1071
1052
  return result;
1072
1053
  }
@@ -1087,23 +1068,24 @@ export async function installCompaction(pi, options = {}) {
1087
1068
  // A request of a run whose prompt's cut is not saved yet: the saved conversation in it, which other handlers may surround, is replaced by
1088
1069
  // the conversation with the cut applied. Pi sends the saved system message apart from the request's messages, so neither side carries it
1089
1070
  // here. A request that does not carry the saved conversation as it stands cannot take the cut.
1090
- const cutRequest = (messages, ctx, cut) => {
1071
+ const cutRequest = (messages, ctx) => {
1091
1072
  const conversation = (all) => all.filter(message => message.role !== "system");
1092
1073
  const saved = conversation(ctx.sessionManager.buildSessionProjection().messages);
1093
1074
  for (let at = 0; at + saved.length <= messages.length; at++)
1094
1075
  if (saved.every((message, i) => isDeepStrictEqual(messages[at + i], message)))
1095
- return [...messages.slice(0, at), ...conversation(withCut(ctx, cut)), ...messages.slice(at + saved.length)];
1076
+ return [...messages.slice(0, at), ...conversation(view(ctx).session.buildSessionProjection().messages), ...messages.slice(at + saved.length)];
1096
1077
  throw new Error("The prompt's cut cannot be applied: the request does not carry the saved conversation");
1097
1078
  };
1098
1079
  pi.on("context", (event, ctx) => {
1099
1080
  try {
1100
1081
  if (!active())
1101
1082
  return;
1083
+ run?.prompt?.id(ctx.sessionManager.getBranch());
1102
1084
  // Before a cut, Pi owns the whole request, including its object identities and system-message serialization.
1103
1085
  if (!historyToolsEnabled(ctx))
1104
- return clips.size ? { messages: applyClips(event.messages, clips) } : undefined;
1086
+ return clips().size ? { messages: applyClips(event.messages, clips()) } : undefined;
1105
1087
  access();
1106
- return { messages: applyClips(projectedMessages(promptCut ? cutRequest(event.messages, ctx, promptCut) : event.messages, ctx, options.recallPolicy), clips) };
1088
+ return { messages: applyClips(projectedMessages(view(ctx).pending ? cutRequest(event.messages, ctx) : event.messages, ctx, options.recallPolicy), clips()) };
1107
1089
  }
1108
1090
  catch (error) {
1109
1091
  try {
@@ -1134,17 +1116,18 @@ export async function installCompaction(pi, options = {}) {
1134
1116
  fail(new (event.fromExtension ? CommitFault : Error)(event.errorMessage ?? "Compaction failed"), ctx);
1135
1117
  });
1136
1118
  pi.on("agent_before_settle", (event, ctx) => {
1137
- if (pendingBoundaryCommit) {
1119
+ if (run?.pendingBoundaryCommit) {
1138
1120
  if (event.outcome === "error")
1139
1121
  return void fail(new CommitFault("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
1140
- pendingBoundaryCommit = false;
1122
+ if (run)
1123
+ run.pendingBoundaryCommit = false;
1141
1124
  }
1142
1125
  // Pi commits these entries before the run settles.
1143
1126
  try {
1144
- if (!active() || !admittedRun || failure || !settingsFor().getCompactionSettings(ctx.model).enabled)
1127
+ if (!active() || run?.phase !== "admitted" || failure() || !settingsFor().getCompactionSettings(ctx.model).enabled)
1145
1128
  return;
1146
1129
  const decide = event.outcome === "completed" && !ctx.signal?.aborted;
1147
- if (promptCut ? event.outcome === "error" : !decide)
1130
+ if (view(ctx).pending ? event.outcome === "error" : !decide)
1148
1131
  return;
1149
1132
  return atBoundary(event, ctx, "run-end", decide);
1150
1133
  }
@@ -1154,7 +1137,8 @@ export async function installCompaction(pi, options = {}) {
1154
1137
  });
1155
1138
  pi.on("context_with_system", (event, ctx) => {
1156
1139
  // Reaching the next request boundary proves native commit returned, not durable storage.
1157
- pendingBoundaryCommit = false;
1140
+ if (run)
1141
+ run.pendingBoundaryCommit = false;
1158
1142
  pending = undefined;
1159
1143
  try {
1160
1144
  if (!active())
@@ -1173,7 +1157,7 @@ export async function installCompaction(pi, options = {}) {
1173
1157
  observeBudget(tokens, ctx);
1174
1158
  estimatedTokens = corrected(tokens, ctx);
1175
1159
  const { room, hardLine } = limits(ctx);
1176
- if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
1160
+ if (run?.prompt && !carriesPrompt(messages, run.prompt.text(ctx.sessionManager.getBranch()), run.prompt.role))
1177
1161
  throw new Refusal("Prompt check failed: the request is missing the whole text of the prompt that started this run. Shorten the prompt or choose a model with a larger context window, then retry");
1178
1162
  if (!fits(tokens, hardLine, ctx))
1179
1163
  throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");