Release v0.51.332 — Release KV (distinguish script cron jobs in Tasks, #3589) (#3847)

Script cron jobs (no_agent) in the Tasks panel now show a script badge,
banner, script path + working directory, and 'Script output' run labels
instead of an empty Prompt card (#3589, @pamnard).

Self-rebased onto v0.51.331 (was CONFLICTING, CHANGELOG-only stale base).
Maintainer fix: added the 10 new cron i18n keys to Polish (pl), which the
PR had missed (12/13 locales) — all keys now 13/13, locale tests pass.
Full suite 8285 passed, ESLint/scope-undef/ruff CLEAN, Opus SHIP-safe.

Co-authored-by: pamnard <pamnard@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-06-08 14:58:56 -07:00 committed by GitHub
parent d560a312f2
commit 2fd039cce4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 321 additions and 46 deletions

View file

@ -3,6 +3,11 @@
## [Unreleased]
## [v0.51.332] — 2026-06-08 — Release KV (distinguish script cron jobs in Tasks)
### Fixed
- **Script cron jobs (`no_agent`) in the Tasks panel no longer show an empty Prompt card that looked like missing configuration.** Script jobs now display a 📜 script badge (next to the agent 🤖 badge), a "Script" banner, the script path + working directory, and "Script output" run-history labels distinct from agent jobs. (#3589, @pamnard)
## [v0.51.331] — 2026-06-08 — Release KU (dismissible error toasts)
### Fixed

View file

@ -1148,6 +1148,16 @@ const LOCALES = {
cron_collapse_prompt: 'Collapse prompt',
cron_expand_output: 'Expand output',
cron_collapse_output: 'Collapse output',
cron_mode_agent: 'Agent',
cron_mode_script: 'Script',
cron_mode_label: 'Mode',
cron_script_job_banner: 'Runs a script on schedule — stdout is delivered to the target. No agent, prompt, or skills.',
cron_script_card_title: 'Script',
cron_script_output: 'Script output',
cron_script_path_label: 'Script path',
cron_script_path_hint: 'Resolved under ~/.hermes/scripts/ unless an absolute path. Edit the script file on the server to change behavior.',
cron_script_badge_title: 'Script job (no agent)',
cron_workdir_label: 'Working directory',
cron_view_full_output: 'View full output',
cron_all_runs: 'All runs',
cron_hide_runs: 'Hide runs',
@ -2513,6 +2523,16 @@ const LOCALES = {
cron_collapse_prompt: 'Comprimi prompt',
cron_expand_output: 'Espandi output',
cron_collapse_output: 'Comprimi output',
cron_mode_agent: 'Agente',
cron_mode_script: 'Script',
cron_mode_label: 'Modalità',
cron_script_job_banner: 'Esegue uno script a intervalli — stdout viene consegnato alla destinazione. Nessun agente, prompt o skill.',
cron_script_card_title: 'Script',
cron_script_output: 'Output script',
cron_script_path_label: 'Percorso script',
cron_script_path_hint: 'Risolto sotto ~/.hermes/scripts/ salvo percorso assoluto. Modifica lo script sul server per cambiare il comportamento.',
cron_script_badge_title: 'Job script (senza agente)',
cron_workdir_label: 'Directory di lavoro',
cron_view_full_output: 'View full output',
cron_all_runs: 'Tutte le esecuzioni',
cron_hide_runs: 'Nascondi esecuzioni',
@ -3883,6 +3903,16 @@ const LOCALES = {
cron_collapse_prompt: 'プロンプトを折りたたむ',
cron_expand_output: '出力を展開',
cron_collapse_output: '出力を折りたたむ',
cron_mode_agent: 'エージェント',
cron_mode_script: 'スクリプト',
cron_mode_label: 'モード',
cron_script_job_banner: 'スケジュールでスクリプトを実行し、stdout を配信先へ送ります。エージェント・プロンプト・スキルは使いません。',
cron_script_card_title: 'スクリプト',
cron_script_output: 'スクリプト出力',
cron_script_path_label: 'スクリプトパス',
cron_script_path_hint: '絶対パスでない限り ~/.hermes/scripts/ から解決されます。動作を変えるにはサーバー上のスクリプトを編集してください。',
cron_script_badge_title: 'スクリプトジョブ(エージェントなし)',
cron_workdir_label: '作業ディレクトリ',
cron_view_full_output: '全出力を表示',
cron_all_runs: 'すべての実行',
cron_hide_runs: '実行履歴を隠す',
@ -4967,6 +4997,16 @@ const LOCALES = {
cron_collapse_prompt: 'Свернуть промпт',
cron_expand_output: 'Развернуть вывод',
cron_collapse_output: 'Свернуть вывод',
cron_mode_agent: 'Агент',
cron_mode_script: 'Скрипт',
cron_mode_label: 'Режим',
cron_script_job_banner: 'По расписанию запускается скрипт — stdout доставляется в канал. Агент, промпт и skills не используются.',
cron_script_card_title: 'Скрипт',
cron_script_output: 'Вывод скрипта',
cron_script_path_label: 'Путь к скрипту',
cron_script_path_hint: 'Ищется в ~/.hermes/scripts/, если не указан абсолютный путь. Логику меняй в файле скрипта на сервере.',
cron_script_badge_title: 'Скриптовая задача (без агента)',
cron_workdir_label: 'Рабочая директория',
cron_view_full_output: 'View full output',
cron_all_runs: 'Все запуски',
cron_hide_runs: 'Скрыть запуски',
@ -6274,6 +6314,16 @@ const LOCALES = {
cron_collapse_prompt: 'Collapse prompt',
cron_expand_output: 'Expand output',
cron_collapse_output: 'Collapse output',
cron_mode_agent: 'Agent',
cron_mode_script: 'Script',
cron_mode_label: 'Mode',
cron_script_job_banner: 'Runs a script on schedule — stdout is delivered to the target. No agent, prompt, or skills.',
cron_script_card_title: 'Script',
cron_script_output: 'Script output',
cron_script_path_label: 'Script path',
cron_script_path_hint: 'Resolved under ~/.hermes/scripts/ unless an absolute path. Edit the script file on the server to change behavior.',
cron_script_badge_title: 'Script job (no agent)',
cron_workdir_label: 'Working directory',
cron_view_full_output: 'View full output',
cron_all_runs: 'All runs',
cron_hide_runs: 'Hide runs',
@ -7784,6 +7834,16 @@ const LOCALES = {
cron_collapse_prompt: 'Prompt einklappen',
cron_expand_output: 'Ausgabe erweitern',
cron_collapse_output: 'Ausgabe einklappen',
cron_mode_agent: 'Agent',
cron_mode_script: 'Skript',
cron_mode_label: 'Modus',
cron_script_job_banner: 'Führt ein Skript nach Plan aus — stdout wird zugestellt. Kein Agent, Prompt oder Skills.',
cron_script_card_title: 'Skript',
cron_script_output: 'Skriptausgabe',
cron_script_path_label: 'Skriptpfad',
cron_script_path_hint: 'Wird unter ~/.hermes/scripts/ aufgelöst, sofern kein absoluter Pfad. Verhalten über die Skriptdatei auf dem Server ändern.',
cron_script_badge_title: 'Skript-Job (ohne Agent)',
cron_workdir_label: 'Arbeitsverzeichnis',
cron_view_full_output: 'View full output',
cron_all_runs: 'Alle Ausführungen',
cron_hide_runs: 'Ausführungen ausblenden',
@ -8890,6 +8950,16 @@ const LOCALES = {
cron_collapse_prompt: '收起提示词',
cron_expand_output: '展开输出',
cron_collapse_output: '收起输出',
cron_mode_agent: '代理',
cron_mode_script: '脚本',
cron_mode_label: '模式',
cron_script_job_banner: '按计划运行脚本,stdout 会投递到目标。不使用代理、提示词或技能。',
cron_script_card_title: '脚本',
cron_script_output: '脚本输出',
cron_script_path_label: '脚本路径',
cron_script_path_hint: '除非是绝对路径,否则在 ~/.hermes/scripts/ 下解析。请在服务器上编辑脚本文件以更改行为。',
cron_script_badge_title: '脚本任务(无代理)',
cron_workdir_label: '工作目录',
cron_view_full_output: 'View full output',
cron_all_runs: '全部运行记录',
cron_hide_runs: '隐藏记录',
@ -10460,6 +10530,16 @@ const LOCALES = {
cron_collapse_prompt: '收起提示詞',
cron_expand_output: '展開輸出',
cron_collapse_output: '收起輸出',
cron_mode_agent: '代理',
cron_mode_script: '腳本',
cron_mode_label: '模式',
cron_script_job_banner: '依排程執行腳本,stdout 會送到交付目標。不使用代理、提示詞或技能。',
cron_script_card_title: '腳本',
cron_script_output: '腳本輸出',
cron_script_path_label: '腳本路徑',
cron_script_path_hint: '除非為絕對路徑,否則會在 ~/.hermes/scripts/ 下解析。請在伺服器上編輯腳本檔以變更行為。',
cron_script_badge_title: '腳本任務(無代理)',
cron_workdir_label: '工作目錄',
cron_view_full_output: '檢視完整輸出',
cron_all_runs: '所有執行',
cron_hide_runs: '隱藏執行記錄',
@ -11647,6 +11727,16 @@ const LOCALES = {
cron_collapse_prompt: 'Contraer prompt',
cron_expand_output: 'Expandir saída',
cron_collapse_output: 'Contraer saída',
cron_mode_agent: 'Agente',
cron_mode_script: 'Script',
cron_mode_label: 'Modo',
cron_script_job_banner: 'Executa um script no agendamento — stdout é entregue ao destino. Sem agente, prompt ou skills.',
cron_script_card_title: 'Script',
cron_script_output: 'Saída do script',
cron_script_path_label: 'Caminho do script',
cron_script_path_hint: 'Resolvido em ~/.hermes/scripts/ salvo caminho absoluto. Edite o script no servidor para alterar o comportamento.',
cron_script_badge_title: 'Tarefa script (sem agente)',
cron_workdir_label: 'Diretório de trabalho',
cron_view_full_output: 'View full output',
cron_all_runs: 'Todas execuções',
cron_hide_runs: 'Esconder execuções',
@ -12912,6 +13002,16 @@ const LOCALES = {
cron_collapse_prompt: 'Collapse prompt',
cron_expand_output: 'Expand output',
cron_collapse_output: 'Collapse output',
cron_mode_agent: 'Agent',
cron_mode_script: 'Script',
cron_mode_label: 'Mode',
cron_script_job_banner: 'Runs a script on schedule — stdout is delivered to the target. No agent, prompt, or skills.',
cron_script_card_title: 'Script',
cron_script_output: 'Script output',
cron_script_path_label: 'Script path',
cron_script_path_hint: 'Resolved under ~/.hermes/scripts/ unless an absolute path. Edit the script file on the server to change behavior.',
cron_script_badge_title: 'Script job (no agent)',
cron_workdir_label: 'Working directory',
cron_view_full_output: 'View full output',
cron_all_runs: 'All runs',
cron_hide_runs: 'Hide runs',
@ -14218,6 +14318,16 @@ const LOCALES = {
cron_collapse_prompt: 'Réduire le prompt',
cron_expand_output: 'Développer la sortie',
cron_collapse_output: 'Réduire la sortie',
cron_mode_agent: 'Agent',
cron_mode_script: 'Script',
cron_mode_label: 'Mode',
cron_script_job_banner: 'Exécute un script selon l\'horaire — stdout est livré à la cible. Pas d\'agent, prompt ni skills.',
cron_script_card_title: 'Script',
cron_script_output: 'Sortie du script',
cron_script_path_label: 'Chemin du script',
cron_script_path_hint: 'Résolu sous ~/.hermes/scripts/ sauf chemin absolu. Modifiez le script sur le serveur pour changer le comportement.',
cron_script_badge_title: 'Tâche script (sans agent)',
cron_workdir_label: 'Répertoire de travail',
cron_view_full_output: 'View full output',
cron_all_runs: 'Toutes les courses',
cron_hide_runs: 'Masquer les courses',
@ -15577,6 +15687,16 @@ const LOCALES = {
cron_collapse_prompt: 'İstemi daralt',
cron_expand_output: 'Çıktıyı genişlet',
cron_collapse_output: 'Çıktıyı daralt',
cron_mode_agent: 'Ajan',
cron_mode_script: 'Betik',
cron_mode_label: 'Mod',
cron_script_job_banner: 'Zamanlanmış betiği çalıştırır — stdout hedefe iletilir. Ajan, istem veya beceri yok.',
cron_script_card_title: 'Betik',
cron_script_output: 'Betik çıktısı',
cron_script_path_label: 'Betik yolu',
cron_script_path_hint: 'Mutlak yol değilse ~/.hermes/scripts/ altında çözülür. Davranışı değiştirmek için sunucudaki betiği düzenleyin.',
cron_script_badge_title: 'Betik işi (ajan yok)',
cron_workdir_label: 'Çalışma dizini',
cron_view_full_output: 'View full output',
cron_all_runs: 'Tüm koşular',
cron_hide_runs: 'Çalıştırmaları gizle',
@ -17013,6 +17133,16 @@ const LOCALES = {
cron_collapse_prompt: 'Zwiń zapytanie',
cron_expand_output: 'Rozwiń wyjście',
cron_collapse_output: 'Zwiń wyjście',
cron_mode_agent: 'Agent',
cron_mode_script: 'Skrypt',
cron_mode_label: 'Tryb',
cron_script_job_banner: 'Uruchamia skrypt według harmonogramu — standardowe wyjście jest dostarczane do celu. Bez agenta, promptu ani umiejętności.',
cron_script_card_title: 'Skrypt',
cron_script_output: 'Wyjście skryptu',
cron_script_path_label: 'Ścieżka skryptu',
cron_script_path_hint: 'Rozwiązywana względem ~/.hermes/scripts/, chyba że podano ścieżkę bezwzględną. Edytuj plik skryptu na serwerze, aby zmienić działanie.',
cron_script_badge_title: 'Zadanie skryptowe (bez agenta)',
cron_workdir_label: 'Katalog roboczy',
cron_view_full_output: 'Wyświetl pełne wyjście',
cron_all_runs: 'Wszystkie uruchomienia',
cron_hide_runs: 'Ukryj uruchomienia',

View file

@ -516,7 +516,7 @@ async function loadCrons(animate) {
item.innerHTML = `
<div class="cron-header">
${isNewRun ? '<span class="cron-new-dot" title="New run"></span>' : ''}
${isAgentMode ? '<span class="cron-agent-badge" title="Agent mode">🤖</span>' : ''}
${isAgentMode ? '<span class="cron-agent-badge" title="Agent mode">🤖</span>' : `<span class="cron-script-badge" title="${esc(t('cron_script_badge_title') || 'Script job (no agent)')}">📜</span>`}
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
<span class="cron-profile-badge" title="${esc(profileTitle)}">${esc(profileLabel)}</span>
<span class="cron-status ${status.listClass}">${esc(status.label)}</span>
@ -579,6 +579,55 @@ function toggleCronRunExpanded(jobId, filename, runId){
}
}
function _isCronScriptJob(job){
return !!(job && job.no_agent);
}
function _cronModeLabel(job){
return _isCronScriptJob(job)
? (t('cron_mode_script') || 'Script')
: (t('cron_mode_agent') || 'Agent');
}
function _cronOutputTitle(job){
return _isCronScriptJob(job)
? (t('cron_script_output') || 'Script output')
: (t('cron_last_output') || 'Last output');
}
function _cronScriptJobBannerHtml(){
return `<div class="detail-alert cron-script-job-banner">
<div class="detail-alert-title">${esc(t('cron_mode_script') || 'Script')}</div>
<p>${esc(t('cron_script_job_banner') || 'Runs a script on schedule — stdout is delivered to the target. No agent, prompt, or skills.')}</p>
</div>`;
}
function _cronScriptCardHtml(job){
const script = String(job && job.script || '').trim() || '—';
const workdir = String(job && job.workdir || '').trim();
const workdirRow = workdir
? `<div class="detail-row"><div class="detail-row-label">${esc(t('cron_workdir_label') || 'Working directory')}</div><div class="detail-row-value"><code>${esc(workdir)}</code></div></div>`
: '';
return `<div class="detail-card cron-script-card">
<div class="detail-card-title">${esc(t('cron_script_card_title') || 'Script')}</div>
<div class="detail-script">${esc(script)}</div>
${workdirRow}
<div class="detail-hint cron-script-card-hint">${esc(t('cron_script_path_hint') || 'Resolved under ~/.hermes/scripts/ unless an absolute path. Edit the script file on the server to change behavior.')}</div>
</div>`;
}
function _cronAgentPromptCardHtml(job){
const promptExpanded = _cronExpansionGet(_cronPanelExpandKey(job.id, 'prompt'));
const promptToggleLabel = promptExpanded ? (t('cron_collapse_prompt') || 'Collapse prompt') : (t('cron_expand_prompt') || 'Expand prompt');
return `<div class="detail-card">
<div class="detail-card-title detail-card-title-row">
<span>${esc(t('cron_prompt_label') || 'Prompt')}</span>
<button type="button" class="detail-expand-toggle" onclick="toggleCronPromptExpanded('${esc(job.id)}')" title="${esc(promptToggleLabel)}" aria-label="${esc(promptToggleLabel)}">${esc(promptExpanded ? '▴' : '▾')}</button>
</div>
<div class="detail-prompt ${promptExpanded ? 'expanded' : ''}">${esc(job.prompt || '')}</div>
</div>`;
}
function _renderCronDetail(job){
_currentCronDetail = job;
const title = $('taskDetailTitle');
@ -592,14 +641,13 @@ function _renderCronDetail(job){
const schedule = job.schedule_display || (job.schedule && job.schedule.expression) || '';
const skills = Array.isArray(job.skills) && job.skills.length ? job.skills.join(', ') : '—';
const deliver = job.deliver || 'local';
const isNoAgent = !!job.no_agent;
const cronJobMode = isNoAgent ? 'no-agent' : 'agent';
const isNoAgent = _isCronScriptJob(job);
const cronJobMode = _cronModeLabel(job);
const modelProvider =
job.provider && job.model ? `${esc(job.provider)}/${esc(job.model)}` :
job.model ? esc(job.model) :
job.provider ? esc(job.provider) :
isNoAgent ? '' : 'default';
const script = job.script || '';
const profileLabel = _cronProfileLabel(job.profile);
const profileTitle = _cronProfileTitle(job.profile);
const lastError = job.last_error ? `<div class="detail-row"><div class="detail-row-label">${esc(t('error_prefix').replace(/:\s*$/,''))}</div><div class="detail-row-value" style="color:var(--accent-text)">${esc(job.last_error)}</div></div>` : '';
@ -619,11 +667,13 @@ function _renderCronDetail(job){
</div>
</div>` : '';
const toastNotifications = job.toast_notifications !== false;
const promptExpanded = _cronExpansionGet(_cronPanelExpandKey(job.id, 'prompt'));
const promptToggleLabel = promptExpanded ? (t('cron_collapse_prompt') || 'Collapse prompt') : (t('cron_expand_prompt') || 'Expand prompt');
const outputTitle = _cronOutputTitle(job);
const skillsRow = isNoAgent ? '' : `<div class="detail-row"><div class="detail-row-label">${esc(t('cron_skills_label') || 'Skills')}</div><div class="detail-row-value">${esc(skills)}</div></div>`;
const instructionCard = isNoAgent ? _cronScriptCardHtml(job) : _cronAgentPromptCardHtml(job);
body.innerHTML = `
<div class="main-view-content">
${attentionBanner}
${isNoAgent ? _cronScriptJobBannerHtml() : ''}
<div class="detail-card">
<div class="detail-card-title">${esc(t('cron_status_active').replace(/./,c=>c.toUpperCase()))}</div>
<div class="detail-row"><div class="detail-row-label">Status</div><div class="detail-row-value"><span class="detail-badge ${status.detailClass}">${esc(status.label)}</span></div></div>
@ -631,22 +681,15 @@ function _renderCronDetail(job){
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_next'))}</div><div class="detail-row-value">${esc(nextRun)}</div></div>
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_last'))}</div><div class="detail-row-value">${esc(lastRun)}</div></div>
<div class="detail-row"><div class="detail-row-label">Deliver</div><div class="detail-row-value">${esc(deliver)}</div></div>
<div class="detail-row"><div class="detail-row-label">Mode</div><div class="detail-row-value"><span class="detail-badge" id="cronJobMode">${esc(cronJobMode)}</span>${modelProvider ? ` <code>${modelProvider}</code>` : ''}</div></div>
${isNoAgent ? `<div class="detail-row"><div class="detail-row-label">No-agent script</div><div class="detail-row-value"><code>${esc(script || '—')}</code></div></div>` : ''}
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_mode_label') || 'Mode')}</div><div class="detail-row-value"><span class="detail-badge cron-mode-badge ${isNoAgent ? 'script' : 'agent'}" id="cronJobMode">${esc(cronJobMode)}</span>${modelProvider ? ` <code>${modelProvider}</code>` : ''}</div></div>
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_profile_label') || 'Profile')}</div><div class="detail-row-value"><span class="detail-badge active" title="${esc(profileTitle)}">${esc(profileLabel)}</span></div></div>
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_toast_notifications_label') || 'Completion toasts')}</div><div class="detail-row-value"><span class="detail-badge ${toastNotifications ? 'active' : ''}">${esc(toastNotifications ? (t('cron_toast_notifications_enabled') || 'Enabled') : (t('cron_toast_notifications_disabled') || 'Disabled'))}</span></div></div>
<div class="detail-row"><div class="detail-row-label">Skills</div><div class="detail-row-value">${esc(skills)}</div></div>
${skillsRow}
${lastError}
</div>
<div class="detail-card">
<div class="detail-card-title detail-card-title-row">
<span>Prompt</span>
<button type="button" class="detail-expand-toggle" onclick="toggleCronPromptExpanded('${esc(job.id)}')" title="${esc(promptToggleLabel)}" aria-label="${esc(promptToggleLabel)}">${esc(promptExpanded ? '▴' : '▾')}</button>
</div>
<div class="detail-prompt ${promptExpanded ? 'expanded' : ''}">${esc(job.prompt || '')}</div>
</div>
${instructionCard}
<div class="detail-card ${_cronNewJobIds.has(String(job.id)) ? 'has-new-run' : ''}" id="cronDetailRuns">
<div class="detail-card-title">${esc(t('cron_last_output'))}</div>
<div class="detail-card-title">${esc(outputTitle)}</div>
<div style="color:var(--muted);font-size:12px">${esc(t('loading'))}</div>
</div>
</div>`;
@ -693,8 +736,10 @@ async function _loadCronDetailRuns(jobId){
if (!_currentCronDetail || _currentCronDetail.id !== jobId) return;
const card = $('cronDetailRuns');
if (!card) return;
const outputTitle = _cronOutputTitle(_currentCronDetail);
const isScriptJob = _isCronScriptJob(_currentCronDetail);
if (!data.runs || !data.runs.length) {
card.innerHTML = `<div class="detail-card-title">${esc(t('cron_last_output'))}</div><div style="color:var(--muted);font-size:12px">${esc(t('cron_no_runs_yet'))}</div>`;
card.innerHTML = `<div class="detail-card-title">${esc(outputTitle)}</div><div style="color:var(--muted);font-size:12px">${esc(t('cron_no_runs_yet'))}</div>`;
return;
}
const rows = data.runs.map((run, i) => {
@ -702,7 +747,7 @@ async function _loadCronDetailRuns(jobId){
const sizeStr = run.size > 1024 ? (run.size/1024).toFixed(1)+' KB' : run.size+' B';
const dateStr = new Date(run.modified * 1000).toLocaleString();
const rid = `cron-det-run-${jobId}-${i}`;
const usageStrip = _formatCronRunUsageStrip(run.usage);
const usageStrip = isScriptJob ? '' : _formatCronRunUsageStrip(run.usage);
const runExpanded = _cronExpansionGet(_cronRunExpandKey(jobId, run.filename));
const runToggleLabel = runExpanded ? (t('cron_collapse_output') || 'Collapse output') : (t('cron_expand_output') || 'Expand output');
return `<div class="detail-run-item" id="${rid}">
@ -717,7 +762,7 @@ async function _loadCronDetailRuns(jobId){
</div>`;
}).join('');
const countLabel = data.total > 50 ? ` (${data.total} runs, showing latest 50)` : ` (${data.total} runs)`;
card.innerHTML = `<div class="detail-card-title">${esc(t('cron_last_output'))}${countLabel}</div>${rows}`;
card.innerHTML = `<div class="detail-card-title">${esc(outputTitle)}${countLabel}</div>${rows}`;
} catch(e) { /* ignore */ }
}
@ -925,8 +970,30 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
const isNoAgent = !!no_agent;
const toastNotifications = toast_notifications !== false;
title.textContent = isEdit ? (t('edit') + ' · ' + (name || schedule || t('scheduled_jobs'))) : t('new_job');
const promptBlock = isNoAgent ? '' : `
<div class="detail-form-row">
<label for="cronFormPrompt">${esc(t('cron_prompt_label') || 'Prompt')}</label>
<textarea id="cronFormPrompt" rows="6" placeholder="${esc(t('cron_prompt_placeholder') || 'Must be self-contained')}" required>${esc(prompt || '')}</textarea>
</div>`;
const scriptBlock = isNoAgent ? `
<div class="detail-form-row">
<label for="cronFormScript">${esc(t('cron_script_path_label') || 'Script path')}</label>
<input type="text" id="cronFormScript" value="${esc(script || '')}" readonly autocomplete="off">
<div class="detail-form-hint">${esc(t('cron_script_path_hint') || 'Resolved under ~/.hermes/scripts/ unless an absolute path. Edit the script file on the server to change behavior.')}</div>
</div>` : '';
const skillsBlock = isNoAgent ? '' : `
<div class="detail-form-row">
<label for="cronFormSkillSearch">${esc(t('cron_skills_label') || 'Skills')}</label>
<div class="skill-picker-wrap">
<input type="text" id="cronFormSkillSearch" placeholder="${esc(t('cron_skills_placeholder') || 'Add skills (optional)...')}" autocomplete="off" ${isEdit ? 'disabled' : ''}>
<div id="cronFormSkillDropdown" class="skill-picker-dropdown" style="display:none"></div>
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
</div>
${isEdit ? `<div class="detail-form-hint">${esc(t('cron_skills_edit_hint') || 'Skill list is not editable after creation.')}</div>` : ''}
</div>`;
body.innerHTML = `
<div class="main-view-content">
${isNoAgent ? _cronScriptJobBannerHtml() : ''}
<form class="detail-form" onsubmit="event.preventDefault(); saveCronForm();">
<div class="detail-form-row">
<label for="cronFormName">${esc(t('cron_name_label') || 'Name')}</label>
@ -938,11 +1005,8 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
<div class="detail-form-hint">${esc(t('cron_schedule_hint') || "Cron expression or shorthand like 'every 1h'.")}</div>
<div id="cronFormScheduleOnceWarning" class="detail-form-warning cron-once-warning" style="display:none">${esc(t('cron_schedule_once_warning') || "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.")}</div>
</div>
<div class="detail-form-row ${isNoAgent ? 'cron-no-agent-prompt-row' : ''}">
<label for="cronFormPrompt">${esc(t('cron_prompt_label') || 'Prompt')}</label>
<textarea id="cronFormPrompt" rows="6" placeholder="${esc(t('cron_prompt_placeholder') || 'Must be self-contained')}"${isNoAgent ? ' disabled' : ' required'}>${esc(prompt || '')}</textarea>
${isNoAgent ? `<div class="detail-form-hint cron-no-agent-hint">No-agent mode runs the configured script directly; Prompt is unused. No-agent script: <code>${esc(script || '—')}</code></div>` : ''}
</div>
${scriptBlock}
${promptBlock}
<div class="detail-form-row">
<label for="cronFormDeliver">${esc(t('cron_deliver_label') || 'Deliver output to')}</label>
<select id="cronFormDeliver">
@ -963,15 +1027,7 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
<span>${esc(t('cron_toast_notifications_hint') || 'Show a toast when this cron finishes.')}</span>
</label>
</div>
<div class="detail-form-row">
<label for="cronFormSkillSearch">${esc(t('cron_skills_label') || 'Skills')}</label>
<div class="skill-picker-wrap">
<input type="text" id="cronFormSkillSearch" placeholder="${esc(t('cron_skills_placeholder') || 'Add skills (optional)...')}" autocomplete="off" ${isEdit ? 'disabled' : ''}>
<div id="cronFormSkillDropdown" class="skill-picker-dropdown" style="display:none"></div>
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
</div>
${isEdit ? `<div class="detail-form-hint">${esc(t('cron_skills_edit_hint') || 'Skill list is not editable after creation.')}</div>` : ''}
</div>
${skillsBlock}
<div id="cronFormError" class="detail-form-error" style="display:none"></div>
</form>
</div>`;
@ -979,7 +1035,7 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
if (empty) empty.style.display = 'none';
_setCronHeaderButtons(isEdit ? 'edit' : 'create');
_populateCronDeliverOptions(deliver, isEdit);
_renderCronSkillTags();
if (!isNoAgent) _renderCronSkillTags();
const scheduleEl = $('cronFormSchedule');
if (scheduleEl) {
scheduleEl.addEventListener('input', _syncCronScheduleWarning);
@ -1088,14 +1144,15 @@ async function saveCronForm(){
const profileEl=$('cronFormProfile');
const toastEl=$('cronFormToastNotifications');
const errEl=$('cronFormError');
if(!schEl||!promptEl||!errEl) return;
if(!schEl||!errEl) return;
const isNoAgent = !!(_cronPreFormDetail && _cronPreFormDetail.no_agent);
if(!isNoAgent && !promptEl) return;
const name=(nameEl?nameEl.value:'').trim();
const schedule=schEl.value.trim();
const prompt=promptEl.value.trim();
const prompt=promptEl ? promptEl.value.trim() : '';
const deliver=delivEl?delivEl.value:'local';
const profile=profileEl?profileEl.value:'';
const toastNotifications=toastEl?!!toastEl.checked:true;
const isNoAgent = !!(_cronPreFormDetail && _cronPreFormDetail.no_agent);
errEl.style.display='none';
if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;}
if(!isNoAgent && !prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;}

View file

@ -4077,6 +4077,14 @@ main.main > #mainPlugin{display:none;}
.cron-badge{position:absolute;top:2px;right:2px;background:#e53e3e;color:#fff;font-size:9px;font-weight:700;min-width:14px;height:14px;line-height:14px;text-align:center;border-radius:7px;padding:0 3px;}
.cron-new-dot{width:7px;height:7px;border-radius:50%;background:var(--success,#22c55e);flex-shrink:0;animation:cron-dot-pulse 2s ease-in-out infinite;}
.cron-agent-badge{flex-shrink:0;font-size:12px;line-height:1;}
.cron-script-badge{flex-shrink:0;font-size:12px;line-height:1;opacity:.92;}
.cron-script-job-banner{margin-bottom:12px;}
.cron-script-job-banner .detail-alert-title{font-weight:600;}
.detail-script{background:var(--sidebar);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:12px;white-space:pre-wrap;line-height:1.55;color:var(--text);font-family:'SF Mono',ui-monospace,monospace;word-break:break-word;}
.detail-hint{font-size:11px;color:var(--muted);line-height:1.5;}
.cron-script-card .detail-hint,.cron-script-card-hint{margin-top:8px;}
.detail-badge.cron-mode-badge.script{background:rgba(148,163,184,.14);color:var(--text-secondary);}
.detail-badge.cron-mode-badge.agent{background:rgba(99,179,237,.14);color:#90cdf4;}
@keyframes cron-dot-pulse{0%,100%{opacity:1;}50%{opacity:.4;}}
.has-new-run{border-color:var(--success,#22c55e)!important;box-shadow:0 0 0 1px var(--success,#22c55e);}

View file

@ -2,7 +2,6 @@
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
@ -46,13 +45,16 @@ def test_open_cron_edit_plumbs_no_agent_and_script_to_form():
assert "script: job.script || ''" in body
def test_no_agent_form_drops_prompt_required_attribute_and_shows_script_context():
def test_no_agent_form_hides_prompt_and_shows_readonly_script_path():
body = _function_body("_renderCronForm")
assert "no_agent" in body and "script" in body
assert "const isNoAgent = !!no_agent;" in body
assert "cron-no-agent-hint" in body
assert "No-agent script" in body
assert "${isNoAgent ? ' disabled' : ' required'}" in body
assert "const promptBlock = isNoAgent ? '' :" in body
assert 'id="cronFormScript"' in body
assert "readonly autocomplete=\"off\"" in body
assert "cron_script_path_hint" in body
assert "const skillsBlock = isNoAgent ? '' :" in body
assert "if (!isNoAgent) _renderCronSkillTags();" in body
def test_save_cron_form_keeps_agent_prompt_required_but_skips_no_agent_edits():
@ -65,7 +67,8 @@ def test_save_cron_form_keeps_agent_prompt_required_but_skips_no_agent_edits():
def test_no_agent_detail_displays_mode_and_script():
body = _function_body("_renderCronDetail")
assert "const isNoAgent = !!job.no_agent;" in body
assert "No-agent script" in body
assert "const isNoAgent = _isCronScriptJob(job);" in body
assert "isNoAgent ? _cronScriptCardHtml(job) : _cronAgentPromptCardHtml(job)" in body
assert "isNoAgent ? _cronScriptJobBannerHtml() : ''" in body
assert "cronJobMode" in body
assert "job.script" in body
assert "cron-mode-badge ${isNoAgent ? 'script' : 'agent'}" in body

View file

@ -0,0 +1,72 @@
"""Static coverage for script (no_agent) cron jobs in Tasks UI."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
def test_cron_script_job_helpers_exist():
assert "function _isCronScriptJob(job)" in PANELS_JS
assert "function _cronModeLabel(job)" in PANELS_JS
assert "function _cronOutputTitle(job)" in PANELS_JS
assert "function _cronScriptJobBannerHtml()" in PANELS_JS
assert "function _cronScriptCardHtml(job)" in PANELS_JS
assert "function _cronAgentPromptCardHtml(job)" in PANELS_JS
def test_cron_detail_branches_on_no_agent():
assert "const isNoAgent = _isCronScriptJob(job)" in PANELS_JS
assert "isNoAgent ? _cronScriptCardHtml(job) : _cronAgentPromptCardHtml(job)" in PANELS_JS
assert "isNoAgent ? _cronScriptJobBannerHtml() : ''" in PANELS_JS
assert 'class="detail-script"' in PANELS_JS
assert 'class="detail-alert cron-script-job-banner"' in PANELS_JS
assert "cron-mode-badge ${isNoAgent ? 'script' : 'agent'}" in PANELS_JS
def test_cron_list_shows_script_badge():
assert "cron-script-badge" in PANELS_JS
assert "cron_script_badge_title" in PANELS_JS
def test_cron_form_hides_prompt_for_script_jobs():
assert "const isNoAgent = !!no_agent" in PANELS_JS
assert "const promptBlock = isNoAgent ? '' :" in PANELS_JS
assert "const scriptBlock = isNoAgent ?" in PANELS_JS
assert 'id="cronFormScript"' in PANELS_JS
assert "if(!isNoAgent && !prompt)" in PANELS_JS
def test_cron_runs_skip_usage_strip_for_script_jobs():
assert "const isScriptJob = _isCronScriptJob(_currentCronDetail)" in PANELS_JS
assert "const usageStrip = isScriptJob ? '' : _formatCronRunUsageStrip(run.usage)" in PANELS_JS
def test_cron_script_job_styles_exist():
assert ".cron-script-badge" in STYLE_CSS
assert ".cron-script-job-banner" in STYLE_CSS
assert ".detail-script{" in STYLE_CSS
assert ".detail-badge.cron-mode-badge.script" in STYLE_CSS
assert ".detail-badge.cron-mode-badge.agent" in STYLE_CSS
def test_cron_script_job_i18n_keys_exist_in_every_locale():
locale_count = I18N_JS.count("cron_last_output:")
assert locale_count >= 9
for key in (
"cron_mode_agent",
"cron_mode_script",
"cron_mode_label",
"cron_script_job_banner",
"cron_script_card_title",
"cron_script_output",
"cron_script_path_label",
"cron_script_path_hint",
"cron_script_badge_title",
"cron_workdir_label",
):
assert I18N_JS.count(f"{key}:") >= locale_count, key