refactor: centralize cron, doctor, and TUI ownership (#118515)

* refactor(cron): centralize queued run activation

* refactor(doctor): share session SQLite report scaffolding

* refactor(tui): unify slash command registry

* fix(tui): satisfy command registry lint

* fix(tui): align shared command usage help

* fix(btw): preserve outbound usage placeholder
This commit is contained in:
Peter Steinberger
2026-08-03 04:08:54 -07:00
committed by GitHub
parent fe6fa891ea
commit a6f9da8bdb
18 changed files with 778 additions and 768 deletions
+3 -1
View File
@@ -115,7 +115,8 @@ Session controls:
- `/trace <on|off>`
- `/reasoning <on|off|stream>`
- `/usage <off|tokens|full|reset>` (`reset`/`inherit`/`clear`/`default` clears the session override)
- `/goal [status] | /goal start <objective> | /goal edit <objective> | /goal pause|resume|complete|block|clear`
- `/goal <objective> | /goal [status] | /goal start <objective> | /goal edit <objective> | /goal pause|resume|complete|block|clear`
- `/btw <side question>` (alias: `/side`; asks without changing future session context)
- `/elevated <on|off|ask|full>` (alias: `/elev`)
- `/activation <mention|always>`
- `/queue <steer|followup|collect|interrupt> [debounce:<duration>] [cap:<n>] [drop:<summarize|old|new>]`
@@ -126,6 +127,7 @@ Session lifecycle:
- `/new` (spawn a fresh, isolated session under a new key; does not affect other TUI clients on the old session)
- `/reset` (reset the current session key in place)
- `/abort` (abort the active run)
- `/stop` (stop the active or queued run)
- `/settings`
- `/exit` (or `/quit`)
@@ -23,10 +23,13 @@ import {
type SessionSqliteMigrationTargetInput,
} from "./doctor-session-sqlite-migration-run.js";
import { resolveTargetSqlitePath } from "./doctor-session-sqlite-readers.js";
import type {
DoctorSessionSqliteOptions,
DoctorSessionSqliteReport,
DoctorSessionSqliteTargetReport,
import {
createDoctorSessionSqliteTotals,
createDoctorSessionSqliteTargetReport,
sumDoctorSessionSqliteTargets,
type DoctorSessionSqliteOptions,
type DoctorSessionSqliteReport,
type DoctorSessionSqliteTargetReport,
} from "./doctor-session-sqlite-types.js";
type SessionSqliteRecoverTargetValidator = (
@@ -383,70 +386,38 @@ function createSyntheticRecoverTargetReport(
env: NodeJS.ProcessEnv,
message: string,
): DoctorSessionSqliteTargetReport {
return {
return createDoctorSessionSqliteTargetReport({
agentId: "recover",
archivedTranscriptFiles: [],
archivedUnreferencedJsonlFiles: [],
importedEntries: 0,
importedTranscriptEvents: 0,
issues: [{ code: "recover_manifest_missing", message }],
legacyEntries: 0,
referencedTranscriptFiles: 0,
sqliteEntries: 0,
sqlitePath: "",
storePath: resolveSessionSqliteMigrationRunsDir(env),
unreferencedJsonlFiles: [],
validatedEntries: 0,
validatedTranscriptEvents: 0,
};
});
}
function createEmptyRecoverTargetReport(
target: SessionStoreTarget,
sqlitePath: string,
): DoctorSessionSqliteTargetReport {
return {
return createDoctorSessionSqliteTargetReport({
agentId: target.agentId,
archivedTranscriptFiles: [],
archivedUnreferencedJsonlFiles: [],
importedEntries: 0,
importedTranscriptEvents: 0,
issues: [],
legacyEntries: 0,
referencedTranscriptFiles: 0,
sqliteEntries: 0,
sqlitePath,
storePath: target.storePath,
unreferencedJsonlFiles: [],
validatedEntries: 0,
validatedTranscriptEvents: 0,
};
});
}
function summarizeRecoverReport(
targets: DoctorSessionSqliteTargetReport[],
): DoctorSessionSqliteReport {
const sum = (value: (target: DoctorSessionSqliteTargetReport) => number) =>
sumDoctorSessionSqliteTargets(targets, value);
return {
mode: "recover",
targets,
totals: {
archivedTranscriptFiles: 0,
archivedUnreferencedJsonlFiles: 0,
importedEntries: 0,
importedTranscriptEvents: 0,
issues: targets.reduce((total, target) => total + target.issues.length, 0),
legacyEntries: targets.reduce((total, target) => total + target.legacyEntries, 0),
sqliteEntries: targets.reduce((total, target) => total + target.sqliteEntries, 0),
targets: targets.length,
unreferencedJsonlFiles: targets.reduce(
(total, target) => total + target.unreferencedJsonlFiles.length,
0,
),
validatedEntries: targets.reduce((total, target) => total + target.validatedEntries, 0),
validatedTranscriptEvents: targets.reduce(
(total, target) => total + target.validatedTranscriptEvents,
0,
),
},
totals: createDoctorSessionSqliteTotals(targets, {
legacyEntries: sum((target) => target.legacyEntries),
unreferencedJsonlFiles: sum((target) => target.unreferencedJsonlFiles.length),
validatedEntries: sum((target) => target.validatedEntries),
validatedTranscriptEvents: sum((target) => target.validatedTranscriptEvents),
}),
};
}
@@ -5,9 +5,11 @@ import {
restoreSessionSqliteMigrationRuns,
} from "./doctor-session-sqlite-migration-run.js";
import { readSqliteEntryCount, resolveTargetSqlitePath } from "./doctor-session-sqlite-readers.js";
import type {
DoctorSessionSqliteReport,
DoctorSessionSqliteTargetReport,
import {
createDoctorSessionSqliteTargetReport,
createDoctorSessionSqliteTotals,
type DoctorSessionSqliteReport,
type DoctorSessionSqliteTargetReport,
} from "./doctor-session-sqlite-types.js";
export async function restoreDoctorSessionSqliteTargets(params: {
@@ -40,44 +42,23 @@ export async function restoreDoctorSessionSqliteTargets(params: {
}
function createEmptyTargetReport(target: SessionStoreTarget): DoctorSessionSqliteTargetReport {
return {
return createDoctorSessionSqliteTargetReport({
agentId: target.agentId,
archivedTranscriptFiles: [],
archivedUnreferencedJsonlFiles: [],
importedEntries: 0,
importedTranscriptEvents: 0,
issues: [],
legacyEntries: 0,
referencedTranscriptFiles: 0,
sqliteEntries: readSqliteEntryCount(target),
sqlitePath: resolveTargetSqlitePath(target),
storePath: target.storePath,
unreferencedJsonlFiles: [],
validatedEntries: 0,
validatedTranscriptEvents: 0,
};
});
}
function createSyntheticRestoreTargetReport(
env: NodeJS.ProcessEnv,
manifestPath: string,
): DoctorSessionSqliteTargetReport {
return {
return createDoctorSessionSqliteTargetReport({
agentId: "restore",
archivedTranscriptFiles: [],
archivedUnreferencedJsonlFiles: [],
importedEntries: 0,
importedTranscriptEvents: 0,
issues: [],
legacyEntries: 0,
referencedTranscriptFiles: 0,
sqliteEntries: 0,
sqlitePath: "",
storePath: manifestPath || resolveSessionSqliteMigrationRunsDir(env),
unreferencedJsonlFiles: [],
validatedEntries: 0,
validatedTranscriptEvents: 0,
};
});
}
function summarizeRestoreReport(
@@ -86,18 +67,6 @@ function summarizeRestoreReport(
return {
mode: "restore",
targets,
totals: {
archivedTranscriptFiles: 0,
archivedUnreferencedJsonlFiles: 0,
importedEntries: 0,
importedTranscriptEvents: 0,
issues: targets.reduce((total, target) => total + target.issues.length, 0),
legacyEntries: 0,
sqliteEntries: targets.reduce((total, target) => total + target.sqliteEntries, 0),
targets: targets.length,
unreferencedJsonlFiles: 0,
validatedEntries: 0,
validatedTranscriptEvents: 0,
},
totals: createDoctorSessionSqliteTotals(targets),
};
}
@@ -116,6 +116,26 @@ export type DoctorSessionSqliteTargetReport = {
restore?: DoctorSessionSqliteRestoreReport;
};
export function createDoctorSessionSqliteTargetReport(
values: Pick<DoctorSessionSqliteTargetReport, "agentId" | "sqlitePath" | "storePath"> &
Partial<Omit<DoctorSessionSqliteTargetReport, "agentId" | "sqlitePath" | "storePath">>,
): DoctorSessionSqliteTargetReport {
return {
archivedTranscriptFiles: [],
archivedUnreferencedJsonlFiles: [],
importedEntries: 0,
importedTranscriptEvents: 0,
issues: [],
legacyEntries: 0,
referencedTranscriptFiles: 0,
sqliteEntries: 0,
unreferencedJsonlFiles: [],
validatedEntries: 0,
validatedTranscriptEvents: 0,
...values,
};
}
export type DoctorSessionSqliteReport = {
migrationRun?: {
failureReportJsonPath?: string;
@@ -142,3 +162,34 @@ export type DoctorSessionSqliteReport = {
validatedTranscriptEvents: number;
};
};
export function sumDoctorSessionSqliteTargets(
targets: DoctorSessionSqliteTargetReport[],
value: (target: DoctorSessionSqliteTargetReport) => number,
): number {
return targets.reduce((total, target) => total + value(target), 0);
}
export function createDoctorSessionSqliteTotals(
targets: DoctorSessionSqliteTargetReport[],
values: Partial<
Omit<DoctorSessionSqliteReport["totals"], "issues" | "sqliteEntries" | "targets">
> = {},
): DoctorSessionSqliteReport["totals"] {
const { archivedLegacyStoreFiles, reclaimedBytes } = values;
return {
...(archivedLegacyStoreFiles === undefined ? {} : { archivedLegacyStoreFiles }),
archivedTranscriptFiles: values.archivedTranscriptFiles ?? 0,
archivedUnreferencedJsonlFiles: values.archivedUnreferencedJsonlFiles ?? 0,
importedEntries: values.importedEntries ?? 0,
importedTranscriptEvents: values.importedTranscriptEvents ?? 0,
issues: sumDoctorSessionSqliteTargets(targets, (target) => target.issues.length),
legacyEntries: values.legacyEntries ?? 0,
...(reclaimedBytes === undefined ? {} : { reclaimedBytes }),
sqliteEntries: sumDoctorSessionSqliteTargets(targets, (target) => target.sqliteEntries),
targets: targets.length,
unreferencedJsonlFiles: values.unreferencedJsonlFiles ?? 0,
validatedEntries: values.validatedEntries ?? 0,
validatedTranscriptEvents: values.validatedTranscriptEvents ?? 0,
};
}
@@ -1246,6 +1246,8 @@ describe("runDoctorSessionSqlite", () => {
});
expect(restore.totals.issues).toBe(0);
expect(restore.totals).not.toHaveProperty("archivedLegacyStoreFiles");
expect(restore.totals).not.toHaveProperty("reclaimedBytes");
expect(restore.targets[0]?.restore).toMatchObject({
conflicts: [],
restoredFiles: expect.arrayContaining(sourcePaths),
@@ -2256,6 +2258,8 @@ describe("runDoctorSessionSqlite", () => {
});
expect(recover.mode).toBe("recover");
expect(recover.totals).not.toHaveProperty("archivedLegacyStoreFiles");
expect(recover.totals).not.toHaveProperty("reclaimedBytes");
expect(recover.targets[0]?.issues).toMatchObject([
{ code: "active_sqlite_transcript_jsonl", sessionKey: "agent:main:main" },
]);
@@ -2691,6 +2695,7 @@ describe("runDoctorSessionSqlite", () => {
issues: 0,
sqliteEntries: 2,
});
expect(report.totals).toHaveProperty("reclaimedBytes");
const manifest = readMigrationManifest(report.migrationRun?.manifestPath);
for (const target of manifest.targets) {
expect(target.completedMoves.some((move) => move.kind === "legacy-store")).toBe(true);
+19 -53
View File
@@ -56,7 +56,10 @@ import {
import { recoverDoctorSessionSqliteTargets } from "./doctor-session-sqlite-recover-report.js";
import { restoreDoctorSessionSqliteTargets } from "./doctor-session-sqlite-restore-report.js";
import {
createDoctorSessionSqliteTotals,
createDoctorSessionSqliteTargetReport,
isSessionSqliteMigrationWarning,
sumDoctorSessionSqliteTargets,
type DoctorSessionSqliteIssue,
type DoctorSessionSqliteMode,
type DoctorSessionSqliteOptions,
@@ -295,13 +298,9 @@ async function inspectOrMigrateTarget(params: {
const referencedTranscriptFiles = new Set(
allRecords.flatMap((record) => (record.transcriptPath ? [record.transcriptPath] : [])),
);
const report: DoctorSessionSqliteTargetReport = {
const report = createDoctorSessionSqliteTargetReport({
agentId: params.target.agentId,
archivedLegacyStoreFiles: [],
archivedTranscriptFiles: [],
archivedUnreferencedJsonlFiles: [],
importedEntries: 0,
importedTranscriptEvents: 0,
issues,
legacyEntries: records.length,
referencedTranscriptFiles: referencedTranscriptFiles.size,
@@ -311,9 +310,7 @@ async function inspectOrMigrateTarget(params: {
unreferencedJsonlFiles: listUnreferencedJsonlFiles(params.target.storePath, [
...referencedTranscriptFiles,
]),
validatedEntries: 0,
validatedTranscriptEvents: 0,
};
});
if (params.mode === "inspect") {
report.sqliteEntries = readSqliteEntryCount(params.target);
appendSqliteDbStats(params.target, report);
@@ -1332,6 +1329,8 @@ function summarizeDoctorSessionSqliteReport(
targets: DoctorSessionSqliteTargetReport[],
activeRun?: ActiveSessionSqliteMigrationRun,
): DoctorSessionSqliteReport {
const sum = (value: (target: DoctorSessionSqliteTargetReport) => number) =>
sumDoctorSessionSqliteTargets(targets, value);
return {
...(activeRun
? {
@@ -1349,51 +1348,18 @@ function summarizeDoctorSessionSqliteReport(
: {}),
mode,
targets,
totals: {
archivedLegacyStoreFiles: targets.reduce(
(total, target) => total + (target.archivedLegacyStoreFiles?.length ?? 0),
0,
),
archivedTranscriptFiles: targets.reduce(
(total, target) => total + target.archivedTranscriptFiles.length,
0,
),
archivedUnreferencedJsonlFiles: targets.reduce(
(total, target) => total + target.archivedUnreferencedJsonlFiles.length,
0,
),
importedEntries: sumTargets(targets, "importedEntries"),
importedTranscriptEvents: sumTargets(targets, "importedTranscriptEvents"),
issues: targets.reduce((total, target) => total + target.issues.length, 0),
legacyEntries: sumTargets(targets, "legacyEntries"),
reclaimedBytes: targets.reduce(
(total, target) => total + (target.compact?.reclaimedBytes ?? 0),
0,
),
sqliteEntries: sumTargets(targets, "sqliteEntries"),
targets: targets.length,
unreferencedJsonlFiles: targets.reduce(
(total, target) => total + target.unreferencedJsonlFiles.length,
0,
),
validatedEntries: sumTargets(targets, "validatedEntries"),
validatedTranscriptEvents: sumTargets(targets, "validatedTranscriptEvents"),
},
totals: createDoctorSessionSqliteTotals(targets, {
archivedLegacyStoreFiles: sum((target) => target.archivedLegacyStoreFiles?.length ?? 0),
archivedTranscriptFiles: sum((target) => target.archivedTranscriptFiles.length),
archivedUnreferencedJsonlFiles: sum((target) => target.archivedUnreferencedJsonlFiles.length),
importedEntries: sum((target) => target.importedEntries),
importedTranscriptEvents: sum((target) => target.importedTranscriptEvents),
legacyEntries: sum((target) => target.legacyEntries),
reclaimedBytes: sum((target) => target.compact?.reclaimedBytes ?? 0),
unreferencedJsonlFiles: sum((target) => target.unreferencedJsonlFiles.length),
validatedEntries: sum((target) => target.validatedEntries),
validatedTranscriptEvents: sum((target) => target.validatedTranscriptEvents),
}),
};
}
function sumTargets(
targets: DoctorSessionSqliteTargetReport[],
key: keyof Pick<
DoctorSessionSqliteTargetReport,
| "importedEntries"
| "importedTranscriptEvents"
| "legacyEntries"
| "sqliteEntries"
| "validatedEntries"
| "validatedTranscriptEvents"
>,
): number {
return targets.reduce((total, target) => total + target[key], 0);
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+18 -5
View File
@@ -14,13 +14,26 @@ import { createCronStreamSourceIdentity, resolveCronStreamBatching } from "../st
import type { CronJob, CronSchedule } from "../types.js";
import { autoDisableCronJob } from "./auto-disable.js";
import { normalizePayloadToSystemText } from "./normalize.js";
import { isQueuedCronRun, isQueuedForceCronRun } from "./run-admission.js";
import type { CronServiceState, DeferredCronNotifications } from "./state.js";
const STUCK_RUN_MS = 2 * 60 * 60 * 1000;
const STAGGER_OFFSET_CACHE_MAX = 4096;
const staggerOffsetCache = new Map<string, number>();
// A matching process reservation keeps its durable queued/running marker live;
// disabled jobs additionally require force-run ownership.
function ownsCronRunMarker(
state: CronServiceState,
jobId: string,
markerAtMs: number,
requireForce = false,
): boolean {
const reservation = state.queuedRunReservationsByJobId.get(jobId);
return (
reservation?.markerAtMs === markerAtMs && (!requireForce || reservation.preserveWhenDisabled)
);
}
export function normalizeStreamScheduleBounds(schedule: CronSchedule): CronSchedule {
if (schedule.kind !== "stream") {
return schedule;
@@ -439,14 +452,14 @@ function normalizeJobTickState(params: { state: CronServiceState; job: CronJob;
}
if (
job.state.queuedAtMs !== undefined &&
!isQueuedForceCronRun(state, job.id, job.state.queuedAtMs)
!ownsCronRunMarker(state, job.id, job.state.queuedAtMs, true)
) {
job.state.queuedAtMs = undefined;
changed = true;
}
if (
job.state.runningAtMs !== undefined &&
!isQueuedForceCronRun(state, job.id, job.state.runningAtMs) &&
!ownsCronRunMarker(state, job.id, job.state.runningAtMs, true) &&
!isCronJobActive(job.id)
) {
job.state.runningAtMs = undefined;
@@ -474,7 +487,7 @@ function normalizeJobTickState(params: { state: CronServiceState; job: CronJob;
if (
typeof queuedAt === "number" &&
Math.abs(nowMs - queuedAt) > STUCK_RUN_MS &&
!isQueuedCronRun(state, job.id, queuedAt)
!ownsCronRunMarker(state, job.id, queuedAt)
) {
state.deps.log.warn(
{ jobId: job.id, queuedAtMs: queuedAt },
@@ -488,7 +501,7 @@ function normalizeJobTickState(params: { state: CronServiceState; job: CronJob;
if (
typeof runningAt === "number" &&
Math.abs(nowMs - runningAt) > STUCK_RUN_MS &&
!isQueuedCronRun(state, job.id, runningAt)
!ownsCronRunMarker(state, job.id, runningAt)
) {
state.deps.log.warn(
{ jobId: job.id, runningAtMs: runningAt },
+10 -31
View File
@@ -15,12 +15,12 @@ import {
import { locked } from "./locked.js";
import { markManualCronJobActive, ownsStreamSource } from "./ops-shared.js";
import {
activateQueuedCronRun,
clearQueuedCronRunReservationMarker,
isQueuedCronRunReservationCurrent,
isQueuedCronRunReservationMarkerCurrent,
releaseQueuedCronRun,
reserveQueuedCronRun,
updateQueuedCronRunReservationMarker,
} from "./run-admission.js";
import type { CronEvent, CronServiceState, DeferredCronNotifications } from "./state.js";
import { emit } from "./state.js";
@@ -468,39 +468,18 @@ export async function activatePreparedManualRun(
return { ok: true, ran: false, reason: "invalid-spec" } as const;
}
const startedAt = state.deps.nowMs();
const previousLastError = job.state.lastError;
const activationRollbackSnapshot = snapshotStoreForRollback(state);
delete job.state.queuedAtMs;
job.state.runningAtMs = startedAt;
job.state.lastError = undefined;
// A failed write restores the durable reservation; run() owns releasing
// that queued claim for every activation failure before it propagates.
await persistOrRestore(state, activationRollbackSnapshot);
updateQueuedCronRunReservationMarker(
const activation = await activateQueuedCronRun({
state,
prepared.jobId,
prepared.reservationIdentity,
startedAt,
previousLastError,
);
if (state.stopped || state.restartRecoveryPending) {
job.state.lastError = previousLastError;
const rollbackSnapshot = snapshotStoreForRollback(state);
delete job.state.runningAtMs;
try {
await persistOrRestore(state, rollbackSnapshot);
} catch (error) {
job,
reservationIdentity: prepared.reservationIdentity,
onUnavailableRollbackError: async () => {
await releasePreparedManualReservationWithRetry(state, prepared);
throw error;
}
releaseQueuedCronRun(state, prepared.jobId, prepared.reservationIdentity);
return {
ok: true,
ran: false,
reason: state.stopped ? "stopped" : "restart-recovery-pending",
} as const;
},
});
if (activation.kind === "unavailable") {
return { ok: true, ran: false, reason: activation.reason } as const;
}
const { startedAt } = activation;
emit(state, { jobId: job.id, action: "started", job, runAtMs: startedAt });
const taskRunId = tryCreateCronTaskRun({
state,
@@ -152,6 +152,7 @@ describe("cron service run admission cleanup", () => {
});
const realSave = cronStoreModule.saveCronJobsStore;
let reservationPersisted = false;
const markerTransitions: Array<"queued" | "running" | "idle"> = [];
const saveSpy = vi
.spyOn(cronStoreModule, "saveCronJobsStore")
.mockImplementation(async (storePath, nextStore, opts) => {
@@ -161,9 +162,13 @@ describe("cron service run admission cleanup", () => {
await realSave(storePath, nextStore, opts);
if (!reservationPersisted && queuedAtMs === dueAt) {
reservationPersisted = true;
markerTransitions.push("queued");
now = dueAt + 1;
} else if (reservationPersisted && runningAtMs === dueAt + 1) {
markerTransitions.push("running");
stop(state);
} else if (markerTransitions.length === 2 && !queuedAtMs && !runningAtMs) {
markerTransitions.push("idle");
}
});
@@ -184,6 +189,7 @@ describe("cron service run admission cleanup", () => {
}
expect(runIsolatedAgentJob).not.toHaveBeenCalled();
expect(markerTransitions).toEqual(["queued", "running", "idle"]);
expect(state.queuedRunReservationsByJobId.has(job.id)).toBe(false);
const persistedJob = (await loadCronStore(store.storePath)).jobs.find(
(entry) => entry.id === job.id,
+45 -32
View File
@@ -1,6 +1,8 @@
// Shared execution admission for scheduled, manual, and on-exit cron runs.
import { DEFAULT_CRON_MAX_CONCURRENT_RUNS } from "../../config/cron-limits.js";
import type { CronJob } from "../types.js";
import type { CronServiceState } from "./state.js";
import { persistOrRestore, snapshotStoreForRollback } from "./store.js";
export function resolveRunConcurrency(): number {
return DEFAULT_CRON_MAX_CONCURRENT_RUNS;
@@ -93,22 +95,6 @@ export function isQueuedCronRunReservationCurrent(
return state.queuedRunReservationsByJobId.get(jobId)?.identity === identity;
}
export function updateQueuedCronRunReservationMarker(
state: CronServiceState,
jobId: string,
identity: object,
runningAtMs: number,
previousLastError: string | undefined,
): boolean {
const reservation = state.queuedRunReservationsByJobId.get(jobId);
if (reservation?.identity !== identity) {
return false;
}
reservation.markerAtMs = runningAtMs;
reservation.activationPreviousLastError = { value: previousLastError };
return true;
}
export function restoreQueuedCronRunReservationLastError(
state: CronServiceState,
jobId: string,
@@ -157,23 +143,50 @@ export function isQueuedCronRunReservationMarkerCurrent(
return reservation?.identity === identity && reservation.markerAtMs === runningAtMs;
}
/** A matching process-local record means this durable queued or running marker is still owned. */
export function isQueuedCronRun(
state: CronServiceState,
jobId: string,
queuedAtMs: number,
): boolean {
return state.queuedRunReservationsByJobId.get(jobId)?.markerAtMs === queuedAtMs;
}
export async function activateQueuedCronRun(params: {
state: CronServiceState;
job: CronJob;
reservationIdentity: object;
onUnavailable?: () => void;
onUnavailableRollbackError?: () => Promise<void>;
}): Promise<
| { kind: "activated"; startedAt: number }
| { kind: "unavailable"; reason: "stopped" | "restart-recovery-pending" }
> {
const { state, job, reservationIdentity } = params;
const startedAt = state.deps.nowMs();
const previousLastError = job.state.lastError;
const activationRollbackSnapshot = snapshotStoreForRollback(state);
delete job.state.queuedAtMs;
job.state.runningAtMs = startedAt;
job.state.lastError = undefined;
// Persist running ownership before execution. A failed write restores the
// durable queued marker so the caller can release or recover that claim.
await persistOrRestore(state, activationRollbackSnapshot);
const reservation = state.queuedRunReservationsByJobId.get(job.id);
if (reservation?.identity === reservationIdentity) {
reservation.markerAtMs = startedAt;
reservation.activationPreviousLastError = { value: previousLastError };
}
if (!state.stopped && !state.restartRecoveryPending) {
return { kind: "activated", startedAt };
}
/** A disabled job can retain only a force reservation that predated the disabled state. */
export function isQueuedForceCronRun(
state: CronServiceState,
jobId: string,
markerAtMs: number,
): boolean {
const reservation = state.queuedRunReservationsByJobId.get(jobId);
return reservation?.markerAtMs === markerAtMs && reservation.preserveWhenDisabled;
params.onUnavailable?.();
job.state.lastError = previousLastError;
const rollbackSnapshot = snapshotStoreForRollback(state);
delete job.state.runningAtMs;
try {
await persistOrRestore(state, rollbackSnapshot);
} catch (error) {
await params.onUnavailableRollbackError?.();
throw error;
}
releaseQueuedCronRun(state, job.id, reservationIdentity);
return {
kind: "unavailable",
reason: state.stopped ? "stopped" : "restart-recovery-pending",
};
}
/**
+7 -21
View File
@@ -9,11 +9,11 @@ import {
} from "./jobs.js";
import { locked } from "./locked.js";
import {
activateQueuedCronRun,
isQueuedCronRunReservationCurrent,
releaseQueuedCronRun,
reserveQueuedCronRun,
runWithCronAdmission,
updateQueuedCronRunReservationMarker,
} from "./run-admission.js";
import { type CronServiceState, type DeferredCronNotifications, emit } from "./state.js";
import { ensureLoaded, persist, persistOrRestore, snapshotStoreForRollback } from "./store.js";
@@ -322,29 +322,15 @@ async function executeStartupCatchupPlan(
releaseQueuedCronRun(state, candidate.jobId, candidate.reservationIdentity);
return undefined;
}
const startedAt = state.deps.nowMs();
const previousLastError = job.state.lastError;
const activationRollbackSnapshot = snapshotStoreForRollback(state);
delete job.state.queuedAtMs;
job.state.runningAtMs = startedAt;
job.state.lastError = undefined;
await persistOrRestore(state, activationRollbackSnapshot);
updateQueuedCronRunReservationMarker(
const activation = await activateQueuedCronRun({
state,
candidate.jobId,
candidate.reservationIdentity,
startedAt,
previousLastError,
);
if (state.stopped || state.restartRecoveryPending) {
job.state.lastError = previousLastError;
const rollbackSnapshot = snapshotStoreForRollback(state);
delete job.state.runningAtMs;
await persistOrRestore(state, rollbackSnapshot);
releaseQueuedCronRun(state, candidate.jobId, candidate.reservationIdentity);
job,
reservationIdentity: candidate.reservationIdentity,
});
if (activation.kind === "unavailable") {
return undefined;
}
return { ...candidate, job, startedAt };
return { ...candidate, job, startedAt: activation.startedAt };
});
if (!startedCandidate) {
return undefined;
+10 -22
View File
@@ -18,6 +18,7 @@ import {
} from "./jobs.js";
import { locked } from "./locked.js";
import {
activateQueuedCronRun,
clearQueuedCronRunReservationMarker,
isQueuedCronRunReservationCurrent,
isQueuedCronRunReservationMarkerCurrent,
@@ -26,7 +27,6 @@ import {
resolveRunConcurrency,
restoreQueuedCronRunReservationLastError,
runWithCronAdmission,
updateQueuedCronRunReservationMarker,
} from "./run-admission.js";
import { type CronServiceState, type DeferredCronNotifications, emit } from "./state.js";
import { ensureLoaded, persistOrRestore, snapshotStoreForRollback } from "./store.js";
@@ -457,30 +457,18 @@ async function onAdmittedTimer(state: CronServiceState) {
releaseQueuedCronRun(state, due.id, due.reservationIdentity);
return undefined;
}
const startedAt = state.deps.nowMs();
const previousLastError = job.state.lastError;
const activationRollbackSnapshot = snapshotStoreForRollback(state);
delete job.state.queuedAtMs;
job.state.runningAtMs = startedAt;
job.state.lastError = undefined;
await persistOrRestore(state, activationRollbackSnapshot);
updateQueuedCronRunReservationMarker(
const activation = await activateQueuedCronRun({
state,
due.id,
due.reservationIdentity,
startedAt,
previousLastError,
);
if (state.stopped || state.restartRecoveryPending) {
stopAdmittingDueJobs = true;
job.state.lastError = previousLastError;
const rollbackSnapshot = snapshotStoreForRollback(state);
delete job.state.runningAtMs;
await persistOrRestore(state, rollbackSnapshot);
releaseQueuedCronRun(state, due.id, due.reservationIdentity);
job,
reservationIdentity: due.reservationIdentity,
onUnavailable: () => {
stopAdmittingDueJobs = true;
},
});
if (activation.kind === "unavailable") {
return undefined;
}
return { ...due, job, startedAt };
return { ...due, job, startedAt: activation.startedAt };
});
if (!currentDueJob) {
return pMapSkip;
+19
View File
@@ -202,6 +202,25 @@ describe("helpText", () => {
expect(output).toContain("/openclaw [request]");
});
it.each(["goal", "btw", "queue", "stop"])(
"keeps /%s visible in completion and help across TUI modes",
(name) => {
for (const options of [{}, { local: true }]) {
expect(getSlashCommands(options).map((command) => command.name)).toContain(name);
expect(helpText(options)).toContain(`/${name}`);
}
},
);
it.each([{}, { local: true }])("shows required arguments in shared command help", (options) => {
const output = helpText(options);
expect(output).toContain("/goal start <objective>");
expect(output).toContain("/goal edit <objective>");
expect(output).toContain("/btw <side question>");
expect(output).not.toContain("/btw [side question]");
});
it("does not advertise Gateway-owned commands in local mode", () => {
const output = helpText({ local: true });
+178 -117
View File
@@ -38,16 +38,6 @@ type SlashCommandOptions = {
dynamicCommands?: CommandEntry[];
};
const COMMAND_ALIASES: Record<string, string> = {
crestodian: "openclaw", // hidden alias
gwstatus: "gateway-status",
};
// These shared commands have explicit local TUI routing but no same-named
// built-in autocomplete entry. Other shared commands require the Gateway and
// must stay out of local autocomplete and model prompts.
const LOCAL_TUI_ROUTED_SHARED_COMMANDS = new Set(["btw", "goal", "queue", "stop"]);
function createLevelCompletion(
levels: string[],
): NonNullable<SlashCommand["getArgumentCompletions"]> {
@@ -66,6 +56,138 @@ export function formatTuiLevelCommandUsage(command: "verbose" | "reasoning"): st
return `/${command} <${levels.join("|")}>`;
}
type TuiCommandDescriptor = {
name: string;
description?: string;
aliases?: readonly { name: string; description?: string; hidden?: boolean }[];
scope?: "both" | "local" | "remote";
shared?: boolean;
handler?: true;
help?: string | readonly string[];
completions?: readonly string[] | "thinking";
};
type TuiCommandRow = readonly [
name: string,
description?: string,
help?: string | readonly string[],
completions?: readonly string[] | "thinking",
options?: Pick<TuiCommandDescriptor, "aliases" | "scope" | "shared"> & { handler?: false },
];
const TUI_COMMAND_ROWS = [
["help", "Show slash command help", "/help"],
[
"commands",
undefined,
"/commands",
undefined,
{ scope: "remote", shared: true, handler: false },
],
["status", undefined, "/status", undefined, { scope: "remote", shared: true, handler: false }],
[
"gateway-status",
"Show gateway status summary",
["/gateway-status", "/gwstatus"],
undefined,
{ aliases: [{ name: "gwstatus", description: "Alias for /gateway-status" }] },
],
["auth", "Run provider auth/login flow", "/auth [provider]", undefined, { scope: "local" }],
["agent", "Switch agent (or open picker)", "/agent <id> (or /agents)"],
["agents", "Open agent picker"],
[
"openclaw",
"Return to OpenClaw",
"/openclaw [request]",
undefined,
{ aliases: [{ name: "crestodian", hidden: true }] },
],
["session", "Switch session (or open picker)", "/session <key> (or /sessions)"],
["sessions", "Open session picker"],
["model", "Set model (or open picker)", "/model <provider/model> (or /models)"],
["models", "Open model picker"],
["think", "Set thinking level", "/think <{thinkingLevels}>", "thinking"],
["fast", "Set fast mode auto/on/off", "/fast <status|auto|on|off>", FAST_LEVELS],
[
"verbose",
`Set verbose ${VERBOSE_LEVELS.join("/")}`,
formatTuiLevelCommandUsage("verbose"),
VERBOSE_LEVELS,
],
["trace", "Set trace on/off", "/trace <on|off>", TRACE_LEVELS],
[
"reasoning",
`Set reasoning ${REASONING_LEVELS.join("/")}`,
formatTuiLevelCommandUsage("reasoning"),
REASONING_LEVELS,
],
[
"usage",
"Toggle per-response usage line",
"/usage <off|tokens|full|reset|inherit|clear|default>",
USAGE_FOOTER_LEVELS,
],
[
"elevated",
"Set elevated on/off/ask/full",
["/elevated <on|off|ask|full>", "/elev <on|off|ask|full>"],
ELEVATED_LEVELS,
{ aliases: [{ name: "elev", description: "Alias for /elevated" }] },
],
["activation", "Set group activation", "/activation <mention|always>", ACTIVATION_LEVELS],
["context", undefined, undefined, undefined, { scope: "remote", shared: true }],
[
"goal",
undefined,
"/goal <objective> | /goal [status] | /goal start <objective> | /goal edit <objective> | /goal pause|resume|complete|block|clear",
undefined,
{ shared: true },
],
["btw", undefined, "/btw <side question>", undefined, { shared: true }],
["queue", undefined, "/queue [mode]", undefined, { shared: true }],
["stop", undefined, "/stop", undefined, { shared: true }],
["new", "Spawn a new isolated session", "/new or /reset"],
["reset", "Reset the current session"],
["abort", "Abort active run", "/abort"],
["settings", "Open settings", "/settings"],
[
"exit",
"Exit the TUI",
"/exit",
undefined,
{ aliases: [{ name: "quit", description: "Exit the TUI" }] },
],
] as const satisfies readonly TuiCommandRow[];
const TUI_COMMAND_ROW_VALUES: readonly TuiCommandRow[] = TUI_COMMAND_ROWS;
const TUI_COMMAND_DESCRIPTORS: readonly TuiCommandDescriptor[] = TUI_COMMAND_ROW_VALUES.map(
([name, description, help, completions, options]) => {
const descriptor: TuiCommandDescriptor = { name, description, help, completions };
descriptor.aliases = options?.aliases;
descriptor.scope = options?.scope;
descriptor.shared = options?.shared;
if (options?.handler !== false) {
descriptor.handler = true;
}
return descriptor;
},
);
export type TuiCommandHandlerName = Exclude<
(typeof TUI_COMMAND_ROWS)[number][0],
"commands" | "status"
>;
export function resolveTuiCommandDescriptor(name: string): TuiCommandDescriptor | undefined {
return TUI_COMMAND_DESCRIPTORS.find(
(command) => command.name === name || command.aliases?.some((alias) => alias.name === name),
);
}
function commandIsVisible(command: TuiCommandDescriptor, local: boolean): boolean {
return command.scope !== (local ? "remote" : "local");
}
function normalizeSlashCommandName(value: string): string {
return value.replace(/^\//, "").trim();
}
@@ -75,13 +197,14 @@ function appendSlashCommand(
seen: Set<string>,
name: string,
description: string,
getArgumentCompletions?: SlashCommand["getArgumentCompletions"],
) {
const normalizedName = normalizeSlashCommandName(name);
if (!normalizedName || seen.has(normalizedName)) {
return;
}
seen.add(normalizedName);
commands.push({ name: normalizedName, description });
commands.push({ name: normalizedName, description, getArgumentCompletions });
}
export function parseCommand(input: string): ParsedCommand {
@@ -98,8 +221,9 @@ export function parseCommand(input: string): ParsedCommand {
}
const [name, ...rest] = trimmed.split(/\s+/);
const normalized = normalizeLowercaseStringOrEmpty(name);
const descriptor = resolveTuiCommandDescriptor(normalized);
return {
name: COMMAND_ALIASES[normalized] ?? normalized,
name: descriptor?.name ?? normalized,
args: rest.join(" ").trim(),
};
}
@@ -113,92 +237,43 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman
const thinkLevels = options.thinkingLevels?.length
? options.thinkingLevels.map((level) => level.label)
: listThinkingLevelLabels(options.provider, options.model, undefined, options.agentRuntime);
const verboseCompletions = createLevelCompletion(VERBOSE_LEVELS);
const traceCompletions = createLevelCompletion(TRACE_LEVELS);
const fastCompletions = createLevelCompletion(FAST_LEVELS);
const reasoningCompletions = createLevelCompletion(REASONING_LEVELS);
const usageCompletions = createLevelCompletion(USAGE_FOOTER_LEVELS);
const elevatedCompletions = createLevelCompletion(ELEVATED_LEVELS);
const activationCompletions = createLevelCompletion(ACTIVATION_LEVELS);
const commands: SlashCommand[] = [
{ name: "help", description: "Show slash command help" },
{ name: "gateway-status", description: "Show gateway status summary" },
{ name: "gwstatus", description: "Alias for /gateway-status" },
...(options.local ? [{ name: "auth", description: "Run provider auth/login flow" }] : []),
{ name: "agent", description: "Switch agent (or open picker)" },
{ name: "agents", description: "Open agent picker" },
{ name: "openclaw", description: "Return to OpenClaw" },
{ name: "session", description: "Switch session (or open picker)" },
{ name: "sessions", description: "Open session picker" },
{
name: "model",
description: "Set model (or open picker)",
},
{ name: "models", description: "Open model picker" },
{
name: "think",
description: "Set thinking level",
getArgumentCompletions: (prefix) =>
thinkLevels
.filter((v) => v.startsWith(normalizeLowercaseStringOrEmpty(prefix)))
.map((value) => ({ value, label: value })),
},
{
name: "fast",
description: "Set fast mode auto/on/off",
getArgumentCompletions: fastCompletions,
},
{
name: "verbose",
description: `Set verbose ${VERBOSE_LEVELS.join("/")}`,
getArgumentCompletions: verboseCompletions,
},
{
name: "trace",
description: "Set trace on/off",
getArgumentCompletions: traceCompletions,
},
{
name: "reasoning",
description: `Set reasoning ${REASONING_LEVELS.join("/")}`,
getArgumentCompletions: reasoningCompletions,
},
{
name: "usage",
description: "Toggle per-response usage line",
getArgumentCompletions: usageCompletions,
},
{
name: "elevated",
description: "Set elevated on/off/ask/full",
getArgumentCompletions: elevatedCompletions,
},
{
name: "elev",
description: "Alias for /elevated",
getArgumentCompletions: elevatedCompletions,
},
{
name: "activation",
description: "Set group activation",
getArgumentCompletions: activationCompletions,
},
{ name: "abort", description: "Abort active run" },
{ name: "new", description: "Spawn a new isolated session" },
{ name: "reset", description: "Reset the current session" },
{ name: "settings", description: "Open settings" },
{ name: "exit", description: "Exit the TUI" },
{ name: "quit", description: "Exit the TUI" },
];
const commands: SlashCommand[] = [];
const seen = new Set<string>();
for (const command of TUI_COMMAND_DESCRIPTORS) {
if (
command.shared ||
!command.description ||
!commandIsVisible(command, options.local === true)
) {
continue;
}
const completions =
command.completions === "thinking"
? createLevelCompletion(thinkLevels)
: command.completions
? createLevelCompletion([...command.completions])
: undefined;
appendSlashCommand(commands, seen, command.name, command.description, completions);
for (const alias of command.aliases ?? []) {
if (!alias.hidden) {
appendSlashCommand(
commands,
seen,
alias.name,
alias.description ?? command.description,
completions,
);
}
}
}
const seen = new Set(commands.map((command) => command.name));
const gatewayCommands = options.cfg ? listChatCommandsForConfig(options.cfg) : listChatCommands();
for (const command of gatewayCommands) {
if (
options.local &&
!seen.has(command.key) &&
!LOCAL_TUI_ROUTED_SHARED_COMMANDS.has(command.key)
) {
const descriptor = resolveTuiCommandDescriptor(command.key);
if (options.local && !seen.has(command.key) && !descriptor?.shared) {
continue;
}
if (options.local && descriptor && !commandIsVisible(descriptor, true)) {
continue;
}
const aliases = command.textAliases.length > 0 ? command.textAliases : [`/${command.key}`];
@@ -247,30 +322,16 @@ export function helpText(options: SlashCommandOptions = {}): string {
undefined,
options.agentRuntime,
);
const commandHelp = TUI_COMMAND_DESCRIPTORS.flatMap((command) => {
if (!command.help || !commandIsVisible(command, options.local === true)) {
return [];
}
const lines = typeof command.help === "string" ? [command.help] : command.help;
return lines.map((line) => line.replace("{thinkingLevels}", thinkLevels));
});
return [
"Slash commands:",
"/help",
...(options.local ? [] : ["/commands", "/status"]),
"/gateway-status",
"/gwstatus",
...(options.local ? ["/auth [provider]"] : []),
"/agent <id> (or /agents)",
"/openclaw [request]",
"/session <key> (or /sessions)",
"/model <provider/model> (or /models)",
`/think <${thinkLevels}>`,
"/fast <status|auto|on|off>",
formatTuiLevelCommandUsage("verbose"),
"/trace <on|off>",
formatTuiLevelCommandUsage("reasoning"),
"/usage <off|tokens|full|reset|inherit|clear|default>",
"/elevated <on|off|ask|full>",
"/elev <on|off|ask|full>",
"/activation <mention|always>",
"/new or /reset",
"/abort",
"/settings",
"/exit",
...commandHelp,
"",
"Keyboard shortcuts:",
"Enter: send message",
+1 -1
View File
@@ -571,7 +571,7 @@ describe("tui command handlers", () => {
const emptySide = createHarness({ opts: { local: true } });
await emptySide.handleCommand("/side");
expect(emptySide.sendChat).not.toHaveBeenCalled();
expect(emptySide.addSystem).toHaveBeenCalledWith("Usage: /btw [side question]");
expect(emptySide.addSystem).toHaveBeenCalledWith("Usage: /btw <side question>");
const side = createHarness({ opts: { local: true } });
await side.handleCommand("/side check this");
+371 -395
View File
@@ -20,6 +20,8 @@ import {
helpText,
isSharedTextCommand,
parseCommand,
resolveTuiCommandDescriptor,
type TuiCommandHandlerName,
} from "./commands.js";
import type { ChatLog } from "./components/chat-log.js";
import {
@@ -490,412 +492,386 @@ export function createCommandHandlers(context: CommandHandlerContext) {
tui.requestRender();
};
type CommandHandler = (args: string, raw: string) => void | Promise<void>;
const commandHandlers = {
help: () => {
chatLog.addSystem(
helpText({
local: opts.local,
provider: state.sessionInfo.modelProvider,
model: state.sessionInfo.model,
agentRuntime: state.sessionInfo.agentRuntime?.id,
}),
);
},
auth: async (args) => {
if (!runAuthFlow) {
chatLog.addSystem("auth login is only available in local embedded mode");
return;
}
if (state.activeChatRunId || hasPendingSubmit(state)) {
chatLog.addSystem("abort the current run before /auth");
return;
}
const provider = args.trim() || state.sessionInfo.modelProvider || undefined;
chatLog.addSystem(
provider
? `opening auth flow for ${provider}; TUI will resume when it exits`
: "opening auth flow; TUI will resume when it exits",
);
tui.requestRender();
setActivityStatus("auth");
try {
const result = await runAuthFlow({ provider });
await refreshSessionInfo();
if (result.exitCode === 0 && !result.signal) {
chatLog.addSystem(provider ? `auth flow finished for ${provider}` : "auth flow finished");
setActivityStatus("idle");
} else {
const failureSuffix = result.signal
? ` (signal ${result.signal})`
: typeof result.exitCode === "number"
? ` (exit ${String(result.exitCode)})`
: "";
chatLog.addSystem(`auth flow failed${failureSuffix}`);
setActivityStatus("error");
}
} catch (err) {
chatLog.addSystem(`auth flow failed: ${formatTuiErrorMessage(err)}`);
setActivityStatus("error");
}
},
"gateway-status": async () => {
try {
const status = await client.getGatewayStatus();
if (typeof status === "string") {
chatLog.addSystem(status);
return;
}
if (status && typeof status === "object") {
const lines = formatStatusSummary(status as GatewayStatusSummary);
for (const line of lines) {
chatLog.addSystem(line);
}
return;
}
chatLog.addSystem("status: unknown response");
} catch (err) {
chatLog.addSystem(`status failed: ${formatTuiErrorMessage(err)}`);
}
},
agent: async (args) => {
if (!args) {
await openAgentSelector();
} else {
await setAgent(args);
}
},
agents: async () => await openAgentSelector(),
context: async (args, raw) => {
if (opts.local) {
addUnsupportedLocalCommand("context");
} else if (!args) {
openContextModeSelector();
} else {
await sendMessage(raw);
}
},
goal: async (_args, raw) => {
if (opts.local === true && client.runGoalCommand) {
try {
const result = await client.runGoalCommand({
sessionKey: state.currentSessionKey,
agentId: state.currentAgentId,
command: raw,
});
chatLog.addSystem(result.text);
await refreshSessionInfo();
if (result.continuationPrompt) {
await sendMessage(result.continuationPrompt);
}
} catch (err) {
chatLog.addSystem(`goal failed: ${formatTuiErrorMessage(err)}`);
}
} else {
await sendMessage(raw);
}
},
btw: async (args, raw) => {
if (args) {
await sendMessage(raw);
} else {
chatLog.addSystem("Usage: /btw <side question>");
}
},
queue: async (_args, raw) => await sendMessage(raw),
openclaw: (args) => {
chatLog.addSystem(
args ? `returning to OpenClaw with request: ${args}` : "returning to OpenClaw",
);
requestExit({
exitReason: "return-to-system-agent",
...(args ? { systemAgentMessage: args } : {}),
});
},
session: async (args) => {
if (!args) {
await openSessionSelector();
} else {
await setSession(args);
}
},
sessions: async () => await openSessionSelector(),
model: async (args, raw) => {
if (shouldForwardModelCommandToServer(args)) {
await sendMessage(raw);
} else if (!args) {
await openModelSelector();
} else {
await applySessionSetting(
{ model: args },
(result) => {
const resolvedModel = result.resolved?.model;
const resolvedProvider = result.resolved?.modelProvider;
const resolvedModelRef = resolvedModel
? resolvedProvider
? modelKey(resolvedProvider, resolvedModel)
: resolvedModel
: args;
return `model set to ${resolvedModelRef}`;
},
"model set failed",
);
}
},
models: async () => await openModelSelector(),
think: async (args) => {
if (!args) {
const levels =
state.sessionInfo.thinkingLevels?.map((level) => level.label).join("|") ||
formatThinkingLevels(
state.sessionInfo.modelProvider,
state.sessionInfo.model,
"|",
undefined,
state.sessionInfo.agentRuntime?.id,
);
chatLog.addSystem(`usage: /think <${levels}>`);
return;
}
await applySessionSetting({ thinkingLevel: args }, `thinking set to ${args}`, "think failed");
},
verbose: async (args) => {
if (!args) {
chatLog.addSystem(`usage: ${formatTuiLevelCommandUsage("verbose")}`);
return;
}
await applySessionSetting(
{ verboseLevel: args },
`verbose set to ${args}`,
"verbose failed",
async () => {
if (args === "off") {
chatLog.clearTools();
await refreshSessionInfo();
} else {
await loadHistory();
}
},
);
},
trace: async (args) => {
if (!args) {
chatLog.addSystem("usage: /trace <on|off>");
return;
}
await applySessionSetting({ traceLevel: args }, `trace set to ${args}`, "trace failed");
},
fast: async (args) => {
if (!args || args === "status") {
chatLog.addSystem(`fast mode: ${formatTuiFastMode(state.sessionInfo.fastMode)}`);
return;
}
if (args !== "auto" && args !== "on" && args !== "off") {
chatLog.addSystem("usage: /fast <status|auto|on|off>");
return;
}
await applySessionSetting(
{ fastMode: args === "auto" ? "auto" : args === "on" },
`fast mode set to ${args}`,
"fast failed",
);
},
reasoning: async (args) => {
if (!args) {
chatLog.addSystem(`usage: ${formatTuiLevelCommandUsage("reasoning")}`);
return;
}
await applySessionSetting(
{ reasoningLevel: args },
`reasoning set to ${args}`,
"reasoning failed",
);
},
usage: async (args) => {
const isReset = args ? isSessionDefaultDirectiveValue(args) : false;
const normalized = args && !isReset ? normalizeUsageDisplay(args) : undefined;
if (args && !normalized && !isReset) {
chatLog.addSystem("usage: /usage <off|tokens|full|reset>");
return;
}
if (isReset) {
await applySessionSetting(
{ responseUsage: null },
"usage footer: reset to default",
"usage failed",
async () => {
delete state.sessionInfo.responseUsage;
delete state.sessionInfo.effectiveResponseUsage;
await refreshSessionInfo();
},
);
return;
}
const current =
state.sessionInfo.effectiveResponseUsage ??
resolveResponseUsageMode(state.sessionInfo.responseUsage);
const next =
normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
await applySessionSetting({ responseUsage: next }, `usage footer: ${next}`, "usage failed");
},
elevated: async (args) => {
if (!args) {
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
return;
}
if (!["on", "off", "ask", "full"].includes(args)) {
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
return;
}
await applySessionSetting(
{ elevatedLevel: args },
`elevated set to ${args}`,
"elevated failed",
);
},
activation: async (args) => {
if (!args) {
chatLog.addSystem("usage: /activation <mention|always>");
return;
}
const activation = normalizeGroupActivation(args);
if (!activation) {
chatLog.addSystem("usage: /activation <mention|always>");
return;
}
await applySessionSetting(
{ groupActivation: activation },
`activation set to ${activation}`,
"activation failed",
);
},
new: async () => {
if (rejectUnsafeSessionRollover("new")) {
return;
}
const finishSessionTransition = beginSessionTransition("new");
try {
// Clear token counts immediately to avoid stale display (#1523)
state.sessionInfo.inputTokens = null;
state.sessionInfo.outputTokens = null;
state.sessionInfo.totalTokens = null;
tui.requestRender();
const uniqueKey = `tui-${randomUUID()}`;
const result = await client.createSession({
key: uniqueKey,
agentId: state.currentAgentId,
...(state.currentSessionId
? { parentSessionKey: state.currentSessionKey, succeedsParent: true }
: {}),
});
if (!result.key) {
throw new Error("sessions.create returned no session key");
}
await setSession(result.key);
chatLog.addSystem(`new session: ${result.key}`);
} catch (err) {
chatLog.addSystem(`new session failed: ${formatTuiErrorMessage(err)}`);
} finally {
finishSessionTransition();
}
},
reset: async () => {
if (rejectUnsafeSessionRollover("reset")) {
return;
}
const resetSelection = captureSessionSelection();
let resetResultSelection = resetSelection;
const finishSessionTransition = beginSessionTransition("reset");
try {
// Clear token counts immediately to avoid stale display (#1523)
state.sessionInfo.inputTokens = null;
state.sessionInfo.outputTokens = null;
state.sessionInfo.totalTokens = null;
tui.requestRender();
const result = await client.resetSession(
resetSelection.sessionKey,
"reset",
resetSelection.sessionKey === "global" ? { agentId: resetSelection.agentId } : undefined,
);
if (!isCurrentSessionSelection(resetSelection)) {
return;
}
if (applySessionMutationResult(result, resetSelection)) {
resetResultSelection = captureSessionSelection();
await refreshSessionInfo();
} else {
await loadHistory();
}
if (!isCurrentSessionSelection(resetResultSelection)) {
return;
}
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
} catch (err) {
if (!isCurrentSessionSelection(resetResultSelection)) {
return;
}
chatLog.addSystem(`reset failed: ${formatTuiErrorMessage(err)}`);
} finally {
finishSessionTransition();
}
},
abort: async () => await abortActive(),
stop: async () => {
// Queued client runs can terminalize before the followup executes, so
// local run ids are not a complete stop target inventory.
await abortActive({ preferActive: true });
},
settings: () => openSettings(),
exit: () => requestExit(),
} satisfies Record<TuiCommandHandlerName, CommandHandler>;
const handleCommand = async (raw: string) => {
const { name, args } = parseCommand(raw);
if (!name) {
return;
}
if (sessionTransition.active && name !== "exit" && name !== "quit") {
const descriptor = resolveTuiCommandDescriptor(name);
if (sessionTransition.active && descriptor?.name !== "exit") {
chatLog.addSystem(
`session change in progress; wait for /${sessionTransition.active} to finish`,
);
tui.requestRender();
return;
}
switch (name) {
case "help":
chatLog.addSystem(
helpText({
local: opts.local,
provider: state.sessionInfo.modelProvider,
model: state.sessionInfo.model,
agentRuntime: state.sessionInfo.agentRuntime?.id,
}),
);
break;
case "auth": {
if (!runAuthFlow) {
chatLog.addSystem("auth login is only available in local embedded mode");
break;
}
if (state.activeChatRunId || hasPendingSubmit(state)) {
chatLog.addSystem("abort the current run before /auth");
break;
}
const provider = args.trim() || state.sessionInfo.modelProvider || undefined;
chatLog.addSystem(
provider
? `opening auth flow for ${provider}; TUI will resume when it exits`
: "opening auth flow; TUI will resume when it exits",
);
tui.requestRender();
setActivityStatus("auth");
try {
const result = await runAuthFlow({ provider });
await refreshSessionInfo();
if (result.exitCode === 0 && !result.signal) {
chatLog.addSystem(
provider ? `auth flow finished for ${provider}` : "auth flow finished",
);
setActivityStatus("idle");
} else {
const failureSuffix = result.signal
? ` (signal ${result.signal})`
: typeof result.exitCode === "number"
? ` (exit ${String(result.exitCode)})`
: "";
chatLog.addSystem(`auth flow failed${failureSuffix}`);
setActivityStatus("error");
}
} catch (err) {
chatLog.addSystem(`auth flow failed: ${formatTuiErrorMessage(err)}`);
setActivityStatus("error");
}
break;
}
case "gateway-status":
try {
const status = await client.getGatewayStatus();
if (typeof status === "string") {
chatLog.addSystem(status);
break;
}
if (status && typeof status === "object") {
const lines = formatStatusSummary(status as GatewayStatusSummary);
for (const line of lines) {
chatLog.addSystem(line);
}
break;
}
chatLog.addSystem("status: unknown response");
} catch (err) {
chatLog.addSystem(`status failed: ${formatTuiErrorMessage(err)}`);
}
break;
case "agent":
if (!args) {
await openAgentSelector();
} else {
await setAgent(args);
}
break;
case "agents":
await openAgentSelector();
break;
case "context":
if (opts.local) {
addUnsupportedLocalCommand(name);
} else if (!args) {
openContextModeSelector();
} else {
await sendMessage(raw);
}
break;
case "goal":
if (opts.local === true && client.runGoalCommand) {
try {
const result = await client.runGoalCommand({
sessionKey: state.currentSessionKey,
agentId: state.currentAgentId,
command: raw,
});
chatLog.addSystem(result.text);
await refreshSessionInfo();
if (result.continuationPrompt) {
await sendMessage(result.continuationPrompt);
}
} catch (err) {
chatLog.addSystem(`goal failed: ${formatTuiErrorMessage(err)}`);
}
} else {
await sendMessage(raw);
}
break;
case "btw":
if (args) {
await sendMessage(raw);
} else {
chatLog.addSystem("Usage: /btw [side question]");
}
break;
case "queue":
await sendMessage(raw);
break;
case "openclaw":
chatLog.addSystem(
args ? `returning to OpenClaw with request: ${args}` : "returning to OpenClaw",
);
requestExit({
exitReason: "return-to-system-agent",
...(args ? { systemAgentMessage: args } : {}),
});
break;
case "session":
if (!args) {
await openSessionSelector();
} else {
await setSession(args);
}
break;
case "sessions":
await openSessionSelector();
break;
case "model":
if (shouldForwardModelCommandToServer(args)) {
await sendMessage(raw);
} else if (!args) {
await openModelSelector();
} else {
await applySessionSetting(
{ model: args },
(result) => {
const resolvedModel = result.resolved?.model;
const resolvedProvider = result.resolved?.modelProvider;
const resolvedModelRef = resolvedModel
? resolvedProvider
? modelKey(resolvedProvider, resolvedModel)
: resolvedModel
: args;
return `model set to ${resolvedModelRef}`;
},
"model set failed",
);
}
break;
case "models":
await openModelSelector();
break;
case "think":
if (!args) {
const levels =
state.sessionInfo.thinkingLevels?.map((level) => level.label).join("|") ||
formatThinkingLevels(
state.sessionInfo.modelProvider,
state.sessionInfo.model,
"|",
undefined,
state.sessionInfo.agentRuntime?.id,
);
chatLog.addSystem(`usage: /think <${levels}>`);
break;
}
await applySessionSetting(
{ thinkingLevel: args },
`thinking set to ${args}`,
"think failed",
);
break;
case "verbose":
if (!args) {
chatLog.addSystem(`usage: ${formatTuiLevelCommandUsage("verbose")}`);
break;
}
await applySessionSetting(
{ verboseLevel: args },
`verbose set to ${args}`,
"verbose failed",
async () => {
if (args === "off") {
chatLog.clearTools();
await refreshSessionInfo();
} else {
await loadHistory();
}
},
);
break;
case "trace":
if (!args) {
chatLog.addSystem("usage: /trace <on|off>");
break;
}
await applySessionSetting({ traceLevel: args }, `trace set to ${args}`, "trace failed");
break;
case "fast":
if (!args || args === "status") {
chatLog.addSystem(`fast mode: ${formatTuiFastMode(state.sessionInfo.fastMode)}`);
break;
}
if (args !== "auto" && args !== "on" && args !== "off") {
chatLog.addSystem("usage: /fast <status|auto|on|off>");
break;
}
await applySessionSetting(
{ fastMode: args === "auto" ? "auto" : args === "on" },
`fast mode set to ${args}`,
"fast failed",
);
break;
case "reasoning":
if (!args) {
chatLog.addSystem(`usage: ${formatTuiLevelCommandUsage("reasoning")}`);
break;
}
await applySessionSetting(
{ reasoningLevel: args },
`reasoning set to ${args}`,
"reasoning failed",
);
break;
case "usage": {
const isReset = args ? isSessionDefaultDirectiveValue(args) : false;
const normalized = args && !isReset ? normalizeUsageDisplay(args) : undefined;
if (args && !normalized && !isReset) {
chatLog.addSystem("usage: /usage <off|tokens|full|reset>");
break;
}
if (isReset) {
await applySessionSetting(
{ responseUsage: null },
"usage footer: reset to default",
"usage failed",
async () => {
delete state.sessionInfo.responseUsage;
delete state.sessionInfo.effectiveResponseUsage;
await refreshSessionInfo();
},
);
break;
}
const current =
state.sessionInfo.effectiveResponseUsage ??
resolveResponseUsageMode(state.sessionInfo.responseUsage);
const next =
normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
await applySessionSetting({ responseUsage: next }, `usage footer: ${next}`, "usage failed");
break;
}
case "elevated":
if (!args) {
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
break;
}
if (!["on", "off", "ask", "full"].includes(args)) {
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
break;
}
await applySessionSetting(
{ elevatedLevel: args },
`elevated set to ${args}`,
"elevated failed",
);
break;
case "activation": {
if (!args) {
chatLog.addSystem("usage: /activation <mention|always>");
break;
}
const activation = normalizeGroupActivation(args);
if (!activation) {
chatLog.addSystem("usage: /activation <mention|always>");
break;
}
await applySessionSetting(
{ groupActivation: activation },
`activation set to ${activation}`,
"activation failed",
);
break;
}
case "new": {
if (rejectUnsafeSessionRollover("new")) {
break;
}
const finishSessionTransition = beginSessionTransition("new");
try {
// Clear token counts immediately to avoid stale display (#1523)
state.sessionInfo.inputTokens = null;
state.sessionInfo.outputTokens = null;
state.sessionInfo.totalTokens = null;
tui.requestRender();
const uniqueKey = `tui-${randomUUID()}`;
const result = await client.createSession({
key: uniqueKey,
agentId: state.currentAgentId,
...(state.currentSessionId
? { parentSessionKey: state.currentSessionKey, succeedsParent: true }
: {}),
});
if (!result.key) {
throw new Error("sessions.create returned no session key");
}
await setSession(result.key);
chatLog.addSystem(`new session: ${result.key}`);
} catch (err) {
chatLog.addSystem(`new session failed: ${formatTuiErrorMessage(err)}`);
} finally {
finishSessionTransition();
}
break;
}
case "reset": {
if (rejectUnsafeSessionRollover("reset")) {
break;
}
const resetSelection = captureSessionSelection();
let resetResultSelection = resetSelection;
const finishSessionTransition = beginSessionTransition("reset");
try {
// Clear token counts immediately to avoid stale display (#1523)
state.sessionInfo.inputTokens = null;
state.sessionInfo.outputTokens = null;
state.sessionInfo.totalTokens = null;
tui.requestRender();
const result = await client.resetSession(
resetSelection.sessionKey,
name,
resetSelection.sessionKey === "global"
? { agentId: resetSelection.agentId }
: undefined,
);
if (!isCurrentSessionSelection(resetSelection)) {
return;
}
if (applySessionMutationResult(result, resetSelection)) {
resetResultSelection = captureSessionSelection();
await refreshSessionInfo();
} else {
await loadHistory();
}
if (!isCurrentSessionSelection(resetResultSelection)) {
return;
}
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
} catch (err) {
if (!isCurrentSessionSelection(resetResultSelection)) {
return;
}
chatLog.addSystem(`reset failed: ${formatTuiErrorMessage(err)}`);
} finally {
finishSessionTransition();
}
break;
}
case "abort":
await abortActive();
break;
case "stop":
// Queued client runs can terminalize before the followup executes, so
// local run ids are not a complete stop target inventory.
await abortActive({ preferActive: true });
break;
case "settings":
openSettings();
break;
case "exit":
case "quit":
requestExit();
break;
default: {
if (opts.local && isSharedTextCommand(raw)) {
addUnsupportedLocalCommand(name);
break;
}
await sendMessage(raw);
break;
}
if (descriptor?.handler) {
await commandHandlers[descriptor.name as TuiCommandHandlerName](args, raw);
} else if (opts.local && isSharedTextCommand(raw)) {
addUnsupportedLocalCommand(name);
} else {
await sendMessage(raw);
}
tui.requestRender();
};
+5
View File
@@ -872,6 +872,11 @@ describe.sequential("TUI PTY harness", () => {
await fixture.run.waitForOutput("/help");
await fixture.run.waitForOutput("/verbose <on|off|full>");
await fixture.run.waitForOutput("/reasoning <on|off|stream>");
await fixture.run.waitForOutput("/goal");
await fixture.run.waitForOutput("/goal start <objective>");
await fixture.run.waitForOutput("/btw <side question>");
await fixture.run.waitForOutput("/queue");
await fixture.run.waitForOutput("/stop");
await fixture.run.waitForOutput("/exit");
},
TEST_TIMEOUT_MS,
+1 -1
View File
@@ -876,7 +876,7 @@ describe("TUI PTY real backends", () => {
);
}
await fixture.run.write("/side\r");
await fixture.run.waitForOutput("Usage: /btw [side question]");
await fixture.run.waitForOutput("Usage: /btw <side question>");
expect(fixture.mockModel.requests()).toHaveLength(0);
await fixture.run.write("slow local parent\r");