Skip to content
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,12 @@ picker to return to input selection, then `Esc` again to close rewind.
/cls clear the visible transcript only
```

New sessions are saved when you submit their first input or explicitly rename them.
Exiting an untouched session does not save it or print a resume command. Renaming
an empty session saves it immediately, including its custom title, so it can be
resumed before its first turn. Resume commands are shown only for saved sessions;
an invalid resume target is rejected without replacing the current session.

`/cls` clears what the TUI displays without touching the session. The
runtime's own `/clear` is an alias of `/new` and starts a fresh session, so it
is forwarded to the runtime unchanged.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
"test:node": "node --test test/node/*.test.cjs",
"test:tui": "bun run test:tui:component && bun run test:tui:e2e",
"test:tui:component": "bun test test/tui/scenario-http.test.ts test/tui/scenario-runtime.test.ts test/tui/scenario-shell.test.ts test/tui/scenario-workspace.test.ts test/tui/terminal-screen.test.ts",
"test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/input-actions.test.ts test/tui/model-resume.test.ts test/tui/model-status.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-rename.test.ts test/tui/startup.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts",
"test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/input-actions.test.ts test/tui/model-resume.test.ts test/tui/model-status.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-lifecycle.test.ts test/tui/session-rename.test.ts test/tui/startup.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts",
"test:tui:host": "bun test test/tui/scenario-mountx.test.ts",
"test:tui:manual": "bun scripts/tui-scenario.ts --manual",
"test:tui-scenario": "bun scripts/tui-scenario.ts",
Expand Down
24 changes: 21 additions & 3 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5993,6 +5993,22 @@ class ZCodeTui {
if (usage !== undefined) this.applySessionUsage(usage);
}

private async readExitResumeTarget(): Promise<string | undefined> {
const readSessionResumeTarget = this.options.readSessionResumeTarget;
if (!readSessionResumeTarget) return undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
Promise.resolve().then(() => readSessionResumeTarget()).catch(() => undefined),
new Promise<undefined>((resolve) => {
timeout = setTimeout(() => resolve(undefined), exitUsageQueryTimeoutMs);
})
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}

private finishTurn(unfinishedToolState = "interrupted"): void {
const notification = this.pendingTurnNotification;
const notificationDetail = notification === "completed"
Expand All @@ -6008,7 +6024,9 @@ class ZCodeTui {
if (unfinishedToolState === "cancelled") {
this.turnWork.cancel();
this.settleTurnTiming();
} else if (!this.turnWork.finishForeground(Boolean(this.options.readRuntimeProjection))) {
} else if (!this.turnWork.finishForeground(
Boolean(this.options.readRuntimeProjection) && unfinishedToolState !== "failed"
)) {
this.settleTurnTiming();
}
this.activity = undefined;
Expand Down Expand Up @@ -6093,11 +6111,11 @@ class ZCodeTui {

private async finishStop(elapsedMilliseconds: number): Promise<void> {
try {
await this.refreshExitUsage();
const [, resumeTarget] = await Promise.all([this.refreshExitUsage(), this.readExitResumeTarget()]);
const summary = buildExitSummary({
elapsedMilliseconds,
metrics: this.sessionMetrics,
sessionId: this.sessionId ?? this.runtimeProjection?.sessionId,
sessionId: resumeTarget,
width: this.ui.terminal.columns
});
const lines = [
Expand Down
3 changes: 2 additions & 1 deletion packages/zcode-tui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export interface RuntimeAdapter {
readTodos?: () => Promise<unknown>;
readRuntimeProjection?: () => Promise<unknown>;
readSessionUsage?: () => Promise<unknown>;
readSessionResumeTarget?: () => Promise<string | undefined>;
cancelBackgroundTask?: (taskId: string) => Promise<unknown>;
sendBackgroundTaskMessage?: (options: {
taskId: string;
Expand All @@ -113,7 +114,7 @@ export interface RuntimeAdapter {
) => Promise<unknown>;
submitPrompt: (input: unknown, options: PromptCallOptions) => Promise<unknown>;
setMode?: (mode: string) => Promise<unknown>;
/** Persists a user-owned session title (`title_source='custom'`). */
/** Saves the session if needed, then persists a user-owned title (`title_source='custom'`). */
setCustomSessionTitle?: (options: { title: string; traceContext?: unknown }) => Promise<unknown>;
/** Returns the current session's custom title, or undefined for other title sources. */
readCustomSessionTitle?: () => Promise<unknown>;
Expand Down
57 changes: 57 additions & 0 deletions scripts/runtime-session-lifecycle-patches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const identifier = "[A-Za-z_$][\\w$]*";
const helper = 'require(require("node:path").join(__dirname,"cli-config.cjs"))';

function escape(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}

function unique(source: string, pattern: RegExp, name: string): RegExpMatchArray {
const matches = [...source.matchAll(new RegExp(pattern.source, "gu"))];
if (matches.length !== 1) throw new Error(`ZCode runtime is incompatible with session lifecycle (${name} missing or ambiguous).`);
return matches[0]!;
}

export function hasRuntimeSessionLifecycle(runtime: string): boolean {
return runtime.includes(`${helper}.ensureSessionForTitle(this,`)
&& runtime.includes("$zValidatedResumeSession=")
&& runtime.includes("$zResumeCandidateError")
&& runtime.includes("$zTuiResumeReady=")
&& runtime.includes("$zTuiResumeOptions=")
&& runtime.includes('"readSessionResumeTarget"');
}

export function patchRuntimeSessionLifecycle(runtime: string): string {
if (hasRuntimeSessionLifecycle(runtime)) return runtime;
let patched = runtime;
if (!patched.includes(`${helper}.ensureSessionForTitle(this,`)) {
const title = unique(patched, new RegExp(`${identifier}\\((${identifier}),"setCustomSessionTitle"\\)`, "u"), "custom title method");
const prefix = unique(patched, new RegExp(`async function ${escape(title[1]!)}\\((${identifier})\\)\\{`, "u"), "custom title function");
patched = patched.replace(prefix[0], `${prefix[0]}await ${helper}.ensureSessionForTitle(this,${prefix[1]});`);
}
if (!patched.includes("$zValidatedResumeSession=")) {
const store = unique(patched, new RegExp(`(${identifier})=(${identifier})\\.sessionStore===void 0,(${identifier})=\\2\\.sessionStore\\?\\?await ${identifier}\\(${identifier},${identifier}\\),`, "u"), "app session store");
patched = patched.replace(store[0], `${store[0]}$zValidatedResumeSession=${store[2]}.resume?await ${helper}.assertResumableSession(${store[3]},${store[2]}.sessionId).catch(async $zResumeStoreError=>{if(${store[1]})await ${store[3]}.close();throw $zResumeStoreError}):void 0,`);
}
if (!patched.includes('"readSessionResumeTarget"')) {
const facade = unique(patched, new RegExp(`setCustomSessionTitle:(${identifier})\\(async (${identifier})=>(${identifier})\\.runtime\\.setCustomSessionTitle\\(\\{title:\\2\\.title,traceContext:\\2\\.traceContext\\?\\?\\3\\.traceContext\\}\\),"setCustomSessionTitle"\\)`, "u"), "app title facade");
patched = patched.replace(facade[0], `${facade[0]},readSessionResumeTarget:${facade[1]}(async()=>await ${helper}.readSessionResumeTarget(${facade[3]}),"readSessionResumeTarget")`);
}
if (!patched.includes("$zResumeCandidateError")) {
const switchApp = unique(patched, new RegExp(`(${identifier})\\(async (${identifier})=>\\{let (${identifier})=(${identifier}),(${identifier})=await \\2\\(\\);return \\4=\\5,\\3&&\\3!==\\5&&await (${identifier})\\(\\3\\),`, "u"), "app replacement");
const createApp = unique(patched, new RegExp(`return (${identifier})\\.current=(${identifier})\\((${identifier}),${identifier}\\(\\1\\)\\),\\3\\},"createApp"\\)`, "u"), "candidate app publication");
patched = patched.replace(switchApp[0], switchApp[0].replace(";return ", `;try{await ${switchApp[5]}.readExecutionState()}catch($zResumeCandidateError){await ${switchApp[6]}(${switchApp[5]});throw $zResumeCandidateError}return `));
patched = patched.replace(createApp[0], `return ${createApp[3]}},"createApp")`);
}
if (!patched.includes("$zTuiResumeReady=")) {
const cleanup = unique(patched, new RegExp(`cleanup:${identifier}\\(async\\(\\)=>\\{await (${identifier})\\.close\\?\\.\\(\\)\\},"cleanup"\\)`, "u"), "TUI app cleanup");
const options = unique(patched, new RegExp(`${escape(cleanup[1]!)}=${identifier}\\(${identifier},${identifier},${identifier},(${identifier}),${identifier}\\.locale,`, "u"), "TUI resume options");
const startTui = unique(patched, new RegExp(`try\\{return await (${identifier})\\(\\{loadStartupOptions:`, "u"), "TUI startup");
patched = patched.replace(startTui[0], `try{let $zTuiResumeOptions=${options[1]},$zTuiResumeReady=($zTuiResumeOptions.resumeSessionId||$zTuiResumeOptions.continueSession)?await ${cleanup[1]}.readExecutionState?.():void 0;return await ${startTui[1]}({loadStartupOptions:`);
} else if (!patched.includes("$zTuiResumeOptions=")) {
const cleanup = unique(patched, new RegExp(`cleanup:${identifier}\\(async\\(\\)=>\\{await (${identifier})\\.close\\?\\.\\(\\)\\},"cleanup"\\)`, "u"), "TUI app cleanup");
const options = unique(patched, new RegExp(`${escape(cleanup[1]!)}=${identifier}\\(${identifier},${identifier},${identifier},(${identifier}),${identifier}\\.locale,`, "u"), "TUI resume options");
patched = patched.replace(`let $zTuiResumeReady=await ${cleanup[1]}.readExecutionState?.();`, `let $zTuiResumeOptions=${options[1]},$zTuiResumeReady=($zTuiResumeOptions.resumeSessionId||$zTuiResumeOptions.continueSession)?await ${cleanup[1]}.readExecutionState?.():void 0;`);
}
if (!hasRuntimeSessionLifecycle(patched)) throw new Error("Session lifecycle patch failed postcondition verification.");
return patched;
}
14 changes: 14 additions & 0 deletions scripts/sync-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
} from "../src/runtime-capabilities.ts";
import { parseReleaseVersion, syncedReleaseVersion } from "./release-version.ts";
import { markRuntimeModified } from "./runtime-attribution.ts";
import { hasRuntimeSessionLifecycle, patchRuntimeSessionLifecycle } from "./runtime-session-lifecycle-patches.ts";
export { hasRuntimeSessionLifecycle, patchRuntimeSessionLifecycle } from "./runtime-session-lifecycle-patches.ts";
import { hasRuntimeBigmodelOAuth, patchRuntimeBigmodelOAuth } from "./runtime-bigmodel-oauth-patches.ts";
import { hasRuntimeSharedOAuth, patchRuntimeSharedOAuth } from "./runtime-shared-oauth-patches.ts";
export { hasRuntimeSharedOAuth, patchRuntimeSharedOAuth } from "./runtime-shared-oauth-patches.ts";
Expand Down Expand Up @@ -585,6 +587,7 @@ export function patchRuntimeTuiBridge(runtime: string): string {
&& runtime.includes(".applyFileRewind=async e=>")
&& runtime.includes(".setCustomSessionTitle=async e=>")
&& runtime.includes(".readCustomSessionTitle=async()=>")
&& runtime.includes(".readSessionResumeTarget=async()=>")
&& runtime.includes(interruptTurnMarker)
&& runtime.includes(interruptWaitForIdleMarker)
&& runtime.includes(".promoteQueuedInput=async(")
Expand All @@ -604,6 +607,7 @@ export function patchRuntimeTuiBridge(runtime: string): string {
&& /applyFileRewind:[A-Za-z_$][\w$]*\.applyFileRewind/u.test(runtime)
&& /setCustomSessionTitle:[A-Za-z_$][\w$]*\.setCustomSessionTitle/u.test(runtime)
&& /readCustomSessionTitle:[A-Za-z_$][\w$]*\.readCustomSessionTitle/u.test(runtime)
&& /readSessionResumeTarget:[A-Za-z_$][\w$]*\.readSessionResumeTarget/u.test(runtime)
&& /interruptTurn:[A-Za-z_$][\w$]*\.interruptTurn/u.test(runtime)
&& /promoteQueuedInput:[A-Za-z_$][\w$]*\.promoteQueuedInput/u.test(runtime)
&& /readSessionUsage:[A-Za-z_$][\w$]*\.readSessionUsage/u.test(runtime)
Expand Down Expand Up @@ -825,6 +829,9 @@ export function patchRuntimeTuiBridge(runtime: string): string {
if (!patched.includes(".readCustomSessionTitle=async()=>")) {
assignments.push(`${bridge}.readCustomSessionTitle=async()=>{let t=await ${getApp}(),o=t.sessionStore??t.runtime?.sessionStore,r=await o?.getSession?.(t.sessionId);return r?.titleSource==="custom"&&typeof r.title==="string"?r.title:void 0}`);
}
if (!patched.includes(".readSessionResumeTarget=async()=>")) {
assignments.push(`${bridge}.readSessionResumeTarget=async()=>await(await ${getApp}()).readSessionResumeTarget?.()`);
}
// The upstream CLI shim turns every switch into a permanent --mode override,
// which prevents later resumes from loading their saved execution state.
if (!queryHelper) patched = patched.replace(cliModeOverrideBridge, `${bridge}.setMode=async e=>{return await(await ${getApp}()).setMode(e)}`);
Expand Down Expand Up @@ -938,6 +945,9 @@ export function patchRuntimeTuiBridge(runtime: string): string {
if (!/readCustomSessionTitle:[A-Za-z_$][\w$]*\.readCustomSessionTitle/u.test(patched)) {
optionFields.push(`readCustomSessionTitle:${submitBridge}.readCustomSessionTitle`);
}
if (!/readSessionResumeTarget:[A-Za-z_$][\w$]*\.readSessionResumeTarget/u.test(patched)) {
optionFields.push(`readSessionResumeTarget:${submitBridge}.readSessionResumeTarget`);
}
if (!/interruptTurn:[A-Za-z_$][\w$]*\.interruptTurn/u.test(patched)) {
optionFields.push(`interruptTurn:${submitBridge}.interruptTurn`);
}
Expand Down Expand Up @@ -1757,6 +1767,10 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [
id: "session-model-recovery", requirement: "required", apply: patchRuntimeSessionModelRecovery,
verify: runtime => runtime.includes("$zRestoredSessionModel") && runtime.includes('"readSessionModelState"')
},
{
id: "session-lifecycle", requirement: "required", apply: patchRuntimeSessionLifecycle,
verify: hasRuntimeSessionLifecycle
},
{
id: "official-mcp-availability",
requirement: "optional",
Expand Down
11 changes: 10 additions & 1 deletion src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,17 @@ interface TuiRuntimeDiagnosticState {
initialized: boolean;
path?: string;
writeFailed: boolean;
pendingLine?: string;
sessionError?: string;
}

function appendTuiRuntimeDiagnostic(chunk: Buffer | string, state: TuiRuntimeDiagnosticState): void {
const lines = `${state.pendingLine ?? ""}${chunk.toString()}`.split(/\r?\n/u);
state.pendingLine = lines.pop()?.slice(-1024);
for (const line of lines) {
const missing = /^Error: Session not found: ([A-Za-z0-9_-]{1,512})$/u.exec(line);
if (missing) state.sessionError = `Session not found: ${missing[1]}`;
}
if (state.bytes >= tuiRuntimeLogLimitBytes) return;
const text = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
try {
Expand Down Expand Up @@ -386,7 +394,8 @@ function tuiRuntimeFailureMessage(code: number, state: TuiRuntimeDiagnosticState
const diagnostic = state.path && !state.writeFailed
? ` Diagnostics: ${state.path}`
: " Runtime diagnostics could not be written.";
return `Error: ZCode runtime exited with status ${code}.${diagnostic}\n`;
const message = state.sessionError ?? `ZCode runtime exited with status ${code}`;
return `Error: ${message}.${diagnostic}\n`;
}

async function runRuntime(
Expand Down
1 change: 1 addition & 0 deletions src/runtime-config-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
import { cliSettingsPath, legacyCliConfigPath, providerConfigPath, providerMigrationMarkerPath, readDesktopSettings } from "./config-paths.ts";

export { assertSessionModelReady, readSessionModelState } from "./session-model-recovery.ts";
export { assertResumableSession, ensureSessionForTitle, readSessionResumeTarget } from "./runtime-session-lifecycle.ts";
export { readTuiRuntimeProjection, sendTuiBackgroundTaskMessage } from "./runtime-tui-bridge.ts";
export { createTuiWorkflowManagement, startTuiSavedWorkflow } from "./runtime-workflow-bridge.ts";
export { restoreTuiBackgroundTasks } from "./runtime-background-restore.ts";
Expand Down
40 changes: 40 additions & 0 deletions src/runtime-session-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export interface SessionLifecycleStore {
getSession(sessionId: string): Promise<{ time?: { created?: number; updated?: number; deleted?: number } } | null | undefined>;
}

export interface SessionLifecycleRuntime {
sessionId?: string;
sessionStore?: SessionLifecycleStore;
rootTraceContext?: unknown;
ensureSessionPersistedForExternalActivity?: (title: string, options: { traceContext?: unknown }) => Promise<void>;
}

export async function assertResumableSession(store: SessionLifecycleStore, sessionId: string): Promise<void> {
const session = await store.getSession(sessionId);
if (!session || session.time?.deleted !== undefined) throw new Error(`Session not found: ${sessionId}`);
}

export async function ensureSessionForTitle(
runtime: SessionLifecycleRuntime,
options: { title: string; traceContext?: unknown }
): Promise<void> {
if (!runtime.sessionId || !runtime.sessionStore || !runtime.ensureSessionPersistedForExternalActivity) {
throw new Error("Session persistence is unavailable in this runtime.");
}
await runtime.ensureSessionPersistedForExternalActivity(options.title, {
traceContext: options.traceContext ?? runtime.rootTraceContext
});
await assertResumableSession(runtime.sessionStore, runtime.sessionId);
}

export async function readSessionResumeTarget(app: {
sessionId?: string;
sessionStore?: SessionLifecycleStore;
runtime?: SessionLifecycleRuntime;
}): Promise<string | undefined> {
const sessionId = app.sessionId ?? app.runtime?.sessionId;
const store = app.sessionStore ?? app.runtime?.sessionStore;
if (!sessionId || !store) return undefined;
const session = await store.getSession(sessionId);
return session && session.time?.deleted === undefined ? sessionId : undefined;
}
29 changes: 29 additions & 0 deletions test/fixtures/session-lifecycle-runtime.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const Module = require("node:module");
const path = require("node:path");

function loadSessionLifecycle() {
const file = path.resolve(__dirname, "../../vendor/zcode.cjs");
let source = fs.readFileSync(file, "utf8");
const names = ["setCustomSessionTitle", "ensureSessionPersisted", "ensureSessionPersistedForExternalActivity", "persistSession"];
const functions = names.map(name => {
const symbol = new RegExp(`[A-Za-z_$][\\w$]*\\(([A-Za-z_$][\\w$]*),"${name}"\\)`, "u").exec(source);
assert.ok(symbol, `Missing native ${name}`);
const init = [...source.slice(0, symbol.index).matchAll(/([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*\(\(\)=>\{/gu)].at(-1)?.[1];
assert.ok(init, `Missing initializer for ${name}`);
return { name, symbol: symbol[1], init };
});
const main = /async function [A-Za-z_$][\w$]*\(\)\{let [A-Za-z_$][\w$]*=process\.argv\.slice\(2\);/u.exec(source);
assert.ok(main, "Missing native lifecycle test entry");
source = source.replace(main[0], () => main[0]
+ [...new Set(functions.map(entry => entry.init))].map(init => `${init}();`).join("")
+ `module.exports={${functions.map(entry => `${entry.name}:${entry.symbol}`).join(",")}};return;`);
const runtime = new Module(file, module);
runtime.filename = file;
runtime.paths = Module._nodeModulePaths(path.dirname(file));
runtime._compile(source, file);
return runtime.exports;
}

module.exports = { loadSessionLifecycle };
1 change: 1 addition & 0 deletions test/fixtures/tui-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ await runTui({
}
return { applied: true, response: "Rewound fixture workspace files." };
},
readSessionResumeTarget: async () => "feature-session",
readSessionUsage: async () => ({
totalTokens: 18_500,
inputTokens: 14_000,
Expand Down
Loading
Loading