Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7788,6 +7788,13 @@ def handle_get(handler, parsed) -> bool:
data["linked_files"] = {}
return j(handler, data)

# ── Scripts API (GET) ──
if parsed.path == "/api/scripts/list":
return _handle_scripts_list(handler)

if parsed.path == "/api/scripts/raw":
return _handle_scripts_raw(handler, parsed)

# ── Memory API (GET) ──
if parsed.path == "/api/memory":
return _handle_memory_read(handler, parsed)
Expand Down Expand Up @@ -13128,6 +13135,81 @@ def _read_active_project_context(workspace: Path | None) -> dict:
}
)
return payload
def _hermes_scripts_dir() -> Path:
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
hermes_home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser().resolve()
return hermes_home / "scripts"


def _parse_script_docstring(path: Path) -> str:
"""Extract leading docstring or comment block from a script file."""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
ext = path.suffix.lower()
if ext == ".py":
import ast
try:
tree = ast.parse(text)
doc = ast.get_docstring(tree)
return (doc or "").split("\n")[0].strip()
except (SyntaxError, ValueError):
pass
# .sh and fallback: collect leading # comment lines
lines = []
for line in text.splitlines():
Comment thread
rodboev marked this conversation as resolved.
stripped = line.strip()
if stripped.startswith("#") and not stripped.startswith("#!"):
lines.append(stripped.lstrip("#").strip())
elif stripped and not lines:
continue
else:
break
return " ".join(lines[:3]).strip()


def _handle_scripts_list(handler) -> None:
scripts_dir = _hermes_scripts_dir()
if not scripts_dir.exists():
return j(handler, {"scripts": []})
scripts = []
for p in sorted(scripts_dir.iterdir()):
if not p.is_file():
continue
if p.suffix.lower() not in (".py", ".sh", ".bash", ".zsh"):
continue
scripts.append({
"name": p.name,
"description": _parse_script_docstring(p),
})
return j(handler, {"scripts": scripts})


def _handle_scripts_raw(handler, parsed) -> None:
qs = parse_qs(parsed.query)
name = qs.get("path", [""])[0]
if not name:
return bad(handler, "path required", 400)
scripts_dir = _hermes_scripts_dir()
if not scripts_dir.exists():
return bad(handler, "scripts directory not found", 404)
try:
target = safe_resolve(scripts_dir, name)
except ValueError:
return bad(handler, "invalid path", 400)
if not target.exists() or not target.is_file():
return bad(handler, "script not found", 404)
if target.suffix.lower() not in (".py", ".sh", ".bash", ".zsh"):
return bad(handler, "unsupported file type", 400)
try:
source = target.read_text(encoding="utf-8", errors="replace")
except OSError as e:
return bad(handler, _sanitize_error(e), 500)
return j(handler, {"name": target.name, "source": source})


def _handle_memory_read(handler, parsed=None):
Expand Down
65 changes: 65 additions & 0 deletions static/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,11 @@ const LOCALES = {
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)',
tab_tasks_jobs: 'Jobs',
tab_tasks_scripts: 'Scripts',
scripts_no_scripts: 'No scripts found in ~/.hermes/scripts/.',
scripts_load_error: 'Failed to load script source.',
scripts_path_label: 'Path',
cron_workdir_label: 'Working directory',
cron_view_full_output: 'View full output',
cron_all_runs: 'All runs',
Expand Down Expand Up @@ -2732,6 +2737,11 @@ const LOCALES = {
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',
tab_tasks_jobs: 'Attività',
tab_tasks_scripts: 'Script',
scripts_no_scripts: 'Nessuno script trovato in ~/.hermes/scripts/.',
scripts_load_error: 'Impossibile caricare il source dello script.',
scripts_path_label: 'Percorso',
cron_view_full_output: 'View full output',
cron_all_runs: 'Tutte le esecuzioni',
cron_hide_runs: 'Nascondi esecuzioni',
Expand Down Expand Up @@ -4214,6 +4224,11 @@ const LOCALES = {
cron_script_path_hint: '絶対パスでない限り ~/.hermes/scripts/ から解決されます。動作を変えるにはサーバー上のスクリプトを編集してください。',
cron_script_badge_title: 'スクリプトジョブ(エージェントなし)',
cron_workdir_label: '作業ディレクトリ',
tab_tasks_jobs: 'ジョブ',
tab_tasks_scripts: 'スクリプト',
scripts_no_scripts: '~/.hermes/scripts/にスクリプトが見つかりません。',
scripts_load_error: 'スクリプトソースの読み込みに失敗しました。',
scripts_path_label: 'パス',
cron_view_full_output: '全出力を表示',
cron_all_runs: 'すべての実行',
cron_hide_runs: '実行履歴を隠す',
Expand Down Expand Up @@ -5396,6 +5411,11 @@ const LOCALES = {
cron_script_path_hint: 'Ищется в ~/.hermes/scripts/, если не указан абсолютный путь. Логику меняй в файле скрипта на сервере.',
cron_script_badge_title: 'Скриптовая задача (без агента)',
cron_workdir_label: 'Рабочая директория',
tab_tasks_jobs: 'Задачи',
tab_tasks_scripts: 'Скрипты',
scripts_no_scripts: 'В ~/.hermes/scripts/ не найдено скриптов.',
scripts_load_error: 'Ошибка загрузки источника скрипта.',
scripts_path_label: 'Путь',
cron_view_full_output: 'View full output',
cron_all_runs: 'Все запуски',
cron_hide_runs: 'Скрыть запуски',
Expand Down Expand Up @@ -6815,6 +6835,11 @@ const LOCALES = {
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',
tab_tasks_jobs: 'Trabajos',
tab_tasks_scripts: 'Scripts',
scripts_no_scripts: 'No se encontraron scripts en ~/.hermes/scripts/.',
scripts_load_error: 'Error al cargar el código fuente del script.',
scripts_path_label: 'Ruta',
cron_view_full_output: 'View full output',
cron_all_runs: 'All runs',
cron_hide_runs: 'Hide runs',
Expand Down Expand Up @@ -8447,6 +8472,11 @@ const LOCALES = {
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',
tab_tasks_jobs: 'Aufgaben',
tab_tasks_scripts: 'Skripte',
scripts_no_scripts: 'Keine Skripte in ~/.hermes/scripts/ gefunden.',
scripts_load_error: 'Fehler beim Laden der Skriptquelle.',
scripts_path_label: 'Pfad',
cron_view_full_output: 'View full output',
cron_all_runs: 'Alle Ausführungen',
cron_hide_runs: 'Ausführungen ausblenden',
Expand Down Expand Up @@ -9655,6 +9685,11 @@ const LOCALES = {
cron_script_path_hint: '除非是绝对路径,否则在 ~/.hermes/scripts/ 下解析。请在服务器上编辑脚本文件以更改行为。',
cron_script_badge_title: '脚本任务(无代理)',
cron_workdir_label: '工作目录',
tab_tasks_jobs: '任务',
tab_tasks_scripts: '脚本',
scripts_no_scripts: '在 ~/.hermes/scripts/ 中未找到脚本。',
scripts_load_error: '无法加载脚本源。',
scripts_path_label: '路径',
cron_view_full_output: 'View full output',
cron_all_runs: '全部运行记录',
cron_hide_runs: '隐藏记录',
Expand Down Expand Up @@ -11352,6 +11387,11 @@ const LOCALES = {
cron_script_path_label: '腳本路徑',
cron_script_path_hint: '除非為絕對路徑,否則會在 ~/.hermes/scripts/ 下解析。請在伺服器上編輯腳本檔以變更行為。',
cron_script_badge_title: '腳本任務(無代理)',
tab_tasks_jobs: '工作',
tab_tasks_scripts: '腳本',
scripts_no_scripts: '在 ~/.hermes/scripts/ 中找不到腳本。',
scripts_load_error: '無法載入腳本原始碼。',
scripts_path_label: '路徑',
cron_workdir_label: '工作目錄',
cron_view_full_output: '檢視完整輸出',
cron_all_runs: '所有執行',
Expand Down Expand Up @@ -12648,6 +12688,11 @@ const LOCALES = {
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',
tab_tasks_jobs: 'Tarefas',
tab_tasks_scripts: 'Scripts',
scripts_no_scripts: 'Nenhum script encontrado em ~/.hermes/scripts/.',
scripts_load_error: 'Falha ao carregar o código-fonte do script.',
scripts_path_label: 'Caminho',
cron_view_full_output: 'View full output',
cron_all_runs: 'Todas execuções',
cron_hide_runs: 'Esconder execuções',
Expand Down Expand Up @@ -14025,6 +14070,11 @@ const LOCALES = {
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',
tab_tasks_jobs: '작업',
tab_tasks_scripts: '스크립트',
scripts_no_scripts: '~/.hermes/scripts/에서 스크립트를 찾을 수 없습니다.',
scripts_load_error: '스크립트 소스를 로드하지 못했습니다.',
scripts_path_label: '경로',
cron_view_full_output: 'View full output',
cron_all_runs: 'All runs',
cron_hide_runs: 'Hide runs',
Expand Down Expand Up @@ -15447,6 +15497,11 @@ const LOCALES = {
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',
tab_tasks_jobs: 'Tâches',
tab_tasks_scripts: 'Scripts',
scripts_no_scripts: 'Aucun script trouvé dans ~/.hermes/scripts/.',
scripts_load_error: 'Erreur lors du chargement de la source du script.',
scripts_path_label: 'Chemin',
cron_view_full_output: 'View full output',
cron_all_runs: 'Toutes les courses',
cron_hide_runs: 'Masquer les courses',
Expand Down Expand Up @@ -16914,6 +16969,11 @@ const LOCALES = {
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',
tab_tasks_jobs: 'İşler',
tab_tasks_scripts: 'Betikler',
scripts_no_scripts: '~/.hermes/scripts/ içinde script bulunamadı.',
scripts_load_error: 'Betik kaynağı yüklenemedi.',
scripts_path_label: 'Yol',
cron_view_full_output: 'View full output',
cron_all_runs: 'Tüm koşular',
cron_hide_runs: 'Çalıştırmaları gizle',
Expand Down Expand Up @@ -18469,6 +18529,11 @@ const LOCALES = {
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',
tab_tasks_jobs: 'Zadania',
tab_tasks_scripts: 'Skrypty',
scripts_no_scripts: 'Nie znaleziono skryptów w ~/.hermes/scripts/.',
scripts_load_error: 'Nie udało się załadować źródła skryptu.',
scripts_path_label: 'Ścieżka',
cron_view_full_output: 'Wyświetl pełne wyjście',
cron_all_runs: 'Wszystkie uruchomienia',
cron_hide_runs: 'Ukryj uruchomienia',
Expand Down
31 changes: 27 additions & 4 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -207,14 +207,37 @@
<!-- Tasks (cron) panel -->
<div class="panel-view" id="panelTasks">
<div class="panel-head">
<span data-i18n="scheduled_jobs">Scheduled jobs</span>
<div class="panel-head-actions">
<div class="tasks-subtabs">
<button class="tasks-subtab active" id="tasksSubtabJobs"
data-i18n="tab_tasks_jobs" onclick="switchTasksSubtab('jobs')">Jobs</button>
<button class="tasks-subtab" id="tasksSubtabScripts"
data-i18n="tab_tasks_scripts" onclick="switchTasksSubtab('scripts')">Scripts</button>
</div>
<div class="panel-head-actions" id="tasksJobActions">
<button class="panel-head-btn has-tooltip has-tooltip--bottom" id="cronRefreshBtn" onclick="loadCrons(true)" data-tooltip="Refresh job list" aria-label="Refresh job list"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
<button class="panel-head-btn has-tooltip has-tooltip--bottom" onclick="openCronCreate()" data-tooltip="New job" data-i18n-title="new_job" aria-label="New job"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
</div>
<div class="panel-head-actions" id="tasksScriptActions" style="display:none">
<button class="panel-head-btn has-tooltip has-tooltip--bottom"
id="scriptsRefreshBtn" onclick="loadScripts(true)"
data-tooltip="Refresh scripts" aria-label="Refresh scripts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
</div>
</div>
<!-- Jobs subtab -->
<div id="tasksJobsPane">
<div id="cronGatewayNotice" class="detail-alert cron-gateway-notice" style="display:none"></div>
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Scripts subtab -->
<div id="tasksScriptsPane" style="display:none">
<div class="scripts-list" id="scriptsList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<div id="cronGatewayNotice" class="detail-alert cron-gateway-notice" style="display:none"></div>
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Kanban panel -->
<div class="panel-view" id="panelKanban">
Expand Down
80 changes: 79 additions & 1 deletion static/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,10 @@ async function switchPanel(name, opts = {}) {
});
}
// Lazy-load panel data
if (nextPanel === 'tasks') await loadCrons();
if (nextPanel === 'tasks') {
if (_tasksSubtab === 'scripts') switchTasksSubtab(_tasksSubtab);
else await loadCrons();
}
if (nextPanel === 'kanban') await loadKanban();
if (nextPanel === 'skills') await loadSkills();
if (nextPanel === 'memory') await loadMemory();
Expand Down Expand Up @@ -861,6 +864,81 @@ function _clearCronDetail(){
_setCronHeaderButtons('empty');
}

let _tasksSubtab = 'jobs';
function switchTasksSubtab(tab) {
_tasksSubtab = tab;
$('tasksJobsPane').style.display = tab === 'jobs' ? '' : 'none';
$('tasksScriptsPane').style.display = tab === 'scripts' ? '' : 'none';
$('tasksJobActions').style.display = tab === 'jobs' ? '' : 'none';
$('tasksScriptActions').style.display = tab === 'scripts' ? '' : 'none';
document.querySelectorAll('.tasks-subtab').forEach(b =>
b.classList.toggle('active', b.id === 'tasksSubtab' + tab.charAt(0).toUpperCase() + tab.slice(1))
);
if (tab === 'scripts') loadScripts();
}

let _scriptsData = null;
async function loadScripts(animate) {
const box = $('scriptsList');
const refreshBtn = $('scriptsRefreshBtn');
if (animate) {
_scriptsData = null;
if (refreshBtn) { refreshBtn.style.opacity = '0.5'; refreshBtn.disabled = true; }
}
if (_scriptsData) { _renderScriptsList(_scriptsData); return; }
try {
const data = await api('/api/scripts/list');
_scriptsData = data.scripts || [];
_renderScriptsList(_scriptsData);
} catch(e) {
box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`;
} finally {
if (animate && refreshBtn) { refreshBtn.style.opacity = ''; refreshBtn.disabled = false; }
}
}

function _renderScriptsList(scripts) {
const box = $('scriptsList');
if (!scripts.length) {
box.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('scripts_no_scripts'))}</div>`;
return;
}
box.innerHTML = '';
for (const s of scripts) {
const el = document.createElement('div');
el.className = 'script-item';
const ext = s.name.split('.').pop();
const stem = s.name.replace(/\.[^.]+$/, '');
const langClass = ext === 'py' ? 'language-python' : 'language-bash';
el.innerHTML = `
<div class="script-header" onclick="this.closest('.script-item').classList.toggle('expanded')">
<span class="script-name">${esc(stem)}</span>
<span class="script-ext">.${esc(ext)}</span>
</div>
Comment thread
rodboev marked this conversation as resolved.
${s.description ? `<div class="script-desc">${esc(s.description)}</div>` : ''}
<div class="script-source" style="display:none">
<pre><code class="${langClass}">${esc(s.source || '')}</code></pre>
</div>`;
// Toggle source visibility on expand
Comment thread
rodboev marked this conversation as resolved.
el.querySelector('.script-header').addEventListener('click', async () => {
const expanded = el.classList.contains('expanded');
const sourceEl = el.querySelector('.script-source');
if (expanded && !s._loaded) {
try {
const r = await api('/api/scripts/raw?path=' + encodeURIComponent(s.name));
sourceEl.querySelector('code').textContent = r.source || '';
s._loaded = true;
if (window.Prism) Prism.highlightElement(sourceEl.querySelector('code'));
} catch(e) {
sourceEl.querySelector('code').textContent = t('scripts_load_error') || 'Failed to load source.';
}
}
sourceEl.style.display = expanded ? '' : 'none';
});
box.appendChild(el);
}
}

async function runCurrentCron(){ if (_currentCronDetail) await cronRun(_currentCronDetail.id); }
async function pauseCurrentCron(){ if (_currentCronDetail) await cronPause(_currentCronDetail.id); }
async function resumeCurrentCron(){ if (_currentCronDetail) await cronResume(_currentCronDetail.id); }
Expand Down
Loading
Loading