mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
fe6fa891ea
commit
a6f9da8bdb
+3
-1
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user