From 48ec2e77d5bd3e12b6b37aec7f02f09644ebc6f5 Mon Sep 17 00:00:00 2001
From: xunilzita
Date: Tue, 25 Aug 2026 12:53:50 +0200
Subject: [PATCH] feat: add Spanish (es) locale for Dashboard and CLI
Add full Spanish i18n alongside English and Chinese so SuperTask can be
used natively by Spanish-speaking operators without losing upgrades.
- Dashboard: ES message catalog, language switcher, Accept-Language/cookie
- CLI: --lang es / SUPERTASK_LANG=es and Spanish system locale auto-detect
- Docs: README.es.md, changelog, operations notes
- Tests: CLI language coverage and browser language switch smoke
---
CHANGELOG.md | 7 +
README.es.md | 240 ++++++++++++++++++++
README.md | 6 +-
README.zh-CN.md | 2 +-
docs/README.md | 2 +-
docs/operations.md | 2 +-
src/cli/database-output.ts | 78 ++++++-
src/cli/i18n.ts | 13 +-
src/cli/index.ts | 280 ++++++++++++------------
src/web/index.tsx | 29 ++-
src/web/ui.ts | 339 ++++++++++++++++++++++++++++-
tests/browser/dashboard.browser.ts | 4 +
tests/cli-language.test.ts | 10 +
13 files changed, 845 insertions(+), 167 deletions(-)
create mode 100644 README.es.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62be8d0..ea393a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## Unreleased
+
+### Added
+- Dashboard and CLI now support Spanish (`es`) alongside English and Chinese.
+- CLI `--lang es` / `SUPERTASK_LANG=es`, and automatic selection from Spanish system locales.
+- Dashboard language switcher includes **ES**; `Accept-Language` and `supertask_locale` negotiate `es`.
+
# Changelog
All notable user-facing changes are recorded here. This project follows semantic versioning while it is in the `0.x` development series.
diff --git a/README.es.md b/README.es.md
new file mode 100644
index 0000000..58cd5d3
--- /dev/null
+++ b/README.es.md
@@ -0,0 +1,240 @@
+# SuperTask
+
+Encola. Programa. Reintenta. Sabe qué ocurrió.
+
+
+
+
+
+
+
+
+ English | 简体中文 | Español
+
+
+SuperTask convierte los comandos puntuales `opencode run` en operaciones durables de agentes. Ofrece a los agentes de OpenCode una cola SQLite persistente, programación, reintentos, control de concurrencia, cancelación segura, historial de ejecución y un panel web local.
+
+OpenCode puede ejecutar un agente ahora. SuperTask garantiza que el trabajo siga rastreado tras cerrar el terminal, si el proceso falla o si la máquina se reinicia.
+
+## ¿Por qué SuperTask?
+
+| Si necesitas... | Usa |
+| --- | --- |
+| Ejecutar un agente una sola vez | `opencode run` |
+| Ejecutar unos pocos comandos fijos a horas fijas | `cron`, `launchd`, `systemd` o GitHub Actions |
+| Reiniciar un proceso de larga duración con una programación | PM2 `cron_restart` |
+| Gestionar trabajos de agentes variables con estado durable, reintentos, prioridades e historial | **SuperTask** |
+
+SuperTask no es otro envoltorio sobre cron. El trabajo programado se convierte en una tarea normal de la cola durable, de modo que los trabajos manuales y programados comparten las mismas reglas de concurrencia, reintento, cancelación, dependencia e historial.
+
+## Qué obtienes
+
+| Capacidad | Qué significa |
+| --- | --- |
+| Cola durable | Las tareas y cada ejecución sobreviven a reinicios de proceso y de máquina en SQLite WAL |
+| Tres tipos de programación | Cron, retraso de una sola vez e intervalo fijo recurrente |
+| Recuperación automática | Presupuesto de reintentos, backoff exponencial, estado dead-letter y reintento manual |
+| Ejecución controlada | Concurrencia global, orden por prioridad, dependencias y serialización global por lote |
+| Conciencia de proyecto | Cada tarea conserva su directorio de proyecto OpenCode, agente, modelo y variante opcional |
+| Manejo seguro de procesos | Cancelación y apagado esperan a que el grupo de procesos Unix gestionado de OpenCode se drene |
+| Ejecuciones observables | ID de sesión, comando exacto reproducible, salida del modelo, herramientas, errores y JSONL en bruto |
+| Panel local | Crear, programar, inspeccionar, reintentar, cancelar y diagnosticar desde `127.0.0.1` |
+
+## Inicio rápido en tres minutos
+
+### 1. Instala una versión exacta
+
+```bash
+VERSION="$(npm view opencode-supertask dist-tags.latest)"
+npm install -g "opencode-supertask@$VERSION"
+opencode plugin "opencode-supertask@$VERSION" --global --force
+```
+
+Fijar la versión exacta mantiene alineados el plugin de OpenCode, la CLI global y el Gateway. No lo sustituyas por el nombre del paquete sin versión ni por `@latest` en `opencode.json`.
+
+### 2. Reinicia OpenCode e inicia el Gateway
+
+```bash
+supertask install # recomendado: arranque con PM2, recuperación ante fallos y rotación de logs
+```
+
+Para desarrollo en primer plano:
+
+```bash
+supertask gateway
+```
+
+El plugin nunca instala servicios globales al arrancar OpenCode. La configuración de PM2 solo ocurre cuando ejecutas explícitamente `supertask install`.
+
+### 3. Pide a OpenCode que cree una tarea
+
+```text
+Crea una SuperTask llamada "Revisar errores de API".
+Usa el agente build en este proyecto, reintenta dos veces y ejecútala ahora.
+```
+
+OpenCode recibe ocho herramientas nativas del plugin `supertask_*`. El directorio del proyecto actual se toma del contexto de herramientas de OpenCode y no se confía en la entrada del modelo.
+
+### 4. Observa la ejecución
+
+```bash
+supertask status
+supertask list --limit 10
+supertask ui
+```
+
+El panel se abre en .
+
+## Cómo funciona
+
+```mermaid
+flowchart LR
+ A[Herramientas OpenCode / CLI / Panel] --> B[Cola de tareas SQLite]
+ B --> C[Gateway]
+ C --> D[Worker]
+ C --> E[Scheduler]
+ C --> F[Watchdog]
+ D --> G[opencode run]
+ G --> H[Historial de ejecución y sesión]
+```
+
+Un único Gateway posee las transiciones de estado en tiempo de ejecución. Los clientes crean y gestionan trabajo; solo el Gateway marca las ejecuciones como iniciadas, completadas, fallidas, reintentadas o canceladas.
+
+## Úsalo a tu manera
+
+### Lenguaje natural en OpenCode
+
+```text
+Ejecuta una revisión de seguridad con el agente build, el modelo provider/model y la variante high.
+
+Cada día laborable a las 9:00, crea una tarea de informe para este proyecto.
+
+Muestra las tareas fallidas de este proyecto y reintenta las recuperables.
+
+Comprueba si el lote "release" se está ejecutando en otro proyecto.
+```
+
+Herramientas del plugin disponibles:
+
+```text
+supertask_add supertask_schedule supertask_status supertask_retry
+supertask_list supertask_get supertask_next supertask_upgrade
+```
+
+### CLI
+
+```bash
+# Encolar trabajo
+supertask add --name "Revisión de seguridad" --agent build \
+ --model openai/gpt-5.6-sol --variant xhigh \
+ --prompt "Revisar autenticación y autorización" \
+ --importance 5 --urgency 4 --max-retries 2 \
+ --retry-backoff 30s --timeout 30min
+
+# Programar trabajo
+supertask template add --name "Informe laborable" --agent build \
+ --model openai/gpt-5.6-sol --variant high \
+ --prompt "Resumir cambios importantes del proyecto" \
+ --type cron --cron "0 9 * * 1-5"
+
+# Inspeccionar y recuperar
+supertask status
+supertask list --status failed --limit 20
+supertask retry --id 42
+supertask cancel --id 42
+```
+
+Ejecuta `supertask --help` o `supertask --help` para la superficie completa de comandos. La ayuda de la CLI y los diagnósticos legibles admiten `auto`, `en`, `es` y `zh-CN`.
+
+## Panel
+
+El panel adaptable admite inglés, español y chino, temas claro y oscuro, y cuatro vistas centradas:
+
+| Página | Propósito |
+| --- | --- |
+| Cola de tareas | Explorar proyectos, crear/editar tareas, ver prioridades y estado activo, reintentar, cancelar o eliminar con seguridad |
+| Tareas programadas | Crear/editar plantillas cron, retrasadas y recurrentes; ejecutar una de inmediato sin saltarse la cola |
+| Registros de ejecución | Leer salida estructurada, herramientas, errores, sesiones y el comando histórico exacto |
+| Estado del sistema | Inspeccionar la configuración activa, la salud, la concurrencia y el mantenimiento de la base de datos con copia de seguridad previa |
+
+El selector de proyectos lee la salida real de `opencode agent list` y `opencode models --verbose` del directorio elegido, de modo que los formularios solo ofrecen modelos disponibles localmente, las variantes declaradas de cada modelo y agentes directamente ejecutables. Dejar la variante en su valor predeterminado omite `--variant` y sigue la configuración del agente/modelo.
+
+## Fiabilidad sin rodeos
+
+- SQLite `BEGIN IMMEDIATE` protege el bloqueo de un solo Gateway y la serialización global por lote.
+- La selección de candidatos y la transición a `running` ocurren en una sola transacción inmediata, de modo que ediciones concurrentes no pueden alterar una tarea reclamada.
+- Cada ejecución gestionada tiene una identidad de lanzador única y un grupo de procesos Unix aislado.
+- Una ejecución solo se cierra después de que el lanzador demuestra que todo el grupo de procesos se ha drenado.
+- La contención de procesos termina en ese grupo: los descendientes que llaman deliberadamente a `setsid()` o arrancan como daemons desacoplados deben gestionar su propio ciclo de vida.
+- El apagado y la cancelación fallan de forma cerrada cuando no se puede demostrar la propiedad del proceso.
+- `supertask doctor` verifica OpenCode, el plugin fijado efectivo, la caché, la CLI, el paquete del Gateway, el bloqueo de listo, SQLite, el panel y el entorno PM2.
+- Vaciar y restaurar la base de datos son operaciones transaccionales, con copia de seguridad previa, coherentes con WAL y rechazan el trabajo activo.
+
+Las garantías detalladas y las reglas de recuperación están en [Architecture](docs/architecture.md) y [Operations and Troubleshooting](docs/operations.md).
+
+## Actualizar y diagnosticar
+
+```bash
+supertask upgrade # actualizar solo cuando las versiones o componentes hayan divergido
+supertask upgrade --force # reinstalar la versión actual, refrescar el entorno y reiniciar
+supertask doctor
+supertask doctor --smoke --smoke-agent build --smoke-model provider/model --smoke-variant high
+```
+
+Cuando todos los componentes ya coinciden con npm `latest`, la actualización normal no hace nada y no reinicia el Gateway. Los diagnósticos smoke realizan una llamada real al modelo; el `doctor` ordinario no.
+
+## Requisitos
+
+- OpenCode
+- Bun 1.1.45 o posterior
+- Node.js/npm para el flujo documentado de instalación y actualización
+- macOS o Linux para la ejecución de tareas del Gateway
+
+La ejecución del Worker en Windows permanece deshabilitada hasta que el aislamiento con OS Job Object pueda ofrecer un drenado gestionado equivalente y una prueba recuperable. La ejecución de la cola no requiere PM2 cuando el Gateway corre en primer plano.
+
+## Instalar desde el código fuente
+
+```bash
+git clone https://github.com/vbgate/opencode-supertask.git
+cd opencode-supertask
+bun install
+bun run build
+```
+
+Apunta OpenCode al archivo del plugin construido:
+
+```json
+{
+ "plugin": [
+ "file:///home/user/src/opencode-supertask/dist/plugin/supertask.js"
+ ]
+}
+```
+
+Luego reinicia OpenCode y ejecuta `bun run gateway` desde el repositorio.
+
+## Documentación
+
+- [Operaciones y resolución de problemas](docs/operations.md)
+- [Arquitectura y decisiones actuales](docs/architecture.md)
+- [Changelog](CHANGELOG.md)
+- [Índice de documentación](docs/README.md)
+- [Reglas para contribuidores y agentes](AGENTS.md)
+
+## Desarrollo
+
+```bash
+bun install --frozen-lockfile
+bun test
+bun run typecheck
+bun run typecheck:tests
+bun run lint
+bun run test:coverage
+bun run test:browser
+bun run build
+bun run package:smoke
+```
+
+## Licencia
+
+MIT
diff --git a/README.md b/README.md
index e30208c..6ffd1e5 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
- English | 简体中文
+ English | 简体中文 | Español
SuperTask turns one-off `opencode run` commands into durable Agent operations. It gives OpenCode agents a persistent SQLite queue, scheduling, retries, concurrency control, safe cancellation, execution history, and a local Web Dashboard.
@@ -144,11 +144,11 @@ supertask retry --id 42
supertask cancel --id 42
```
-Run `supertask --help` or `supertask --help` for the complete command surface. CLI help and human-readable diagnostics support `auto`, `en`, and `zh-CN`.
+Run `supertask --help` or `supertask --help` for the complete command surface. CLI help and human-readable diagnostics support `auto`, `en`, `es`, and `zh-CN`.
## Dashboard
-The responsive Dashboard supports English and Chinese, light and dark themes, and four focused views:
+The responsive Dashboard supports English, Spanish, and Chinese, light and dark themes, and four focused views:
| Page | Purpose |
| --- | --- |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 86b26ff..7cd25bc 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -144,7 +144,7 @@ supertask retry --id 42
supertask cancel --id 42
```
-运行 `supertask --help` 或 `supertask <命令> --help` 查看完整参数。CLI 帮助和人类可读诊断支持 `auto`、`zh-CN` 和 `en`。
+运行 `supertask --help` 或 `supertask <命令> --help` 查看完整参数。CLI 帮助和人类可读诊断支持 `auto`、`zh-CN`、`en` 和 `es`。
## Web 控制台
diff --git a/docs/README.md b/docs/README.md
index 6d2f3df..c95d0dc 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -6,7 +6,7 @@
| 文档 | 用途 | 权威范围 |
|---|---|---|
-| [README](../README.md) / [简体中文](../README.zh-CN.md) | 产品定位、安装、快速开始、CLI 摘要 | 面向使用者的双语入口 |
+| [README](../README.md) / [简体中文](../README.zh-CN.md) / [Español](../README.es.md) | 产品定位、安装、快速开始、CLI 摘要 | 面向使用者的多语言入口 |
| [CHANGELOG](../CHANGELOG.md) | 各稳定版本与未发布变更 | 面向升级与发布核对 |
| [当前架构与决策](./architecture.md) | 组件边界、执行链路、状态语义、架构取舍 | 当前源码架构的权威说明 |
| [运行与排障手册](./operations.md) | 启停、PM2、配置、重试、备份、故障定位 | 当前运行行为的权威说明 |
diff --git a/docs/operations.md b/docs/operations.md
index b1d75ac..1780f22 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -89,7 +89,7 @@ PM2 生命周期操作和 macOS supervisor 始终先获取 `PM2_HOME/supertask-g
CLI 的任务/模板 ID、优先级、重试次数和列表数量均按完整十进制整数解析;带尾随字符、小数、越界值和未知任务状态会返回非零退出码,不会再由 `parseInt` 静默截断。
-CLI 帮助、`doctor` 和数据库维护的交互式摘要支持 `auto | zh-CN | en`。默认 `auto` 根据 `LC_ALL`、`LC_MESSAGES`、`LANG` 选择,非中文 locale 回退英文;可用全局 `--lang` 或 `SUPERTASK_LANG` 覆盖。JSON 字段和底层诊断错误保持原样,不因界面语言变化而破坏 Agent、管道和脚本解析。
+CLI 帮助、`doctor` 和数据库维护的交互式摘要支持 `auto | zh-CN | en | es`。默认 `auto` 根据 `LC_ALL`、`LC_MESSAGES`、`LANG` 选择,中文/西班牙文 locale 分别选择对应语言,其余回退英文;可用全局 `--lang` 或 `SUPERTASK_LANG` 覆盖。JSON 字段和底层诊断错误保持原样,不因界面语言变化而破坏 Agent、管道和脚本解析。
## 完整配置
diff --git a/src/cli/database-output.ts b/src/cli/database-output.ts
index 784f8a1..d87d1e7 100644
--- a/src/cli/database-output.ts
+++ b/src/cli/database-output.ts
@@ -48,20 +48,41 @@ function formatCounts(counts: DatabaseCounts, locale: CliLocale): string {
locale,
`任务 ${counts.tasks} · 执行记录 ${counts.taskRuns} · 定时任务模板 ${counts.taskTemplates}`,
`tasks ${counts.tasks} · runs ${counts.taskRuns} · scheduled templates ${counts.taskTemplates}`,
+ `tareas ${counts.tasks} · ejecuciones ${counts.taskRuns} · plantillas programadas ${counts.taskTemplates}`,
);
}
function formatGateway(gateway: GatewayMaintenanceReport, locale: CliLocale): string {
- if (gateway.restarted) return cliText(locale, '已自动停止、重启并恢复就绪', 'stopped, restarted, and ready');
- if (gateway.keptStopped) return cliText(locale, '已自动停止,按要求保持停止', 'stopped and left stopped as requested');
+ if (gateway.restarted) {
+ return cliText(
+ locale,
+ '已自动停止、重启并恢复就绪',
+ 'stopped, restarted, and ready',
+ 'detenido, reiniciado y listo',
+ );
+ }
+ if (gateway.keptStopped) {
+ return cliText(
+ locale,
+ '已自动停止,按要求保持停止',
+ 'stopped and left stopped as requested',
+ 'detenido y dejado detenido según lo solicitado',
+ );
+ }
if (!gateway.wasRunning) {
return cliText(
locale,
'无需自动停启(未发现匹配当前数据库的 PM2 Gateway)',
'no matching PM2 Gateway was running',
+ 'no había un Gateway PM2 coincidente en ejecución',
);
}
- return cliText(locale, '原 Gateway 未恢复运行', 'the previous Gateway was not restored');
+ return cliText(
+ locale,
+ '原 Gateway 未恢复运行',
+ 'the previous Gateway was not restored',
+ 'el Gateway anterior no se restauró',
+ );
}
function formatCheck(result: DatabaseCheckResult, locale: CliLocale): string {
@@ -84,6 +105,25 @@ function formatCheck(result: DatabaseCheckResult, locale: CliLocale): string {
}
return lines.join('\n');
}
+ if (locale === 'es') {
+ const lines = [
+ result.ok ? '✓ Comprobación de la base de datos correcta' : '✗ Comprobación de la base de datos fallida',
+ '',
+ `Base de datos: ${result.path}`,
+ `Tamaño: ${formatBytes(result.sizeBytes)}`,
+ `Modo de journal: ${result.journalMode}`,
+ `Recuentos: ${formatCounts(result.counts, locale)}`,
+ `En ejecución: tareas ${result.runningTasks} · ejecuciones ${result.runningRuns}`,
+ ];
+ if (!result.ok) {
+ lines.push(
+ `Integridad: ${result.integrityMessages.join('; ') || 'sin resultado'}`,
+ `Violaciones de clave foránea: ${result.foreignKeyViolations}`,
+ `Tablas ausentes: ${result.missingTables.join(', ') || 'ninguna'}`,
+ );
+ }
+ return lines.join('\n');
+ }
const lines = [
result.ok ? '✓ 数据库检查通过' : '✗ 数据库检查未通过',
'',
@@ -123,6 +163,16 @@ function formatHuman(
`Integrity: ${backup.check.ok ? 'passed' : 'failed'}`,
].join('\n');
}
+ if (locale === 'es') {
+ return [
+ '✓ Copia de seguridad de la base de datos completada',
+ '',
+ `Copia: ${backup.path}`,
+ `Tamaño: ${formatBytes(backup.sizeBytes)}`,
+ `Recuentos: ${formatCounts(backup.check.counts, locale)}`,
+ `Integridad: ${backup.check.ok ? 'correcta' : 'fallida'}`,
+ ].join('\n');
+ }
return [
'✓ 数据库备份完成',
'',
@@ -144,6 +194,16 @@ function formatHuman(
`Integrity: ${cleared.check.ok ? 'passed' : 'failed'}`,
].join('\n');
}
+ if (locale === 'es') {
+ return [
+ '✓ Base de datos vaciada de forma segura',
+ '',
+ `Eliminado: ${formatCounts(cleared.deleted, locale)}`,
+ `Copia de seguridad de seguridad: ${cleared.backupPath}`,
+ `Gateway: ${formatGateway(cleared.gateway, locale)}`,
+ `Integridad: ${cleared.check.ok ? 'correcta' : 'fallida'}`,
+ ].join('\n');
+ }
return [
'✓ 数据库已安全清空',
'',
@@ -167,6 +227,18 @@ function formatHuman(
`Integrity: ${restored.check.ok ? 'passed' : 'failed'}`,
].join('\n');
}
+ if (locale === 'es') {
+ return [
+ '✓ Restauración de la base de datos completada',
+ '',
+ `Origen: ${restored.sourcePath}`,
+ `Copia de seguridad previa a la restauración: ${restored.safetyBackupPath}`,
+ `Estado de ejecución recuperado: tareas ${restored.recoveredRunningTasks} · ejecuciones ${restored.closedRunningRuns}`,
+ `Recuentos: ${formatCounts(restored.check.counts, locale)}`,
+ `Gateway: ${formatGateway(restored.gateway, locale)}`,
+ `Integridad: ${restored.check.ok ? 'correcta' : 'fallida'}`,
+ ].join('\n');
+ }
return [
'✓ 数据库恢复完成',
'',
diff --git a/src/cli/i18n.ts b/src/cli/i18n.ts
index a784efa..65e2d7f 100644
--- a/src/cli/i18n.ts
+++ b/src/cli/i18n.ts
@@ -1,4 +1,4 @@
-export type CliLocale = 'zh-CN' | 'en';
+export type CliLocale = 'zh-CN' | 'en' | 'es';
function requestedLanguage(argv: string[]): string | undefined {
for (let index = 2; index < argv.length; index += 1) {
@@ -15,12 +15,17 @@ export function resolveCliLocale(
): CliLocale {
const requested = requestedLanguage(argv) ?? env.SUPERTASK_LANG ?? 'auto';
if (requested === 'zh' || requested === 'zh-CN') return 'zh-CN';
+ if (requested === 'es' || requested === 'es-ES' || requested === 'es-MX') return 'es';
if (requested === 'en') return 'en';
const systemLocale = env.LC_ALL || env.LC_MESSAGES || env.LANG || '';
- return /^zh(?:[_-]|$)/i.test(systemLocale) ? 'zh-CN' : 'en';
+ if (/^zh(?:[_-]|$)/i.test(systemLocale)) return 'zh-CN';
+ if (/^es(?:[_-]|$)/i.test(systemLocale)) return 'es';
+ return 'en';
}
-export function cliText(locale: CliLocale, zh: string, en: string): string {
- return locale === 'zh-CN' ? zh : en;
+export function cliText(locale: CliLocale, zh: string, en: string, es: string = en): string {
+ if (locale === 'zh-CN') return zh;
+ if (locale === 'es') return es;
+ return en;
}
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 653a515..130bedf 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -28,7 +28,7 @@ import { cliText, resolveCliLocale } from './i18n';
import { runDoctorSmoke, type DoctorSmokeResult } from './doctor-smoke';
const cliLocale = resolveCliLocale();
-const t = (zh: string, en: string): string => cliText(cliLocale, zh, en);
+const t = (zh: string, en: string, es?: string): string => cliText(cliLocale, zh, en, es);
async function withDb(
fn: () => Promise,
@@ -67,13 +67,13 @@ const program = new Command();
program
.name('supertask')
- .description(t('面向 OpenCode Agent 的持久化任务队列与定时任务系统', 'Durable task queue and scheduler for OpenCode agents'))
- .addOption(new Option('--lang ', t('CLI 语言:auto / zh-CN / en', 'CLI language: auto / zh-CN / en'))
- .choices(['auto', 'zh-CN', 'en'])
+ .description(t('面向 OpenCode Agent 的持久化任务队列与定时任务系统', 'Durable task queue and scheduler for OpenCode agents', 'Cola de tareas durable y programador para agentes de OpenCode'))
+ .addOption(new Option('--lang ', t('CLI 语言:auto / zh-CN / en / es', 'CLI language: auto / zh-CN / en / es', 'idioma de la CLI: auto / zh-CN / en / es'))
+ .choices(['auto', 'zh-CN', 'en', 'es'])
.default('auto'))
- .helpOption('-h, --help', t('显示帮助', 'display help for command'))
- .addHelpCommand('help [command]', t('显示命令帮助', 'display help for command'))
- .version(getPackageVersion(), '-V, --version', t('显示版本号', 'output the version number'));
+ .helpOption('-h, --help', t('显示帮助', 'display help for command', 'mostrar la ayuda del comando'))
+ .addHelpCommand('help [command]', t('显示命令帮助', 'display help for command', 'mostrar la ayuda del comando'))
+ .version(getPackageVersion(), '-V, --version', t('显示版本号', 'output the version number', 'mostrar el número de versión'));
if (cliLocale === 'zh-CN') {
program.configureOutput({
@@ -87,21 +87,21 @@ if (cliLocale === 'zh-CN') {
program
.command('add')
- .description(t('创建新任务', 'create a queued task'))
- .requiredOption('-n, --name ', t('任务名称', 'task name'))
- .requiredOption('-a, --agent ', t('主 Agent 名称', 'primary agent name'))
- .requiredOption('-p, --prompt ', t('提示词', 'prompt'))
- .option('-m, --model ', t('模型', 'model'))
- .option('--variant ', t('模型 variant,如 high / xhigh', 'model variant, e.g. high / xhigh'))
- .option('-c, --category ', t('分类', 'category'), 'general')
- .option('-i, --importance ', t('重要程度 (1-5)', 'importance (1-5)'), '3')
- .option('-u, --urgency ', t('紧急程度 (1-5)', 'urgency (1-5)'), '3')
- .option('-b, --batch ', t('批次 ID', 'batch ID'))
- .option('-d, --depends ', t('依赖的任务 ID', 'dependency task ID'))
- .option('--max-retries ', t('首次执行之外允许的重试次数', 'retries allowed after the first attempt'), '3')
- .option('--retry-backoff ', t('重试退避基础间隔,如 30s / 5min', 'retry backoff base, e.g. 30s / 5min'), '30s')
- .option('--timeout ', t('任务硬超时,如 30min / 2h', 'hard timeout, e.g. 30min / 2h'))
- .option('-w, --cwd ', t('(已废弃) 系统会自动记录提交时的当前目录', '(deprecated) the submission directory is recorded automatically'))
+ .description(t('创建新任务', 'create a queued task', 'crear una tarea en cola'))
+ .requiredOption('-n, --name ', t('任务名称', 'task name', 'nombre de la tarea'))
+ .requiredOption('-a, --agent ', t('主 Agent 名称', 'primary agent name', 'nombre del agente principal'))
+ .requiredOption('-p, --prompt ', t('提示词', 'prompt', 'prompt'))
+ .option('-m, --model ', t('模型', 'model', 'modelo'))
+ .option('--variant ', t('模型 variant,如 high / xhigh', 'model variant, e.g. high / xhigh', 'variante del modelo, p. ej. high / xhigh'))
+ .option('-c, --category ', t('分类', 'category', 'categoría'), 'general')
+ .option('-i, --importance ', t('重要程度 (1-5)', 'importance (1-5)', 'importancia (1-5)'), '3')
+ .option('-u, --urgency ', t('紧急程度 (1-5)', 'urgency (1-5)', 'urgencia (1-5)'), '3')
+ .option('-b, --batch ', t('批次 ID', 'batch ID', 'ID de lote'))
+ .option('-d, --depends ', t('依赖的任务 ID', 'dependency task ID', 'ID de la tarea dependiente'))
+ .option('--max-retries ', t('首次执行之外允许的重试次数', 'retries allowed after the first attempt', 'reintentos permitidos tras el primer intento'), '3')
+ .option('--retry-backoff ', t('重试退避基础间隔,如 30s / 5min', 'retry backoff base, e.g. 30s / 5min', 'base de espera entre reintentos, p. ej. 30s / 5min'), '30s')
+ .option('--timeout ', t('任务硬超时,如 30min / 2h', 'hard timeout, e.g. 30min / 2h', 'timeout estricto, p. ej. 30min / 2h'))
+ .option('-w, --cwd ', t('(已废弃) 系统会自动记录提交时的当前目录', '(deprecated) the submission directory is recorded automatically', '(obsoleto) el directorio de envío se registra automáticamente'))
.action(async (options) => withDb(async () => {
const submitCwd = process.cwd();
const retryBackoffMs = parseDuration(options.retryBackoff);
@@ -130,23 +130,23 @@ program
program
.command('edit')
- .description(t('修改当前项目中尚未完成的任务', 'edit an unfinished task in the current project'))
- .requiredOption('--id ', t('任务 ID', 'task ID'))
- .option('-n, --name ', t('任务名称', 'task name'))
- .option('-a, --agent ', t('Agent 名称', 'agent name'))
- .option('-m, --model ', t('模型', 'model'))
- .option('--variant ', t('模型 variant,如 high / xhigh', 'model variant, e.g. high / xhigh'))
- .option('--clear-variant', t('清空任务级 variant,跟随 Agent / 模型默认值', 'clear the task variant and use the Agent / model default'))
- .option('-p, --prompt ', t('提示词', 'prompt'))
- .option('-c, --category ', t('分类', 'category'))
- .option('-i, --importance ', t('重要程度 (1-5)', 'importance (1-5)'))
- .option('-u, --urgency ', t('紧急程度 (1-5)', 'urgency (1-5)'))
- .option('-b, --batch ', t('批次 ID', 'batch ID'))
- .option('--clear-batch', t('清空批次 ID', 'clear the batch ID'))
- .option('--max-retries ', t('首次执行之外允许的重试次数', 'retries allowed after the first attempt'))
- .option('--retry-backoff ', t('重试退避基础间隔,如 30s / 5min', 'retry backoff base, e.g. 30s / 5min'))
- .option('--timeout ', t('任务硬超时,如 30min / 2h', 'hard timeout, e.g. 30min / 2h'))
- .option('--clear-timeout', t('清空任务级超时,改用 Gateway 默认值', 'clear the task timeout and use the Gateway default'))
+ .description(t('修改当前项目中尚未完成的任务', 'edit an unfinished task in the current project', 'editar una tarea no finalizada del proyecto actual'))
+ .requiredOption('--id ', t('任务 ID', 'task ID', 'ID de la tarea'))
+ .option('-n, --name ', t('任务名称', 'task name', 'nombre de la tarea'))
+ .option('-a, --agent ', t('Agent 名称', 'agent name', 'nombre del agente'))
+ .option('-m, --model ', t('模型', 'model', 'modelo'))
+ .option('--variant ', t('模型 variant,如 high / xhigh', 'model variant, e.g. high / xhigh', 'variante del modelo, p. ej. high / xhigh'))
+ .option('--clear-variant', t('清空任务级 variant,跟随 Agent / 模型默认值', 'clear the task variant and use the Agent / model default', 'borrar la variante de la tarea y usar el valor predeterminado del agente / modelo'))
+ .option('-p, --prompt ', t('提示词', 'prompt', 'prompt'))
+ .option('-c, --category ', t('分类', 'category', 'categoría'))
+ .option('-i, --importance ', t('重要程度 (1-5)', 'importance (1-5)', 'importancia (1-5)'))
+ .option('-u, --urgency ', t('紧急程度 (1-5)', 'urgency (1-5)', 'urgencia (1-5)'))
+ .option('-b, --batch ', t('批次 ID', 'batch ID', 'ID de lote'))
+ .option('--clear-batch', t('清空批次 ID', 'clear the batch ID', 'borrar el ID de lote'))
+ .option('--max-retries ', t('首次执行之外允许的重试次数', 'retries allowed after the first attempt', 'reintentos permitidos tras el primer intento'))
+ .option('--retry-backoff ', t('重试退避基础间隔,如 30s / 5min', 'retry backoff base, e.g. 30s / 5min', 'base de espera entre reintentos, p. ej. 30s / 5min'))
+ .option('--timeout ', t('任务硬超时,如 30min / 2h', 'hard timeout, e.g. 30min / 2h', 'timeout estricto, p. ej. 30min / 2h'))
+ .option('--clear-timeout', t('清空任务级超时,改用 Gateway 默认值', 'clear the task timeout and use the Gateway default', 'borrar el timeout de la tarea y usar el predeterminado del Gateway'))
.action(async (options) => withDb(async () => {
if (options.batch !== undefined && options.clearBatch) {
throw new Error('batch 和 clear-batch 不能同时使用');
@@ -194,7 +194,7 @@ program
program
.command('next')
- .description(t('获取下一个待执行的任务', 'show the next runnable task'))
+ .description(t('获取下一个待执行的任务', 'show the next runnable task', 'mostrar la siguiente tarea ejecutable'))
.action(async () => withDb(async () => {
const task = await TaskService.next({ cwd: process.cwd() });
if (task) {
@@ -217,8 +217,8 @@ program
program
.command('cancel')
- .description(t('取消任务', 'cancel a task'))
- .requiredOption('--id ', t('任务 ID', 'task ID'))
+ .description(t('取消任务', 'cancel a task', 'cancelar una tarea'))
+ .requiredOption('--id ', t('任务 ID', 'task ID', 'ID de la tarea'))
.action(async (options) => withDb(async () => {
const task = await TaskService.cancel(parsePositiveInteger(options.id, 'id'), { cwd: process.cwd() });
if (task) {
@@ -230,13 +230,13 @@ program
}));
const runCommand = new Command('run')
- .description(t('管理隔离的执行记录', 'manage quarantined task runs'));
+ .description(t('管理隔离的执行记录', 'manage quarantined task runs', 'gestionar ejecuciones de tareas en cuarentena'));
runCommand
.command('abandon')
- .description(t('人工关闭已确认不存在遗留进程的旧版无 PID 隔离记录', 'close a legacy no-PID quarantine after confirming no process remains'))
- .requiredOption('--id ', t('执行记录 run ID', 'run ID'))
- .option('--confirm ', t('危险操作确认,必须填写 ABANDON', 'confirmation word; must be ABANDON'))
+ .description(t('人工关闭已确认不存在遗留进程的旧版无 PID 隔离记录', 'close a legacy no-PID quarantine after confirming no process remains', 'cerrar una cuarentena legacy sin PID tras confirmar que no queda ningún proceso'))
+ .requiredOption('--id ', t('执行记录 run ID', 'run ID', 'ID de la ejecución'))
+ .option('--confirm ', t('危险操作确认,必须填写 ABANDON', 'confirmation word; must be ABANDON', 'palabra de confirmación; debe ser ABANDON'))
.action(async (options: { id: string; confirm?: string }) => withDb(async () => {
if (options.confirm !== 'ABANDON') {
throw new Error('关闭旧版隔离 run 必须显式传入 --confirm ABANDON');
@@ -251,9 +251,9 @@ program.addCommand(runCommand);
program
.command('retry')
- .description(t('重试失败的任务', 'retry failed tasks'))
- .option('--id ', t('任务 ID', 'task ID'))
- .option('-b, --batch ', t('批次 ID(批量重试)', 'batch ID for bulk retry'))
+ .description(t('重试失败的任务', 'retry failed tasks', 'reintentar tareas fallidas'))
+ .option('--id ', t('任务 ID', 'task ID', 'ID de la tarea'))
+ .option('-b, --batch ', t('批次 ID(批量重试)', 'batch ID for bulk retry', 'ID de lote para reintento masivo'))
.action(async (options) => withDb(async () => {
if (options.id) {
const task = await TaskService.retry(parsePositiveInteger(options.id, 'id'), { cwd: process.cwd() });
@@ -274,8 +274,8 @@ program
program
.command('status')
- .description(t('查看任务统计', 'show task counts'))
- .option('-b, --batch ', t('按批次统计', 'filter counts by batch'))
+ .description(t('查看任务统计', 'show task counts', 'mostrar recuentos de tareas'))
+ .option('-b, --batch ', t('按批次统计', 'filter counts by batch', 'filtrar recuentos por lote'))
.action(async (options) => withDb(async () => {
const stats = await TaskService.stats({ batchId: options.batch, cwd: process.cwd() });
console.log(JSON.stringify(stats, null, 2));
@@ -283,11 +283,11 @@ program
program
.command('list')
- .description(t('列出任务', 'list tasks'))
- .option('-s, --status ', t('按状态筛选', 'filter by status'))
- .option('-b, --batch ', t('按批次筛选', 'filter by batch'))
- .option('-c, --category ', t('按分类筛选', 'filter by category'))
- .option('-l, --limit ', t('限制数量', 'maximum rows'), '20')
+ .description(t('列出任务', 'list tasks', 'listar tareas'))
+ .option('-s, --status ', t('按状态筛选', 'filter by status', 'filtrar por estado'))
+ .option('-b, --batch ', t('按批次筛选', 'filter by batch', 'filtrar por lote'))
+ .option('-c, --category ', t('按分类筛选', 'filter by category', 'filtrar por categoría'))
+ .option('-l, --limit ', t('限制数量', 'maximum rows', 'filas máximas'), '20')
.action(async (options) => withDb(async () => {
const tasks = await TaskService.list({
status: parseTaskStatus(options.status),
@@ -301,8 +301,8 @@ program
program
.command('get')
- .description(t('获取单个任务详情', 'show one task'))
- .requiredOption('--id ', t('任务 ID', 'task ID'))
+ .description(t('获取单个任务详情', 'show one task', 'mostrar una tarea'))
+ .requiredOption('--id ', t('任务 ID', 'task ID', 'ID de la tarea'))
.action(async (options) => withDb(async () => {
const task = await TaskService.getById(parsePositiveInteger(options.id, 'id'), { cwd: process.cwd() });
if (task) {
@@ -315,8 +315,8 @@ program
program
.command('delete')
- .description(t('删除任务', 'delete a task'))
- .requiredOption('--id ', t('任务 ID', 'task ID'))
+ .description(t('删除任务', 'delete a task', 'eliminar una tarea'))
+ .requiredOption('--id ', t('任务 ID', 'task ID', 'ID de la tarea'))
.action(async (options) => withDb(async () => {
const id = parsePositiveInteger(options.id, 'id');
const deleted = await TaskService.delete(id, { cwd: process.cwd() });
@@ -325,27 +325,27 @@ program
program
.command('template')
- .description(t('管理定时任务模板', 'manage scheduled task templates'))
+ .description(t('管理定时任务模板', 'manage scheduled task templates', 'gestionar plantillas de tareas programadas'))
.addCommand(
new Command('add')
- .description(t('创建定时任务模板', 'create a scheduled task template'))
- .requiredOption('-n, --name ', t('模板名称', 'template name'))
- .requiredOption('-a, --agent ', t('Agent 名称', 'agent name'))
- .requiredOption('-p, --prompt ', t('提示词', 'prompt'))
- .requiredOption('-t, --type ', t('定时类型:cron/delayed/recurring', 'schedule type: cron/delayed/recurring'))
- .option('--cron ', t('cron 表达式(cron 类型必填)', 'cron expression (required for cron)'))
- .option('--delay ', t('延迟时间(delayed 必填),如 30s / 5min / 1h / 2d', 'delay (required for delayed), e.g. 30s / 5min / 1h / 2d'))
- .option('--interval ', t('循环间隔(recurring 必填),如 1h / 30min / 5s', 'interval (required for recurring), e.g. 1h / 30min / 5s'))
- .option('-m, --model ', t('模型', 'model'))
- .option('--variant ', t('模型 variant,如 high / xhigh', 'model variant, e.g. high / xhigh'))
- .option('-c, --category ', t('分类', 'category'), 'general')
- .option('-i, --importance ', t('重要程度 1-5', 'importance 1-5'), '3')
- .option('-u, --urgency ', t('紧急程度 1-5', 'urgency 1-5'), '3')
- .option('-b, --batch ', t('模板生成任务的批次 ID', 'batch ID for generated tasks'))
- .option('--max-instances ', t('自动定时任务的活跃实例上限(立即触发不受限)', 'active instance limit for automatic scheduling (Run now is unrestricted)'), '1')
- .option('--max-retries ', t('最大重试次数', 'maximum retries'), '3')
- .option('--retry-backoff ', t('退避基础间隔,如 30s / 5min', 'retry backoff base, e.g. 30s / 5min'), '30s')
- .option('--timeout ', t('每次任务硬超时,如 30min / 2h', 'hard timeout per task, e.g. 30min / 2h'))
+ .description(t('创建定时任务模板', 'create a scheduled task template', 'crear una plantilla de tarea programada'))
+ .requiredOption('-n, --name ', t('模板名称', 'template name', 'nombre de la plantilla'))
+ .requiredOption('-a, --agent ', t('Agent 名称', 'agent name', 'nombre del agente'))
+ .requiredOption('-p, --prompt ', t('提示词', 'prompt', 'prompt'))
+ .requiredOption('-t, --type ', t('定时类型:cron/delayed/recurring', 'schedule type: cron/delayed/recurring', 'tipo de programación: cron/delayed/recurring'))
+ .option('--cron ', t('cron 表达式(cron 类型必填)', 'cron expression (required for cron)', 'expresión cron (obligatoria para cron)'))
+ .option('--delay ', t('延迟时间(delayed 必填),如 30s / 5min / 1h / 2d', 'delay (required for delayed), e.g. 30s / 5min / 1h / 2d', 'retraso (obligatorio para delayed), p. ej. 30s / 5min / 1h / 2d'))
+ .option('--interval ', t('循环间隔(recurring 必填),如 1h / 30min / 5s', 'interval (required for recurring), e.g. 1h / 30min / 5s', 'intervalo (obligatorio para recurring), p. ej. 1h / 30min / 5s'))
+ .option('-m, --model ', t('模型', 'model', 'modelo'))
+ .option('--variant ', t('模型 variant,如 high / xhigh', 'model variant, e.g. high / xhigh', 'variante del modelo, p. ej. high / xhigh'))
+ .option('-c, --category ', t('分类', 'category', 'categoría'), 'general')
+ .option('-i, --importance ', t('重要程度 1-5', 'importance 1-5', 'importancia 1-5'), '3')
+ .option('-u, --urgency ', t('紧急程度 1-5', 'urgency 1-5', 'urgencia 1-5'), '3')
+ .option('-b, --batch ', t('模板生成任务的批次 ID', 'batch ID for generated tasks', 'ID de lote para las tareas generadas'))
+ .option('--max-instances ', t('自动定时任务的活跃实例上限(立即触发不受限)', 'active instance limit for automatic scheduling (Run now is unrestricted)', 'límite de instancias activas para la programación automática (Ejecutar ahora no tiene límite)'), '1')
+ .option('--max-retries ', t('最大重试次数', 'maximum retries', 'reintentos máximos'), '3')
+ .option('--retry-backoff ', t('退避基础间隔,如 30s / 5min', 'retry backoff base, e.g. 30s / 5min', 'base de espera entre reintentos, p. ej. 30s / 5min'), '30s')
+ .option('--timeout ', t('每次任务硬超时,如 30min / 2h', 'hard timeout per task, e.g. 30min / 2h', 'timeout estricto por tarea, p. ej. 30min / 2h'))
.action(async (options) => withDb(async () => {
let intervalMs: number | null = null;
let runAt: number | null = null;
@@ -397,7 +397,7 @@ program
)
.addCommand(
new Command('list')
- .description(t('列出定时任务模板', 'list scheduled task templates'))
+ .description(t('列出定时任务模板', 'list scheduled task templates', 'listar plantillas de tareas programadas'))
.action(async () => withDb(async () => {
const templates = await TaskTemplateService.list();
console.log(JSON.stringify(templates, null, 2));
@@ -405,8 +405,8 @@ program
)
.addCommand(
new Command('enable')
- .description(t('启用模板', 'enable a template'))
- .requiredOption('--id ', t('模板 ID', 'template ID'))
+ .description(t('启用模板', 'enable a template', 'activar una plantilla'))
+ .requiredOption('--id ', t('模板 ID', 'template ID', 'ID de la plantilla'))
.action(async (options) => withDb(async () => {
const tmpl = await TaskTemplateService.enable(parsePositiveInteger(options.id, 'id'));
if (tmpl) {
@@ -419,8 +419,8 @@ program
)
.addCommand(
new Command('disable')
- .description(t('禁用模板', 'disable a template'))
- .requiredOption('--id ', t('模板 ID', 'template ID'))
+ .description(t('禁用模板', 'disable a template', 'desactivar una plantilla'))
+ .requiredOption('--id ', t('模板 ID', 'template ID', 'ID de la plantilla'))
.action(async (options) => withDb(async () => {
const tmpl = await TaskTemplateService.disable(parsePositiveInteger(options.id, 'id'));
if (tmpl) {
@@ -433,8 +433,8 @@ program
)
.addCommand(
new Command('delete')
- .description(t('删除模板', 'delete a template'))
- .requiredOption('--id ', t('模板 ID', 'template ID'))
+ .description(t('删除模板', 'delete a template', 'eliminar una plantilla'))
+ .requiredOption('--id ', t('模板 ID', 'template ID', 'ID de la plantilla'))
.action(async (options) => withDb(async () => {
const id = parsePositiveInteger(options.id, 'id');
const deleted = await TaskTemplateService.delete(id);
@@ -443,12 +443,12 @@ program
);
const databaseCommand = new Command('db')
- .description(t('数据库检查、备份、清空与恢复', 'check, back up, clear, and restore the database'));
+ .description(t('数据库检查、备份、清空与恢复', 'check, back up, clear, and restore the database', 'comprobar, respaldar, vaciar y restaurar la base de datos'));
databaseCommand
.command('check')
- .description(t('检查数据库完整性、外键和业务表统计', 'check integrity, foreign keys, and table counts'))
- .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)'))
+ .description(t('检查数据库完整性、外键和业务表统计', 'check integrity, foreign keys, and table counts', 'comprobar integridad, claves foráneas y recuentos de tablas'))
+ .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)', 'forzar salida JSON (ya es el valor predeterminado fuera de modo interactivo)'))
.action(async (options: { json?: boolean }) => withDb(async () => {
const result = DatabaseMaintenanceService.check();
console.log(renderDatabaseResult('check', result, { forceJson: options.json, locale: cliLocale }));
@@ -457,9 +457,9 @@ databaseCommand
databaseCommand
.command('backup')
- .description(t('创建经过完整性校验的一致性备份', 'create a consistent, integrity-checked backup'))
- .option('-o, --output ', t('备份文件路径(默认写入数据库目录)', 'backup path (defaults to the database directory)'))
- .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)'))
+ .description(t('创建经过完整性校验的一致性备份', 'create a consistent, integrity-checked backup', 'crear una copia de seguridad coherente y verificada'))
+ .option('-o, --output ', t('备份文件路径(默认写入数据库目录)', 'backup path (defaults to the database directory)', 'ruta de la copia de seguridad (por defecto el directorio de la base de datos)'))
+ .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)', 'forzar salida JSON (ya es el valor predeterminado fuera de modo interactivo)'))
.action(async (options: { output?: string; json?: boolean }) => withDb(async () => {
const result = DatabaseMaintenanceService.backup(options.output);
console.log(renderDatabaseResult('backup', result, { forceJson: options.json, locale: cliLocale }));
@@ -467,10 +467,10 @@ databaseCommand
databaseCommand
.command('clear')
- .description(t('备份后事务性清空任务、执行记录和定时任务模板', 'back up, then transactionally clear tasks, runs, and scheduled templates'))
- .option('--confirm ', t('危险操作确认,必须填写 CLEAR', 'confirmation word; must be CLEAR'))
- .option('--keep-stopped', t('维护结束后不重启原本由 PM2 管理的 Gateway', 'leave a previously managed Gateway stopped'))
- .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)'))
+ .description(t('备份后事务性清空任务、执行记录和定时任务模板', 'back up, then transactionally clear tasks, runs, and scheduled templates', 'hacer copia de seguridad y vaciar de forma transaccional tareas, ejecuciones y plantillas programadas'))
+ .option('--confirm ', t('危险操作确认,必须填写 CLEAR', 'confirmation word; must be CLEAR', 'palabra de confirmación; debe ser CLEAR'))
+ .option('--keep-stopped', t('维护结束后不重启原本由 PM2 管理的 Gateway', 'leave a previously managed Gateway stopped', 'dejar detenido un Gateway gestionado previamente'))
+ .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)', 'forzar salida JSON (ya es el valor predeterminado fuera de modo interactivo)'))
.action(async (options: { confirm?: string; keepStopped?: boolean; json?: boolean }) => withDb(async () => {
if (options.confirm !== 'CLEAR') {
throw new Error('清空数据库必须显式传入 --confirm CLEAR');
@@ -484,11 +484,11 @@ databaseCommand
databaseCommand
.command('restore')
- .description(t('自动备份当前库后,从指定备份恢复数据库', 'back up the live database, then restore from a backup'))
- .requiredOption('--from ', t('要恢复的 SQLite 备份文件', 'SQLite backup to restore'))
- .option('--confirm ', t('危险操作确认,必须填写 RESTORE', 'confirmation word; must be RESTORE'))
- .option('--keep-stopped', t('维护结束后不重启原本由 PM2 管理的 Gateway', 'leave a previously managed Gateway stopped'))
- .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)'))
+ .description(t('自动备份当前库后,从指定备份恢复数据库', 'back up the live database, then restore from a backup', 'hacer copia de seguridad de la base en vivo y restaurar desde una copia'))
+ .requiredOption('--from ', t('要恢复的 SQLite 备份文件', 'SQLite backup to restore', 'copia de seguridad SQLite a restaurar'))
+ .option('--confirm ', t('危险操作确认,必须填写 RESTORE', 'confirmation word; must be RESTORE', 'palabra de confirmación; debe ser RESTORE'))
+ .option('--keep-stopped', t('维护结束后不重启原本由 PM2 管理的 Gateway', 'leave a previously managed Gateway stopped', 'dejar detenido un Gateway gestionado previamente'))
+ .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)', 'forzar salida JSON (ya es el valor predeterminado fuera de modo interactivo)'))
.action(async (options: { from: string; confirm?: string; keepStopped?: boolean; json?: boolean }) => withDb(async () => {
if (options.confirm !== 'RESTORE') {
throw new Error('恢复数据库必须显式传入 --confirm RESTORE');
@@ -504,7 +504,7 @@ program.addCommand(databaseCommand);
program
.command('init')
- .description(t('初始化 SuperTask(创建配置并执行迁移)', 'initialize SuperTask (create config and run migrations)'))
+ .description(t('初始化 SuperTask(创建配置并执行迁移)', 'initialize SuperTask (create config and run migrations)', 'inicializar SuperTask (crear configuración y ejecutar migraciones)'))
.action(async () => withDb(async () => {
const { existsSync, mkdirSync, writeFileSync } = await import('fs');
const { dirname } = await import('path');
@@ -531,7 +531,7 @@ program
program
.command('migrate')
- .description(t('执行数据库迁移', 'run database migrations'))
+ .description(t('执行数据库迁移', 'run database migrations', 'ejecutar migraciones de la base de datos'))
.action(async () => withDb(async () => {
const { getDb } = await import('@core/db');
getDb();
@@ -540,7 +540,7 @@ program
program
.command('gateway')
- .description(t('在前台启动 Gateway', 'start the Gateway in the foreground'))
+ .description(t('在前台启动 Gateway', 'start the Gateway in the foreground', 'iniciar el Gateway en primer plano'))
.action(async () => {
const { main } = await import('@gateway/index');
await main();
@@ -548,7 +548,7 @@ program
program
.command('ui')
- .description(t('打开 Gateway 内置的 Web 管理界面', 'open the Web Dashboard embedded in the Gateway'))
+ .description(t('打开 Gateway 内置的 Web 管理界面', 'open the Web Dashboard embedded in the Gateway', 'abrir el panel web integrado en el Gateway'))
.action(async () => {
const { loadConfig } = await import('@gateway/config');
const cfg = loadConfig();
@@ -563,7 +563,7 @@ program
program
.command('config')
- .description(t('显示当前配置', 'show the current configuration'))
+ .description(t('显示当前配置', 'show the current configuration', 'mostrar la configuración actual'))
.action(async () => {
const { loadConfig } = await import('@gateway/config');
const cfg = loadConfig();
@@ -572,14 +572,14 @@ program
program
.command('doctor')
- .description(t('检查 OpenCode、数据库、Gateway、Web 界面和日志轮转', 'diagnose OpenCode, database, Gateway, Dashboard, and log rotation'))
- .option('--json', t('强制输出 JSON', 'force JSON output'))
- .option('--smoke', t('通过 Gateway 提交一个真实 OpenCode 任务并验证输出', 'queue a real OpenCode task through Gateway and verify its output'))
- .option('--smoke-agent ', t('真实冒烟任务使用的 Agent', 'Agent used by the real smoke task'), 'build')
- .option('--smoke-model ', t('真实冒烟任务使用的模型;默认跟随 Agent 配置', 'model used by the real smoke task; defaults to the Agent configuration'))
- .option('--smoke-variant ', t('真实冒烟任务使用的模型 variant', 'model variant used by the real smoke task'))
- .option('--smoke-cwd ', t('真实冒烟任务的项目目录;默认为当前目录', 'project directory for the real smoke task; defaults to the current directory'))
- .option('--smoke-timeout ', t('真实冒烟任务等待上限,如 2min / 5min', 'real smoke task timeout, e.g. 2min / 5min'), '3min')
+ .description(t('检查 OpenCode、数据库、Gateway、Web 界面和日志轮转', 'diagnose OpenCode, database, Gateway, Dashboard, and log rotation', 'diagnosticar OpenCode, la base de datos, el Gateway, el panel y la rotación de logs'))
+ .option('--json', t('强制输出 JSON', 'force JSON output', 'forzar salida JSON'))
+ .option('--smoke', t('通过 Gateway 提交一个真实 OpenCode 任务并验证输出', 'queue a real OpenCode task through Gateway and verify its output', 'encolar una tarea real de OpenCode a través del Gateway y verificar su salida'))
+ .option('--smoke-agent ', t('真实冒烟任务使用的 Agent', 'Agent used by the real smoke task', 'Agente usado por la tarea smoke real'), 'build')
+ .option('--smoke-model ', t('真实冒烟任务使用的模型;默认跟随 Agent 配置', 'model used by the real smoke task; defaults to the Agent configuration', 'modelo usado por la tarea smoke real; por defecto la configuración del agente'))
+ .option('--smoke-variant ', t('真实冒烟任务使用的模型 variant', 'model variant used by the real smoke task', 'variante del modelo usada por la tarea smoke real'))
+ .option('--smoke-cwd ', t('真实冒烟任务的项目目录;默认为当前目录', 'project directory for the real smoke task; defaults to the current directory', 'directorio del proyecto para la tarea smoke real; por defecto el directorio actual'))
+ .option('--smoke-timeout ', t('真实冒烟任务等待上限,如 2min / 5min', 'real smoke task timeout, e.g. 2min / 5min', 'timeout de la tarea smoke real, p. ej. 2min / 5min'), '3min')
.action(async (options: {
json?: boolean;
smoke?: boolean;
@@ -668,8 +668,8 @@ program
}
if (gateway.startupConfigured === false) {
warnings.push(process.platform === 'linux'
- ? t('未检测到已启用且包含可恢复 PM2 dump 的 systemd 自启服务', 'No enabled systemd startup service with a recoverable PM2 dump was detected')
- : t('未检测到正在运行且包含可恢复 PM2 dump 的 macOS LaunchAgent', 'No running macOS LaunchAgent with a recoverable PM2 dump was detected'));
+ ? t('未检测到已启用且包含可恢复 PM2 dump 的 systemd 自启服务', 'No enabled systemd startup service with a recoverable PM2 dump was detected', 'No se detectó un servicio de arranque systemd habilitado con un dump PM2 recuperable')
+ : t('未检测到正在运行且包含可恢复 PM2 dump 的 macOS LaunchAgent', 'No running macOS LaunchAgent with a recoverable PM2 dump was detected', 'No se detectó un LaunchAgent de macOS en ejecución con un dump PM2 recuperable'));
}
if (gateway.processFound && !gateway.scopeMatches) {
warnings.push(t(
@@ -685,13 +685,13 @@ program
}
for (const run of legacyQuarantinedRuns) {
const cwdHint = run.taskCwd == null
- ? t('(旧任务没有 cwd,请先在 Dashboard 取消)', ' (the legacy task has no cwd; cancel it in the Dashboard first)')
- : t(`(在 ${run.taskCwd} 执行)`, ` (run in ${run.taskCwd})`);
+ ? t('(旧任务没有 cwd,请先在 Dashboard 取消)', ' (the legacy task has no cwd; cancel it in the Dashboard first)', ' (la tarea legacy no tiene cwd; cancélala primero en el panel)')
+ : t(`(在 ${run.taskCwd} 执行)`, ` (run in ${run.taskCwd})`, ` (ejecutar en ${run.taskCwd})`);
const cancel = run.taskStatus === 'cancelled'
? ''
- : t(`先${cwdHint} supertask cancel --id ${run.taskId};`, `first${cwdHint}: supertask cancel --id ${run.taskId}; `);
+ : t(`先${cwdHint} supertask cancel --id ${run.taskId};`, `first${cwdHint}: supertask cancel --id ${run.taskId}; `, `primero${cwdHint}: supertask cancel --id ${run.taskId}; `);
const owner = run.ownerAlive
- ? t(`owner PID ${run.workerPid} 仍存活,先确认并停止对应进程;`, `owner PID ${run.workerPid} is still alive; confirm and stop it first; `)
+ ? t(`owner PID ${run.workerPid} 仍存活,先确认并停止对应进程;`, `owner PID ${run.workerPid} is still alive; confirm and stop it first; `, `el PID propietario ${run.workerPid} sigue activo; confírmalo y detenlo primero; `)
: '';
warnings.push(t(
`旧版隔离 run #${run.runId}:${owner}${cancel}确认没有遗留 OpenCode 进程后执行 supertask run abandon --id ${run.runId} --confirm ABANDON`,
@@ -728,7 +728,7 @@ program
timeoutMs: smokeTimeoutMs,
});
}
- if (!smoke.ok) warnings.push(smoke.error ?? t('真实冒烟任务失败', 'Real smoke task failed'));
+ if (!smoke.ok) warnings.push(smoke.error ?? t('真实冒烟任务失败', 'Real smoke task failed', 'La tarea smoke real falló'));
}
const ok = opencode.ok
&& plugin.ok
@@ -767,18 +767,18 @@ program
console.log(JSON.stringify(report, null, 2));
} else {
const mark = (value: boolean) => value ? '✓' : '✗';
- console.log(`SuperTask doctor: ${ok ? t('正常', 'healthy') : t('异常', 'unhealthy')}`);
- console.log(`${mark(opencode.ok)} OpenCode ${opencode.version ?? opencode.error ?? t('不可用', 'unavailable')}`);
- console.log(`${mark(gateway.gatewayOpenCode?.ok === true)} ${t('Gateway 环境中的 OpenCode', 'OpenCode in Gateway environment')} ${gateway.gatewayOpenCode?.version ?? gateway.gatewayOpenCode?.error ?? t('不可用', 'unavailable')}${gateway.gatewayOpenCode?.executable ? `,${gateway.gatewayOpenCode.executable}` : ''}`);
- console.log(`${mark(plugin.ok)} ${t('OpenCode 插件', 'OpenCode plugin')} ${plugin.spec || plugin.error || t('未配置', 'not configured')}${plugin.cachedVersion ? t(`(缓存 v${plugin.cachedVersion})`, ` (cached v${plugin.cachedVersion})`) : ''}`);
- console.log(`${mark(database.ok)} ${t('数据库', 'Database')} ${database.path}${t(`(任务 ${database.counts.tasks},运行中 ${database.runningTasks})`, ` (tasks ${database.counts.tasks}, running ${database.runningTasks})`)}`);
+ console.log(`SuperTask doctor: ${ok ? t('正常', 'healthy', 'saludable') : t('异常', 'unhealthy', 'no saludable')}`);
+ console.log(`${mark(opencode.ok)} OpenCode ${opencode.version ?? opencode.error ?? t('不可用', 'unavailable', 'no disponible')}`);
+ console.log(`${mark(gateway.gatewayOpenCode?.ok === true)} ${t('Gateway 环境中的 OpenCode', 'OpenCode in Gateway environment', 'OpenCode en el entorno del Gateway')} ${gateway.gatewayOpenCode?.version ?? gateway.gatewayOpenCode?.error ?? t('不可用', 'unavailable', 'no disponible')}${gateway.gatewayOpenCode?.executable ? `,${gateway.gatewayOpenCode.executable}` : ''}`);
+ console.log(`${mark(plugin.ok)} ${t('OpenCode 插件', 'OpenCode plugin', 'plugin de OpenCode')} ${plugin.spec || plugin.error || t('未配置', 'not configured', 'no configurado')}${plugin.cachedVersion ? t(`(缓存 v${plugin.cachedVersion})`, ` (cached v${plugin.cachedVersion})`, ` (en caché v${plugin.cachedVersion})`) : ''}`);
+ console.log(`${mark(database.ok)} ${t('数据库', 'Database', 'Base de datos')} ${database.path}${t(`(任务 ${database.counts.tasks},运行中 ${database.runningTasks})`, ` (tasks ${database.counts.tasks}, running ${database.runningTasks})`, ` (tareas ${database.counts.tasks}, en ejecución ${database.runningTasks})`)}`);
console.log(`${mark(gateway.status === 'online' && gateway.ready && gatewayEntryPinned && gatewayVersionMatchesPackage)} Gateway ${gateway.status ?? 'missing'}${gateway.pid ? `,PID ${gateway.pid}` : ''}${gateway.runningVersion ? `,v${gateway.runningVersion}` : ''}${gateway.gatewayEntry ? `,${gateway.gatewayEntry}` : ''}`);
- console.log(`${mark(dashboard.ok)} Dashboard ${dashboard.enabled ? dashboard.url : t('已禁用', 'disabled')}`);
+ console.log(`${mark(dashboard.ok)} Dashboard ${dashboard.enabled ? dashboard.url : t('已禁用', 'disabled', 'desactivado')}`);
if (options.smoke) {
const smokeSummary = smoke && 'taskId' in smoke
? `task #${smoke.taskId}${smoke.runId ? ` / run #${smoke.runId}` : ''},${smoke.durationMs}ms`
- : smoke?.error ?? t('未执行', 'not run');
- console.log(`${mark(smoke?.ok === true)} ${t('真实 Gateway 冒烟任务', 'Real Gateway smoke task')} ${smokeSummary}`);
+ : smoke?.error ?? t('未执行', 'not run', 'no ejecutado');
+ console.log(`${mark(smoke?.ok === true)} ${t('真实 Gateway 冒烟任务', 'Real Gateway smoke task', 'Tarea smoke real del Gateway')} ${smokeSummary}`);
}
for (const warning of warnings) console.log(`! ${warning}`);
}
@@ -787,7 +787,7 @@ program
program
.command('install')
- .description(t('用 PM2 安装 Gateway(开机启动、崩溃恢复、日志轮转)', 'install the Gateway with PM2 (startup, crash recovery, log rotation)'))
+ .description(t('用 PM2 安装 Gateway(开机启动、崩溃恢复、日志轮转)', 'install the Gateway with PM2 (startup, crash recovery, log rotation)', 'instalar el Gateway con PM2 (arranque, recuperación ante fallos y rotación de logs)'))
.action(async () => {
try {
const { install: pm2Install } = await import('../daemon/pm2');
@@ -800,7 +800,7 @@ program
program
.command('uninstall')
- .description(t('停止并移除 PM2 Gateway', 'stop and remove the PM2 Gateway'))
+ .description(t('停止并移除 PM2 Gateway', 'stop and remove the PM2 Gateway', 'detener y eliminar el Gateway de PM2'))
.action(async () => {
try {
const { uninstall: pm2Uninstall } = await import('../daemon/pm2');
@@ -813,10 +813,10 @@ program
program
.command('upgrade')
- .description(t('更新 OpenCode 插件、CLI 和 Gateway;已是最新版本时不重启', 'update the OpenCode plugin, CLI, and Gateway without restarting when already current'))
- .option('--force', t('即使已是最新版本也重新安装并重启 Gateway', 'reinstall and restart the Gateway even when already current'))
+ .description(t('更新 OpenCode 插件、CLI 和 Gateway;已是最新版本时不重启', 'update the OpenCode plugin, CLI, and Gateway without restarting when already current', 'actualizar el plugin de OpenCode, la CLI y el Gateway sin reiniciar si ya están al día'))
+ .option('--force', t('即使已是最新版本也重新安装并重启 Gateway', 'reinstall and restart the Gateway even when already current', 'reinstalar y reiniciar el Gateway aunque ya esté al día'))
.action(async (options: { force?: boolean }) => {
- console.log(t('正在检查 opencode-supertask 更新...', 'Checking for opencode-supertask updates...'));
+ console.log(t('正在检查 opencode-supertask 更新...', 'Checking for opencode-supertask updates...', 'Comprobando actualizaciones de opencode-supertask...'));
let installed: { gatewayEntry: string; version: string };
let previousVersion: string;
let targetVersion: string;
@@ -842,11 +842,11 @@ program
}
previousVersion = plugin.version ?? updater.resolveInstalledPlugin().version;
} catch (error) {
- console.error(t('无法检查当前版本,已取消升级:', 'Could not check the current version; upgrade cancelled: ')
+ console.error(t('无法检查当前版本,已取消升级:', 'Could not check the current version; upgrade cancelled: ', 'No se pudo comprobar la versión actual; actualización cancelada: ')
+ (error instanceof Error ? error.message : String(error)));
process.exit(1);
}
- console.log(t('正在更新 opencode-supertask...', 'Updating opencode-supertask...'));
+ console.log(t('正在更新 opencode-supertask...', 'Updating opencode-supertask...', 'Actualizando opencode-supertask...'));
try {
installed = updater.installPluginVersion(targetVersion);
} catch (err) {
@@ -871,7 +871,7 @@ program
`\nSuperTask 已升级:${result.before ?? 'unknown'} → ${result.after}`,
`\nSuperTask upgraded: ${result.before ?? 'unknown'} → ${result.after}`,
));
- console.log(t('Gateway 已重启。请重启 OpenCode 加载新插件。', 'Gateway restarted. Restart OpenCode to load the new plugin.'));
+ console.log(t('Gateway 已重启。请重启 OpenCode 加载新插件。', 'Gateway restarted. Restart OpenCode to load the new plugin.', 'Gateway reiniciado. Reinicia OpenCode para cargar el plugin nuevo.'));
try {
const { updateGlobalCli } = await import('../daemon/update');
const cli = updateGlobalCli(installed.version);
@@ -881,10 +881,10 @@ program
`Global CLI updated to v${installed.version} with ${cli.packageManager}.`,
));
} else if (cli.action === 'not-installed') {
- console.log(t('未找到全局 CLI;插件和 Gateway 升级已完成。', 'No global CLI installation was found; plugin and Gateway upgrade is complete.'));
+ console.log(t('未找到全局 CLI;插件和 Gateway 升级已完成。', 'No global CLI installation was found; plugin and Gateway upgrade is complete.', 'No se encontró una instalación global de la CLI; la actualización del plugin y del Gateway ha terminado.'));
}
} catch (cliError) {
- console.error(t('插件和 Gateway 已升级,但全局 CLI 未更新:', 'Plugin and Gateway were upgraded, but the global CLI was not: ')
+ console.error(t('插件和 Gateway 已升级,但全局 CLI 未更新:', 'Plugin and Gateway were upgraded, but the global CLI was not: ', 'Se actualizaron el plugin y el Gateway, pero no la CLI global: ')
+ (cliError instanceof Error ? cliError.message : String(cliError)));
console.error(t(
`执行 npm install -g opencode-supertask@${installed.version} 或 bun add -g opencode-supertask@${installed.version},再运行 supertask doctor。`,
@@ -901,7 +901,7 @@ program
} catch (rollbackError) {
detail += `; Gateway 已回滚,但 OpenCode 插件回滚失败: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`;
}
- console.error(t('Gateway 重启失败:', 'Gateway restart failed:'), detail);
+ console.error(t('Gateway 重启失败:', 'Gateway restart failed:', 'Falló el reinicio del Gateway:'), detail);
process.exit(1);
}
});
diff --git a/src/web/index.tsx b/src/web/index.tsx
index bfa4c33..0dd2432 100644
--- a/src/web/index.tsx
+++ b/src/web/index.tsx
@@ -40,6 +40,7 @@ import {
formatFuture,
formatRelative,
icon,
+ intlLocale,
renderLayout,
runStatusText,
statusText,
@@ -283,23 +284,39 @@ function safeStatus(value: string | null): TaskStatus | 'unknown' {
return value && TASK_STATUSES.has(value as TaskStatus) ? value as TaskStatus : 'unknown';
}
+const SUPPORTED_LOCALES = new Set(['en', 'es', 'zh-CN']);
+
+function parseLocale(value: string | null | undefined): Locale | null {
+ if (!value) return null;
+ const normalized = value.trim().toLowerCase().replace('_', '-');
+ if (normalized === 'zh' || normalized.startsWith('zh-')) return 'zh-CN';
+ if (normalized === 'es' || normalized.startsWith('es-')) return 'es';
+ if (normalized === 'en' || normalized.startsWith('en-')) return 'en';
+ return SUPPORTED_LOCALES.has(value as Locale) ? value as Locale : null;
+}
+
function resolveLocale(c: Context): Locale {
- const requested = c.req.query('lang');
- if (requested === 'en' || requested === 'zh-CN') return requested;
+ const requested = parseLocale(c.req.query('lang'));
+ if (requested) return requested;
const cookie = c.req.header('Cookie') ?? '';
const match = /(?:^|;\s*)supertask_locale=([^;]+)/.exec(cookie);
if (match) {
try {
- const saved = decodeURIComponent(match[1]);
- if (saved === 'en' || saved === 'zh-CN') return saved;
+ const saved = parseLocale(decodeURIComponent(match[1]));
+ if (saved) return saved;
} catch {
// Ignore malformed client cookies and continue with browser negotiation.
}
}
const accepted = c.req.header('Accept-Language')?.toLowerCase() ?? '';
- return accepted.startsWith('en') ? 'en' : 'zh-CN';
+ for (const part of accepted.split(',')) {
+ const tag = part.split(';')[0]?.trim() ?? '';
+ const matched = parseLocale(tag);
+ if (matched) return matched;
+ }
+ return 'en';
}
app.use('*', async (c, next) => {
@@ -558,7 +575,7 @@ function durationControl(
}
function formatInterval(milliseconds: number, locale: Locale): string {
- const formatter = new Intl.NumberFormat(locale === 'en' ? 'en-US' : 'zh-CN', {
+ const formatter = new Intl.NumberFormat(intlLocale(locale), {
maximumFractionDigits: 1,
});
if (milliseconds < 60_000) {
diff --git a/src/web/ui.ts b/src/web/ui.ts
index 2b21b8c..9b5e01c 100644
--- a/src/web/ui.ts
+++ b/src/web/ui.ts
@@ -1,4 +1,4 @@
-export type Locale = 'zh-CN' | 'en';
+export type Locale = 'zh-CN' | 'en' | 'es';
export type ActiveTab = 'tasks' | 'templates' | 'runs' | 'system';
const ZH = {
@@ -615,15 +615,339 @@ const EN: Record = {
'a11y.refreshing': 'Refreshing',
};
+const ES: Record = {
+ 'app.name': 'SuperTask',
+ 'app.dashboard': 'Panel',
+ 'app.tagline': 'Orquestación local fiable de agentes',
+ 'app.local': 'Ejecutándose en local',
+ 'app.footer': 'Local-first · Tus datos permanecen en este dispositivo',
+ 'nav.tasks': 'Tareas',
+ 'nav.templates': 'Tareas programadas',
+ 'nav.runs': 'Ejecuciones',
+ 'nav.system': 'Sistema',
+ 'page.tasks.title': 'Cola de tareas',
+ 'page.tasks.description': 'Sigue la prioridad, el estado de ejecución y los reintentos, y actúa sobre las tareas que requieren atención.',
+ 'page.templates.title': 'Tareas programadas',
+ 'page.templates.description': 'Crea y edita tareas cron, de intervalo fijo y de una sola vez, incluyendo modelo, agente y prompt.',
+ 'page.runs.title': 'Historial de ejecución',
+ 'page.runs.description': 'Inspecciona el estado, la duración, el heartbeat y la salida de cada ejecución del agente.',
+ 'page.system.title': 'Ajustes del sistema',
+ 'page.system.description': 'Ajusta el comportamiento del Gateway, inspecciona el trabajo activo y gestiona los datos locales.',
+ 'action.refresh': 'Actualizar',
+ 'action.details': 'Detalles',
+ 'action.retry': 'Reintentar',
+ 'action.cancel': 'Cancelar',
+ 'action.delete': 'Eliminar',
+ 'action.edit': 'Editar',
+ 'action.enable': 'Activar',
+ 'action.disable': 'Desactivar',
+ 'action.trigger': 'Ejecutar ahora',
+ 'action.continueSession': 'Continuar sesión',
+ 'action.logs': 'Ver registro',
+ 'action.hideLogs': 'Ocultar registro',
+ 'action.save': 'Guardar ajustes',
+ 'action.saveAndRestart': 'Guardar y reiniciar',
+ 'action.copy': 'Copiar datos en bruto',
+ 'action.close': 'Cerrar',
+ 'action.confirm': 'Confirmar',
+ 'action.clearDatabase': 'Vaciar base de datos',
+ 'action.createTask': 'Nueva tarea',
+ 'action.saveTask': 'Añadir a la cola',
+ 'action.updateTask': 'Guardar cambios',
+ 'action.createTemplate': 'Nueva tarea programada',
+ 'action.saveTemplate': 'Guardar tarea programada',
+ 'action.chooseFolder': 'Elegir carpeta',
+ 'action.chooseThisFolder': 'Elegir esta carpeta',
+ 'action.home': 'Inicio',
+ 'action.up': 'Subir',
+ 'action.copyCommand': 'Copiar comando',
+ 'action.showHidden': 'Mostrar carpetas ocultas',
+ 'action.hideHidden': 'Ocultar carpetas ocultas',
+ 'status.pending': 'Pendiente',
+ 'status.running': 'En ejecución',
+ 'status.done': 'Completada',
+ 'status.failed': 'Esperando reintento',
+ 'status.dead_letter': 'Detenida',
+ 'status.deadLetterHint': 'El sistema no volverá a ejecutar esta tarea automáticamente. Puede haber agotado los reintentos o una dependencia no puede continuar; revisa el error y reintenta manualmente si procede.',
+ 'status.deadLetterAction': 'Revisa el error y reintenta manualmente',
+ 'status.cancelled': 'Cancelada',
+ 'status.executionStillActive': 'El proceso de ejecución aún se está deteniendo y ocupa temporalmente un hueco',
+ 'status.unknown': 'Desconocido',
+ 'runStatus.running': 'En ejecución',
+ 'runStatus.done': 'Correcta',
+ 'runStatus.failed': 'Fallida',
+ 'stats.total': 'Total de tareas',
+ 'stats.pending': 'Pendientes',
+ 'stats.running': 'En ejecución',
+ 'stats.done': 'Completadas',
+ 'stats.failedDead': 'Esperando reintento y detenidas',
+ 'stats.templates': 'Tareas programadas',
+ 'stats.enabled': 'Activadas',
+ 'stats.disabled': 'Desactivadas',
+ 'stats.records': 'Total de ejecuciones',
+ 'stats.pageDone': 'Correctas en esta página',
+ 'stats.pageFailed': 'Fallidas en esta página',
+ 'stats.pageRunning': 'En ejecución aquí',
+ 'filter.all': 'Todas',
+ 'filter.searchTasks': 'Buscar tareas, proyectos, lotes, agentes o prompts en esta página',
+ 'filter.noResults': 'Ninguna tarea coincide con esta búsqueda',
+ 'table.id': 'ID',
+ 'table.task': 'Tarea',
+ 'table.name': 'Nombre',
+ 'table.agent': 'Agente',
+ 'table.status': 'Estado',
+ 'table.duration': 'Duración',
+ 'table.retries': 'Reintentos',
+ 'table.actions': 'Acciones',
+ 'table.type': 'Tipo',
+ 'table.rule': 'Regla',
+ 'table.lastRun': 'Última ejecución',
+ 'table.nextRun': 'Próxima ejecución',
+ 'table.run': 'Ejecución',
+ 'table.heartbeat': 'Heartbeat',
+ 'table.session': 'Sesión',
+ 'table.model': 'Modelo',
+ 'table.startedAt': 'Inicio',
+ 'table.pid': 'PID',
+ 'pagination.previous': 'Anterior',
+ 'pagination.next': 'Siguiente',
+ 'pagination.summary': 'Página {page} de {pages} · {total} elementos',
+ 'empty.tasks': 'Tu cola está vacía',
+ 'empty.tasksHint': 'Elige «Nueva tarea», o usa el plugin de OpenCode o supertask add.',
+ 'empty.templates': 'Aún no hay tareas programadas',
+ 'empty.templatesHint': 'Elige «Nueva tarea programada» para crear una.',
+ 'empty.runs': 'Aún no hay historial de ejecución',
+ 'empty.running': 'No hay tareas en ejecución ahora mismo',
+ 'schedule.cron': 'Cron',
+ 'schedule.recurring': 'Intervalo fijo',
+ 'schedule.delayed': 'Una sola vez',
+ 'schedule.unknown': 'Desconocido',
+ 'schedule.enabled': 'Activada',
+ 'schedule.disabled': 'Desactivada',
+ 'schedule.minutes': '{count} min',
+ 'schedule.seconds': '{count} s',
+ 'schedule.hours': '{count} h',
+ 'schedule.days': '{count} días',
+ 'schedule.overdue': 'Vencida',
+ 'duration.unit': 'Unidad de tiempo',
+ 'duration.seconds': 'segundos',
+ 'duration.minutes': 'minutos',
+ 'duration.hours': 'horas',
+ 'duration.days': 'días',
+ 'duration.systemDefault': 'Usar el timeout predeterminado del Gateway',
+ 'duration.immediate': 'Reintentar de inmediato',
+ 'duration.custom': 'Personalizado…',
+ 'duration.every': 'Cada {duration}',
+ 'system.worker': 'Ejecución de tareas',
+ 'system.scheduler': 'Servicio de tareas programadas',
+ 'system.watchdog': 'Monitor en tiempo de ejecución',
+ 'system.maxConcurrency': 'Concurrencia máxima',
+ 'system.pollInterval': 'Intervalo de sondeo',
+ 'system.heartbeatInterval': 'Intervalo de heartbeat',
+ 'system.taskTimeout': 'Timeout de tarea',
+ 'system.schedulerEnabled': 'Activar tareas programadas',
+ 'system.checkInterval': 'Intervalo de comprobación',
+ 'system.heartbeatTimeout': 'Timeout de heartbeat',
+ 'system.cleanupInterval': 'Intervalo de limpieza',
+ 'system.retentionDays': 'Retención de datos',
+ 'system.milliseconds': 'ms',
+ 'system.seconds': 'segundos',
+ 'system.minutes': 'minutos',
+ 'system.hours': 'horas',
+ 'system.days': 'días',
+ 'system.activeTemplates': 'Tareas programadas activas',
+ 'system.saveHint': 'Reinicia el Gateway para aplicar los ajustes guardados.',
+ 'system.configApplied': 'Los ajustes mostrados aquí están activos.',
+ 'system.configPending': 'Los ajustes están guardados, pero este Gateway sigue usando los valores de su último arranque.',
+ 'system.configForeground': 'Esta página no está conectada a la configuración en ejecución del Gateway. Reinicia el Gateway tras guardar.',
+ 'system.configRestartManually': 'Los ajustes están guardados pero no activos. Vuelve al terminal que lanzó el Gateway, pulsa Ctrl-C y vuelve a ejecutar supertask gateway.',
+ 'system.runningTasks': 'Tareas activas ({running} / {limit} concurrentes)',
+ 'system.taskStats': 'Resumen de tareas',
+ 'system.configFile': 'Archivo de configuración',
+ 'system.path': 'Ruta',
+ 'system.fileExists': 'El archivo existe',
+ 'system.yes': 'Sí',
+ 'system.noDefault': 'No, usando valores predeterminados',
+ 'system.danger': 'Zona de peligro',
+ 'system.dangerDescription': 'Primero se crea una copia de seguridad verificada; después se vacían de forma transaccional las tareas, ejecuciones y plantillas. El trabajo activo bloquea esta operación.',
+ 'template.createTitle': 'Nueva tarea programada',
+ 'template.editTitle': 'Editar tarea programada',
+ 'template.formSubtitle': 'Elige cuándo ejecutar y qué proyecto, modelo y prompt debe usar OpenCode.',
+ 'template.name': 'Nombre',
+ 'template.cwd': 'Directorio del proyecto',
+ 'template.cwdHint': 'OpenCode ejecuta la tarea en este directorio.',
+ 'template.agent': 'Agente',
+ 'template.model': 'Modelo',
+ 'template.variant': 'Variante del modelo',
+ 'template.prompt': 'Prompt',
+ 'template.scheduleType': 'Programación',
+ 'template.cronExpr': 'Expresión cron',
+ 'template.cronHint': 'Ejemplo: 0 9 * * * (cada día a las 09:00)',
+ 'template.interval': 'Intervalo',
+ 'template.runAt': 'Ejecutar a las',
+ 'template.durationHint': 'Admite 30s, 5min, 1h, 2d',
+ 'template.intervalHint': 'Elige una frecuencia habitual; personaliza solo si es necesario.',
+ 'template.retryBackoffHint': 'Cuánto esperar tras un fallo antes de reintentar.',
+ 'template.timeoutHint': 'Déjalo en blanco para usar el timeout predeterminado de tarea del Gateway.',
+ 'template.advanced': 'Más ajustes de ejecución',
+ 'template.category': 'Categoría',
+ 'template.batchId': 'ID de lote',
+ 'template.importance': 'Importancia (1-5)',
+ 'template.urgency': 'Urgencia (1-5)',
+ 'template.maxInstances': 'Límite de programación automática',
+ 'template.maxInstancesHint': 'Solo limita la programación automática. «Ejecutar ahora» siempre encola una tarea. Las instancias activas incluyen pendientes, en ejecución y en espera de reintento.',
+ 'template.maxRetries': 'Reintentos ante fallo',
+ 'template.retryBackoff': 'Espera entre reintentos',
+ 'template.timeout': 'Timeout de ejecución',
+ 'template.optional': 'Opcional',
+ 'template.futureOnly': 'Los ajustes guardados se aplican a las tareas creadas en el futuro. Editar no cambia las tareas en cola o en ejecución.',
+ 'projects.title': 'Proyectos',
+ 'projects.description': 'El directorio del proyecto es la clave de aislamiento. Revisa el trabajo activo y en cola antes de añadir más.',
+ 'projects.all': 'Todos los proyectos',
+ 'projects.legacy': 'Sin agrupar',
+ 'projects.legacyHint': 'Tareas antiguas sin directorio de proyecto. Puedes verlas, cancelarlas o eliminarlas; recrea bajo el proyecto correcto para modificarlas.',
+ 'projects.counts': 'En ejecución {running} · en cola {pending} · con problemas {failed}',
+ 'task.createTitle': 'Nueva tarea',
+ 'task.editTitle': 'Editar tarea',
+ 'task.formSubtitle': 'La tarea entra de inmediato en la cola persistente y espera automáticamente si la concurrencia está llena.',
+ 'task.batchHint': 'Las tareas con el mismo ID de lote no vacío se ejecutan en serie. En blanco elimina la restricción de lote; la concurrencia global y las dependencias siguen aplicándose.',
+ 'task.projectExisting': 'Este proyecto tiene {total} tareas: {running} en ejecución, {pending} en cola y {failed} con problemas.',
+ 'task.projectNew': 'Este es un grupo de proyecto nuevo. Aparecerá en la lista de proyectos tras crearlo.',
+ 'catalog.chooseProject': 'Elige primero un directorio de proyecto',
+ 'catalog.defaultModel': 'Usar el modelo predeterminado del agente / OpenCode',
+ 'catalog.defaultProvider': 'Modelo predeterminado',
+ 'catalog.defaultVariant': 'Usar el predeterminado del agente / modelo',
+ 'catalog.provider': 'Proveedor del modelo',
+ 'catalog.model': 'Modelo',
+ 'catalog.modelHint': 'Elige un proveedor y luego un modelo devuelto por opencode models --verbose para este proyecto. El valor predeterminado no pasa -m.',
+ 'catalog.variantHint': 'Solo se muestran las variantes declaradas por el modelo seleccionado. El valor predeterminado no pasa --variant.',
+ 'catalog.agentHint': 'Cargado desde opencode agent list de este proyecto.',
+ 'catalog.loading': 'Cargando agentes y modelos disponibles para este proyecto…',
+ 'catalog.loaded': 'Se cargaron {agents} agentes y {models} modelos desde OpenCode local.',
+ 'catalog.failed': 'No se pudo cargar la configuración de OpenCode de este proyecto: {error}',
+ 'catalog.primary': 'agente principal',
+ 'catalog.subagent': 'subagente',
+ 'catalog.all': 'agente general',
+ 'directory.title': 'Elegir directorio del proyecto',
+ 'directory.subtitle': 'OpenCode se ejecuta en este directorio y se cargan sus agentes y modelos específicos del proyecto.',
+ 'directory.empty': 'Esta carpeta no tiene subcarpetas',
+ 'logs.command': 'Comando ejecutado',
+ 'logs.output': 'Salida del agente',
+ 'logs.error': 'Motivo del fallo',
+ 'logs.tools': 'Llamadas a herramientas',
+ 'logs.raw': 'Ver registro de ejecución en bruto',
+ 'logs.noText': 'Esta ejecución no produjo texto visible. Revisa el registro en bruto para más detalles.',
+ 'theme.label': 'Tema',
+ 'theme.system': 'Sistema',
+ 'theme.light': 'Claro',
+ 'theme.dark': 'Oscuro',
+ 'language.label': 'Idioma',
+ 'details.title': 'Detalles',
+ 'details.subtitle': 'La información clave está organizada abajo; los datos en bruto solo sirven para diagnóstico.',
+ 'details.taskTitle': 'Detalles de la tarea',
+ 'details.runTitle': 'Detalles de la ejecución',
+ 'details.templateTitle': 'Detalles de la tarea programada',
+ 'details.raw': 'Ver datos en bruto (JSON)',
+ 'details.copySuccess': 'Datos en bruto copiados',
+ 'details.id': 'ID',
+ 'details.project': 'Directorio del proyecto',
+ 'details.variant': 'Variante del modelo',
+ 'details.prompt': 'Prompt',
+ 'details.result': 'Resultado / motivo del fallo',
+ 'details.category': 'Categoría',
+ 'details.batch': 'Lote',
+ 'details.dependency': 'Dependencia',
+ 'details.importance': 'Importancia',
+ 'details.urgency': 'Urgencia',
+ 'details.retryCount': 'Reintentos usados / permitidos',
+ 'details.retryBackoff': 'Espera entre reintentos',
+ 'details.timeout': 'Timeout de ejecución',
+ 'details.createdAt': 'Creada',
+ 'details.updatedAt': 'Actualizada',
+ 'details.startedAt': 'Iniciada',
+ 'details.finishedAt': 'Finalizada',
+ 'details.scheduledAt': 'Programada para',
+ 'details.enabled': 'Ejecuciones automáticas',
+ 'details.scheduleRule': 'Regla de programación',
+ 'details.maxInstances': 'Límite de programación automática',
+ 'details.maxRetries': 'Reintentos ante fallo',
+ 'details.lastRun': 'Última ejecución',
+ 'details.nextRun': 'Próxima ejecución',
+ 'details.taskId': 'Tarea',
+ 'details.session': 'Sesión de OpenCode',
+ 'details.heartbeat': 'Último heartbeat',
+ 'details.process': 'Procesos',
+ 'details.history': 'Historial de ejecuciones',
+ 'details.noHistory': 'Aún no hay ejecuciones',
+ 'details.none': 'Ninguno',
+ 'details.default': 'Usar el predeterminado del sistema',
+ 'details.enabledYes': 'Activada',
+ 'details.enabledNo': 'Desactivada',
+ 'dialog.cancelTask': '¿Cancelar la tarea #{id}?',
+ 'dialog.cancelTaskBody': 'Una tarea en ejecución terminará su grupo de procesos gestionado en el siguiente sondeo del worker.',
+ 'dialog.retryTask': '¿Reintentar la tarea #{id}?',
+ 'dialog.retryTaskBody': 'La tarea vuelve a pendiente y se restablece su presupuesto de reintentos automáticos.',
+ 'dialog.deleteTask': '¿Eliminar la tarea #{id}?',
+ 'dialog.deleteTaskBody': 'La tarea y su historial de ejecución se eliminarán de forma permanente.',
+ 'dialog.disableTemplate': '¿Desactivar esta programación?',
+ 'dialog.disableTemplateBody': 'Dejará de crear tareas nuevas automáticamente. Las tareas existentes no cambian.',
+ 'dialog.deleteTemplate': '¿Eliminar esta programación?',
+ 'dialog.deleteTemplateBody': 'Esta configuración de tarea programada se eliminará de forma permanente.',
+ 'dialog.triggerTemplate': '¿Ejecutar esta programación ahora?',
+ 'dialog.triggerTemplateBody': 'Se encola de inmediato una tarea con los ajustes actuales. Si la concurrencia global está llena, esperará a ejecutarse.',
+ 'dialog.clearTitle': 'Confirmar vaciado de la base de datos',
+ 'dialog.clearBody': 'Esto elimina todas las tareas, ejecuciones y tareas programadas tras crear una copia de seguridad.',
+ 'dialog.clearInstruction': 'Escribe CLEAR para confirmar',
+ 'dialog.restartGateway': '¿Reiniciar y aplicar los ajustes?',
+ 'dialog.restartGatewayBody': 'El Gateway no estará disponible brevemente y debería recuperarse con PM2 en unos segundos.',
+ 'dialog.restartGatewayRunningBody': 'Hay {count} tareas en ejecución. El sistema espera primero; tras el periodo de gracia, las tareas incompletas se detienen de forma segura y vuelven a la cola antes de que PM2 restaure el Gateway.',
+ 'feedback.retryFailed': 'El reintento falló',
+ 'feedback.cancelFailed': 'La cancelación falló',
+ 'feedback.deleteFailed': 'La eliminación falló',
+ 'feedback.requestFailed': 'La solicitud falló',
+ 'feedback.triggered': 'Tarea #{id} creada',
+ 'feedback.taskCreated': 'Tarea #{id} añadida a la cola',
+ 'feedback.taskUpdated': 'Tarea #{id} actualizada',
+ 'feedback.templateCreated': 'Tarea programada creada',
+ 'feedback.templateUpdated': 'Tarea programada actualizada',
+ 'feedback.configSaved': 'Ajustes guardados',
+ 'feedback.sessionCommandCopied': 'Comando de sesión copiado. Pégalo en un terminal para continuar.',
+ 'feedback.commandCopied': 'Comando de ejecución copiado',
+ 'feedback.restarting': 'El Gateway se está reiniciando…',
+ 'feedback.restartTimeout': 'El Gateway aún no se ha recuperado. Actualiza más tarde o comprueba el estado de PM2.',
+ 'feedback.databaseCleared': 'Base de datos vaciada. Copia de seguridad: {path}',
+ 'feedback.copyFailed': 'La copia falló. Selecciona el contenido manualmente.',
+ 'a11y.skip': 'Saltar al contenido principal',
+ 'a11y.refreshing': 'Actualizando',
+};
+
+const MESSAGES: Record> = {
+ 'zh-CN': ZH,
+ en: EN,
+ es: ES,
+};
+
export function t(
locale: Locale,
key: MessageKey,
values: Record = {},
): string {
- const template = (locale === 'en' ? EN : ZH)[key];
+ const template = MESSAGES[locale][key];
return template.replace(/\{([a-zA-Z]+)\}/g, (_, name: string) => String(values[name] ?? `{${name}}`));
}
+export function intlLocale(locale: Locale): string {
+ if (locale === 'en') return 'en-US';
+ if (locale === 'es') return 'es-ES';
+ return 'zh-CN';
+}
+
+export function nextLocale(locale: Locale): Locale {
+ if (locale === 'en') return 'es';
+ if (locale === 'es') return 'zh-CN';
+ return 'en';
+}
+
export function statusText(locale: Locale, status: string): string {
const key = `status.${status}` as MessageKey;
return key in ZH ? t(locale, key) : t(locale, 'status.unknown');
@@ -647,8 +971,7 @@ export function formatRelative(timestamp: number | null, locale: Locale): string
if (!timestamp) return '—';
const deltaMs = timestamp - Date.now();
const abs = Math.abs(deltaMs);
- const language = locale === 'en' ? 'en' : 'zh-CN';
- const formatter = new Intl.RelativeTimeFormat(language, { numeric: 'auto' });
+ const formatter = new Intl.RelativeTimeFormat(intlLocale(locale), { numeric: 'auto' });
if (abs < 60_000) return formatter.format(Math.round(deltaMs / 1000), 'second');
if (abs < 3_600_000) return formatter.format(Math.round(deltaMs / 60_000), 'minute');
if (abs < 86_400_000) return formatter.format(Math.round(deltaMs / 3_600_000), 'hour');
@@ -664,7 +987,7 @@ export function formatFuture(timestamp: number | null, locale: Locale): string {
export function formatDateTime(value: Date | number | null, locale: Locale): string {
if (!value) return '—';
const date = value instanceof Date ? value : new Date(value);
- return new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'zh-CN', {
+ return new Intl.DateTimeFormat(intlLocale(locale), {
month: 'short',
day: '2-digit',
hour: '2-digit',
@@ -1128,9 +1451,8 @@ export function renderLayout(options: {
{ id: 'system' as const, href: '/system', icon: 'system' as const },
];
const ui = JSON.stringify(clientMessages(locale)).replace(/
-
+
@@ -1157,8 +1479,9 @@ export function renderLayout(options: {
中
EN
+ ES
- ${icon('globe')}
+ ${icon('globe')}
${icon('sun')}
diff --git a/tests/browser/dashboard.browser.ts b/tests/browser/dashboard.browser.ts
index 72c4d64..54e1430 100644
--- a/tests/browser/dashboard.browser.ts
+++ b/tests/browser/dashboard.browser.ts
@@ -16,6 +16,10 @@ test('Dashboard 核心客户端交互可在真实浏览器执行', async ({ page
await expect(page.locator('html')).toHaveAttribute('lang', 'zh-CN');
await expect(page.getByText('任务队列', { exact: true }).first()).toBeVisible();
+ await page.getByRole('button', { name: 'ES', exact: true }).click();
+ await expect(page.locator('html')).toHaveAttribute('lang', 'es-ES');
+ await expect(page.getByText('Cola de tareas', { exact: true }).first()).toBeVisible();
+
const createTaskButton = page.locator('button[onclick="openTaskCreator()"]');
await createTaskButton.click();
await expect(page.locator('#task-dialog')).toBeVisible();
diff --git a/tests/cli-language.test.ts b/tests/cli-language.test.ts
index 151fa7a..fd2c0c5 100644
--- a/tests/cli-language.test.ts
+++ b/tests/cli-language.test.ts
@@ -13,11 +13,16 @@ function help(args: string[], env: NodeJS.ProcessEnv = process.env) {
describe('CLI 语言', () => {
test('auto 根据系统 locale 选择语言,显式参数优先于环境变量', () => {
expect(resolveCliLocale(['bun', 'cli'], { LANG: 'zh_CN.UTF-8' })).toBe('zh-CN');
+ expect(resolveCliLocale(['bun', 'cli'], { LANG: 'es_ES.UTF-8' })).toBe('es');
expect(resolveCliLocale(['bun', 'cli'], { LANG: 'C.UTF-8' })).toBe('en');
expect(resolveCliLocale(['bun', 'cli', '--lang', 'en'], {
SUPERTASK_LANG: 'zh-CN',
LANG: 'zh_CN.UTF-8',
})).toBe('en');
+ expect(resolveCliLocale(['bun', 'cli', '--lang', 'es'], {
+ SUPERTASK_LANG: 'en',
+ LANG: 'en_US.UTF-8',
+ })).toBe('es');
});
test('中英文帮助可显式切换,--lang 放在子命令后也生效', () => {
@@ -31,6 +36,11 @@ describe('CLI 语言', () => {
expect(chinese.stdout).toContain('创建新任务');
expect(chinese.stdout).toContain('主 Agent 名称');
expect(chinese.stdout).toContain('选项:');
+
+ const spanish = help(['--lang', 'es', '--help']);
+ expect(spanish.status).toBe(0);
+ expect(spanish.stdout).toContain('Cola de tareas durable y programador');
+ expect(spanish.stdout).toContain('crear una tarea en cola');
});
test('拒绝未知语言值', () => {