diff --git a/docs/web/tui.md b/docs/web/tui.md index 583e1e5a0187..f4d725f1bd9d 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -115,7 +115,8 @@ Session controls: - `/trace ` - `/reasoning ` - `/usage ` (`reset`/`inherit`/`clear`/`default` clears the session override) -- `/goal [status] | /goal start | /goal edit | /goal pause|resume|complete|block|clear` +- `/goal | /goal [status] | /goal start | /goal edit | /goal pause|resume|complete|block|clear` +- `/btw ` (alias: `/side`; asks without changing future session context) - `/elevated ` (alias: `/elev`) - `/activation ` - `/queue [debounce:] [cap:] [drop:]` @@ -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`) diff --git a/src/commands/doctor-session-sqlite-recover-report.ts b/src/commands/doctor-session-sqlite-recover-report.ts index 65e567b8f3a4..a8c0de77d329 100644 --- a/src/commands/doctor-session-sqlite-recover-report.ts +++ b/src/commands/doctor-session-sqlite-recover-report.ts @@ -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), + }), }; } diff --git a/src/commands/doctor-session-sqlite-restore-report.ts b/src/commands/doctor-session-sqlite-restore-report.ts index ed9e14487b13..8acf20460933 100644 --- a/src/commands/doctor-session-sqlite-restore-report.ts +++ b/src/commands/doctor-session-sqlite-restore-report.ts @@ -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), }; } diff --git a/src/commands/doctor-session-sqlite-types.ts b/src/commands/doctor-session-sqlite-types.ts index fe20617fddee..53689c1f805d 100644 --- a/src/commands/doctor-session-sqlite-types.ts +++ b/src/commands/doctor-session-sqlite-types.ts @@ -116,6 +116,26 @@ export type DoctorSessionSqliteTargetReport = { restore?: DoctorSessionSqliteRestoreReport; }; +export function createDoctorSessionSqliteTargetReport( + values: Pick & + Partial>, +): 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"] { + 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, + }; +} diff --git a/src/commands/doctor-session-sqlite.test.ts b/src/commands/doctor-session-sqlite.test.ts index 78910159596b..953ae54d842f 100644 --- a/src/commands/doctor-session-sqlite.test.ts +++ b/src/commands/doctor-session-sqlite.test.ts @@ -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); diff --git a/src/commands/doctor-session-sqlite.ts b/src/commands/doctor-session-sqlite.ts index 2c01230a400c..1ba02652f490 100644 --- a/src/commands/doctor-session-sqlite.ts +++ b/src/commands/doctor-session-sqlite.ts @@ -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. */ diff --git a/src/cron/service/jobs-scheduling.ts b/src/cron/service/jobs-scheduling.ts index f5c716de9229..3215288be3f8 100644 --- a/src/cron/service/jobs-scheduling.ts +++ b/src/cron/service/jobs-scheduling.ts @@ -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(); +// 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 }, diff --git a/src/cron/service/ops-run-preparation.ts b/src/cron/service/ops-run-preparation.ts index df9473eacd31..c8b1589cb032 100644 --- a/src/cron/service/ops-run-preparation.ts +++ b/src/cron/service/ops-run-preparation.ts @@ -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, diff --git a/src/cron/service/ops.run-admission-cleanup.test.ts b/src/cron/service/ops.run-admission-cleanup.test.ts index 9d0120c01210..b6d389966075 100644 --- a/src/cron/service/ops.run-admission-cleanup.test.ts +++ b/src/cron/service/ops.run-admission-cleanup.test.ts @@ -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, diff --git a/src/cron/service/run-admission.ts b/src/cron/service/run-admission.ts index fdf6a2547381..27a1dd22624d 100644 --- a/src/cron/service/run-admission.ts +++ b/src/cron/service/run-admission.ts @@ -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; +}): 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", + }; } /** diff --git a/src/cron/service/timer-catchup.ts b/src/cron/service/timer-catchup.ts index 8f3fb0cbd0d2..9827d56fe2e5 100644 --- a/src/cron/service/timer-catchup.ts +++ b/src/cron/service/timer-catchup.ts @@ -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; diff --git a/src/cron/service/timer-scheduler.ts b/src/cron/service/timer-scheduler.ts index e51e7c4b8f56..af643412dba8 100644 --- a/src/cron/service/timer-scheduler.ts +++ b/src/cron/service/timer-scheduler.ts @@ -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; diff --git a/src/tui/commands.test.ts b/src/tui/commands.test.ts index 10282b24533c..1cad9ce14fa0 100644 --- a/src/tui/commands.test.ts +++ b/src/tui/commands.test.ts @@ -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 "); + expect(output).toContain("/goal edit "); + expect(output).toContain("/btw "); + expect(output).not.toContain("/btw [side question]"); + }); + it("does not advertise Gateway-owned commands in local mode", () => { const output = helpText({ local: true }); diff --git a/src/tui/commands.ts b/src/tui/commands.ts index c8b8f289da0a..d284e1814d37 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -38,16 +38,6 @@ type SlashCommandOptions = { dynamicCommands?: CommandEntry[]; }; -const COMMAND_ALIASES: Record = { - 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 { @@ -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 & { 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 (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 (or /sessions)"], + ["sessions", "Open session picker"], + ["model", "Set model (or open picker)", "/model (or /models)"], + ["models", "Open model picker"], + ["think", "Set thinking level", "/think <{thinkingLevels}>", "thinking"], + ["fast", "Set fast mode auto/on/off", "/fast ", FAST_LEVELS], + [ + "verbose", + `Set verbose ${VERBOSE_LEVELS.join("/")}`, + formatTuiLevelCommandUsage("verbose"), + VERBOSE_LEVELS, + ], + ["trace", "Set trace on/off", "/trace ", TRACE_LEVELS], + [ + "reasoning", + `Set reasoning ${REASONING_LEVELS.join("/")}`, + formatTuiLevelCommandUsage("reasoning"), + REASONING_LEVELS, + ], + [ + "usage", + "Toggle per-response usage line", + "/usage ", + USAGE_FOOTER_LEVELS, + ], + [ + "elevated", + "Set elevated on/off/ask/full", + ["/elevated ", "/elev "], + ELEVATED_LEVELS, + { aliases: [{ name: "elev", description: "Alias for /elevated" }] }, + ], + ["activation", "Set group activation", "/activation ", ACTIVATION_LEVELS], + ["context", undefined, undefined, undefined, { scope: "remote", shared: true }], + [ + "goal", + undefined, + "/goal | /goal [status] | /goal start | /goal edit | /goal pause|resume|complete|block|clear", + undefined, + { shared: true }, + ], + ["btw", undefined, "/btw ", 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, 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(); + 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 (or /agents)", - "/openclaw [request]", - "/session (or /sessions)", - "/model (or /models)", - `/think <${thinkLevels}>`, - "/fast ", - formatTuiLevelCommandUsage("verbose"), - "/trace ", - formatTuiLevelCommandUsage("reasoning"), - "/usage ", - "/elevated ", - "/elev ", - "/activation ", - "/new or /reset", - "/abort", - "/settings", - "/exit", + ...commandHelp, "", "Keyboard shortcuts:", "Enter: send message", diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 12691f08392e..544fd84d3dfe 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -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 "); const side = createHarness({ opts: { local: true } }); await side.handleCommand("/side check this"); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index c7c617d076a9..bb0a0a59b00a 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -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; + 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 "); + } + }, + 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 "); + 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 "); + 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 "); + 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 "); + return; + } + if (!["on", "off", "ask", "full"].includes(args)) { + chatLog.addSystem("usage: /elevated "); + return; + } + await applySessionSetting( + { elevatedLevel: args }, + `elevated set to ${args}`, + "elevated failed", + ); + }, + activation: async (args) => { + if (!args) { + chatLog.addSystem("usage: /activation "); + return; + } + const activation = normalizeGroupActivation(args); + if (!activation) { + chatLog.addSystem("usage: /activation "); + 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; + 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 "); - 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 "); - 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 "); - 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 "); - break; - } - if (!["on", "off", "ask", "full"].includes(args)) { - chatLog.addSystem("usage: /elevated "); - break; - } - await applySessionSetting( - { elevatedLevel: args }, - `elevated set to ${args}`, - "elevated failed", - ); - break; - case "activation": { - if (!args) { - chatLog.addSystem("usage: /activation "); - break; - } - const activation = normalizeGroupActivation(args); - if (!activation) { - chatLog.addSystem("usage: /activation "); - 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(); }; diff --git a/src/tui/tui-pty-harness.e2e.test.ts b/src/tui/tui-pty-harness.e2e.test.ts index dabc249365f4..a0fe30ffa5c5 100644 --- a/src/tui/tui-pty-harness.e2e.test.ts +++ b/src/tui/tui-pty-harness.e2e.test.ts @@ -872,6 +872,11 @@ describe.sequential("TUI PTY harness", () => { await fixture.run.waitForOutput("/help"); await fixture.run.waitForOutput("/verbose "); await fixture.run.waitForOutput("/reasoning "); + await fixture.run.waitForOutput("/goal"); + await fixture.run.waitForOutput("/goal start "); + await fixture.run.waitForOutput("/btw "); + await fixture.run.waitForOutput("/queue"); + await fixture.run.waitForOutput("/stop"); await fixture.run.waitForOutput("/exit"); }, TEST_TIMEOUT_MS, diff --git a/src/tui/tui-pty-local.e2e.test.ts b/src/tui/tui-pty-local.e2e.test.ts index 3233ad4f04fd..da6624361288 100644 --- a/src/tui/tui-pty-local.e2e.test.ts +++ b/src/tui/tui-pty-local.e2e.test.ts @@ -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 "); expect(fixture.mockModel.requests()).toHaveLength(0); await fixture.run.write("slow local parent\r");