Ship the batch: undo, parallel subagents, lazy MCP, hot-reloaded skills; docs and CI/CD

Loop and ergonomics batch across Now/Next and Maintenance:
- /undo and /redo via pre-prompt file snapshots (snapshot.ts)
- task takes a tasks[] array and runs investigations concurrently (subagent.ts)
- lazy MCP tools: mcp_list/mcp_inspect/mcp_call meta-tools, eager opt-in (mcp.ts, config.ts)
- skill tool reads its list live so a mid-session install is callable next turn (skills.ts)
- tool-name lists (tool-kinds.ts) derived from a mutating() marker; gates previously ungated writes
- prune/session recovery path summarized, and step-back doom-loop primitive (step-back.ts)
- @file completion re-walks on a slow cooldown; estimateTokens and pricing labeled as estimates

Docs: README, CHANGELOG, docs/{mcp,architecture,development} updated to match.
CI/CD: bun install-store cache and concurrency gates on both workflows; release.yml now
composes file-based release notes via scripts/make-release-notes.ts and verifies every binary.
This commit is contained in:
Muhammad Zakir Ramadhan
2026-09-17 17:49:19 +07:00
parent ffa9a02c26
commit f8c3cc2d8e
57 changed files with 3808 additions and 573 deletions
+270 -4
View File
@@ -17,7 +17,9 @@ import { Permissions, type PermissionConfig } from './permission';
import type { PluginHost } from './plugins';
import { costOf, formatUsd } from './pricing';
import { systemPrompt } from './prompt';
import { detachProviderItems, pruneToFit } from './prune';
import { detachProviderItems, digestOf, droppedBy, isPrunedSpanSummary, prunedSpanMessage, pruneToFit } from './prune';
import { restore, Snapshots, type TurnSnapshot } from './snapshot';
import { createStepBackTool, type LoopEntry } from './step-back';
import { createSkillTool, renderSkills, type Skill } from './skills';
import { disabledToolNames, onBashOutput, tools as builtinTools, type ToolSetName } from './tools';
@@ -38,6 +40,21 @@ export type ApprovalRequest = {
/** 'once' runs this call only; 'always' whitelists the suggested pattern for the session. */
export type ApprovalDecision = 'once' | 'always' | 'deny';
/** What an undo did, so the UI can say which files moved and which did not. */
export type UndoResult = {
snapshot: TurnSnapshot;
restored: string[];
removed: string[];
conversationTrimmed: boolean;
};
export type RedoResult = {
snapshot: TurnSnapshot;
/** False when files were asked for: only pre-images are ever captured. */
filesRestored: boolean;
what: 'both' | 'files' | 'conversation';
};
export type AgentEvent =
| { type: 'text'; text: string }
| { type: 'reasoning'; text: string }
@@ -74,6 +91,8 @@ export type SessionOptions = {
autoApprove?: readonly string[];
/** Prune the history once the estimated token count crosses this. */
compactThreshold?: number;
/** Identical calls to an allowed tool before it is asked about anyway. Default 3. */
repeatLimit?: number;
/** Retries per model call for transient failures. */
maxRetries?: number;
/** AGENTS.md-style files appended to the system prompt. */
@@ -94,6 +113,12 @@ export type SessionOptions = {
onNotebookChange?: (state: NotebookState) => void;
};
/**
* Length-based token estimate for deciding *when to prune*, not for billing.
* JSON char count / 4 approximates token count closely enough to gate compaction,
* but real billed tokens come from the SDK's reported usage (`inputTokens`), never
* from here. `/cost` and the budget ceiling use the SDK figure.
*/
const estimateTokens = (messages: ModelMessage[]) => Math.round(JSON.stringify(messages).length / 4);
/** Estimated tokens at which the wire history is pruned. */
@@ -102,6 +127,26 @@ const DEFAULT_COMPACT_THRESHOLD = 120_000;
/** Identical calls in one turn before an allowed tool is asked about anyway. */
const REPEAT_LIMIT = 3;
/**
* Squashes a tool result into a few characters for the loop trace.
*
* The trace is fed back to the model verbatim, so a 30 KB read_file output would
* fill the reflection with noise. A short string keeps `step_back` honest about
* what happened without flooding the next context window.
*/
function summarizeToolResult(output: unknown): string {
if (typeof output === 'string') return output.length <= 80 ? output : `${output.slice(0, 80)}…(${output.length} chars)`;
try {
const json = JSON.stringify(output);
return json.length <= 80 ? json : `${json.slice(0, 80)}…`;
} catch {
return String(output);
}
}
/** Ceiling on an injected span summary, so the summary cannot defeat the compaction. */
const MAX_SPAN_SUMMARY_CHARS = 1_200;
const callKey = (toolName: string, input: unknown) => `${toolName}:${JSON.stringify(input ?? null)}`;
/**
@@ -121,6 +166,8 @@ export class Session {
readonly messages: ModelMessage[];
readonly tools: ToolSet;
readonly notebook: Notebook;
/** Pre-images of files this session's turns have changed, newest last. */
readonly snapshots: Snapshots;
inputTokens = 0;
outputTokens = 0;
/** Subagent token use, priced against the subagent's own model id in /cost. */
@@ -136,6 +183,14 @@ export class Session {
/** The 80% spend warning is shown once, not on every turn past the line. */
private warnedSpend = false;
private controller: AbortController | undefined;
/** Tools this turn used that no snapshot can cover, reported when the turn ends. */
private readonly uncoveredTools = new Set<string>();
/** The snapshot the last undo removed, so `/redo` can put it back. */
private lastUndone: TurnSnapshot | undefined;
/** Every tool call this turn, input + outcome, for the loop-detection tool to reflect on. */
private readonly loopTrace: LoopEntry[] = [];
/** toolCallId -> { toolName, input }, so a result can be paired with its call. */
private readonly callInputs = new Map<string, { toolName: string; input: string }>();
constructor(private readonly opts: SessionOptions) {
this.messages = opts.messages ?? [];
@@ -143,12 +198,19 @@ export class Session {
this.notebook.restore(opts.notebook);
this.model = opts.model;
this.variant = opts.agent ?? DEFAULT_VARIANT;
this.snapshots = new Snapshots(opts.cwd ?? process.cwd());
const sessionTools = {
...this.notebook.tools(),
...(opts.memory ? opts.memory.tools() : {}),
...(opts.skills && opts.skills.length > 0 ? { skill: createSkillTool(opts.skills) } : {}),
// Always registered, even with no skills, so a mid-session install of the
// first skill is callable next turn without a session rebuild. The tool's
// description reads the live list and says "none" when it is empty.
skill: createSkillTool(() => this.opts.skills ?? []),
...(opts.ask ? { ask: createAskTool(opts.ask) } : {}),
step_back: createStepBackTool({
trace: () => this.loopTrace,
}),
};
this.tools = { ...builtinTools, ...sessionTools, ...(opts.plugins?.tools ?? {}), ...(opts.extraTools ?? {}) };
@@ -306,6 +368,26 @@ export class Session {
return count;
}
/**
* Appends a completed tool call to the loop trace, pairing it with its input.
*
* The SDK streams `tool-result` without the input that produced it, so the input is
* kept alongside on the `tool-call` part. A `step_back` call needs this pairing to
* say *which* call produced *which* outcome.
*/
private recordTrace(toolCallId: string, result: string): void {
const call = this.callInputs.get(toolCallId);
this.callInputs.delete(toolCallId);
if (!call) return;
this.loopTrace.push({
step: this.loopTrace.length + 1,
toolName: call.toolName,
input: call.input,
result,
at: new Date().toISOString(),
});
}
/**
* Approval decisions, evaluated per call by the SDK.
*
@@ -326,6 +408,11 @@ export class Session {
return async ({ toolCall }: { toolCall: { toolName: string; input: unknown } }) => {
const { toolName, input } = toolCall;
// Before anything else, because the guard may deny the call and because a
// later hook must not be able to move the capture after the write.
const { covered } = await this.snapshots.captureFor(toolName, input);
if (!covered) this.uncoveredTools.add(toolName);
const blocked = await this.opts.plugins?.guard({
toolName,
input,
@@ -346,7 +433,18 @@ export class Session {
}
const repeats = this.repeatCount(toolName, input);
if (decision === 'allow' && repeats < REPEAT_LIMIT) return undefined;
const limit = this.opts.repeatLimit ?? REPEAT_LIMIT;
if (decision === 'allow' && repeats < limit) return undefined;
if (decision === 'allow') {
// Repeated three times with a permission that says `allow`: the model is
// looping, not asking, and it should stop and look at the trace rather than
// burn another approval. This is the point the step_back tool exists for.
notices.push(
`You have called ${toolName} with the same input ${repeats + 1} times this turn. It is not making progress. ` +
`Use step_back to reflect on what changed between attempts, then try a different approach or stop.`,
);
}
why.set(callKey(toolName, input), {
...(pattern ? { matchedPattern: pattern } : {}),
@@ -378,6 +476,92 @@ export class Session {
return { before, after: this.messages.length };
}
/**
* Walks the last turn back: files, conversation, or both.
*
* The three-way split is the point. Restoring files without the conversation leaves
* the model believing edits are on disk that are not, so its next turn is built on a
* state that no longer exists — it re-reads a file expecting its own change and finds
* the original, which reads to the model as the change having been rejected. Restoring
* the conversation without the files is the mirror: the model forgets it made an edit
* that is still there. So the default is both, and the caller can narrow it.
*
* Returns undefined when there is nothing to undo, which the UI reports as such
* rather than as a failure.
*/
async undo(what: 'both' | 'files' | 'conversation' = 'both'): Promise<UndoResult | undefined> {
const snap = this.snapshots.pop();
if (!snap) return undefined;
const files = what === 'conversation' ? { restored: [], removed: [] } : await restore(snap, this.snapshots.cwdOf());
if (what !== 'files') this.trimTo(snap.messageCount);
this.lastUndone = snap;
return { snapshot: snap, ...files, conversationTrimmed: what !== 'files' };
}
/**
* Puts back what `undo` took, without a second snapshot.
*
* A redo cannot restore file content from the session, because the content that
* existed after the turn was never captured — only the pre-image was. So a redo of
* the files is declined honestly rather than approximated: the whole point of undo
* is that the user trusts what it says it did. The conversation is restored from the
* snapshot's own record, which is exact.
*/
redo(what: 'both' | 'files' | 'conversation' = 'conversation'): RedoResult | undefined {
const snap = this.lastUndone;
if (!snap) return undefined;
this.snapshots.push(snap);
this.lastUndone = undefined;
return { snapshot: snap, filesRestored: false, what };
}
undoable(): readonly TurnSnapshot[] {
return this.snapshots.list();
}
private trimTo(length: number): void {
if (this.messages.length <= length) return;
this.messages.length = Math.max(0, length);
this.opts.onChange?.(this.messages);
}
/**
* Closes the open snapshot and reports what the turn could not cover.
*
* Called before every `done`, not from `send`'s `finally`, because a notice that
* arrives after `done` is a notice the UI has already stopped listening for —
* `done` is what a consumer treats as the end of the turn and stops on.
*
* Two reasons to speak, and the second does not depend on the first: a turn with no
* snapshotted edits can still have run `bash` and changed the tree, which is exactly
* when the warning matters most.
*/
private *closingNotices(): Generator<AgentEvent> {
const snapshot = this.snapshots.commit();
const uncovered = [...this.uncoveredTools];
if (uncovered.length === 0) return;
const covered = snapshot ? `${snapshot.files.length} file(s) changed this turn and can be undone with /undo. ` : '';
yield {
type: 'notice',
text: `${covered}${uncovered.join(', ')} ran this turn and cannot be snapshotted, so any changes it made will not be undone.`,
};
}
/**
* Swaps the live skill list at a turn boundary.
*
* The `skill` tool and the system-prompt catalogue both read the list on each
* call, so replacing it here is atomic across the two and takes effect on the
* next turn with no session rebuild. The caller is responsible for only doing
* this between turns — a turn in flight already holds its rules.
*/
setSkills(skills: Skill[]): void {
this.opts.skills = skills;
}
async *send(userText: string): AsyncGenerator<AgentEvent> {
// The ceiling is checked before the model is: a turn started past the limit
// would spend money the caller said not to. An unpriced model cannot be
@@ -402,7 +586,12 @@ export class Session {
// Per turn, not per step: a tool called once in each of three steps is the
// loop this guards against.
this.seen.clear();
this.uncoveredTools.clear();
this.loopTrace.length = 0;
this.staleItemsRepaired = false;
// Opened before the model runs and closed after it stops, so every write the
// turn makes lands in one snapshot the user can walk back to.
this.snapshots.begin(userText, this.messages.length);
const outputs: Extract<AgentEvent, { type: 'tool-output' }>[] = [];
onBashOutput(({ toolCallId, chunk }) => {
@@ -414,6 +603,10 @@ export class Session {
yield* this.run(signal, threshold, outputs);
} finally {
onBashOutput(undefined);
// Belt and braces: closingNotices runs before every `done`, but an aborted turn
// can return through a path that never reached it, and an uncommitted snapshot
// would then be silently dropped rather than kept.
this.snapshots.commit();
await this.opts.plugins?.afterTurn();
}
}
@@ -431,6 +624,70 @@ export class Session {
return true;
}
/**
* Prunes the canonical history once a run has landed.
*
* prepareStep only trims the wire copy for the next request; without this write-back
* the stored history keeps growing, the context meter pins at 100%, and every later
* turn re-prunes the same messages from scratch.
*/
private async compactCanonical(threshold: number): Promise<{ before: number; after: number } | null> {
const before = this.messages.length;
if (estimateTokens(this.messages) <= threshold) return null;
const pruned = pruneToFit({ messages: this.messages, threshold, estimate: estimateTokens });
if (pruned.length === before) return null;
const dropped = droppedBy(this.messages, pruned);
const summary = await this.summarizeSpan(dropped.filter((m) => !isPrunedSpanSummary(m)));
this.replace(this.withSummarizedSpan(summary, pruned, dropped));
return { before, after: this.messages.length };
}
/**
* Puts a summary of the discarded span at the head of the history it was dropped from.
*
* Without this the model is told which tool results to keep and nothing about what
* was dropped, so it states a decision it made forty messages ago as though it had
* never made it.
*
* The summary is bounded two ways because a summary that grows with the session
* defeats the point of compacting at all: the input is capped at the span's own
* digest, and the output is capped by instruction and by hard truncation. A failed
* or empty call falls back to the digest, which costs nothing and still carries
* which tool touched which path — the part a contradiction is usually built from.
*/
private async summarizeSpan(dropped: readonly ModelMessage[]): Promise<string | undefined> {
const digest = digestOf(dropped);
if (!digest) return undefined;
try {
const { text } = await generateText({
model: this.model,
system:
'These lines are the condensed record of an earlier part of a coding session that has been ' +
'compacted out of the conversation. Write at most 120 words of notes capturing decisions made, ' +
'files touched, commands run and their outcome, and anything still pending. State only what the ' +
'lines support; do not invent detail and do not address the reader.',
messages: [{ role: 'user', content: digest }],
maxRetries: this.opts.maxRetries ?? 3,
});
const trimmed = text.trim();
return trimmed ? trimmed.slice(0, MAX_SPAN_SUMMARY_CHARS) : undefined;
} catch {
// A summarizer that cannot run must not cost the turn its compaction: the
// digest is a worse record, not an absent one.
return undefined;
}
}
private withSummarizedSpan(summary: string | undefined, pruned: ModelMessage[], dropped: readonly ModelMessage[]): ModelMessage[] {
const worthSummarizing = dropped.filter((m) => !isPrunedSpanSummary(m));
if (worthSummarizing.length === 0) return pruned;
const prior = dropped.filter(isPrunedSpanSummary);
const spanMessage = prunedSpanMessage(summary, worthSummarizing);
if (!spanMessage) return pruned;
return [...prior, spanMessage, ...pruned];
}
private async *run(
signal: AbortSignal,
threshold: number,
@@ -506,12 +763,15 @@ export class Session {
yield { type: 'tool-start', id: part.id, name: part.toolName };
break;
case 'tool-call':
this.callInputs.set(part.toolCallId, { toolName: part.toolName, input: JSON.stringify(part.input ?? null) });
yield { type: 'tool-call', id: part.toolCallId, name: part.toolName, input: part.input };
break;
case 'tool-result':
this.recordTrace(part.toolCallId, summarizeToolResult(part.output));
yield { type: 'tool-result', id: part.toolCallId, name: part.toolName, output: part.output };
break;
case 'tool-error':
this.recordTrace(part.toolCallId, `error: ${part.error instanceof Error ? part.error.message : String(part.error)}`);
yield { type: 'tool-error', id: part.toolCallId, name: part.toolName, error: part.error };
break;
case 'tool-approval-request': {
@@ -535,6 +795,7 @@ export class Session {
yield { type: 'tool-denied', name: part.toolName };
break;
case 'abort':
yield* this.closingNotices();
yield { type: 'done' };
return;
case 'error':
@@ -555,6 +816,7 @@ export class Session {
}
} catch (error) {
if (signal.aborted) {
yield* this.closingNotices();
yield { type: 'done' };
return;
}
@@ -579,7 +841,10 @@ export class Session {
while (guardNotices.length > 0) yield { type: 'notice', text: guardNotices.shift()! };
this.messages.push(...(await result.responseMessages));
this.opts.onChange?.(this.messages);
// prepareStep already emits `compacted` at the same threshold crossing, and
// replace() fires onChange on the fold path; both would double up otherwise.
if (!(await this.compactCanonical(threshold))) this.opts.onChange?.(this.messages);
if (pending.length === 0) {
const usage = await result.usage;
@@ -595,6 +860,7 @@ export class Session {
text: `approaching spend ceiling: ${formatUsd(spend.usd ?? 0)} of ${formatUsd(spend.ceiling ?? 0)} used`,
};
}
yield* this.closingNotices();
yield { type: 'done', inputTokens: usage.inputTokens, outputTokens: usage.outputTokens };
return;
}