diff --git a/AGENTS.md b/AGENTS.md index 5d49368812..87bf50ae94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Start with the task's files; read only relevant sections of these references, no | Shared wire types and validation | `shared/src/types.ts`, `schemas.ts`, `socket.ts`, `modes.ts` | | Native API contract, chat conformance | [client contract](docs/api/client-contract/index.md), [iOS](ios/README.md), [Android](android/README.md) | | Encrypted native push relay | [relay/README.md](relay/README.md) | +| Session-attached jobs (outliving work) | [session jobs guide](docs/guide/session-jobs.md), `cli/src/commands/job.ts` | | User docs / marketing site | `docs/` (VitePress) / `website/` | ## Repository conventions @@ -39,6 +40,20 @@ Start with the task's files; read only relevant sections of these references, no - Metadata/state updates are versioned; preserve stale-update rejection. Permission controls use per-flavor catalogs in `shared/src/modes.ts`, further constrained by session capabilities. - `shared/fixtures/**` is generated from the web chat pipeline, the source of truth for native conformance. Never hand-edit fixtures. For changes to fixture inputs or generation (paths in [.github/workflows/fixtures.yml](.github/workflows/fixtures.yml)), run `bun run gen:fixtures` and include any generated changes in the deliverable. CI checks drift and runs native conformance on fixture changes. +## Session-attached jobs (outliving work) + +When an agent starts process-shaped work that will keep running after the agent goes idle (`nohup`, batch imports, long scripts, external daemons), attach it so the session list stays truthful while `active: false`. This is **not** thinking progress / todos / in-agent background tools. + +Agent contract (idle agents cannot heartbeat — bare set + nohup freezes the bar): + +1. **Required for process-shaped work:** Shell `hapi job run --label … -- ` (auto-heartbeat + exit status). Use `"$HAPI_SESSION_ID"` only when it matches the operator chat row (`/sessions/` in the web URL). +2. MCP `session_job` **refuses `action=set`**. Use it only for `update` / `clear` / `list` on a job the supervisor already created. +3. Manual CLI `set` only with a self-heartbeating wrapper (`update` ≥~10m); never MCP set + nohup. +4. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown — never invent a percent. +5. Elapsed wall clock is always shown from `startedAt` (not an ETA). + +Full guide: [docs/guide/session-jobs.md](docs/guide/session-jobs.md). CLI: `hapi job --help`. + ## Verification and completion Choose checks by the change's impact, not by the number of workflow steps: diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/LanguagePrefs.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/LanguagePrefs.kt index 6a41f757b2..98aed0f519 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/LanguagePrefs.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/LanguagePrefs.kt @@ -12,9 +12,10 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map /** - * App language choice. [ENGLISH]/[SIMPLIFIED_CHINESE] mirror the web's - * `Locale` (`'en' | 'zh-Hans'`); [SYSTEM] is the Android-only default — - * follow the device language (empty per-app locale list). + * App language choice. [ENGLISH]/[SIMPLIFIED_CHINESE]/[RUSSIAN] mirror the + * web's `Locale` (`'en' | 'zh-Hans'` plus the fork's `'ru'`); [SYSTEM] is the + * Android-only default — follow the device language (empty per-app locale + * list). * * B-M5a wires the selection through `AppCompatDelegate.setApplicationLocales` * ([localeTags] is the BCP-47 tag list to apply); the Settings screen applies @@ -24,7 +25,8 @@ import kotlinx.coroutines.flow.map enum class AppLanguage(val storageKey: String, val localeTags: String) { SYSTEM("system", ""), ENGLISH("en", "en"), - SIMPLIFIED_CHINESE("zh-Hans", "zh-Hans"); + SIMPLIFIED_CHINESE("zh-Hans", "zh-Hans"), + RUSSIAN("ru", "ru"); companion object { fun fromStorageKey(raw: String?): AppLanguage = diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt index 9da3db6414..7a4e7a4d53 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt @@ -377,4 +377,5 @@ private fun languageLabel(language: AppLanguage): String = when (language) { AppLanguage.SYSTEM -> stringResource(R.string.settings_language_system) AppLanguage.ENGLISH -> "English" AppLanguage.SIMPLIFIED_CHINESE -> "简体中文" + AppLanguage.RUSSIAN -> "Русский" } diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000000..c21500a96e --- /dev/null +++ b/android/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,573 @@ + + + HAPI + + + Копировать + Скопировано + текст + Двоичный файл не показан + Показать ещё строк: %1$d + Свернуть + + + Сопряжение с хабом + HAPI размещается самостоятельно: ваши агенты кодинга работают на ваших машинах за хабом HAPI, которым управляете вы. Сопрягите это приложение с хабом, чтобы наблюдать за ними и управлять ими с телефона. + Отсканируйте QR-код «Companion app», который хаб печатает при запуске (с --relay), либо введите URL хаба и токен доступа вручную. + Сканировать QR-код + Ввести вручную + Закрыть + Закрыть + Проверка хаба… + Сопрячь с этим хабом? + Этот хаб уже сопряжён с этим устройством. + Сопрячь сейчас + Использовать существующее сопряжение + Пересопрячь с новым кодом + Отмена + Эта ссылка для сопряжения недействительна, неполна или не использует HTTPS. + Назад + + + Ручное сопряжение + Оба значения можно найти в терминале хаба при запуске или в веб-приложении в разделе Настройки → Сопряжение с приложением. + URL хаба + https://hub.example.com + Токен доступа + Вставьте токен доступа + Сопрячь + + + Сканировать QR для сопряжения + Наведите камеру на QR-код сопряжения + Этот QR-код не является действительным HTTPS-кодом сопряжения HAPI. Отсканируйте один из двух QR-кодов, которые печатает хаб (Companion app или Web app). + Сканировать снова + Для сканирования QR-кода нужен доступ к камере. Можно также сопрячь вручную. + Разрешить доступ к камере + + + Меню + Настройки + Хаб + Хабы и настройки + Переключить хаб + Сопрячь другой хаб… + Выйти + Удалить сопряжение с этим хабом? Сохранённые учётные данные будут удалены с устройства (сам хаб не затрагивается). + Отмена + + + Новая сессия + Назад + Новая сессия + Машина + Загрузка машин… + Нет машин онлайн + Последняя ошибка запуска раннера: %1$s + Каталог + /путь/к/проекту + Обзор каталогов + Выбрать каталог + Перейти в родительский каталог + Обновить + Показывать скрытые + Нет подкаталогов + Выбрать текущий каталог + Недавние пути + Тип сессии + Обычная + Использовать выбранный каталог как есть + Worktree + Создать новый git worktree рядом с репозиторием + Имя worktree + feature-x (необязательно) + Агент + Проверка установленных агентов… + Повторить + Модель + Усилие + Усилие рассуждений + Режим разрешений + Управляется агентом + Этот агент сам управляет разрешениями. Режим YOLO недоступен. + Режим YOLO + Обойти подтверждения и песочницу + Использует опасные флаги агента при запуске. + Применяет нативный режим %1$s. + Режим совместной работы + Режим агента + Быстрый режим + Стандартный + Быстрый + Загрузка… + Создать + Создание… + Создать и создать каталог + + + Настройки + Назад + Отмена + Повторить + Не удалось загрузить эту панель. + Эта панель доступна только владельцу хаба (пространство имён по умолчанию). + + Оформление + Тема + Как в системе + Светлая + Тёмная + Чёрная OLED + Цвета Material You + Брать цвета приложения из обоев. + Недоступно с чёрной OLED. + + Язык + Язык приложения + Как в системе + + Аналитика + Использование токенов по агентам и моделям + Размер базы данных хаба на диске + + О программе + Версия приложения + Версия протокола + Хаб + Проверка хаба… + Статус: %1$s · протокол v%2$d + Хаб недоступен — нажмите, чтобы повторить. + + + Использование + 7 дней + 30 дней + За всё время + Всего токенов + Без кеша + Вход + Выход + Чтение кеша + Создание кеша + Доля попаданий в кеш + Запросы + Токены по дням + Токены: %1$s · запросов: %2$d · вход %3$s / выход %4$s + 0 токенов + По агентам + По моделям + Запросы: %1$d · вход %2$s · выход %3$s + Сессий с использованием: %1$d + За этот период использование не зафиксировано. + + + Хранилище + Место на диске, занимаемое базой SQLite хаба (база, журнал упреждающей записи, общая память). + Файлы SQLite + Данные о хранилище не переданы. + База данных + Журнал упреждающей записи + Общая память + Итого + Путь + Обновить + Обновление… + + + Запросы разрешений + Агент ждёт, чтобы вы разрешили или отклонили вызов инструмента + Вопросы к вам + Агенту нужен ваш ответ на вопрос в сессии + Агент готов + Агент завершил работу и ждёт ввода + Уведомления о задачах + Задача выполнена или завершилась с ошибкой, а также другие обновления + Разрешить + Отклонить + Ответить + Закрыть + Сообщение + Разрешение… + Отклонение… + Отправка… + Разрешено + Отклонено + Ответ отправлен + Уже обработано + Сессия больше не активна — откройте приложение, чтобы возобновить её + Сессия не найдена ни на одном сопряжённом хабе + Не удалось связаться с хабом — откройте приложение, чтобы ответить + Ответ не отправлен — откройте приложение, чтобы повторить + + + Закреплённые + Сессии + Все + Неизвестная машина + Офлайн — показаны кешированные сессии + Загрузка сессий… + Хаб недоступен + Сессий пока нет + Получение списка сессий с хаба. + Потяните, чтобы повторить, когда вернётесь онлайн. + Запустите агента через CLI hapi, и он появится здесь. + нужен ввод + одобрить %1$s + ожидает + Открепить + Закрепить за проектом + Закрепить глобально + Переименовать + Открыть заново + Архивировать + Удалить + Не удалось закрепить + Не удалось архивировать + Не удалось переименовать + Не удалось удалить + Не удалось открыть заново + Не удалось загрузить машины + Сессия ещё активна — сначала архивируйте её + Не удалось открыть сессию заново + Переименовать сессию + Имя сессии + Удалить сессию? + «%1$s» и история её сообщений будут навсегда удалены с хаба. Активные сессии сначала нужно архивировать. + Отмена + сейчас + + + Назад + Файлы сессии + Заметки + Заметки (%1$d) + Настройки сессии + Действия с сессией + Заметки + Сессия неактивна — отправьте сообщение, чтобы возобновить, или + Повторить + Загрузка сообщений… + Загрузка старых сообщений… + Не удалось загрузить эту сессию + Проверьте подключение к хабу и попробуйте снова. + Сообщений пока нет + Сообщения появятся здесь по мере работы агента. + 1 новое сообщение ↓ + Новых сообщений: %1$d ↓ + + + На хабе не настроен провайдер транскрипции + Для диктовки нужен доступ к микрофону + Для фото нужен доступ к камере + %1$s превышает лимит загрузки 50 МБ + Не удалось прочитать %1$s + Черновик отложен в заметки + Заметки заполнены (200 записей) + Не удалось отложить черновик — проверьте подключение к хабу + Вложения ещё загружаются — подождите либо повторите или удалите неудачные + Сессия неактивна, и её не удалось возобновить + Не удалось прервать + Не удалось переименовать сессию + Не удалось удалить сессию + Не удалось отменить сообщение в очереди + Сообщение отменено — текущий черновик сохранён + Уже доставлено агенту + Не удалось направить сообщение + Запрос уже обработан + Запрос не выполнен + Не удалось загрузить модели + Не удалось обновить сессию + + + Сообщение агенту… + Загрузка… + Ошибка — нажмите, чтобы повторить + ● Запись… %1$s + Отмена + Отложить черновик в заметки + Добавить вложение + Начать диктовку + Остановить запись + Отправить + Остановить текущий ход + Отправить и вклиниться в текущий ход + 1 сообщение в очереди + Сообщений в очереди: %1$d + По расписанию · %1$s + Направить + Изменить + Прикрепить + Галерея + Камера + Файлы + + + Не удалось начать запись + Не удалось связаться с хабом + Ошибка записи звука + Звук не записан + Ошибка транскрипции + + + Сессия офлайн — изменения применятся после возобновления или могут быть отклонены. + Сессией управляют из терминала — изменения конфигурации будут отклонены. + Режим разрешений + Модель + Загрузка моделей… + Список моделей недоступен для этой сессии. + Усилие + + + Ожидает одобрения + ⏳ Ожидает одобрения + ✓ Одобрено + ✕ Отклонено + Решено в Codex + — Отменено + ожидает + ошибка + 1 шаг агента + Шаги агента: %1$d + 1 инструмент + Инструменты: %1$d + До + После + Правка %1$d/%2$d + (пусто) + Результат + Результат · ошибка + 💭 Рассуждения ▾ + 💭 Рассуждения ▸ + Не доставлено — нажмите, чтобы повторить + Не доставлено + Ревью кода + 1 замечание + Замечания: %1$d + + + Разрешить + Отклонить + Прервать + Разрешить для этой сессии + Разрешить все правки + Уже обработано в другом месте + Введите ответ… + Сначала введите ответ + Сначала ответьте на все вопросы + Сначала ответьте на все обязательные вопросы + Отправить + Другое… + %1$s (необязательно) + Добавить примечание… + Ничего из перечисленного + При желании добавьте подробности в примечании. + + + Открыть ссылку? + Открыть + Нет приложения, способного открыть эту ссылку + + + Терминал + Чтение файла + Чтение блокнота + Правка файла + Правка блокнота + Запись файла + Поиск файлов + Поиск по содержимому + Список файлов + Веб-запрос + Веб-поиск + Задача + Запуск агента + Агент: %1$s + Агент + Запустить агента + Возобновить агента + Ждать агента + Закрыть агента + Прервать агента + Широковещание + Завершение: %1$s + агент + Ответ на завершение + Сообщение: %1$s + Сообщение агенту + Список агентов + Команда: %1$s + Создать команду + Удалить команду + Список задач + План + Предложение плана + Реализовать план + Продолжить планирование + Навык + Навык: %1$s + Рассуждения + Запрос разрешения + Разрешение: %1$s + Применить изменения + Диф + Журнал %1$s + Просмотр журнала задачи + Фоновая задача + Ошибка + Запуск оболочки + Поиск + Открыть URL + Запрос + Вопрос + Вопросы: %1$d + %1$s (+ещё %2$d) + Строки: %1$d + Символы: %1$d + %1$s (правки: %2$d) + + + Назад + Файлы + Обновить + Изменения + Обзор + Поиск + Статус Git недоступен для этой сессии + Отсоединённый HEAD + %1$d в индексе · %2$d вне индекса + В рабочем дереве нет изменений + В индексе (%1$d) + Вне индекса (%1$d) + Корень проекта + Показывать скрытые файлы + Свернуть + Развернуть + Поиск файлов… + Начните вводить текст для поиска файлов в каталоге сессии + Файлы не найдены + Статус Git недоступен + Диф вне индекса недоступен: %1$s + Диф в индексе недоступен: %1$s + неизвестная ошибка + Не удалось получить список каталога + Не удалось выполнить поиск файлов + Не удалось загрузить диф + Не удалось прочитать файл + Скопировано + Копировать путь + Диф + Файл + Вне индекса + В индексе + Исходник + Предпросмотр + Строка %1$d + В этом файле нет изменений + Этот файл пуст + Двоичный файл — предпросмотр недоступен + Предпросмотр для этого формата изображения недоступен + + + Назад + Заметки + Нет заметок + 1 заметка + Заметки: %1$d + Новая заметка + Редактировать заметку + В поле ввода + Только вложение + Заметка, черновик, идея на потом… + Добавить фото + Удалить + Отмена + Сохранить + Повторить + Не удалось загрузить заметки + Проверьте подключение к хабу и попробуйте снова. + Заметок пока нет + Откладывайте черновики из поля ввода или нажмите +, чтобы записать заметку для этой сессии. + Заметки заполнены (200 записей) — сначала удалите одну + Заметки заполнены (200 записей) + У заметки должен быть текст или вложение — используйте «Удалить», чтобы убрать её + Не удалось сохранить заметку — проверьте подключение к хабу + Не удалось удалить заметку — проверьте подключение к хабу + Не удалось прикрепить файл — проверьте подключение к хабу + Не удалось убрать вложение — проверьте подключение к хабу + Этот файл превышает лимит размера файла на хабе + Ошибка загрузки — проверьте подключение к хабу + Не удалось прочитать выбранный файл + Изображение слишком большое даже после сжатия + Максимум вложений в заметке: %1$d + Этот тип файла не разрешён для вложений в заметки + Этот файл превышает лимит %1$d МБ + Бюджет вложений этой заметки исчерпан + + + Введите корректный HTTPS-URL хаба, например https://hub.example.com. + Введите токен доступа, который показывает ваш хаб. + Хаб отклонил этот токен доступа. Токены меняются при ротации CLI_API_TOKEN на хабе — возьмите свежий QR-код или токен и попробуйте снова. + Этот хаб больше не сопряжён. Сопрягите его заново ниже. + Не удалось связаться с %1$s. Проверьте адрес, сеть и то, что хаб запущен. + %1$s ответил, но это не похоже на хаб HAPI. Перепроверьте URL (это должен быть адрес самого хаба, а не веб-приложения). + Этот хаб использует протокол v%1$d, а приложение поддерживает v%2$d. Обновите приложение и попробуйте снова. + Этот хаб использует протокол v%1$d, а приложение поддерживает v%2$d. Обновите хаб и попробуйте снова. + Сопряжение не удалось (HTTP %1$d). Попробуйте снова через мгновение. + Сопряжение этого устройства отозвано — токен доступа хаба изменился. Сопрягите заново, чтобы продолжить. + Хаб продолжал отклонять сессию этого устройства. Сопрягите заново, чтобы продолжить. + Сохранённые учётные данные для этого хаба пропали. Сопрягите заново, чтобы продолжить. + + + Для сессий worktree нужен существующий каталог репозитория. + Каталог не существует. При создании сессии он будет создан автоматически. + Каталог не существует. Нажмите «Создать» ещё раз, чтобы создать его автоматически. + Не удалось создать сессию + Не удалось загрузить модели Codex + Не удалось загрузить модели: %1$s + Имя должно содержать хотя бы одну букву или цифру + Каталог должен находиться внутри одного из корней рабочего пространства этой машины. + Не удалось просмотреть каталоги + Не удалось проверить установленных агентов + Обновите и перезапустите раннер HAPI этой машины перед созданием сессий. + На этой машине не установлено ни одного поддерживаемого агента. + Выбранный агент недоступен на этой машине. + К последним ↓ + Не удалось загрузить историю. Нажмите, чтобы повторить. + Продолжить загрузку истории + Загрузить старые сообщения + Начало разговора + Копировать всё содержимое + Показано %1$d из %2$d частей + Загрузить больше + Вопросы и ответы + Ответы + Свой ответ + Примечание + Показать сообщение целиком + Сообщение целиком + Предыдущая часть + Следующая часть + Часть %1$d + Повторить экспорт + Экспортировать полный текст + Скопировано + Закрыть просмотр + Снимок только для чтения. Эта запись больше не в текущем окне сообщений. + Более ранние вызовы не загружены. Вернитесь к разговору, чтобы загрузить старую историю. + Детали инструмента + Это содержимое недоступно. Вернитесь к разговору, чтобы загрузить его снова. + Последний инструмент + Показать текущий файл + Показать полный ввод + Показать процесс · шагов: %1$d + Процесс агента + Показать изображение + Переподключение… Нажмите, чтобы повторить + Фильтры + Фильтры применены + Сбросить фильтр + Машина · %1$s + Фильтр: %1$s + Политика конфиденциальности + Нет приложения, способного открыть эту ссылку. + diff --git a/android/app/src/main/res/xml/locales_config.xml b/android/app/src/main/res/xml/locales_config.xml index 281d26a726..13466b1bb3 100644 --- a/android/app/src/main/res/xml/locales_config.xml +++ b/android/app/src/main/res/xml/locales_config.xml @@ -4,4 +4,5 @@ + diff --git a/cli/README.md b/cli/README.md index 11066a7ba1..1d20bf0df9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -44,6 +44,7 @@ Choose a supported coding agent from your terminal and control its sessions remo - `hapi resume [sessionId]` - List resumable sessions for this machine or resume one locally. - `hapi ping-peer ` - Resume (if needed) and message another session. Prefer this or MCP `ping_peer` / `list_peers` over reinventing JWT+curl. Also `--message-file` / `--list`. - `hapi inspect-peer ` - Read-only peer metadata + recent message text (no resume). Prefer this or MCP `inspect_peer` when a user cites `[title](/sessions/)` or Copy-reference `See session "…" (/sessions/) for context`. `/sessions/` is a hub path, not a local file. Optional `--limit`. +- `hapi job set|update|clear|list|run` - Attach long-running outliving work to a session so the list UI shows progress while the agent is idle (`tiann/hapi#1404`). Prefer `"$HAPI_SESSION_ID"`. Heartbeat via `update` (or `run`); honest `--remaining` or `--done`/`--total` (omit counts if unknown — never invent a percent). Late-attach clock fix: `set --started-at ` or clear+set. See `docs/guide/session-jobs.md` and `hapi job --help`. The picker lists agents alphabetically by command name. Use Up/Down and Enter to choose; Esc or Ctrl-C cancels. It appears on every bare invocation, even diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index ee388e6dc5..fdc99b6a35 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -5,6 +5,15 @@ import { AcpStdioTransport, type AcpStderrError } from './AcpStdioTransport'; import { AcpMessageHandler, type AcpTextChunkMode } from './AcpMessageHandler'; import { ACP_SESSION_UPDATE_TYPES } from './constants'; import { thinkingHintFromSessionUpdate } from './shouldBumpThinkingFromSessionUpdate'; + +/** Why the backend is bumping/clearing hub thinking — launchers filter chatter here (#1553). */ +export type AgentActivitySource = + | 'state_update_running' + | 'state_update_idle' + | 'state_update_requires_action' + | 'permission'; + +export type AgentActivityListener = (thinking: boolean, source: AgentActivitySource) => void; import { logger } from '@/ui/logger'; import { withRetry } from '@/utils/time'; import packageJson from '../../../../package.json'; @@ -93,7 +102,7 @@ export class AcpSdkBackend implements AgentBackend { private usageUpdateListener: ((msg: AgentMessage) => void) | null = null; private sessionInfoUpdateListener: ((update: AcpSessionInfoUpdate) => void) | null = null; /** Fired on foreground ACP state / permission so launchers can bump hub thinking (#1470). */ - private agentActivityListener: ((thinking: boolean) => void) | null = null; + private agentActivityListener: AgentActivityListener | null = null; /** Debounce timer for state_update running → thinking (#1502 chatter). */ private runningThinkingTimer: ReturnType | null = null; private lastForwardedUsageUpdate: AcpUsageUpdate | null = null; @@ -524,7 +533,7 @@ export class AcpSdkBackend implements AgentBackend { * `false` = `state_update` idle (skipped while a HAPI prompt turn is still draining). * Launchers should ignore no-ops when session.thinking already matches. */ - setAgentActivityListener(listener: ((thinking: boolean) => void) | null): void { + setAgentActivityListener(listener: AgentActivityListener | null): void { this.agentActivityListener = listener; } @@ -988,7 +997,7 @@ export class AcpSdkBackend implements AgentBackend { if (this.isProcessingMessage) { return; } - this.agentActivityListener(false); + this.agentActivityListener(false, 'state_update_idle'); return; } @@ -999,13 +1008,17 @@ export class AcpSdkBackend implements AgentBackend { } this.runningThinkingTimer = setTimeout(() => { this.runningThinkingTimer = null; - this.agentActivityListener?.(true); + this.agentActivityListener?.(true, 'state_update_running'); }, AcpSdkBackend.RUNNING_THINKING_DEBOUNCE_MS); return; } this.clearRunningThinkingTimer(); - this.agentActivityListener(true); + const source: AgentActivitySource = update.sessionUpdate === 'state_update' + && update.state === 'requires_action' + ? 'state_update_requires_action' + : 'state_update_running'; + this.agentActivityListener(true, source); } private clearRunningThinkingTimer(): void { @@ -1191,7 +1204,7 @@ export class AcpSdkBackend implements AgentBackend { if (this.permissionHandler) { try { // Permission prompts imply the agent is awake (#1470). - this.agentActivityListener?.(true); + this.agentActivityListener?.(true, 'permission'); this.permissionHandler(request); } catch (error) { this.pendingPermissions.delete(toolCallId); diff --git a/cli/src/agent/runners/runAgentSession.test.ts b/cli/src/agent/runners/runAgentSession.test.ts index fe1886578a..2eb74df6b7 100644 --- a/cli/src/agent/runners/runAgentSession.test.ts +++ b/cli/src/agent/runners/runAgentSession.test.ts @@ -74,7 +74,7 @@ vi.mock('@/claude/utils/startHappyServer', () => ({ harness.startHappyServerOptions = options return { url: 'http://127.0.0.1:1234', - toolNames: ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'], + toolNames: ['change_title', 'display_image', 'list_peers', 'ping_peer', 'inspect_peer', 'session_job', 'skill_lookup'], stop: harness.stopServer } }) @@ -167,7 +167,7 @@ describe('runAgentSession', () => { '--url', 'http://127.0.0.1:1234', '--tools', - 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,skill_lookup' + 'change_title,display_image,list_peers,ping_peer,inspect_peer,session_job,skill_lookup' ]) expect(harness.newSessionOptions).toMatchObject({ cwd: '/tmp/project', diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index 7f0aed5505..d46f221943 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -135,6 +135,41 @@ describe('bootstrapExistingSession', () => { ) }) + it('exports the requested existing session id for Cursor runner resume (#1404)', async () => { + // Runner passes --existing-session-id (buildCliArgs); child bootstrap must + // publish that same hub row into HAPI_SESSION_ID for shell `hapi job run`. + const session = createSession() + session.metadata = { + ...session.metadata!, + flavor: 'cursor', + cursorSessionId: 'cursor-csid-1', + cursorSessionProtocol: 'acp' + } + const sessionClient = { updateMetadata: vi.fn() } + getSessionMock.mockResolvedValue(session) + getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' }) + sessionSyncClientMock.mockReturnValue(sessionClient) + readSettingsMock.mockResolvedValue({ machineId: 'machine-1' }) + + await bootstrapExistingSession({ + sessionId: 'hapi-session-1', + flavor: 'cursor', + startedBy: 'runner', + workingDirectory: '/tmp/project' + }) + + expect(getSessionMock).toHaveBeenCalledWith('hapi-session-1') + expect(process.env[HAPI_SESSION_ID_ENV]).toBe('hapi-session-1') + expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith( + 'hapi-session-1', + expect.objectContaining({ + flavor: 'cursor', + startedBy: 'runner', + startedFromRunner: true + }) + ) + }) + it('refuses to reopen a hub-archived session (hub-archive resurrection guard)', async () => { // #1911 M1 belt: hub-archived only (matches store merge-preserve scope). const session = createSession() diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index 8abb273f25..9865c11d9b 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -44,6 +44,7 @@ describe('startHappyServer skill_lookup', () => { async function connect(enableSkillLookup = true): Promise { sendAgentMessage = vi.fn() const sessionClient = { + sessionId: 'test-session-id', updateMetadata: vi.fn(), sendAgentMessage, sendClaudeSessionMessage: vi.fn() @@ -107,15 +108,16 @@ describe('startHappyServer skill_lookup', () => { const mcp = await connect(false) const tools = await mcp.listTools() - expect(tools.tools.map((tool) => tool.name)).toEqual([ + expect(tools.tools.map((tool) => tool.name).sort()).toEqual([ 'change_title', 'display_image', 'display_video', 'display_media', - 'ping_peer', 'inspect_peer', - 'list_peers' - ]) + 'list_peers', + 'ping_peer', + 'session_job', + ].sort()) }) it('describes display_image as user output rather than image input', async () => { @@ -170,6 +172,7 @@ describe('startHappyServer skill_lookup', () => { it('does not expose change_title when native ACP titles are enabled', async () => { const sessionClient = { + sessionId: 'test-session-id', updateMetadata: vi.fn(), sendAgentMessage: vi.fn(), sendClaudeSessionMessage: vi.fn() @@ -182,15 +185,24 @@ describe('startHappyServer skill_lookup', () => { await mcp.connect(new StreamableHTTPClientTransport(new URL(server.url))) const tools = await mcp.listTools() - expect(server.toolNames).toEqual(['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']) - expect(tools.tools.map((tool) => tool.name)).toEqual([ + expect(server.toolNames).toEqual([ 'display_image', 'display_video', 'display_media', + 'list_peers', 'ping_peer', 'inspect_peer', - 'list_peers' + 'session_job', ]) + expect(tools.tools.map((tool) => tool.name).sort()).toEqual([ + 'display_image', + 'display_media', + 'display_video', + 'inspect_peer', + 'list_peers', + 'ping_peer', + 'session_job', + ].sort()) }) }) @@ -317,11 +329,13 @@ describe('toClaudeAllowedHapiMcpTools', () => { 'list_peers', 'ping_peer', 'inspect_peer', + 'session_job', 'skill_lookup' ])).toEqual([ 'mcp__hapi__change_title', 'mcp__hapi__display_image', 'mcp__hapi__list_peers', + 'mcp__hapi__session_job', 'mcp__hapi__skill_lookup' ]) expect(toClaudeAllowedHapiMcpTools(['display_video'])).not.toContain('mcp__hapi__display_video') diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 10a2a02d66..e2d4e1c70d 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -28,6 +28,13 @@ import { } from '@hapi/protocol/sessionCitation' import { PingPeerError, formatInspectPeerReport, formatPeerSessionsList, inspectPeer, listPeerSessions, peerListFetchLimit, pingPeer } from "@/modules/pingPeer/pingPeer"; import { applySessionDisplayRename, normalizeSessionDisplayTitle } from "@/agent/sessionDisplayRename"; +import { + SESSION_JOB_TOOL_DESCRIPTION, + SESSION_JOB_TOOL_NAME, + handleSessionJobTool, + sessionJobInputSchema, + type SessionJobToolArgs, +} from "@/modules/sessionJob/sessionJobMcp"; type StartHappyServerOptions = { /** @@ -376,6 +383,19 @@ function createHapiMcpServer( } }); + mcp.registerTool(SESSION_JOB_TOOL_NAME, { + description: SESSION_JOB_TOOL_DESCRIPTION, + title: 'Session-Attached Job', + inputSchema: sessionJobInputSchema, + }, async (args: SessionJobToolArgs) => { + logger.debug('[hapiMCP] session_job:', args.action, args.jobKey); + const result = await handleSessionJobTool(args, client.sessionId); + return { + content: [{ type: 'text' as const, text: result.text }], + isError: result.isError, + }; + }); + mcp.registerTool('list_peers', { description: 'List peer HAPI sessions on the same hub/namespace (id prefix, active, flavor, name). Uses this session\'s hub credentials - works from runner-spawned agents without being on the hub host. Prefer this over shelling `hapi ping-peer --list`. Then call inspect_peer / ping_peer with a listed id.', title: 'List Peer Sessions', @@ -541,8 +561,8 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH })); const toolNames = enableChangeTitle - ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'] - : ['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; + ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', SESSION_JOB_TOOL_NAME] + : ['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', SESSION_JOB_TOOL_NAME]; if (options.skillLookup) { toolNames.push('skill_lookup'); } diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 3174ac6edd..03cfd1c7bc 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -2,6 +2,7 @@ import { trimIdent } from "@/utils/trimIdent"; import { buildSessionCitationSteerInstruction } from "@hapi/protocol/sessionCitation"; import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_MEDIA_PROMPT_CLAUDE, DISPLAY_VIDEO_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; +import { withSessionJobInstruction } from "@/modules/common/sessionJobInstruction"; import { withSessionSummaryInstruction } from "@/modules/common/sessionSummaryInstruction"; /** @@ -42,5 +43,5 @@ export function getSystemPrompt(): string { const base = includeCoAuthored ? BASE_SYSTEM_PROMPT + '\n\n' + CO_AUTHORED_CREDITS : BASE_SYSTEM_PROMPT; - return withSessionSummaryInstruction(base); + return withSessionSummaryInstruction(withSessionJobInstruction(base)); } diff --git a/cli/src/codex/happyMcpStdioBridge.test.ts b/cli/src/codex/happyMcpStdioBridge.test.ts index c940d0bc50..5ae2d27064 100644 --- a/cli/src/codex/happyMcpStdioBridge.test.ts +++ b/cli/src/codex/happyMcpStdioBridge.test.ts @@ -70,16 +70,20 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { '--url', 'http://127.0.0.1:43006', '--tools', - 'change_title,display_image,display_video,display_media,skill_lookup' + 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,session_job,skill_lookup' ]) - expect([...harness.tools.keys()]).toEqual([ + expect([...harness.tools.keys()].sort()).toEqual([ 'change_title', 'display_image', - 'display_video', 'display_media', - 'skill_lookup' - ]) + 'display_video', + 'inspect_peer', + 'list_peers', + 'ping_peer', + 'session_job', + 'skill_lookup', + ].sort()) const handler = harness.tools.get('skill_lookup') expect(handler).toBeDefined() @@ -140,6 +144,24 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { ]) }) + it('registers session_job when included in --tools', async () => { + await runHappyMcpStdioBridge([ + '--url', + 'http://127.0.0.1:43006', + '--tools', + 'change_title,display_image,list_peers,ping_peer,inspect_peer,session_job' + ]) + + expect([...harness.tools.keys()].sort()).toEqual([ + 'change_title', + 'display_image', + 'inspect_peer', + 'list_peers', + 'ping_peer', + 'session_job', + ].sort()) + }) + it('registers inspect_peer when included in --tools', async () => { await runHappyMcpStdioBridge([ '--url', diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index be0d03d0bb..177549a43b 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -22,8 +22,22 @@ import { PING_PEER_TOOL_DESCRIPTION, SESSION_ID_PREFIX_PARAM_DESCRIPTION, } from '@hapi/protocol/sessionCitation'; +import { + SESSION_JOB_TOOL_DESCRIPTION, + SESSION_JOB_TOOL_NAME, + sessionJobInputSchema, +} from '@/modules/sessionJob/sessionJobMcp'; -const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; +const DEFAULT_TOOL_NAMES = [ + 'change_title', + 'display_image', + 'display_video', + 'display_media', + 'list_peers', + 'ping_peer', + 'inspect_peer', + SESSION_JOB_TOOL_NAME, +]; function parseArgs(argv: string[]): { url: string | null; toolNames: Set } { let url: string | null = null; @@ -289,6 +303,34 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { ); } + if (toolNames.has(SESSION_JOB_TOOL_NAME)) { + server.registerTool( + SESSION_JOB_TOOL_NAME, + { + description: SESSION_JOB_TOOL_DESCRIPTION, + title: 'Session-Attached Job', + inputSchema: sessionJobInputSchema, + }, + async (args: Record) => { + try { + const client = await ensureHttpClient(); + const response = await client.callTool({ name: SESSION_JOB_TOOL_NAME, arguments: args }); + return response as any; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to run session_job: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + } + ); + } + const skillLookupInputSchema: z.ZodTypeAny = z.object({ name: z.string().trim().min(1).max(128).describe('Exact skill name shown by HAPI skill autocomplete'), }); diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index d45e677f92..dc3dd28702 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -7,7 +7,7 @@ import { codexCollaborationSpawnAgentInstructions, supportsReasoningSummary } from './appServerConfig'; -import { codexSystemPrompt } from './systemPrompt'; +import { getCodexSystemPrompt } from './systemPrompt'; describe('appServerConfig', () => { const mcpServers = { hapi: { command: 'node', args: ['mcp'] } }; @@ -23,7 +23,7 @@ describe('appServerConfig', () => { }); expect(params).not.toHaveProperty('baseInstructions'); - expect(params.developerInstructions).toBe(codexSystemPrompt); + expect(params.developerInstructions).toBe(getCodexSystemPrompt()); }); it('keeps an explicit base instruction override separate from HAPI developer instructions', () => { @@ -35,7 +35,7 @@ describe('appServerConfig', () => { }); expect(params.baseInstructions).toBe('Custom base instructions.'); - expect(params.developerInstructions).toBe(codexSystemPrompt); + expect(params.developerInstructions).toBe(getCodexSystemPrompt()); }); it('applies CLI overrides when permission mode is default', () => { @@ -50,13 +50,13 @@ describe('appServerConfig', () => { expect(params.sandbox).toBe('danger-full-access'); expect(params.approvalPolicy).toBe('never'); expect(params.baseInstructions).toBeUndefined(); - expect(params.developerInstructions).toBe(codexSystemPrompt); + expect(params.developerInstructions).toBe(getCodexSystemPrompt()); expect(params.config).toEqual({ 'mcp_servers.hapi': { command: 'node', args: ['mcp'] }, - developer_instructions: codexSystemPrompt + developer_instructions: getCodexSystemPrompt() }); }); @@ -98,7 +98,7 @@ describe('appServerConfig', () => { } } }, - developer_instructions: codexSystemPrompt + developer_instructions: getCodexSystemPrompt() }); }); @@ -195,13 +195,13 @@ describe('appServerConfig', () => { }); expect(params.baseInstructions).toBeUndefined(); - expect(params.developerInstructions).toBe(`${codexSystemPrompt}\n\nOnly respond in Chinese.`); + expect(params.developerInstructions).toBe(`${getCodexSystemPrompt()}\n\nOnly respond in Chinese.`); expect(params.config).toEqual({ 'mcp_servers.hapi': { command: 'node', args: ['mcp'] }, - developer_instructions: `${codexSystemPrompt}\n\nOnly respond in Chinese.` + developer_instructions: `${getCodexSystemPrompt()}\n\nOnly respond in Chinese.` }); }); @@ -217,7 +217,7 @@ describe('appServerConfig', () => { command: 'node', args: ['mcp'] }, - developer_instructions: codexSystemPrompt, + developer_instructions: getCodexSystemPrompt(), model_reasoning_effort: 'ultra' }); }); @@ -383,7 +383,7 @@ describe('appServerConfig', () => { settings: { model: 'o3', reasoning_effort: 'high', - developer_instructions: withCollaborationInstructions(codexSystemPrompt) + developer_instructions: withCollaborationInstructions(getCodexSystemPrompt()) } }); expect(params.model).toBeUndefined(); @@ -409,7 +409,7 @@ describe('appServerConfig', () => { settings: { model: 'gpt-5.3-codex-spark', reasoning_effort: 'high', - developer_instructions: withCollaborationInstructions(codexSystemPrompt) + developer_instructions: withCollaborationInstructions(getCodexSystemPrompt()) } }); }); @@ -573,7 +573,7 @@ describe('appServerConfig', () => { mode: 'default', settings: { model: 'o3', - developer_instructions: withCollaborationInstructions(codexSystemPrompt) + developer_instructions: withCollaborationInstructions(getCodexSystemPrompt()) } }); }); @@ -593,7 +593,7 @@ describe('appServerConfig', () => { mode: 'default', settings: { model: 'o3', - developer_instructions: withCollaborationInstructions(codexSystemPrompt) + developer_instructions: withCollaborationInstructions(getCodexSystemPrompt()) } }); }); @@ -612,7 +612,7 @@ describe('appServerConfig', () => { mode: 'default', settings: { model: 'gpt-5', - developer_instructions: withCollaborationInstructions(codexSystemPrompt) + developer_instructions: withCollaborationInstructions(getCodexSystemPrompt()) } }); expect(params.model).toBeUndefined(); diff --git a/cli/src/codex/utils/buildHapiMcpBridge.test.ts b/cli/src/codex/utils/buildHapiMcpBridge.test.ts index 8af0d57768..8a2ba68c05 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.test.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.test.ts @@ -14,8 +14,8 @@ vi.mock('@/claude/utils/startHappyServer', () => ({ return { url: 'http://127.0.0.1:43006/', toolNames: options.skillLookup - ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'] - : ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'], + ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'session_job', 'skill_lookup'] + : ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'session_job'], stop: vi.fn() } }) @@ -71,14 +71,15 @@ describe('buildHapiMcpBridge skill lookup config', () => { '--url', 'http://127.0.0.1:43006/', '--tools', - 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,skill_lookup' + 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,session_job,skill_lookup' ]) expect(bridge.mcpServers.hapi.tools).toEqual({ - change_title: { approval_mode: 'approve' }, display_image: { approval_mode: 'prompt' }, display_video: { approval_mode: 'prompt' }, display_media: { approval_mode: 'prompt' }, + change_title: { approval_mode: 'approve' }, list_peers: { approval_mode: 'approve' }, + session_job: { approval_mode: 'approve' }, skill_lookup: { approval_mode: 'approve' } }) }) @@ -86,13 +87,16 @@ describe('buildHapiMcpBridge skill lookup config', () => { it('does not expose skill_lookup for native-skill bridge callers', async () => { const bridge = await buildHapiMcpBridge(createClient()) - expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer') + expect(harness.cliArgs.at(-1)).toBe( + 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,session_job' + ) expect(bridge.mcpServers.hapi.tools).toEqual({ - change_title: { approval_mode: 'approve' }, display_image: { approval_mode: 'prompt' }, display_video: { approval_mode: 'prompt' }, display_media: { approval_mode: 'prompt' }, - list_peers: { approval_mode: 'approve' } + change_title: { approval_mode: 'approve' }, + list_peers: { approval_mode: 'approve' }, + session_job: { approval_mode: 'approve' } }) }) diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 0d6ae4856f..453d354dc1 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -111,6 +111,10 @@ export async function buildHapiMcpBridge( tools.list_peers = { approval_mode: 'approve' }; + // Own-session progress meter (tiann/hapi#1404) — hub REST, not peer inject. + tools.session_job = { + approval_mode: 'approve' + }; // ping_peer / inspect_peer are registered on the HTTP MCP server / stdio // bridge, but are not auto-approved: they target another session (resume + // inject, or read peer histories). diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index bd85efc36c..7ad815856e 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -8,6 +8,7 @@ import { trimIdent } from '@/utils/trimIdent'; import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCitation'; import { DISPLAY_IMAGE_PROMPT_CODEX, DISPLAY_MEDIA_PROMPT_CODEX, DISPLAY_VIDEO_PROMPT_CODEX } from '@/modules/common/displayImagePrompt'; +import { withSessionJobInstruction } from '@/modules/common/sessionJobInstruction'; import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction'; /** @@ -36,7 +37,7 @@ export const TITLE_INSTRUCTION = trimIdent(` * Session-summary contract is resolved at call time (hub toggle / env). */ export function getCodexSystemPrompt(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(TITLE_INSTRUCTION, env) + return withSessionSummaryInstruction(withSessionJobInstruction(TITLE_INSTRUCTION), env) } /** Alias kept for existing call sites / tests that expect a string constant name. */ diff --git a/cli/src/commands/job.test.ts b/cli/src/commands/job.test.ts new file mode 100644 index 0000000000..08864c521e --- /dev/null +++ b/cli/src/commands/job.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest' +import { formatJobLine, parseJobArgs } from '@/commands/job' +import { + SessionJobError, + exitCodeForSessionJobError, + resolveSessionByPrefix, + resolveSessionIdForJobCli +} from '@/modules/sessionJob/sessionJob' + +describe('parseJobArgs', () => { + it('parses set with long flags', () => { + const parsed = parseJobArgs([ + 'set', + 'abcd1234', + 'beets', + '--label', + 'beets import', + '--remaining', + '12', + '--unit=tracks' + ]) + expect(parsed.action).toBe('set') + expect(parsed.sessionIdPrefix).toBe('abcd1234') + expect(parsed.jobKey).toBe('beets') + expect(parsed.label).toBe('beets import') + expect(parsed.remaining).toBe(12) + expect(parsed.unit).toBe('tracks') + }) + + it('parses run with command after --', () => { + const parsed = parseJobArgs([ + 'run', + 'sid', + 'drain', + '--label=rsync', + '--heartbeat-sec=60', + '--', + 'bash', + '-c', + 'echo hi' + ]) + expect(parsed.action).toBe('run') + expect(parsed.label).toBe('rsync') + expect(parsed.heartbeatSec).toBe(60) + expect(parsed.command).toEqual(['bash', '-c', 'echo hi']) + }) + + it('rejects bad status', () => { + expect(() => parseJobArgs(['update', 's', 'k', '--status', 'nope'])).toThrow(SessionJobError) + }) + + it('parses --started-at for set', () => { + const parsed = parseJobArgs([ + 'set', + 'sid', + 'beets', + '--label=beets', + '--started-at=1785304595000' + ]) + expect(parsed.startedAt).toBe(1_785_304_595_000) + }) + + it('rejects --started-at on update', () => { + expect(() => parseJobArgs([ + 'update', + 'sid', + 'beets', + '--started-at=1785304595000' + ])).toThrow(/--started-at is only valid with job set/) + }) + + it('rejects --heartbeat-sec on non-run actions', () => { + expect(() => parseJobArgs([ + 'set', + 'sid', + 'beets', + '--label=beets', + '--heartbeat-sec=300' + ])).toThrow(/--heartbeat-sec is only valid with job run/) + expect(() => parseJobArgs([ + 'update', + 'sid', + 'beets', + '--heartbeat-sec=60' + ])).toThrow(/--heartbeat-sec is only valid with job run/) + }) + + it.each([ + ['list', ['list', 'sid', '--label=x'], /job list does not accept mutation flags/], + ['clear+status', ['clear', 'sid', 'beets', '--status', 'completed'], /job clear only accepts --expected-run-id/], + ['run+status', ['run', 'sid', 'beets', '--label=x', '--status', 'completed', '--', 'true'], /--status is not valid with job run/], + ['clear+cmd', ['clear', 'sid', 'beets', '--', 'rm', '-rf', '/'], /-- is only valid with job run/] + ] as const)('rejects unsupported flags for %s', (_name, argv, pattern) => { + expect(() => parseJobArgs([...argv])).toThrow(pattern) + }) + + it('parses --clear-remaining as null for update patches', () => { + const parsed = parseJobArgs([ + 'update', + 'sid', + 'beets', + '--clear-remaining', + '--done', + '3', + '--total', + '10' + ]) + expect(parsed.remaining).toBeNull() + expect(parsed.done).toBe(3) + expect(parsed.total).toBe(10) + }) + + it('parses --run-id on set and --expected-run-id on update/clear', () => { + const setParsed = parseJobArgs([ + 'set', + 'sid', + 'beets', + '--label=beets', + '--run-id=11111111-1111-1111-1111-111111111111' + ]) + expect(setParsed.runId).toBe('11111111-1111-1111-1111-111111111111') + + const updateParsed = parseJobArgs([ + 'update', + 'sid', + 'beets', + '--remaining=9', + '--expected-run-id=11111111-1111-1111-1111-111111111111' + ]) + expect(updateParsed.expectedRunId).toBe('11111111-1111-1111-1111-111111111111') + + const clearParsed = parseJobArgs([ + 'clear', + 'sid', + 'beets', + '--expected-run-id=11111111-1111-1111-1111-111111111111' + ]) + expect(clearParsed.expectedRunId).toBe('11111111-1111-1111-1111-111111111111') + }) + + it('rejects --run-id on update and --expected-run-id on set', () => { + expect(() => parseJobArgs([ + 'update', + 'sid', + 'beets', + '--run-id=11111111-1111-1111-1111-111111111111' + ])).toThrow(/--run-id is only valid with job set/) + expect(() => parseJobArgs([ + 'set', + 'sid', + 'beets', + '--label=beets', + '--expected-run-id=11111111-1111-1111-1111-111111111111' + ])).toThrow(/--expected-run-id is only valid with job update or clear/) + }) +}) + +describe('resolveSessionByPrefix', () => { + const sessions = [ + { id: 'aaaaaaaa-1111-1111-1111-111111111111' }, + { id: 'bbbbbbbb-2222-2222-2222-222222222222' }, + { id: 'bbbbcccc-3333-3333-3333-333333333333' } + ] + + it('matches exact id', () => { + expect(resolveSessionByPrefix(sessions, sessions[0]!.id).id).toBe(sessions[0]!.id) + }) + + it('matches unique prefix', () => { + expect(resolveSessionByPrefix(sessions, 'aaaa').id).toBe(sessions[0]!.id) + }) + + it('errors on ambiguous prefix', () => { + expect(() => resolveSessionByPrefix(sessions, 'bbbb')).toThrow(/matches 2 sessions/) + }) + + it('errors on no match', () => { + expect(() => resolveSessionByPrefix(sessions, 'zzzz')).toThrow(/no session matching/) + }) +}) + +describe('resolveSessionIdForJobCli', () => { + it('passes a full UUID through when missing from the session list', () => { + const deleted = 'cccccccc-4444-4444-4444-444444444444' + expect(resolveSessionIdForJobCli([], deleted)).toBe(deleted) + }) + + it('still errors for a non-uuid prefix with no list match', () => { + expect(() => resolveSessionIdForJobCli([], 'deadbeef')).toThrow(/no session matching/) + }) +}) + +describe('exitCodeForSessionJobError', () => { + it('maps codes', () => { + expect(exitCodeForSessionJobError(new SessionJobError('bad_args', 'x'))).toBe(2) + expect(exitCodeForSessionJobError(new SessionJobError('auth_failed', 'x'))).toBe(3) + expect(exitCodeForSessionJobError(new SessionJobError('not_found', 'x'))).toBe(4) + expect(exitCodeForSessionJobError(new SessionJobError('ambiguous', 'x'))).toBe(5) + expect(exitCodeForSessionJobError(new SessionJobError('request_failed', 'x'))).toBe(1) + }) +}) + +describe('formatJobLine terminal sanitization', () => { + it('strips ANSI/OSC and C0 controls from job text fields', () => { + const line = formatJobLine({ + key: 'beets\u0007', + label: '\u001b[31mimport\u001b[0m', + status: 'running', + unit: 'tracks\u001b]8;;http://evil\u0007', + detail: 'phase\u001b]52;c;QUFB\u0007done\u009devil\u009c', + runId: 'run-\u001b[1mid\u001b[0m', + remaining: 3, + heartbeatAt: Date.now() - 5_000, + startedAt: Date.now() - 60_000 + }, { includeTiming: true }) + expect(line).not.toMatch(/\u001b/) + expect(line).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/) + expect(line).toContain('beets') + expect(line).toContain('import') + expect(line).toContain('3 tracks left') + expect(line).toContain('phase') + expect(line).toContain('done') + expect(line).toContain('runId run-id') + expect(line).not.toMatch(/http:\/\/evil/) + expect(line).not.toMatch(/QUFB/) + }) +}) diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts new file mode 100644 index 0000000000..a0c55c56d3 --- /dev/null +++ b/cli/src/commands/job.ts @@ -0,0 +1,509 @@ +import chalk from 'chalk' +import { initializeToken } from '@/ui/tokenInit' +import type { AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' +import { + SessionJobError, + SESSION_JOB_REMAINING_ZERO_HINT, + clearSessionJob, + exitCodeForSessionJobError, + formatSessionJobNotFoundHint, + listSessionJobs, + setSessionJob, + updateSessionJob +} from '@/modules/sessionJob/sessionJob' +import { formatJobLine } from '@/modules/sessionJob/formatJobLine' +import { runSessionJob } from '@/modules/sessionJob/runSessionJob' +import type { CommandDefinition } from './types' + +export { formatJobLine, terminalText } from '@/modules/sessionJob/formatJobLine' + +export type ParsedJobArgs = { + help: boolean + action?: 'set' | 'update' | 'clear' | 'list' | 'run' + sessionIdPrefix?: string + jobKey?: string + label?: string + status?: 'running' | 'completed' | 'failed' + done?: number | null + total?: number | null + remaining?: number | null + unit?: string | null + detail?: string | null + startedAt?: number + heartbeatSec?: number + runId?: string + expectedRunId?: string + command?: string[] +} + +function showHelp(): void { + console.log(` +${chalk.bold('hapi job')} - Attach long-running work to a HAPI session (tiann/hapi#1404) + +${chalk.bold('When to use:')} + Work that outlives the agent (batch / long scripts / external daemons) + while the session may be idle. Not thinking progress or in-agent background tools. + +${chalk.bold('Agent contract:')} + Prefer ${chalk.bold('hapi job run')} — it heartbeats for you and marks completed/failed on exit. + An idle agent cannot heartbeat; set-once jobs go amber after ~15m. + Prefer honest --remaining or --done/--total; omit counts if unknown. + Never invent a fake percent. + +${chalk.bold('Usage:')} + hapi job run --label [--heartbeat-sec 300] [progress flags] -- [args...] + hapi job set --label [--started-at MS] [--run-id UUID] [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] + hapi job update [--expected-run-id UUID] [--remaining N] [--done N] [--total N] [--status running|completed|failed] [--detail ...] + [--clear-remaining|--clear-done|--clear-total|--clear-unit|--clear-detail] + hapi job clear [--expected-run-id UUID] + hapi job list + +${chalk.bold('Progress UI:')} + remaining → "N units left · 2h" + done + total → "P% · done/total · 2h" + label/detail only → "running · 2h" + indeterminate bar + elapsed always from startedAt (wall clock) — never an ETA / time-remaining field + --clear-remaining → drop leftover meter so done/total can take over (PATCH null) + +${chalk.bold('startedAt / elapsed:')} + Prefer ${chalk.bold('update')} for heartbeats/progress so the clock is never wiped. + PATCH rejects startedAt. PUT/set without --started-at keeps the existing clock. + Late attach or wrong clock: ${chalk.bold('set --started-at ')} (explicit PUT), + or clear then set with --started-at (works on older hubs that ignored PUT corrections). + Manual babysitter wrappers: mint one UUID per run (${chalk.bold('set --run-id ')}), + then pass the same value on every heartbeat (${chalk.bold('update --expected-run-id ')}) + and on clear (${chalk.bold('clear --expected-run-id ')}) so a key-reuse cannot + steal the older wrapper's PATCHes or DELETE the newer run. + +${chalk.bold('Notes:')} + Hub-persisted. Prefer "$HAPI_SESSION_ID" when it matches the operator chat row + (/sessions/ in the web URL). Remote Cursor runner may export a worker-row id — + pass that URL uuid explicitly (see docs/guide/session-jobs.md). + Needs a hub/CLI that includes the job subcommand (soup / feat build — not every npm release). + Job key: 1-128 chars, alnum / . _ - + Session lookup prefers exact id; prefix scan is the 500 most-recently-updated sessions. + Docs: docs/guide/session-jobs.md + +${chalk.bold('Env:')} + HAPI_API_URL / CLI_API_TOKEN (or ~/.hapi/settings.json via \`hapi auth login\`) +`) +} + +function parseOptionalNumber(flag: string, value: string | undefined): number { + if (value === undefined) { + throw new SessionJobError('bad_args', `${flag} requires a number`) + } + const n = Number(value) + if (!Number.isFinite(n)) { + throw new SessionJobError('bad_args', `${flag} must be a number`) + } + return n +} + +export function parseJobArgs(args: string[]): ParsedJobArgs { + const result: ParsedJobArgs = { help: false } + const dashDash = args.indexOf('--') + const flagArgs = dashDash >= 0 ? args.slice(0, dashDash) : args + if (dashDash >= 0) { + result.command = args.slice(dashDash + 1) + } + + for (let i = 0; i < flagArgs.length; i++) { + const arg = flagArgs[i]! + if (arg === '--help' || arg === '-h') { + result.help = true + continue + } + if (arg === '--label') { + result.label = flagArgs[++i] + if (!result.label) throw new SessionJobError('bad_args', '--label requires a value') + continue + } + if (arg.startsWith('--label=')) { + result.label = arg.slice('--label='.length) + continue + } + if (arg === '--status') { + const value = flagArgs[++i] + if (value !== 'running' && value !== 'completed' && value !== 'failed') { + throw new SessionJobError('bad_args', '--status must be running|completed|failed') + } + result.status = value + continue + } + if (arg.startsWith('--status=')) { + const value = arg.slice('--status='.length) + if (value !== 'running' && value !== 'completed' && value !== 'failed') { + throw new SessionJobError('bad_args', '--status must be running|completed|failed') + } + result.status = value + continue + } + if (arg === '--done') { + result.done = parseOptionalNumber('--done', flagArgs[++i]) + continue + } + if (arg.startsWith('--done=')) { + result.done = parseOptionalNumber('--done', arg.slice('--done='.length)) + continue + } + if (arg === '--total') { + result.total = parseOptionalNumber('--total', flagArgs[++i]) + continue + } + if (arg.startsWith('--total=')) { + result.total = parseOptionalNumber('--total', arg.slice('--total='.length)) + continue + } + if (arg === '--remaining') { + result.remaining = parseOptionalNumber('--remaining', flagArgs[++i]) + continue + } + if (arg.startsWith('--remaining=')) { + result.remaining = parseOptionalNumber('--remaining', arg.slice('--remaining='.length)) + continue + } + if (arg === '--unit') { + result.unit = flagArgs[++i] + if (!result.unit) throw new SessionJobError('bad_args', '--unit requires a value') + continue + } + if (arg.startsWith('--unit=')) { + result.unit = arg.slice('--unit='.length) + continue + } + if (arg === '--detail') { + result.detail = flagArgs[++i] + if (result.detail === undefined) throw new SessionJobError('bad_args', '--detail requires a value') + continue + } + if (arg.startsWith('--detail=')) { + result.detail = arg.slice('--detail='.length) + continue + } + if (arg === '--heartbeat-sec') { + result.heartbeatSec = parseOptionalNumber('--heartbeat-sec', flagArgs[++i]) + continue + } + if (arg.startsWith('--heartbeat-sec=')) { + result.heartbeatSec = parseOptionalNumber('--heartbeat-sec', arg.slice('--heartbeat-sec='.length)) + continue + } + if (arg === '--started-at') { + result.startedAt = parseOptionalNumber('--started-at', flagArgs[++i]) + continue + } + if (arg.startsWith('--started-at=')) { + result.startedAt = parseOptionalNumber('--started-at', arg.slice('--started-at='.length)) + continue + } + if (arg === '--clear-remaining') { + result.remaining = null + continue + } + if (arg === '--clear-done') { + result.done = null + continue + } + if (arg === '--clear-total') { + result.total = null + continue + } + if (arg === '--clear-unit') { + result.unit = null + continue + } + if (arg === '--clear-detail') { + result.detail = null + continue + } + if (arg === '--run-id') { + result.runId = flagArgs[++i] + if (!result.runId) throw new SessionJobError('bad_args', '--run-id requires a value') + continue + } + if (arg.startsWith('--run-id=')) { + result.runId = arg.slice('--run-id='.length) + if (!result.runId) throw new SessionJobError('bad_args', '--run-id requires a value') + continue + } + if (arg === '--expected-run-id') { + result.expectedRunId = flagArgs[++i] + if (!result.expectedRunId) { + throw new SessionJobError('bad_args', '--expected-run-id requires a value') + } + continue + } + if (arg.startsWith('--expected-run-id=')) { + result.expectedRunId = arg.slice('--expected-run-id='.length) + if (!result.expectedRunId) { + throw new SessionJobError('bad_args', '--expected-run-id requires a value') + } + continue + } + if (arg.startsWith('-')) { + throw new SessionJobError('bad_args', `unexpected flag: ${arg}`) + } + if (!result.action) { + if ( + arg !== 'set' + && arg !== 'update' + && arg !== 'clear' + && arg !== 'list' + && arg !== 'run' + ) { + throw new SessionJobError('bad_args', `unknown action '${arg}' (set|update|clear|list|run)`) + } + result.action = arg + continue + } + if (!result.sessionIdPrefix) { + result.sessionIdPrefix = arg + continue + } + if (!result.jobKey && result.action !== 'list') { + result.jobKey = arg + continue + } + throw new SessionJobError('bad_args', `unexpected arg: ${arg}`) + } + + if (result.startedAt !== undefined && result.action !== undefined && result.action !== 'set') { + throw new SessionJobError('bad_args', '--started-at is only valid with job set') + } + + if ( + result.heartbeatSec !== undefined + && result.action !== undefined + && result.action !== 'run' + ) { + throw new SessionJobError('bad_args', '--heartbeat-sec is only valid with job run') + } + + const clearUsed = + result.done === null + || result.total === null + || result.remaining === null + || result.unit === null + || result.detail === null + if (clearUsed && result.action !== undefined && result.action !== 'update') { + throw new SessionJobError('bad_args', '--clear-* flags are only valid with job update') + } + if (result.runId !== undefined && result.action !== undefined && result.action !== 'set') { + throw new SessionJobError('bad_args', '--run-id is only valid with job set') + } + if ( + result.expectedRunId !== undefined + && result.action !== undefined + && result.action !== 'update' + && result.action !== 'clear' + ) { + throw new SessionJobError('bad_args', '--expected-run-id is only valid with job update or clear') + } + + const mutationFieldsUsed = [ + result.label, + result.status, + result.done, + result.total, + result.remaining, + result.unit, + result.detail + ].some((value) => value !== undefined) + + if (result.action === 'list' && mutationFieldsUsed) { + throw new SessionJobError('bad_args', 'job list does not accept mutation flags') + } + if (result.action === 'clear' && mutationFieldsUsed) { + throw new SessionJobError('bad_args', 'job clear only accepts --expected-run-id') + } + if (result.action === 'run' && result.status !== undefined) { + throw new SessionJobError('bad_args', '--status is not valid with job run') + } + + if ( + result.command !== undefined + && result.action !== undefined + && result.action !== 'run' + ) { + throw new SessionJobError('bad_args', '-- is only valid with job run') + } + + return result +} + +function formatCliJobLine(job: { + key: string + label: string + status: string + done?: number + total?: number + remaining?: number + unit?: string + detail?: string + runId?: string + heartbeatAt: number + startedAt: number +}): string { + return formatJobLine(job, { includeTiming: true }) +} + +export async function handleJobCommand(args: string[]): Promise { + const parsed = parseJobArgs(args) + if (parsed.help || !parsed.action) { + showHelp() + if (!parsed.action && !parsed.help) { + throw new SessionJobError('bad_args', 'missing action; usage: hapi job set|update|clear|list|run ...') + } + return + } + + await initializeToken() + + if (!parsed.sessionIdPrefix) { + showHelp() + throw new SessionJobError('bad_args', 'missing session id') + } + + if (parsed.action === 'list') { + const result = await listSessionJobs({ sessionIdPrefix: parsed.sessionIdPrefix }) + console.log(`session ${result.sessionId}`) + if (result.jobs.length === 0) { + console.log('(no jobs)') + return + } + for (const job of result.jobs) { + const mark = result.primary?.key === job.key ? '*' : ' ' + console.log(`${mark} ${formatCliJobLine(job)}`) + } + return + } + + if (!parsed.jobKey) { + throw new SessionJobError('bad_args', 'missing job key') + } + + if (parsed.startedAt !== undefined && parsed.action !== 'set') { + throw new SessionJobError('bad_args', '--started-at is only valid with job set') + } + + if (parsed.action === 'clear') { + const result = await clearSessionJob({ + sessionIdPrefix: parsed.sessionIdPrefix, + jobKey: parsed.jobKey, + ...(parsed.expectedRunId !== undefined + ? { expectedRunId: parsed.expectedRunId } + : {}) + }) + console.log(`cleared ${parsed.jobKey} on ${result.sessionId}`) + return + } + + if (parsed.action === 'set') { + if (!parsed.label) { + throw new SessionJobError('bad_args', 'set requires --label') + } + if (parsed.startedAt !== undefined && !Number.isFinite(parsed.startedAt)) { + throw new SessionJobError('bad_args', '--started-at must be epoch milliseconds') + } + const body: AttachedJobUpsert = { + label: parsed.label, + status: parsed.status ?? 'running', + ...(typeof parsed.done === 'number' ? { done: parsed.done } : {}), + ...(typeof parsed.total === 'number' ? { total: parsed.total } : {}), + ...(typeof parsed.remaining === 'number' ? { remaining: parsed.remaining } : {}), + ...(typeof parsed.unit === 'string' ? { unit: parsed.unit } : {}), + ...(typeof parsed.detail === 'string' ? { detail: parsed.detail } : {}), + ...(parsed.startedAt !== undefined ? { startedAt: parsed.startedAt } : {}), + ...(parsed.runId !== undefined ? { runId: parsed.runId } : {}) + } + const result = await setSessionJob({ + sessionIdPrefix: parsed.sessionIdPrefix, + jobKey: parsed.jobKey, + body + }) + console.log(`set ${formatCliJobLine(result.job)}`) + return + } + + if (parsed.action === 'run') { + if (!parsed.label) { + throw new SessionJobError('bad_args', 'run requires --label') + } + if (!parsed.command || parsed.command.length === 0) { + throw new SessionJobError('bad_args', 'run requires a command after --') + } + const exitCode = await runSessionJob({ + sessionIdPrefix: parsed.sessionIdPrefix, + jobKey: parsed.jobKey, + label: parsed.label, + command: parsed.command, + ...(parsed.heartbeatSec !== undefined + ? { heartbeatMs: Math.max(5, parsed.heartbeatSec) * 1000 } + : {}), + ...(typeof parsed.done === 'number' ? { done: parsed.done } : {}), + ...(typeof parsed.total === 'number' ? { total: parsed.total } : {}), + ...(typeof parsed.remaining === 'number' ? { remaining: parsed.remaining } : {}), + ...(typeof parsed.unit === 'string' ? { unit: parsed.unit } : {}), + ...(typeof parsed.detail === 'string' ? { detail: parsed.detail } : {}) + }) + if (exitCode !== 0) { + process.exitCode = exitCode + } + console.log(`run finished exit=${exitCode} job=${parsed.jobKey}`) + return + } + + // update + const body: AttachedJobPatch = { + ...(parsed.label !== undefined ? { label: parsed.label } : {}), + ...(parsed.status !== undefined ? { status: parsed.status } : {}), + ...(parsed.done !== undefined ? { done: parsed.done } : {}), + ...(parsed.total !== undefined ? { total: parsed.total } : {}), + ...(parsed.remaining !== undefined ? { remaining: parsed.remaining } : {}), + ...(parsed.unit !== undefined ? { unit: parsed.unit } : {}), + ...(parsed.detail !== undefined ? { detail: parsed.detail } : {}), + ...(parsed.expectedRunId !== undefined ? { expectedRunId: parsed.expectedRunId } : {}) + } + // Empty body is a heartbeat-only update; hub stamps heartbeatAt. + let result + try { + result = await updateSessionJob({ + sessionIdPrefix: parsed.sessionIdPrefix, + jobKey: parsed.jobKey, + body + }) + } catch (error) { + if (error instanceof SessionJobError && error.code === 'not_found') { + throw new SessionJobError('not_found', formatSessionJobNotFoundHint('update')) + } + throw error + } + console.log(`updated ${formatCliJobLine(result.job)}`) + if (result.job.status === 'running' && result.job.remaining === 0) { + console.error(chalk.yellow('hapi job hint:'), SESSION_JOB_REMAINING_ZERO_HINT) + } +} + +export const jobCommand: CommandDefinition = { + name: 'job', + requiresRuntimeAssets: false, + run: async ({ commandArgs }) => { + try { + await handleJobCommand(commandArgs) + } catch (error) { + if (error instanceof SessionJobError) { + console.error(chalk.red('hapi job:'), error.message) + process.exit(exitCodeForSessionJobError(error)) + } + console.error( + chalk.red('hapi job:'), + error instanceof Error ? error.message : 'Unknown error' + ) + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 2bf8c598fb..b306620224 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -21,6 +21,7 @@ import { notifyCommand } from './notify' import { hubCommand } from './hub' import { pingPeerCommand } from './pingPeer' import { inspectPeerCommand } from './inspectPeer' +import { jobCommand } from './job' import type { CommandContext, CommandDefinition } from './types' // Gemini CLI was sunset (Google stopped serving the consumer Gemini CLI on @@ -62,7 +63,8 @@ const COMMANDS: CommandDefinition[] = [ runnerCommand, notifyCommand, pingPeerCommand, - inspectPeerCommand + inspectPeerCommand, + jobCommand ] const commandMap = new Map() diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts index bc283a72ff..dfc27e792d 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts @@ -44,7 +44,7 @@ const harness = vi.hoisted(() => ({ stderrErrorHandler: null as ((error: { type: string; message: string; raw?: string }) => void) | null, disconnectError: null as Error | null, overlayCleanup: null as ReturnType | null, - agentActivityListener: null as ((thinking: boolean) => void) | null + agentActivityListener: null as ((thinking: boolean, source?: string) => void) | null })); const legacyLauncher = vi.hoisted(() => vi.fn()); @@ -178,7 +178,7 @@ vi.mock('./utils/cursorAcpBackend', () => ({ harness.stderrErrorHandler = handler ?? null; }), setUsageUpdateListener: vi.fn(), - setAgentActivityListener: vi.fn((listener: ((thinking: boolean) => void) | null) => { + setAgentActivityListener: vi.fn((listener: ((thinking: boolean, source?: string) => void) | null) => { harness.agentActivityListener = listener; }), setSessionInfoUpdateListener: vi.fn(), @@ -629,7 +629,7 @@ describe('cursorAcpRemoteLauncher', () => { expect(session.model).toBe('auto'); }); - it('applies harness thinking transitions once per edge (#1470)', async () => { + it('ignores ambient state_update_running without a prompt; still applies idle/permission (#1553)', async () => { const keepAlive = vi.fn(); const queue = new MessageQueue2(() => 'mode'); const client = { @@ -650,24 +650,78 @@ describe('cursorAcpRemoteLauncher', () => { permissionMode: 'default' }); session.onSessionFoundWithProtocol = vi.fn(); - // Keep the launcher in the main loop long enough to wire the listener. const runPromise = cursorAcpRemoteLauncher(session); await vi.waitFor(() => expect(harness.agentActivityListener).not.toBeNull()); expect(session.thinking).toBe(false); keepAlive.mockClear(); - harness.agentActivityListener!(true); - harness.agentActivityListener!(true); - harness.agentActivityListener!(false); + // Ambient Cursor chatter while queue-idle must not float Working. + harness.agentActivityListener!(true, 'state_update_running'); + expect(session.thinking).toBe(false); + expect(keepAlive).not.toHaveBeenCalled(); + + harness.agentActivityListener!(true, 'permission'); + expect(session.thinking).toBe(true); + expect(keepAlive).toHaveBeenCalled(); + keepAlive.mockClear(); + harness.agentActivityListener!(false, 'state_update_idle'); expect(session.thinking).toBe(false); - expect(keepAlive.mock.calls.map((call) => call[0])).toEqual([true, false]); + expect(keepAlive).toHaveBeenCalled(); queue.close(); await runPromise; }); + it('applies state_update_running while a prompt is in flight (#1470)', async () => { + const keepAlive = vi.fn(); + const queue = new MessageQueue2(() => 'mode'); + const client = { + ...makeClient(), + keepAlive + } as unknown as ApiSessionClient; + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + session.onSessionFoundWithProtocol = vi.fn(); + + harness.deferPrompt = new Promise((resolve) => { + harness.releasePrompt = resolve; + }); + queue.push('do work', { permissionMode: 'default' }); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.promptCalls).toBeGreaterThan(0)); + await vi.waitFor(() => expect(harness.agentActivityListener).not.toBeNull()); + + // Prompt start already set thinking; clear so we can assert the listener edge. + session.onThinkingChange(false); + keepAlive.mockClear(); + harness.agentActivityListener!(true, 'state_update_running'); + expect(session.thinking).toBe(true); + expect(keepAlive).toHaveBeenCalled(); + + harness.agentActivityListener!(true, 'state_update_requires_action'); + expect(session.thinking).toBe(true); + + harness.releasePrompt?.(); + harness.releasePrompt = null; + harness.deferPrompt = null; + queue.close(); + await runPromise; + }); + it('retries a transient Cursor connection failure three times using api_error events', async () => { harness.promptErrors = [ new Error('Error: RetriableError: [canceled] http/2 stream closed with error code CANCEL'), diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 8ac1c978eb..e9809a71f5 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -830,11 +830,17 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { } /** - * #1470 / #1502: ACP foreground state → hub thinking via keepalive. - * Background tool/content updates are ignored; running is debounced in the backend. + * #1470 / #1502 / #1553: ACP foreground state → hub thinking via keepalive. + * Ignore ambient Cursor `state_update_running` when no HAPI prompt is in + * flight — that chatter otherwise floats quiet sessions into Working and + * hides an attached job from the Jobs bucket. Prompt-owned running, + * permission, and requires_action still bump thinking. */ private wireAgentActivityThinking(backend: AcpSdkBackend, session: CursorSession): void { - backend.setAgentActivityListener((thinking) => { + backend.setAgentActivityListener((thinking, source) => { + if (source === 'state_update_running' && !this.promptInFlight) { + return; + } if (session.thinking !== thinking) { session.onThinkingChange(thinking); } diff --git a/cli/src/grok/utils/systemPrompt.ts b/cli/src/grok/utils/systemPrompt.ts index 695bc9ea42..07af91a8fd 100644 --- a/cli/src/grok/utils/systemPrompt.ts +++ b/cli/src/grok/utils/systemPrompt.ts @@ -1,9 +1,10 @@ import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction' +import { withSessionJobInstruction } from '@/modules/common/sessionJobInstruction' import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction' export const GROK_TITLE_INSTRUCTION = `Use the tool "hapi_change_title" once after the initial request is clear to set a concise session title. Do not rename for routine progress or substeps.\n${SKILL_LOOKUP_INSTRUCTION}` export function getGrokTitleInstruction(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(GROK_TITLE_INSTRUCTION, env) + return withSessionSummaryInstruction(withSessionJobInstruction(GROK_TITLE_INSTRUCTION), env) } diff --git a/cli/src/modules/common/permission/BasePermissionHandler.test.ts b/cli/src/modules/common/permission/BasePermissionHandler.test.ts index 6febd9a4c2..ebd73f8b37 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.test.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.test.ts @@ -101,3 +101,15 @@ describe('resolveToolAutoApprovalDecision list_peers', () => { )).toBeNull() }) }) + +describe('resolveToolAutoApprovalDecision session_job', () => { + it.each([ + 'session_job', + 'hapi_session_job', + 'mcp__hapi__session_job', + 'Session-Attached Job' + ])('does not name-only auto-approve spoofable job tool %s', (toolName) => { + // Bridge / --allowedTools own the approve path; global title allowlist must not. + expect(resolveToolAutoApprovalDecision('default', toolName, 'call-1')).toBeNull() + }) +}) diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index 860c542624..639b782feb 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -39,11 +39,14 @@ const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([ // ACP permission requests often surface MCP tool title, not the snake_case name. 'list peer sessions' ]); +// session_job is intentionally NOT in this name-only allowlist: ACP titles are +// spoofable (tool-name derivation prefers backend title). Auto-approve only via +// trusted HAPI bridge config (Codex tools map / Claude --allowedTools). // ping_peer / inspect_peer intentionally omitted from always-approve: they can // resume+inject into another session or read peer histories, so permission // modes must still gate them. Treat both as write-like in read-only so ACP // titles such as "Ping Peer Session" / "Inspect Peer Session" also require -// approval. list_peers is discovery-only and is auto-approved above. +// approval. list_peers stays auto-approved above. const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'save_memory']; const SENSITIVE_TOOL_NAME_HINTS = [ 'ping_peer', diff --git a/cli/src/modules/common/sessionJobInstruction.test.ts b/cli/src/modules/common/sessionJobInstruction.test.ts new file mode 100644 index 0000000000..e05479ba9a --- /dev/null +++ b/cli/src/modules/common/sessionJobInstruction.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { + SESSION_JOB_INSTRUCTION, + withSessionJobInstruction +} from './sessionJobInstruction' + +describe('sessionJobInstruction', () => { + it('requires job run and forbids MCP set / fake percent', () => { + expect(SESSION_JOB_INSTRUCTION).toContain('session_job') + expect(SESSION_JOB_INSTRUCTION).toContain('ping_peer') + expect(SESSION_JOB_INSTRUCTION).toContain('hapi job run') + expect(SESSION_JOB_INSTRUCTION).toContain('idle agents cannot') + expect(SESSION_JOB_INSTRUCTION).toMatch(/action=set \(refused\)/) + expect(SESSION_JOB_INSTRUCTION).toContain('Never invent a fake percent') + expect(SESSION_JOB_INSTRUCTION).toContain('HAPI_SESSION_ID') + expect(SESSION_JOB_INSTRUCTION).toMatch(/operator chat URL uuid|URL uuid explicitly/i) + expect(SESSION_JOB_INSTRUCTION).toMatch(/action=list on start\/resume/i) + expect(SESSION_JOB_INSTRUCTION).toMatch(/before done\/archive/i) + expect(SESSION_JOB_INSTRUCTION).toMatch(/never ask the operator/i) + }) + + it('appends after an existing prompt block', () => { + expect(withSessionJobInstruction('Base.')).toBe(`Base.\n\n${SESSION_JOB_INSTRUCTION}`) + expect(withSessionJobInstruction('')).toBe(SESSION_JOB_INSTRUCTION) + }) +}) diff --git a/cli/src/modules/common/sessionJobInstruction.ts b/cli/src/modules/common/sessionJobInstruction.ts new file mode 100644 index 0000000000..67fca24cf1 --- /dev/null +++ b/cli/src/modules/common/sessionJobInstruction.ts @@ -0,0 +1,40 @@ +/** + * Always-on steer for session-attached long-running jobs (tiann/hapi#1404). + * + * Injected into flavors that have a HAPI system / developer-instructions seam + * today: Claude, Codex, OpenCode, Grok. Cursor/Kimi/Copilot/Pi get the same + * MCP tool (`session_job`) via the HAPI MCP bridge — catalog discovery, not + * this prompt block. Estate skill remains a Cursor backup. + */ + +/** Canonical one-block contract. Keep short — every session's prompt budget. */ +export const SESSION_JOB_INSTRUCTION = [ + 'Session-attached jobs (outliving work):', + 'When you start work that will keep running after this agent goes idle', + '(batch imports, long scripts, external daemons), attach a session job so', + 'the session list shows progress while you are idle — same class of HAPI', + 'tooling as ping_peer / inspect_peer.', + 'REQUIRED for process-shaped work (idle agents cannot heartbeat):', + 'Shell → hapi job run --label -- …', + 'Use "$HAPI_SESSION_ID" only when it matches the operator chat URL uuid;', + 'remote Cursor runner may export a worker-row id — pass /sessions/ explicitly.', + '(auto-heartbeats + completed/failed on exit). Wrap the real cmd — never a sleep', + 'stub while work runs elsewhere (SSH/wget/nohup).', + 'HYGIENE: MCP session_job action=list on start/resume AND before done/archive.', + 'Clear or fail zombie running meters (dead process) yourself — never ask the operator.', + 'Leave live jobs with fresh heartbeats alone.', + 'Do NOT use MCP session_job action=set (refused) or bare set+nohup — that freezes the bar.', + 'MCP session_job is only update / clear / list after job run created the meter.', + 'remaining=0 does not finish the job — pass --status completed or job clear when done.', + 'Prefer honest remaining or done+total; omit counts when unknown', + '(UI shows "running" + elapsed). Never invent a fake percent.', + 'Full contract: hapi job --help.' +].join(' ') + +/** Append instruction to an existing prompt block (blank line separator). */ +export function withSessionJobInstruction(base: string): string { + const trimmed = base.trimEnd() + return trimmed.length > 0 + ? `${trimmed}\n\n${SESSION_JOB_INSTRUCTION}` + : SESSION_JOB_INSTRUCTION +} diff --git a/cli/src/modules/sessionJob/formatJobLine.ts b/cli/src/modules/sessionJob/formatJobLine.ts new file mode 100644 index 0000000000..4c6ac76cdd --- /dev/null +++ b/cli/src/modules/sessionJob/formatJobLine.ts @@ -0,0 +1,69 @@ +import { stripVTControlCharacters } from 'node:util' + +/** + * Job fields can carry agent- or repo-supplied text. Strip VT/ANSI and remaining + * C0 controls before printing so CLI stdout and MCP tool results cannot emit + * OSC clipboard / title escapes into agent or operator terminals. + */ +export function terminalText(value: string): string { + // stripVT removes ESC-based VT; also scrub C0 + DEL + C1 (incl. 8-bit OSC/ST). + return stripVTControlCharacters(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ') +} + +export type FormatJobLineJob = { + key: string + label: string + status: string + done?: number + total?: number + remaining?: number + unit?: string + detail?: string + runId?: string + heartbeatAt?: number + startedAt?: number +} + +export type FormatJobLineOptions = { + /** CLI list/set/update include wall-clock elapsed + heartbeat age. */ + includeTiming?: boolean + nowMs?: number +} + +export function formatJobLine( + job: FormatJobLineJob, + options: FormatJobLineOptions = {} +): string { + const unit = job.unit ? ` ${terminalText(job.unit)}` : '' + const parts = [terminalText(job.key), terminalText(job.label), terminalText(job.status)] + if (job.remaining !== undefined) { + parts.push(`${job.remaining}${unit} left`) + } else if (job.done !== undefined && job.total !== undefined) { + parts.push(`${job.done}/${job.total}${unit}`) + } + if (options.includeTiming && job.startedAt !== undefined) { + const nowMs = options.nowMs ?? Date.now() + const elapsedSec = Math.max(0, Math.round((nowMs - job.startedAt) / 1000)) + if (elapsedSec < 60) { + parts.push(`elapsed ${elapsedSec}s`) + } else if (elapsedSec < 3600) { + parts.push(`elapsed ${Math.floor(elapsedSec / 60)}m`) + } else if (elapsedSec < 86400) { + const h = Math.floor(elapsedSec / 3600) + const m = Math.floor((elapsedSec % 3600) / 60) + parts.push(m > 0 ? `elapsed ${h}h ${m}m` : `elapsed ${h}h`) + } else { + const d = Math.floor(elapsedSec / 86400) + const h = Math.floor((elapsedSec % 86400) / 3600) + parts.push(h > 0 ? `elapsed ${d}d ${h}h` : `elapsed ${d}d`) + } + } + if (job.detail) parts.push(terminalText(job.detail)) + if (job.runId) parts.push(`runId ${terminalText(job.runId)}`) + if (options.includeTiming && job.heartbeatAt !== undefined) { + const nowMs = options.nowMs ?? Date.now() + const ageSec = Math.max(0, Math.round((nowMs - job.heartbeatAt) / 1000)) + parts.push(`heartbeat ${ageSec}s ago`) + } + return parts.join(' · ') +} diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts new file mode 100644 index 0000000000..dd894b90a1 --- /dev/null +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -0,0 +1,694 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { SESSION_JOB_JWT_REFRESH_AFTER_MS, resolveSessionJobClient, updateSessionJob } from './sessionJob' +import { runSessionJob } from './runSessionJob' + +function fakeChild(exitCode: number, deferExit = false) { + const child = new EventEmitter() as EventEmitter & { + pid: number + killed: boolean + exit: () => void + } + child.pid = 4242 + child.killed = false + child.exit = () => child.emit('exit', exitCode, null) + if (!deferExit) { + queueMicrotask(() => child.exit()) + } + return child +} + +describe('runSessionJob', () => { + it('sets running, heartbeats, then marks completed on exit 0', async () => { + const http = { + post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async (_url: string, body: { status?: string; startedAt?: number }) => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: body.status ?? 'running', + heartbeatAt: 1, + startedAt: body.startedAt ?? 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async (_url: string, body: { status?: string }) => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: body.status ?? 'running', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + } + } + })) + } + + const timers: Array<() => void> = [] + const child = fakeChild(0, true) + const running = runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['true'], + heartbeatMs: 10, + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => child) as never, + setIntervalImpl: ((fn: () => void) => { + timers.push(fn) + return 1 as unknown as NodeJS.Timeout + }) as never, + clearIntervalImpl: (() => undefined) as never + }) + + await vi.waitFor(() => expect(http.put).toHaveBeenCalled()) + expect(http.put.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + status: 'running', + runId: expect.any(String) + })) + expect((http.put.mock.calls[0]?.[1] as { startedAt?: number }).startedAt).toBeUndefined() + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) + + // Heartbeat ticks reuse resolved client (no extra auth) and must not + // send status:running (late heartbeat must not resurrect after exit). + expect(timers.length).toBe(1) + timers[0]!() + await vi.waitFor(() => expect(http.patch).toHaveBeenCalled()) + const putRunId = (http.put.mock.calls[0]?.[1] as { runId: string }).runId + expect(putRunId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ) + const heartbeatBody = http.patch.mock.calls[0]?.[1] as { + status?: string + expectedRunId?: string + } + expect(heartbeatBody.status).toBeUndefined() + expect(heartbeatBody.expectedRunId).toBe(putRunId) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) + + child.exit() + const exitCode = await running + expect(exitCode).toBe(0) + const lastPatch = http.patch.mock.calls.at(-1)?.[1] as { + status?: string + expectedRunId?: string + } + expect(lastPatch.status).toBe('completed') + expect(lastPatch.expectedRunId).toBe(putRunId) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) + }) + + it('does not mark terminal when hub reports run mismatch (key reused)', async () => { + const http = { + post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async (_url: string, body: { startedAt?: number }) => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 1, + startedAt: body.startedAt ?? 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async () => ({ + status: 409, + data: { error: 'Job run mismatch' } + })) + } + + const child = fakeChild(0, true) + const err = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const running = runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['true'], + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => child) as never, + setIntervalImpl: ((() => 1) as unknown as typeof setInterval), + clearIntervalImpl: (() => undefined) as never, + sleepImpl: async () => undefined + }) + await vi.waitFor(() => expect(http.put).toHaveBeenCalled()) + child.exit() + const exitCode = await running + expect(exitCode).toBe(0) + expect(http.patch).toHaveBeenCalledTimes(1) + expect(err).toHaveBeenCalledWith(expect.stringMatching(/run mismatch|Job run mismatch/i)) + err.mockRestore() + }) + + it('retries terminal status write after transient failures', async () => { + let patchCalls = 0 + const http = { + post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async () => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async (_url: string, body: { status?: string }) => { + patchCalls += 1 + if (body.status === 'completed' && patchCalls < 3) { + throw new Error('transient hub 503') + } + return { + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: body.status ?? 'running', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + } + } + } + }) + } + + const sleeps: number[] = [] + const exitCode = await runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['true'], + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => fakeChild(0)) as never, + setIntervalImpl: ((() => 1) as never), + clearIntervalImpl: (() => undefined) as never, + sleepImpl: async (ms) => { sleeps.push(ms) } + }) + + expect(exitCode).toBe(0) + expect(patchCalls).toBe(3) + expect(sleeps).toEqual([1_000, 2_000]) + const lastPatch = http.patch.mock.calls.at(-1)?.[1] as { status?: string } + expect(lastPatch.status).toBe('completed') + }) + + it('exits 0 when child succeeds but meter was cleared before terminal write', async () => { + const http = { + post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async () => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async (_url: string, body: { status?: string }) => { + if (body.status === 'completed') { + return { status: 404, data: { error: 'job not found' } } + } + return { + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + } + } + } + }) + } + + const exitCode = await runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['true'], + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => fakeChild(0)) as never, + setIntervalImpl: ((() => 1) as never), + clearIntervalImpl: (() => undefined) as never, + sleepImpl: async () => undefined + }) + + expect(exitCode).toBe(0) + expect(http.patch).toHaveBeenCalled() + }) + + it('exits 1 when child succeeds but terminal status never persists', async () => { + const http = { + post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async () => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async () => { + throw new Error('hub unreachable') + }) + } + + const err = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const exitCode = await runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['true'], + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => fakeChild(0)) as never, + setIntervalImpl: ((() => 1) as never), + clearIntervalImpl: (() => undefined) as never, + sleepImpl: async () => undefined + }) + + expect(exitCode).toBe(1) + expect(http.patch).toHaveBeenCalled() + expect(err).toHaveBeenCalledWith(expect.stringMatching(/failed to mark job completed/i)) + err.mockRestore() + }) + + it('marks failed on non-zero exit', async () => { + const http = { + post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async () => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async (_url: string, body: { status?: string }) => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: body.status ?? 'running', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + } + } + })) + } + + const exitCode = await runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['false'], + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => fakeChild(7)) as never, + setIntervalImpl: ((() => 1) as never), + clearIntervalImpl: (() => undefined) as never + }) + + expect(exitCode).toBe(7) + const lastPatch = http.patch.mock.calls.at(-1)?.[1] as { status?: string } + expect(lastPatch.status).toBe('failed') + }) + + it('re-exchanges JWT on heartbeat 401 and still marks completed (hub 4h expiry)', async () => { + let jwtIssue = 0 + let patchCalls = 0 + const http = { + post: vi.fn(async () => { + jwtIssue += 1 + return { status: 200, data: { token: `jwt-${jwtIssue}` } } + }), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async () => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async (_url: string, body: { status?: string }, cfg?: { headers?: Record }) => { + patchCalls += 1 + const auth = cfg?.headers?.Authorization ?? '' + // First heartbeat still carries jwt-1 after hub expiry → 401. + if (patchCalls === 1 && auth.includes('jwt-1')) { + return { status: 401, data: { error: 'expired' } } + } + return { + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: body.status ?? 'running', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + } + } + } + }) + } + + const timers: Array<() => void> = [] + const child = fakeChild(0, true) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const running = runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['true'], + heartbeatMs: 10, + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => child) as never, + setIntervalImpl: ((fn: () => void) => { + timers.push(fn) + return 1 as unknown as NodeJS.Timeout + }) as never, + clearIntervalImpl: (() => undefined) as never + }) + + await vi.waitFor(() => expect(http.put).toHaveBeenCalled()) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) + + timers[0]!() + await vi.waitFor(() => expect(http.post).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(http.patch.mock.calls.length).toBeGreaterThanOrEqual(2)) + // Session list not re-fetched — only JWT refresh. + expect(http.get).toHaveBeenCalledTimes(1) + + child.exit() + const exitCode = await running + expect(exitCode).toBe(0) + const lastPatch = http.patch.mock.calls.at(-1)?.[1] as { status?: string } + expect(lastPatch.status).toBe('completed') + errSpy.mockRestore() + }) + + it('proactively re-exchanges JWT after 3h without re-listing sessions', async () => { + let jwtIssue = 0 + const http = { + post: vi.fn(async () => { + jwtIssue += 1 + return { status: 200, data: { token: `jwt-${jwtIssue}` } } + }), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + patch: vi.fn(async (_url: string, _body: unknown, cfg?: { headers?: Record }) => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + }, + _auth: cfg?.headers?.Authorization + } + })) + } + + const resolved = await resolveSessionJobClient({ + sessionIdPrefix: 'aaaa', + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never + }) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) + + // Inside the window: no second exchange. + await updateSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + body: { remaining: 9 }, + resolved, + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never + }) + expect(http.post).toHaveBeenCalledTimes(1) + + // Past proactive refresh threshold: exchange once, keep session id. + resolved.jwtIssuedAtMs = Date.now() - SESSION_JOB_JWT_REFRESH_AFTER_MS - 1 + await updateSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + body: { remaining: 8 }, + resolved, + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never + }) + expect(http.post).toHaveBeenCalledTimes(2) + expect(http.get).toHaveBeenCalledTimes(1) + expect(resolved.jwt).toBe('jwt-2') + const auth = (http.patch.mock.calls.at(-1)?.[2] as { headers?: Record } | undefined) + ?.headers?.Authorization + expect(auth).toContain('jwt-2') + + // Next tick inside the new window: no exchange storm. + await updateSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + body: { remaining: 7 }, + resolved, + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never + }) + expect(http.post).toHaveBeenCalledTimes(2) + expect(http.get).toHaveBeenCalledTimes(1) + }) + + it('forwards SIGINT/SIGTERM via tree-kill so grandchildren cannot outlive the job', async () => { + const http = { + post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async () => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + runId: 'run-1', + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async () => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'completed', + runId: 'run-1', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + } + } + })) + } + + const child = fakeChild(0, true) + const killChild = vi.fn(async () => true) + const running = runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['sleep', '30'], + heartbeatMs: 60_000, + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => child) as never, + setIntervalImpl: (() => 1 as unknown as NodeJS.Timeout) as never, + clearIntervalImpl: (() => undefined) as never, + killChildImpl: killChild + }) + + await vi.waitFor(() => expect(http.put).toHaveBeenCalled()) + process.emit('SIGINT') + expect(killChild).toHaveBeenCalledWith(child, 'SIGINT') + child.exit() + await running + }) + + it('awaits verified tree-kill before writing terminal job state', async () => { + const http = { + post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async () => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + runId: 'run-1', + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async ( + _url: string, + body: { status?: string; detail?: string } + ) => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: body.status ?? 'failed', + runId: 'run-1', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2, + ...(body.detail !== undefined ? { detail: body.detail } : {}), + } + } + })) + } + + let resolveKill: ((ok: boolean) => void) | undefined + const killChild = vi.fn(() => new Promise((resolve) => { + resolveKill = resolve + })) + const child = fakeChild(0, true) + const err = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const running = runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['sleep', '30'], + heartbeatMs: 60_000, + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => child) as never, + setIntervalImpl: (() => 1 as unknown as NodeJS.Timeout) as never, + clearIntervalImpl: (() => undefined) as never, + killChildImpl: killChild + }) + + await vi.waitFor(() => expect(http.put).toHaveBeenCalled()) + process.emit('SIGTERM') + expect(killChild).toHaveBeenCalledWith(child, 'SIGTERM') + child.exit() + + // Child exited, but tree-kill still pending → no terminal write yet. + await Promise.resolve() + expect(http.patch).not.toHaveBeenCalled() + + resolveKill?.(false) + const exitCode = await running + expect(exitCode).toBe(1) + const lastPatch = http.patch.mock.calls.at(-1)?.[1] + expect(lastPatch?.status).toBe('failed') + expect(lastPatch?.detail).toMatch(/complete child process tree/i) + expect(err).toHaveBeenCalledWith(expect.stringMatching(/complete child process tree/i)) + err.mockRestore() + }) +}) diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts new file mode 100644 index 0000000000..9c8b0d0855 --- /dev/null +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -0,0 +1,249 @@ +/** + * Supervise a child command while heartbeating a session-attached job. + * Fixes the idle-agent heartbeat gap (cold review #1404). + */ + +import { randomUUID } from 'node:crypto' +import { spawn, type ChildProcess } from 'node:child_process' +import { constants as osConstants } from 'node:os' +import type { AttachedJobUpsert } from '@hapi/protocol' +import { killProcessByChildProcess } from '@/utils/process' +import { + SessionJobError, + resolveSessionJobClient, + setSessionJob, + updateSessionJob, + type SessionJobClientOptions, + type SessionJobResolvedClient +} from './sessionJob' + +export type RunSessionJobOptions = SessionJobClientOptions & { + jobKey: string + label: string + command: string[] + heartbeatMs?: number + remaining?: number + done?: number + total?: number + unit?: string + detail?: string + /** Injected for tests. */ + spawnImpl?: typeof spawn + setIntervalImpl?: typeof setInterval + clearIntervalImpl?: typeof clearInterval + /** Injected for tests (terminal-status retry backoff). */ + sleepImpl?: (ms: number) => Promise + /** Injected for tests — defaults to tree-kill via killProcessByChildProcess. */ + killChildImpl?: (child: ChildProcess, signal: NodeJS.Signals) => void | Promise +} + +const DEFAULT_HEARTBEAT_MS = 5 * 60 * 1000 +const TERMINAL_STATUS_ATTEMPTS = 3 + +async function markTerminalWithRetry(options: { + clientOpts: SessionJobClientOptions & { resolved: SessionJobResolvedClient } + jobKey: string + status: 'completed' | 'failed' + detail?: string + expectedRunId: string + sleep: (ms: number) => Promise +}): Promise { + let lastError: unknown + for (let attempt = 0; attempt < TERMINAL_STATUS_ATTEMPTS; attempt += 1) { + try { + await updateSessionJob({ + ...options.clientOpts, + jobKey: options.jobKey, + body: { + status: options.status, + expectedRunId: options.expectedRunId, + ...(options.detail !== undefined ? { detail: options.detail } : {}), + }, + }) + return + } catch (error) { + lastError = error + // Another run owns the key — retrying cannot help and may confuse logs. + if (error instanceof SessionJobError && error.code === 'run_mismatch') { + break + } + // Operator cleared the meter while the child still ran — detached, not a + // supervision failure. Retry transient 404s; accept absence on last attempt. + if ( + error instanceof SessionJobError + && error.code === 'not_found' + && attempt === TERMINAL_STATUS_ATTEMPTS - 1 + ) { + return + } + if (attempt === TERMINAL_STATUS_ATTEMPTS - 1) { + break + } + await options.sleep(1_000 * 2 ** attempt) + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)) +} + +export async function runSessionJob(options: RunSessionJobOptions): Promise { + if (options.command.length === 0) { + throw new SessionJobError('bad_args', 'run requires a command after --') + } + + // Hub stamps startedAt when runId starts a new generation (runner/hub + // clocks may skew). runId is the unique generation fence. + const runId = randomUUID() + const body: AttachedJobUpsert = { + label: options.label, + status: 'running', + runId, + ...(options.done !== undefined ? { done: options.done } : {}), + ...(options.total !== undefined ? { total: options.total } : {}), + ...(options.remaining !== undefined ? { remaining: options.remaining } : {}), + ...(options.unit !== undefined ? { unit: options.unit } : {}), + ...(options.detail !== undefined ? { detail: options.detail } : {}) + } + + // Cache sessionId for the run. JWT is refreshed in-place before hub's 4h + // expiry and on 401 (see sessionJob.withAuthedRequest) — do not skip refresh. + const resolved: SessionJobResolvedClient = await resolveSessionJobClient(options) + const clientOpts = { + sessionIdPrefix: options.sessionIdPrefix, + resolved, + accessToken: options.accessToken, + apiUrl: options.apiUrl, + http: options.http + } + + await setSessionJob({ + ...clientOpts, + jobKey: options.jobKey, + body + }) + + const spawnFn = options.spawnImpl ?? spawn + const setIntervalFn = options.setIntervalImpl ?? setInterval + const clearIntervalFn = options.clearIntervalImpl ?? clearInterval + const sleep = options.sleepImpl + ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS + + const child: ChildProcess = spawnFn(options.command[0]!, options.command.slice(1), { + stdio: 'inherit', + env: process.env + }) + + let loggedHeartbeatFailure = false + let inflightHeartbeat: Promise = Promise.resolve() + const heartbeat = setIntervalFn(() => { + // Never PATCH status:running on the heartbeat — a late in-flight + // request must not resurrect running after the terminal write. + // Fence with runId so a superseded run cannot touch a key reuse. + // Do not re-send --detail: MCP/CLI progress updates would otherwise be + // clobbered by the next timer tick with the launch-time string. + inflightHeartbeat = updateSessionJob({ + ...clientOpts, + jobKey: options.jobKey, + body: { expectedRunId: runId } + }).catch((error: unknown) => { + // Best-effort — exit path still marks terminal status. Log once so + // a broken supervisor is visible (stuck chip with dead PID is worse). + if (!loggedHeartbeatFailure) { + loggedHeartbeatFailure = true + const message = error instanceof Error ? error.message : String(error) + console.error(`[hapi job run] heartbeat failed (will keep trying): ${message}`) + } + }) + }, heartbeatMs) + // Don't keep the event loop alive solely for heartbeats if child already exited. + heartbeat.unref?.() + + // Forward the received signal through tree-kill (not always SIGTERM) and + // await verified teardown before writing terminal job state. + let terminationPromise: Promise | null = null + const forward = (signal: NodeJS.Signals) => { + if (child.pid && !child.killed) { + // Tree-kill: a supervised shell can exit while a grandchild keeps + // running; bare process.kill(child.pid) would leave orphans and let + // the wrapper mark the job terminal while work continues. + const killChild = options.killChildImpl + ?? ((target: ChildProcess, sig: NodeJS.Signals) => + killProcessByChildProcess(target, false, sig)) + terminationPromise ??= Promise.resolve(killChild(child, signal)).then( + (result) => result !== false + ) + } + } + const onSigInt = () => forward('SIGINT') + const onSigTerm = () => forward('SIGTERM') + process.on('SIGINT', onSigInt) + process.on('SIGTERM', onSigTerm) + + let spawnErrorDetail: string | undefined + const exitCode = await new Promise((resolve) => { + child.on('error', async (error) => { + clearIntervalFn(heartbeat) + spawnErrorDetail = error.message + resolve(127) + }) + child.on('exit', (code, signal) => { + clearIntervalFn(heartbeat) + if (signal) { + const signalNumber = osConstants.signals[signal] ?? 1 + resolve(128 + signalNumber) + return + } + resolve(code ?? 1) + }) + }) + + process.off('SIGINT', onSigInt) + process.off('SIGTERM', onSigTerm) + + // Drain any in-flight heartbeat so a late status:running cannot clobber + // the terminal completed/failed write. + await inflightHeartbeat.catch(() => undefined) + + let treeKillFailed = false + if (terminationPromise) { + const stopped = await terminationPromise + if (!stopped) { + treeKillFailed = true + console.error('[hapi job run] failed to terminate the complete child process tree') + } + } + + const terminalStatus = exitCode === 0 && !treeKillFailed ? 'completed' : 'failed' + let terminalWriteFailed = false + try { + await markTerminalWithRetry({ + clientOpts, + jobKey: options.jobKey, + status: terminalStatus, + expectedRunId: runId, + ...(spawnErrorDetail !== undefined + ? { detail: spawnErrorDetail } + : treeKillFailed + ? { detail: 'failed to terminate the complete child process tree' } + : {}), + sleep, + }) + } catch (error) { + // run_mismatch means another generation owns the key — not our meter to fix. + // not_found means the meter was cleared externally (e.g. session_job clear). + terminalWriteFailed = !( + error instanceof SessionJobError + && (error.code === 'run_mismatch' || error.code === 'not_found') + ) + const message = error instanceof Error ? error.message : String(error) + console.error(`[hapi job run] failed to mark job ${terminalStatus}: ${message}`) + } + + // Child success with a frozen "running" meter (or uncleared descendants) is + // worse than a nonzero exit — surface the supervision failure so wrappers/CI + // cannot claim green. + if (treeKillFailed) { + return exitCode === 0 ? 1 : exitCode + } + return exitCode === 0 && terminalWriteFailed ? 1 : exitCode +} diff --git a/cli/src/modules/sessionJob/sessionJob.ts b/cli/src/modules/sessionJob/sessionJob.ts new file mode 100644 index 0000000000..582f3c3e82 --- /dev/null +++ b/cli/src/modules/sessionJob/sessionJob.ts @@ -0,0 +1,452 @@ +/** + * Register / update / clear session-attached jobs (tiann/hapi#1404). + * Same hub JWT flow as ping-peer — works while the agent session is idle. + */ + +import axios, { type AxiosInstance } from 'axios' +import type { AttachedJob, AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' +import { configuration } from '@/configuration' +import { getAuthToken } from '@/api/auth' +import { buildHubRequestHeaders } from '@/api/hubExtraHeaders' + +export type SessionJobErrorCode = + | 'bad_args' + | 'auth_failed' + | 'not_found' + | 'ambiguous' + | 'run_mismatch' + | 'request_failed' + +export class SessionJobError extends Error { + readonly code: SessionJobErrorCode + + constructor(code: SessionJobErrorCode, message: string) { + super(message) + this.name = 'SessionJobError' + this.code = code + } +} + +/** Exact Shell recipe agents should run instead of MCP set or update-before-create. */ +export const SESSION_JOB_RUN_RECIPE = + 'hapi job run --label "