Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# changes — senpi-monorepo root

## Oversized saved-session recovery (2026-09-01)

Coding-agent bootstrap now distinguishes implicit saved-model restoration from
explicit model selection. A restored session that outgrew its saved model picks
the authenticated candidate with the greatest verified remaining budget,
skips unavailable providers, records model-specific selection history only
after extension admission succeeds, and opens normally. Explicit selections
remain fail-closed; candidate probes are marked provisional so Claude SDK
continuity is not invalidated before admission, post-select failures roll back
runtime state before falling through, and each provider is authenticated once;
no-capable-model failures render as clean typed CLI errors.

This is core startup behavior because the failure occurs before extensions or
interactive UI activation.

## Shared-host rendering isolation (2026-08-30)

Shared socket clients now register `rendered_components` through additive `set_client_info` capabilities. Factory-rendered component records are filtered per connection, including capability-aware snapshot replay. Capabilities remain connection-wide across sessions and are cleared only on socket release; explicit close removes only the closing width. Shared bindings retain factories while disposing live renderers and footer providers when no capable connection remains, recreating them for later capable joiners.
Expand Down
9 changes: 9 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@

### Fixed

- Existing sessions whose restored transcript no longer fits their saved model
now reopen on the configured authenticated model with the largest usable
remaining context budget, skipping unavailable providers without leaving
partial model or thinking history persisted. Explicit `--model` selections
stay strict, rejected candidates roll back their model-specific runtime state
before later candidates are tried, Claude SDK continuity ignores provisional
candidate probes, and a session with no capable model exits with actionable
budget guidance instead of an uncaught stack trace.

### New Features

### Breaking Changes
Expand Down
14 changes: 14 additions & 0 deletions packages/coding-agent/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Local fork changes

## 2026-09-01 - Recover oversized session resumes

- Implicit saved-model restores now recover onto the authenticated model with
the largest usable remaining context budget while preserving explicit model
admission checks.
- Recovery skips unavailable providers and commits model-specific selection
history only after extension admission succeeds.
- A candidate rejected by a model-select budget hook no longer prevents later
capable candidates from being tried; rejected candidate runtime state is
rolled back, Claude SDK continuity ignores provisional candidate probes, and
each provider is authenticated once per recovery.
- When no recovery model exists, CLI startup prints the typed budget guidance
and exits cleanly instead of exposing an uncaught exception stack.

## 2026-09-01 - Acknowledge RPC abort before quiesce

- The RPC `abort` command now acknowledges immediately after dispatching the abort signal, while observing quiesce failures through the existing `rpc_error` event path.
Expand Down
31 changes: 31 additions & 0 deletions packages/coding-agent/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
# changes

## 2026-09-01 - Make oversized sessions resumable

### What changed

- Session bootstrap now recovers an unusable implicit saved model onto the
authenticated model with the largest verified remaining context budget.
- An unavailable highest-capacity provider no longer aborts recovery; startup
tries the next usable provider, and failed model-select admission leaves no
model-specific thinking or model history persisted.
- A post-select budget rejection rolls back candidate-specific runtime state
before falling through to the next capable model; provider authentication is
cached for that recovery attempt and Claude SDK continuity ignores
provisional candidate probes.
- CLI startup renders a no-capable-model budget failure as an actionable error
without leaking an uncaught Node stack.

### Why

- Recent long-running sessions could no longer reopen after their restored
transcript outgrew the saved model, even though a larger configured model was
available.

### Why an extension could not handle it

- Both the saved-model restore and startup usability gate precede extension
activation and TUI construction.

### Expected merge conflict zones

- MEDIUM: `core/sdk.ts` and the runtime creation boundary in `main.ts`.

## 2026-09-01 - Negotiate RPC session auto-titling

### What changed
Expand Down
156 changes: 149 additions & 7 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,8 @@ export class AgentSession {
private _currentServiceTier: ServiceTier | undefined = undefined;
private _sessionFastMode = false;
private readonly _shownHighReasoningWarningKeys = new Set<string>();
/** Buffers public events until transactional model admission commits. */
private _deferredSessionEvents?: AgentSessionEvent[];
// Widened with the upstream BuildSystemPromptOptions user-override fields so
// extensions (prompt-preset) can see CLI/SDK custom prompts via
// before_agent_start/model_select systemPromptOptions and ctx.getSystemPromptOptions().
Expand Down Expand Up @@ -1613,6 +1615,10 @@ export class AgentSession {
}

private _emit(event: AgentSessionEvent): void {
if (this._deferredSessionEvents) {
this._deferredSessionEvents.push(event);
return;
}
this._logSessionEvent(event);
for (const l of this._eventListeners) {
l(event);
Expand Down Expand Up @@ -4493,6 +4499,8 @@ export class AgentSession {
nextModel: Model<any>,
previousModel: Model<any> | undefined,
source: ModelSelectSource,
publish = true,
provisional = false,
): Promise<SystemPromptChangeEvent | undefined> {
this.syncPromptCacheSafeWaitEnv();
if (!this._modelSelectionChangesContext(previousModel, nextModel)) return undefined;
Expand All @@ -4501,6 +4509,7 @@ export class AgentSession {
model: nextModel,
previousModel,
source,
...(provisional ? { provisional: true } : {}),
systemPrompt: this.agent.state.systemPrompt,
systemPromptOptions: this._baseSystemPromptOptions,
});
Expand All @@ -4526,9 +4535,13 @@ export class AgentSession {
if (result.systemPromptName) {
event.systemPromptName = result.systemPromptName;
}
if (publish) await this._publishSystemPromptChange(event);
return event;
}

private async _publishSystemPromptChange(event: SystemPromptChangeEvent): Promise<void> {
await this._extensionRunner.emit(event);
this._emit(event);
return event;
}

/**
Expand Down Expand Up @@ -4583,6 +4596,39 @@ export class AgentSession {
return this._setModel(model, false);
}

/**
* Recover an existing session whose restored model cannot carry its live
* context. Startup owns candidate selection and full-budget admission; this
* seam records the recovered model without changing global defaults or
* treating the restore as a manual fallback reset.
* @internal
*/
async setStartupRecoveryModel(
model: Model<Api>,
liveContextTokens: number,
): Promise<SystemPromptChangeEvent | undefined> {
this.assertModelUsable(model, liveContextTokens);
const previousModel = this.model;
const systemPromptChange = await this._switchActiveModel(model, {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
persistDefault: false,
appendSessionEntry: false,
emitModelSelect: true,
Comment on lines +4612 to +4615

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer thinking persistence until recovery admission succeeds

When the recovery candidate supports different thinking levels and a model_select handler subsequently rejects it—for example, by installing an oversized model-specific prompt—_switchActiveModel calls _setThinkingLevel(..., false, ...) before the final usability assertion, and that method still appends a thinking_level_change entry. Although appendSessionEntry: false prevents the candidate model from being recorded, the failed startup therefore mutates the saved session's thinking history; defer this mutation until admission succeeds or roll it back with the model and prompt.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0b3be0a: startup recovery now uses the target model’s normal thinking resolution but applies it ephemerally (persistThinkingLevel: false) until model-select admission succeeds, so a rejected recovery cannot append candidate-specific thinking history.

modelSelectSource: "restore",
invalidateCompaction: true,
persistThinkingLevel: false,
liveContextTokens,
transactionalModelSelect: true,
});
this.sessionManager.appendModelChange(
model.provider,
model.id,
undefined,
previousModel?.provider,
previousModel?.id,
);
return systemPromptChange;
}

private async _setModel(
model: Model<Api>,
updateGlobalDefaults: boolean,
Expand Down Expand Up @@ -4640,9 +4686,78 @@ export class AgentSession {
modelSelectSource: ModelSelectSource;
invalidateCompaction: boolean;
ephemeralThinkingLevel?: ThinkingLevel;
persistThinkingLevel?: boolean;
liveContextTokens?: number;
transactionalModelSelect?: boolean;
},
): Promise<SystemPromptChangeEvent | undefined> {
const previousModel = this.model;
const snapshot = opts.transactionalModelSelect
? {
model: this.agent.state.model,
systemPrompt: this.agent.state.systemPrompt,
tools: this.agent.state.tools,
thinkingLevel: this.agent.state.thinkingLevel,
thinkingSelection: this.agent.state.thinkingSelection,
abortServerSideFallback: this.agent.abortServerSideFallback,
currentServiceTier: this._currentServiceTier,
sessionFastMode: this._sessionFastMode,
baseSystemPrompt: this._baseSystemPrompt,
baseSystemPromptOptions: { ...this._baseSystemPromptOptions },
systemPromptOverride: this._systemPromptOverride,
requestedActiveToolNames: this._requestedActiveToolNames
? [...this._requestedActiveToolNames]
: undefined,
withheldEvalOnlyToolNames: new Set(this._withheldEvalOnlyToolNames),
publishedEvalOnlyHintNames: new Set(this._publishedEvalOnlyHintNames),
removedToolHints: { ...this.agent.removedToolHints },
toolRegistry: new Map(this._toolRegistry),
toolDefinitions: new Map(this._toolDefinitions),
toolPromptSnippets: new Map(this._toolPromptSnippets),
toolPromptGuidelines: new Map(this._toolPromptGuidelines),
shownHighReasoningWarningKeys: new Set(this._shownHighReasoningWarningKeys),
}
: undefined;
const ownsDeferredEvents = opts.transactionalModelSelect && this._deferredSessionEvents === undefined;
if (ownsDeferredEvents) this._deferredSessionEvents = [];
const restoreSnapshot = () => {
if (!snapshot) return;
this.agent.state.model = snapshot.model;
this.agent.state.systemPrompt = snapshot.systemPrompt;
this.agent.state.tools = snapshot.tools;
this.agent.state.thinkingLevel = snapshot.thinkingLevel;
this.agent.state.thinkingSelection = snapshot.thinkingSelection;
this.agent.abortServerSideFallback = snapshot.abortServerSideFallback;
this._currentServiceTier = snapshot.currentServiceTier;
this._sessionFastMode = snapshot.sessionFastMode;
this._baseSystemPrompt = snapshot.baseSystemPrompt;
this._baseSystemPromptOptions = snapshot.baseSystemPromptOptions;
this._systemPromptOverride = snapshot.systemPromptOverride;
this._requestedActiveToolNames = snapshot.requestedActiveToolNames;
this._withheldEvalOnlyToolNames.clear();
for (const toolName of snapshot.withheldEvalOnlyToolNames) {
this._withheldEvalOnlyToolNames.add(toolName);
}
this._publishedEvalOnlyHintNames.clear();
for (const toolName of snapshot.publishedEvalOnlyHintNames) {
this._publishedEvalOnlyHintNames.add(toolName);
}
this.agent.removedToolHints = snapshot.removedToolHints;
this._toolRegistry = snapshot.toolRegistry;
this._toolDefinitions = snapshot.toolDefinitions;
this._toolPromptSnippets = snapshot.toolPromptSnippets;
this._toolPromptGuidelines = snapshot.toolPromptGuidelines;
this._shownHighReasoningWarningKeys.clear();
for (const key of snapshot.shownHighReasoningWarningKeys) {
this._shownHighReasoningWarningKeys.add(key);
}
};
const flushDeferredEvents = () => {
if (!ownsDeferredEvents) return;
const deferredEvents = this._deferredSessionEvents ?? [];
this._deferredSessionEvents = undefined;
for (const event of deferredEvents) this._emit(event);
};
if (
opts.invalidateCompaction &&
(this._modelSelectionChangesContext(previousModel, model) ||
Expand All @@ -4652,7 +4767,7 @@ export class AgentSession {
this._invalidateCompactionForModelSelection();
}
const thinking = this._getThinkingForModelSwitch(model, opts.ephemeralThinkingLevel);
const liveContextTokens = this._getDownswitchLiveContextTokens(model);
const liveContextTokens = opts.liveContextTokens ?? this._getDownswitchLiveContextTokens(model);
this.agent.state.model = model;
this.agent.abortServerSideFallback =
this.settingsManager.getAbortServerSideFallback() && this._retryFallback.hasConfiguredChain();
Expand All @@ -4676,6 +4791,8 @@ export class AgentSession {

if (opts.ephemeralThinkingLevel !== undefined) {
this._applyEphemeralThinkingLevel(thinking.level);
} else if (opts.persistThinkingLevel === false) {
this._applyEphemeralThinkingLevel(thinking.level);
} else {
this._setThinkingLevel(thinking.level, false, thinking.selection);
}
Expand All @@ -4694,13 +4811,38 @@ export class AgentSession {
if (!opts.emitModelSelect) return undefined;
const previousSystemPrompt = this.agent.state.systemPrompt;
try {
const systemPromptChange = await this._emitModelSelect(model, previousModel, opts.modelSelectSource);
const systemPromptChange = await this._emitModelSelect(
model,
previousModel,
opts.modelSelectSource,
!opts.transactionalModelSelect,
opts.transactionalModelSelect,
);
this.assertModelUsable(model, liveContextTokens);
const committedSystemPromptChange = opts.transactionalModelSelect

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a recovery hook returns the same prompt on both passes, the provisional pass consumes the prompt transition and the committed pass emits no system_prompt_change. Restore the pre-probe prompt before the committed pass, or otherwise publish the committed transition against the original prompt.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/agent-session.ts, line 4822:

<comment>When a recovery hook returns the same prompt on both passes, the provisional pass consumes the prompt transition and the committed pass emits no `system_prompt_change`. Restore the pre-probe prompt before the committed pass, or otherwise publish the committed transition against the original prompt.</comment>

<file context>
@@ -4814,18 +4816,24 @@ export class AgentSession {
+				opts.transactionalModelSelect,
 			);
 			this.assertModelUsable(model, liveContextTokens);
+			const committedSystemPromptChange = opts.transactionalModelSelect
+				? await this._emitModelSelect(model, previousModel, opts.modelSelectSource, false)
+				: systemPromptChange;
</file context>

? await this._emitModelSelect(model, previousModel, opts.modelSelectSource, false)
: systemPromptChange;
this.assertModelUsable(model, liveContextTokens);
return systemPromptChange;
flushDeferredEvents();
if (committedSystemPromptChange && opts.transactionalModelSelect) {
await this._publishSystemPromptChange(committedSystemPromptChange);
}
return committedSystemPromptChange;
} catch (error) {
if (previousModel) this.agent.state.model = previousModel;
else delete (this.agent.state as { model?: Model<Api> }).model;
this.agent.state.systemPrompt = previousSystemPrompt;
if (snapshot) {
restoreSnapshot();
try {
if (previousModel)
await this._emitModelSelect(previousModel, model, opts.modelSelectSource, false, true);
} finally {
restoreSnapshot();
}
} else {
if (previousModel) this.agent.state.model = previousModel;
else delete (this.agent.state as { model?: Model<Api> }).model;
this.agent.state.systemPrompt = previousSystemPrompt;
}
if (ownsDeferredEvents) this._deferredSessionEvents = undefined;
throw error;
}
}
Expand Down
35 changes: 35 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
# changes

## 2026-09-01 - Recover oversized saved sessions onto a usable model

### What changed

- `sdk.ts` now distinguishes an implicit saved-model restore from an explicit
startup model selection when the assembled runtime fails the live-context
usability projection.
- Implicit restores deterministically select the authenticated candidate with

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The doc claims recovery 'select[s] the authenticated candidate with the greatest remaining context budget' and that a session with 'no capable authenticated recovery model' keeps the typed ModelUsabilityBudgetError. In the code, findStartupRecoveryModel() selects purely by remaining budget from configured (not authenticated) models, and setStartupRecoveryModel() throws a plain No API key Error when the winner lacks auth — it never falls back to the next authenticated candidate, and the typed error is not preserved on that path. Make the recovery selection auth-filtered (or fall back to the next authenticated candidate) so the doc and the promised stack-free, typed failure match the behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/changes.md, line 10:

<comment>The doc claims recovery 'select[s] the authenticated candidate with the greatest remaining context budget' and that a session with 'no capable authenticated recovery model' keeps the typed ModelUsabilityBudgetError. In the code, findStartupRecoveryModel() selects purely by remaining budget from configured (not authenticated) models, and setStartupRecoveryModel() throws a plain `No API key` Error when the winner lacks auth — it never falls back to the next authenticated candidate, and the typed error is not preserved on that path. Make the recovery selection auth-filtered (or fall back to the next authenticated candidate) so the doc and the promised stack-free, typed failure match the behavior.</comment>

<file context>
@@ -1,5 +1,33 @@
+- `sdk.ts` now distinguishes an implicit saved-model restore from an explicit
+  startup model selection when the assembled runtime fails the live-context
+  usability projection.
+- Implicit restores deterministically select the authenticated candidate with
+  the greatest remaining context budget, persist that change only in the
+  session history, and return a visible fallback notice.
</file context>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b5d6c84 and documented in the trackers: candidates are budget-ranked, then provider auth is checked in that order; unavailable providers are skipped and deduplicated before the next capable candidate is selected.

the greatest remaining context budget, persist that change only in the
session history, and return a visible fallback notice.
- Recovery validates candidate credentials in budget order, skips unavailable
providers, and defers both model and model-specific thinking persistence
until extension admission succeeds.
- If a model-select hook makes the highest-capacity candidate unusable, startup
rolls back its runtime state before continuing through the remaining
budget-ranked candidates; Claude SDK continuity ignores provisional candidate
probes and provider authentication is cached per recovery.
- Explicit startup models remain fail-closed, and sessions with no capable
authenticated recovery model keep the typed `ModelUsabilityBudgetError`.

### Why

- The restored-transcript admission guard correctly prevented undersized model
switches, but it also made long existing sessions impossible to reopen from
the normal session picker even when a larger configured model was available.

### Why an extension could not handle it

- Model restoration and the first usability assertion happen before extensions
receive a live session surface.

### Expected merge conflict zones

- MEDIUM: `sdk.ts` startup model selection and final usability assertion.

## 2026-08-31 - Session activity contract for host occupancy decisions

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export function registerSessionRegistry(
await invalidateBinding(pi, ctx, "tree_changed");
});
pi.on("model_select", async (event, ctx) => {
if (event.provisional) return;
const sessionId = ctx.sessionManager.getSessionId();
if (event.model?.provider !== CLAUDE_SDK_OAUTH_PROVIDER_ID) {
closeSession(sessionId, "model_selected");
Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent/src/core/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,11 @@ export interface ModelSelectEvent {
model: Model<any>;
previousModel: Model<any> | undefined;
source: ModelSelectSource;
/**
* A startup-recovery candidate probe. Handlers may adjust in-memory prompt or
* tool state, but must defer external side effects until the committed event.
*/
provisional?: boolean;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This public API addition to ModelSelectEvent (the new provisional field) is not recorded in extensions/changes.md, which the extensions AGENTS.md requires for every types.ts public-API change with no exceptions. Add a section documenting the new provisional probe field and its side-effect deferral contract, including the expected merge-conflict zone.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/extensions/types.ts, line 1132:

<comment>This public API addition to `ModelSelectEvent` (the new `provisional` field) is not recorded in `extensions/changes.md`, which the extensions AGENTS.md requires for every types.ts public-API change with no exceptions. Add a section documenting the new `provisional` probe field and its side-effect deferral contract, including the expected merge-conflict zone.</comment>

<file context>
@@ -1125,6 +1125,11 @@ export interface ModelSelectEvent {
+	 * A startup-recovery candidate probe. Handlers may adjust in-memory prompt or
+	 * tool state, but must defer external side effects until the committed event.
+	 */
+	provisional?: boolean;
 	/** The active system prompt before model_select handlers run. */
 	systemPrompt: string;
</file context>

/** The active system prompt before model_select handlers run. */
systemPrompt: string;
/** Structured options used to build the base system prompt. */
Expand Down
Loading