From 5153ff23c196c7b84818752f7a631420ef0eb25b Mon Sep 17 00:00:00 2001 From: Ananovo Date: Mon, 5 Oct 2026 08:35:53 +0800 Subject: [PATCH 01/99] test(ios): make SessionSplit width assertion responsive (#1907) --- ios/HapiTests/SessionSplitPresentationTests.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/ios/HapiTests/SessionSplitPresentationTests.swift b/ios/HapiTests/SessionSplitPresentationTests.swift index 3dd7cfdda9..c6da724c6c 100644 --- a/ios/HapiTests/SessionSplitPresentationTests.swift +++ b/ios/HapiTests/SessionSplitPresentationTests.swift @@ -94,9 +94,17 @@ final class SessionSplitPresentationTests: XCTestCase { navigation.open("a") try await settle { probe.instances["a"] != nil } let identity = try XCTUnwrap(probe.instances["a"]) - let sidebar = try XCTUnwrap(split.viewController(for: .primary).flatMap { self.findCollection(in: $0.view) }) - XCTAssertGreaterThanOrEqual(sidebar.bounds.width, 280) - XCTAssertLessThanOrEqual(sidebar.bounds.width, 360) + let primaryView = try XCTUnwrap(split.viewController(for: .primary)?.view) + let sidebar = try XCTUnwrap(self.findCollection(in: primaryView)) + let primaryFrame = primaryView.convert(primaryView.bounds, to: window) + let sidebarFrame = sidebar.convert(sidebar.bounds, to: window) + // NavigationSplitView treats the requested column width as a preferred + // range. The balanced iPad style may expand it beyond the 360pt request + // on a wide window, so assert the actual responsive invariant instead + // of coupling the test to one UIKit measurement or OS release. + XCTAssertEqual(sidebarFrame.width, primaryFrame.width, accuracy: 1) + XCTAssertGreaterThanOrEqual(primaryFrame.width, 280) + XCTAssertLessThanOrEqual(primaryFrame.width, window.bounds.width / 2) list.selectMachine("debian") try await settle { list.rows.map(\.id) == ["b"] } From c2d2cfe6f84fa20628ad8c394f14ce19fce9754f Mon Sep 17 00:00:00 2001 From: Dave Rapin Date: Mon, 5 Oct 2026 05:09:07 -0400 Subject: [PATCH 02/99] Add 130% app font size (#1975) --- .../settings/SettingsPrimitives.tsx | 10 ++-- web/src/hooks/useFontScale.test.ts | 47 +++++++++++++++++++ web/src/hooks/useFontScale.ts | 5 +- web/src/routes/settings/display.tsx | 2 +- 4 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 web/src/hooks/useFontScale.test.ts diff --git a/web/src/components/settings/SettingsPrimitives.tsx b/web/src/components/settings/SettingsPrimitives.tsx index 4bc32f91c4..610b906ccf 100644 --- a/web/src/components/settings/SettingsPrimitives.tsx +++ b/web/src/components/settings/SettingsPrimitives.tsx @@ -82,13 +82,13 @@ export function SettingsChoiceGroup(props: { value: T options: ReadonlyArray<{ value: T; label: string; description?: string }> onChange: (value: T) => void - columns?: 2 | 4 | 5 + columns?: 2 | 4 | 5 | 6 }) { - const columns = props.columns === 5 ? 'grid-cols-5' : props.columns === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2' + const columns = props.columns === 6 ? 'grid-cols-6' : props.columns === 5 ? 'grid-cols-5' : props.columns === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2' return ( -
+
-
+
{props.options.map((option) => { const selected = props.value === option.value return ( @@ -98,7 +98,7 @@ export function SettingsChoiceGroup(props: { role="radio" aria-checked={selected} onClick={() => props.onChange(option.value)} - className={`min-w-0 rounded-lg border px-2 py-2 text-center text-sm transition-colors ${selected + className={`min-w-0 rounded-lg border ${props.columns === 6 ? 'px-0 text-xs tracking-tight sm:text-sm sm:tracking-normal' : 'px-2 text-sm'} py-2 text-center transition-colors ${selected ? 'border-[var(--app-link)] bg-[var(--app-subtle-bg)] text-[var(--app-link)]' : 'border-[var(--app-border)] text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'}`} > diff --git a/web/src/hooks/useFontScale.test.ts b/web/src/hooks/useFontScale.test.ts new file mode 100644 index 0000000000..35882a808d --- /dev/null +++ b/web/src/hooks/useFontScale.test.ts @@ -0,0 +1,47 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getFontScaleOptions, initializeFontScale, useFontScale } from './useFontScale' + +describe('useFontScale', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + afterEach(() => { + document.documentElement.style.removeProperty('--app-font-scale') + }) + + it('offers 130% alongside the existing sizes', () => { + expect(getFontScaleOptions()).toEqual([ + { value: 0.8, label: '80%' }, + { value: 0.9, label: '90%' }, + { value: 1, label: '100%' }, + { value: 1.1, label: '110%' }, + { value: 1.2, label: '120%' }, + { value: 1.3, label: '130%' }, + ]) + }) + + it.each([1.3] as const)('applies scale %s and restores it on startup and remount', (scale) => { + const { result, unmount } = renderHook(() => useFontScale()) + act(() => result.current.setFontScale(scale)) + + expect(document.documentElement.style.getPropertyValue('--app-font-scale')).toBe(String(scale)) + expect(window.localStorage.getItem('hapi-font-scale')).toBe(String(scale)) + unmount() + + document.documentElement.style.removeProperty('--app-font-scale') + initializeFontScale() + expect(document.documentElement.style.getPropertyValue('--app-font-scale')).toBe(String(scale)) + + const restored = renderHook(() => useFontScale()) + expect(restored.result.current.fontScale).toBe(scale) + restored.unmount() + }) + + it('falls back to 100% for an unsupported stored scale', () => { + window.localStorage.setItem('hapi-font-scale', '1.4') + initializeFontScale() + expect(document.documentElement.style.getPropertyValue('--app-font-scale')).toBe('1') + }) +}) diff --git a/web/src/hooks/useFontScale.ts b/web/src/hooks/useFontScale.ts index b4b038bcf3..976d332270 100644 --- a/web/src/hooks/useFontScale.ts +++ b/web/src/hooks/useFontScale.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useState } from 'react' -export type FontScale = 0.8 | 0.9 | 1 | 1.1 | 1.2 +export type FontScale = 0.8 | 0.9 | 1 | 1.1 | 1.2 | 1.3 export function getFontScaleOptions(): ReadonlyArray<{ value: FontScale; label: string }> { return [ @@ -9,6 +9,7 @@ export function getFontScaleOptions(): ReadonlyArray<{ value: FontScale; label: { value: 1, label: '100%' }, { value: 1.1, label: '110%' }, { value: 1.2, label: '120%' }, + { value: 1.3, label: '130%' }, ] } @@ -57,7 +58,7 @@ function safeRemoveItem(key: string): void { function parseFontScale(raw: string | null): FontScale { const value = Number(raw) - if (value === 0.8 || value === 0.9 || value === 1 || value === 1.1 || value === 1.2) { + if (value === 0.8 || value === 0.9 || value === 1 || value === 1.1 || value === 1.2 || value === 1.3) { return value } return 1 diff --git a/web/src/routes/settings/display.tsx b/web/src/routes/settings/display.tsx index 92f23f50d1..5c71ca24bc 100644 --- a/web/src/routes/settings/display.tsx +++ b/web/src/routes/settings/display.tsx @@ -171,7 +171,7 @@ export default function SettingsDisplayPage() { - + From d3d8bf2620b9ccf08a7c3267fc975c28f9e4c465 Mon Sep 17 00:00:00 2001 From: Matvey Rudoy <75200069+mrudybtw@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:12:57 +0300 Subject: [PATCH 03/99] feat(ios): add Russian locale (#1964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ios): add Russian locale Add Russian translations to Localizable.xcstrings for all 591 catalog entries (reusing matching Android translations and translating the rest); zh-Hans and format placeholders are preserved. * fix(ios): make Russian selectable and use correct plural forms - add AppLanguage.russian ("ru"), its display name, the AppleLanguages override, and register ru in the Xcode project's knownRegions so the language picker can actually select Russian - add Russian one/few/many/other plural variations for %lld sessions, %lld attachments and %@, %lld sessions - use count-agnostic phrasing for the other dynamic counts (tools, edits, lines, chars, findings, notes, queued messages, questions, ...) - correct Input/Output to Ввод/Вывод - cover the new language case in ThemePrefsTests * fix(ios): use Russian plural forms for the remaining count labels * fix(ios): pass the step count to the plural localization * fix(ios): pass tool counts to the plural localization * fix(ios): keep the singular English tool-group labels --------- Co-authored-by: mrudy --- ios/Hapi.xcodeproj/project.pbxproj | 1 + .../Chat/Blocks/ToolCallBlockView.swift | 2 +- .../Chat/Blocks/ToolGroupBlockView.swift | 23 +- ios/Hapi/Features/Settings/SettingsView.swift | 2 + ios/Hapi/Resources/Localizable.xcstrings | 3762 +++++++++++++++++ .../HapiClient/Settings/SettingsPrefs.swift | 2 + .../Settings/ThemePrefsTests.swift | 5 + 7 files changed, 3788 insertions(+), 9 deletions(-) diff --git a/ios/Hapi.xcodeproj/project.pbxproj b/ios/Hapi.xcodeproj/project.pbxproj index 489c1dd224..76a2a6bc30 100644 --- a/ios/Hapi.xcodeproj/project.pbxproj +++ b/ios/Hapi.xcodeproj/project.pbxproj @@ -248,6 +248,7 @@ en, Base, "zh-Hans", + ru, ); mainGroup = A100000000000000000000A1; minimizedProjectReferenceProxies = 1; diff --git a/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift b/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift index ce5ea01d18..c67d40c5d4 100644 --- a/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift +++ b/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift @@ -48,7 +48,7 @@ struct ToolCallBlockView: View { if opensToolProcess(block) { Button { openTool?(block) } label: { Label( - String(format: String(localized: "View process · %lld steps"), Int64(block.children.count)), + String(localized: "View process · \(block.children.count) steps"), systemImage: "arrow.right" ) .font(typography.captionFont) diff --git a/ios/Hapi/Features/Chat/Blocks/ToolGroupBlockView.swift b/ios/Hapi/Features/Chat/Blocks/ToolGroupBlockView.swift index 295be5d0ec..a89ce181bc 100644 --- a/ios/Hapi/Features/Chat/Blocks/ToolGroupBlockView.swift +++ b/ios/Hapi/Features/Chat/Blocks/ToolGroupBlockView.swift @@ -31,16 +31,23 @@ struct ToolGroupPresentation: Identifiable, Equatable { let parts = ToolGroupActionKind.allCases.compactMap { kind -> String? in let count = countsByKind[kind] ?? 0 guard count > 0 else { return nil } - let format: String + // Interpolated lookups pass the count to the catalog so the Russian + // plural variations are selected; the explicit singular keys keep + // the English labels grammatical. switch kind { - case .read: format = count == 1 ? String(localized: "1 read") : String(localized: "%lld reads") - case .search: format = count == 1 ? String(localized: "1 search") : String(localized: "%lld searches") - case .command: format = count == 1 ? String(localized: "1 command") : String(localized: "%lld commands") - case .mutation: format = count == 1 ? String(localized: "1 edit") : String(localized: "%lld edits") - case .web: format = count == 1 ? String(localized: "1 web request") : String(localized: "%lld web requests") - case .other: format = count == 1 ? String(localized: "1 other tool") : String(localized: "%lld other tools") + case .read: + return count == 1 ? String(localized: "1 read") : String(localized: "\(count) reads") + case .search: + return count == 1 ? String(localized: "1 search") : String(localized: "\(count) searches") + case .command: + return count == 1 ? String(localized: "1 command") : String(localized: "\(count) commands") + case .mutation: + return count == 1 ? String(localized: "1 edit") : String(localized: "\(count) edits") + case .web: + return count == 1 ? String(localized: "1 web request") : String(localized: "\(count) web requests") + case .other: + return count == 1 ? String(localized: "1 other tool") : String(localized: "\(count) other tools") } - return String(format: format, Int64(count)) } return parts.joined(separator: " · ") } diff --git a/ios/Hapi/Features/Settings/SettingsView.swift b/ios/Hapi/Features/Settings/SettingsView.swift index f2db4525d3..789c7670b1 100644 --- a/ios/Hapi/Features/Settings/SettingsView.swift +++ b/ios/Hapi/Features/Settings/SettingsView.swift @@ -165,6 +165,8 @@ struct SettingsView: View { UserDefaults.standard.set(["en"], forKey: "AppleLanguages") case .simplifiedChinese: UserDefaults.standard.set(["zh-Hans"], forKey: "AppleLanguages") + case .russian: + UserDefaults.standard.set(["ru"], forKey: "AppleLanguages") } } ) diff --git a/ios/Hapi/Resources/Localizable.xcstrings b/ios/Hapi/Resources/Localizable.xcstrings index 489dfb5674..fb39a06050 100644 --- a/ios/Hapi/Resources/Localizable.xcstrings +++ b/ios/Hapi/Resources/Localizable.xcstrings @@ -4,6 +4,12 @@ "%@\n\nOpen a session chat to browse its files." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@\n\nОткройте чат сессии, чтобы просмотреть её файлы." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -15,6 +21,12 @@ "%@ (%lld edits)" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (правки: %lld)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -26,6 +38,12 @@ "%@ (+%lld more)" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (+ещё %lld)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -37,6 +55,12 @@ "%@ (optional)" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (необязательно)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -48,6 +72,12 @@ "%@ is over the 50 MB upload limit" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ превышает лимит загрузки 50 МБ" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -59,6 +89,12 @@ "%@ log" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -70,6 +106,12 @@ "%@ rejected its stored credentials and was signed out. Pair it again from the hub menu." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ отклонил сохранённые учётные данные, и устройство вышло из аккаунта. Сопрягите его заново из меню хаба." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -81,6 +123,12 @@ "%@ tokens · %lld req · in %@ / out %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Токены: %@ · запросов: %lld · вход %@ / выход %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -97,12 +145,24 @@ "state" : "new", "value" : "%1$@ · %2$@ requests" } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@ запросов" + } } } }, "%@ · %lld requests" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · запросы: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -132,6 +192,36 @@ } } }, + "ru" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@, %lld сессия" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@, %lld сессии" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@, %lld сессий" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@, %lld сессий" + } + } + } + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -143,6 +233,12 @@ "%lld Questions" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вопросы: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -154,6 +250,36 @@ "%lld agent steps" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld шаг агента" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld шага агента" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld шагов агента" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld шага агента" + } + } + } + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -183,6 +309,36 @@ } } }, + "ru" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld вложение" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld вложения" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld вложений" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld вложений" + } + } + } + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -194,6 +350,12 @@ "%lld chars" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Символы: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -205,6 +367,36 @@ "%lld commands" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld команда" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld команды" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld команд" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld команды" + } + } + } + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -216,6 +408,12 @@ "%lld edits" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Правки: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -227,6 +425,12 @@ "%lld failed tools" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбойные инструменты: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -238,6 +442,12 @@ "%lld findings" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замечания: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -249,6 +459,12 @@ "%lld lines" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Строки: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -260,6 +476,36 @@ "%lld new messages ↓" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld новое сообщение ↓" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld новых сообщения ↓" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld новых сообщений ↓" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld новых сообщения ↓" + } + } + } + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -271,6 +517,12 @@ "%lld notes" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заметки: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -282,6 +534,12 @@ "%lld other tools" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Другие инструменты: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -293,6 +551,12 @@ "%lld queued messages" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщения в очереди: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -304,6 +568,36 @@ "%lld reads" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld чтение" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld чтения" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld чтений" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld чтения" + } + } + } + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -315,6 +609,12 @@ "%lld requests · in %@ · out %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запросы: %lld · вход %@ · выход %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -326,6 +626,36 @@ "%lld searches" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld поиск" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld поиска" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld поисков" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld поиска" + } + } + } + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -355,6 +685,36 @@ } } }, + "ru" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld сессия" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld сессии" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld сессий" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld сессий" + } + } + } + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -366,6 +726,12 @@ "%lld staged · %lld unstaged" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld в индексе · %lld вне индекса" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -377,6 +743,12 @@ "%lld tool calls" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вызовы инструментов: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -388,6 +760,12 @@ "%lld tools" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инструменты: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -399,6 +777,12 @@ "%lld web requests" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Веб-запросы: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -410,6 +794,12 @@ "(empty)" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "(пусто)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -421,6 +811,12 @@ "0 tokens" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 токенов" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -432,6 +828,12 @@ "1 agent step" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 шаг агента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -443,6 +845,12 @@ "1 command" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 команда" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -454,6 +862,12 @@ "1 edit" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 правка" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -465,6 +879,12 @@ "1 finding" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 замечание" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -476,6 +896,12 @@ "1 new message ↓" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 новое сообщение ↓" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -487,6 +913,12 @@ "1 note" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 заметка" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -498,6 +930,12 @@ "1 other tool" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 другой инструмент" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -509,6 +947,12 @@ "1 queued message" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 сообщение в очереди" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -520,6 +964,12 @@ "1 read" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 чтение" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -531,6 +981,12 @@ "1 search" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 поиск" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -542,6 +998,12 @@ "1 tool" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 инструмент" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -553,6 +1015,12 @@ "1 tool call" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 вызов инструмента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -564,6 +1032,12 @@ "1 web request" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 веб-запрос" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -575,6 +1049,12 @@ "30 days" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 дней" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -586,6 +1066,12 @@ "7 days" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 дней" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -597,6 +1083,12 @@ "A note can hold at most %lld attachments" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимум вложений в заметке: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -608,6 +1100,12 @@ "Abort" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прервать" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -619,6 +1117,12 @@ "About" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "О программе" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -630,6 +1134,12 @@ "Access token" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Токен доступа" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -641,6 +1151,12 @@ "Action failed" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Действие не выполнено" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -652,6 +1168,12 @@ "Active" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активна" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -663,6 +1185,12 @@ "Add Hub" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить хаб" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -674,6 +1202,12 @@ "Add Hub…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить хаб…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -685,6 +1219,12 @@ "Add a note…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить примечание…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -696,6 +1236,12 @@ "Add another hub" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить ещё хаб" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -707,6 +1253,12 @@ "Add attachment" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить вложение" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -718,6 +1270,12 @@ "Add photo" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить фото" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -729,6 +1287,12 @@ "Add to send queue" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить в очередь отправки" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -740,6 +1304,12 @@ "After" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "После" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -751,6 +1321,12 @@ "Agent" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Агент" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -762,6 +1338,12 @@ "Agent Mode" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим агента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -773,6 +1355,12 @@ "Agent process" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Процесс агента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -784,6 +1372,12 @@ "Agent: %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Агент: %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -795,6 +1389,12 @@ "All" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Все" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -806,6 +1406,12 @@ "All Machines" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Все машины" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -817,6 +1423,12 @@ "All time" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "За всё время" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -828,6 +1440,12 @@ "Allow" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -839,6 +1457,12 @@ "Allow all edits" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить все правки" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -850,6 +1474,12 @@ "Allow camera access in Settings to scan the pairing QR, or go back and use manual entry." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешите доступ к камере в Настройках, чтобы сканировать QR для сопряжения, или вернитесь и введите данные вручную." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -861,6 +1491,12 @@ "Allow for this session" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить для этой сессии" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -872,6 +1508,12 @@ "Already delivered to the agent" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уже доставлено агенту" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -883,6 +1525,12 @@ "Already handled elsewhere" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уже обработано в другом месте" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -894,6 +1542,12 @@ "Already handled elsewhere." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уже обработано в другом месте." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -905,6 +1559,12 @@ "Already paired with %@ — switched to it." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уже сопряжено с %@ — переключились на него." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -916,6 +1576,12 @@ "Already queued — couldn't remove the draft. Retry removal only." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уже в очереди — не удалось удалить черновик. Повторите только удаление." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -927,6 +1593,12 @@ "Answer every question first" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сначала ответьте на все вопросы" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -938,6 +1610,12 @@ "Answer every required question first" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сначала ответьте на все обязательные вопросы" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -949,6 +1627,12 @@ "Answered" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отвечено" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -960,6 +1644,12 @@ "Answers" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ответы" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -971,6 +1661,12 @@ "App language" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Язык приложения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -982,6 +1678,12 @@ "App version" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия приложения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -993,6 +1695,12 @@ "Appearance" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оформление" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1004,6 +1712,12 @@ "Append to input" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить к вводу" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1015,6 +1729,12 @@ "Applies after the app is relaunched." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Применится после перезапуска приложения." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1026,6 +1746,12 @@ "Apply changes" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Применить изменения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1037,6 +1763,12 @@ "Applying changes…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Применение изменений…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1048,6 +1780,12 @@ "Archive" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Архивировать" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1059,6 +1797,12 @@ "Archive failed: %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось архивировать: %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1070,6 +1814,12 @@ "Attach" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прикрепить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1081,6 +1831,12 @@ "Attachment only" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только вложение" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1092,6 +1848,12 @@ "Attachments are still uploading — wait, or retry/remove the failed ones" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вложения ещё загружаются — подождите либо повторите или удалите неудачные" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1103,6 +1865,12 @@ "Audio recording failed" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка записи звука" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1114,6 +1882,12 @@ "Awaiting approval" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ожидает одобрения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1125,6 +1899,12 @@ "Awaiting response" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ожидание ответа" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1136,6 +1916,12 @@ "Back to chat" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назад в чат" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1147,6 +1933,12 @@ "Back to latest" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "К последним" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1158,6 +1950,12 @@ "Background task" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фоновая задача" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1169,6 +1967,12 @@ "Before" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "До" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1180,6 +1984,12 @@ "Beginning of conversation" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начало разговора" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1191,6 +2001,12 @@ "Binary file not shown" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Двоичный файл не показан" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1202,6 +2018,12 @@ "Binary file — no preview available" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Двоичный файл — предпросмотр недоступен" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1213,6 +2035,12 @@ "Broadcast" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Широковещание" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1224,6 +2052,12 @@ "Browse" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обзор" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1235,6 +2069,12 @@ "By agent" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По агентам" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1246,6 +2086,12 @@ "By model" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По моделям" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1257,6 +2103,12 @@ "Bypass approvals and sandbox" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обойти подтверждения и песочницу" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1268,6 +2120,12 @@ "Cache creation" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создание кеша" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1279,6 +2137,12 @@ "Cache hit rate" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доля попаданий в кеш" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1290,6 +2154,12 @@ "Cache read" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чтение кеша" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1301,6 +2171,12 @@ "Camera" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Камера" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1312,6 +2188,12 @@ "Camera access denied" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступ к камере запрещён" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1323,6 +2205,12 @@ "Cancel" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1334,6 +2222,12 @@ "Changes" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1345,6 +2239,12 @@ "Chat" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чат" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1356,6 +2256,12 @@ "Check the connection to your hub and try again." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверьте подключение к хабу и попробуйте снова." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1367,6 +2273,12 @@ "Check the hub address. HTTPS is recommended, for example https://hub.example.com." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверьте адрес хаба. Рекомендуется HTTPS, например https://hub.example.com." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1378,6 +2290,12 @@ "Checking directory…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверка каталога…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1389,6 +2307,12 @@ "Checking hub…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверка хаба…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1400,6 +2324,12 @@ "Checking installed Agents…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверка установленных агентов…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1411,6 +2341,12 @@ "Choose a session from the sidebar, or start a new one." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите сессию на боковой панели или создайте новую." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1422,6 +2358,12 @@ "Choose Directory" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать каталог" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1433,6 +2375,12 @@ "Clear Filters" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить фильтры" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1444,6 +2392,12 @@ "Close" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1455,6 +2409,12 @@ "Close agent" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть агента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1466,6 +2426,12 @@ "Close details to approve or answer in the conversation." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закройте детали, чтобы одобрить или ответить в разговоре." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1477,6 +2443,12 @@ "Code review" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ревью кода" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1488,6 +2460,12 @@ "Collaboration Mode" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим совместной работы" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1499,6 +2477,12 @@ "Collapsed" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Свёрнуто" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1510,6 +2494,12 @@ "Completed" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершено" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1521,6 +2511,12 @@ "Connecting…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключение…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1532,6 +2528,12 @@ "Connection protocol" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Протокол подключения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1543,6 +2545,12 @@ "Connection: %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключение: %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1554,6 +2562,12 @@ "Continue command" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить команду" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1565,6 +2579,12 @@ "Continue loading history" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить загрузку истории" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1576,6 +2596,12 @@ "Continue planning" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить планирование" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1587,6 +2613,12 @@ "Copy" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1598,6 +2630,12 @@ "Copy Contents" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать содержимое" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1609,6 +2647,12 @@ "Copy Path" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать путь" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1620,6 +2664,12 @@ "Copy full content" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать всё содержимое" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1631,6 +2681,12 @@ "Copy input" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать ввод" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1642,6 +2698,12 @@ "Copy result" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать результат" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1653,6 +2715,12 @@ "Copy text" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать текст" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1664,6 +2732,12 @@ "Could not load this dashboard." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось загрузить эту панель." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1675,6 +2749,12 @@ "Could not reach the hub" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось связаться с хабом" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1686,6 +2766,12 @@ "Could not reach your hub. Open HAPI to respond." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось связаться с вашим хабом. Откройте HAPI, чтобы ответить." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1697,6 +2783,12 @@ "Could not save" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось сохранить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1708,6 +2800,12 @@ "Could not start recording" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось начать запись" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1719,6 +2817,12 @@ "Could not store the push key." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось сохранить ключ push-уведомлений." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1730,6 +2834,12 @@ "Couldn't attach the file — check the hub connection" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось прикрепить файл — проверьте подключение к хабу" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1741,6 +2851,12 @@ "Couldn't delete the note — check the hub connection" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось удалить заметку — проверьте подключение к хабу" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1752,6 +2868,12 @@ "Couldn't load history. Tap to retry." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось загрузить историю. Нажмите, чтобы повторить." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1763,6 +2885,12 @@ "Couldn't load the scratchlist" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось загрузить заметки" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1774,6 +2902,12 @@ "Couldn't load this session" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось загрузить эту сессию" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1785,6 +2919,12 @@ "Couldn't park the draft — check the hub connection" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось отложить черновик — проверьте подключение к хабу" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1796,6 +2936,12 @@ "Couldn't prepare the attachments — retry or remove the failed files" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось подготовить вложения — повторите или удалите неудачные файлы" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1807,6 +2953,12 @@ "Couldn't queue the draft — retry" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось поставить черновик в очередь — повторите" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1818,6 +2970,12 @@ "Couldn't read %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось прочитать %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1829,6 +2987,12 @@ "Couldn't read the selected photo" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось прочитать выбранное фото" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1840,6 +3004,12 @@ "Couldn't read the selected video" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось прочитать выбранное видео" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1851,6 +3021,12 @@ "Couldn't refresh — showing saved drafts" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось обновить — показаны сохранённые черновики" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1862,6 +3038,12 @@ "Couldn't remove the attachment — check the hub connection" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось убрать вложение — проверьте подключение к хабу" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1873,6 +3055,12 @@ "Couldn't save the draft — your changes are kept here" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось сохранить черновик — ваши изменения остались здесь" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1884,6 +3072,12 @@ "Couldn't save the note — check the hub connection" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось сохранить заметку — проверьте подключение к хабу" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1895,6 +3089,12 @@ "Create" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1906,6 +3106,12 @@ "Create Team" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать команду" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1917,6 +3123,12 @@ "Create a new git worktree next to the repo" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать новый git worktree рядом с репозиторием" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1928,6 +3140,12 @@ "Create and Make Directory" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать и создать каталог" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1939,6 +3157,12 @@ "Creating…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создание…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1950,6 +3174,12 @@ "Current Directory" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущий каталог" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1961,6 +3191,12 @@ "Custom answer" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Свой ответ" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1972,6 +3208,12 @@ "Daily tokens" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Токены по дням" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1983,6 +3225,12 @@ "Dark" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тёмная" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1994,6 +3242,12 @@ "Database" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "База данных" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2005,6 +3259,12 @@ "Default" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По умолчанию" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2016,6 +3276,12 @@ "Delete" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2027,6 +3293,12 @@ "Delete Team" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить команду" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2038,6 +3310,12 @@ "Delete this draft?" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить этот черновик?" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2049,6 +3327,12 @@ "Delivery outcome unknown" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Результат доставки неизвестен" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2060,6 +3344,12 @@ "Denied" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отклонено" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2071,6 +3361,12 @@ "Deny" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отклонить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2082,6 +3378,12 @@ "Detached HEAD" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отсоединённый HEAD" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2093,6 +3395,12 @@ "Details" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подробности" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2104,6 +3412,12 @@ "Dictate" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Диктовать" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2115,6 +3429,12 @@ "Diff" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Диф" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2126,6 +3446,12 @@ "Directory" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каталог" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2137,6 +3463,12 @@ "Directory does not exist. Creating the session will create it automatically." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каталог не существует. При создании сессии он будет создан автоматически." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2148,6 +3480,12 @@ "Directory does not exist. Tap Create again to create it automatically." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каталог не существует. Нажмите «Создать» ещё раз, чтобы создать его автоматически." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2159,6 +3497,12 @@ "Directory must be inside one of this machine's workspace roots." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каталог должен находиться внутри одного из корней рабочего пространства этой машины." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2170,6 +3514,12 @@ "Discard changes" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отменить изменения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2181,6 +3531,12 @@ "Discard your changes?" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отменить ваши изменения?" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2192,6 +3548,12 @@ "Disk footprint of the hub's SQLite database (database, write-ahead log, shared memory)." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Место на диске, занимаемое базой SQLite хаба (база, журнал упреждающей записи, общая память)." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2203,6 +3565,12 @@ "Dismiss" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2214,6 +3582,12 @@ "Domain or IP address" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Домен или IP-адрес" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2225,6 +3599,12 @@ "Done" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Готово" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2236,6 +3616,12 @@ "Draft" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Черновик" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2247,6 +3633,12 @@ "Draft actions" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Действия с черновиком" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2258,6 +3650,12 @@ "Draft added to the send queue" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Черновик добавлен в очередь отправки" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2269,6 +3667,12 @@ "Draft no longer available" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Черновик больше недоступен" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2280,6 +3684,12 @@ "Draft parked to scratchlist" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Черновик отложен в заметки" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2291,6 +3701,12 @@ "Draft text" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текст черновика" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2302,6 +3718,12 @@ "Drafts can contain at most 10,000 characters — shorten the text before saving" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Черновик может содержать не более 10 000 символов — сократите текст перед сохранением" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2313,6 +3735,12 @@ "Earlier calls aren't loaded. Close this view and load older messages in the conversation." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Более ранние вызовы не загружены. Закройте этот просмотр и загрузите старые сообщения в разговоре." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2324,6 +3752,12 @@ "Edit" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2335,6 +3769,12 @@ "Edit %lld/%lld" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Правка %lld/%lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2346,6 +3786,12 @@ "Edit draft" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редактировать черновик" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2357,6 +3803,12 @@ "Edit file" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Правка файла" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2368,6 +3820,12 @@ "Edit note" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редактировать заметку" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2379,6 +3837,12 @@ "Edit notebook" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Правка блокнота" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2390,6 +3854,12 @@ "Effort" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Усилие" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2401,6 +3871,12 @@ "Enable in system settings…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить в системных настройках…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2412,6 +3888,12 @@ "Enabled" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включено" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2423,6 +3905,12 @@ "Enter Hub Details" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите данные хаба" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2434,6 +3922,12 @@ "Enter Manually" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ввести вручную" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2445,6 +3939,12 @@ "Enter a valid hub domain or IP address, with an optional port." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите корректный домен или IP-адрес хаба, при необходимости с портом." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2456,6 +3956,12 @@ "Enter the access token printed by your hub." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите токен доступа, напечатанный вашим хабом." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2467,6 +3973,12 @@ "Enter your hub address without the protocol, or paste a full URL or pairing link." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите адрес хаба без протокола либо вставьте полный URL или ссылку для сопряжения." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2478,6 +3990,12 @@ "Error" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2489,6 +4007,12 @@ "Expanded" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Развёрнуто" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2500,6 +4024,12 @@ "Failed to abort" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось прервать" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2511,6 +4041,12 @@ "Failed to browse directories" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось просмотреть каталоги" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2522,6 +4058,12 @@ "Failed to cancel queued message" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось отменить сообщение в очереди" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2533,6 +4075,12 @@ "Failed to check directory" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось проверить каталог" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2544,6 +4092,12 @@ "Failed to check installed Agents" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось проверить установленных агентов" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2555,6 +4109,12 @@ "Failed to create session" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось создать сессию" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2566,6 +4126,12 @@ "Failed to list directory" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось получить список каталога" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2577,6 +4143,12 @@ "Failed to load Codex models" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось загрузить модели Codex" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2588,6 +4160,12 @@ "Failed to load diff" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось загрузить диф" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2599,6 +4177,12 @@ "Failed to load models" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось загрузить модели" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2610,6 +4194,12 @@ "Failed to load models: %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось загрузить модели: %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2621,6 +4211,12 @@ "Failed to load older messages" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось загрузить старые сообщения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2632,6 +4228,12 @@ "Failed to read file" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось прочитать файл" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2643,6 +4245,12 @@ "Failed to search files" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось выполнить поиск файлов" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2654,6 +4262,12 @@ "Failed to steer message" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось направить сообщение" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2665,6 +4279,12 @@ "Failed to synchronize messages" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось синхронизировать сообщения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2676,6 +4296,12 @@ "Failed to update session" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось обновить сессию" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2687,6 +4313,12 @@ "Failed — tap to retry" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка — нажмите, чтобы повторить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2698,6 +4330,12 @@ "Fast" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Быстрый" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2709,6 +4347,12 @@ "Fast Mode" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Быстрый режим" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2720,6 +4364,12 @@ "Fetching the session list from the hub." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получение списка сессий с хаба." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2731,6 +4381,12 @@ "File" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файл" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2742,6 +4398,12 @@ "File actions" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Действия с файлом" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2753,6 +4415,12 @@ "Files" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файлы" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2764,6 +4432,12 @@ "Filters" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фильтры" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2775,6 +4449,12 @@ "Follow system" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как в системе" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2786,6 +4466,12 @@ "Full message" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщение целиком" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2797,6 +4483,12 @@ "Git status unavailable" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус Git недоступен" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2808,6 +4500,12 @@ "Git status unavailable for this session" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус Git недоступен для этой сессии" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2819,6 +4517,12 @@ "HAPI is self-hosted: your agent sessions run on your own machine, served by a hub you operate. Start the hub on your computer, then pair this app with the QR code it prints — or enter the hub URL and access token by hand." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "HAPI размещается самостоятельно: ваши сессии агентов работают на вашей машине за хабом, которым управляете вы. Запустите хаб на компьютере, затем сопрягите это приложение по QR-коду, который он печатает, либо введите URL хаба и токен доступа вручную." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2830,6 +4534,12 @@ "HTTP is unencrypted and may be restricted by iOS. HTTPS is recommended." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "HTTP не шифруется и может блокироваться iOS. Рекомендуется HTTPS." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2841,6 +4551,12 @@ "Held — not sent" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удержано — не отправлено" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2852,6 +4568,12 @@ "Hide note" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть примечание" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2863,6 +4585,12 @@ "High risk" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высокий риск" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2874,6 +4602,12 @@ "Hub" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хаб" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2885,6 +4619,12 @@ "Hub URL" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL хаба" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2896,6 +4636,12 @@ "Hub already paired" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хаб уже сопряжён" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2907,6 +4653,12 @@ "Hub database size on disk" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Размер базы данных хаба на диске" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2918,6 +4670,12 @@ "Hub error" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка хаба" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2929,6 +4687,12 @@ "Hub unreachable" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хаб недоступен" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2940,6 +4704,12 @@ "Hub unreachable — tap to retry." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хаб недоступен — нажмите, чтобы повторить." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2951,6 +4721,12 @@ "Hubs" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хабы" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2962,6 +4738,12 @@ "Image is too large even after compression" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изображение слишком большое даже после сжатия" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2973,6 +4755,12 @@ "Implement plan" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Реализовать план" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2984,6 +4772,12 @@ "Inactive" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неактивна" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2995,6 +4789,12 @@ "Input" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ввод" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3006,6 +4806,12 @@ "Insights" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Аналитика" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3017,6 +4823,12 @@ "Inspecting task log" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Просмотр журнала задачи" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3028,6 +4840,12 @@ "Interrupt agent" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прервать агента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3039,6 +4857,12 @@ "Invalid hub URL" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неверный URL хаба" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3050,6 +4874,12 @@ "Keep an instruction for later…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить инструкцию на потом…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3061,6 +4891,12 @@ "Keep editing" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить редактирование" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3072,6 +4908,12 @@ "Language" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Язык" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3083,6 +4925,12 @@ "Latest tool" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последний инструмент" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3094,6 +4942,12 @@ "Launch Agent" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запуск агента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3105,6 +4959,12 @@ "Light" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Светлая" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3116,6 +4976,12 @@ "Line %lld" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Строка %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3127,6 +4993,12 @@ "List agents" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Список агентов" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3138,6 +5010,12 @@ "List files" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Список файлов" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3149,6 +5027,12 @@ "Live" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Онлайн" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3160,6 +5044,12 @@ "Live updates interrupted — reconnecting…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Живые обновления прерваны — переподключение…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3171,6 +5061,12 @@ "Load more content" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузить больше" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3182,6 +5078,12 @@ "Load older messages" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузить старые сообщения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3193,6 +5095,12 @@ "Loading machines…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка машин…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3204,6 +5112,12 @@ "Loading messages…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка сообщений…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3215,6 +5129,12 @@ "Loading models…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка моделей…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3226,6 +5146,12 @@ "Loading older messages…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка старых сообщений…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3237,6 +5163,12 @@ "Loading sessions…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка сессий…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3248,6 +5180,12 @@ "Loading…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3259,6 +5197,12 @@ "Machine" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Машина" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3270,6 +5214,12 @@ "Machine · %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Машина · %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3281,6 +5231,12 @@ "Machine: %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Машина: %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3292,6 +5248,12 @@ "Managed by agent" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управляется агентом" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3303,6 +5265,12 @@ "Message" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщение" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3314,6 +5282,12 @@ "Message agent" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщение агенту" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3325,6 +5299,12 @@ "Message cancelled — kept your current draft" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщение отменено — текущий черновик сохранён" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3336,6 +5316,12 @@ "Message the agent…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщение агенту…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3347,6 +5333,12 @@ "Message: %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщение: %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3358,6 +5350,12 @@ "Messages will appear here as the agent works." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщения появятся здесь по мере работы агента." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3369,6 +5367,12 @@ "Microphone access is disabled — allow it in Settings to dictate" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступ к микрофону отключён — разрешите его в Настройках для диктовки" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3380,6 +5384,12 @@ "Microphone permission is needed for dictation" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для диктовки нужен доступ к микрофону" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3391,6 +5401,12 @@ "Model" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3402,6 +5418,12 @@ "Model list unavailable for this session." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Список моделей недоступен для этой сессии." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3413,6 +5435,12 @@ "More approval options" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Больше вариантов одобрения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3424,6 +5452,12 @@ "Name needs at least one letter or digit" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имя должно содержать хотя бы одну букву или цифру" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3435,6 +5469,12 @@ "Needs approval" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нужно одобрение" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3446,6 +5486,12 @@ "Needs attention" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требует внимания" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3457,6 +5503,12 @@ "Needs reply" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нужен ответ" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3468,6 +5520,12 @@ "New Session" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новая сессия" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3479,6 +5537,12 @@ "New note" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новая заметка" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3490,6 +5554,12 @@ "Next part" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Следующая часть" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3501,6 +5571,12 @@ "Next question" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Следующий вопрос" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3512,6 +5588,12 @@ "Next tool" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Следующий инструмент" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3523,6 +5605,12 @@ "No HAPI hub answered at this address. Check that the hub is running and that this device can reach it (same network, or the relay tunnel is up)." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По этому адресу не ответил ни один хаб HAPI. Проверьте, что хаб запущен и это устройство может до него добраться (та же сеть или активный туннель реле)." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3534,6 +5622,12 @@ "No agent steps available yet." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Шагов агента пока нет." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3545,6 +5639,12 @@ "No audio was recorded" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Звук не записан" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3556,6 +5656,12 @@ "No changes in the working tree" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В рабочем дереве нет изменений" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3567,6 +5673,12 @@ "No changes in this file" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В этом файле нет изменений" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3578,6 +5690,12 @@ "No drafts yet" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Черновиков пока нет" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3589,6 +5707,12 @@ "No files matched" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файлы не найдены" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3600,6 +5724,12 @@ "No filters" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет фильтров" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3611,6 +5741,12 @@ "No machines online" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет машин онлайн" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3622,6 +5758,12 @@ "No matching directories" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подходящих каталогов нет" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3633,6 +5775,12 @@ "No messages yet" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщений пока нет" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3644,6 +5792,12 @@ "No notes" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет заметок" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3655,6 +5809,12 @@ "No notes yet" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заметок пока нет" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3666,6 +5826,12 @@ "No output" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет вывода" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3677,6 +5843,12 @@ "No sessions yet" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сессий пока нет" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3688,6 +5860,12 @@ "No settings available for this session." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для этой сессии нет доступных настроек." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3699,6 +5877,12 @@ "No storage data reported." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные о хранилище не переданы." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3710,6 +5894,12 @@ "No subdirectories" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет подкаталогов" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3721,6 +5911,12 @@ "No supported Agents are installed on this machine." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "На этой машине не установлено ни одного поддерживаемого агента." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3732,6 +5928,12 @@ "No transcription provider configured on hub" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "На хабе не настроен провайдер транскрипции" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3743,6 +5945,12 @@ "No usage recorded in this period." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "За этот период использование не зафиксировано." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3754,6 +5962,12 @@ "None of the above" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ничего из перечисленного" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3765,6 +5979,12 @@ "Not delivered" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не доставлено" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3776,6 +5996,12 @@ "Not delivered — tap to retry" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не доставлено — нажмите, чтобы повторить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3787,6 +6013,12 @@ "Not requested" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не запрошено" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3798,6 +6030,12 @@ "Not selected" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не выбрано" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3809,6 +6047,12 @@ "Note" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Примечание" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3820,6 +6064,12 @@ "Note, draft, parking-lot idea…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заметка, черновик, идея на потом…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3831,6 +6081,12 @@ "Notifications" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3842,6 +6098,12 @@ "OK" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3853,6 +6115,12 @@ "OLED black" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чёрная OLED" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3864,6 +6132,12 @@ "Offline" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Офлайн" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3875,6 +6149,12 @@ "Offline — showing cached sessions" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Офлайн — показаны кешированные сессии" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3886,6 +6166,12 @@ "Open" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3897,6 +6183,12 @@ "Open Settings" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть Настройки" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3908,6 +6200,12 @@ "Open URL" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть URL" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3919,6 +6217,12 @@ "Open link?" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть ссылку?" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3930,6 +6234,12 @@ "Optional" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Необязательно" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3941,6 +6251,12 @@ "Optionally, add details in notes." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При желании добавьте подробности в примечании." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3952,6 +6268,12 @@ "Options" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Варианты" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3963,6 +6285,12 @@ "Other answer" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Другой ответ" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3974,6 +6302,12 @@ "Other…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Другое…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3985,6 +6319,12 @@ "Output" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вывод" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3996,6 +6336,12 @@ "Pair" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сопрячь" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4007,6 +6353,12 @@ "Pair with your hub" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сопряжение с хабом" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4018,6 +6370,12 @@ "Pairing details filled in. Review them, then tap Pair." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные для сопряжения заполнены. Проверьте их и нажмите «Сопрячь»." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4029,6 +6387,12 @@ "Pairing succeeded but the credentials could not be stored on this device. Try again." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сопряжение удалось, но учётные данные не удалось сохранить на этом устройстве. Попробуйте снова." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4040,6 +6404,12 @@ "Pairing…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сопряжение…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4051,6 +6421,12 @@ "Park current draft" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отложить текущий черновик" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4062,6 +6438,12 @@ "Park drafts from the header, or tap + to jot a note for this session." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Откладывайте черновики из шапки или нажмите +, чтобы записать заметку для этой сессии." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4073,6 +6455,12 @@ "Part %lld" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Часть %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4084,6 +6472,12 @@ "Path" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Путь" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4095,6 +6489,12 @@ "Path is outside workspace roots" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Путь вне корней рабочего пространства" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4106,6 +6506,12 @@ "Paused" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приостановлено" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4117,6 +6523,12 @@ "Permission Mode" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим разрешений" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4128,6 +6540,12 @@ "Permission mode" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим разрешений" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4139,6 +6557,12 @@ "Permission modes control how the agent requests approval. Behavior varies by agent." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режимы разрешений определяют, как агент запрашивает одобрение. Поведение зависит от агента." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4150,6 +6574,12 @@ "Permission request" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запрос разрешения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4161,6 +6591,12 @@ "Permission: %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешение: %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4172,6 +6608,12 @@ "Photo library" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Галерея" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4183,6 +6625,12 @@ "Pin Globally" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрепить глобально" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4194,6 +6642,12 @@ "Pin failed: %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось закрепить: %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4205,6 +6659,12 @@ "Pin to Project" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрепить за проектом" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4216,6 +6676,12 @@ "Pinned" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закреплённые" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4227,6 +6693,12 @@ "Plan" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "План" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4238,6 +6710,12 @@ "Plan proposal" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предложение плана" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4249,6 +6727,12 @@ "Point the camera at the pairing QR from the hub terminal or the web app's Companion Pairing settings." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Наведите камеру на QR-код сопряжения из терминала хаба или из настроек Companion Pairing веб-приложения." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4260,6 +6744,12 @@ "Preview" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предпросмотр" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4271,6 +6761,12 @@ "Preview not available for this image format" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предпросмотр для этого формата изображения недоступен" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4282,6 +6778,12 @@ "Previous part" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предыдущая часть" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4293,6 +6795,12 @@ "Previous question" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предыдущий вопрос" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4304,6 +6812,12 @@ "Previous tool" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предыдущий инструмент" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4315,6 +6829,12 @@ "Printed by the hub at startup, or available in web Settings → Companion Pairing. You can also paste a pairing link here." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Печатается хабом при запуске или доступен в веб-Настройках → Companion Pairing. Здесь также можно вставить ссылку для сопряжения." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4326,6 +6846,12 @@ "Privacy Policy" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Политика конфиденциальности" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4337,6 +6863,12 @@ "Project root" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Корень проекта" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4348,6 +6880,12 @@ "Protocol version" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия протокола" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4359,6 +6897,12 @@ "Pull to retry once you are back online." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Потяните, чтобы повторить, когда вернётесь онлайн." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4370,6 +6914,12 @@ "Push notifications" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Push-уведомления" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4381,6 +6931,12 @@ "Pushes are end-to-end encrypted; only this device can read them." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Push-уведомления защищены сквозным шифрованием; прочитать их может только это устройство." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4392,6 +6948,12 @@ "Query" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запрос" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4403,6 +6965,12 @@ "Question" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вопрос" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4414,6 +6982,12 @@ "Question %lld of %lld" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вопрос %lld из %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4425,6 +6999,12 @@ "Question canceled" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вопрос отменён" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4436,6 +7016,12 @@ "Question failed" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вопрос не выполнен" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4447,6 +7033,12 @@ "Question handled" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вопрос обработан" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4458,6 +7050,12 @@ "Questions & Answers" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вопросы и ответы" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4469,6 +7067,12 @@ "Range" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Диапазон" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4480,6 +7084,12 @@ "Re-register push" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перерегистрировать push" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4491,6 +7101,12 @@ "Read file" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чтение файла" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4502,6 +7118,12 @@ "Read notebook" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чтение блокнота" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4513,6 +7135,12 @@ "Reasoning" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рассуждения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4524,6 +7152,12 @@ "Reasoning Effort" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Усилие рассуждений" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4535,6 +7169,12 @@ "Reasoning…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рассуждения…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4546,6 +7186,12 @@ "Recommended" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендуется" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4557,6 +7203,12 @@ "Reconnecting…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переподключение…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4568,6 +7220,12 @@ "Refresh" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4579,6 +7237,12 @@ "Refreshing…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4590,6 +7254,12 @@ "Registered hubs" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зарегистрированные хабы" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4601,6 +7271,12 @@ "Remove" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4612,6 +7288,12 @@ "Remove %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4623,6 +7305,12 @@ "Removes the stored access token for this hub. Pair again to reconnect." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаляет сохранённый токен доступа для этого хаба. Сопрягите заново, чтобы переподключиться." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4634,6 +7322,12 @@ "Reply" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ответить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4645,6 +7339,12 @@ "Request failed" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запрос не выполнен" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4656,6 +7356,12 @@ "Request was already handled" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запрос уже обработан" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4667,6 +7373,12 @@ "Requests" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запросы" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4678,6 +7390,12 @@ "Result" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Результат" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4689,6 +7407,12 @@ "Result · error" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Результат · ошибка" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4700,6 +7424,12 @@ "Resume agent" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Возобновить агента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4711,6 +7441,12 @@ "Retry" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4722,6 +7458,12 @@ "Retry removal" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторить удаление" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4733,6 +7475,12 @@ "Run script" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запустить скрипт" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4744,6 +7492,12 @@ "Run shell" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запуск оболочки" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4755,6 +7509,12 @@ "Runner last spawn error: %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последняя ошибка запуска раннера: %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4766,6 +7526,12 @@ "Running" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выполняется" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4777,6 +7543,12 @@ "SQLite files" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файлы SQLite" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4788,6 +7560,12 @@ "Save" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4799,6 +7577,12 @@ "Save draft" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить черновик" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4810,6 +7594,12 @@ "Save input, then take draft" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить ввод, затем взять черновик" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4821,6 +7611,12 @@ "Scan Again" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сканировать снова" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4832,6 +7628,12 @@ "Scan QR Code" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сканировать QR-код" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4843,6 +7645,12 @@ "Scan to Pair" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сканировать для сопряжения" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4854,6 +7662,12 @@ "Scanning unavailable" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сканирование недоступно" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4865,6 +7679,12 @@ "Scheduled · %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По расписанию · %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4876,6 +7696,12 @@ "Scratchlist" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заметки" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4887,6 +7713,12 @@ "Scratchlist (%lld)" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заметки (%lld)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4898,6 +7730,12 @@ "Scratchlist is full (200 entries)" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заметки заполнены (200 записей)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4909,6 +7747,12 @@ "Scratchlist is full (200 entries) — delete one first" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заметки заполнены (200 записей) — сначала удалите одну" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4920,6 +7764,12 @@ "Search" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4931,6 +7781,12 @@ "Search content" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск по содержимому" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4942,6 +7798,12 @@ "Search drafts" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск черновиков" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4953,6 +7815,12 @@ "Search files" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск файлов" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4964,6 +7832,12 @@ "Search files…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск файлов…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4975,6 +7849,12 @@ "Searching directories…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск каталогов…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4986,6 +7866,12 @@ "Section" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Раздел" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4997,6 +7883,12 @@ "Select" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5008,6 +7900,12 @@ "Select a session" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите сессию" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5019,6 +7917,12 @@ "Select all that apply" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите все подходящие" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5030,6 +7934,12 @@ "Selected" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрано" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5041,6 +7951,12 @@ "Selected machine is offline" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбранная машина офлайн" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5052,6 +7968,12 @@ "Selected machine is offline. Choose an online machine." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбранная машина офлайн. Выберите машину онлайн." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5063,6 +7985,12 @@ "Send" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5074,6 +8002,12 @@ "Send & steer into current turn" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить и вклиниться в текущий ход" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5085,6 +8019,12 @@ "Send — long-press to steer" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить — долгое нажатие, чтобы направить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5096,6 +8036,12 @@ "Session Settings" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки сессии" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5107,6 +8053,12 @@ "Session Type" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип сессии" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5118,6 +8070,12 @@ "Session files" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файлы сессии" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5129,6 +8087,12 @@ "Session is controlled from the terminal — config changes will be rejected." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сессией управляют из терминала — изменения конфигурации будут отклонены." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5140,6 +8104,12 @@ "Session is inactive and could not be resumed" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сессия неактивна, и её не удалось возобновить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5151,6 +8121,12 @@ "Session is offline — changes apply after it resumes or may be rejected." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сессия офлайн — изменения применятся после возобновления или могут быть отклонены." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5162,6 +8138,12 @@ "Session not found on any paired hub." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сессия не найдена ни на одном сопряжённом хабе." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5173,6 +8155,12 @@ "Session settings" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки сессии" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5184,6 +8172,12 @@ "Session type" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип сессии" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5195,6 +8189,12 @@ "Sessions" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сессии" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5206,6 +8206,12 @@ "Sessions with usage: %lld" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сессий с использованием: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5217,6 +8223,12 @@ "Settings" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5228,6 +8240,12 @@ "Setup guide" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Руководство по настройке" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5239,6 +8257,12 @@ "Share file" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться файлом" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5250,6 +8274,12 @@ "Shared memory" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Общая память" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5261,6 +8291,12 @@ "Show Hidden" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать скрытые" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5272,6 +8308,12 @@ "Show all %lld lines" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать все строки: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5283,6 +8325,12 @@ "Show hidden files" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать скрытые файлы" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5294,6 +8342,12 @@ "Show image" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать изображение" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5305,6 +8359,12 @@ "Show video" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать видео" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5316,6 +8376,12 @@ "Showing %lld of %lld parts" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показано %lld из %lld частей" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5327,6 +8393,12 @@ "Showing the last available group. Live group updates are unavailable." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показана последняя доступная группа. Живые обновления группы недоступны." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5338,6 +8410,12 @@ "Showing the last available record. Live updates are unavailable for this tool." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показана последняя доступная запись. Живые обновления для этого инструмента недоступны." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5349,6 +8427,12 @@ "Shutdown Response" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ответ на завершение" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5360,6 +8444,12 @@ "Shutdown: %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершение: %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5371,6 +8461,12 @@ "Sign Out" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выйти" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5382,6 +8478,12 @@ "Sign Out…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выйти…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5393,6 +8495,12 @@ "Sign out of %@?" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выйти из %@?" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5404,6 +8512,12 @@ "Signed out of %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы вышли из %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5415,6 +8529,12 @@ "Simple" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обычная" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5426,6 +8546,12 @@ "Source" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Исходник" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5437,6 +8563,12 @@ "Spawn agent" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запустить агента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5448,6 +8580,12 @@ "Staged" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В индексе" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5459,6 +8597,12 @@ "Staged (%lld)" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В индексе (%lld)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5470,6 +8614,12 @@ "Standard" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стандартный" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5481,6 +8631,12 @@ "Start an agent with the hapi CLI and it will appear here." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запустите агента через CLI hapi, и он появится здесь." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5492,6 +8648,12 @@ "Status: %@ · protocol v%lld" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус: %@ · протокол v%lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5503,6 +8665,12 @@ "Steer" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Направить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5514,6 +8682,12 @@ "Stop dictation" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить диктовку" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5525,6 +8699,12 @@ "Stop the current turn" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить текущий ход" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5536,6 +8716,12 @@ "Storage" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хранилище" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5547,6 +8733,12 @@ "Subdirectories" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подкаталоги" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5558,6 +8750,12 @@ "Submit" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5569,6 +8767,12 @@ "Submit answer" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить ответ" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5580,6 +8784,12 @@ "Submitting answer…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправка ответа…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5591,6 +8801,12 @@ "Submitting approval…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправка одобрения…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5602,6 +8818,12 @@ "Take draft" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Взять черновик" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5613,6 +8835,12 @@ "Team: %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Команда: %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5624,6 +8852,12 @@ "Terminal" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Терминал" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5635,6 +8869,12 @@ "That code is not a HAPI pairing QR — scan one printed by the hub." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот код не является QR-кодом сопряжения HAPI — отсканируйте напечатанный хабом." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5646,6 +8886,12 @@ "That file is over the %lld MB limit" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот файл превышает лимит %lld МБ" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5657,6 +8903,12 @@ "That file is over the hub's per-file size limit" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот файл превышает лимит размера файла на хабе" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5668,6 +8920,12 @@ "That file type isn't allowed for scratchlist attachments" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот тип файла не разрешён для вложений в заметки" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5679,6 +8937,12 @@ "The app checks the hub is reachable, then exchanges the token for a session." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приложение проверяет доступность хаба, затем обменивает токен на сессию." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5690,6 +8954,12 @@ "The attachments exceed the scratchlist limits — remove a file or choose a smaller one" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вложения превышают лимиты заметок — удалите файл или выберите меньший" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5701,6 +8971,12 @@ "The hub answered unexpectedly (HTTP %lld). Try again in a moment." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хаб ответил неожиданно (HTTP %lld). Попробуйте снова через мгновение." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5712,6 +8988,12 @@ "The hub rejected the stored credentials — the access token was probably rotated. Pair again to reconnect." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хаб отклонил сохранённые учётные данные — вероятно, токен доступа был обновлён. Сопрягите заново, чтобы переподключиться." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5723,6 +9005,12 @@ "The hub rejected this access token — it may have been rotated. Get a fresh pairing code from the hub and try again." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хаб отклонил этот токен доступа — возможно, он был обновлён. Получите свежий код сопряжения с хаба и попробуйте снова." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5734,6 +9022,12 @@ "The hub speaks protocol v%lld, this app supports v%lld. Update the older side, then try again." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хаб использует протокол v%lld, а это приложение поддерживает v%lld. Обновите более старую сторону и попробуйте снова." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5745,6 +9039,12 @@ "The selected Agent is not available on this machine." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбранный агент недоступен на этой машине." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5756,6 +9056,12 @@ "The session is no longer active." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сессия больше не активна." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5767,6 +9073,12 @@ "Theme" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5778,6 +9090,12 @@ "Thinking" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Думает" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5789,6 +9107,12 @@ "This agent manages its own permissions. YOLO mode is not available." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот агент сам управляет разрешениями. Режим YOLO недоступен." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5800,6 +9124,12 @@ "This dashboard is only available to the hub owner (default namespace)." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эта панель доступна только владельцу хаба (пространство имён по умолчанию)." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5811,6 +9141,12 @@ "This device cannot scan QR codes (for example the Simulator). Go back and use manual entry instead." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это устройство не может сканировать QR-коды (например, симулятор). Вернитесь и введите данные вручную." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5822,6 +9158,12 @@ "This file is empty" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот файл пуст" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5833,6 +9175,12 @@ "This hub will be added to your paired hubs and become active." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот хаб будет добавлен к сопряжённым и станет активным." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5844,6 +9192,12 @@ "This note's attachment budget is used up" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Бюджет вложений этой заметки исчерпан" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5855,6 +9209,12 @@ "This pairing link is invalid or incomplete. Copy the full link from your hub." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эта ссылка для сопряжения недействительна или неполна. Скопируйте полную ссылку с хаба." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5866,6 +9226,12 @@ "This question could not be displayed. Open details to inspect the request." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось показать этот вопрос. Откройте детали, чтобы изучить запрос." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5877,6 +9243,12 @@ "To composer" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В поле ввода" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5888,6 +9260,12 @@ "Todo list" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Список задач" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5899,6 +9277,12 @@ "Token rejected" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Токен отклонён" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5910,6 +9294,12 @@ "Token usage across agents and models" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использование токенов по агентам и моделям" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5921,6 +9311,12 @@ "Tool %lld of %lld" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инструмент %lld из %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5932,6 +9328,12 @@ "Tool details" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Детали инструмента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5943,6 +9345,12 @@ "Total" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Итого" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5954,6 +9362,12 @@ "Total tokens" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего токенов" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5965,6 +9379,12 @@ "Transcription failed" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка транскрипции" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5976,6 +9396,12 @@ "Try Again" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5987,6 +9413,12 @@ "Type an answer first" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сначала введите ответ" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5998,6 +9430,12 @@ "Type to search files in the session directory" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начните вводить текст для поиска файлов в каталоге сессии" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6009,6 +9447,12 @@ "Type your answer…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите ответ…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6020,6 +9464,12 @@ "Uncached" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Без кеша" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6031,6 +9481,12 @@ "Unknown machine" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестная машина" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6042,6 +9498,12 @@ "Unpin" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открепить" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6053,6 +9515,12 @@ "Unread" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Непрочитано" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6064,6 +9532,12 @@ "Unstaged" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вне индекса" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6075,6 +9549,12 @@ "Unstaged (%lld)" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вне индекса (%lld)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6086,6 +9566,12 @@ "Up" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вверх" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6097,6 +9583,12 @@ "Upgrade and restart this machine's HAPI runner before creating sessions." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновите и перезапустите раннер HAPI этой машины перед созданием сессий." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6108,6 +9600,12 @@ "Upload failed — check the hub connection" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка загрузки — проверьте подключение к хабу" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6119,6 +9617,12 @@ "Uploading…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6130,6 +9634,12 @@ "Usage" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использование" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6141,6 +9651,12 @@ "Use selected directory as-is" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать выбранный каталог как есть" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6152,6 +9668,12 @@ "Uses dangerous agent flags when spawning." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использует опасные флаги агента при запуске." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6163,6 +9685,12 @@ "Uses dangerous agent flags when spawning. Applies native %@ mode." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использует опасные флаги агента при запуске. Применяет нативный режим %@." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6174,6 +9702,12 @@ "Version mismatch" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Несовпадение версий" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6185,6 +9719,12 @@ "View agent process" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать процесс агента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6196,6 +9736,12 @@ "View all" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать все" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6207,6 +9753,12 @@ "View current file" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать текущий файл" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6218,6 +9770,12 @@ "View full input" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать полный ввод" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6229,6 +9787,12 @@ "View full message" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать сообщение целиком" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6240,6 +9804,12 @@ "View full questions and answers" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать все вопросы и ответы" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6251,6 +9821,12 @@ "View image" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать изображение" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6262,6 +9838,36 @@ "View process · %lld steps" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать процесс · %lld шаг" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать процесс · %lld шага" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать процесс · %lld шагов" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать процесс · %lld шага" + } + } + } + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6273,6 +9879,12 @@ "View the full draft" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать черновик целиком" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6284,6 +9896,12 @@ "View tool details" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать детали инструмента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6295,6 +9913,12 @@ "View tool group" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать группу инструментов" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6306,6 +9930,12 @@ "Wait for agent" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ждать агента" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6317,6 +9947,12 @@ "Wait for result" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ждать результат" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6328,6 +9964,12 @@ "Waiting for output…" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ожидание вывода…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6339,6 +9981,12 @@ "Web fetch" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Веб-запрос" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6350,6 +9998,12 @@ "Web search" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Веб-поиск" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6361,6 +10015,12 @@ "Workspace Roots" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Корни рабочего пространства" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6372,6 +10032,12 @@ "Workspace file" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файл рабочего пространства" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6383,6 +10049,12 @@ "Worktree" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Worktree" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6394,6 +10066,12 @@ "Worktree sessions require an existing repository directory." : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для сессий worktree нужен существующий каталог репозитория." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6405,6 +10083,12 @@ "Write draft" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Написать черновик" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6416,6 +10100,12 @@ "Write file" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запись файла" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6427,6 +10117,12 @@ "Write-ahead log" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал упреждающей записи" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6438,6 +10134,12 @@ "You already have a draft" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "У вас уже есть черновик" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6449,6 +10151,12 @@ "Your answer" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш ответ" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6460,6 +10168,12 @@ "Your input changed — review it and try again" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш ввод изменился — проверьте его и попробуйте снова" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6471,6 +10185,12 @@ "error" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ошибка" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6482,6 +10202,12 @@ "feature-x (optional)" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "feature-x (необязательно)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6493,6 +10219,12 @@ "now" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сейчас" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6504,6 +10236,12 @@ "pending" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ожидает" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6515,6 +10253,12 @@ "— Canceled" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "— Отменено" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6526,6 +10270,12 @@ "● Recording… %@" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "● Запись… %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6537,6 +10287,12 @@ "✓ Approved" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ Одобрено" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6548,6 +10304,12 @@ "✕ Denied" : { "extractionState" : "manual", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "✕ Отклонено" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", diff --git a/ios/Packages/HapiKit/Sources/HapiClient/Settings/SettingsPrefs.swift b/ios/Packages/HapiKit/Sources/HapiClient/Settings/SettingsPrefs.swift index 932d84876d..b6608f037e 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/Settings/SettingsPrefs.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/Settings/SettingsPrefs.swift @@ -47,6 +47,7 @@ public enum AppLanguage: String, CaseIterable, Sendable { case system = "system" case english = "en" case simplifiedChinese = "zh-Hans" + case russian = "ru" /// Unknown/corrupt/absent stored values degrade to ``system``. public init(storageKey: String?) { @@ -63,6 +64,7 @@ public enum AppLanguage: String, CaseIterable, Sendable { case .system: return "Follow system" case .english: return "English" case .simplifiedChinese: return "简体中文" + case .russian: return "Русский" } } } diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/Settings/ThemePrefsTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/Settings/ThemePrefsTests.swift index e849e6d85d..00d7647105 100644 --- a/ios/Packages/HapiKit/Tests/HapiClientTests/Settings/ThemePrefsTests.swift +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/Settings/ThemePrefsTests.swift @@ -47,6 +47,7 @@ struct ThemePrefsTests { #expect(AppLanguage.system.storageKey == "system") #expect(AppLanguage.english.storageKey == "en") #expect(AppLanguage.simplifiedChinese.storageKey == "zh-Hans") + #expect(AppLanguage.russian.storageKey == "ru") } // MARK: - Resolution @@ -76,6 +77,9 @@ struct ThemePrefsTests { LanguagePrefs(defaults: defaults).setLanguage(.english) #expect(LanguagePrefs(defaults: defaults).language == .english) + + LanguagePrefs(defaults: defaults).setLanguage(.russian) + #expect(LanguagePrefs(defaults: defaults).language == .russian) } @Test func corruptStoredLanguageDegradesToFollowSystem() throws { @@ -89,6 +93,7 @@ struct ThemePrefsTests { @Test func languageNamesAreShownInTheirOwnLanguage() { #expect(AppLanguage.english.displayName == "English") #expect(AppLanguage.simplifiedChinese.displayName == "简体中文") + #expect(AppLanguage.russian.displayName == "Русский") // The follow-system row's label is localized at the app layer; the // package carries the English source string. #expect(AppLanguage.system.displayName == "Follow system") From 0ef5f976c0a183e4b0f463aae334cc4cd08c6cb0 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Mon, 5 Oct 2026 18:13:14 +0900 Subject: [PATCH 04/99] fix(web): render Codex goal responses as cards (#1954) --- .../messages/SystemMessage.test.tsx | 76 ++++++++++ .../AssistantChat/messages/SystemMessage.tsx | 31 +++- web/src/components/ToolCard/ToolCard.tsx | 2 +- web/src/components/ToolCard/goalTools.test.ts | 107 ++++++++++++++ web/src/components/ToolCard/goalTools.ts | 110 ++++++++++++++ web/src/components/ToolCard/knownTools.tsx | 18 ++- .../ToolCard/views/GoalView.test.tsx | 137 ++++++++++++++++++ .../components/ToolCard/views/GoalView.tsx | 106 ++++++++++++++ web/src/components/ToolCard/views/_all.tsx | 7 +- .../components/ToolCard/views/_results.tsx | 9 ++ web/src/lib/locales/en.ts | 14 ++ web/src/lib/locales/zh-CN.ts | 14 ++ 12 files changed, 623 insertions(+), 8 deletions(-) create mode 100644 web/src/components/AssistantChat/messages/SystemMessage.test.tsx create mode 100644 web/src/components/ToolCard/goalTools.test.ts create mode 100644 web/src/components/ToolCard/goalTools.ts create mode 100644 web/src/components/ToolCard/views/GoalView.test.tsx create mode 100644 web/src/components/ToolCard/views/GoalView.tsx diff --git a/web/src/components/AssistantChat/messages/SystemMessage.test.tsx b/web/src/components/AssistantChat/messages/SystemMessage.test.tsx new file mode 100644 index 0000000000..80652b47de --- /dev/null +++ b/web/src/components/AssistantChat/messages/SystemMessage.test.tsx @@ -0,0 +1,76 @@ +import type { HTMLAttributes } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { HappySystemMessage } from './SystemMessage' + +const { message } = vi.hoisted(() => ({ + message: { + role: 'system', id: 'status-example', + content: [] as { type: string; text: string }[], + metadata: { custom: {} as Record } + } +})) +vi.mock('@assistant-ui/react', () => ({ + useAuiState: (selector: (state: { message: typeof message }) => unknown) => selector({ message }), + MessagePrimitive: { Root: (props: HTMLAttributes) =>
} +})) +vi.mock('./MessageTimestamp', () => ({ MessageTimestamp: () => 12:00 })) + +const goal = { + threadId: 'thread-example-1', objective: 'Compare the options and validate the chosen approach.', + status: 'active', tokenBudget: null, tokensUsed: 0, timeUsedSeconds: 0, + createdAt: 1704067200, updatedAt: 1704067200 +} +function show(text: string, eventType = 'message', kind = 'event') { + message.content = [{ type: 'text', text }] + message.metadata.custom = { kind, event: { type: eventType, message: text } } + return render() +} + +beforeEach(() => { message.role = 'system' }) + +describe('slash-command goal status messages', () => { + it('renders the actual event/message response as a goal snapshot card', () => { + const raw = JSON.stringify({ goal }) + const { container } = show(raw) + expect(screen.getByText('Goal')).toBeInTheDocument() + expect(screen.getByText(goal.objective)).toBeInTheDocument() + expect(screen.getByText('Active')).toBeInTheDocument() + expect(screen.getByText('0 tokens')).toBeInTheDocument() + expect(screen.getByText('No limit')).toBeInTheDocument() + expect(screen.getByText('0.0s')).toBeInTheDocument() + expect(container.querySelector('[data-goal-status="active"]')).not.toBeNull() + const details = screen.getByText('Raw JSON').closest('details')! + expect(details.open).toBe(false) + fireEvent.click(screen.getByText('Raw JSON')) + expect(details.open).toBe(true) + expect(details.textContent).toContain('thread-example-1') + expect(details.textContent).toContain(raw) + }) + + it('shows an explicitly empty goal', () => { + show('{"goal":null}') + expect(screen.getByText('No goal set')).toBeInTheDocument() + }) + + it.each(['Goal cleared', '{broken', '{"objective":"ordinary data"}', JSON.stringify({ goal, error: 'denied' })]) + ('preserves ordinary and malformed status text', (text) => { + const { container } = show(text) + expect(screen.getByText(text)).toBeInTheDocument() + expect(container.querySelector('[data-goal-status]')).toBeNull() + }) + + it.each([['error', 'event'], ['message', 'other']])('only formats confirmed message events', (eventType, kind) => { + const raw = JSON.stringify({ goal }) + const { container } = show(raw, eventType, kind) + expect(screen.getByText(raw)).toBeInTheDocument() + expect(container.querySelector('[data-goal-status]')).toBeNull() + }) + + it('does not format ordinary assistant content', () => { + message.role = 'assistant' + const { container } = show(JSON.stringify({ goal })) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/web/src/components/AssistantChat/messages/SystemMessage.tsx b/web/src/components/AssistantChat/messages/SystemMessage.tsx index 5bc0dd3cce..ff4321c0a4 100644 --- a/web/src/components/AssistantChat/messages/SystemMessage.tsx +++ b/web/src/components/AssistantChat/messages/SystemMessage.tsx @@ -5,6 +5,10 @@ import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' import { getConversationMessageAnchorId } from '@/chat/outline' import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp' import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { CodeBlock } from '@/components/CodeBlock' +import { parseGoalStatusMessage } from '@/components/ToolCard/goalTools' +import { GoalSummary } from '@/components/ToolCard/views/GoalView' +import { useTranslation } from '@/lib/use-translation' function formatTokenDelta(event: AgentEvent | undefined): string | null { if (!event || event.type !== 'compact-summary') return null @@ -16,17 +20,17 @@ function formatTokenDelta(event: AgentEvent | undefined): string | null { } export function HappySystemMessage() { + const { t } = useTranslation() const role = useAuiState((s) => s.message.role) const messageId = useAuiState((s) => s.message.id) const text = useAuiState((s) => { if (s.message.role !== 'system') return '' return s.message.content[0]?.type === 'text' ? s.message.content[0].text : '' }) - const icon = useAuiState((s) => { + const event = useAuiState((s) => { if (s.message.role !== 'system') return null const custom = s.message.metadata.custom as Partial | undefined - const event = custom?.kind === 'event' ? custom.event : undefined - return event ? getEventPresentation(event).icon : null + return custom?.kind === 'event' ? custom.event : undefined }) const compactSummary = useAuiState((s) => { if (s.message.role !== 'system') return undefined @@ -36,6 +40,27 @@ export function HappySystemMessage() { if (role !== 'system') return null + const icon = event ? getEventPresentation(event).icon : null + const goalMessage = event?.type === 'message' && typeof event.message === 'string' ? event.message : null + const goal = goalMessage !== null ? parseGoalStatusMessage(goalMessage) : null + if (goal && goalMessage !== null) { + return ( + +
+
+ {t('session.status.goal')} + +
+ +
+ {t('tool.goal.raw')} + +
+
+
+ ) + } + // Pi compaction summaries are real content, not status: render them as an // independent block (header with token delta + the summary markdown) // instead of the tiny centered status line used for other events. diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index d2c398d05b..c4c0098738 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -436,7 +436,7 @@ function ToolCardInner(props: ToolCardProps) { const isCodexAgentCard = toolName === 'CodexAgent' const useCompactTerminalCard = shouldUseCompactTerminalToolCard(toolName, props.terminalToolDisplayMode) const showInline = shouldShowInlineToolCardBody(toolName, presentation.minimal, props.terminalToolDisplayMode) - const CompactToolView = showInline ? getToolViewComponent(toolName) : null + const CompactToolView = showInline ? getToolViewComponent(toolName, props.block.tool) : null const compactViewOwnsInteractions = toolName === 'CodexDiff' const ResultToolView = getToolResultViewComponent(toolName) const permission = props.block.tool.permission diff --git a/web/src/components/ToolCard/goalTools.test.ts b/web/src/components/ToolCard/goalTools.test.ts new file mode 100644 index 0000000000..56f35b7cce --- /dev/null +++ b/web/src/components/ToolCard/goalTools.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { isGoalToolName, parseGoalToolResult } from './goalTools' + +const goal = { + threadId: 'thread-1', + objective: 'Compare the options and validate the chosen approach.', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1704067200, + updatedAt: 1704067200 +} + +describe('goal tool results', () => { + it.each([ + { goal }, + JSON.stringify({ goal }), + { output: JSON.stringify({ goal }) }, + { result: { content: [{ type: 'text', text: JSON.stringify({ goal }) }] } }, + [{ type: 'text', text: JSON.stringify({ goal }) }], + goal + ])('reads the goal snapshot from supported envelopes', (result) => { + expect(parseGoalToolResult(result)).toEqual({ kind: 'goal', goal }) + }) + + it('keeps an explicit empty goal distinct from missing results', () => { + expect(parseGoalToolResult({ goal: null })).toEqual({ kind: 'empty' }) + expect(parseGoalToolResult(undefined)).toBeNull() + expect(parseGoalToolResult(null)).toBeNull() + }) + + it.each([ + '{broken', + 'Output:\n' + JSON.stringify({ goal }), + { data: { goal } }, + { goal: { status: 'active' } }, + { goal: { objective: ' ', status: 'active' } }, + { isError: true, content: [{ type: 'text', text: JSON.stringify({ goal }) }] }, + { error: 'denied', goal }, + { result: { result: { result: { result: { result: { goal } } } } } } + ])('leaves malformed, error and unrelated results to the generic renderer', (result) => { + expect(parseGoalToolResult(result)).toBeNull() + }) + + it('normalizes named snake_case fields and preserves zero and null', () => { + expect(parseGoalToolResult({ goal: { + objective: goal.objective, status: 'futureState', thread_id: 'thread-2', + token_budget: null, tokens_used: 0, time_used_seconds: 0, + created_at: 1704067200 + } })).toEqual({ kind: 'goal', goal: { + objective: goal.objective, status: 'futureState', threadId: 'thread-2', + tokenBudget: null, tokensUsed: 0, timeUsedSeconds: 0, createdAt: 1704067200 + } }) + }) + + it('does not invent missing counters or display invalid numeric fields', () => { + expect(parseGoalToolResult({ goal: { + objective: 'ship', status: 'active', tokensUsed: -1, + tokenBudget: Infinity, timeUsedSeconds: NaN, updatedAt: -2 + } })).toEqual({ kind: 'goal', goal: { objective: 'ship', status: 'active' } }) + expect(parseGoalToolResult({ goal: { objective: 'ship', status: 'active' } })) + .toEqual({ kind: 'goal', goal: { objective: 'ship', status: 'active' } }) + }) + + it('does not substitute snake_case values for explicit camelCase null', () => { + expect(parseGoalToolResult({ goal: { ...goal, token_budget: 100 } })) + .toEqual({ kind: 'goal', goal }) + }) + + it('stops at the nesting limit and handles circular objects', () => { + const cycle: { result?: unknown } = {} + cycle.result = cycle + expect(parseGoalToolResult(cycle)).toBeNull() + }) +}) + +describe('goal tool names', () => { + it.each(['create_goal', 'get_goal', 'update_goal', 'functions.create_goal', 'functions.get_goal', 'functions.update_goal']) + ('recognizes %s', (name) => expect(isGoalToolName(name)).toBe(true)) + + it.each(['mcp__other__create_goal', 'other.create_goal', 'recreate_goal', 'functions.functions.create_goal', 'Create_Goal', 'create_goal_extra']) + ('does not claim unrelated tool %s', (name) => expect(isGoalToolName(name)).toBe(false)) +}) + + +describe('goal status messages', () => { + it('recognizes the complete slash-command response', async () => { + const { parseGoalStatusMessage } = await import('./goalTools') + expect(parseGoalStatusMessage(JSON.stringify({ goal }))).toEqual({ kind: 'goal', goal }) + expect(parseGoalStatusMessage('{"goal":null}')).toEqual({ kind: 'empty' }) + }) + + it.each([ + JSON.stringify(goal), + JSON.stringify({ goal, error: 'denied' }), + JSON.stringify({ result: { goal } }), + JSON.stringify({ goal, unrelated: true }), + JSON.stringify({ goal: { objective: 'incomplete' } }), + '```json\n' + JSON.stringify({ goal }) + '\n```', + 'Goal result: ' + JSON.stringify({ goal }), + '{broken' + ])('leaves unrelated and malformed status messages unchanged', async (message) => { + const { parseGoalStatusMessage } = await import('./goalTools') + expect(parseGoalStatusMessage(message)).toBeNull() + }) +}) diff --git a/web/src/components/ToolCard/goalTools.ts b/web/src/components/ToolCard/goalTools.ts new file mode 100644 index 0000000000..b393736404 --- /dev/null +++ b/web/src/components/ToolCard/goalTools.ts @@ -0,0 +1,110 @@ +import { isObject } from '@hapi/protocol' +import type { ChatToolCall } from '@/chat/types' + +const GOAL_TOOL_NAMES = ['create_goal', 'get_goal', 'update_goal'] as const +export type GoalToolName = typeof GOAL_TOOL_NAMES[number] + +export function getGoalToolName(name: string): GoalToolName | null { + const candidate = name.startsWith('functions.') ? name.slice('functions.'.length) : name + return (GOAL_TOOL_NAMES as readonly string[]).includes(candidate) ? candidate as GoalToolName : null +} + +export function isGoalToolName(name: string): boolean { + return getGoalToolName(name) !== null +} + +export type GoalSnapshot = { + objective: string + status: string + threadId?: string + tokenBudget?: number | null + tokensUsed?: number + timeUsedSeconds?: number + createdAt?: number + updatedAt?: number +} + +export type GoalResult = { kind: 'goal'; goal: GoalSnapshot } | { kind: 'empty' } + +function readField(record: Record, camel: string, snake: string): unknown { + return Object.prototype.hasOwnProperty.call(record, camel) ? record[camel] : record[snake] +} + +function parseSnapshot(value: unknown): GoalSnapshot | null { + if (!isObject(value) || typeof value.objective !== 'string' || !value.objective.trim() + || typeof value.status !== 'string' || !value.status.trim()) return null + + const goal: GoalSnapshot = { objective: value.objective, status: value.status } + const threadId = readField(value, 'threadId', 'thread_id') + if (typeof threadId === 'string' && threadId.trim()) goal.threadId = threadId + + const fields = [ + ['tokenBudget', 'token_budget'], ['tokensUsed', 'tokens_used'], + ['timeUsedSeconds', 'time_used_seconds'], ['createdAt', 'created_at'], ['updatedAt', 'updated_at'] + ] as const + for (const [camel, snake] of fields) { + const number = readField(value, camel, snake) + if (camel === 'tokenBudget' && number === null) goal.tokenBudget = null + else if (typeof number === 'number' && Number.isFinite(number) && number >= 0) goal[camel] = number + } + return goal +} + +/** Only unwrap documented result/text containers; arbitrary objects are not searched. */ +export function parseGoalToolResult(result: unknown, depth = 0): GoalResult | null { + if (depth > 4) return null + if (typeof result === 'string') { + const text = result.trim() + if (!text.startsWith('{') && !text.startsWith('[')) return null + try { + return parseGoalToolResult(JSON.parse(text) as unknown, depth) + } catch { + return null + } + } + if (Array.isArray(result)) { + if (result.length === 0 || !result.every((block) => isObject(block) + && block.type === 'text' && typeof block.text === 'string')) return null + return parseGoalToolResult(result.map((block) => block.text).join('\n'), depth + 1) + } + if (!isObject(result) || result.isError === true || result.is_error === true + || (result.error !== undefined && result.error !== null)) return null + + if (Object.prototype.hasOwnProperty.call(result, 'goal')) { + if (result.goal === null) return { kind: 'empty' } + const goal = parseSnapshot(result.goal) + return goal ? { kind: 'goal', goal } : null + } + const goal = parseSnapshot(result) + if (goal) return { kind: 'goal', goal } + for (const key of ['result', 'output', 'content', 'text']) { + if (Object.prototype.hasOwnProperty.call(result, key)) { + return parseGoalToolResult(result[key], depth + 1) + } + } + return null +} + +/** The shared Codex /goal command emits a standalone {goal} status message. */ +export function parseGoalStatusMessage(message: string): GoalResult | null { + try { + const value: unknown = JSON.parse(message) + if (!isObject(value) || Object.keys(value).length !== 1 + || !Object.prototype.hasOwnProperty.call(value, 'goal')) return null + return parseGoalToolResult(value) + } catch { + return null + } +} + +export function getPendingGoalRequest(input: unknown): { kind: 'objective' | 'status'; text: string } | null { + if (!isObject(input)) return null + if (typeof input.objective === 'string' && input.objective.trim()) return { kind: 'objective', text: input.objective } + return typeof input.status === 'string' && input.status.trim() ? { kind: 'status', text: input.status } : null +} + +export function canShowGoalPreview(tool: Pick): boolean { + if (tool.state === 'error') return false + if (tool.state === 'completed') return parseGoalToolResult(tool.result) !== null + return getPendingGoalRequest(tool.input) !== null +} diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index a1cde754e1..6139d7569d 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -6,6 +6,7 @@ import type { ChecklistItem } from '@/components/ToolCard/checklist' import { extractTodoChecklist, extractUpdatePlanChecklist } from '@/components/ToolCard/checklist' import { basename, resolveDisplayPath } from '@/utils/path' import { getInputStringAny, truncate } from '@/lib/toolInputUtils' +import { getGoalToolName } from '@/components/ToolCard/goalTools' import { getCodexAgentActivity, getCodexAgentPrompt, @@ -125,6 +126,18 @@ export const knownTools: Record string | null minimal?: boolean | ((opts: ToolOpts) => boolean) }> = { + create_goal: { + icon: () => , + title: () => 'Create goal' + }, + get_goal: { + icon: () => , + title: () => 'Get goal' + }, + update_goal: { + icon: () => , + title: () => 'Update goal' + }, // agy transitional "Inside the task-NNN log…" narration → compact chip. // description carries the short "task-NNN log" label from normalizeAgent. AgyTaskLog: { @@ -664,12 +677,13 @@ export function getToolPresentation( } } - const known = knownTools[opts.toolName] + const goalName = getGoalToolName(opts.toolName) + const known = knownTools[goalName ?? opts.toolName] if (known) { const minimal = typeof known.minimal === 'function' ? known.minimal(opts) : (known.minimal ?? false) return { icon: known.icon(opts), - title: known.title(opts), + title: goalName && t ? t(`tool.goal.${goalName}`) : known.title(opts), subtitle: known.subtitle ? known.subtitle(opts) : null, minimal } diff --git a/web/src/components/ToolCard/views/GoalView.test.tsx b/web/src/components/ToolCard/views/GoalView.test.tsx new file mode 100644 index 0000000000..179d8af8d8 --- /dev/null +++ b/web/src/components/ToolCard/views/GoalView.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import type { ApiClient } from '@/api/client' +import type { ToolCallBlock } from '@/chat/types' +import { ToolCard } from '@/components/ToolCard/ToolCard' +import { getToolResultViewComponent } from '@/components/ToolCard/views/_results' +import { I18nProvider } from '@/lib/i18n-context' + +const goal = { + objective: 'Compare the options and validate the chosen approach.', status: 'active', threadId: 'thread-1', + tokensUsed: 0, tokenBudget: null, timeUsedSeconds: 0, + createdAt: 1704067200, updatedAt: 1704067200 +} + +function goalBlock(result: unknown, state: ToolCallBlock['tool']['state'] = 'completed', name = 'create_goal', input: unknown = { objective: 'requested objective', status: 'complete' }): ToolCallBlock { + return { + kind: 'tool-call', id: 'goal-1', localId: null, createdAt: 1000, children: [], + tool: { + id: 'goal-1', name, state, input, + result, description: null, createdAt: 1000, startedAt: 1000, completedAt: 1500, + execStartedAt: null, execCompletedAt: null + } + } +} + +function renderGoal(result: unknown, state: ToolCallBlock['tool']['state'] = 'completed', name = 'create_goal', input?: unknown) { + return render( {}} block={goalBlock(result, state, name, input)} + />) +} + +describe('goal cards in ToolCard', () => { + beforeEach(() => localStorage.setItem('hapi-lang', 'en')) + + it.each(['create_goal', 'get_goal', 'update_goal', 'functions.create_goal', 'functions.get_goal', 'functions.update_goal'])('shows the snapshot once for %s', (name) => { + renderGoal(JSON.stringify({ goal }), 'completed', name) + expect(screen.getAllByText(goal.objective)).toHaveLength(1) + expect(screen.getByText('Active')).toBeInTheDocument() + expect(screen.getByText('0 tokens')).toBeInTheDocument() + expect(screen.getByText('No limit')).toBeInTheDocument() + expect(screen.getByText('0.0s')).toBeInTheDocument() + expect(screen.queryByText('requested objective')).not.toBeInTheDocument() + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + expect(screen.queryByText('thread-1')).not.toBeInTheDocument() + }) + + it('shows token budget consumption without hiding over-budget usage', () => { + renderGoal({ goal: { ...goal, tokensUsed: 150, tokenBudget: 100 } }) + expect(screen.getByText('150 / 100 tokens')).toBeInTheDocument() + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100') + expect(screen.getByRole('progressbar')).toHaveAccessibleName('Token budget used') + }) + + it('shows no goal distinctly', () => { + renderGoal({ goal: null }) + expect(screen.getByText('No goal set')).toBeInTheDocument() + expect(screen.queryByText('Active')).not.toBeInTheDocument() + }) + + it('does not infer successful goal state while pending', () => { + renderGoal(undefined, 'pending') + expect(screen.getByText('requested objective')).toBeInTheDocument() + expect(screen.queryByText('Complete')).not.toBeInTheDocument() + }) + + it.each([ + ['en', 'Requested status', 'Complete'], + ['zh-CN', '请求的状态', '已完成'] + ])('labels status-only pending requests in %s without a successful state badge', (locale, label, status) => { + localStorage.setItem('hapi-lang', locale) + const { container } = renderGoal(undefined, 'pending', 'update_goal', { status: 'complete' }) + expect(screen.getByText(label)).toBeInTheDocument() + expect(screen.getByText(status)).toBeInTheDocument() + expect(screen.queryByText('complete')).not.toBeInTheDocument() + expect(container.querySelector('[data-goal-status]')).toBeNull() + }) + + it('keeps unknown requested statuses readable', () => { + renderGoal(undefined, 'pending', 'update_goal', { status: 'futureState' }) + expect(screen.getByText('Requested status')).toBeInTheDocument() + expect(screen.getByText('futureState')).toBeInTheDocument() + }) + + it.each([ + ['en', 0, '0.0s'], ['zh-CN', 0, '0.0s'], + ['en', 0.5, '0.5s'], ['zh-CN', 0.5, '0.5s'], + ['en', 76, '1m 16s'], ['zh-CN', 76, '1m 16s'] + ])('uses the common duration format for %s at %s seconds', (locale, seconds, duration) => { + localStorage.setItem('hapi-lang', locale) + const { container } = renderGoal({ goal: { ...goal, timeUsedSeconds: seconds } }) + expect(within(container.querySelector('[data-goal-status]') as HTMLElement).getByText(duration)).toBeInTheDocument() + }) + + it.each(['error', 'completed'] as const)('preserves the generic fallback for %s errors', (state) => { + renderGoal('Permission denied', state) + expect(screen.getByText('Permission denied')).toBeInTheDocument() + expect(screen.queryByText('Active')).not.toBeInTheDocument() + }) + + it('does not render a successful-looking goal payload when the tool failed', () => { + renderGoal({ goal }, 'error') + expect(screen.queryByText('Active')).not.toBeInTheDocument() + expect(screen.getByText('Result')).toBeInTheDocument() + }) + + it('keeps unknown status readable and omits absent metrics', () => { + renderGoal({ goal: { objective: goal.objective, status: 'futureState' } }) + expect(screen.getByText('futureState')).toBeInTheDocument() + expect(screen.queryByText('0 tokens')).not.toBeInTheDocument() + expect(screen.queryByText('0.0s')).not.toBeInTheDocument() + }) + + it('shows metadata and collapsed original JSON only in the detail dialog', () => { + renderGoal({ goal }) + fireEvent.click(screen.getByRole('button', { expanded: false })) + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByText('thread-1')).toBeInTheDocument() + const raw = within(dialog).getByText('Raw JSON').closest('details')! + expect(raw.open).toBe(false) + fireEvent.click(within(dialog).getByText('Raw JSON')) + expect(within(dialog).getByText('Raw JSON').closest('details')).toBe(raw) + }) + + it('keeps metadata and raw output when used by a trace result without a surface', () => { + const ResultView = getToolResultViewComponent('get_goal') + render() + expect(screen.getByText('thread-1')).toBeInTheDocument() + expect(screen.getByText('Raw JSON').closest('details')?.open).toBe(false) + }) + + it('hides times that overflow millisecond conversion', () => { + const { container } = renderGoal({ goal: { ...goal, timeUsedSeconds: Number.MAX_VALUE } }) + expect(container.textContent).not.toMatch(/Infinity|NaN/) + expect(screen.getByText(goal.objective)).toBeInTheDocument() + }) +}) diff --git a/web/src/components/ToolCard/views/GoalView.tsx b/web/src/components/ToolCard/views/GoalView.tsx new file mode 100644 index 0000000000..24efcbb4dc --- /dev/null +++ b/web/src/components/ToolCard/views/GoalView.tsx @@ -0,0 +1,106 @@ +import { safeStringify } from '@hapi/protocol' +import { CodeBlock } from '@/components/CodeBlock' +import { formatDuration } from '@/chat/presentation' +import { useTranslation } from '@/lib/use-translation' +import { getPendingGoalRequest, parseGoalToolResult, type GoalResult } from '../goalTools' +import type { ToolViewProps } from './_all' + +const STATUS_TONES: Record = { + active: 'bg-blue-500/10 text-blue-700 dark:text-blue-300', + paused: 'bg-amber-500/10 text-amber-700 dark:text-amber-300', + budgetLimited: 'bg-amber-500/10 text-amber-700 dark:text-amber-300', + usageLimited: 'bg-amber-500/10 text-amber-700 dark:text-amber-300', + blocked: 'bg-red-500/10 text-red-700 dark:text-red-300', + complete: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' +} + +function formatGoalDate(seconds: number | undefined, locale: string): string | null { + if (!seconds || seconds <= 0) return null + const date = new Date(seconds * 1000) + return Number.isFinite(date.getTime()) ? date.toLocaleString(locale) : null +} + +export function GoalSummary({ result, surface }: { result: GoalResult; surface?: 'inline' | 'dialog' }) { + const { t, locale } = useTranslation() + if (result.kind === 'empty') { + return
{t('tool.goal.empty')}
+ } + const { goal } = result + const number = (value: number) => value.toLocaleString(locale) + const used = goal.tokensUsed + const budget = goal.tokenBudget + const durationMs = goal.timeUsedSeconds !== undefined ? goal.timeUsedSeconds * 1000 : null + const duration = durationMs !== null && Number.isFinite(durationMs) + ? formatDuration(durationMs) + : null + const percentage = used !== undefined && typeof budget === 'number' && budget > 0 + ? Math.min(100, used / budget * 100) : null + const tone = Object.prototype.hasOwnProperty.call(STATUS_TONES, goal.status) ? STATUS_TONES[goal.status] : null + const metadata = [ + [t('tool.goal.thread'), goal.threadId], + [t('tool.goal.created'), formatGoalDate(goal.createdAt, locale)], + [t('tool.goal.updated'), formatGoalDate(goal.updatedAt, locale)] + ] + + return ( +
+
+ + {tone ? t(`session.status.goal.${goal.status}`) : goal.status} + +
{goal.objective}
+
+
+ {used !== undefined ? ( + {t('tool.goal.tokens', { value: typeof budget === 'number' ? `${number(used)} / ${number(budget)}` : number(used) })} + ) : typeof budget === 'number' ? ( + {t('tool.goal.budget', { value: number(budget) })} + ) : null} + {budget === null ? {t('tool.goal.unlimited')} : null} + {duration ? {duration} : null} +
+ {percentage !== null ? ( +
+
+
+ ) : null} + {surface === 'dialog' && metadata.some(([, value]) => value) ? ( +
+ {metadata.filter(([, value]) => value).map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ) : null} +
+ ) +} + +export function GoalView({ block, surface }: ToolViewProps) { + const { t } = useTranslation() + const result = block.tool.state === 'completed' ? parseGoalToolResult(block.tool.result) : null + if (!result) { + const request = getPendingGoalRequest(block.tool.input) + const isStatus = request?.kind === 'status' + const text = isStatus && Object.prototype.hasOwnProperty.call(STATUS_TONES, request.text) + ? t(`session.status.goal.${request.text}`) : request?.text + return
+ {t(isStatus ? 'tool.goal.requestedStatus' : 'tool.goal.requested')} + {text} +
+ } + return ( +
+ + {surface === 'dialog' ? ( +
+ {t('tool.goal.raw')} + +
+ ) : null} +
+ ) +} diff --git a/web/src/components/ToolCard/views/_all.tsx b/web/src/components/ToolCard/views/_all.tsx index 72076192e2..282fb7a206 100644 --- a/web/src/components/ToolCard/views/_all.tsx +++ b/web/src/components/ToolCard/views/_all.tsx @@ -1,5 +1,5 @@ import type { ComponentType } from 'react' -import type { ToolCallBlock } from '@/chat/types' +import type { ChatToolCall, ToolCallBlock } from '@/chat/types' import type { SessionMetadataSummary } from '@/types/api' import { CodexDiffCompactView, CodexDiffFullView } from '@/components/ToolCard/views/CodexDiffView' import { CodexPatchView } from '@/components/ToolCard/views/CodexPatchView' @@ -12,6 +12,8 @@ import { MultiEditFullView, MultiEditView } from '@/components/ToolCard/views/Mu import { TodoWriteView } from '@/components/ToolCard/views/TodoWriteView' import { UpdatePlanView } from '@/components/ToolCard/views/UpdatePlanView' import { WriteView } from '@/components/ToolCard/views/WriteView' +import { GoalView } from '@/components/ToolCard/views/GoalView' +import { canShowGoalPreview, isGoalToolName } from '@/components/ToolCard/goalTools' import { getInputStringAny } from '@/lib/toolInputUtils' import { getCodexAgentFieldRows, @@ -126,7 +128,8 @@ export const toolFullViewRegistry: Record = { request_user_input: RequestUserInputView } -export function getToolViewComponent(toolName: string): ToolViewComponent | null { +export function getToolViewComponent(toolName: string, tool?: Pick): ToolViewComponent | null { + if (isGoalToolName(toolName)) return tool && canShowGoalPreview(tool) ? GoalView : null return toolViewRegistry[toolName] ?? null } diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx index c14fde7f7a..6ab793dfa7 100644 --- a/web/src/components/ToolCard/views/_results.tsx +++ b/web/src/components/ToolCard/views/_results.tsx @@ -6,6 +6,8 @@ import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { ChecklistList, extractTodoChecklist } from '@/components/ToolCard/checklist' import { basename, resolveDisplayPath } from '@/utils/path' import { getInputStringAny } from '@/lib/toolInputUtils' +import { isGoalToolName, parseGoalToolResult } from '@/components/ToolCard/goalTools' +import { GoalView } from '@/components/ToolCard/views/GoalView' import { getCodexAgentActivity, getCodexAgentTargets, @@ -1070,7 +1072,14 @@ export const toolResultViewRegistry: Record = { exit_plan_mode: MarkdownResultView } +const GoalResultView: ToolViewComponent = (props) => { + return props.block.tool.state === 'completed' && parseGoalToolResult(props.block.tool.result) + ? + : +} + export function getToolResultViewComponent(toolName: string): ToolViewComponent { + if (isGoalToolName(toolName)) return GoalResultView if (toolName.startsWith('mcp__')) { return GenericResultView } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index cfdc81fb6c..7344164495 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -1183,6 +1183,20 @@ export default { 'session.action.syncCodexDisabledActive': 'Stop or archive the running session before syncing Codex history.', 'codexSync.manual.success.noNewMessages': 'No new Codex messages.', 'session.status.title': 'Session status', + 'tool.goal.create_goal': 'Create goal', + 'tool.goal.get_goal': 'Get goal', + 'tool.goal.update_goal': 'Update goal', + 'tool.goal.empty': 'No goal set', + 'tool.goal.tokens': '{value} tokens', + 'tool.goal.budget': 'Budget: {value} tokens', + 'tool.goal.unlimited': 'No limit', + 'tool.goal.budgetUsed': 'Token budget used', + 'tool.goal.thread': 'Thread', + 'tool.goal.created': 'Created', + 'tool.goal.updated': 'Updated', + 'tool.goal.raw': 'Raw JSON', + 'tool.goal.requested': 'Requested goal', + 'tool.goal.requestedStatus': 'Requested status', 'session.status.goal': 'Goal', 'session.status.tasks': 'Tasks', 'session.status.subagents': 'Subagents', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 2c3bf40054..73b2f73083 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -1181,6 +1181,20 @@ export default { 'session.action.syncCodexDisabledActive': '当前会话正在运行,停止或归档后才能同步 Codex 历史。', 'codexSync.manual.success.noNewMessages': '没有新的 Codex 消息。', 'session.status.title': '会话状态', + 'tool.goal.create_goal': '创建目标', + 'tool.goal.get_goal': '查看目标', + 'tool.goal.update_goal': '更新目标', + 'tool.goal.empty': '未设置目标', + 'tool.goal.tokens': '{value} token', + 'tool.goal.budget': '预算:{value} token', + 'tool.goal.unlimited': '无限制', + 'tool.goal.budgetUsed': 'Token 预算用量', + 'tool.goal.thread': '会话', + 'tool.goal.created': '创建时间', + 'tool.goal.updated': '更新时间', + 'tool.goal.raw': '原始 JSON', + 'tool.goal.requested': '请求的目标', + 'tool.goal.requestedStatus': '请求的状态', 'session.status.goal': '目标', 'session.status.tasks': '任务', 'session.status.subagents': '子代理', From efc9004fda5c8fd30f876efdebd8f47aaa1b64ff Mon Sep 17 00:00:00 2001 From: Matvey Rudoy <75200069+mrudybtw@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:13:53 +0300 Subject: [PATCH 05/99] feat(web): add Russian locale and detect initial language (#1958) * feat(web): add Russian locale - Add web/src/lib/locales/ru.ts - Register 'ru' in the locale registry, i18n context, language switcher and settings - Format outline timestamps with the day.month convention for 'ru' * feat(web): detect initial locale from Telegram and browser The Telegram Mini App runs in its own web view, so a language chosen in the browser does not carry over and the app always opened in English. Pick the first session locale from an explicit stored choice, then the Telegram user's language_code, then navigator.language, then English. * feat(web): translate the newly localized strings Russian values for the keys added by the hardcoded-string fix (see the separate web i18n PR). Keeping them on this branch so the locale PR is complete when both land. * feat(web): add Russian for the newly localized strings * fix(web): recheck the Telegram language after a slow SDK load * fix(web): keep a manual language choice over late Telegram detection --------- Co-authored-by: mrudy --- web/src/chat/presentation.ts | 7 +- web/src/components/LanguageSwitcher.tsx | 1 + web/src/components/settings/SettingsNav.tsx | 2 +- web/src/hooks/useTelegram.ts | 1 + web/src/lib/i18n-context.test.ts | 140 ++ web/src/lib/i18n-context.tsx | 109 +- web/src/lib/locales/index.ts | 1 + web/src/lib/locales/ru.ts | 1284 +++++++++++++++++++ web/src/routes/settings/general.tsx | 1 + 9 files changed, 1537 insertions(+), 9 deletions(-) create mode 100644 web/src/lib/i18n-context.test.ts create mode 100644 web/src/lib/locales/ru.ts diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index 5877bbcf1c..09b4bf07aa 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -45,7 +45,7 @@ export function formatMessageTimestamp(date: Date, now: Date = new Date()): stri export function formatOutlineTimestamp( date: Date, - locale: 'en' | 'zh-CN', + locale: 'en' | 'zh-CN' | 'ru', now: Date = new Date() ): string { const sameDay = date.getFullYear() === now.getFullYear() @@ -67,6 +67,11 @@ export function formatOutlineTimestamp( return `${dateLabel} ${time}` } + if (locale === 'ru') { + const dateLabel = sameYear ? `${day}.${month}` : `${day}.${month}.${year}` + return `${dateLabel} ${time}` + } + const dateLabel = sameYear ? `${month}/${day}` : `${year}/${month}/${day}` return `${dateLabel} ${time}` } diff --git a/web/src/components/LanguageSwitcher.tsx b/web/src/components/LanguageSwitcher.tsx index 45fbbf8e63..fa8243c4bd 100644 --- a/web/src/components/LanguageSwitcher.tsx +++ b/web/src/components/LanguageSwitcher.tsx @@ -4,6 +4,7 @@ import { useTranslation, type Locale } from '@/lib/use-translation' const locales: { value: Locale; label: string }[] = [ { value: 'en', label: 'English' }, { value: 'zh-CN', label: '简体中文' }, + { value: 'ru', label: 'Русский' }, ] function LanguageIcon() { diff --git a/web/src/components/settings/SettingsNav.tsx b/web/src/components/settings/SettingsNav.tsx index 5e4fbea52a..d8038d2b35 100644 --- a/web/src/components/settings/SettingsNav.tsx +++ b/web/src/components/settings/SettingsNav.tsx @@ -30,7 +30,7 @@ export function SettingsNav(props: { activeId?: string; mobile?: boolean }) { const { composerEnterBehavior } = useComposerEnterBehavior() const summaries: Record = { - general: locale === 'zh-CN' ? '简体中文' : 'English', + general: locale === 'zh-CN' ? '简体中文' : locale === 'ru' ? 'Русский' : 'English', display: `${t(`settings.display.appearance.${appearance}`)} · ${Math.round(fontScale * 100)}%`, chat: t(`settings.chat.enterBehavior.${composerEnterBehavior}`), voice: t('settings.hub.voice.summary'), diff --git a/web/src/hooks/useTelegram.ts b/web/src/hooks/useTelegram.ts index 4d52d08838..60c3967ea1 100644 --- a/web/src/hooks/useTelegram.ts +++ b/web/src/hooks/useTelegram.ts @@ -40,6 +40,7 @@ export type TelegramWebAppUser = { username?: string first_name: string last_name?: string + language_code?: string } export type TelegramWebAppInitDataUnsafe = { diff --git a/web/src/lib/i18n-context.test.ts b/web/src/lib/i18n-context.test.ts new file mode 100644 index 0000000000..753b5d2a1c --- /dev/null +++ b/web/src/lib/i18n-context.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + applyLateTelegramLocale, + detectInitialLocale, + normalizeLocaleTag, + resolveLateTelegramLocale +} from './i18n-context' + +function setTelegramLanguage(code: string | undefined) { + if (code === undefined) { + delete (window as unknown as { Telegram?: unknown }).Telegram + return + } + ;(window as unknown as { Telegram?: unknown }).Telegram = { + WebApp: { initDataUnsafe: { user: { language_code: code } } } + } +} + +beforeEach(() => { + localStorage.clear() + setTelegramLanguage(undefined) +}) + +afterEach(() => { + localStorage.clear() + setTelegramLanguage(undefined) + vi.restoreAllMocks() +}) + +describe('normalizeLocaleTag', () => { + it('maps Russian tags', () => { + expect(normalizeLocaleTag('ru')).toBe('ru') + expect(normalizeLocaleTag('ru-RU')).toBe('ru') + expect(normalizeLocaleTag('RU')).toBe('ru') + expect(normalizeLocaleTag('ru_RU')).toBe('ru') + }) + + it('maps Chinese tags to the simplified locale we ship', () => { + expect(normalizeLocaleTag('zh')).toBe('zh-CN') + expect(normalizeLocaleTag('zh-Hans')).toBe('zh-CN') + expect(normalizeLocaleTag('zh-CN')).toBe('zh-CN') + expect(normalizeLocaleTag('zh_CN')).toBe('zh-CN') + }) + + it('maps English tags', () => { + expect(normalizeLocaleTag('en')).toBe('en') + expect(normalizeLocaleTag('en-US')).toBe('en') + }) + + it('rejects unsupported or empty tags', () => { + expect(normalizeLocaleTag('de')).toBeNull() + expect(normalizeLocaleTag('')).toBeNull() + expect(normalizeLocaleTag(null)).toBeNull() + expect(normalizeLocaleTag(undefined)).toBeNull() + }) +}) + +describe('detectInitialLocale', () => { + it('prefers an explicit stored choice over Telegram', () => { + localStorage.setItem('hapi-lang', 'en') + setTelegramLanguage('ru') + + expect(detectInitialLocale()).toBe('en') + }) + + it('uses the Telegram Mini App language when nothing is stored', () => { + setTelegramLanguage('ru') + + expect(detectInitialLocale()).toBe('ru') + }) + + it('normalizes the Telegram language tag', () => { + setTelegramLanguage('zh-hans') + + expect(detectInitialLocale()).toBe('zh-CN') + }) + + it('falls back to the browser language', () => { + vi.spyOn(navigator, 'language', 'get').mockReturnValue('ru-RU') + + expect(detectInitialLocale()).toBe('ru') + }) + + it('falls back to English for unsupported languages', () => { + setTelegramLanguage('de') + vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-DE') + + expect(detectInitialLocale()).toBe('en') + }) +}) + +describe('resolveLateTelegramLocale', () => { + it('picks up the language when the SDK appears after the timeout', async () => { + vi.useFakeTimers() + setTelegramLanguage(undefined) + + const pending = resolveLateTelegramLocale(500, 5) + await vi.advanceTimersByTimeAsync(1500) + setTelegramLanguage('ru') + await vi.advanceTimersByTimeAsync(500) + + await expect(pending).resolves.toBe('ru') + vi.useRealTimers() + }) + + it('resolves null when the SDK never loads', async () => { + vi.useFakeTimers() + setTelegramLanguage(undefined) + + const pending = resolveLateTelegramLocale(500, 2) + await vi.advanceTimersByTimeAsync(1500) + + await expect(pending).resolves.toBeNull() + vi.useRealTimers() + }) +}) + +describe('applyLateTelegramLocale', () => { + it('applies the detected locale when nothing is stored', () => { + const apply = vi.fn() + + expect(applyLateTelegramLocale('ru', apply)).toBe(true) + expect(apply).toHaveBeenCalledWith('ru') + }) + + it('does not override a manual choice made while the SDK loaded', () => { + localStorage.setItem('hapi-lang', 'en') + const apply = vi.fn() + + expect(applyLateTelegramLocale('ru', apply)).toBe(false) + expect(apply).not.toHaveBeenCalled() + }) + + it('ignores a null detection', () => { + const apply = vi.fn() + + expect(applyLateTelegramLocale(null, apply)).toBe(false) + expect(apply).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/lib/i18n-context.tsx b/web/src/lib/i18n-context.tsx index 99979c566c..1f80c7c7a9 100644 --- a/web/src/lib/i18n-context.tsx +++ b/web/src/lib/i18n-context.tsx @@ -1,7 +1,8 @@ import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react' -import { en, zhCN } from './locales' +import { getTelegramWebApp, isTelegramEnvironment } from '@/hooks/useTelegram' +import { en, zhCN, ru } from './locales' -export type Locale = 'en' | 'zh-CN' +export type Locale = 'en' | 'zh-CN' | 'ru' export type Translations = Record @@ -13,7 +14,54 @@ export type I18nContextValue = { export const I18nContext = createContext(null) -const locales: Record = { en, 'zh-CN': zhCN } +const locales: Record = { en, 'zh-CN': zhCN, ru } + +/** + * Map a BCP-47-ish language tag (`ru`, `ru-RU`, `zh-Hans`, `en-US`) onto a + * supported locale. Returns null for anything we do not ship. + */ +export function normalizeLocaleTag(tag: string | null | undefined): Locale | null { + if (!tag) return null + const lower = tag.trim().toLowerCase().replace(/_/g, '-') + if (!lower) return null + if (lower === 'ru' || lower.startsWith('ru-')) return 'ru' + if (lower === 'zh' || lower.startsWith('zh-')) return 'zh-CN' + if (lower === 'en' || lower.startsWith('en-')) return 'en' + return null +} + +function readStoredLocale(): Locale | null { + try { + return normalizeLocaleTag(localStorage.getItem('hapi-lang')) + } catch { + return null + } +} + +/** + * Pick the locale for a fresh session: an explicit user choice wins, then the + * Telegram Mini App user's language, then the browser language, then English. + * Telegram runs the app in its own web view, so `localStorage` from a normal + * browser visit is not available and the language must be detected here. + */ +export function detectInitialLocale(): Locale { + const stored = readStoredLocale() + if (stored) return stored + + if (typeof window !== 'undefined') { + const telegramLocale = normalizeLocaleTag( + getTelegramWebApp()?.initDataUnsafe?.user?.language_code + ) + if (telegramLocale) return telegramLocale + } + + if (typeof navigator !== 'undefined') { + const browserLocale = normalizeLocaleTag(navigator.language) + if (browserLocale) return browserLocale + } + + return 'en' +} function interpolate(str: string, params?: Record): string { if (!params) return str @@ -23,11 +71,40 @@ function interpolate(str: string, params?: Record): str }) } +/** + * Telegram loads its SDK asynchronously and `loadTelegramSdk` gives up after a + * timeout, so the user's `language_code` can appear after the first render. + * Poll briefly for the WebApp and resolve the language once it shows up. + */ +export async function resolveLateTelegramLocale( + intervalMs = 500, + maxAttempts = 20 +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const telegramLocale = normalizeLocaleTag( + getTelegramWebApp()?.initDataUnsafe?.user?.language_code + ) + if (telegramLocale) return telegramLocale + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + return null +} + +/** + * Apply a late-detected Telegram locale unless the user has stored an explicit + * choice in the meantime. Returns true when the locale was applied. + */ +export function applyLateTelegramLocale( + detected: Locale | null, + apply: (locale: Locale) => void +): boolean { + if (!detected || readStoredLocale()) return false + apply(detected) + return true +} + export function I18nProvider({ children }: { children: ReactNode }) { - const [locale, setLocaleState] = useState(() => { - const saved = localStorage.getItem('hapi-lang') - return (saved === 'en' || saved === 'zh-CN') ? saved : 'en' - }) + const [locale, setLocaleState] = useState(() => detectInitialLocale()) const setLocale = useCallback((newLocale: Locale) => { setLocaleState(newLocale) @@ -46,6 +123,24 @@ export function I18nProvider({ children }: { children: ReactNode }) { document.documentElement.lang = locale }, [locale]) + // The Telegram SDK can finish loading after `detectInitialLocale` ran, so + // pick up the Telegram language then — unless the user chose one explicitly. + useEffect(() => { + if (typeof window === 'undefined') return + if (readStoredLocale()) return + if (!isTelegramEnvironment()) return + if (normalizeLocaleTag(getTelegramWebApp()?.initDataUnsafe?.user?.language_code)) return + + let cancelled = false + void resolveLateTelegramLocale().then((detected) => { + if (cancelled) return + applyLateTelegramLocale(detected, setLocaleState) + }) + return () => { + cancelled = true + } + }, []) + return ( {children} diff --git a/web/src/lib/locales/index.ts b/web/src/lib/locales/index.ts index e46688244a..6e339e936d 100644 --- a/web/src/lib/locales/index.ts +++ b/web/src/lib/locales/index.ts @@ -1,2 +1,3 @@ export { default as en } from './en' export { default as zhCN } from './zh-CN' +export { default as ru } from './ru' diff --git a/web/src/lib/locales/ru.ts b/web/src/lib/locales/ru.ts new file mode 100644 index 0000000000..c3d0391011 --- /dev/null +++ b/web/src/lib/locales/ru.ts @@ -0,0 +1,1284 @@ +export default { + // Состояния загрузки + 'loading': 'Загрузка…', + 'authorizing': 'Авторизация…', + 'loading.session': 'Загрузка сессии…', + 'loading.git': 'Загрузка статуса Git…', + 'loading.file': 'Загрузка файла…', + 'loading.files': 'Загрузка файлов…', + 'loading.messages': 'Загрузка сообщений…', + 'loading.machines': 'Загрузка машин…', + 'message.copy': 'Копировать', + 'message.copied': 'Скопировано', + 'message.info': 'Сведения о сообщении', + 'message.fork': 'Форк', + 'message.rewind': 'Откат', + 'message.fork.confirmTitle': 'Форк диалога', + 'message.fork.confirmDescription': 'Создать новую сессию с этого места?\nТекущая сессия не будет изменена.', + 'message.fork.confirming': 'Создание форка…', + 'message.rewind.confirmTitle': 'Откат диалога', + 'message.rewind.confirmDescription': 'Откатить эту сессию к этому месту?\nИстория диалога после этого момента будет удалена безвозвратно. Файлы изменены не будут.', + 'message.rewind.confirming': 'Откат…', + 'message.rewind.fallbackTitle': 'Откат недоступен', + 'message.rewind.fallbackDescription': 'HAPI не изменил эту сессию, поскольку её нативная граница Codex неоднозначна.\nСоздать новую сессию до этого места? Текущая сессия не будет изменена.', + 'message.rewind.fallbackFork': 'Форк здесь', + 'message.rewind.fallbackForking': 'Создание форка…', + 'message.shareTurn': 'Поделиться ходом как изображением', + 'shareTurn.title': 'Поделиться ходом как изображением', + 'shareTurn.badge': 'Общий ход', + 'shareTurn.generated': 'Создано в HAPI', + 'shareTurn.cancel': 'Отмена', + 'shareTurn.copy': 'Копировать', + 'shareTurn.copying': 'Копирование…', + 'shareTurn.copied': 'Скопировано', + 'shareTurn.share': 'Поделиться', + 'shareTurn.sharing': 'Отправка…', + 'shareTurn.download': 'Скачать', + 'shareTurn.saving': 'Сохранение…', + + // Вход / Аутентификация + 'login.title': 'HAPI', + 'login.subtitle': 'Vibe Coding — в любое время, в любом месте', + 'login.bind.title': 'Привязать Telegram', + 'login.submit': 'Войти', + 'login.bind.submit': 'Привязать', + 'login.submitting': 'Вход…', + 'login.bind.submitting': 'Привязка…', + 'login.placeholder': 'Токен доступа', + 'login.error.enterToken': 'Введите токен доступа', + 'login.error.bindingUnavailable': 'Привязка недоступна.', + 'login.error.loginUnavailable': 'Вход недоступен.', + 'login.error.bindFailed': 'Не удалось привязать', + 'login.error.authFailed': 'Ошибка аутентификации', + 'login.help': 'Нужна помощь?', + 'login.server.default': '(По умолчанию)', + 'login.server.custom': '(Свой)', + 'login.server.title': 'URL хаба', + 'login.server.description': 'Задайте origin хаба HAPI для API и живых обновлений.', + 'login.server.current': 'Текущий:', + 'login.server.origin': 'Origin хаба', + 'login.server.placeholder': 'https://hapi.example.com', + 'login.server.hint': 'Только http(s). Путь игнорируется.', + 'login.server.required': 'Требуется URL хаба. Укажите его перед входом.', + 'login.server.useSameOrigin': 'Использовать тот же origin', + 'login.server.save': 'Сохранить хаб', + 'login.footer': 'Создано с', + 'login.footer.for': 'для Vibe Coding', + 'login.footer.copyright': '©', + + // Страница сессий + 'sessions.new': 'Новая сессия', + 'sessions.empty.title': 'Сессий пока нет', + 'sessions.empty.hint': 'Запустите сессию в любой папке рабочего пространства или сначала просмотрите дерево.', + 'sessions.empty.startSession': 'Запустить сессию', + 'sessions.empty.browse': 'Просмотр рабочего пространства', + 'sessions.search.placeholder': 'Поиск по названию/пути/агенту/машине/ID…', + 'sessions.search.open': 'Поиск сессий (название, путь, агент, имя машины, ID и другое)', + 'sessions.search.clear': 'Очистить поиск', + 'sessions.search.noResults': 'Нет сессий, соответствующих фильтрам.', + 'sessions.unreadFilter.toggle': 'Только непрочитанные', + 'sessions.markAllRead.button': 'Отметить все как прочитанные ({count})', + 'sessions.markAllRead.title': 'Отметить все как прочитанные?', + 'sessions.markAllRead.description': 'Будут отмечены как прочитанные {count} непрочитанных сессий на этом устройстве.', + 'sessions.markAllRead.confirm': 'Подтвердить', + 'sessions.markAllRead.confirming': 'Отмечаем как прочитанные…', + 'sessions.timeFilter.label': 'Фильтр сессий по последней активности', + 'sessions.timeFilter.pickStart': 'Выбрать дату начала', + 'sessions.timeFilter.pickEnd': 'Выбрать дату окончания', + 'sessions.timeFilter.clear': 'Очистить', + 'sessions.timeFilter.close': 'Закрыть выбор даты', + 'sessions.timeFilter.previousMonth': 'Предыдущий месяц', + 'sessions.timeFilter.nextMonth': 'Следующий месяц', + 'sessions.timeFilter.dayWithActivity': '{date}, есть активность сессии', + 'sessions.group.expand': 'Развернуть {n}', + 'sessions.group.collapse': 'Свернуть {n}', + 'sessions.runningSection': 'В работе', + 'sessions.activeSection': 'Активные сессии', + 'sessions.group.new': 'Новая сессия в этой папке', + 'sessions.machineFilter.label': 'Фильтр сессий по машине', + 'sessions.machineFilter.all': 'Все', + 'codexSync.tooltip': 'Импортировать сессии из Codex в Hapi', + 'codexSync.newSessionAction': 'Импорт истории Codex', + 'codexSync.confirm.title': 'Импорт сессий Codex', + 'codexSync.confirm.description': 'Выберите сессии Codex для импорта в Hapi', + 'piImport.inline.title': 'Импорт истории Pi', + 'piImport.inline.description': 'Необязательно: выберите существующую локальную сессию Pi для импорта и продолжения.', + 'piImport.inline.clear': 'Очистить', + 'piImport.inline.choose': 'Выбрать историю', + 'piImport.dialog.title': 'Импорт сессий Pi', + 'piImport.dialog.description': 'Выберите локальные истории Pi. Один выбор продолжается сразу; несколько выборов импортируются в фоне.', + 'piImport.dialog.concurrentWarning': 'Несколько процессов могут открыть одну и ту же сессию Pi, но избегайте записи из устаревших копий. Перед продолжением в HAPI перезагрузите или закройте любой другой процесс Pi, чтобы избежать непреднамеренного ответвления.', + 'piImport.loading': 'Сканирование сессий Pi…', + 'piImport.empty': 'Не найдено сессий Pi для импорта.', + 'piImport.selectedCount': 'Выбрано: {n}', + 'piImport.clearAll': 'Очистить все', + 'piImport.selectAll': 'Выбрать все', + 'piImport.allDirectories': 'Все каталоги', + 'piImport.search': 'Поиск по названию, запросу, пути, модели или ID', + 'piImport.imported': 'Импортировано', + 'piImport.needsAttention': 'Требует внимания', + 'piImport.messages': 'сообщений', + 'piImport.importing': 'Импорт…', + 'piImport.confirm': 'Импортировать', + 'piImport.success.title': 'Сессии Pi импортированы', + 'piImport.success.body': 'Импортировано сессий Pi: {n}.', + 'piImport.failed.title': 'Не удалось импортировать Pi', + 'piImport.failed.body': 'Не удалось импортировать историю Pi.', + 'piImport.failed.partial': 'Не удалось импортировать сессий: {failed}. Причина: {reason}', + 'piImport.error.diverged': 'Локальная ветка Pi изменилась после предыдущего импорта. Проверьте сессию перед продолжением.', + 'piImport.error.active': 'Остановите активную сессию Hapi Pi перед импортом более новой нативной истории.', + 'piImport.manual.success.title': 'Синхронизация истории Pi завершена', + 'piImport.manual.success.body': 'Синхронизировано новых элементов истории Pi: {n}.', + 'piImport.manual.success.noNewMessages': 'История Pi уже актуальна.', + 'piImport.manual.failed.title': 'Не удалось синхронизировать историю Pi', + // Комментарий: ниже — тексты для списка с множественным выбором в диалоге импорта, состояния загрузки и подсказки кнопки перезапуска клиента Codex в правом верхнем углу. + 'codexSync.confirm.selectAll': 'Выбрать все', + 'codexSync.confirm.clearAll': 'Очистить все', + 'codexSync.confirm.selectedCount': 'Выбрано сессий: {n}', + 'codexSync.confirm.empty': 'Локальные сессии Codex не найдены', + 'codexSync.confirm.current': 'Связано', + 'codexSync.confirm.cwd': 'Рабочий каталог', + 'codexSync.confirm.cwdFilter': 'Рабочий каталог', + 'codexSync.confirm.cwdFilterAll': 'Все рабочие каталоги', + 'codexSync.confirm.emptyForWorkdir': 'Нет сессий Codex в этом рабочем каталоге', + 'codexSync.confirm.confirm': 'Импортировать', + 'codexSync.confirm.confirming': 'Импорт…', + 'codexSync.confirm.loading': 'Загрузка локальных сессий Codex…', + 'codexSync.success.title': 'Импорт завершён', + 'codexSync.success.body': 'Импортировано сессий Codex в Hapi: {n}.', + 'codexSync.manual.success.title': 'Синхронизация Codex завершена', + 'codexSync.manual.success.body': 'Синхронизировано сессий Codex: {n}.', + 'codexSync.manual.failed.title': 'Не удалось синхронизировать сессию Codex', + 'codexSync.error.timeout': 'Время операции истекло', + 'codexSync.error.active': 'Эта сессия ещё активна. Дождитесь её завершения и повторите.', + 'codexSync.failed.title': 'Не удалось импортировать сессии Codex', + 'codexSync.failed.body': 'Не удалось импортировать сессии Codex.', + 'codexSync.failed.bodyWithReason': 'Импорт не удался: {reason}', + 'codexSync.restart.tooltip': 'Перезапустить клиент Codex', + 'codexSync.restart.title': 'Перезапустить клиент Codex', + 'codexSync.restart.description': 'Перезапустить клиент Codex сейчас, чтобы обновить сессии?', + 'codexSync.restart.confirm': 'Перезапустить', + 'codexSync.restart.confirming': 'Перезапуск…', + 'codexSync.restart.failed.title': 'Не удалось перезапустить клиент Codex', + 'codexSync.restart.failed.notFound': 'Не удалось перезапустить клиент Codex: не установлен или не найден', + 'codexSync.restart.failed.body': 'Не удалось выполнить скрипт перезапуска.', + 'codexSync.restart.started.title': 'Перезапуск запущен', + 'codexSync.restart.started.body': 'Клиент Codex перезапускается для обновления сессий.', + 'codexSync.duplicates.confirm.title': 'Обнаружены дубликаты сессий', + 'codexSync.duplicates.confirm.description': 'Обнаружены дубликаты сессий. Объединить их?', + 'codexSync.duplicates.confirm.confirm': 'Объединить', + 'codexSync.duplicates.confirm.confirming': 'Объединение…', + 'codexSync.duplicates.detect.failed.title': 'Не удалось обнаружить дубликаты сессий', + 'codexSync.duplicates.detect.failed.body': 'Не удалось обнаружить дубликаты сессий.', + 'codexSync.duplicates.merge.success.title': 'Дубликаты сессий объединены', + 'codexSync.duplicates.merge.success.body': 'Объединены дубликаты выбранных сессий Codex.', + 'codexSync.duplicates.merge.failed.title': 'Не удалось объединить дубликаты сессий', + 'codexSync.duplicates.merge.failed.body': 'Не удалось объединить дубликаты сессий.', + + // Список сессий + 'session.item.path': 'путь', + 'session.item.agent': 'агент', + 'session.item.model': 'модель', + 'session.item.machine': 'машина', + 'session.modelChange.pending': 'Изменение…', + 'session.modelChange.cursorAutoRestart': 'Авто (для переключения обратно нужен перезапуск)', + 'session.modelChange.pendingTooltip': 'Модель изменится, когда текущая задача завершится.', + 'session.item.worktree': 'рабочее дерево', + 'session.item.pending': 'ожидание', + 'session.item.thinking': 'размышление', + 'session.item.running': 'Выполняется', + 'session.item.active': 'Активна', + 'session.item.idle': 'Простаивает (только keepalive)', + 'session.item.permission': 'Требуется разрешение', + 'session.item.needsInput': 'Нужен ввод', + 'session.item.background': 'Выполняются фоновые задачи', + 'session.item.newActivity': 'Новая активность', + 'session.item.scheduledMessage': 'Ожидает отложенное сообщение', + 'session.item.scheduledMessages': 'Ожидает отложенных сообщений: {count}', + 'session.tooltip.permission.body': 'Одобрить:', + 'session.tooltip.input.body': 'Ответить:', + 'session.tooltip.background.count.one': 'Выполняется 1 задача', + 'session.tooltip.background.count.other': 'Выполняется задач: {count}', + 'session.tooltip.scheduled.body': 'Сработает по наступлении срока.', + 'session.tooltip.scheduled.fires': 'Сработает {when}', + 'session.tooltip.scheduled.next': 'Следующее {when} · ещё +{more}', + 'session.tooltip.moreCount': 'ещё +{count}', + 'session.time.justNow': 'только что', + 'session.time.minutesAgo': '{n} мин назад', + 'session.time.hoursAgo': '{n} ч назад', + 'session.time.daysAgo': '{n} дн назад', + 'session.time.inLessThanMinute': 'через <1 мин', + 'session.time.inMinutes': 'через {n} мин', + 'session.time.inHours': 'через {n} ч', + 'session.time.inDays': 'через {n} дн', + 'session.time.soon': 'скоро', + 'session.time.importedFromCodex.justNow': 'только что импортировано из Codex', + 'session.time.importedFromCodex.minutesAgo': 'импортировано из Codex {n} мин назад', + 'session.time.importedFromCodex.hoursAgo': 'импортировано из Codex {n} ч назад', + 'session.time.importedFromCodex.daysAgo': 'импортировано из Codex {n} дн назад', + 'session.cursorMigration.banner.title': 'Обновление сессии Cursor', + 'session.cursorMigration.banner.body': 'Переключение этого устаревшего чата на более безопасный протокол ACP. Для сессий с длинной историей это занимает 15–20 секунд; ваш диалог возобновится автоматически, а любой черновой текст сохранится.', + 'session.cursorMigration.bannerAmbiguous.title': 'Обновление сессии Cursor требует проверки вручную', + 'session.cursorMigration.bannerAmbiguous.body': 'Этот чат существует на диске в нескольких папках рабочего пространства, либо размер на диске не совпадает с синхронизированной историей, поэтому HAPI отказался переносить его автоматически. Проверьте логи хаба на предмет списка кандидатов (поиск по «[migrator] ambiguous legacy store» или «[migrator] size sanity check refused»), удалите устаревшие папки в ~/.cursor/chats/ и откройте сессию заново.', + + // Неактивная сессия + 'session.inactive.autoResume': 'Эта сессия неактивна. Отправьте сообщение, чтобы возобновить.', + 'session.inactive.cannotResume': 'Эта сессия неактивна и не может быть возобновлена.', + + // Шапка сессии + 'session.title': 'Файлы', + 'session.view.returnToChat': 'Вернуться к диалогу', + 'session.more': 'Дополнительные действия', + 'session.outline.open': 'План диалога', + 'session.outline.close': 'Закрыть план', + 'session.outline.title': 'План', + 'session.outline.loadOlder': 'Загрузить более ранние', + 'session.outline.empty': 'В загруженных сообщениях нет элементов плана', + 'session.outline.searchPlaceholder': 'Поиск по плану', + 'session.outline.searchLabel': 'Поиск элементов плана', + 'session.outline.searchResults': '{matched} из {total} элементов', + 'session.outline.noSearchResults': 'Нет подходящих элементов плана', + + // Действия с сессией + 'session.action.rename': 'Переименовать', + 'session.action.pinProject': 'Закрепить в проекте', + 'session.action.unpinProject': 'Открепить от проекта', + 'session.action.pinGlobal': 'Закрепить глобально', + 'session.action.unpinGlobal': 'Открепить глобально', + 'session.action.pinFailed': 'Не удалось изменить закрепление', + 'sessions.pinnedSection': 'Закреплённые сессии', + 'session.action.export': 'Экспортировать диалог', + 'session.action.markUnread': 'Отметить как непрочитанную', + 'session.action.syncCodex': 'Синхронизировать Codex', + 'session.action.syncPi': 'Синхронизировать историю Pi', + 'session.action.archive': 'Архивировать', + 'session.action.reopen': 'Открыть заново', + 'session.action.reopenCursorChecking': 'Проверка доступности данных чата Cursor на записанной машине.', + 'session.action.reopenCursorMissing': 'Данные чата Cursor больше недоступны на записанной машине.', + 'session.action.reopenCursorCheckFailed': 'Не удалось проверить данные чата Cursor на записанной машине.', + 'session.action.reopenCursorUnverified': 'Не удалось проверить данные чата Cursor (раннер может быть устаревшим). Повторное открытие всё ещё доступно — обновите CLI на той машине, если оно не сработает.', + 'runner.skew.updateRequired': 'ТРЕБУЕТСЯ ОБНОВЛЕНИЕ', + 'runner.skew.banner.title': 'Раннер устарел на {host}', + 'runner.skew.banner.body': 'Этому хабу нужна более новая версия HAPI CLI на {host} (сейчас {version}). Обновите CLI на той машине, затем перезапустите раннер (`systemctl --user restart hapi-runner` или `hapi runner start`). Этот баннер будет отображаться, пока раннер не сообщит о наличии требуемых возможностей.', + 'runner.skew.banner.bodyUnknownVersion': 'Этому хабу нужна более новая версия HAPI CLI на {host}. Обновите CLI на той машине, затем перезапустите раннер (`systemctl --user restart hapi-runner` или `hapi runner start`). Этот баннер будет отображаться, пока раннер не сообщит о наличии требуемых возможностей.', + 'runner.skew.banner.summaryTitle': 'Устаревших раннеров: {count}', + 'runner.skew.banner.summaryBody': 'Обновите HAPI CLI на каждом хосте ниже, затем перезапустите соответствующий раннер. Сверните или отложите этот баннер, чтобы добраться до сессий под ним.', + 'runner.skew.banner.minimized': 'Устаревших раннеров: {count} ({hosts}) — нажмите, чтобы развернуть', + 'runner.skew.banner.minimize': 'Свернуть', + 'runner.skew.banner.dismissTemp': 'Отложить на 1 ч', + 'runner.skew.banner.restart': 'Перезапустить раннер', + 'runner.skew.banner.restartUnavailable': 'Сначала обновите CLI', + 'runner.skew.banner.restartNeedsNewerBinary': 'Перезапуск поможет только после установки более новой версии HAPI CLI на этом хосте. Он не скачивает пакеты.', + 'runner.skew.banner.restartNeedsSupervisor': 'Перезапуск работает только когда раннер под наблюдением (HAPI_RUNNER_SUPERVISED=1 / systemd). Остановка без наблюдателя оставит хост офлайн.', + 'runner.skew.banner.restarting': 'Перезапуск…', + 'runner.skew.banner.binaryUpdatedHint': '(на диске уже более новая версия CLI — раннер скоро перезапустится сам)', + 'runner.skew.banner.upgradeCliFirst': '(на диске ещё нет более новой версии CLI — установите/обновите на этом хосте)', + 'runner.skew.banner.handoffHint': 'После обновления CLI на хосте этот раннер обычно перезапускается сам (передача версии). Перезапуск здесь только останавливает раннер под наблюдением, чтобы systemd/pm2 перезапустил его — никогда не используйте на раннере отдельного ноутбука.', + 'runner.skew.restartFailed': 'Не удалось перезапустить раннер', + 'session.action.delete': 'Удалить', + 'session.action.copy': 'Копировать', + 'session.action.copyReference': 'Копировать ссылку', + + // Диалоги + 'dialog.uri.title': 'Открыть эту ссылку?', + 'dialog.uri.description': 'Эта ссылка использует пользовательскую схему URI. Убедитесь, что доверяете этой ссылке, прежде чем открывать её.', + 'dialog.uri.open': 'Открыть', + 'dialog.uri.alwaysAllow': 'Всегда разрешать {scheme}:', + 'dialog.rename.title': 'Переименовать сессию', + 'dialog.rename.placeholder': 'Название сессии', + 'dialog.rename.save': 'Сохранить', + 'dialog.rename.saving': 'Сохранение…', + 'dialog.rename.error': 'Не удалось переименовать. Попробуйте ещё раз.', + 'dialog.rename.generate': 'Сгенерировать', + 'dialog.rename.generating': 'Генерация…', + 'dialog.rename.generateError': 'Не удалось сгенерировать название. Попробуйте ещё раз.', + 'dialog.archive.title': 'Архивировать сессию', + 'dialog.archive.description': 'Вы уверены, что хотите архивировать «{name}»? Активная сессия будет отключена.', + 'dialog.archive.confirm': 'Архивировать', + 'dialog.archive.confirming': 'Архивирование…', + 'dialog.reopen.errorTitle': 'Не удалось открыть сессию заново', + 'dialog.reopen.dismiss': 'Закрыть', + 'dialog.delete.title': 'Удалить сессию', + 'dialog.delete.description': 'Вы уверены, что хотите удалить «{name}»? Это действие необратимо.', + 'dialog.delete.scratchlist.one': 'Также будет удалена 1 запись блокнота.', + 'dialog.delete.scratchlist.other': 'Также будут удалены записи блокнота: {n}.', + 'dialog.delete.confirm': 'Удалить', + 'dialog.delete.confirming': 'Удаление…', + 'dialog.error.default': 'Операция не удалась. Попробуйте ещё раз.', + + // Экспорт сессии + 'session.export.title': 'Экспорт диалога', + 'session.export.description': 'Выберите формат, затем скачайте весь видимый диалог и записи блокнота.', + 'session.export.format.json': 'JSON', + 'session.export.format.json.description': 'Полные данные без потерь: метаданные сессии, сообщения и записи блокнота (только метаданные вложений).', + 'session.export.format.markdown': 'Markdown', + 'session.export.format.markdown.description': 'Читаемое представление, сгенерированное из тех же данных экспорта.', + 'session.export.download': 'Скачать', + 'session.export.downloadAnyway': 'Скачать в любом случае', + 'session.export.downloading': 'Экспорт…', + 'session.export.warning.title': 'Большой экспорт', + 'session.export.warning.description': 'Этот экспорт содержит {count} сообщений и оценивается в {size}. Это превышает рекомендуемый порог в {limit} сообщений.', + 'session.export.error.noApi': 'Нет соединения с сервером', + 'session.export.error.tooLarge': 'Этот экспорт слишком велик для безопасной подготовки ({count} сообщений, {size}; максимум {maxSize}).', + 'session.export.error.default': 'Не удалось экспортировать диалог', + 'session.export.toast.success.title': 'Диалог экспортирован', + 'session.export.toast.success.body': 'Скачан {filename}', + 'session.export.toast.error.title': 'Экспорт не удался', + + // Диаграммы Mermaid + 'mermaid.openFullscreen': 'Открыть диаграмму на весь экран', + 'mermaid.viewerTitle': 'Диаграмма', + 'mermaid.loading': 'Загрузка диаграммы…', + 'mermaid.renderError': 'Не удалось отобразить диаграмму.', + + // Общие кнопки + 'button.cancel': 'Отмена', + 'button.save': 'Сохранить', + 'button.confirm': 'Подтвердить', + 'button.close': 'Закрыть', + 'button.dismiss': 'Закрыть', + 'button.copy': 'Копировать', + 'button.paste': 'Вставить', + + // Форма новой сессии + 'newSession.title': 'Создать сессию', + 'newSession.machine': 'Машина', + 'newSession.directory': 'Каталог', + 'newSession.placeholder': '/путь/к/проекту', + 'newSession.browse': 'Обзор', + 'newSession.recent': 'Недавние пути', + 'newSession.type': 'Тип сессии', + 'newSession.type.simple': 'Обычная', + 'newSession.type.simple.desc': 'Использовать выбранный каталог как есть', + 'newSession.type.worktree': 'Рабочее дерево', + 'newSession.type.worktree.desc': 'Создать новое рабочее дерево рядом с репозиторием (Cursor использует нативные ~/.cursor/worktrees)', + 'newSession.type.worktree.placeholder': 'feature-x (по умолчанию 1228-xxxx)', + 'newSession.agent': 'Агент', + 'newSession.agentAvailabilityLoading': 'Проверка установленных агентов…', + 'newSession.agentAvailabilityFailed': 'Не удалось проверить установленные агенты.', + 'newSession.runnerUpgradeRequired': 'Перед созданием сессий обновите и перезапустите раннер HAPI на этой машине.', + 'newSession.noAvailableAgents': 'На этой машине не установлены поддерживаемые агенты.', + 'newSession.agentUnavailable': 'Выбранный агент больше недоступен на этой машине. Обновите список и выберите другого агента.', + 'newSession.directoryOutsideWorkspaceRoots': 'Каталог должен находиться внутри одного из корней рабочего пространства этой машины.', + 'newSession.model': 'Модель', + 'newSession.effort': 'Усилия', + 'newSession.model.optional': 'необязательно', + 'newSession.model.default': 'По умолчанию', + 'newSession.model.loadFailed': 'Не удалось загрузить модели', + 'newSession.model.selectVariant': 'Выбрать вариант', + 'newSession.model.cursorUnavailable': 'Моделей Cursor пока нет. Запустите сессию Cursor один раз или используйте Auto.', + 'newSession.agyModel.fetchingModels': 'Получение доступных моделей…', + 'newSession.agyModel.authRequired': 'Требуется аутентификация', + 'newSession.agyModel.authHint': 'Выполните `agy` в терминале, чтобы войти через Google.', + 'newSession.agyModel.retry': 'Повторить', + 'newSession.agyModel.notListed': 'больше не в списке', + 'newSession.agyModel.noModels': 'Нет доступных моделей Antigravity', + 'newSession.opencodeModel.loading': 'Поиск моделей OpenCode…', + 'newSession.opencodeModel.loadFailed': 'Не удалось загрузить модели OpenCode', + 'newSession.opencodeModel.retry': 'Повторить', + 'newSession.opencodeModel.empty': 'Для этого каталога не найдено моделей OpenCode', + 'newSession.opencodeModel.default': 'По умолчанию', + 'newSession.permission.managedTitle': 'Управляется агентом', + 'newSession.permission.managedDesc': 'Этот агент управляет своими разрешениями самостоятельно. Режим YOLO недоступен.', + 'newSession.reasoningEffort': 'Уровень рассуждений', + 'newSession.collaborationMode': 'Режим совместной работы', + 'newSession.copilotAgentMode': 'Режим агента', + 'newSession.fastMode': 'Быстрый режим', + 'newSession.yolo': 'Режим YOLO', + 'newSession.yolo.title': 'Обойти подтверждения и песочницу', + 'newSession.yolo.desc': 'Использует опасные флаги агента при запуске.', + 'newSession.yolo.mapsTo': 'Применяет нативный режим {mode}.', + 'newSession.grokAutoUnavailable': 'недоступно', + 'newSession.grokAutoUnavailableDesc': 'Grok не включил автоматические разрешения для этой учётной записи или сборки CLI.', + 'newSession.create': 'Создать', + 'newSession.creating': 'Создание…', + 'button.retry': 'Повторить', + + 'session.directoryMissingSimple': 'Каталог не существует. При создании сессии он будет создан автоматически.', + 'session.directoryMissingSimpleConfirm': 'Каталог не существует. Нажмите ещё раз, чтобы создать его автоматически.', + 'session.directoryMissingWorktree': 'Сессии с рабочим деревом требуют существующего каталога репозитория.', + 'session.codexModelsLoadFailed': 'Не удалось загрузить модели Codex', + 'session.createAndCreateDirectory': 'Создать и создать каталог', + + // Машина + 'machine.unknown': 'Неизвестная платформа', + 'machine.os.windows': 'Windows', + 'machine.os.linux': 'Linux', + 'machine.os.macos': 'macOS', + 'machine.os.unknown': 'Неизвестная ОС', + 'machine.header.sessionCount': 'Сессий: {n}', + 'machine.health.tooltip.title': 'Ёмкость машины', + 'machine.health.status.healthy': 'В норме — есть место для новых агентов', + 'machine.health.status.elevated': 'Повышенная — новые агенты могут работать медленнее', + 'machine.health.status.high': 'Высокая нагрузка — лучше не запускать здесь новые', + 'machine.health.status.unknown': 'Метрики недоступны', + 'machine.health.metric.cpu': 'ЦП', + 'machine.health.metric.ram': 'ОЗУ', + 'machine.health.tooltip.load': 'Очередь выполнения (1 мин): {value}', + 'machine.health.tooltip.loadShort': 'Нагрузка (1м)', + 'machine.health.tooltip.uptimeShort': 'Аптайм', + 'machine.health.uptimeCompact': 'работает {value}', + 'machine.health.tooltip.hint': 'Обновляется примерно каждые 20 с раннером на этой машине', + 'machine.health.aria.cpu': 'ЦП {n} процентов', + 'machine.health.aria.ram': 'ОЗУ {n} процентов', + 'machine.health.aria.unknown': 'Состояние машины недоступно', + + // Чат + 'chat.placeholder': 'Введите сообщение…', + 'chat.send': 'Отправить', + 'chat.abort': 'Прервать', + 'chat.settings': 'Настройки', + 'chat.terminal': 'Терминал', + 'chat.switchRemote': 'Переключиться в удалённый режим', + 'chat.sendError.fallback': 'Не удалось отправить сообщение. Отредактируйте и попробуйте ещё раз.', + 'chat.sendError.sessionInactive': 'Эта сессия заархивирована. Откройте её заново, чтобы отправить сообщение.', + 'chat.sendError.sessionInactive.action': 'Открыть заново', + + // Обзор Codex + 'codexReview.title': 'Обзор Codex', + 'codexReview.findings': 'Находок: {count}', + 'codexReview.confidence': 'Уверенность {value}', + 'codexReview.location.missing': 'Нет местоположения', + + // Терминал + 'terminal.commandName': 'Команда', + 'terminal.commandMessage': 'Сообщение команды', + 'terminal.commandArgs': 'Аргументы команды', + 'terminal.stdout': 'Stdout', + 'terminal.stderr': 'Stderr', + 'terminal.unsupportedWindows': 'Удалённый терминал недоступен на этом хосте.', + 'terminal.retry': 'Повторить', + 'terminal.inputMode.label': 'Режим ввода терминала', + 'terminal.inputMode.command': 'Команда', + 'terminal.inputMode.direct': 'Прямой', + 'terminal.command.placeholder': 'Введите команду…', + 'terminal.command.inputLabel': 'Ввод команды', + 'terminal.command.run': 'Выполнить команду', + 'terminal.command.shortcuts': 'Основные команды', + 'terminal.direct.pages': 'Страницы прямых клавиш', + 'terminal.direct.page.control': 'Управление', + 'terminal.direct.page.navigation': 'Навигация', + 'terminal.direct.page.shell': 'Оболочка', + 'terminal.direct.page.symbols': 'Символы', + 'terminal.paste.fallbackTitle': 'Вставить ввод', + 'terminal.paste.fallbackDescription': 'Чтение из буфера обмена недоступно. Вставьте текст ниже.', + 'terminal.paste.placeholder': 'Вставьте ввод терминала сюда…', + + // Блок кода + 'code.copy': 'Копировать', + 'code.truncated': 'Предпросмотр обрезан — откройте детали для полного вывода', + 'code.wrap.enable': 'Включить перенос строк', + 'code.wrap.disable': 'Отключить перенос строк', + + // Просмотр изменений + 'diff.title': 'Изменения', + 'diff.view': 'Просмотр', + + // Страница файлов + 'files.page.title': 'Файлы', + 'files.page.refresh': 'Обновить', + 'files.page.refreshFilesystem': 'Обновить вид файловой системы', + 'files.page.searchPlaceholder': 'Поиск файлов', + 'files.projectRoot': 'корень проекта', + 'files.branch.detached': 'отделённый HEAD', + 'files.branch.summary': '{staged} индексировано, {unstaged} не индексировано', + 'files.tab.changes': 'Изменения', + 'files.tab.directories': 'Каталоги', + 'files.search.empty': 'Нет файлов, соответствующих поиску.', + 'files.search.error.failed': 'Не удалось выполнить поиск файлов.', + 'files.search.error.failedWithDetail': 'Не удалось выполнить поиск файлов: {error}', + 'files.changes.section.staged': 'Индексированные изменения ({n})', + 'files.changes.section.unstaged': 'Неиндексированные изменения ({n})', + 'files.changes.empty.unavailable': 'Статус Git недоступен. Используйте «Каталоги» для просмотра всех файлов или поиск.', + 'files.changes.empty.none': 'Изменений не обнаружено. Используйте «Каталоги» для просмотра всех файлов или поиск.', + 'files.changes.error.gitStatusUnavailable': 'Статус Git недоступен.', + 'files.changes.error.gitStatusUnavailableWithDetail': 'Статус Git недоступен: {error}', + 'files.changes.error.unstagedDiffUnavailableWithDetail': 'Неиндексированные изменения недоступны: {error}', + 'files.changes.error.stagedDiffUnavailableWithDetail': 'Индексированные изменения недоступны: {error}', + 'files.directories.empty': 'Пустой каталог.', + 'files.directories.error.listFailed': 'Не удалось получить список каталога.', + 'files.directories.error.listFailedWithDetail': 'Не удалось получить список каталога: {error}', + 'files.directories.download': 'Скачать файл', + 'files.directories.downloadNamed': 'Скачать {name}', + 'files.directories.download.error.title': 'Скачивание не удалось', + 'files.directories.download.error.default': 'Не удалось скачать файл.', + 'files.sort.title': 'Сортировка файлов', + 'files.sort.by': 'Сортировать по', + 'files.sort.name': 'Имя', + 'files.sort.modified': 'Время изменения', + 'files.sort.size': 'Размер файла', + 'files.sort.nameAsc': 'От А до Я', + 'files.sort.nameDesc': 'От Я до А', + 'files.sort.oldest': 'Сначала старые', + 'files.sort.newest': 'Сначала новые', + 'files.sort.smallest': 'Сначала маленькие', + 'files.sort.largest': 'Сначала большие', + + // Контекстное меню файла + 'file.menu.title': 'Действия с файлом', + 'file.menu.copyPath': 'Копировать путь', + 'file.menu.copyAbsolutePath': 'Копировать абсолютный путь', + 'file.menu.addToComposer': 'Добавить в редактор', + + // Страница файла + 'file.page.fallbackName': 'Файл', + 'file.page.unknownPath': 'Неизвестный путь', + 'file.page.copyPath': 'Копировать путь', + 'file.page.copyContent': 'Копировать содержимое файла', + 'file.page.download': 'Скачать файл', + 'file.page.tab.diff': 'Изменения', + 'file.page.tab.file': 'Файл', + 'file.page.tab.source': 'Исходник', + 'file.page.tab.preview': 'Предпросмотр', + 'file.page.missingPath': 'Путь к файлу не указан.', + 'file.page.binary': 'Похоже, это бинарный файл. Его нельзя отобразить.', + 'file.page.imagePreviewAlt': 'Предпросмотр изображения {name}', + 'file.page.empty': 'Файл пуст.', + 'file.page.noChanges': 'Нет изменений для отображения.', + 'file.error.readFailed': 'Не удалось прочитать файл.', + 'file.error.readFailedWithDetail': 'Не удалось прочитать файл: {error}', + 'file.error.diffUnavailable': 'Изменения недоступны.', + 'file.error.diffUnavailableWithDetail': 'Изменения недоступны: {error}', + + // Встроенные медиа + 'media.displayed.image': 'Показано изображение', + 'media.displayed.video': 'Показано видео', + 'media.displayed.audio': 'Показано аудио', + 'media.displayed.file': 'Показан файл', + 'media.displayed.header': '{label}: {fileName}', + 'media.displayed.unavailable': '{label} недоступно. {error}', + + // Карточка инструмента + 'tool.askQuestion': 'Другое', + 'tool.edit': 'Правка', + 'tool.multiedit': 'Мультиправка', + 'tool.write': 'Запись', + 'tool.todo': 'Задачи', + 'tool.confirm': 'Подтвердить', + 'tool.deny': 'Отклонить', + 'tool.abort': 'Прервать', + 'tool.allow': 'Разрешить', + 'tool.allowAll': 'Разрешить все правки', + 'tool.denyAll': 'Запретить все правки', + 'tool.approved': 'Одобрено', + 'tool.rejected': 'Отклонено', + 'tool.cancel': 'Отмена', + 'tool.exitPlan': 'Выйти из режима плана', + 'tool.plan.implement': 'Реализовать план', + 'tool.plan.continue': 'Продолжить планирование', + 'tool.patch': 'Патч', + 'tool.input': 'Ввод', + 'tool.label': 'Инструмент', + 'tool.more': '+ещё {count}', + 'tool.trace': 'Трассировка', + 'tool.trace.callsSuffix': 'вызовов', + 'tool.result': 'Результат', + 'tool.duration': 'Длительность', + 'tool.startedAt': 'Начато', + 'tool.completedAt': 'Завершено', + 'tool.semanticTitle.readFile': 'Чтение файла', + 'tool.semanticTitle.runShell': 'Выполнение команды', + 'tool.semanticTitle.search': 'Поиск', + 'tool.semanticTitle.openUrl': 'Открыть URL', + 'tool.semanticTitle.query': 'Запрос', + 'tool.questionsAnswers': 'Вопросы и ответы', + 'tool.submit': 'Отправить', + 'tool.submitting': 'Отправка…', + 'tool.prev': '← Назад', + 'tool.next': 'Далее →', + 'tool.yes': 'Да', + 'tool.yesForSession': 'Да для сессии', + 'tool.allowForSession': 'Разрешить для сессии', + 'tool.abortLabel': 'Прервать', + 'tool.other': 'Другое', + 'tool.otherDescription': 'Введите свой ответ', + 'tool.question': 'Вопрос', + 'tool.selectOption': 'Выберите хотя бы один вариант или введите ответ.', + 'tool.waitingForApproval': 'Ожидание одобрения…', + 'tool.resolved': 'Разрешено в Codex', + 'tool.canceled': 'Отменено', + 'tool.approvedForSession': 'Одобрено для сессии', + 'tool.aborted': 'Прервано', + 'tool.approvedAllowAllEdits': 'Одобрено: разрешить все правки', + 'tool.requestFailed': 'Запрос не удался', + 'tool.askUserQuestion.fallback': 'Данные AskUserQuestion не в ожидаемом формате. Введите ответ:', + 'tool.askUserQuestion.placeholder': 'Введите ответ…', + 'tool.askUserQuestion.otherPlaceholder': 'Или введите свой ответ…', + 'tool.requestUserInput.textPlaceholder': 'Введите ответ…', + 'tool.requestUserInput.noteLabel': 'Дополнительное примечание (необязательно)', + 'tool.requestUserInput.notePlaceholder': 'Добавить примечание…', + 'tool.requestUserInput.otherLabel': 'Ничего из вышеперечисленного', + 'tool.requestUserInput.otherDescription': 'При желании добавьте детали в примечаниях.', + 'tool.requestUserInput.popupBlocked': 'Не удалось открыть страницу входа. Разрешите всплывающие окна и попробуйте ещё раз.', + 'toolGroup.title': 'Активность инструментов', + 'toolGroup.friendly.inspectTarget': 'Проверить {target}', + 'toolGroup.friendly.searchTarget': 'Найти «{target}»', + 'toolGroup.friendly.runTarget': 'Выполнить {target}', + 'toolGroup.friendly.editTarget': 'Изменить {target}', + 'toolGroup.primary.fileTargets': '{target} +{n}', + 'toolGroup.primary.commandTargets': '{target} +{n}', + 'toolGroup.primary.searchTargets': '{target} +{n}', + 'toolGroup.primary.urlTargets': '{target} +{n}', + 'toolGroup.primary.otherTargets': '{target} +{n}', + 'toolGroup.summary.read': 'Чтение {n}', + 'toolGroup.summary.mutation': 'Правка {n}', + 'toolGroup.summary.command': 'Выполнение {n}', + 'toolGroup.summary.search': 'Поиск {n}', + 'toolGroup.summary.web': 'Веб {n}', + 'toolGroup.summary.other': 'Инструмент {n}', + 'toolGroup.friendly.inspectFiles': 'Проверить файлы проекта', + 'toolGroup.friendly.searchContent': 'Найти в содержимом проекта', + 'toolGroup.friendly.runCommands': 'Выполнить команды проекта', + 'toolGroup.friendly.editFiles': 'Изменить файлы проекта', + 'toolGroup.friendly.openWeb': 'Просмотреть веб-содержимое', + 'toolGroup.friendly.genericCommand': 'Выполнить команду', + 'toolGroup.friendly.genericTool': 'Использовать инструмент', + 'toolGroup.badge.running': '{n} выполняется', + 'toolGroup.badge.pending': '{n} в ожидании', + 'toolGroup.badge.error': '{n} ошибка', + 'toolGroup.badge.fileTargets': '{n} файлов', + 'toolGroup.toolCount': '{n} действий', + 'toolGroup.loadingOlderHistory': 'Загрузка более ранней активности инструментов…', + 'toolGroup.historyUnavailable': 'Более ранняя активность инструментов недоступна.', + 'toolGroup.rowStatus.running': 'Выполняется', + 'toolGroup.rowStatus.pending': 'Ожидание', + 'toolGroup.rowStatus.error': 'Ошибка', + 'toolGroup.codex.exploring': 'Изучение', + 'toolGroup.codex.explored': 'Изучено', + 'toolGroup.codex.read': 'Чтение', + 'toolGroup.codex.list': 'Список', + 'toolGroup.codex.search': 'Поиск', + 'toolGroup.codex.searchIn': '{query} в {path}', + + // Кнопки редактора + 'composer.settings': 'Настройки', + 'composer.terminal': 'Терминал', + 'composer.abort': 'Прервать', + 'composer.switchRemote': 'Переключиться в удалённый режим', + 'composer.attach': 'Прикрепить файл', + 'composer.expand': 'Развернуть редактор сообщений', + 'composer.collapse': 'Свернуть редактор сообщений', + 'composer.dropToAttach': 'Перетащите, чтобы прикрепить', + 'composer.send': 'Отправить', + 'composer.stop': 'Остановить', + 'composer.voice': 'Голосовой ассистент', + 'composer.dictate': 'Диктовка (Ctrl/Cmd+Shift+D)', + 'composer.scheduleSend': 'Отложенная отправка', + 'composer.scheduleRelativeTab': 'Относительно', + 'composer.scheduleSpecificTab': 'Точное время', + 'composer.scheduleSpecificHint': 'Максимум 7 дней. Требуется работающий хаб; CLI наверстает отправку при следующем подключении после этого времени.', + 'composer.scheduleErrorPast': 'Время отправки должно быть в будущем.', + 'composer.scheduleErrorTooFar': 'Максимальное время отправки — 7 дней.', + 'queuedMessages.scheduledFor': 'Запланировано на {time}', + 'queuedMessages.editAlreadyInvoked': 'Сообщение уже отправлено — его нельзя отредактировать', + 'queuedMessages.editBusyNotRestored': 'Доставка всё ещё не завершена — черновик не восстановлен (может ещё быть в пути)', + 'queuedMessages.editCurrentDraftKept': 'Отложенное сообщение отменено — текущий черновик и расписание сохранены.', + 'queuedMessages.steer': 'Доставить в текущий ход сейчас', + 'queuedMessages.steerFailed': 'Не удалось направить — сообщение остаётся в очереди', + 'queuedMessages.steerOutcomeUnknown': 'Исход доставки неизвестен — повторите или явно отмените', + 'queuedMessages.retryOutcome': 'Повторить неопределённую доставку', + 'queuedMessages.retryFailed': 'Повтор не удался', + 'queuedMessages.retryMissing': 'Сообщение больше недоступно', + 'queuedMessages.retryUnavailable': 'Агент всё ещё обрабатывает эту доставку', + 'queuedMessages.steeredBadge': '↳ Направлено', + 'queuedMessages.steeredBadgeTitle': 'Направлено в активный ход', + + // Блокнот (рабочее пространство сессии) + 'scratchlist.title': 'Блокнот', + 'scratchlist.heldLabel': 'отложено — не отправлено', + 'scratchlist.empty': 'пусто', + 'scratchlist.count.one': '1 элемент', + 'scratchlist.count.other': 'Элементов: {n}', + 'scratchlist.attachmentOnly': '(вложение)', + 'scratchlist.emptyHint': 'Откладывайте сюда заметки, черновики и идеи. Ничего не отправляется, пока вы не продвинете запись.', + 'scratchlist.drawerHint': 'Печатайте ниже — кнопка «Отправить» добавляет следующее сообщение в блокнот, а не в чат. Нажмите иконку заметки ещё раз, чтобы выйти.', + 'scratchlist.toggleAriaLabel': 'Панель блокнота', + 'scratchlist.toggleTooltip': 'Блокнот — откладывать заметки и черновики (Ctrl/Cmd+Shift+S)', + 'scratchlist.sendToScratchlist': 'Отправить в блокнот', + 'scratchlist.fueTitle': 'Новое: блокнот', + 'scratchlist.fueBody': 'Откладывайте сюда заметки и черновики без отправки. Пока вы копите записи, кнопка «Отправить» светится янтарным; нажмите иконку (или Ctrl/Cmd+Shift+S) ещё раз, чтобы выйти.', + 'richComposer.fueTitle': 'Новое: @упоминание другой сессии', + 'richComposer.fueBody': 'Введите @, чтобы упомянуть другую сессию. Она станет чипом (наведите для деталей в стиле боковой панели). При отправке сообщение включает полный ID этой сессии, чтобы агент мог вызвать inspect_peer или ping_peer.', + 'scratchlist.addPlaceholder': 'Заметка, черновик или идея — Enter для добавления', + 'scratchlist.addAriaLabel': 'Добавить запись в блокнот', + 'scratchlist.add': 'Добавить', + 'scratchlist.atCap': 'Блокнот достиг лимита в {n} записей. Удалите старую запись, чтобы добавить новую.', + 'scratchlist.confirmDelete': 'Удалить эту запись блокнота? Это действие необратимо.', + 'scratchlist.listAriaLabel': 'Записи блокнота', + 'scratchlist.action.moveUp': 'Переместить запись вверх', + 'scratchlist.action.moveDown': 'Переместить запись вниз', + 'scratchlist.action.promoteToComposer': 'Скопировать в редактор', + 'scratchlist.action.promoteToQueue': 'Отправить в очередь', + 'scratchlist.action.copy': 'Скопировать текст в буфер обмена (не изображения)', + 'scratchlist.action.copied': 'Скопировано!', + 'scratchlist.action.delete': 'Удалить запись', + 'scratchlist.entry.lastSaved': 'Сохранено {time}', + 'scratchlist.entry.lastSavedAriaLabel': 'Запись сохранена {time}', + 'scratchlist.migrationBanner.title': 'Блокнот теперь синхронизируется между устройствами', + 'scratchlist.migrationBanner.body': 'Ваши существующие заметки скопированы в хаб — ничего не потеряно. Теперь изменения в этой сессии будут появляться на каждом используемом вами устройстве.', + 'scratchlist.migrationBanner.dismiss': 'Понятно', + 'fue.newFeatureDot': 'Доступна новая функция', + 'fue.gotIt': 'Понятно', + 'fue.closeAriaLabel': 'Закрыть пояснение', + 'composer.codexSlashUnsupported.title': 'Команда Codex недоступна', + 'composer.codexSlashUnsupported.body': 'Удалённый режим HAPI пока не выполняет встроенные слэш-команды Codex, такие как {command}. Используйте обычный язык или выполните её в локальном TUI Codex.', + + // Голосовой ассистент + 'voice.connecting': 'Подключение...', + 'voice.active': 'Активен', + 'voice.muted': 'Выключен звук', + 'voice.error': 'Ошибка', + 'voice.mute': 'Выключить звук', + 'voice.unmute': 'Включить звук', + 'voice.end': 'Завершить', + 'voice.error.micPermission': 'Доступ к микрофону запрещён', + 'voice.error.network': 'Ошибка сети', + 'voice.error.notInitialized': 'Голосовая сессия не инициализирована', + 'voice.error.startFailed': 'Не удалось запустить голосовую сессию', + 'voice.error.notAllowed': 'Голос недоступен', + 'voice.error.connection': 'Ошибка подключения', + 'voice.dismiss': 'Закрыть', + + // Баннеры + 'offline.title': 'Офлайн', + 'offline.message': 'Вы сейчас офлайн. Некоторые функции могут быть ограничены.', + 'syncing.title': 'Синхронизация…', + 'syncing.message': 'Ваши данные синхронизируются.', + 'reconnecting.message': 'Переподключение...', + 'reconnecting.reason.error': 'ошибка потока', + 'reconnecting.reason.closed': 'поток закрыт', + 'reconnecting.reason.heartbeatTimeout': 'таймаут heartbeat', + 'reconnecting.reason.visibilityRecovery': 'возобновление после фона', + 'reconnecting.reason.connectTimeout': 'таймаут подключения', + 'reconnecting.reason.transportError': 'ошибка потока', + 'pwa.update.title': 'Доступна новая версия', + 'pwa.update.body': 'Перезагрузите, чтобы получить последнюю версию HAPI', + 'pwa.update.reload': 'Перезагрузить', + 'pwa.update.whyToggle': 'Почему нельзя закрыть?', + 'pwa.update.whyBody': 'HAPI не будет перезагружать вкладку автоматически, пока у вас может работать агент, ожидаться разрешение или отправляться сообщение. Запуск старой веб-сборки с текущим сервером может вызвать ошибки синхронизации и неудачные действия. Этот баннер остаётся видимым до перезагрузки, чтобы вы случайно не застряли на устаревшей версии — но вы сами решаете, когда нажать «Перезагрузить», закончив текущие дела.', + + // Отправка заблокирована + 'send.blocked.title': 'Не удаётся отправить сообщение', + 'send.blocked.noConnection': 'Нет соединения с сервером', + 'resume.failed.title': 'Не удалось возобновить', + 'toast.ready.title': 'Готово к вводу', + 'toast.ready.body': '{agent} ожидает в {session}', + 'toast.permission.title': 'Запрос разрешения', + 'toast.input.title': '{agent} нужен ваш ввод', + 'toast.task.completed': 'Задача завершена', + 'toast.task.failed': 'Задача не удалась', + + // Предложение установки + 'install.title': 'Установить HAPI', + 'install.description': 'Добавьте HAPI на главный экран для быстрого доступа.', + 'install.button': 'Установить', + + // Переключатель языка + 'language.title': 'Язык', + 'language.english': 'English', + 'language.chinese': '简体中文', + + // Страница настроек + 'common.back': 'Назад', + 'settings.title': 'Настройки', + 'settings.hub.description': 'Выберите категорию, чтобы настроить HAPI под свой рабочий процесс.', + 'settings.hub.voice.summary': 'Голос, язык и поведение', + 'settings.general.title': 'Общие', + 'settings.storage.title': 'Хранилище', + 'settings.storage.summary': 'Использование базы данных хаба', + 'settings.storage.description': 'Текущий размер базы данных SQLite хаба на диске.', + 'settings.storage.loading': 'Загрузка использования хранилища…', + 'settings.storage.error': 'Не удалось загрузить использование хранилища', + 'settings.storage.total': 'Всего', + 'settings.storage.database': 'База данных', + 'settings.storage.wal': 'Журнал предзаписи', + 'settings.storage.shm': 'Разделяемая память', + 'settings.storage.path': 'Путь', + 'settings.storage.refresh': 'Обновить', + 'settings.storage.refreshing': 'Обновление…', + 'settings.storage.chartTitle': 'Относительная доля', + 'settings.storage.chartEmpty': 'Пока нет данных о размере на диске.', + 'settings.usage.title': 'Расход токенов', + 'settings.usage.summary': 'Панель расхода только по токенам', + 'settings.usage.description': 'Токены, обработанные при управлении агентами через HAPI. Ранее импортированные расшифровки не учитываются; стоимость не входит.', + 'settings.usage.range.label': 'Период', + 'settings.usage.range.7d': '7 дней', + 'settings.usage.range.30d': '30 дней', + 'settings.usage.range.all': 'Всё время', + 'settings.usage.loading': 'Загрузка расхода токенов…', + 'settings.usage.error': 'Не удалось загрузить расход токенов', + 'settings.usage.empty': 'За этот период расход токенов не зафиксирован.', + 'settings.usage.total': 'Обработано токенов (с учётом кэша)', + 'settings.usage.uncached': 'Токены без кэша', + 'settings.usage.input': 'Входные токены (включая кэш)', + 'settings.usage.output': 'Выходные токены', + 'settings.usage.cacheRead': 'Чтение кэша (включено во входные)', + 'settings.usage.cacheCreation': 'Создание кэша (включено)', + 'settings.usage.cacheHitRate': 'Доля попаданий в кэш', + 'settings.usage.requests': 'Запросы', + 'settings.usage.daily.title': 'Динамика по дням', + 'settings.usage.agent.title': 'По агентам', + 'settings.usage.model.title': 'По моделям', + 'settings.usage.bucketDetails': '{requests} запросов · {input} вход · {output} выход', + 'settings.usage.sessions': 'Сессий с расходом: {count}', + 'settings.general.description': 'Язык, сопряжение с приложением-компаньоном и общие предпочтения приложения.', + 'settings.general.sessionSummary.title': 'Сводки статуса сессий', + 'settings.general.sessionSummary.description': 'Выберите, будут ли поддерживаемые агенты формировать сводки статуса и отображаться ли они в чате.', + 'settings.general.sessionSummaryContract': 'Формировать сводки статуса', + 'settings.general.sessionSummaryContract.desc': 'По умолчанию выключено. При включении поддерживаемых агентов просят добавлять после каждого хода завершающую строку AGENT_NOTIFY_SUMMARY для уведомлений и записей о фоновой работе. Применяется к новым/возобновлённым сессиям. (Поддерживаются: Claude, Codex, OpenCode, удалённый Grok; пока не поддерживаются: локальный Grok, Cursor)', + 'settings.general.sessionSummaryInChat': 'Показывать сводки статуса в чате', + 'settings.general.sessionSummaryInChat.desc': 'Влияет только на отображение в чате и скопированном содержимом; не влияет на формирование сводок, уведомления или записи о фоновой работе. При включении показывается строка статуса, при выключении она скрыта. Сохранённые сообщения не изменяются.', + 'settings.language.title': 'Язык', + 'settings.language.label': 'Язык', + 'settings.display.title': 'Отображение', + 'settings.display.description': 'Внешний вид, типографика и настройки списка сессий.', + 'settings.display.typography': 'Типографика', + 'settings.display.sessions': 'Список сессий', + 'settings.display.appearance': 'Оформление', + 'settings.display.appearanceMode': 'Режим оформления', + 'settings.display.appearance.system': 'Как в системе', + 'settings.display.appearance.dark': 'Тёмная', + 'settings.display.appearance.oled': 'OLED-чёрный', + 'settings.display.appearance.light': 'Светлая', + 'settings.display.colorTheme': 'Цветовая тема', + 'settings.display.colorTheme.default': 'По умолчанию', + 'settings.display.colorTheme.notion': 'Notion', + 'settings.display.colorTheme.one': 'One', + 'settings.display.colorTheme.proof': 'Proof', + 'settings.display.colorTheme.raycast': 'Raycast', + 'settings.display.colorTheme.rosePine': 'Rose Pine', + 'settings.display.colorTheme.solarized': 'Solarized', + 'settings.display.colorTheme.vercel': 'Vercel', + 'settings.display.colorTheme.vsCodePlus': 'VS Code Plus', + 'settings.display.colorTheme.xcode': 'Xcode', + 'settings.display.colorTheme.linear': 'Linear', + 'settings.display.colorTheme.lobster': 'Lobster', + 'settings.display.colorTheme.material': 'Material', + 'settings.display.colorTheme.matrix': 'Matrix', + 'settings.display.colorTheme.monokai': 'Monokai', + 'settings.display.colorTheme.nightOwl': 'Night Owl', + 'settings.display.colorTheme.nord': 'Nord', + 'settings.display.themeColors.title': 'Свои цвета', + 'settings.display.themeColors.description': 'Применяется к текущему оформлению. Переключите оформление, чтобы настроить каждое отдельно.', + 'settings.display.themeColors.reset': 'Сбросить', + 'settings.display.themeColors.resetAll': 'Сбросить все', + 'settings.display.themeColors.expand': 'Настроить', + 'settings.display.themeColors.key.background': 'Фон', + 'settings.display.themeColors.key.surface': 'Карточки и поверхности', + 'settings.display.themeColors.key.text': 'Текст', + 'settings.display.themeColors.key.hint': 'Приглушённый текст', + 'settings.display.themeColors.key.accent': 'Акцент и ссылки', + 'settings.display.themeColors.key.border': 'Границы', + 'settings.display.themeColors.key.userBubble': 'Пузырь вашего сообщения', + 'settings.display.fontSize': 'Размер шрифта', + 'settings.display.terminalFontSize': 'Размер шрифта терминала', + 'settings.display.sessionPreviewLimit': 'Сессий до сворачивания', + 'settings.display.sessionPreviewLimit.decrease': 'Показывать меньше сессий до сворачивания', + 'settings.display.sessionPreviewLimit.increase': 'Показывать больше сессий до сворачивания', + 'settings.display.activeSessionsOnly': 'Только активные сессии', + 'settings.display.activeSessionsOnly.desc': 'Скрыть неактивные сессии в боковой панели. Открытая сессия остаётся видимой.', + 'settings.display.pinInProgressSessions': 'Закреплять сессии в работе', + 'settings.display.pinInProgressSessions.desc': 'Перемещать незакреплённые подключённые сессии в разделы над папками проекта: сначала выполняющиеся и ожидающие, затем спокойные активные сессии (выполнение завершено, подключение есть). Глобальные закрепления остаются выше них. Выключено — всё остаётся в группах по каталогам.', + 'settings.display.appBadge': 'Значок непрочитанного на панели задач', + 'settings.display.appBadge.desc': 'Показывать число сессий с новой активностью в установленной PWA Edge или Chrome. Внешний вид определяет браузер хоста; в обычной вкладке браузера это не действует.', + 'settings.display.sessionListStatus': 'Подсказки статуса в списке сессий', + 'settings.display.sessionListStatus.standard': 'Базовые', + 'settings.display.sessionListStatus.detailed': 'Расширенные', + 'settings.display.sessionListStatus.detailedDescription': 'Выберите, какие подсказки статуса показывать в списке сессий. Базовые показывают состояние выполнения; расширенные также показывают подсказки о разрешениях, вводе, фоновых задачах, новой активности и отложенных сообщениях (иконка часов).', + 'settings.display.sessionHeader': 'Шапка сессии', + 'settings.display.sessionHeader.description': 'Выберите, какие детали показывать под названием сессии. На мобильных показывается агент плюс самая приоритетная доступная деталь.', + 'settings.display.sessionHeader.showLabels': 'Показывать подписи полей', + 'settings.display.sessionHeader.agent': 'Агент', + 'settings.display.sessionHeader.model': 'Модель', + 'settings.display.sessionHeader.reasoning': 'Уровень рассуждений', + 'settings.display.sessionHeader.fastMode': 'Быстрый режим', + 'settings.display.sessionHeader.machine': 'Машина', + 'settings.display.sessionHeader.lastActive': 'Время активности', + 'settings.display.sessionHeader.createdAt': 'Время создания', + 'settings.display.sessionHeader.updatedAt': 'Время обновления', + 'settings.display.sessionHeader.worktree': 'Ветка рабочего дерева', + 'settings.display.links': 'Ссылки', + 'settings.display.openExternalLinksInNewTab': 'Открывать внешние ссылки в новой вкладке', + 'settings.display.openExternalLinksInNewTab.desc': 'Выключено (по умолчанию) сохраняет текущее поведение. Включено — открывает внешние ссылки в отдельной вкладке браузера.', + 'session.header.createdAt': 'Создана', + 'session.header.updatedAt': 'Обновлена', + 'settings.chat.title': 'Чат', + 'settings.chat.description': 'Ввод сообщений, карточки инструментов и цвета диалога.', + 'settings.chat.input': 'Ввод', + 'settings.chat.tools': 'Карточки инструментов', + 'settings.chat.colors': 'Цвета диалога', + 'settings.chat.enterBehavior': 'Клавиша Enter', + 'settings.chat.enterBehavior.send': 'Отправить сообщение', + 'settings.chat.enterBehavior.newline': 'Вставить перенос строки', + 'settings.chat.terminalToolDisplay': 'Карточки инструментов терминала', + 'settings.chat.terminalToolDisplay.compact': 'Компактные (только команда)', + 'settings.chat.terminalToolDisplay.detailed': 'Подробные (показывать предпросмотр вывода)', + 'settings.chat.codexExplorationCollapsed': 'Сворачивать изученные группы инструментов по умолчанию', + 'settings.chat.codexExplorationCollapsed.desc': 'Держать активность чтения и поиска Codex свёрнутой, пока вы её не откроете.', + 'settings.chat.reasoningCollapsed': 'Сворачивать рассуждения по умолчанию', + 'settings.chat.reasoningCollapsed.desc': 'Держать сообщения с рассуждениями ИИ свёрнутыми, в том числе во время потокового вывода.', + 'settings.chat.groupedToolBackground': 'Фон сгруппированного использования инструментов', + 'settings.chat.userMessageBackground': 'Фон сообщения пользователя', + 'settings.chat.surfaceColor.default': 'Цвет по умолчанию', + 'settings.chat.surfaceColor.softBlue': 'Мягкий синий', + 'settings.chat.surfaceColor.softGreen': 'Мягкий зелёный', + 'settings.chat.surfaceColor.softYellow': 'Мягкий жёлтый', + 'settings.chat.surfaceColor.custom': 'Свой цвет', + 'settings.chat.composerToolbar.title': 'Настройка панели инструментов редактора', + 'settings.chat.composerToolbar.description': 'Выберите, как расположить действия панели под полем ввода сообщения.', + 'settings.chat.composerToolbar.layout': 'Выравнивание', + 'settings.chat.composerToolbar.order': 'Порядок инструментов', + 'settings.chat.composerToolbar.hidden': 'Скрытые инструменты', + 'settings.chat.composerToolbar.emptyHidden': 'Перетащите сюда инструменты, чтобы скрыть их', + 'settings.chat.composerToolbar.hide': 'Скрыть', + 'settings.chat.composerToolbar.show': 'Показать', + 'settings.chat.composerToolbar.layout.left': 'Слева', + 'settings.chat.composerToolbar.layout.center': 'По центру', + 'settings.chat.composerToolbar.layout.right': 'Справа', + 'settings.chat.composerToolbar.layout.split': 'Раздельно', + 'settings.chat.composerToolbar.dragHint': 'Перетаскивайте действия, чтобы изменить порядок. В раздельной раскладке перетаскивайте между двумя группами.', + 'settings.chat.composerToolbar.previewHint': 'Перетаскивайте реальные иконки панели ниже, чтобы изменить порядок, или перемещайте их в «Скрытые инструменты». Изменения применяются сразу; наведите на иконку, чтобы увидеть её действие.', + 'settings.chat.composerToolbar.reset': 'Сбросить', + 'settings.chat.composerToolbar.leftGroup': 'Левая группа', + 'settings.chat.composerToolbar.rightGroup': 'Правая группа', + 'settings.chat.composerToolbar.emptyGroup': 'Перетащите действия сюда', + 'settings.chat.composerToolbar.moveLeft': 'Переместить в левую группу', + 'settings.chat.composerToolbar.moveRight': 'Переместить в правую группу', + 'settings.chat.composerToolbar.moveEarlier': 'Переместить раньше', + 'settings.chat.composerToolbar.moveLater': 'Переместить позже', + 'settings.chat.composerToolbar.item.attachment': 'Добавить вложение', + 'settings.chat.composerToolbar.item.settings': 'Настройки сессии', + 'settings.chat.composerToolbar.item.expand': 'Развернуть редактор сообщений', + 'settings.chat.composerToolbar.item.model': 'Модель', + 'settings.chat.composerToolbar.item.effort': 'Усилия', + 'settings.chat.composerToolbar.item.terminal': 'Терминал', + 'settings.chat.composerToolbar.item.abort': 'Прервать', + 'settings.chat.composerToolbar.item.switch': 'Переключиться на удалённый', + 'settings.chat.composerToolbar.item.voiceMic': 'Микрофон голоса', + 'settings.chat.composerToolbar.item.scratchlist': 'Блокнот', + 'settings.chat.composerToolbar.item.schedule': 'Отложенная отправка', + 'settings.voice.title': 'Голос', + 'settings.voice.description': 'Выберите диктовку или полный голосовой ассистент.', + 'settings.voice.inputMode.title': 'Режим голоса', + 'settings.voice.inputMode.hint': 'Диктовка только вставляет текст в редактор; она никогда не отправляет автоматически.', + 'settings.voice.inputMode.assistant': 'Голосовой ассистент', + 'settings.voice.inputMode.assistant.hint': 'Двусторонний голосовой разговор', + 'settings.voice.inputMode.dictation': 'Диктовка', + 'settings.voice.inputMode.dictation.hint': 'Только ввод речи в текст', + 'settings.voice.transcriptionProvider': 'Провайдер транскрипции', + 'settings.voice.noTranscriptionProvider': 'На хабе пока нет провайдера транскрипции — добавьте OpenAI, ElevenLabs или Groq ниже (или используйте встроенный в браузер, если доступен).', + 'settings.voice.noVoiceBackend': 'На хабе не настроен бэкенд голосового ассистента — добавьте его ниже.', + 'settings.voice.credentials.hint': 'Выберите OpenAI, ElevenLabs или Groq. Ключи хранятся на хабе; браузер никогда не хранит долгоживущие секреты провайдера.', + 'settings.voice.credentials.assistantHint': 'Добавьте ключ ElevenLabs, Gemini Live или Qwen Realtime. Ключи хранятся на хабе.', + 'settings.voice.credentials.provider': 'Провайдер', + 'settings.voice.credentials.openaiCompatible': 'OpenAI-совместимый / локальный', + 'settings.voice.credentials.geminiLive': 'Gemini Live', + 'settings.voice.credentials.qwenRealtime': 'Qwen Realtime', + 'settings.voice.credentials.apiKey': 'API-ключ', + 'settings.voice.credentials.apiKeyPlaceholder': 'Вставьте API-ключ', + 'settings.voice.credentials.apiKeyReplace': 'Вставьте новый ключ для замены', + 'settings.voice.credentials.baseUrl': 'Базовый URL', + 'settings.voice.credentials.model': 'Модель', + 'settings.voice.credentials.save': 'Сохранить на хабе', + 'settings.voice.credentials.clear': 'Очистить', + 'settings.voice.credentials.saved': 'Сохранено. Список провайдеров обновлён.', + 'settings.voice.credentials.cleared': 'Удалено из настроек хаба.', + 'settings.voice.credentials.saveFailed': 'Не удалось сохранить учётные данные.', + 'settings.voice.credentials.configuredAs': 'Настроено через {source}: {hint}', + 'settings.voice.credentials.source.env': 'окружение', + 'settings.voice.credentials.source.settings': 'Настройки', + 'settings.voice.credentials.envLocked': 'Этот ключ задан окружением хаба и не может быть изменён здесь.', + 'settings.voice.credentials.manage': 'Добавить или управлять провайдерами', + 'settings.voice.credentials.manageHint': 'Добавьте OpenAI, ElevenLabs или Groq, чтобы включить диктовку на хабе.', + 'settings.voice.credentials.manageAssistantHint': 'Вставьте API-ключ, чтобы включить ElevenLabs, Gemini Live или Qwen Realtime на хабе.', + 'settings.voice.transcriptionMode': 'Режим транскрипции', + 'settings.voice.transcriptionMode.standard': 'Стандартный', + 'settings.voice.transcriptionMode.standard.hint': 'Записать, затем транскрибировать (по умолчанию)', + 'settings.voice.transcriptionMode.realtime': 'Реального времени', + 'settings.voice.transcriptionMode.realtime.hint': 'Показывать живой текст во время речи; вставить итог после остановки', + 'settings.voice.voices.description': 'Выберите голос, используемый выбранным бэкендом.', + 'settings.voice.sounds.title': 'Как это звучит', + 'settings.voice.responds.title': 'Как отвечает', + 'settings.voice.connection.title': 'Подключение и провайдер', + 'settings.voice.behaves.title': 'Как себя ведёт', + 'settings.voice.persona.title': 'Персона и инструкции', + 'settings.voice.advanced.section.title': 'Дополнительно', + 'settings.voice.responseLength.label': 'Длина ответа', + 'settings.voice.responseLength.brief': 'Краткий', + 'settings.voice.responseLength.balanced': 'Сбалансированный', + 'settings.voice.responseLength.detailed': 'Подробный', + 'settings.voice.responseLength.brief.hint': 'Отвечать в 1–2 предложениях.', + 'settings.voice.responseLength.balanced.hint': 'По умолчанию — ассистент сам определяет длину по контексту.', + 'settings.voice.responseLength.detailed.hint': 'Давать развёрнутые ответы, когда полезна глубина.', + 'settings.voice.opening.label': 'Начало', + 'settings.voice.opening.greet': 'Поздороваться', + 'settings.voice.opening.brief': 'Ввести в курс', + 'settings.voice.opening.greet.hint': 'Ассистент здоровается и ждёт, пока вы заговорите.', + 'settings.voice.opening.brief.hint': 'Ассистент кратко описывает активность агента при подключении.', + 'settings.voice.group.label': 'Настройки голоса', + 'settings.voice.group.hint': 'Настройки применяются к выбранному голосовому провайдеру или провайдеру транскрипции.', + 'settings.voice.backend': 'Бэкенд голоса', + 'settings.voice.language': 'Язык голоса', + 'settings.voice.autoDetect': 'Автоопределение', + 'settings.voice.voice': 'Голос', + 'settings.voice.voiceDefault': 'По умолчанию', + 'settings.voice.advanced.title': 'Расширенные настройки голоса', + 'settings.voice.advanced.hint': 'Идентичность, характер, фикстуры, ползунки. Значения по умолчанию подходят — открывайте только если хотите перенастроить.', + 'settings.voice.advanced.customizedBadge': 'изменено', + 'settings.voice.fixtures.title': 'Правила платформы (только чтение)', + 'settings.voice.fixtures.hint': 'Поставляются с приложением: инструменты, маршрутизация, правила TTS. Обновляются при обновлении HAPI — здесь не редактируются.', + 'settings.voice.identity.title': 'Идентичность и бренд', + 'settings.voice.identity.hint': 'Кто такой голосовой ассистент. HAPI — это приложение; здесь можно переименовать или ребрендировать персону (например, «надзиратель»). Ограничения провайдера всегда применяются отдельно.', + 'settings.voice.identity.reset': 'Сбросить идентичность', + 'settings.voice.identity.default': 'Встроенная идентичность по умолчанию', + 'settings.voice.identity.customized': 'Своя идентичность', + 'settings.voice.character.promptTitle': 'Характер и стиль речи', + 'settings.voice.character.promptHint': 'Тон и стиль. Текст предустановленной подачи объединяется сюда, когда характер пуст. Компонуется с правилами платформы во время выполнения на всех голосовых бэкендах.', + 'settings.voice.character.reset': 'Сбросить характер', + 'settings.voice.character.appendPreset': 'Добавить предустановленный текст подачи', + 'settings.voice.layers.resetAll': 'Сбросить идентичность и характер', + 'settings.voice.layers.composedNote': 'Промпт во время выполнения = правила платформы + ваша идентичность + характер (все бэкенды).', + 'settings.voice.wireBudget.elevenlabs': 'ElevenLabs отправляет собранные инструкции при подключении; история сессии инициализируется, затем передаётся потоком.', + 'settings.voice.wireBudget.gemini': 'Gemini Live использует те же собранные инструкции через прокси хаба.', + 'settings.voice.wireBudget.qwen': 'Qwen Realtime использует те же собранные инструкции в session.update.', + 'settings.voice.wireBudget.storage': 'Лимит локального хранилища (символов):', + 'settings.voice.wireBudget.elevenlabsLimits': 'Ограничения канала: ~{msg} Б на сообщение WebRTC; бюджет собранного промпта ~{prompt} Б.', + 'settings.voice.wireBudget.promptTruncated': 'Ваш собранный промпт был сокращён, чтобы уложиться в лимит канала ElevenLabs. Сократите идентичность/характер, иначе мы ещё обрежем его при подключении.', + 'settings.voice.wireBudget.composedSize': 'Размер последнего собранного промпта', + 'settings.voice.contextNotice': 'Последняя голосовая сессия', + 'settings.voice.character.title': 'Предустановка стиля речи', + 'settings.voice.character.presetSlidersHint': 'Предустановки настраивают ползунки TTS ElevenLabs и необязательную формулировку подачи (слой характера).', + 'settings.voice.character.preset.label': 'Предустановка', + 'settings.voice.character.preset.balanced': 'Сбалансированная', + 'settings.voice.character.preset.balancedHint': 'Тёплая и сосредоточенная — подходит для повседневных задач.', + 'settings.voice.character.preset.warm': 'Тёплая и выразительная', + 'settings.voice.character.preset.warmHint': 'Более живая подача; аудио-теги ElevenLabs, когда доступны.', + 'settings.voice.character.preset.calm': 'Спокойная', + 'settings.voice.character.preset.calmHint': 'Медленный, размеренный тон для долгих или поздних сессий.', + 'settings.voice.character.preset.direct': 'Прямая', + 'settings.voice.character.preset.directHint': 'Кратко и эффективно — минимум лишнего.', + 'settings.voice.character.preset.custom': 'Своя', + 'settings.voice.character.preset.customHint': 'Вы управляете ползунками и системным промптом выше.', + 'settings.voice.tuning.title': 'Акустическая настройка', + 'settings.voice.tuning.elevenlabs': 'ElevenLabs', + 'settings.voice.tuning.gemini': 'Gemini Live', + 'settings.voice.tuning.stability': 'Стабильность', + 'settings.voice.tuning.stabilityHint': 'Ниже = динамичнее; выше = ровнее.', + 'settings.voice.tuning.expressiveness': 'Выразительность', + 'settings.voice.tuning.expressivenessHint': 'Преувеличение стиля (стиль ElevenLabs).', + 'settings.voice.tuning.speakingRate': 'Скорость речи', + 'settings.voice.tuning.speakingRateHint': '0,7–1,2× (API ElevenLabs).', + 'settings.voice.tuning.similarity': 'Усиление схожести', + 'settings.voice.tuning.similarityHint': 'Более точное совпадение с обученным голосом.', + 'settings.voice.tuning.speakerBoost': 'Усиление голоса', + 'settings.voice.tuning.affectiveDialog': 'Аффективный диалог', + 'settings.voice.tuning.affectiveDialogHint': 'Подстраивать подачу под ваш тон (Gemini v1alpha).', + 'settings.voice.tuning.qwenHint': 'У Qwen здесь нет ползунков TTS — отредактируйте голосовой системный промпт выше; эмоции подстраиваются под вашу речь.', + 'settings.voice.tuning.selectBackend': 'Ползунки для каждого бэкенда появляются при выборе голосового бэкенда.', + 'settings.voice.preview.elevenlabsOnly': 'Предпросмотр голоса доступен только для ElevenLabs', + 'settings.voice.preview.unavailable': 'Предпросмотр недоступен без API-ключа ElevenLabs', + 'settings.voice.preview.play': 'Прослушать голос', + 'settings.voice.preview.stop': 'Остановить предпросмотр', + 'settings.voice.staticCatalogHint': 'Откройте список, чтобы увидеть заметки о характере голоса. Аудиопредпросмотр — только ElevenLabs.', + 'settings.voice.session.label': 'Поведение сессии', + 'settings.voice.proactive': 'Начинать голосовую сессию со сводкой', + 'settings.voice.proactive.description': 'При включении голосовая сессия начинается с озвученной сводки текущей активности агента. При выключении ассистент здоровается и ждёт, пока вы заговорите.', + 'settings.companion.title': 'Компаньон', + 'settings.companion.noToken': 'Для сопряжения нужен исходный токен доступа (CLI_API_TOKEN). Похоже, вы вошли через Telegram или другим способом, который не сохранил его — вставьте токен вручную в приложении-компаньоне.', + 'settings.companion.description': 'Отсканируйте из приложения-компаньона HAPI на Android (телефон или Wear OS), чтобы привязать его к этому хабу. Код сопряжения — это ваш токен доступа; относитесь к нему как к паролю.', + 'settings.companion.showQr': 'Показать QR сопряжения', + 'settings.companion.qrAriaLabel': 'QR-код сопряжения компаньона', + 'settings.companion.copyLink': 'Копировать ссылку', + 'settings.companion.copied': 'Скопировано!', + 'settings.companion.hide': 'Скрыть', + 'settings.machines.title': 'Машины', + 'settings.machines.description': 'Дайте своим машинам собственные имена. Отображаются только машины, которые сейчас онлайн.', + 'settings.machines.section': 'Ваши машины', + 'settings.machines.rename': 'Переименовать {name}', + 'settings.machines.namePlaceholder': 'Использовать имя хоста', + 'settings.machines.empty': 'Нет машин онлайн.', + 'settings.machines.error': 'Не удалось переименовать эту машину.', + 'settings.hub.machines.summary': 'Собственные имена для ваших машин', + 'settings.about.title': 'О приложении', + 'settings.about.description': 'Ссылки и информация о версии HAPI.', + 'settings.about.website': 'Сайт', + 'settings.about.appVersion': 'Версия приложения', + 'settings.about.protocolVersion': 'Версия протокола', + + // Обзор / Рабочее пространство + 'browse.title': 'Обзор', + 'browse.goUp': 'Наверх', + 'browse.empty': 'Подкаталоги не найдены', + 'browse.refresh': 'Обновить', + 'browse.showHidden': 'Показать скрытые', + 'browse.startSession': 'Запустить сессию', + 'browse.nav': 'Обзор', + 'browse.noRootTitle': 'Обзор рабочего пространства выключен', + 'browse.noRootHint': 'Обзор включается по желанию. Перезапустите раннер с одним или несколькими корнями рабочего пространства, чтобы включить навигацию по дереву файлов и ограниченный запуск сессий.', + 'browse.noRootFooter': 'Вы по-прежнему можете создавать сессии на странице «Новая сессия».', + 'browse.noMachinesConnected': 'CLI не подключён. Выполните `hapi runner start --workspace-root /путь/а --workspace-root /путь/б` на машине, чтобы начать.', + + // Разное + 'misc.noMachines': 'Нет доступных машин', + 'misc.machine': 'Машина', + 'misc.collaborationMode': 'Режим совместной работы', + 'misc.copilotAgentMode': 'Режим агента', + 'misc.permissionMode': 'Режим разрешений', + 'misc.model': 'Модель', + 'misc.reasoningEffort': 'Уровень рассуждений', + 'misc.effort': 'Усилия', + 'misc.fastMode': 'Быстрый режим', + 'misc.fastModeStandard': 'Стандартный', + 'misc.fastModeFast': 'Быстрый', + 'misc.variant': 'Вариант', + 'misc.changeModel': 'Сменить модель…', + 'misc.backToModelList': '← Модели', + 'misc.loading': 'Загрузка…', + 'misc.newMessage': 'Новых сообщений: {n}', + 'misc.scrollToBottom': 'Прокрутить вниз', + 'misc.loadingMessages': 'Загрузка сообщений…', + 'misc.pullToLoadOlder': 'Тяните, чтобы загрузить более ранние сообщения', + 'misc.releaseToLoadOlder': 'Отпустите, чтобы загрузить более ранние сообщения', + 'misc.typeMessage': "Введите 'continue', чтобы продолжить...", + 'misc.typeAMessage': 'Введите сообщение...', + 'misc.typeAMessageWithMentions': 'Введите, что должен сделать агент, или @упомяните другую сессию для контекста или передачи', + 'misc.offline': 'офлайн', + 'misc.permissionRequired': 'требуется разрешение', + 'misc.online': 'онлайн', + 'misc.contextDetails': 'Сведения о контексте', + 'misc.contextCache': 'Кэш: {value}', + 'misc.contextUsed': 'Использовано: {value} ({percent}%)', + 'misc.contextUsedTokens': 'Использовано: {value}', + 'misc.contextRemaining': 'Осталось: {value} ({percent}%)', + + // Выбор цели Web Share + 'share.title': 'Поделиться в HAPI', + 'share.subtitle': 'Выберите сессию, к которой это прикрепить.', + 'share.recentSessions': 'Недавние активные сессии', + 'share.newSession': 'Новая сессия', + 'share.discard': 'Отменить', + 'share.loading': 'Загрузка общего содержимого…', + 'share.notFound.title': 'Общее содержимое не найдено', + 'share.notFound.body': 'Эта ссылка истекла или была открыта напрямую без передачи.', + 'share.error.ingest': 'Не удалось прочитать общее содержимое. Попробуйте ещё раз из исходного приложения.', + 'share.error.noId': 'Не передан идентификатор. Откройте эту страницу из системного меню «Поделиться».', + 'share.backToSessions': 'Назад к сессиям', + 'share.preview.text': 'Общий текст', + 'share.preview.empty': '(пустой общий элемент)', + 'share.preview.files': '{n} файлов', + 'share.noActiveSessions': 'Нет активных сессий. Выберите «Новая сессия» ниже.', + 'sessions.refresh.pull': 'Потяните для обновления', + 'sessions.refresh.release': 'Отпустите для обновления', + 'sessions.refresh.refreshing': 'Обновление…', + 'sessions.refresh.failed.title': 'Не удалось обновить', + 'codexSync.newSessionInline.title': 'Импорт истории Codex', + 'codexSync.newSessionInline.description': 'Необязательно: выберите одну локальную сессию Codex, затем выберите модель/настройки и нажмите «Создать» для импорта.', + 'codexSync.newSessionInline.clear': 'Очистить выбранную историю', + 'codexSync.newSessionInline.choose': 'Выбрать историю', + 'codexSync.confirm.search': 'Поиск', + 'codexSync.confirm.searchPlaceholder': 'Поиск по названию, сообщению, каталогу, ID сессии', + 'codexSync.confirm.useSelected': 'Использовать выбранное', + 'codexSync.confirm.imported': 'Импортировано', + 'codexSync.confirm.original': 'Исходная', + 'codexSync.confirm.fork': 'Форк', + 'codexSync.confirm.archiveAction': 'Архивировать в Codex', + 'session.action.syncCodexDisabledActive': 'Остановите или заархивируйте выполняющуюся сессию перед синхронизацией истории Codex.', + 'codexSync.manual.success.noNewMessages': 'Нет новых сообщений Codex.', + 'session.status.title': 'Статус сессии', + 'session.status.goal': 'Цель', + 'session.status.tasks': 'Задачи', + 'session.status.subagents': 'Подагенты', + 'session.status.terminals': 'Фоновые терминалы', + 'session.status.terminalsUnavailable': 'Выполняется: {count} · точная команда недоступна', + 'session.status.terminalsPossible': 'Возможные команды', + 'session.status.goal.active': 'Активна', + 'session.status.goal.paused': 'Приостановлена', + 'session.status.goal.budgetLimited': 'Ограничена', + 'session.status.goal.usageLimited': 'Ограничена по расходу', + 'session.status.goal.blocked': 'Заблокирована', + 'session.status.goal.complete': 'Завершена', + 'session.status.subagent.running': 'Выполняется', + 'session.status.subagent.waiting': 'Ожидание', + 'session.status.subagent.error': 'Ошибка', + 'share.searchResults': 'Подходящие сессии', + 'share.noSearchResults': 'Нет сессий, соответствующих поиску.', + 'share.searchForMore': 'Ещё {n} активных сессий — воспользуйтесь поиском, чтобы найти их.', + 'session.summary.label': 'Сводка сессии', + 'session.summary.ariaLabel': 'Сводка статуса сессии', + 'session.summary.status.done': 'Готово', + 'session.summary.status.blocked': 'Заблокирована', + 'session.summary.status.needsReview': 'Требует проверки', + 'session.summary.status.needsDecision': 'Требует решения', + 'session.summary.status.failed': 'Не удалась', + 'session.summary.status.stalled': 'Застопорилась', + + // Install prompt (iOS add-to-home-screen guide) + 'install.close': 'Закрыть', + 'install.ios.step1.before': 'Нажмите', + 'install.ios.step1.after': '«Поделиться» на панели', + 'install.ios.step2.before': 'Прокрутите вниз и нажмите', + 'install.ios.step2.after': 'На экран «Домой»', + 'install.ios.step3.before': 'Нажмите', + 'install.ios.step3.emphasis': 'Добавить', + 'install.ios.step3.after': 'в правом верхнем углу', + + // Queued messages bar + 'queuedMessages.title': 'Сообщения в очереди', + 'queuedMessages.status': 'В очереди', + 'queuedMessages.editLabel': 'Изменить сообщение в очереди', + 'queuedMessages.cancelLabel': 'Отменить сообщение в очереди', + 'queuedMessages.pendingInvocation.one': '{n} сообщение в очереди ожидает вызова', + 'queuedMessages.pendingInvocation.other': '{n} сообщений в очереди ожидают вызова', + + // Image viewer / zoomable lightbox + 'image.clickToZoom': 'Нажмите, чтобы увеличить', + 'image.previous': 'Предыдущее изображение', + 'image.next': 'Следующее изображение', + 'image.zoomIn': 'Увеличить', + 'image.zoomOut': 'Уменьшить', + 'image.resetZoom': 'Сбросить масштаб', + 'image.fitToScreen': 'По размеру экрана', + + // Chat chrome and status + 'chat.contextCompacted': 'Контекст сжат', + 'chat.reasoning': 'Рассуждения', + 'chat.queuedStatus': 'В очереди', + 'chat.sendingStatus': 'Отправка', + 'chat.terminalOutput': 'Вывод терминала', + 'chat.removeAttachment': 'Убрать вложение', + 'chat.uploadFailed': 'Ошибка загрузки', + + // Tool cards + 'tool.output': 'Вывод', + 'tool.prompt': 'Промпт', + 'tool.draft': 'Черновик', + 'tool.rawJson': 'Исходный JSON', + 'tool.fileContent': 'Содержимое файла', + + // Team panel + 'team.members': 'Участники', + 'team.tasks': 'Задачи', + 'team.recentMessages': 'Недавние сообщения', + + // Terminal / app shell + 'terminal.inactiveSession': 'Сессия неактивна. Терминал недоступен.', + 'app.sessionUnavailable': 'Сессия недоступна', + + // Tool result states + 'tool.state.waitingForPermission': 'Ожидание разрешения…', + 'tool.state.running': 'Выполняется…', + 'tool.state.noOutput': '(нет вывода)', + 'tool.result.done': 'Готово', + 'tool.result.agentLaunched': 'Агент запущен', + 'tool.result.timedOut': 'Тайм-аут', + 'tool.result.noStatus': 'Нет статуса', + 'tool.result.skillLoaded': 'Навык загружен', + 'tool.result.skillLoadedNamed': 'Навык «{name}» загружен', + 'tool.result.fieldName': 'Имя', + 'tool.result.fieldTask': 'Задача', + 'tool.result.fieldId': 'ID', + 'tool.result.agentCount': 'Агентов: {count}', + + // Session shell + 'session.notFound.returning': 'Сессия не найдена. Возвращаемся к списку…', +} as const diff --git a/web/src/routes/settings/general.tsx b/web/src/routes/settings/general.tsx index 17f6cbe25b..da7c5f6a7d 100644 --- a/web/src/routes/settings/general.tsx +++ b/web/src/routes/settings/general.tsx @@ -8,6 +8,7 @@ import { queryKeys } from '@/lib/query-keys' const locales: ReadonlyArray<{ value: Locale; label: string }> = [ { value: 'en', label: 'English' }, { value: 'zh-CN', label: '简体中文' }, + { value: 'ru', label: 'Русский' }, ] function getNamespace(token: string | null): string | null { From 99cd3d4172fb49f893b62dff39239fa85c5f6275 Mon Sep 17 00:00:00 2001 From: Matvey Rudoy <75200069+mrudybtw@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:14:11 +0300 Subject: [PATCH 06/99] feat(android): add Russian locale (#1963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(android): add Russian locale Translate all 518 app strings into values-ru/strings.xml, register ru in locales_config.xml, and add AppLanguage.RUSSIAN to the in-app language picker. * fix(android): use count-agnostic Russian forms for dynamic counts The *_many strings used a genitive-plural noun after the number, which is wrong for 2-4 (e.g. "2 инструментов"). The house style avoids , so phrase these labels with the noun first ("Инструменты: %1") instead: tool groups, review findings, queued/new messages, agent steps, questions, lines, chars, edits, usage requests, scratchlist count and attachment cap. * fix(android): make the process-steps label count-safe * fix(android): use a count-safe token label in the usage tooltip --------- Co-authored-by: mrudy --- .../feature/settings/LanguagePrefs.kt | 10 +- .../feature/settings/SettingsScreen.kt | 1 + .../app/src/main/res/values-ru/strings.xml | 573 ++++++++++++++++++ .../app/src/main/res/xml/locales_config.xml | 1 + 4 files changed, 581 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/res/values-ru/strings.xml 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 @@ + From 53db5bbfba1d25506c72c69669ed47931f6a3753 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:44:02 +0000 Subject: [PATCH 07/99] feat: session-attached long-running jobs (#1404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hub-persisted jobs that outlive the agent process: register/update/clear via REST + `hapi job`, surface primary running progress on the session list even when active=false. Opt-in registration only — not thinking progress. Co-authored-by: Cursor --- cli/src/commands/job.ts | 290 ++++++++++++++++++ cli/src/commands/registry.ts | 4 +- cli/src/modules/sessionJob/sessionJob.ts | 269 ++++++++++++++++ hub/src/store/index.ts | 57 +++- hub/src/store/migration-v23.test.ts | 5 +- hub/src/store/migration-v25.test.ts | 79 ++++- hub/src/store/sessionJobs.ts | 267 ++++++++++++++++ hub/src/store/sessionJobsStore.ts | 57 ++++ hub/src/store/types.ts | 15 + hub/src/sync/sessionCache.ts | 33 ++ hub/src/sync/syncEngine.ts | 89 ++++++ hub/src/web/routes/sessions-jobs.test.ts | 129 ++++++++ hub/src/web/routes/sessions.ts | 95 +++++- shared/src/index.ts | 12 + shared/src/schemas.sessionPatch.test.ts | 31 +- shared/src/schemas.ts | 54 +++- shared/src/sessionSummary.test.ts | 19 ++ shared/src/sessionSummary.ts | 13 +- shared/src/types.ts | 4 + .../SessionAttentionIndicator.test.tsx | 1 + .../SessionList.directory-action.test.tsx | 1 + .../SessionList.machine-filter.test.tsx | 1 + web/src/components/SessionList.test.ts | 1 + web/src/components/SessionRowSummary.tsx | 50 ++- web/src/hooks/useSSE.test.ts | 1 + web/src/hooks/useSSE.ts | 14 + web/src/lib/attachedJob.test.ts | 46 +++ web/src/lib/attachedJob.ts | 31 ++ web/src/lib/sessionAttention.test.ts | 1 + web/src/lib/sessionReference.test.ts | 1 + 30 files changed, 1657 insertions(+), 13 deletions(-) create mode 100644 cli/src/commands/job.ts create mode 100644 cli/src/modules/sessionJob/sessionJob.ts create mode 100644 hub/src/store/sessionJobs.ts create mode 100644 hub/src/store/sessionJobsStore.ts create mode 100644 hub/src/web/routes/sessions-jobs.test.ts create mode 100644 web/src/lib/attachedJob.test.ts create mode 100644 web/src/lib/attachedJob.ts diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts new file mode 100644 index 0000000000..cbd2fa55fc --- /dev/null +++ b/cli/src/commands/job.ts @@ -0,0 +1,290 @@ +import chalk from 'chalk' +import { initializeToken } from '@/ui/tokenInit' +import type { AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' +import { + SessionJobError, + clearSessionJob, + exitCodeForSessionJobError, + listSessionJobs, + setSessionJob, + updateSessionJob +} from '@/modules/sessionJob/sessionJob' +import type { CommandDefinition } from './types' + +type ParsedJobArgs = { + help: boolean + action?: 'set' | 'update' | 'clear' | 'list' + sessionIdPrefix?: string + jobKey?: string + label?: string + status?: 'running' | 'completed' | 'failed' + done?: number + total?: number + remaining?: number + unit?: string + detail?: string +} + +function showHelp(): void { + console.log(` +${chalk.bold('hapi job')} - Attach long-running work to a HAPI session (tiann/hapi#1404) + +${chalk.bold('Usage:')} + hapi job set --label [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] + hapi job update [--remaining N] [--done N] [--total N] [--status running|completed|failed] [--detail ...] + hapi job clear + hapi job list + +${chalk.bold('Notes:')} + Hub-persisted. Works while the agent is idle/offline — not thinking progress. + Prefer honest remaining/done+total; never invent a fake percent. + Job key: 1-128 chars, alnum / . _ - + +${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 } + + for (let i = 0; i < args.length; i++) { + const arg = args[i]! + if (arg === '--help' || arg === '-h') { + result.help = true + continue + } + if (arg === '--label') { + result.label = args[++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 = args[++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', args[++i]) + continue + } + if (arg.startsWith('--done=')) { + result.done = parseOptionalNumber('--done', arg.slice('--done='.length)) + continue + } + if (arg === '--total') { + result.total = parseOptionalNumber('--total', args[++i]) + continue + } + if (arg.startsWith('--total=')) { + result.total = parseOptionalNumber('--total', arg.slice('--total='.length)) + continue + } + if (arg === '--remaining') { + result.remaining = parseOptionalNumber('--remaining', args[++i]) + continue + } + if (arg.startsWith('--remaining=')) { + result.remaining = parseOptionalNumber('--remaining', arg.slice('--remaining='.length)) + continue + } + if (arg === '--unit') { + result.unit = args[++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 = args[++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.startsWith('-')) { + throw new SessionJobError('bad_args', `unexpected flag: ${arg}`) + } + if (!result.action) { + if (arg !== 'set' && arg !== 'update' && arg !== 'clear' && arg !== 'list') { + throw new SessionJobError('bad_args', `unknown action '${arg}' (set|update|clear|list)`) + } + 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}`) + } + + return result +} + +function formatJobLine(job: { + key: string + label: string + status: string + done?: number + total?: number + remaining?: number + unit?: string + detail?: string + heartbeatAt: number +}): string { + const parts = [`${job.key}`, job.label, job.status] + if (job.remaining !== undefined) { + parts.push(`${job.remaining}${job.unit ? ` ${job.unit}` : ''} left`) + } else if (job.done !== undefined && job.total !== undefined) { + parts.push(`${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''}`) + } + if (job.detail) parts.push(job.detail) + const ageSec = Math.max(0, Math.round((Date.now() - job.heartbeatAt) / 1000)) + parts.push(`heartbeat ${ageSec}s ago`) + return parts.join(' · ') +} + +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 ...') + } + 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} ${formatJobLine(job)}`) + } + return + } + + if (!parsed.jobKey) { + throw new SessionJobError('bad_args', 'missing job key') + } + + if (parsed.action === 'clear') { + const result = await clearSessionJob({ + sessionIdPrefix: parsed.sessionIdPrefix, + jobKey: parsed.jobKey + }) + console.log(`cleared ${parsed.jobKey} on ${result.sessionId}`) + return + } + + if (parsed.action === 'set') { + if (!parsed.label) { + throw new SessionJobError('bad_args', 'set requires --label') + } + const body: AttachedJobUpsert = { + label: parsed.label, + status: parsed.status ?? 'running', + ...(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 } : {}) + } + const result = await setSessionJob({ + sessionIdPrefix: parsed.sessionIdPrefix, + jobKey: parsed.jobKey, + body + }) + console.log(`set ${formatJobLine(result.job)}`) + 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 } : {}) + } + if (Object.keys(body).length === 0) { + throw new SessionJobError('bad_args', 'update requires at least one field') + } + const result = await updateSessionJob({ + sessionIdPrefix: parsed.sessionIdPrefix, + jobKey: parsed.jobKey, + body + }) + console.log(`updated ${formatJobLine(result.job)}`) +} + +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/modules/sessionJob/sessionJob.ts b/cli/src/modules/sessionJob/sessionJob.ts new file mode 100644 index 0000000000..554f403243 --- /dev/null +++ b/cli/src/modules/sessionJob/sessionJob.ts @@ -0,0 +1,269 @@ +/** + * 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' + | 'request_failed' + +export class SessionJobError extends Error { + readonly code: SessionJobErrorCode + + constructor(code: SessionJobErrorCode, message: string) { + super(message) + this.name = 'SessionJobError' + this.code = code + } +} + +const AUTH_RECOVERY_HINT = + 'On a remote runner, set HAPI_API_URL to the runner hub, and set CLI_API_TOKEN ' + + 'or run `hapi auth login`. Prefer `hapi job` over raw JWT+curl.' + +function resolveApiUrl(apiUrl?: string): string { + const raw = (apiUrl ?? configuration.apiUrl).trim().replace(/\/+$/, '') + if (!raw) { + throw new SessionJobError('bad_args', `HAPI API URL is empty. ${AUTH_RECOVERY_HINT}`) + } + return raw +} + +function resolveAccessToken(accessToken?: string): string { + let token = '' + try { + token = (accessToken ?? getAuthToken()).trim() + } catch { + token = (accessToken ?? '').trim() + } + if (!token) { + throw new SessionJobError( + 'bad_args', + `CLI_API_TOKEN is required (run \`hapi auth login\`). ${AUTH_RECOVERY_HINT}` + ) + } + return token +} + +async function exchangeJwt( + apiUrl: string, + accessToken: string, + http: AxiosInstance +): Promise { + try { + const response = await http.post( + `${apiUrl}/api/auth`, + { accessToken }, + { + headers: buildHubRequestHeaders({ 'Content-Type': 'application/json' }), + timeout: 10_000, + validateStatus: () => true + } + ) + const token = typeof response.data?.token === 'string' ? response.data.token : '' + if (response.status < 200 || response.status >= 300 || !token) { + const detail = typeof response.data?.error === 'string' + ? response.data.error + : `HTTP ${response.status}` + throw new SessionJobError( + 'auth_failed', + `failed to exchange access token for JWT (${detail}). Hub URL: ${apiUrl}. ${AUTH_RECOVERY_HINT}` + ) + } + return token + } catch (error) { + if (error instanceof SessionJobError) throw error + throw new SessionJobError( + 'auth_failed', + `failed to exchange access token for JWT (${error instanceof Error ? error.message : String(error)}). Hub URL: ${apiUrl}. ${AUTH_RECOVERY_HINT}` + ) + } +} + +function authHeaders(jwt: string): Record { + return buildHubRequestHeaders({ + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json' + }) +} + +type SessionListItem = { id: string } + +function resolveSessionByPrefix(sessions: SessionListItem[], prefix: string): SessionListItem { + const trimmed = prefix.trim() + if (!trimmed) { + throw new SessionJobError('bad_args', 'session id prefix is required') + } + const exact = sessions.filter((session) => session.id === trimmed) + if (exact.length === 1) return exact[0]! + const matches = sessions.filter((session) => session.id.startsWith(trimmed)) + if (matches.length === 0) { + throw new SessionJobError('not_found', `no session matching prefix '${trimmed}'`) + } + if (matches.length > 1) { + const sample = matches.slice(0, 5).map((session) => session.id.slice(0, 8)).join(', ') + throw new SessionJobError( + 'ambiguous', + `prefix '${trimmed}' matches ${matches.length} sessions (${sample}${matches.length > 5 ? ', ...' : ''}); use a longer prefix` + ) + } + return matches[0]! +} + +async function resolveSessionId( + apiUrl: string, + jwt: string, + http: AxiosInstance, + sessionIdPrefix: string +): Promise { + const response = await http.get(`${apiUrl}/api/sessions`, { + headers: authHeaders(jwt), + params: { limit: 500, order: 'updatedAt' }, + timeout: 15_000, + validateStatus: () => true + }) + if (response.status < 200 || response.status >= 300) { + throw new SessionJobError('request_failed', `list sessions failed: HTTP ${response.status}`) + } + const sessions = Array.isArray(response.data?.sessions) + ? (response.data.sessions as SessionListItem[]) + : [] + return resolveSessionByPrefix(sessions, sessionIdPrefix).id +} + +export type SessionJobClientOptions = { + sessionIdPrefix: string + apiUrl?: string + accessToken?: string + http?: AxiosInstance +} + +async function withClient( + options: SessionJobClientOptions, + fn: (ctx: { apiUrl: string; jwt: string; sessionId: string; http: AxiosInstance }) => Promise +): Promise { + const http = options.http ?? axios + const apiUrl = resolveApiUrl(options.apiUrl) + const accessToken = resolveAccessToken(options.accessToken) + const jwt = await exchangeJwt(apiUrl, accessToken, http) + const sessionId = await resolveSessionId(apiUrl, jwt, http, options.sessionIdPrefix) + return fn({ apiUrl, jwt, sessionId, http }) +} + +export async function listSessionJobs( + options: SessionJobClientOptions +): Promise<{ sessionId: string; jobs: AttachedJob[]; primary: AttachedJob | null }> { + return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { + const response = await http.get(`${apiUrl}/api/sessions/${sessionId}/jobs`, { + headers: authHeaders(jwt), + timeout: 15_000, + validateStatus: () => true + }) + if (response.status < 200 || response.status >= 300) { + throw new SessionJobError('request_failed', `list jobs failed: HTTP ${response.status}`) + } + return { + sessionId, + jobs: Array.isArray(response.data?.jobs) ? response.data.jobs : [], + primary: response.data?.primary ?? null + } + }) +} + +export async function setSessionJob( + options: SessionJobClientOptions & { jobKey: string; body: AttachedJobUpsert } +): Promise<{ sessionId: string; job: AttachedJob }> { + return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { + const response = await http.put( + `${apiUrl}/api/sessions/${sessionId}/jobs/${encodeURIComponent(options.jobKey)}`, + options.body, + { + headers: authHeaders(jwt), + timeout: 15_000, + validateStatus: () => true + } + ) + if (response.status === 404) { + throw new SessionJobError('not_found', 'session or job not found') + } + if (response.status < 200 || response.status >= 300 || !response.data?.job) { + const detail = typeof response.data?.error === 'string' + ? response.data.error + : `HTTP ${response.status}` + throw new SessionJobError('request_failed', `set job failed: ${detail}`) + } + return { sessionId, job: response.data.job as AttachedJob } + }) +} + +export async function updateSessionJob( + options: SessionJobClientOptions & { jobKey: string; body: AttachedJobPatch } +): Promise<{ sessionId: string; job: AttachedJob }> { + return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { + const response = await http.patch( + `${apiUrl}/api/sessions/${sessionId}/jobs/${encodeURIComponent(options.jobKey)}`, + options.body, + { + headers: authHeaders(jwt), + timeout: 15_000, + validateStatus: () => true + } + ) + if (response.status === 404) { + throw new SessionJobError('not_found', 'job not found') + } + if (response.status < 200 || response.status >= 300 || !response.data?.job) { + const detail = typeof response.data?.error === 'string' + ? response.data.error + : `HTTP ${response.status}` + throw new SessionJobError('request_failed', `update job failed: ${detail}`) + } + return { sessionId, job: response.data.job as AttachedJob } + }) +} + +export async function clearSessionJob( + options: SessionJobClientOptions & { jobKey: string } +): Promise<{ sessionId: string }> { + return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { + const response = await http.delete( + `${apiUrl}/api/sessions/${sessionId}/jobs/${encodeURIComponent(options.jobKey)}`, + { + headers: authHeaders(jwt), + timeout: 15_000, + validateStatus: () => true + } + ) + if (response.status === 404) { + throw new SessionJobError('not_found', 'job not found') + } + if (response.status < 200 || response.status >= 300) { + throw new SessionJobError('request_failed', `clear job failed: HTTP ${response.status}`) + } + return { sessionId } + }) +} + +export function exitCodeForSessionJobError(error: SessionJobError): number { + switch (error.code) { + case 'bad_args': + return 2 + case 'auth_failed': + return 3 + case 'not_found': + return 4 + case 'ambiguous': + return 5 + default: + return 1 + } +} diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 7e1e6cf04c..6554f180d5 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -9,6 +9,7 @@ import type { StoredMessage } from './types' import { PushStore } from './pushStore' import { FcmStore } from './fcmStore' import { ScratchlistStore } from './scratchlistStore' +import { SessionJobsStore } from './sessionJobsStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' import { UsageStore } from './usageStore' @@ -22,6 +23,7 @@ export type { StoredPushSubscription, StoredFcmDevice, StoredScratchlistEntry, + StoredSessionJob, StoredSession, StoredUser, VersionedUpdateResult @@ -32,6 +34,7 @@ export { MessageStore } from './messageStore' export { PushStore } from './pushStore' export { FcmStore } from './fcmStore' export { ScratchlistStore } from './scratchlistStore' +export { SessionJobsStore } from './sessionJobsStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' export { UsageStore } from './usageStore' @@ -42,7 +45,7 @@ export { WorkGraphValidationError } from './workGraph' -const SCHEMA_VERSION: number = 26 +const SCHEMA_VERSION: number = 27 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -52,6 +55,7 @@ const REQUIRED_TABLES = [ 'push_subscriptions', 'fcm_devices', 'session_scratchlist', + 'session_jobs', 'usage_events', 'usage_scan_state', 'events', @@ -70,6 +74,7 @@ export class Store { readonly push: PushStore readonly fcm: FcmStore readonly scratchlist: ScratchlistStore + readonly sessionJobs: SessionJobsStore readonly usage: UsageStore readonly workGraph: WorkGraphStore @@ -124,6 +129,7 @@ export class Store { this.push = new PushStore(this.db) this.fcm = new FcmStore(this.db) this.scratchlist = new ScratchlistStore(this.db) + this.sessionJobs = new SessionJobsStore(this.db) this.usage = new UsageStore(this.db) this.workGraph = new WorkGraphStore(this.db) } @@ -343,11 +349,16 @@ export class Store { 18: () => this.migrateFromV18ToV19(), 19: () => this.migrateFromV19ToV20(), 20: () => this.migrateFromV20ToV21(), + // Upstream #1115 dual-pin at v21→v22; #1467 A2A events at v22→v23; + // iOS push_key at v23→v24; steer delivery_state at v24→v25; + // immediate-queue heartbeat index at v25→v26; + // #1404 session_jobs at v26→v27. 21: () => this.migrateFromV21ToV22(), 22: () => this.migrateFromV22ToV23(), 23: () => this.migrateFromV23ToV24(), 24: () => this.migrateFromV24ToV25(), 25: () => this.migrateFromV25ToV26(), + 26: () => this.migrateFromV26ToV27(), }) if (currentVersion === 0) { @@ -519,6 +530,25 @@ export class Store { CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created ON session_scratchlist(session_id, created_at DESC); + CREATE TABLE IF NOT EXISTS session_jobs ( + session_id TEXT NOT NULL, + job_key TEXT NOT NULL, + label TEXT NOT NULL, + status TEXT NOT NULL, + done REAL, + total REAL, + remaining REAL, + unit TEXT, + detail TEXT, + heartbeat_at INTEGER NOT NULL, + started_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, job_key), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_jobs_session_status_updated + ON session_jobs(session_id, status, updated_at DESC); + CREATE TABLE IF NOT EXISTS usage_events ( session_id TEXT NOT NULL, source_key TEXT NOT NULL, @@ -958,6 +988,7 @@ export class Store { } private migrateFromV21ToV22(): void { + // Upstream #1115 dual-pin columns. const columns = this.getSessionColumnNames() if (columns.size === 0) return if (!columns.has('pinned')) { @@ -1054,6 +1085,30 @@ export class Store { `) } + /** v26→v27: #1404 session-attached long-running jobs. */ + private migrateFromV26ToV27(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS session_jobs ( + session_id TEXT NOT NULL, + job_key TEXT NOT NULL, + label TEXT NOT NULL, + status TEXT NOT NULL, + done REAL, + total REAL, + remaining REAL, + unit TEXT, + detail TEXT, + heartbeat_at INTEGER NOT NULL, + started_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, job_key), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_jobs_session_status_updated + ON session_jobs(session_id, status, updated_at DESC); + `) + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/migration-v23.test.ts b/hub/src/store/migration-v23.test.ts index 191fa1cdce..2705a3c738 100644 --- a/hub/src/store/migration-v23.test.ts +++ b/hub/src/store/migration-v23.test.ts @@ -13,7 +13,7 @@ afterEach(() => { } }) -describe('schema migration v22 to v26', () => { +describe('schema migration v22 to v27', () => { it('adds events and event_links tables to a V22 database', () => { const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v23-')) tempDirs.push(dir) @@ -42,7 +42,8 @@ describe('schema migration v22 to v26', () => { expect(links?.name).toBe('event_links') const columns = internalDb.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }> expect(columns.map((column) => column.name)).toContain('delivery_state') - expect(version.user_version).toBe(26) + // Tip after upstream V26 heartbeat index + #1404: V27 session_jobs. + expect(version.user_version).toBe(27) migrated.close() }) }) diff --git a/hub/src/store/migration-v25.test.ts b/hub/src/store/migration-v25.test.ts index c72bac7e6b..166c662419 100644 --- a/hub/src/store/migration-v25.test.ts +++ b/hub/src/store/migration-v25.test.ts @@ -13,7 +13,7 @@ afterEach(() => { } }) -describe('schema migration v25 to v26', () => { +describe('schema migration v25 to v27', () => { it('adds an index used by immediate queued-message replay', () => { const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v25-')) tempDirs.push(dir) @@ -41,8 +41,83 @@ describe('schema migration v25 to v26', () => { ORDER BY seq ASC `).all('session-id') as Array<{ detail: string }> - expect(version.user_version).toBe(26) + expect(version.user_version).toBe(27) expect(plan.some((row) => row.detail.includes('idx_messages_immediate_queued'))).toBe(true) migrated.close() }) }) + +function getColumns(store: Store, table: string): string[] { + const db: Database = (store as unknown as { db: Database }).db + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }> + return rows.map((row) => row.name) +} + +describe('Store V26→V27 migration: session_jobs table', () => { + it('fresh DB has session_jobs with expected columns', () => { + const store = new Store(':memory:') + const cols = getColumns(store, 'session_jobs') + expect(cols).toContain('session_id') + expect(cols).toContain('job_key') + expect(cols).toContain('label') + expect(cols).toContain('status') + expect(cols).toContain('done') + expect(cols).toContain('total') + expect(cols).toContain('remaining') + expect(cols).toContain('heartbeat_at') + expect(cols).toContain('started_at') + expect(cols).toContain('updated_at') + store.close() + }) + + it('upserts, patches, deletes a job and surfaces primary running', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default') + + const created = store.sessionJobs.upsert(session.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 100, + unit: 'tracks' + }) + expect(created.outcome).toBe('upserted') + if (created.outcome !== 'upserted') throw new Error('unreachable') + + const primary = store.sessionJobs.getPrimaryRunning(session.id) + expect(primary?.key).toBe('beets') + expect(primary?.remaining).toBe(100) + + const patched = store.sessionJobs.patch(session.id, 'beets', { remaining: 80 }) + expect(patched?.remaining).toBe(80) + + expect(store.sessionJobs.delete(session.id, 'beets')).toBe(true) + expect(store.sessionJobs.getPrimaryRunning(session.id)).toBeNull() + store.close() + }) + + it('cascade-deletes jobs when session is deleted', async () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default') + store.sessionJobs.upsert(session.id, 'job', { label: 'x', status: 'running' }) + expect(store.sessionJobs.list(session.id)).toHaveLength(1) + await store.sessions.deleteSession(session.id, 'default') + expect(store.sessionJobs.list(session.id)).toHaveLength(0) + store.close() + }) + + it('transfers jobs on merge without colliding keys', () => { + const store = new Store(':memory:') + const oldSession = store.sessions.getOrCreateSession('old', { path: '/a' }, null, 'default') + const newSession = store.sessions.getOrCreateSession('new', { path: '/b' }, null, 'default') + store.sessionJobs.upsert(oldSession.id, 'beets', { + label: 'beets', + status: 'running', + remaining: 5 + }) + const result = store.sessionJobs.transfer(oldSession.id, newSession.id) + expect(result.moved).toBe(1) + expect(store.sessionJobs.getPrimaryRunning(newSession.id)?.remaining).toBe(5) + expect(store.sessionJobs.list(oldSession.id)).toHaveLength(0) + store.close() + }) +}) diff --git a/hub/src/store/sessionJobs.ts b/hub/src/store/sessionJobs.ts new file mode 100644 index 0000000000..c44b5831df --- /dev/null +++ b/hub/src/store/sessionJobs.ts @@ -0,0 +1,267 @@ +import type { Database } from 'bun:sqlite' +import type { AttachedJob, AttachedJobPatch, AttachedJobStatus, AttachedJobUpsert } from '@hapi/protocol' + +import type { StoredSessionJob } from './types' + +/** + * Per-session attached jobs (tiann/hapi#1404). + * + * Registration-first long-running work that outlives the agent process. + * Hub is source of truth; list chrome reads the primary `running` job. + */ + +type DbJobRow = { + session_id: string + job_key: string + label: string + status: string + done: number | null + total: number | null + remaining: number | null + unit: string | null + detail: string | null + heartbeat_at: number + started_at: number + updated_at: number +} + +const JOB_COLUMNS = `session_id, job_key, label, status, done, total, remaining, unit, detail, heartbeat_at, started_at, updated_at` + +function toStored(row: DbJobRow): StoredSessionJob { + return { + sessionId: row.session_id, + key: row.job_key, + label: row.label, + status: row.status as AttachedJobStatus, + done: row.done ?? undefined, + total: row.total ?? undefined, + remaining: row.remaining ?? undefined, + unit: row.unit ?? undefined, + detail: row.detail ?? undefined, + heartbeatAt: row.heartbeat_at, + startedAt: row.started_at, + updatedAt: row.updated_at + } +} + +export function toAttachedJob(job: StoredSessionJob): AttachedJob { + return { + key: job.key, + label: job.label, + status: job.status, + ...(job.done !== undefined ? { done: job.done } : {}), + ...(job.total !== undefined ? { total: job.total } : {}), + ...(job.remaining !== undefined ? { remaining: job.remaining } : {}), + ...(job.unit !== undefined ? { unit: job.unit } : {}), + ...(job.detail !== undefined ? { detail: job.detail } : {}), + heartbeatAt: job.heartbeatAt, + startedAt: job.startedAt, + updatedAt: job.updatedAt + } +} + +export function listSessionJobs(db: Database, sessionId: string): StoredSessionJob[] { + const rows = db.prepare( + `SELECT ${JOB_COLUMNS} + FROM session_jobs + WHERE session_id = ? + ORDER BY updated_at DESC, job_key ASC` + ).all(sessionId) as DbJobRow[] + return rows.map(toStored) +} + +export function getSessionJob( + db: Database, + sessionId: string, + jobKey: string +): StoredSessionJob | null { + const row = db.prepare( + `SELECT ${JOB_COLUMNS} + FROM session_jobs + WHERE session_id = ? AND job_key = ?` + ).get(sessionId, jobKey) as DbJobRow | undefined + return row ? toStored(row) : null +} + +/** Newest `running` job for a session, or null. */ +export function getPrimaryRunningJob(db: Database, sessionId: string): StoredSessionJob | null { + const row = db.prepare( + `SELECT ${JOB_COLUMNS} + FROM session_jobs + WHERE session_id = ? AND status = 'running' + ORDER BY updated_at DESC, job_key ASC + LIMIT 1` + ).get(sessionId) as DbJobRow | undefined + return row ? toStored(row) : null +} + +/** + * Batch primary running jobs for session list enrichment. + * Returns Map sessionId → AttachedJob. + */ +export function getPrimaryRunningJobsBySessionIds( + db: Database, + sessionIds: string[] +): Map { + const result = new Map() + if (sessionIds.length === 0) return result + + const placeholders = sessionIds.map(() => '?').join(', ') + const rows = db.prepare( + `SELECT ${JOB_COLUMNS} + FROM session_jobs + WHERE status = 'running' AND session_id IN (${placeholders}) + ORDER BY updated_at DESC, job_key ASC` + ).all(...sessionIds) as DbJobRow[] + + for (const row of rows) { + if (result.has(row.session_id)) continue + result.set(row.session_id, toAttachedJob(toStored(row))) + } + return result +} + +export type UpsertSessionJobResult = + | { outcome: 'upserted'; job: StoredSessionJob } + | { outcome: 'session-not-found' } + +export function upsertSessionJob( + db: Database, + sessionId: string, + jobKey: string, + body: AttachedJobUpsert, + now: number = Date.now() +): UpsertSessionJobResult { + const existing = getSessionJob(db, sessionId, jobKey) + const heartbeatAt = body.heartbeatAt ?? now + const startedAt = body.startedAt ?? existing?.startedAt ?? now + const status = body.status ?? 'running' + + try { + db.prepare( + `INSERT INTO session_jobs ( + session_id, job_key, label, status, done, total, remaining, unit, detail, + heartbeat_at, started_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id, job_key) DO UPDATE SET + label = excluded.label, + status = excluded.status, + done = excluded.done, + total = excluded.total, + remaining = excluded.remaining, + unit = excluded.unit, + detail = excluded.detail, + heartbeat_at = excluded.heartbeat_at, + started_at = session_jobs.started_at, + updated_at = excluded.updated_at` + ).run( + sessionId, + jobKey, + body.label, + status, + body.done ?? null, + body.total ?? null, + body.remaining ?? null, + body.unit ?? null, + body.detail ?? null, + heartbeatAt, + startedAt, + now + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('FOREIGN KEY') || message.includes('foreign key')) { + return { outcome: 'session-not-found' } + } + throw error + } + + const job = getSessionJob(db, sessionId, jobKey) + if (!job) { + return { outcome: 'session-not-found' } + } + return { outcome: 'upserted', job } +} + +export function patchSessionJob( + db: Database, + sessionId: string, + jobKey: string, + patch: AttachedJobPatch, + now: number = Date.now() +): StoredSessionJob | null { + const existing = getSessionJob(db, sessionId, jobKey) + if (!existing) return null + + const next: StoredSessionJob = { + ...existing, + label: patch.label ?? existing.label, + status: patch.status ?? existing.status, + done: patch.done === null ? undefined : (patch.done ?? existing.done), + total: patch.total === null ? undefined : (patch.total ?? existing.total), + remaining: patch.remaining === null ? undefined : (patch.remaining ?? existing.remaining), + unit: patch.unit === null ? undefined : (patch.unit ?? existing.unit), + detail: patch.detail === null ? undefined : (patch.detail ?? existing.detail), + heartbeatAt: patch.heartbeatAt ?? now, + updatedAt: now + } + + db.prepare( + `UPDATE session_jobs SET + label = ?, status = ?, done = ?, total = ?, remaining = ?, unit = ?, detail = ?, + heartbeat_at = ?, updated_at = ? + WHERE session_id = ? AND job_key = ?` + ).run( + next.label, + next.status, + next.done ?? null, + next.total ?? null, + next.remaining ?? null, + next.unit ?? null, + next.detail ?? null, + next.heartbeatAt, + next.updatedAt, + sessionId, + jobKey + ) + + return getSessionJob(db, sessionId, jobKey) +} + +export function deleteSessionJob(db: Database, sessionId: string, jobKey: string): boolean { + const result = db.prepare( + 'DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?' + ).run(sessionId, jobKey) + return result.changes > 0 +} + +/** + * Re-point jobs during session merge (same contract as scratchlist transfer). + * Call BEFORE deleteSession so CASCADE does not race the move. + */ +export function transferSessionJobs( + db: Database, + fromSessionId: string, + toSessionId: string +): { moved: number; collided: number } { + const rows = listSessionJobs(db, fromSessionId) + let moved = 0 + let collided = 0 + + for (const job of rows) { + const existing = getSessionJob(db, toSessionId, job.key) + if (existing) { + db.prepare('DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?') + .run(fromSessionId, job.key) + collided += 1 + continue + } + db.prepare( + `UPDATE session_jobs SET session_id = ? + WHERE session_id = ? AND job_key = ?` + ).run(toSessionId, fromSessionId, job.key) + moved += 1 + } + + return { moved, collided } +} diff --git a/hub/src/store/sessionJobsStore.ts b/hub/src/store/sessionJobsStore.ts new file mode 100644 index 0000000000..cc59c5c2f6 --- /dev/null +++ b/hub/src/store/sessionJobsStore.ts @@ -0,0 +1,57 @@ +import type { Database } from 'bun:sqlite' +import type { AttachedJob, AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' + +import type { StoredSessionJob } from './types' +import { + deleteSessionJob, + getPrimaryRunningJob, + getPrimaryRunningJobsBySessionIds, + getSessionJob, + listSessionJobs, + patchSessionJob, + toAttachedJob, + transferSessionJobs, + upsertSessionJob, + type UpsertSessionJobResult +} from './sessionJobs' + +export class SessionJobsStore { + private readonly db: Database + + constructor(db: Database) { + this.db = db + } + + list(sessionId: string): StoredSessionJob[] { + return listSessionJobs(this.db, sessionId) + } + + get(sessionId: string, jobKey: string): StoredSessionJob | null { + return getSessionJob(this.db, sessionId, jobKey) + } + + getPrimaryRunning(sessionId: string): AttachedJob | null { + const job = getPrimaryRunningJob(this.db, sessionId) + return job ? toAttachedJob(job) : null + } + + getPrimaryRunningBySessionIds(sessionIds: string[]): Map { + return getPrimaryRunningJobsBySessionIds(this.db, sessionIds) + } + + upsert(sessionId: string, jobKey: string, body: AttachedJobUpsert): UpsertSessionJobResult { + return upsertSessionJob(this.db, sessionId, jobKey, body) + } + + patch(sessionId: string, jobKey: string, patch: AttachedJobPatch): StoredSessionJob | null { + return patchSessionJob(this.db, sessionId, jobKey, patch) + } + + delete(sessionId: string, jobKey: string): boolean { + return deleteSessionJob(this.db, sessionId, jobKey) + } + + transfer(fromSessionId: string, toSessionId: string): { moved: number; collided: number } { + return transferSessionJobs(this.db, fromSessionId, toSessionId) + } +} diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index 5a44d7ef17..9d47f8d156 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -98,6 +98,21 @@ export type StoredScratchlistEntry = { attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] } +export type StoredSessionJob = { + sessionId: string + key: string + label: string + status: import('@hapi/protocol').AttachedJobStatus + done?: number + total?: number + remaining?: number + unit?: string + detail?: string + heartbeatAt: number + startedAt: number + updatedAt: number +} + export type VersionedUpdateResult = | { result: 'success'; version: number; value: T } | { result: 'version-mismatch'; version: number; value: T } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index cba8c5f3a6..98709df8c0 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -752,6 +752,26 @@ export class SessionCache { }) } + /** + * tiann/hapi#1404 — emit primary attached job (or null) so session-list + * caches update inline without a dedicated refetch. + */ + emitAttachedJobChanged( + sessionId: string, + attachedJob: import('@hapi/protocol').AttachedJob | null + ): void { + const cached = this.sessions.get(sessionId) + const namespace = cached?.namespace + ?? this.store.sessions.getSession(sessionId)?.namespace + if (!namespace) return + this.publisher.emit({ + type: 'session-updated', + sessionId, + namespace, + data: { attachedJob } satisfies SessionPatch + }) + } + handleSessionEnd(payload: { sid: string; time: number }): void { const t = clampAliveTime(payload.time) ?? Date.now() @@ -1294,6 +1314,19 @@ export class SessionCache { // the operator's per-session notes, contradicting the v2.0 // promise that scratchlist survives reloads. const movedScratchlist = this.store.scratchlist.transfer(oldSessionId, newSessionId) + const movedJobs = this.store.sessionJobs.transfer(oldSessionId, newSessionId) + if (movedJobs.moved > 0 || movedJobs.collided > 0) { + this.emitAttachedJobChanged( + newSessionId, + this.store.sessionJobs.getPrimaryRunning(newSessionId) + ) + if (!options.deleteOldSession) { + this.emitAttachedJobChanged( + oldSessionId, + this.store.sessionJobs.getPrimaryRunning(oldSessionId) + ) + } + } if (movedScratchlist.moved > 0) { // Attachment hub paths embed the old session id. Re-key files + // metadata so quota/resolve stay correct on the consolidated id. diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index e20e0de104..80178c1c1c 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -788,6 +788,95 @@ export class SyncEngine { return removed } + listSessionJobs(sessionId: string) { + return this.store.sessionJobs.list(sessionId).map((job) => ({ + key: job.key, + label: job.label, + status: job.status, + ...(job.done !== undefined ? { done: job.done } : {}), + ...(job.total !== undefined ? { total: job.total } : {}), + ...(job.remaining !== undefined ? { remaining: job.remaining } : {}), + ...(job.unit !== undefined ? { unit: job.unit } : {}), + ...(job.detail !== undefined ? { detail: job.detail } : {}), + heartbeatAt: job.heartbeatAt, + startedAt: job.startedAt, + updatedAt: job.updatedAt + })) + } + + getPrimaryAttachedJob(sessionId: string) { + return this.store.sessionJobs.getPrimaryRunning(sessionId) + } + + getPrimaryAttachedJobsBySessionIds(sessionIds: string[]) { + return this.store.sessionJobs.getPrimaryRunningBySessionIds(sessionIds) + } + + upsertSessionJob( + sessionId: string, + jobKey: string, + body: import('@hapi/protocol').AttachedJobUpsert + ): + | { outcome: 'upserted'; job: import('@hapi/protocol').AttachedJob } + | { outcome: 'session-not-found' } { + const result = this.store.sessionJobs.upsert(sessionId, jobKey, body) + if (result.outcome === 'session-not-found') { + return result + } + const primary = this.store.sessionJobs.getPrimaryRunning(sessionId) + this.sessionCache.emitAttachedJobChanged(sessionId, primary) + const job = result.job + return { + outcome: 'upserted', + job: { + key: job.key, + label: job.label, + status: job.status, + ...(job.done !== undefined ? { done: job.done } : {}), + ...(job.total !== undefined ? { total: job.total } : {}), + ...(job.remaining !== undefined ? { remaining: job.remaining } : {}), + ...(job.unit !== undefined ? { unit: job.unit } : {}), + ...(job.detail !== undefined ? { detail: job.detail } : {}), + heartbeatAt: job.heartbeatAt, + startedAt: job.startedAt, + updatedAt: job.updatedAt + } + } + } + + patchSessionJob( + sessionId: string, + jobKey: string, + patch: import('@hapi/protocol').AttachedJobPatch + ): import('@hapi/protocol').AttachedJob | null { + const updated = this.store.sessionJobs.patch(sessionId, jobKey, patch) + if (!updated) return null + const primary = this.store.sessionJobs.getPrimaryRunning(sessionId) + this.sessionCache.emitAttachedJobChanged(sessionId, primary) + return { + key: updated.key, + label: updated.label, + status: updated.status, + ...(updated.done !== undefined ? { done: updated.done } : {}), + ...(updated.total !== undefined ? { total: updated.total } : {}), + ...(updated.remaining !== undefined ? { remaining: updated.remaining } : {}), + ...(updated.unit !== undefined ? { unit: updated.unit } : {}), + ...(updated.detail !== undefined ? { detail: updated.detail } : {}), + heartbeatAt: updated.heartbeatAt, + startedAt: updated.startedAt, + updatedAt: updated.updatedAt + } + } + + deleteSessionJob(sessionId: string, jobKey: string): boolean { + const removed = this.store.sessionJobs.delete(sessionId, jobKey) + if (removed) { + const primary = this.store.sessionJobs.getPrimaryRunning(sessionId) + this.sessionCache.emitAttachedJobChanged(sessionId, primary) + } + return removed + } + private async withScratchlistUploadLock( namespace: string, sessionId: string, diff --git a/hub/src/web/routes/sessions-jobs.test.ts b/hub/src/web/routes/sessions-jobs.test.ts new file mode 100644 index 0000000000..a10c09924c --- /dev/null +++ b/hub/src/web/routes/sessions-jobs.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { AttachedJob, AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' +import type { Session, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createSessionsRoutes } from './sessions' + +function createSession(overrides?: Partial): Session { + return { + id: '11111111-1111-1111-1111-111111111111', + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: false, + activeAt: 1, + metadata: { path: '/music', host: 'local', name: 'Lidarr' }, + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null, + ...overrides + } +} + +describe('session-attached jobs routes (tiann/hapi#1404)', () => { + it('lists attachedJob on GET /sessions for inactive session', async () => { + const session = createSession() + const jobs = new Map() + + const engine = { + resolveSessionAccess: () => ({ ok: true as const, sessionId: session.id, session }), + getSessionsByNamespace: () => [session], + getFutureScheduledMessageCounts: () => new Map(), + getNextScheduledAtBySessionIds: () => new Map(), + getPrimaryAttachedJobsBySessionIds: (ids: string[]) => { + const map = new Map() + const primary = [...jobs.values()].find((j) => j.status === 'running') + if (primary) { + for (const id of ids) map.set(id, primary) + } + return map + }, + getPrimaryAttachedJob: () => [...jobs.values()].find((j) => j.status === 'running') ?? null, + listSessionJobs: () => [...jobs.values()], + upsertSessionJob: (_sid: string, key: string, body: AttachedJobUpsert) => { + const now = Date.now() + const job: AttachedJob = { + key, + label: body.label, + status: body.status ?? 'running', + ...(body.done !== undefined ? { done: body.done } : {}), + ...(body.total !== undefined ? { total: body.total } : {}), + ...(body.remaining !== undefined ? { remaining: body.remaining } : {}), + ...(body.unit !== undefined ? { unit: body.unit } : {}), + ...(body.detail !== undefined ? { detail: body.detail } : {}), + heartbeatAt: body.heartbeatAt ?? now, + startedAt: body.startedAt ?? now, + updatedAt: now + } + jobs.set(key, job) + return { outcome: 'upserted' as const, job } + }, + patchSessionJob: (_sid: string, key: string, patch: AttachedJobPatch) => { + const existing = jobs.get(key) + if (!existing) return null + const next: AttachedJob = { + ...existing, + ...(patch.label !== undefined ? { label: patch.label } : {}), + ...(patch.status !== undefined ? { status: patch.status } : {}), + ...(patch.done !== undefined && patch.done !== null ? { done: patch.done } : {}), + ...(patch.remaining !== undefined && patch.remaining !== null + ? { remaining: patch.remaining } + : {}), + heartbeatAt: patch.heartbeatAt ?? Date.now(), + updatedAt: Date.now() + } + jobs.set(key, next) + return next + }, + deleteSessionJob: (_sid: string, key: string) => jobs.delete(key) + } as unknown as SyncEngine + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine)) + + const put = await app.request( + `http://localhost/api/sessions/${session.id}/jobs/beets`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + label: 'beets import', + remaining: 120, + unit: 'tracks' + }) + } + ) + expect(put.status).toBe(200) + + const res = await app.request('http://localhost/api/sessions') + expect(res.status).toBe(200) + const body = await res.json() as { + sessions: Array<{ active: boolean; attachedJob: AttachedJob | null }> + } + expect(body.sessions[0]!.active).toBe(false) + expect(body.sessions[0]!.attachedJob?.key).toBe('beets') + expect(body.sessions[0]!.attachedJob?.remaining).toBe(120) + + const del = await app.request( + `http://localhost/api/sessions/${session.id}/jobs/beets`, + { method: 'DELETE' } + ) + expect(del.status).toBe(200) + const list = await app.request(`http://localhost/api/sessions/${session.id}/jobs`) + const listed = await list.json() as { jobs: AttachedJob[]; primary: AttachedJob | null } + expect(listed.jobs).toEqual([]) + expect(listed.primary).toBeNull() + }) +}) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 4fc91066c5..2df981fd2c 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1,4 +1,6 @@ import { + AttachedJobPatchSchema, + AttachedJobUpsertSchema, CursorMigrateToAcpRequestSchema, DeleteUploadRequestSchema, ForkConversationRequestSchema, @@ -118,8 +120,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const scheduledCounts = engine.getFutureScheduledMessageCounts(sessionRecords.map((session) => session.id)) const nextScheduledAt = engine.getNextScheduledAtBySessionIds(sessionRecords.map((session) => session.id)) + const attachedJobs = engine.getPrimaryAttachedJobsBySessionIds(sessionRecords.map((session) => session.id)) const sessions = sessionRecords.map((session) => { - const summary = toSessionSummary(session) + const summary = toSessionSummary(session, { + attachedJob: attachedJobs.get(session.id) ?? null + }) return { ...summary, futureScheduledMessageCount: scheduledCounts.get(session.id) ?? 0, @@ -1295,6 +1300,94 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ ok: true }) }) + // tiann/hapi#1404 — session-attached long-running jobs (works while agent idle). + const JOB_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/ + + app.get('/sessions/:id/jobs', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + return c.json({ + jobs: engine.listSessionJobs(sessionResult.sessionId), + primary: engine.getPrimaryAttachedJob(sessionResult.sessionId) + }) + }) + + app.put('/sessions/:id/jobs/:jobKey', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + const jobKey = c.req.param('jobKey') + if (!jobKey || !JOB_KEY_RE.test(jobKey)) { + return c.json({ error: 'Invalid jobKey (1-128 chars: alnum, . _ -)' }, 400) + } + const body = await c.req.json().catch(() => null) + const parsed = AttachedJobUpsertSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400) + } + const result = engine.upsertSessionJob(sessionResult.sessionId, jobKey, parsed.data) + if (result.outcome === 'session-not-found') { + return c.json({ error: 'Session not found' }, 404) + } + return c.json({ job: result.job }) + }) + + app.patch('/sessions/:id/jobs/:jobKey', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + const jobKey = c.req.param('jobKey') + if (!jobKey || !JOB_KEY_RE.test(jobKey)) { + return c.json({ error: 'Invalid jobKey (1-128 chars: alnum, . _ -)' }, 400) + } + const body = await c.req.json().catch(() => null) + const parsed = AttachedJobPatchSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400) + } + const job = engine.patchSessionJob(sessionResult.sessionId, jobKey, parsed.data) + if (!job) { + return c.json({ error: 'Job not found' }, 404) + } + return c.json({ job }) + }) + + app.delete('/sessions/:id/jobs/:jobKey', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + const jobKey = c.req.param('jobKey') + if (!jobKey || !JOB_KEY_RE.test(jobKey)) { + return c.json({ error: 'Invalid jobKey (1-128 chars: alnum, . _ -)' }, 400) + } + const removed = engine.deleteSessionJob(sessionResult.sessionId, jobKey) + if (!removed) { + return c.json({ error: 'Job not found' }, 404) + } + return c.json({ ok: true }) + }) + app.get('/sessions/:id/slash-commands', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/index.ts b/shared/src/index.ts index 3e35fdd0bb..9d02e9d8a1 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -26,4 +26,16 @@ export * from './slashCommands' export * from './utils' export * from './usage' export * from './version' +export { + AttachedJobSchema, + AttachedJobUpsertSchema, + AttachedJobPatchSchema, + AttachedJobStatusSchema +} from './schemas' +export type { + AttachedJob, + AttachedJobUpsert, + AttachedJobPatch, + AttachedJobStatus +} from './schemas' export type * from './types' diff --git a/shared/src/schemas.sessionPatch.test.ts b/shared/src/schemas.sessionPatch.test.ts index 36afa290aa..6515ea5c66 100644 --- a/shared/src/schemas.sessionPatch.test.ts +++ b/shared/src/schemas.sessionPatch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { SessionPatchSchema } from './schemas'; +import { AttachedJobSchema, SessionPatchSchema } from './schemas'; // Guard the contract for the second-half-of-#884 fix. The web client routes // `session-updated` events to the structured-patch path only when the event's @@ -101,4 +101,33 @@ describe('SessionPatchSchema structured patches (closes #884 follow-up)', () => }; expect(SessionPatchSchema.safeParse(fullSession).success).toBe(false); }); + + it('accepts attachedJob payload or null (tiann/hapi#1404)', () => { + const job = AttachedJobSchema.parse({ + key: 'beets', + label: 'beets import', + status: 'running', + done: 800, + total: 900, + unit: 'tracks', + heartbeatAt: 2_000, + startedAt: 1_000, + updatedAt: 2_000 + }) + expect(SessionPatchSchema.safeParse({ attachedJob: job }).success).toBe(true) + expect(SessionPatchSchema.safeParse({ attachedJob: null }).success).toBe(true) + }); + + it('rejects fake percent-only attached jobs without counters', () => { + // Progress is explicit counts — no bare percent field on the wire. + expect(AttachedJobSchema.safeParse({ + key: 'x', + label: 'x', + status: 'running', + percent: 91, + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + }).success).toBe(false) + }); }); diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 40da2b475f..a122541808 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -398,6 +398,53 @@ const VersionedTeamStatePatchSchema = z.object({ value: TeamStateSchema.nullable() }) +/** Opt-in long-running work owned by a session (tiann/hapi#1404). */ +export const AttachedJobStatusSchema = z.enum(['running', 'completed', 'failed']) + +export const AttachedJobSchema = z.object({ + key: z.string().min(1).max(128), + label: z.string().min(1).max(200), + status: AttachedJobStatusSchema, + done: z.number().nonnegative().optional(), + total: z.number().positive().optional(), + remaining: z.number().nonnegative().optional(), + unit: z.string().min(1).max(64).optional(), + detail: z.string().max(500).optional(), + heartbeatAt: z.number(), + startedAt: z.number(), + updatedAt: z.number() +}).strict() + +export type AttachedJob = z.infer +export type AttachedJobStatus = z.infer + +export const AttachedJobUpsertSchema = z.object({ + label: z.string().min(1).max(200), + status: AttachedJobStatusSchema.optional().default('running'), + done: z.number().nonnegative().optional(), + total: z.number().positive().optional(), + remaining: z.number().nonnegative().optional(), + unit: z.string().min(1).max(64).optional(), + detail: z.string().max(500).optional(), + heartbeatAt: z.number().optional(), + startedAt: z.number().optional() +}).strict() + +export type AttachedJobUpsert = z.infer + +export const AttachedJobPatchSchema = z.object({ + label: z.string().min(1).max(200).optional(), + status: AttachedJobStatusSchema.optional(), + done: z.number().nonnegative().nullable().optional(), + total: z.number().positive().nullable().optional(), + remaining: z.number().nonnegative().nullable().optional(), + unit: z.string().min(1).max(64).nullable().optional(), + detail: z.string().max(500).nullable().optional(), + heartbeatAt: z.number().optional() +}).strict() + +export type AttachedJobPatch = z.infer + export const SessionPatchSchema = z.object({ active: z.boolean().optional(), thinking: z.boolean().optional(), @@ -430,7 +477,12 @@ export const SessionPatchSchema = z.object({ // signal, not the payload. Keep this minimal: per the operator's 80/20 // ruling, scratchlist mutations are rare relative to keep-alive // patches, so a fresh event type would be overkill. - scratchlistUpdatedAt: z.number().optional() + scratchlistUpdatedAt: z.number().optional(), + // tiann/hapi#1404 — session-attached long-running jobs. Unlike + // scratchlist (watermark → refetch), the list row needs the progress + // payload inline, so patches carry the primary running job (or null + // when cleared / none remain). + attachedJob: AttachedJobSchema.nullable().optional() }).strict() export type SessionPatch = z.infer diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index 792dcba882..c2a7f0b2b4 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -330,6 +330,25 @@ describe('summary derivation helpers', () => { expect(computePendingRequestsCount(undefined)).toBe(0) }) + it('includes attachedJob when provided via extras', () => { + const job = { + key: 'beets', + label: 'beets import', + status: 'running' as const, + remaining: 120, + unit: 'tracks', + heartbeatAt: 9_000, + startedAt: 1_000, + updatedAt: 9_000 + } + const summary = toSessionSummary(makeSession(), { attachedJob: job }) + expect(summary.attachedJob).toEqual(job) + }) + + it('defaults attachedJob to null', () => { + expect(toSessionSummary(makeSession()).attachedJob).toBeNull() + }) + it('toSessionSummaryMetadata returns null for null metadata', () => { expect(toSessionSummaryMetadata(null)).toBeNull() expect(toSessionSummaryMetadata(undefined)).toBeNull() diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index f2d787e6b9..6ded7a20d1 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -1,4 +1,4 @@ -import type { AgentState, Metadata, Session, TodoItem, WorktreeMetadata } from './schemas' +import type { AgentState, AttachedJob, Metadata, Session, TodoItem, WorktreeMetadata } from './schemas' import { isKnownFlavor } from './flavors' import type { AgentFlavor } from './modes' @@ -72,6 +72,11 @@ export type SessionSummary = { futureScheduledMessageCount: number /** Epoch ms of the soonest uninvoked future scheduled message, or null. */ nextScheduledAt: number | null + /** + * Primary running session-attached job (tiann/hapi#1404), or null. + * Independent of agent `active` / thinking — work that outlives the agent. + */ + attachedJob: AttachedJob | null model: string | null modelReasoningEffort?: string | null effort: string | null @@ -203,7 +208,10 @@ export function toSessionSummaryMetadata(metadata: Metadata | null | undefined): } } -export function toSessionSummary(session: Session): SessionSummary { +export function toSessionSummary( + session: Session, + extras?: { attachedJob?: AttachedJob | null } +): SessionSummary { return { hasConversationContent: session.hasConversationContent ?? false, id: session.id, @@ -224,6 +232,7 @@ export function toSessionSummary(session: Session): SessionSummary { backgroundTaskCount: session.backgroundTaskCount ?? 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: extras?.attachedJob ?? null, model: session.model, modelReasoningEffort: session.modelReasoningEffort, effort: session.effort diff --git a/shared/src/types.ts b/shared/src/types.ts index df693464ca..2bd8d96765 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -2,6 +2,10 @@ export type { AgentState, AgentStateCompletedRequest, AgentStateRequest, + AttachedJob, + AttachedJobPatch, + AttachedJobStatus, + AttachedJobUpsert, AttachmentMetadata, DecryptedMessage, Metadata, diff --git a/web/src/components/SessionAttentionIndicator.test.tsx b/web/src/components/SessionAttentionIndicator.test.tsx index d69f293873..bbf540153c 100644 --- a/web/src/components/SessionAttentionIndicator.test.tsx +++ b/web/src/components/SessionAttentionIndicator.test.tsx @@ -29,6 +29,7 @@ function makeSummary(overrides: Partial & { id: string }): Sessi backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index 7e399cbb15..fb49c666fa 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -33,6 +33,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.machine-filter.test.tsx b/web/src/components/SessionList.machine-filter.test.tsx index f786202fd9..4fc1477d44 100644 --- a/web/src/components/SessionList.machine-filter.test.tsx +++ b/web/src/components/SessionList.machine-filter.test.tsx @@ -29,6 +29,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index 28a47941a3..1c37f67fd1 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -42,6 +42,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionRowSummary.tsx b/web/src/components/SessionRowSummary.tsx index 0602f7ae45..4abc7fa584 100644 --- a/web/src/components/SessionRowSummary.tsx +++ b/web/src/components/SessionRowSummary.tsx @@ -12,6 +12,11 @@ import { getCodexImportedAt } from '@/lib/codexImportedSessions' import { getSessionTitle } from '@/lib/sessionTitle' import { useTranslation } from '@/lib/use-translation' import { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel' +import { + attachedJobFraction, + formatAttachedJobProgress, + isAttachedJobStale +} from '@/lib/attachedJob' function LoaderIcon(props: { className?: string }) { return ( @@ -162,14 +167,18 @@ export function SessionRowSummary(props: { const attentionId = attentionTooltipIdProp ?? ownedIds.attentionId const scheduleId = scheduleTooltipIdProp ?? ownedIds.scheduleId const timeLabel = getSessionTimeLabel(s, t) + const attachedJob = s.attachedJob?.status === 'running' ? s.attachedJob : null + const jobStale = attachedJob ? isAttachedJobStale(attachedJob) : false + const jobFraction = attachedJob ? attachedJobFraction(attachedJob) : null + const jobProgressLabel = attachedJob ? formatAttachedJobProgress(attachedJob) : null return (
-
+
{sessionName} @@ -288,6 +297,43 @@ export function SessionRowSummary(props: { ) : null}
+ {attachedJob && jobProgressLabel ? ( +
+
+ ) : null} {projectLabel || machineLabel ? (
{[projectLabel, machineLabel].filter(Boolean).join(' · ')} diff --git a/web/src/hooks/useSSE.test.ts b/web/src/hooks/useSSE.test.ts index c2259ecf2c..7917422acb 100644 --- a/web/src/hooks/useSSE.test.ts +++ b/web/src/hooks/useSSE.test.ts @@ -179,6 +179,7 @@ function makeSummary(overrides: Partial = {}): SessionSummary { backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 2218012083..39236f212c 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -159,6 +159,16 @@ export function isRenderIrrelevantPatch(current: SessionSummary, next: SessionSu && current.thinking === next.thinking && current.updatedAt === next.updatedAt && current.backgroundTaskCount === next.backgroundTaskCount + && current.attachedJob?.key === next.attachedJob?.key + && current.attachedJob?.label === next.attachedJob?.label + && current.attachedJob?.status === next.attachedJob?.status + && current.attachedJob?.done === next.attachedJob?.done + && current.attachedJob?.total === next.attachedJob?.total + && current.attachedJob?.remaining === next.attachedJob?.remaining + && current.attachedJob?.unit === next.attachedJob?.unit + && current.attachedJob?.detail === next.attachedJob?.detail + && current.attachedJob?.heartbeatAt === next.attachedJob?.heartbeatAt + && (current.attachedJob == null) === (next.attachedJob == null) && current.model === next.model && current.modelReasoningEffort === next.modelReasoningEffort && current.effort === next.effort @@ -487,6 +497,7 @@ export function useSSE(options: { const existing = existingIndex >= 0 ? previous.sessions[existingIndex] : undefined const summary = { ...toSessionSummary(session), + attachedJob: existing?.attachedJob ?? null, futureScheduledMessageCount: existing?.futureScheduledMessageCount ?? 0, nextScheduledAt: existing?.nextScheduledAt ?? null } @@ -532,6 +543,9 @@ export function useSSE(options: { backgroundTaskCount: Object.prototype.hasOwnProperty.call(patch, 'backgroundTaskCount') ? patch.backgroundTaskCount ?? 0 : current.backgroundTaskCount, + attachedJob: Object.prototype.hasOwnProperty.call(patch, 'attachedJob') + ? patch.attachedJob ?? null + : current.attachedJob ?? null, model: Object.prototype.hasOwnProperty.call(patch, 'model') ? patch.model ?? null : current.model, modelReasoningEffort: Object.prototype.hasOwnProperty.call(patch, 'modelReasoningEffort') ? patch.modelReasoningEffort ?? null diff --git a/web/src/lib/attachedJob.test.ts b/web/src/lib/attachedJob.test.ts new file mode 100644 index 0000000000..fe3946b192 --- /dev/null +++ b/web/src/lib/attachedJob.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { AttachedJob } from '@hapi/protocol' +import { + ATTACHED_JOB_STALE_MS, + attachedJobFraction, + formatAttachedJobProgress, + isAttachedJobStale +} from './attachedJob' + +function job(overrides: Partial = {}): AttachedJob { + return { + key: 'beets', + label: 'beets import', + status: 'running', + heartbeatAt: 1_000, + startedAt: 1_000, + updatedAt: 1_000, + ...overrides + } +} + +describe('attachedJob helpers', () => { + it('formats remaining count without inventing percent', () => { + expect(formatAttachedJobProgress(job({ remaining: 120, unit: 'tracks' }))).toBe('120 tracks left') + }) + + it('formats done/total with derived percent', () => { + expect(formatAttachedJobProgress(job({ done: 800, total: 900, unit: 'tracks' }))).toBe( + '89% · 800/900 tracks' + ) + }) + + it('falls back to running when only heartbeat', () => { + expect(formatAttachedJobProgress(job())).toBe('running') + }) + + it('computes fraction from remaining+total', () => { + expect(attachedJobFraction(job({ remaining: 100, total: 1000 }))).toBe(0.9) + }) + + it('marks stale after heartbeat window', () => { + const now = 1_000 + ATTACHED_JOB_STALE_MS + 1 + expect(isAttachedJobStale(job({ heartbeatAt: 1_000 }), now)).toBe(true) + expect(isAttachedJobStale(job({ heartbeatAt: now - 60_000 }), now)).toBe(false) + }) +}) diff --git a/web/src/lib/attachedJob.ts b/web/src/lib/attachedJob.ts new file mode 100644 index 0000000000..58c869c1c9 --- /dev/null +++ b/web/src/lib/attachedJob.ts @@ -0,0 +1,31 @@ +import type { AttachedJob } from '@hapi/protocol' + +/** Stale if no heartbeat for 15 minutes — UI amber, still shows progress. */ +export const ATTACHED_JOB_STALE_MS = 15 * 60 * 1000 + +export function formatAttachedJobProgress(job: AttachedJob): string { + if (job.remaining !== undefined) { + const unit = job.unit ? ` ${job.unit}` : '' + return `${job.remaining}${unit} left` + } + if (job.done !== undefined && job.total !== undefined && job.total > 0) { + const pct = Math.min(100, Math.round((job.done / job.total) * 100)) + return `${pct}% · ${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''}` + } + return 'running' +} + +export function attachedJobFraction(job: AttachedJob): number | null { + if (job.done !== undefined && job.total !== undefined && job.total > 0) { + return Math.max(0, Math.min(1, job.done / job.total)) + } + if (job.remaining !== undefined && job.total !== undefined && job.total > 0) { + const done = Math.max(0, job.total - job.remaining) + return Math.max(0, Math.min(1, done / job.total)) + } + return null +} + +export function isAttachedJobStale(job: AttachedJob, now: number = Date.now()): boolean { + return now - job.heartbeatAt > ATTACHED_JOB_STALE_MS +} diff --git a/web/src/lib/sessionAttention.test.ts b/web/src/lib/sessionAttention.test.ts index acf95094b7..a503ac98a9 100644 --- a/web/src/lib/sessionAttention.test.ts +++ b/web/src/lib/sessionAttention.test.ts @@ -22,6 +22,7 @@ function makeSummary(overrides: Partial & { id: string }): Sessi backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides diff --git a/web/src/lib/sessionReference.test.ts b/web/src/lib/sessionReference.test.ts index 4118d4c7d9..407f296646 100644 --- a/web/src/lib/sessionReference.test.ts +++ b/web/src/lib/sessionReference.test.ts @@ -28,6 +28,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides, From e19aee7e99cf5d6e7ac00b3c9a19879a2b47a821 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:53:38 +0000 Subject: [PATCH 08/99] feat(jobs): agent guidance + wall-clock elapsed on list chrome Document the session-job contract for agents and always show startedAt elapsed next to remaining/fraction/running so indeterminate drains still read as wall time without inventing an ETA. Co-authored-by: Cursor --- AGENTS.md | 15 +++ cli/README.md | 1 + cli/src/claude/utils/systemPrompt.ts | 3 +- cli/src/codex/utils/systemPrompt.ts | 3 +- cli/src/commands/job.ts | 36 +++++++- cli/src/grok/utils/systemPrompt.ts | 3 +- .../common/sessionJobInstruction.test.ts | 20 ++++ .../modules/common/sessionJobInstruction.ts | 33 +++++++ cli/src/opencode/utils/systemPrompt.test.ts | 6 +- cli/src/opencode/utils/systemPrompt.ts | 5 +- docs/.vitepress/config.ts | 1 + docs/guide/faq.md | 4 + docs/guide/session-jobs.md | 92 +++++++++++++++++++ web/src/components/SessionRowSummary.tsx | 15 ++- web/src/lib/attachedJob.test.ts | 33 +++++-- web/src/lib/attachedJob.ts | 38 +++++++- 16 files changed, 284 insertions(+), 24 deletions(-) create mode 100644 cli/src/modules/common/sessionJobInstruction.test.ts create mode 100644 cli/src/modules/common/sessionJobInstruction.ts create mode 100644 docs/guide/session-jobs.md 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/cli/README.md b/cli/README.md index 11066a7ba1..606a90c2ff 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` - 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 at least every ~10m; honest `--remaining` or `--done`/`--total` (omit counts if unknown — never invent a percent). 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/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/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.ts b/cli/src/commands/job.ts index cbd2fa55fc..1579621130 100644 --- a/cli/src/commands/job.ts +++ b/cli/src/commands/job.ts @@ -29,16 +29,33 @@ 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 (nohup / batch / long scripts / external daemons) + while the session may be idle. Not thinking progress or in-agent background tools. + +${chalk.bold('Agent contract:')} + 1. set before (or as) the process starts + 2. update / heartbeat at least every ~10 minutes while running + 3. prefer honest --remaining or --done/--total; omit counts if unknown + 4. never invent a fake percent + 5. clear or --status completed|failed when finished + ${chalk.bold('Usage:')} hapi job set --label [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] hapi job update [--remaining N] [--done N] [--total N] [--status running|completed|failed] [--detail ...] hapi job clear 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 + ${chalk.bold('Notes:')} - Hub-persisted. Works while the agent is idle/offline — not thinking progress. - Prefer honest remaining/done+total; never invent a fake percent. + Hub-persisted. Prefer "$HAPI_SESSION_ID" for this chat. Job key: 1-128 chars, alnum / . _ - + Docs: docs/guide/session-jobs.md ${chalk.bold('Env:')} HAPI_API_URL / CLI_API_TOKEN (or ~/.hapi/settings.json via \`hapi auth login\`) @@ -166,6 +183,7 @@ function formatJobLine(job: { unit?: string detail?: string heartbeatAt: number + startedAt: number }): string { const parts = [`${job.key}`, job.label, job.status] if (job.remaining !== undefined) { @@ -173,6 +191,20 @@ function formatJobLine(job: { } else if (job.done !== undefined && job.total !== undefined) { parts.push(`${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''}`) } + const elapsedSec = Math.max(0, Math.round((Date.now() - 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(job.detail) const ageSec = Math.max(0, Math.round((Date.now() - job.heartbeatAt) / 1000)) parts.push(`heartbeat ${ageSec}s ago`) 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/sessionJobInstruction.test.ts b/cli/src/modules/common/sessionJobInstruction.test.ts new file mode 100644 index 0000000000..2cdf00bedb --- /dev/null +++ b/cli/src/modules/common/sessionJobInstruction.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { + SESSION_JOB_INSTRUCTION, + withSessionJobInstruction +} from './sessionJobInstruction' + +describe('sessionJobInstruction', () => { + it('mentions set, update, heartbeat, and no fake percent', () => { + expect(SESSION_JOB_INSTRUCTION).toContain('hapi job set') + expect(SESSION_JOB_INSTRUCTION).toContain('hapi job update') + expect(SESSION_JOB_INSTRUCTION).toContain('~10 minutes') + expect(SESSION_JOB_INSTRUCTION).toContain('Never invent a fake percent') + expect(SESSION_JOB_INSTRUCTION).toContain('HAPI_SESSION_ID') + }) + + 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..c6ba00c3d2 --- /dev/null +++ b/cli/src/modules/common/sessionJobInstruction.ts @@ -0,0 +1,33 @@ +/** + * Always-on steer for session-attached long-running jobs (tiann/hapi#1404). + * + * Unlike the session-summary contract (opt-in), this is short and triggers only + * when the agent spawns outliving work — so it rides every supported flavor's + * system / developer instructions by default. + * + * Cursor ACP has no system-prompt seam today; Cursor agents rely on the estate + * skill `hapi-session-jobs` (and `hapi job --help`) instead. + */ + +/** 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', + '(nohup, batch imports, long scripts, external daemons), attach it to this', + 'HAPI session so the session list can show progress while you are idle.', + 'Use: hapi job set "$HAPI_SESSION_ID" --label ', + '[--remaining N] [--done N --total N] [--unit ] [--detail ].', + 'Heartbeat with hapi job update at least every ~10 minutes (UI goes amber', + 'after ~15m without a heartbeat). Prefer honest remaining or done+total;', + 'omit counts when unknown (UI shows "running" + indeterminate bar).', + 'Never invent a fake percent. On finish: hapi job update … --status', + 'completed|failed, or hapi job clear. 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/opencode/utils/systemPrompt.test.ts b/cli/src/opencode/utils/systemPrompt.test.ts index b22d3f9e2a..483c901075 100644 --- a/cli/src/opencode/utils/systemPrompt.test.ts +++ b/cli/src/opencode/utils/systemPrompt.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { ensureOpencodeConfig } from './opencodeConfig' -import { TITLE_INSTRUCTION } from './systemPrompt' +import { TITLE_INSTRUCTION, getTitleInstruction } from './systemPrompt' describe('OpenCode local HAPI instructions', () => { let configDirectory: string | null = null @@ -20,11 +20,13 @@ describe('OpenCode local HAPI instructions', () => { const { instructionsPath } = ensureOpencodeConfig( configDirectory, { command: 'hapi', args: ['mcp'] }, - TITLE_INSTRUCTION + getTitleInstruction({}) ) const instructions = await readFile(instructionsPath, 'utf8') expect(instructions).toContain('$name') expect(instructions).toContain('skill_lookup') + expect(instructions).toContain('hapi job set') + expect(instructions).toContain(TITLE_INSTRUCTION.trim()) }) }) diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index b1838d33e7..9844625196 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -14,6 +14,7 @@ import { DISPLAY_VIDEO_PROMPT_HAPI_MCP, } from '@/modules/common/displayImagePrompt'; import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction'; +import { withSessionJobInstruction } from '@/modules/common/sessionJobInstruction'; import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction'; /** @@ -30,7 +31,7 @@ export const TITLE_INSTRUCTION = trimIdent(` `); export function getTitleInstruction(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(TITLE_INSTRUCTION, env) + return withSessionSummaryInstruction(withSessionJobInstruction(TITLE_INSTRUCTION), env) } /** @@ -50,7 +51,7 @@ export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(` `); export function getOpencodeNativeToolInstruction(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(OPENCODE_NATIVE_TOOL_INSTRUCTION, env) + return withSessionSummaryInstruction(withSessionJobInstruction(OPENCODE_NATIVE_TOOL_INSTRUCTION), env) } /** diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index e4c4f3fa70..4952a99948 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -31,6 +31,7 @@ export default defineConfig({ text: 'Guide', items: [ { text: 'How it Works', link: '/guide/how-it-works' }, + { text: 'Session-attached jobs', link: '/guide/session-jobs' }, { text: 'Voice Assistant', link: '/guide/voice-assistant' }, { text: 'Why HAPI', link: '/guide/why-hapi' }, { text: 'FAQ', link: '/guide/faq' } diff --git a/docs/guide/faq.md b/docs/guide/faq.md index f68e6183c9..2d050d67cf 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -110,6 +110,10 @@ Yes. Open any session and use the chat interface to send messages directly to th Some agents (especially Cursor) can resume after idle from harness signals such as background Shell `notify_on_output` or `/loop`, without you sending a new HAPI message. HAPI updates the session's thinking indicator when the agent resumes work or requests permission, so the list reflects that activity. +### How do I show progress for a long batch that outlives the agent? + +Use session-attached jobs (`hapi job`). The agent (or a wrapper script) registers a job on the session, heartbeats while the process runs, and clears it when done. The session list shows remaining / fraction / or an indeterminate "running" meter even when the agent is idle. See [Session-attached jobs](./session-jobs.md). + ### Can I access a terminal remotely? Yes. Open a session in the web app and tap the Terminal tab for a remote shell. diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md new file mode 100644 index 0000000000..339d9be7d4 --- /dev/null +++ b/docs/guide/session-jobs.md @@ -0,0 +1,92 @@ +# Session-attached jobs + +Hub-persisted progress for work that **outlives the agent** — batch imports, `nohup` scripts, long drains — so the session list still shows something truthful while the chat is idle (`active: false`). + +This is **not** in-agent thinking progress, todos, or `backgroundTaskCount`. Those die when the agent disconnects. Attached jobs live on the hub until you clear them. + +Upstream: [tiann/hapi#1404](https://github.com/tiann/hapi/issues/1404). + +## When to attach + +Attach a job **before** (or immediately when) you start process-shaped work that will keep running after the agent goes idle: + +| Attach | Do not attach | +|--------|----------------| +| `nohup` / `setsid` / systemd oneshot that runs for hours–days | A tool call that finishes in this turn | +| Beets / rclone / compile / migrate / download batches | Normal coding edits and tests | +| External daemon you own for this session's goal | Claude/Codex Ctrl+B-style background tools | + +If the operator would reopen the chat only to ask "how's it doing?", it belongs here. + +## Agent contract (specification) + +HAPI does **not** write your batch scripts. You (the agent) create the process **and** feed the meter. + +1. **Register** with a stable `job-key` (1–128 chars: alnum / `.` `_` `-`). +2. **Heartbeat** at least every ~10 minutes while running (UI amber after ~15 minutes quiet). +3. **Report progress honestly** — see tiers below. Never invent a bare percent. +4. **Finish cleanly** — `--status completed|failed` or `hapi job clear`. + +Session id: prefer `"$HAPI_SESSION_ID"` (exported into every HAPI-wrapped agent). Prefix match also works. + +```bash +hapi job set "$HAPI_SESSION_ID" beets \ + --label 'beets import' \ + --remaining 150 --done 1637 --total 1787 --unit units \ + --detail 'album: Some Artist - Some Album' + +hapi job update "$HAPI_SESSION_ID" beets --remaining 149 --done 1638 --detail '…' + +hapi job update "$HAPI_SESSION_ID" beets --status completed +# or +hapi job clear "$HAPI_SESSION_ID" beets +``` + +Same auth as `hapi ping-peer` (`HAPI_API_URL` / `CLI_API_TOKEN` or `hapi auth login`). + +## Progress honesty (tiers) + +| What you know | What to send | What the list shows | +|---------------|--------------|---------------------| +| Countable leftover | `--remaining N` (+ optional `--unit`) | `150 units left · 2d 4h` | +| Countable fraction | `--done N --total M` | `91% · 1637/1787 units · 2d 4h` | +| Stage only / unknown size | `--label` + `--detail` + heartbeats | `running · 2d 4h` + indeterminate bar | + +**Elapsed** is always derived from hub `startedAt` (wall clock since register). It is **not** an ETA and there is no time-remaining field - operators get "how long has this been going" plus whatever honest count/detail you report, without a fake completion estimate. + +Rules: + +- Prefer **remaining** when the operator cares about "how much left". +- Prefer **done+total** when both ends of a fraction exist (UI may derive %). +- If you only know a stage name, put it in `--detail` and keep heartbeating — do **not** fake `total=100`. +- There is **no** `--percent` flag and **no** ETA / time-remaining field. Inventing either would train agents to lie. + +## Heartbeat recipe + +Wrap the long process so something calls `hapi job update` on a timer (or on each unit completed). Minimum viable indeterminate job: + +```bash +hapi job set "$HAPI_SESSION_ID" rsync-backup --label 'rsync backup' --detail 'phase: copy' +# in a loop / cron / companion script: +hapi job update "$HAPI_SESSION_ID" rsync-backup --detail "phase: copy · $(date -u +%H:%M)Z" +``` + +When the process exits, mark completed/failed or clear. A stuck green/amber chip with a dead PID is worse than no chip. + +## CLI reference + +```bash +hapi job set --label [options] +hapi job update [options] +hapi job clear +hapi job list +hapi job --help +``` + +Primary running job is enriched onto `GET /api/sessions` as `attachedJob` and pushed on `session-updated` SSE patches. + +## Related + +- [Supported Agents](./agents.md) — flavors and resume +- [How it Works](./how-it-works.md) — CLI ↔ hub ↔ web +- CLI: `hapi job --help`, `cli/README.md` diff --git a/web/src/components/SessionRowSummary.tsx b/web/src/components/SessionRowSummary.tsx index 4abc7fa584..625f7836c4 100644 --- a/web/src/components/SessionRowSummary.tsx +++ b/web/src/components/SessionRowSummary.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import type { SessionSummary } from '@/types/api' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' import { ScheduleIcon } from '@/components/icons' @@ -17,7 +17,6 @@ import { formatAttachedJobProgress, isAttachedJobStale } from '@/lib/attachedJob' - function LoaderIcon(props: { className?: string }) { return ( @@ -168,9 +167,17 @@ export function SessionRowSummary(props: { const scheduleId = scheduleTooltipIdProp ?? ownedIds.scheduleId const timeLabel = getSessionTimeLabel(s, t) const attachedJob = s.attachedJob?.status === 'running' ? s.attachedJob : null - const jobStale = attachedJob ? isAttachedJobStale(attachedJob) : false + // Tick once a minute so elapsed wall-time advances without waiting for a heartbeat SSE. + const [nowMs, setNowMs] = useState(() => Date.now()) + useEffect(() => { + if (!attachedJob) return + setNowMs(Date.now()) + const id = window.setInterval(() => setNowMs(Date.now()), 60_000) + return () => window.clearInterval(id) + }, [attachedJob?.key, attachedJob?.startedAt]) + const jobStale = attachedJob ? isAttachedJobStale(attachedJob, nowMs) : false const jobFraction = attachedJob ? attachedJobFraction(attachedJob) : null - const jobProgressLabel = attachedJob ? formatAttachedJobProgress(attachedJob) : null + const jobProgressLabel = attachedJob ? formatAttachedJobProgress(attachedJob, nowMs) : null return (
diff --git a/web/src/lib/attachedJob.test.ts b/web/src/lib/attachedJob.test.ts index fe3946b192..65dd0682ad 100644 --- a/web/src/lib/attachedJob.test.ts +++ b/web/src/lib/attachedJob.test.ts @@ -3,7 +3,9 @@ import type { AttachedJob } from '@hapi/protocol' import { ATTACHED_JOB_STALE_MS, attachedJobFraction, + formatAttachedJobElapsed, formatAttachedJobProgress, + formatCompactElapsed, isAttachedJobStale } from './attachedJob' @@ -20,18 +22,35 @@ function job(overrides: Partial = {}): AttachedJob { } describe('attachedJob helpers', () => { - it('formats remaining count without inventing percent', () => { - expect(formatAttachedJobProgress(job({ remaining: 120, unit: 'tracks' }))).toBe('120 tracks left') + it('formats compact elapsed without inventing ETA', () => { + expect(formatCompactElapsed(0)).toBe('0s') + expect(formatCompactElapsed(45_000)).toBe('45s') + expect(formatCompactElapsed(5 * 60_000)).toBe('5m') + expect(formatCompactElapsed(3 * 60 * 60_000 + 12 * 60_000)).toBe('3h 12m') + expect(formatCompactElapsed(3 * 60 * 60_000)).toBe('3h') + expect(formatCompactElapsed(2 * 24 * 60 * 60_000 + 4 * 60 * 60_000)).toBe('2d 4h') + expect(formatCompactElapsed(2 * 24 * 60 * 60_000)).toBe('2d') + expect(formatCompactElapsed(-1)).toBe('0s') }) - it('formats done/total with derived percent', () => { - expect(formatAttachedJobProgress(job({ done: 800, total: 900, unit: 'tracks' }))).toBe( - '89% · 800/900 tracks' + it('formats remaining count with elapsed', () => { + const now = 1_000 + 2 * 60 * 60_000 + expect(formatAttachedJobProgress(job({ remaining: 120, unit: 'tracks' }), now)).toBe( + '120 tracks left · 2h' ) }) - it('falls back to running when only heartbeat', () => { - expect(formatAttachedJobProgress(job())).toBe('running') + it('formats done/total with derived percent and elapsed', () => { + const now = 1_000 + 45 * 60_000 + expect(formatAttachedJobProgress(job({ done: 800, total: 900, unit: 'tracks' }), now)).toBe( + '89% · 800/900 tracks · 45m' + ) + }) + + it('falls back to running + elapsed when only heartbeat', () => { + const now = 1_000 + 90_000 + expect(formatAttachedJobProgress(job(), now)).toBe('running · 1m') + expect(formatAttachedJobElapsed(job(), now)).toBe('1m') }) it('computes fraction from remaining+total', () => { diff --git a/web/src/lib/attachedJob.ts b/web/src/lib/attachedJob.ts index 58c869c1c9..7fc2172080 100644 --- a/web/src/lib/attachedJob.ts +++ b/web/src/lib/attachedJob.ts @@ -3,16 +3,46 @@ import type { AttachedJob } from '@hapi/protocol' /** Stale if no heartbeat for 15 minutes — UI amber, still shows progress. */ export const ATTACHED_JOB_STALE_MS = 15 * 60 * 1000 -export function formatAttachedJobProgress(job: AttachedJob): string { +/** + * Compact lettered elapsed duration for list chrome. + * Max two units; never an ETA / time-remaining estimate. + */ +export function formatCompactElapsed(elapsedMs: number): string { + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return '0s' + const totalSec = Math.floor(elapsedMs / 1000) + if (totalSec < 60) return `${totalSec}s` + const totalMin = Math.floor(totalSec / 60) + if (totalMin < 60) return `${totalMin}m` + const totalHr = Math.floor(totalMin / 60) + const remMin = totalMin % 60 + if (totalHr < 24) { + return remMin > 0 ? `${totalHr}h ${remMin}m` : `${totalHr}h` + } + const days = Math.floor(totalHr / 24) + const remHr = totalHr % 24 + return remHr > 0 ? `${days}d ${remHr}h` : `${days}d` +} + +/** Elapsed since job.startedAt (hub clock). */ +export function formatAttachedJobElapsed(job: AttachedJob, now: number = Date.now()): string { + return formatCompactElapsed(now - job.startedAt) +} + +/** + * Progress label for the session row. + * Always appends elapsed from startedAt — honest wall time, not an ETA. + */ +export function formatAttachedJobProgress(job: AttachedJob, now: number = Date.now()): string { + const elapsed = formatAttachedJobElapsed(job, now) if (job.remaining !== undefined) { const unit = job.unit ? ` ${job.unit}` : '' - return `${job.remaining}${unit} left` + return `${job.remaining}${unit} left · ${elapsed}` } if (job.done !== undefined && job.total !== undefined && job.total > 0) { const pct = Math.min(100, Math.round((job.done / job.total) * 100)) - return `${pct}% · ${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''}` + return `${pct}% · ${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''} · ${elapsed}` } - return 'running' + return `running · ${elapsed}` } export function attachedJobFraction(job: AttachedJob): number | null { From 465c62d3126d0fb13add96457d86c090da550f3b Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:26:49 +0000 Subject: [PATCH 09/99] fix(jobs): address Opus cold-review Majors for #1404 Add hapi job run supervisor (auto-heartbeat + exit status), follow post-merge job-owner redirects so $HAPI_SESSION_ID heartbeats keep working, cover the CLI parser/resolve/run paths with tests, and steer agents toward the supervisor instead of an idle-agent heartbeat myth. Co-authored-by: Cursor --- cli/src/commands/job.test.ts | 84 ++++++++++ cli/src/commands/job.ts | 99 +++++++++--- .../common/sessionJobInstruction.test.ts | 6 +- .../modules/common/sessionJobInstruction.ts | 29 ++-- .../modules/sessionJob/runSessionJob.test.ts | 129 ++++++++++++++++ cli/src/modules/sessionJob/runSessionJob.ts | 144 ++++++++++++++++++ cli/src/modules/sessionJob/sessionJob.ts | 2 +- docs/guide/session-jobs.md | 17 ++- hub/src/sync/sessionCache.ts | 116 ++++++++++++++ hub/src/sync/syncEngine.ts | 5 + hub/src/web/routes/sessions-jobs.test.ts | 1 + hub/src/web/routes/sessions.ts | 38 ++++- 12 files changed, 617 insertions(+), 53 deletions(-) create mode 100644 cli/src/commands/job.test.ts create mode 100644 cli/src/modules/sessionJob/runSessionJob.test.ts create mode 100644 cli/src/modules/sessionJob/runSessionJob.ts diff --git a/cli/src/commands/job.test.ts b/cli/src/commands/job.test.ts new file mode 100644 index 0000000000..29d18ea5c2 --- /dev/null +++ b/cli/src/commands/job.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { parseJobArgs } from '@/commands/job' +import { + SessionJobError, + exitCodeForSessionJobError, + resolveSessionByPrefix +} 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) + }) +}) + +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('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) + }) +}) diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts index 1579621130..f9be88ce6b 100644 --- a/cli/src/commands/job.ts +++ b/cli/src/commands/job.ts @@ -9,11 +9,12 @@ import { setSessionJob, updateSessionJob } from '@/modules/sessionJob/sessionJob' +import { runSessionJob } from '@/modules/sessionJob/runSessionJob' import type { CommandDefinition } from './types' -type ParsedJobArgs = { +export type ParsedJobArgs = { help: boolean - action?: 'set' | 'update' | 'clear' | 'list' + action?: 'set' | 'update' | 'clear' | 'list' | 'run' sessionIdPrefix?: string jobKey?: string label?: string @@ -23,6 +24,8 @@ type ParsedJobArgs = { remaining?: number unit?: string detail?: string + heartbeatSec?: number + command?: string[] } function showHelp(): void { @@ -30,21 +33,21 @@ function showHelp(): void { ${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 (nohup / batch / long scripts / external daemons) + 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:')} - 1. set before (or as) the process starts - 2. update / heartbeat at least every ~10 minutes while running - 3. prefer honest --remaining or --done/--total; omit counts if unknown - 4. never invent a fake percent - 5. clear or --status completed|failed when finished + 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 set --label [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] - hapi job update [--remaining N] [--done N] [--total N] [--status running|completed|failed] [--detail ...] - hapi job clear - hapi job list + hapi job run --label [--heartbeat-sec 300] [progress flags] -- [args...] + hapi job set --label [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] + hapi job update [--remaining N] [--done N] [--total N] [--status running|completed|failed] [--detail ...] + hapi job clear + hapi job list ${chalk.bold('Progress UI:')} remaining → "N units left · 2h" @@ -55,6 +58,7 @@ ${chalk.bold('Progress UI:')} ${chalk.bold('Notes:')} Hub-persisted. Prefer "$HAPI_SESSION_ID" for this chat. 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:')} @@ -75,15 +79,20 @@ function parseOptionalNumber(flag: string, value: string | undefined): number { 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 < args.length; i++) { - const arg = args[i]! + 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 = args[++i] + result.label = flagArgs[++i] if (!result.label) throw new SessionJobError('bad_args', '--label requires a value') continue } @@ -92,7 +101,7 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { continue } if (arg === '--status') { - const value = args[++i] + const value = flagArgs[++i] if (value !== 'running' && value !== 'completed' && value !== 'failed') { throw new SessionJobError('bad_args', '--status must be running|completed|failed') } @@ -108,7 +117,7 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { continue } if (arg === '--done') { - result.done = parseOptionalNumber('--done', args[++i]) + result.done = parseOptionalNumber('--done', flagArgs[++i]) continue } if (arg.startsWith('--done=')) { @@ -116,7 +125,7 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { continue } if (arg === '--total') { - result.total = parseOptionalNumber('--total', args[++i]) + result.total = parseOptionalNumber('--total', flagArgs[++i]) continue } if (arg.startsWith('--total=')) { @@ -124,7 +133,7 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { continue } if (arg === '--remaining') { - result.remaining = parseOptionalNumber('--remaining', args[++i]) + result.remaining = parseOptionalNumber('--remaining', flagArgs[++i]) continue } if (arg.startsWith('--remaining=')) { @@ -132,7 +141,7 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { continue } if (arg === '--unit') { - result.unit = args[++i] + result.unit = flagArgs[++i] if (!result.unit) throw new SessionJobError('bad_args', '--unit requires a value') continue } @@ -141,7 +150,7 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { continue } if (arg === '--detail') { - result.detail = args[++i] + result.detail = flagArgs[++i] if (result.detail === undefined) throw new SessionJobError('bad_args', '--detail requires a value') continue } @@ -149,12 +158,26 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { 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.startsWith('-')) { throw new SessionJobError('bad_args', `unexpected flag: ${arg}`) } if (!result.action) { - if (arg !== 'set' && arg !== 'update' && arg !== 'clear' && arg !== 'list') { - throw new SessionJobError('bad_args', `unknown action '${arg}' (set|update|clear|list)`) + 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 @@ -216,7 +239,7 @@ export async function handleJobCommand(args: string[]): Promise { 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 ...') + throw new SessionJobError('bad_args', 'missing action; usage: hapi job set|update|clear|list|run ...') } return } @@ -277,6 +300,34 @@ export async function handleJobCommand(args: string[]): Promise { 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 } + : {}), + ...(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 } : {}) + }) + 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 } : {}), diff --git a/cli/src/modules/common/sessionJobInstruction.test.ts b/cli/src/modules/common/sessionJobInstruction.test.ts index 2cdf00bedb..c6d919a329 100644 --- a/cli/src/modules/common/sessionJobInstruction.test.ts +++ b/cli/src/modules/common/sessionJobInstruction.test.ts @@ -5,10 +5,10 @@ import { } from './sessionJobInstruction' describe('sessionJobInstruction', () => { - it('mentions set, update, heartbeat, and no fake percent', () => { - expect(SESSION_JOB_INSTRUCTION).toContain('hapi job set') + it('prefers job run supervisor and forbids fake percent', () => { + expect(SESSION_JOB_INSTRUCTION).toContain('hapi job run') expect(SESSION_JOB_INSTRUCTION).toContain('hapi job update') - expect(SESSION_JOB_INSTRUCTION).toContain('~10 minutes') + expect(SESSION_JOB_INSTRUCTION).toContain('idle agent cannot') expect(SESSION_JOB_INSTRUCTION).toContain('Never invent a fake percent') expect(SESSION_JOB_INSTRUCTION).toContain('HAPI_SESSION_ID') }) diff --git a/cli/src/modules/common/sessionJobInstruction.ts b/cli/src/modules/common/sessionJobInstruction.ts index c6ba00c3d2..ee3043bf76 100644 --- a/cli/src/modules/common/sessionJobInstruction.ts +++ b/cli/src/modules/common/sessionJobInstruction.ts @@ -1,27 +1,26 @@ /** * Always-on steer for session-attached long-running jobs (tiann/hapi#1404). * - * Unlike the session-summary contract (opt-in), this is short and triggers only - * when the agent spawns outliving work — so it rides every supported flavor's - * system / developer instructions by default. - * - * Cursor ACP has no system-prompt seam today; Cursor agents rely on the estate - * skill `hapi-session-jobs` (and `hapi job --help`) instead. + * Injected into flavors that have a HAPI system / developer-instructions seam + * today: Claude, Codex, OpenCode, Grok. Cursor ACP has no such seam (estate + * skill `hapi-session-jobs` + `hapi job --help` instead). Other ACP flavors + * (Kimi, Copilot, Pi, …) do not receive this block until an MCP job tool or + * per-flavor seam lands — do not claim "every flavor." */ /** 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', - '(nohup, batch imports, long scripts, external daemons), attach it to this', - 'HAPI session so the session list can show progress while you are idle.', - 'Use: hapi job set "$HAPI_SESSION_ID" --label ', - '[--remaining N] [--done N --total N] [--unit ] [--detail ].', - 'Heartbeat with hapi job update at least every ~10 minutes (UI goes amber', - 'after ~15m without a heartbeat). Prefer honest remaining or done+total;', - 'omit counts when unknown (UI shows "running" + indeterminate bar).', - 'Never invent a fake percent. On finish: hapi job update … --status', - 'completed|failed, or hapi job clear. Full contract: hapi job --help.' + '(batch imports, long scripts, external daemons), attach it so the session', + 'list can show progress while you are idle.', + 'Prefer: hapi job run "$HAPI_SESSION_ID" --label -- …', + '(auto-heartbeats + marks completed/failed on exit).', + 'Manual path: hapi job set … then a wrapper must heartbeat via', + 'hapi job update at least every ~10 minutes — an idle agent cannot.', + '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). */ diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts new file mode 100644 index 0000000000..66e444a09d --- /dev/null +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { runSessionJob } from './runSessionJob' + +function fakeChild(exitCode: number) { + const child = new EventEmitter() as EventEmitter & { + pid: number + killed: boolean + } + child.pid = 4242 + child.killed = false + queueMicrotask(() => child.emit('exit', exitCode, null)) + 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 () => ({ + 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 timers: Array<() => void> = [] + const exitCode = await 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: (() => fakeChild(0)) as never, + setIntervalImpl: ((fn: () => void) => { + timers.push(fn) + return 1 as unknown as NodeJS.Timeout + }) as never, + clearIntervalImpl: (() => undefined) as never + }) + + expect(exitCode).toBe(0) + expect(http.put).toHaveBeenCalled() + expect(http.patch).toHaveBeenCalled() + const lastPatch = http.patch.mock.calls.at(-1)?.[1] as { status?: string } + expect(lastPatch.status).toBe('completed') + }) + + 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') + }) +}) diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts new file mode 100644 index 0000000000..f654b1fcd8 --- /dev/null +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -0,0 +1,144 @@ +/** + * Supervise a child command while heartbeating a session-attached job. + * Fixes the idle-agent heartbeat gap (cold review #1404). + */ + +import { spawn, type ChildProcess } from 'node:child_process' +import type { AttachedJobUpsert } from '@hapi/protocol' +import { + SessionJobError, + setSessionJob, + updateSessionJob, + type SessionJobClientOptions +} 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 +} + +const DEFAULT_HEARTBEAT_MS = 5 * 60 * 1000 + +export async function runSessionJob(options: RunSessionJobOptions): Promise { + if (options.command.length === 0) { + throw new SessionJobError('bad_args', 'run requires a command after --') + } + + const body: AttachedJobUpsert = { + label: options.label, + status: 'running', + ...(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 } : {}) + } + + await setSessionJob({ + sessionIdPrefix: options.sessionIdPrefix, + jobKey: options.jobKey, + body, + apiUrl: options.apiUrl, + accessToken: options.accessToken, + http: options.http + }) + + const spawnFn = options.spawnImpl ?? spawn + const setIntervalFn = options.setIntervalImpl ?? setInterval + const clearIntervalFn = options.clearIntervalImpl ?? clearInterval + const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS + + const child: ChildProcess = spawnFn(options.command[0]!, options.command.slice(1), { + stdio: 'inherit', + env: process.env + }) + + const heartbeat = setIntervalFn(() => { + void updateSessionJob({ + sessionIdPrefix: options.sessionIdPrefix, + jobKey: options.jobKey, + body: { + detail: options.detail, + status: 'running' + }, + apiUrl: options.apiUrl, + accessToken: options.accessToken, + http: options.http + }).catch(() => { + // Best-effort — exit path still marks terminal status. + }) + }, heartbeatMs) + // Don't keep the event loop alive solely for heartbeats if child already exited. + heartbeat.unref?.() + + const forward = (signal: NodeJS.Signals) => { + if (child.pid && !child.killed) { + try { + process.kill(child.pid, signal) + } catch { + // Child may have already exited. + } + } + } + const onSigInt = () => forward('SIGINT') + const onSigTerm = () => forward('SIGTERM') + process.on('SIGINT', onSigInt) + process.on('SIGTERM', onSigTerm) + + const exitCode = await new Promise((resolve) => { + child.on('error', async (error) => { + clearIntervalFn(heartbeat) + try { + await updateSessionJob({ + sessionIdPrefix: options.sessionIdPrefix, + jobKey: options.jobKey, + body: { status: 'failed', detail: error.message }, + apiUrl: options.apiUrl, + accessToken: options.accessToken, + http: options.http + }) + } catch { + // ignore + } + resolve(127) + }) + child.on('exit', (code, signal) => { + clearIntervalFn(heartbeat) + if (signal) { + resolve(128 + (signal === 'SIGINT' ? 2 : signal === 'SIGTERM' ? 15 : 1)) + return + } + resolve(code ?? 1) + }) + }) + + process.off('SIGINT', onSigInt) + process.off('SIGTERM', onSigTerm) + + const terminalStatus = exitCode === 0 ? 'completed' : 'failed' + try { + await updateSessionJob({ + sessionIdPrefix: options.sessionIdPrefix, + jobKey: options.jobKey, + body: { status: terminalStatus }, + apiUrl: options.apiUrl, + accessToken: options.accessToken, + http: options.http + }) + } catch { + // Job may already be cleared; still return child exit code. + } + + return exitCode +} diff --git a/cli/src/modules/sessionJob/sessionJob.ts b/cli/src/modules/sessionJob/sessionJob.ts index 554f403243..2f404fce9f 100644 --- a/cli/src/modules/sessionJob/sessionJob.ts +++ b/cli/src/modules/sessionJob/sessionJob.ts @@ -98,7 +98,7 @@ function authHeaders(jwt: string): Record { type SessionListItem = { id: string } -function resolveSessionByPrefix(sessions: SessionListItem[], prefix: string): SessionListItem { +export function resolveSessionByPrefix(sessions: SessionListItem[], prefix: string): SessionListItem { const trimmed = prefix.trim() if (!trimmed) { throw new SessionJobError('bad_args', 'session id prefix is required') diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md index 339d9be7d4..18b78e23c2 100644 --- a/docs/guide/session-jobs.md +++ b/docs/guide/session-jobs.md @@ -20,14 +20,19 @@ If the operator would reopen the chat only to ask "how's it doing?", it belongs ## Agent contract (specification) -HAPI does **not** write your batch scripts. You (the agent) create the process **and** feed the meter. +HAPI does **not** write your batch scripts for you - but prefer the supervisor so heartbeats are not your problem: -1. **Register** with a stable `job-key` (1–128 chars: alnum / `.` `_` `-`). -2. **Heartbeat** at least every ~10 minutes while running (UI amber after ~15 minutes quiet). -3. **Report progress honestly** — see tiers below. Never invent a bare percent. -4. **Finish cleanly** — `--status completed|failed` or `hapi job clear`. +```bash +hapi job run "$HAPI_SESSION_ID" beets \ + --label 'beets import' \ + --remaining 150 --done 1637 --total 1787 --unit units \ + --detail 'album: …' \ + -- ./beets-import.sh +``` + +`hapi job run` registers the job, heartbeats on a timer while the child runs, then marks `completed`/`failed` from the exit code. An idle agent **cannot** heartbeat - set-once + manual update decays to amber. -Session id: prefer `"$HAPI_SESSION_ID"` (exported into every HAPI-wrapped agent). Prefix match also works. +Manual path (only if you already have a self-heartbeating wrapper): ```bash hapi job set "$HAPI_SESSION_ID" beets \ diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 98709df8c0..14bfae8d9b 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1316,6 +1316,12 @@ export class SessionCache { const movedScratchlist = this.store.scratchlist.transfer(oldSessionId, newSessionId) const movedJobs = this.store.sessionJobs.transfer(oldSessionId, newSessionId) if (movedJobs.moved > 0 || movedJobs.collided > 0) { + // Agents keep addressing $HAPI_SESSION_ID from the pre-merge row. + // Record redirects so job REST routes can follow the live job owner. + this.recordJobsAcceptedFromSession(newSessionId, oldSessionId, namespace) + if (!options.deleteOldSession) { + this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) + } this.emitAttachedJobChanged( newSessionId, this.store.sessionJobs.getPrimaryRunning(newSessionId) @@ -1525,6 +1531,116 @@ export class SessionCache { } } + /** + * Target session remembers it absorbed jobs from `fromSessionId` so job + * REST routes can follow `$HAPI_SESSION_ID` after the source row is deleted. + */ + private recordJobsAcceptedFromSession( + toSessionId: string, + fromSessionId: string, + namespace: string + ): void { + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = this.store.sessions.getSessionByNamespace(toSessionId, namespace) + if (!latest) return + const meta = (latest.metadata && typeof latest.metadata === 'object' + ? { ...(latest.metadata as Record) } + : {}) as Record + const prev = Array.isArray(meta.jobsAcceptedFromSessionIds) + ? meta.jobsAcceptedFromSessionIds.filter((id): id is string => typeof id === 'string') + : [] + if (prev.includes(fromSessionId)) return + meta.jobsAcceptedFromSessionIds = [...prev, fromSessionId] + const result = this.store.sessions.updateSessionMetadata( + toSessionId, + meta, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + this.refreshSession(toSessionId) + return + } + if (result.result !== 'version-mismatch') return + } + } + + /** Source session (kept alive) points job APIs at the post-merge owner. */ + private recordJobsTransferredToSession( + fromSessionId: string, + toSessionId: string, + namespace: string + ): void { + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = this.store.sessions.getSessionByNamespace(fromSessionId, namespace) + if (!latest) return + const meta = (latest.metadata && typeof latest.metadata === 'object' + ? { ...(latest.metadata as Record) } + : {}) as Record + if (meta.jobsTransferredToSessionId === toSessionId) return + meta.jobsTransferredToSessionId = toSessionId + const result = this.store.sessions.updateSessionMetadata( + fromSessionId, + meta, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + this.refreshSession(fromSessionId) + return + } + if (result.result !== 'version-mismatch') return + } + } + + /** + * Follow job-owner redirects after session merge/dedup so agents that still + * hold the pre-merge `$HAPI_SESSION_ID` can heartbeat. + */ + resolveAttachedJobSessionId(sessionId: string, namespace: string): string { + let current = sessionId + for (let hop = 0; hop < 5; hop += 1) { + const access = this.resolveSessionAccess(current, namespace) + if (access.ok) { + const meta = access.session.metadata as Record | null | undefined + const next = + (typeof meta?.jobsTransferredToSessionId === 'string' + && meta.jobsTransferredToSessionId.trim()) + || (typeof meta?.supersededBySessionId === 'string' + && meta.supersededBySessionId.trim()) + || '' + if (next && next !== current) { + current = next + continue + } + return current + } + // Source row may already be deleted — find who accepted its jobs. + const acceptor = this.findSessionThatAcceptedJobsFrom(current, namespace) + if (acceptor && acceptor !== current) { + current = acceptor + continue + } + return current + } + return current + } + + private findSessionThatAcceptedJobsFrom(fromSessionId: string, namespace: string): string | null { + for (const session of this.getSessions()) { + if (session.namespace !== namespace) continue + const meta = session.metadata as Record | null | undefined + const accepted = meta?.jobsAcceptedFromSessionIds + if (!Array.isArray(accepted)) continue + if (accepted.some((id) => id === fromSessionId)) { + return session.id + } + } + return null + } + private mergeSessionMetadata(oldMetadata: unknown | null, newMetadata: unknown | null): unknown | null { if (!oldMetadata || typeof oldMetadata !== 'object') { return newMetadata diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 80178c1c1c..871ac38cd1 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -375,6 +375,11 @@ export class SyncEngine { return this.sessionCache.resolveSessionAccess(sessionId, namespace) } + /** Follow job-owner redirects after merge/dedup (tiann/hapi#1404 cold review). */ + resolveAttachedJobSessionId(sessionId: string, namespace: string): string { + return this.sessionCache.resolveAttachedJobSessionId(sessionId, namespace) + } + getActiveSessions(): Session[] { return this.sessionCache.getActiveSessions() } diff --git a/hub/src/web/routes/sessions-jobs.test.ts b/hub/src/web/routes/sessions-jobs.test.ts index a10c09924c..87a0161b5d 100644 --- a/hub/src/web/routes/sessions-jobs.test.ts +++ b/hub/src/web/routes/sessions-jobs.test.ts @@ -35,6 +35,7 @@ describe('session-attached jobs routes (tiann/hapi#1404)', () => { const engine = { resolveSessionAccess: () => ({ ok: true as const, sessionId: session.id, session }), + resolveAttachedJobSessionId: (id: string) => id, getSessionsByNamespace: () => [session], getFutureScheduledMessageCounts: () => new Map(), getNextScheduledAtBySessionIds: () => new Map(), diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 2df981fd2c..74ff6508fe 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1303,12 +1303,42 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho // tiann/hapi#1404 — session-attached long-running jobs (works while agent idle). const JOB_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/ + function resolveJobOwnerSession( + c: Context, + engine: SyncEngine + ): { sessionId: string; session: Session } | Response { + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + // Session may already be deleted after merge — still try acceptor redirect. + const rawId = c.req.param('id') ?? '' + const namespace = c.get('namespace') + const redirected = engine.resolveAttachedJobSessionId(rawId, namespace) + if (redirected !== rawId) { + const access = engine.resolveSessionAccess(redirected, namespace) + if (access.ok) { + return { sessionId: access.sessionId, session: access.session } + } + } + return sessionResult + } + const namespace = c.get('namespace') + const ownerId = engine.resolveAttachedJobSessionId(sessionResult.sessionId, namespace) + if (ownerId === sessionResult.sessionId) { + return sessionResult + } + const access = engine.resolveSessionAccess(ownerId, namespace) + if (!access.ok) { + return sessionResult + } + return { sessionId: access.sessionId, session: access.session } + } + app.get('/sessions/:id/jobs', (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { return engine } - const sessionResult = requireSessionFromParam(c, engine) + const sessionResult = resolveJobOwnerSession(c, engine) if (sessionResult instanceof Response) { return sessionResult } @@ -1323,7 +1353,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (engine instanceof Response) { return engine } - const sessionResult = requireSessionFromParam(c, engine) + const sessionResult = resolveJobOwnerSession(c, engine) if (sessionResult instanceof Response) { return sessionResult } @@ -1348,7 +1378,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (engine instanceof Response) { return engine } - const sessionResult = requireSessionFromParam(c, engine) + const sessionResult = resolveJobOwnerSession(c, engine) if (sessionResult instanceof Response) { return sessionResult } @@ -1373,7 +1403,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (engine instanceof Response) { return engine } - const sessionResult = requireSessionFromParam(c, engine) + const sessionResult = resolveJobOwnerSession(c, engine) if (sessionResult instanceof Response) { return sessionResult } From 2c1c39c857849e8889f3e2c61b1ade8ec717197b Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:33:13 +0000 Subject: [PATCH 10/99] fix(jobs): honor explicit startedAt on PUT; document late-attach clock Beets dogfood: PATCH rejects startedAt; sticky PUT clock made elapsed lie after late attach. Explicit upsert startedAt now corrects; CLI --started-at; clear+set recipe kept for older hubs. Co-authored-by: Cursor --- cli/README.md | 2 +- cli/src/commands/job.test.ts | 11 +++++ cli/src/commands/job.ts | 24 ++++++++++- docs/guide/faq.md | 2 +- docs/guide/session-jobs.md | 67 +++++++++++++++++++++++++++-- hub/src/store/migration-v25.test.ts | 40 +++++++++++++++++ hub/src/store/sessionJobs.ts | 8 +++- hub/src/store/sessionJobsStore.ts | 18 ++++++-- shared/src/schemas.ts | 1 + 9 files changed, 159 insertions(+), 14 deletions(-) diff --git a/cli/README.md b/cli/README.md index 606a90c2ff..1d20bf0df9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -44,7 +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` - 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 at least every ~10m; honest `--remaining` or `--done`/`--total` (omit counts if unknown — never invent a percent). See `docs/guide/session-jobs.md` and `hapi job --help`. +- `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/commands/job.test.ts b/cli/src/commands/job.test.ts index 29d18ea5c2..248ad3b8d9 100644 --- a/cli/src/commands/job.test.ts +++ b/cli/src/commands/job.test.ts @@ -47,6 +47,17 @@ describe('parseJobArgs', () => { 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) + }) }) describe('resolveSessionByPrefix', () => { diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts index f9be88ce6b..b91d189254 100644 --- a/cli/src/commands/job.ts +++ b/cli/src/commands/job.ts @@ -24,6 +24,7 @@ export type ParsedJobArgs = { remaining?: number unit?: string detail?: string + startedAt?: number heartbeatSec?: number command?: string[] } @@ -44,7 +45,7 @@ ${chalk.bold('Agent contract:')} ${chalk.bold('Usage:')} hapi job run --label [--heartbeat-sec 300] [progress flags] -- [args...] - hapi job set --label [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] + hapi job set --label [--started-at MS] [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] hapi job update [--remaining N] [--done N] [--total N] [--status running|completed|failed] [--detail ...] hapi job clear hapi job list @@ -55,8 +56,15 @@ ${chalk.bold('Progress UI:')} label/detail only → "running · 2h" + indeterminate bar elapsed always from startedAt (wall clock) — never an ETA / time-remaining field +${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). + ${chalk.bold('Notes:')} Hub-persisted. Prefer "$HAPI_SESSION_ID" for this chat. + 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 @@ -166,6 +174,14 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { 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.startsWith('-')) { throw new SessionJobError('bad_args', `unexpected flag: ${arg}`) } @@ -282,6 +298,9 @@ export async function handleJobCommand(args: string[]): Promise { 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', @@ -289,7 +308,8 @@ export async function handleJobCommand(args: string[]): Promise { ...(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.detail !== undefined ? { detail: parsed.detail } : {}), + ...(parsed.startedAt !== undefined ? { startedAt: parsed.startedAt } : {}) } const result = await setSessionJob({ sessionIdPrefix: parsed.sessionIdPrefix, diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 2d050d67cf..57fa31a624 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -112,7 +112,7 @@ Some agents (especially Cursor) can resume after idle from harness signals such ### How do I show progress for a long batch that outlives the agent? -Use session-attached jobs (`hapi job`). The agent (or a wrapper script) registers a job on the session, heartbeats while the process runs, and clears it when done. The session list shows remaining / fraction / or an indeterminate "running" meter even when the agent is idle. See [Session-attached jobs](./session-jobs.md). +Use session-attached jobs (`hapi job`). The agent (or a wrapper script) registers a job on the session, heartbeats while the process runs, and clears it when done. The session list shows remaining / fraction / or an indeterminate "running" meter even when the agent is idle. See [Session-attached jobs](./session-jobs.md). This is Layer 0 list chrome - not an A2A Layer 1 work advertisement ([#1332](https://github.com/tiann/hapi/discussions/1332)). ### Can I access a terminal remotely? diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md index 18b78e23c2..90d85215fc 100644 --- a/docs/guide/session-jobs.md +++ b/docs/guide/session-jobs.md @@ -6,6 +6,21 @@ This is **not** in-agent thinking progress, todos, or `backgroundTaskCount`. Tho Upstream: [tiann/hapi#1404](https://github.com/tiann/hapi/issues/1404). +## Relation to A2A (not work advertisements) + +HAPI's Agent-to-Agent control plane ([discussion #1332](https://github.com/tiann/hapi/discussions/1332)) is a **different** object family. Do not merge them. + +| | Session-attached jobs (#1404) | A2A `work_ad` (Layer 1) | +|--|------------------------------|-------------------------| +| Store | `session_jobs` | `events` / work-graph ledger | +| Surface | `SessionSummary.attachedJob` (list chrome) | Durable collaboration ledger | +| Question answered | "Is a long process still running on this session, and how far?" | "What is this session claiming about turn/project work for peers/overseer?" | +| Progress | Heartbeats + honest counts / indeterminate | Status vocabulary (`in_progress`, `done`, `failed`, `stale`, …) | +| Silence | UI amber after ~15m without heartbeat; status stays `running` until explicit exit | `expires_at` → `stale` / `unknown` — silence is **not** failure | +| Self-report | Optional counts/detail; `hapi job run` exit code is machine fact | Optional `AGENT_NOTIFY_SUMMARY` elevation (stays optional forever) | + +Jobs enrich **Layer 0** session summaries (same layer as cite / inspect / ping). They are **not** Google A2A Tasks, and they are **not** a substitute for handoffs or work ads. Do not write job heartbeats into the A2A ledger. A privileged reader may *observe* `attachedJob` later; workers still must not poll the ledger as a work queue. + ## When to attach Attach a job **before** (or immediately when) you start process-shaped work that will keep running after the agent goes idle: @@ -57,7 +72,7 @@ Same auth as `hapi ping-peer` (`HAPI_API_URL` / `CLI_API_TOKEN` or `hapi auth lo | Countable fraction | `--done N --total M` | `91% · 1637/1787 units · 2d 4h` | | Stage only / unknown size | `--label` + `--detail` + heartbeats | `running · 2d 4h` + indeterminate bar | -**Elapsed** is always derived from hub `startedAt` (wall clock since register). It is **not** an ETA and there is no time-remaining field - operators get "how long has this been going" plus whatever honest count/detail you report, without a fake completion estimate. +**Elapsed** is always derived from hub `startedAt` (wall clock). It is **not** an ETA and there is no time-remaining field - operators get "how long has this been going" plus whatever honest count/detail you report, without a fake completion estimate. Rules: @@ -66,6 +81,39 @@ Rules: - If you only know a stage name, put it in `--detail` and keep heartbeating — do **not** fake `total=100`. - There is **no** `--percent` flag and **no** ETA / time-remaining field. Inventing either would train agents to lie. +## `startedAt` / elapsed (late attach) + +Elapsed is honest wall clock from hub `startedAt`. Dogfood gotcha (music drain / beets): + +| Call | `startedAt` behavior | +|------|----------------------| +| `PATCH` / `hapi job update` | **Rejected** if you send `startedAt` (`unrecognized_keys`). Progress/heartbeat only. | +| `PUT` / `hapi job set` without `--started-at` | Keeps the existing clock when the job already exists; first create stamps now. | +| `PUT` / `hapi job set --started-at ` | Sets/corrects the clock (explicit body field). | +| `DELETE` then `PUT` with `startedAt` | Always works — including older hubs that ignored PUT corrections. | + +**Prefer `update` for heartbeats** so you never wipe the clock. Only correct historical start when a late attach stamped attach-time instead of process start: + +```bash +# epoch ms for when the drain actually started (example) +START_MS=1785304595000 + +hapi job clear "$HAPI_SESSION_ID" beets +hapi job set "$HAPI_SESSION_ID" beets \ + --label 'beets import' \ + --started-at "$START_MS" \ + --remaining 0 --done 1787 --total 1787 --unit units \ + --detail 'ALL_DONE' + +# or, on hubs that honor explicit PUT startedAt without delete: +hapi job set "$HAPI_SESSION_ID" beets \ + --label 'beets import' \ + --started-at "$START_MS" \ + --remaining 12 --done 1775 --total 1787 --unit units +``` + +Then keep using `hapi job update` for counts/detail/status. + ## Heartbeat recipe Wrap the long process so something calls `hapi job update` on a timer (or on each unit completed). Minimum viable indeterminate job: @@ -78,16 +126,26 @@ hapi job update "$HAPI_SESSION_ID" rsync-backup --detail "phase: copy · $(date When the process exits, mark completed/failed or clear. A stuck green/amber chip with a dead PID is worse than no chip. -## CLI reference +## CLI / API reference ```bash -hapi job set --label [options] -hapi job update [options] +hapi job set --label [--started-at MS] [progress flags] +hapi job update [progress flags] # no startedAt hapi job clear hapi job list +hapi job run --label -- … hapi job --help ``` +Needs a hub/CLI build that includes `job` (soup / feat — global npm releases may lag). + +| Method | Path | Notes | +|--------|------|-------| +| `GET` | `/api/sessions/:id/jobs` | List jobs | +| `PUT` | `/api/sessions/:id/jobs/:jobKey` | Upsert (`AttachedJobUpsert`; optional `startedAt`) | +| `PATCH` | `/api/sessions/:id/jobs/:jobKey` | Progress/heartbeat (`AttachedJobPatch`; **no** `startedAt`) | +| `DELETE` | `/api/sessions/:id/jobs/:jobKey` | Clear | + Primary running job is enriched onto `GET /api/sessions` as `attachedJob` and pushed on `session-updated` SSE patches. ## Related @@ -95,3 +153,4 @@ Primary running job is enriched onto `GET /api/sessions` as `attachedJob` and pu - [Supported Agents](./agents.md) — flavors and resume - [How it Works](./how-it-works.md) — CLI ↔ hub ↔ web - CLI: `hapi job --help`, `cli/README.md` +- A2A control plane: [discussion #1332](https://github.com/tiann/hapi/discussions/1332) (Layer 1 work ads / handoffs — separate from this feature) diff --git a/hub/src/store/migration-v25.test.ts b/hub/src/store/migration-v25.test.ts index 166c662419..43b58ac498 100644 --- a/hub/src/store/migration-v25.test.ts +++ b/hub/src/store/migration-v25.test.ts @@ -105,6 +105,46 @@ describe('Store V26→V27 migration: session_jobs table', () => { store.close() }) + it('preserves startedAt on PUT without body.startedAt; honors explicit correction', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default') + const historical = 1_785_304_595_000 + + const created = store.sessionJobs.upsert(session.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 10 + }, 2_000) + expect(created.outcome).toBe('upserted') + if (created.outcome !== 'upserted') throw new Error('unreachable') + expect(created.job.startedAt).toBe(2_000) + + const progress = store.sessionJobs.upsert(session.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 9 + }, 3_000) + expect(progress.outcome).toBe('upserted') + if (progress.outcome !== 'upserted') throw new Error('unreachable') + expect(progress.job.startedAt).toBe(2_000) + expect(progress.job.remaining).toBe(9) + + const corrected = store.sessionJobs.upsert(session.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 9, + startedAt: historical + }, 4_000) + expect(corrected.outcome).toBe('upserted') + if (corrected.outcome !== 'upserted') throw new Error('unreachable') + expect(corrected.job.startedAt).toBe(historical) + + const patched = store.sessionJobs.patch(session.id, 'beets', { remaining: 8 }, 5_000) + expect(patched?.startedAt).toBe(historical) + expect(patched?.remaining).toBe(8) + store.close() + }) + it('transfers jobs on merge without colliding keys', () => { const store = new Store(':memory:') const oldSession = store.sessions.getOrCreateSession('old', { path: '/a' }, null, 'default') diff --git a/hub/src/store/sessionJobs.ts b/hub/src/store/sessionJobs.ts index c44b5831df..fb933bef19 100644 --- a/hub/src/store/sessionJobs.ts +++ b/hub/src/store/sessionJobs.ts @@ -134,7 +134,11 @@ export function upsertSessionJob( ): UpsertSessionJobResult { const existing = getSessionJob(db, sessionId, jobKey) const heartbeatAt = body.heartbeatAt ?? now - const startedAt = body.startedAt ?? existing?.startedAt ?? now + // Explicit startedAt wins (late-attach correction). Omitted → keep existing clock, + // else stamp now. PATCH never accepts startedAt — use PUT or clear+PUT. + const startedAt = body.startedAt !== undefined + ? body.startedAt + : (existing?.startedAt ?? now) const status = body.status ?? 'running' try { @@ -152,7 +156,7 @@ export function upsertSessionJob( unit = excluded.unit, detail = excluded.detail, heartbeat_at = excluded.heartbeat_at, - started_at = session_jobs.started_at, + started_at = excluded.started_at, updated_at = excluded.updated_at` ).run( sessionId, diff --git a/hub/src/store/sessionJobsStore.ts b/hub/src/store/sessionJobsStore.ts index cc59c5c2f6..8394730e5b 100644 --- a/hub/src/store/sessionJobsStore.ts +++ b/hub/src/store/sessionJobsStore.ts @@ -39,12 +39,22 @@ export class SessionJobsStore { return getPrimaryRunningJobsBySessionIds(this.db, sessionIds) } - upsert(sessionId: string, jobKey: string, body: AttachedJobUpsert): UpsertSessionJobResult { - return upsertSessionJob(this.db, sessionId, jobKey, body) + upsert( + sessionId: string, + jobKey: string, + body: AttachedJobUpsert, + now?: number + ): UpsertSessionJobResult { + return upsertSessionJob(this.db, sessionId, jobKey, body, now) } - patch(sessionId: string, jobKey: string, patch: AttachedJobPatch): StoredSessionJob | null { - return patchSessionJob(this.db, sessionId, jobKey, patch) + patch( + sessionId: string, + jobKey: string, + patch: AttachedJobPatch, + now?: number + ): StoredSessionJob | null { + return patchSessionJob(this.db, sessionId, jobKey, patch, now) } delete(sessionId: string, jobKey: string): boolean { diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index a122541808..c55d67e663 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -432,6 +432,7 @@ export const AttachedJobUpsertSchema = z.object({ export type AttachedJobUpsert = z.infer +/** Progress/heartbeat only — no startedAt (use PUT upsert with explicit startedAt to correct). */ export const AttachedJobPatchSchema = z.object({ label: z.string().min(1).max(200).optional(), status: AttachedJobStatusSchema.optional(), From 0cffc28e4864e2bb24bb560389a812336deec293 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:40:30 +0000 Subject: [PATCH 11/99] fix(jobs): keep merge redirect keys in MetadataSchema (#1404) SessionCache.refreshSession stripped jobsAcceptedFromSessionIds / jobsTransferredToSessionId as unknown keys, so resolveAttachedJobSessionId could not follow post-merge heartbeats. Declare the fields, add a real SessionCache integration test, and pass full UUIDs through the CLI when the merge source is missing from GET /sessions. Co-authored-by: Cursor --- cli/src/commands/job.test.ts | 14 ++- cli/src/modules/sessionJob/sessionJob.ts | 28 ++++- hub/src/sync/sessionCache-merge-jobs.test.ts | 101 +++++++++++++++++++ shared/src/schemas.clear.test.ts | 11 ++ shared/src/schemas.ts | 7 ++ 5 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 hub/src/sync/sessionCache-merge-jobs.test.ts diff --git a/cli/src/commands/job.test.ts b/cli/src/commands/job.test.ts index 248ad3b8d9..e3785f1561 100644 --- a/cli/src/commands/job.test.ts +++ b/cli/src/commands/job.test.ts @@ -3,7 +3,8 @@ import { parseJobArgs } from '@/commands/job' import { SessionJobError, exitCodeForSessionJobError, - resolveSessionByPrefix + resolveSessionByPrefix, + resolveSessionIdForJobCli } from '@/modules/sessionJob/sessionJob' describe('parseJobArgs', () => { @@ -84,6 +85,17 @@ describe('resolveSessionByPrefix', () => { }) }) +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) diff --git a/cli/src/modules/sessionJob/sessionJob.ts b/cli/src/modules/sessionJob/sessionJob.ts index 2f404fce9f..0a52c661ff 100644 --- a/cli/src/modules/sessionJob/sessionJob.ts +++ b/cli/src/modules/sessionJob/sessionJob.ts @@ -119,6 +119,32 @@ export function resolveSessionByPrefix(sessions: SessionListItem[], prefix: stri return matches[0]! } +const FULL_SESSION_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** + * Resolve a session id for job CLI calls. Prefer list match; if the prefix is + * a full UUID missing from the list (deleted merge source), pass it through so + * hub job routes can follow jobsAcceptedFromSessionIds. + */ +export function resolveSessionIdForJobCli( + sessions: SessionListItem[], + sessionIdPrefix: string +): string { + try { + return resolveSessionByPrefix(sessions, sessionIdPrefix).id + } catch (error) { + if ( + error instanceof SessionJobError + && error.code === 'not_found' + && FULL_SESSION_UUID.test(sessionIdPrefix.trim()) + ) { + return sessionIdPrefix.trim() + } + throw error + } +} + async function resolveSessionId( apiUrl: string, jwt: string, @@ -137,7 +163,7 @@ async function resolveSessionId( const sessions = Array.isArray(response.data?.sessions) ? (response.data.sessions as SessionListItem[]) : [] - return resolveSessionByPrefix(sessions, sessionIdPrefix).id + return resolveSessionIdForJobCli(sessions, sessionIdPrefix) } export type SessionJobClientOptions = { diff --git a/hub/src/sync/sessionCache-merge-jobs.test.ts b/hub/src/sync/sessionCache-merge-jobs.test.ts new file mode 100644 index 0000000000..9171a12fbc --- /dev/null +++ b/hub/src/sync/sessionCache-merge-jobs.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'bun:test' +import type { SyncEvent } from '@hapi/protocol/types' +import { MetadataSchema } from '@hapi/protocol/schemas' +import { Store } from '../store' +import type { EventPublisher } from './eventPublisher' +import { SessionCache } from './sessionCache' + +/** + * Cold-review pass 2 (#1404): job-owner redirects written by recordJobs*() + * must survive SessionCache.refreshSession. MetadataSchema used to strip + * jobsAcceptedFromSessionIds / jobsTransferredToSessionId as unknown keys, + * so resolveAttachedJobSessionId never followed the merge. + */ + +function createCapturingPublisher(events: SyncEvent[]): EventPublisher { + return { + emit: (event: SyncEvent) => { + events.push(event) + } + } as unknown as EventPublisher +} + +function setup() { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createCapturingPublisher(events)) + return { store, events, cache } +} + +function makeSessions(cache: SessionCache, ns: string = 'default') { + const oldSession = cache.getOrCreateSession( + 'agent-jobs-old-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + ns + ) + const newSession = cache.getOrCreateSession( + 'agent-jobs-new-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + ns + ) + return { oldSession, newSession } +} + +describe('mergeSessions job redirect through SessionCache (#1404)', () => { + it('keeps jobsAcceptedFromSessionIds after refresh when old session is deleted', async () => { + const { store, cache } = setup() + const { oldSession, newSession } = makeSessions(cache) + + const upserted = store.sessionJobs.upsert(oldSession.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 12 + }) + expect(upserted.outcome).toBe('upserted') + + await cache.mergeSessions(oldSession.id, newSession.id, 'default') + + expect(store.sessionJobs.getPrimaryRunning(newSession.id)?.key).toBe('beets') + expect(store.sessions.getSession(oldSession.id)).toBeNull() + + // Schema strip regression: refresh must retain the acceptor list. + const refreshed = cache.refreshSession(newSession.id) + expect(refreshed).not.toBeNull() + const accepted = refreshed!.metadata?.jobsAcceptedFromSessionIds + expect(accepted).toContain(oldSession.id) + expect( + MetadataSchema.parse(refreshed!.metadata).jobsAcceptedFromSessionIds + ).toContain(oldSession.id) + + expect(cache.resolveAttachedJobSessionId(oldSession.id, 'default')).toBe(newSession.id) + expect(cache.resolveAttachedJobSessionId(newSession.id, 'default')).toBe(newSession.id) + }) + + it('keeps jobsTransferredToSessionId on a kept-alive source after mergeSessionHistory', async () => { + const { store, cache } = setup() + const { oldSession, newSession } = makeSessions(cache) + + store.sessionJobs.upsert(oldSession.id, 'drain', { + label: 'rsync drain', + status: 'running', + remaining: 3 + }) + + await cache.mergeSessionHistory(oldSession.id, newSession.id, 'default', { + mergeAgentState: false + }) + + expect(store.sessionJobs.getPrimaryRunning(newSession.id)?.key).toBe('drain') + expect(store.sessions.getSession(oldSession.id)).not.toBeNull() + + const refreshedOld = cache.refreshSession(oldSession.id) + expect(refreshedOld?.metadata?.jobsTransferredToSessionId).toBe(newSession.id) + expect( + MetadataSchema.parse(refreshedOld!.metadata).jobsTransferredToSessionId + ).toBe(newSession.id) + + expect(cache.resolveAttachedJobSessionId(oldSession.id, 'default')).toBe(newSession.id) + }) +}) diff --git a/shared/src/schemas.clear.test.ts b/shared/src/schemas.clear.test.ts index e84e5673dd..b8197221c7 100644 --- a/shared/src/schemas.clear.test.ts +++ b/shared/src/schemas.clear.test.ts @@ -10,6 +10,17 @@ describe('fresh-session clear schema contract', () => { })).toMatchObject({ supersededBySessionId: 'new-session-id' }) }) + it('preserves session-job merge redirect fields (must not strip as unknown)', () => { + const parsed = MetadataSchema.parse({ + path: '/tmp/project', + host: 'host', + jobsAcceptedFromSessionIds: ['old-session-id'], + jobsTransferredToSessionId: 'new-session-id' + }) + expect(parsed.jobsAcceptedFromSessionIds).toEqual(['old-session-id']) + expect(parsed.jobsTransferredToSessionId).toBe('new-session-id') + }) + it('accepts cleared as an additive session-end reason', () => { expect(SessionEndReasonSchema.parse('cleared')).toBe('cleared') }) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index c55d67e663..52d32adb9e 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -134,6 +134,13 @@ export const MetadataSchema = z.object({ // Set only after a completed fresh-session clear. The source row remains // archived; web clients use this durable link to follow the replacement. supersededBySessionId: z.string().optional(), + // After session merge/dedup transfers session_jobs: the target remembers + // which source ids it absorbed (so job REST can follow a deleted + // pre-merge $HAPI_SESSION_ID), and a kept-alive source points at the + // post-merge owner. Must be declared here — SessionCache.refreshSession + // parses via MetadataSchema and strips unknown keys (tiann/hapi#1404). + jobsAcceptedFromSessionIds: z.array(z.string()).optional(), + jobsTransferredToSessionId: z.string().optional(), // Durable in-progress state for runner-backed OpenCode /clear. opencodeClearOperation: OpencodeClearOperationSchema.optional(), preferredPermissionMode: PermissionModeSchema.optional(), From da543d6c60cb4227f5790315819a69553e9958ff Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:14:51 +0000 Subject: [PATCH 12/99] feat(jobs): MCP session_job tool for catalog-level discoverability Expose session-attached jobs in the same HAPI MCP catalog as ping_peer / inspect_peer so Cursor/ACP and steered flavors see outliving-work meters as first-class tooling, not docs-only. Auto-approve own-session calls; steer prefers MCP set/update or CLI job run for supervised children. Co-authored-by: Cursor --- cli/src/agent/runners/runAgentSession.test.ts | 4 +- cli/src/claude/utils/startHappyServer.test.ts | 28 ++- cli/src/claude/utils/startHappyServer.ts | 24 ++- cli/src/codex/happyMcpStdioBridge.test.ts | 32 +++- cli/src/codex/happyMcpStdioBridge.ts | 44 ++++- .../codex/utils/buildHapiMcpBridge.test.ts | 18 +- cli/src/codex/utils/buildHapiMcpBridge.ts | 4 + .../permission/BasePermissionHandler.test.ts | 11 ++ .../permission/BasePermissionHandler.ts | 10 +- .../common/sessionJobInstruction.test.ts | 7 +- .../modules/common/sessionJobInstruction.ts | 21 ++- .../modules/sessionJob/sessionJobMcp.test.ts | 64 +++++++ cli/src/modules/sessionJob/sessionJobMcp.ts | 172 ++++++++++++++++++ docs/guide/session-jobs.md | 17 +- 14 files changed, 415 insertions(+), 41 deletions(-) create mode 100644 cli/src/modules/sessionJob/sessionJobMcp.test.ts create mode 100644 cli/src/modules/sessionJob/sessionJobMcp.ts 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/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/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/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/modules/common/permission/BasePermissionHandler.test.ts b/cli/src/modules/common/permission/BasePermissionHandler.test.ts index 6febd9a4c2..3e8ea63108 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.test.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.test.ts @@ -101,3 +101,14 @@ describe('resolveToolAutoApprovalDecision list_peers', () => { )).toBeNull() }) }) + +describe('resolveToolAutoApprovalDecision session_job', () => { + it.each([ + 'session_job', + 'hapi_session_job', + 'mcp__hapi__session_job', + 'Session-Attached Job' + ])('auto-approves own-session job meter %s', (toolName) => { + expect(resolveToolAutoApprovalDecision('default', toolName, 'call-1')).toBe('approved') + }) +}) diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index 860c542624..27924f0388 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -37,13 +37,19 @@ const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([ 'happy__list_peers', 'mcp__hapi__list_peers', // ACP permission requests often surface MCP tool title, not the snake_case name. - 'list peer sessions' + 'list peer sessions', + // Own-session progress meter (tiann/hapi#1404) — hub REST only, not peer inject. + 'session_job', + 'hapi_session_job', + 'happy__session_job', + 'mcp__hapi__session_job', + 'session-attached job' ]); // 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 / session_job are 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 index c6d919a329..45ebbd72cd 100644 --- a/cli/src/modules/common/sessionJobInstruction.test.ts +++ b/cli/src/modules/common/sessionJobInstruction.test.ts @@ -5,10 +5,11 @@ import { } from './sessionJobInstruction' describe('sessionJobInstruction', () => { - it('prefers job run supervisor and forbids fake percent', () => { + it('prefers MCP session_job + job run supervisor and forbids 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('hapi job update') - expect(SESSION_JOB_INSTRUCTION).toContain('idle agent cannot') + expect(SESSION_JOB_INSTRUCTION).toContain('idle agents cannot') expect(SESSION_JOB_INSTRUCTION).toContain('Never invent a fake percent') expect(SESSION_JOB_INSTRUCTION).toContain('HAPI_SESSION_ID') }) diff --git a/cli/src/modules/common/sessionJobInstruction.ts b/cli/src/modules/common/sessionJobInstruction.ts index ee3043bf76..c400615455 100644 --- a/cli/src/modules/common/sessionJobInstruction.ts +++ b/cli/src/modules/common/sessionJobInstruction.ts @@ -2,22 +2,23 @@ * 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 ACP has no such seam (estate - * skill `hapi-session-jobs` + `hapi job --help` instead). Other ACP flavors - * (Kimi, Copilot, Pi, …) do not receive this block until an MCP job tool or - * per-flavor seam lands — do not claim "every flavor." + * 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 it so the session', - 'list can show progress while you are idle.', - 'Prefer: hapi job run "$HAPI_SESSION_ID" --label -- …', - '(auto-heartbeats + marks completed/failed on exit).', - 'Manual path: hapi job set … then a wrapper must heartbeat via', - 'hapi job update at least every ~10 minutes — an idle agent cannot.', + '(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.', + 'Prefer MCP tool session_job (aliases: mcp__hapi__session_job, hapi_session_job,', + 'functions.hapi__session_job) with action=set before the process starts, then', + 'action=update every ~10m (idle agents cannot heartbeat).', + 'For a supervised shell child: hapi job run "$HAPI_SESSION_ID" ', + '--label -- … (auto-heartbeats + completed/failed on exit).', 'Prefer honest remaining or done+total; omit counts when unknown', '(UI shows "running" + elapsed). Never invent a fake percent.', 'Full contract: hapi job --help.' diff --git a/cli/src/modules/sessionJob/sessionJobMcp.test.ts b/cli/src/modules/sessionJob/sessionJobMcp.test.ts new file mode 100644 index 0000000000..87ac653dc6 --- /dev/null +++ b/cli/src/modules/sessionJob/sessionJobMcp.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { handleSessionJobTool, SESSION_JOB_TOOL_DESCRIPTION } from './sessionJobMcp' + +vi.mock('./sessionJob', () => ({ + SessionJobError: class SessionJobError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } + }, + setSessionJob: vi.fn(async () => ({ + sessionId: 'sid-1', + job: { + key: 'beets', + label: 'beets import', + status: 'running', + remaining: 12, + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + })), + updateSessionJob: vi.fn(async () => ({ + sessionId: 'sid-1', + job: { + key: 'beets', + label: 'beets import', + status: 'running', + remaining: 11, + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + } + })), + clearSessionJob: vi.fn(async () => ({ sessionId: 'sid-1' })), + listSessionJobs: vi.fn(async () => ({ sessionId: 'sid-1', jobs: [], primary: null })) +})) + +describe('sessionJobMcp', () => { + it('description steers outliving batch work and honest progress', () => { + expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/OUTLIVES/i) + expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/Never invent a percent/i) + expect(SESSION_JOB_TOOL_DESCRIPTION).toContain('hapi job run') + }) + + it('set requires label and defaults session to caller id', async () => { + const result = await handleSessionJobTool( + { action: 'set', jobKey: 'beets', label: 'beets import', remaining: 12 }, + 'sid-1' + ) + expect(result.isError).toBe(false) + expect(result.text).toContain('set beets') + }) + + it('rejects update with empty patch', async () => { + const result = await handleSessionJobTool( + { action: 'update', jobKey: 'beets' }, + 'sid-1' + ) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/at least one/i) + }) +}) diff --git a/cli/src/modules/sessionJob/sessionJobMcp.ts b/cli/src/modules/sessionJob/sessionJobMcp.ts new file mode 100644 index 0000000000..519f064a9b --- /dev/null +++ b/cli/src/modules/sessionJob/sessionJobMcp.ts @@ -0,0 +1,172 @@ +/** + * MCP surface for session-attached jobs (tiann/hapi#1404). + * Same discovery class as ping_peer / inspect_peer — tool catalog, not docs-only. + */ + +import { z } from 'zod' +import type { AttachedJob, AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' +import { + SessionJobError, + clearSessionJob, + listSessionJobs, + setSessionJob, + updateSessionJob +} from './sessionJob' + +export const SESSION_JOB_TOOL_NAME = 'session_job' + +/** + * Self-contained tool description — agents select by matching intent to this text. + * Write for selection, not for humans browsing a README. + */ +export const SESSION_JOB_TOOL_DESCRIPTION = [ + 'Attach or update a hub-persisted progress meter on a HAPI session for work that', + 'OUTLIVES this agent turn (nohup / batch import / rclone / compile / long drain /', + 'external daemon). The session list shows the meter while the agent is idle', + '(active:false). Call action=set BEFORE starting that process (or immediately when', + 'you start it). Heartbeat with action=update at least every ~10 minutes while it', + 'runs — an idle agent cannot. Prefer honest remaining or done+total; omit counts', + 'when unknown (UI shows "running" + elapsed). Never invent a percent or ETA.', + 'Finish with action=update status=completed|failed or action=clear.', + 'Default sessionId is this chat ($HAPI_SESSION_ID). Not for in-agent todos,', + 'thinking progress, or short tool calls. For a supervised shell child that', + 'auto-heartbeats, prefer CLI: hapi job run "$HAPI_SESSION_ID" --label … -- .', +].join(' ') + +export const sessionJobInputSchema: z.ZodTypeAny = z.object({ + action: z.enum(['set', 'update', 'clear', 'list']).describe( + 'set=register/upsert running job; update=heartbeat/progress/status; clear=remove; list=show jobs' + ), + sessionId: z.string().trim().min(1).optional().describe( + 'Target session id or prefix. Omit to use this chat ($HAPI_SESSION_ID).' + ), + jobKey: z.string().trim().min(1).max(128).optional().describe( + 'Stable job key (alnum . _ -). Required for set/update/clear.' + ), + label: z.string().trim().min(1).max(200).optional().describe( + 'Short human label for the list chrome. Required for set.' + ), + status: z.enum(['running', 'completed', 'failed']).optional().describe( + 'Job status. Default running on set.' + ), + done: z.number().nonnegative().optional().describe('Units completed (pair with total when known).'), + total: z.number().positive().optional().describe('Total units when both ends of a fraction exist.'), + remaining: z.number().nonnegative().optional().describe('Units left — prefer when operator cares about leftover.'), + unit: z.string().trim().min(1).max(64).optional().describe('Unit label (tracks, folders, files, …).'), + detail: z.string().max(500).optional().describe('Stage / current item text (not an ETA).'), + startedAt: z.number().optional().describe( + 'Epoch ms process start. Only on set/upsert; omit on heartbeats. Correct late attach with explicit value.' + ) +}) + +export type SessionJobToolArgs = { + action: 'set' | 'update' | 'clear' | 'list' + sessionId?: string + jobKey?: string + label?: string + status?: 'running' | 'completed' | 'failed' + done?: number + total?: number + remaining?: number + unit?: string + detail?: string + startedAt?: number +} + +function formatJobLine(job: AttachedJob): string { + const parts = [`${job.key}`, job.label, job.status] + if (job.remaining !== undefined) { + parts.push(`${job.remaining}${job.unit ? ` ${job.unit}` : ''} left`) + } else if (job.done !== undefined && job.total !== undefined) { + parts.push(`${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''}`) + } + if (job.detail) parts.push(job.detail) + return parts.join(' · ') +} + +export async function handleSessionJobTool( + args: SessionJobToolArgs, + defaultSessionId: string +): Promise<{ text: string; isError: boolean }> { + const sessionIdPrefix = (args.sessionId?.trim() || defaultSessionId || process.env.HAPI_SESSION_ID || '').trim() + if (!sessionIdPrefix) { + return { + text: 'sessionId required (or set HAPI_SESSION_ID / call from a HAPI-wrapped session)', + isError: true + } + } + + try { + if (args.action === 'list') { + const result = await listSessionJobs({ sessionIdPrefix }) + if (result.jobs.length === 0) { + return { text: `session ${result.sessionId}\n(no jobs)`, isError: false } + } + const lines = result.jobs.map((job) => { + const mark = result.primary?.key === job.key ? '*' : ' ' + return `${mark} ${formatJobLine(job)}` + }) + return { text: `session ${result.sessionId}\n${lines.join('\n')}`, isError: false } + } + + if (!args.jobKey?.trim()) { + return { text: 'jobKey is required for set/update/clear', isError: true } + } + const jobKey = args.jobKey.trim() + + if (args.action === 'clear') { + const result = await clearSessionJob({ sessionIdPrefix, jobKey }) + return { text: `cleared ${jobKey} on ${result.sessionId}`, isError: false } + } + + if (args.action === 'set') { + if (!args.label?.trim()) { + return { text: 'label is required for action=set', isError: true } + } + const body: AttachedJobUpsert = { + label: args.label.trim(), + status: args.status ?? 'running', + ...(args.done !== undefined ? { done: args.done } : {}), + ...(args.total !== undefined ? { total: args.total } : {}), + ...(args.remaining !== undefined ? { remaining: args.remaining } : {}), + ...(args.unit !== undefined ? { unit: args.unit } : {}), + ...(args.detail !== undefined ? { detail: args.detail } : {}), + ...(args.startedAt !== undefined ? { startedAt: args.startedAt } : {}) + } + const result = await setSessionJob({ sessionIdPrefix, jobKey, body }) + return { + text: `set ${formatJobLine(result.job)} on ${result.sessionId}`, + isError: false + } + } + + // update + const body: AttachedJobPatch = { + ...(args.label !== undefined ? { label: args.label } : {}), + ...(args.status !== undefined ? { status: args.status } : {}), + ...(args.done !== undefined ? { done: args.done } : {}), + ...(args.total !== undefined ? { total: args.total } : {}), + ...(args.remaining !== undefined ? { remaining: args.remaining } : {}), + ...(args.unit !== undefined ? { unit: args.unit } : {}), + ...(args.detail !== undefined ? { detail: args.detail } : {}) + } + if (Object.keys(body).length === 0) { + return { + text: 'update requires at least one of label/status/done/total/remaining/unit/detail', + isError: true + } + } + const result = await updateSessionJob({ sessionIdPrefix, jobKey, body }) + return { + text: `updated ${formatJobLine(result.job)} on ${result.sessionId}`, + isError: false + } + } catch (error) { + const message = error instanceof SessionJobError + ? error.message + : error instanceof Error + ? error.message + : String(error) + return { text: `session_job failed: ${message}`, isError: true } + } +} diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md index 90d85215fc..47048b1951 100644 --- a/docs/guide/session-jobs.md +++ b/docs/guide/session-jobs.md @@ -35,7 +35,20 @@ If the operator would reopen the chat only to ask "how's it doing?", it belongs ## Agent contract (specification) -HAPI does **not** write your batch scripts for you - but prefer the supervisor so heartbeats are not your problem: +Treat this like `ping_peer` / `inspect_peer`: it is first-class HAPI tooling, not a docs footnote. + +### MCP (preferred for agents) + +Tool name: `session_job` (Claude: `mcp__hapi__session_job`; Codex: `functions.hapi__session_job`; OpenCode/ACP: `hapi_session_job`). + +```json +{ "action": "set", "jobKey": "beets", "label": "beets import", + "remaining": 150, "done": 1637, "total": 1787, "unit": "units" } +``` + +Then `action=update` every ~10 minutes; finish with `status=completed|failed` or `action=clear`. Omit `sessionId` to target this chat. + +### CLI supervisor (preferred for shell children) ```bash hapi job run "$HAPI_SESSION_ID" beets \ @@ -47,7 +60,7 @@ hapi job run "$HAPI_SESSION_ID" beets \ `hapi job run` registers the job, heartbeats on a timer while the child runs, then marks `completed`/`failed` from the exit code. An idle agent **cannot** heartbeat - set-once + manual update decays to amber. -Manual path (only if you already have a self-heartbeating wrapper): +### CLI manual path ```bash hapi job set "$HAPI_SESSION_ID" beets \ From 84a621b2614b41390d0243ed9df3d3f72359aebc Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:10:31 +0000 Subject: [PATCH 13/99] =?UTF-8?q?feat(web):=20tri-state=20pin=20=E2=80=94?= =?UTF-8?q?=20default=20long-running=20jobs=20with=20#1404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace boolean pin-in-progress with off / jobs / all. Unset preference defaults to jobs so outliving attached-job sessions float to In progress by default; legacy true→all, false→off. Settings Display gets a 3-way control. Co-authored-by: Cursor --- docs/guide/session-jobs.md | 12 ++ .../SessionList.directory-action.test.tsx | 70 ++++++++---- web/src/components/SessionList.tsx | 81 ++++++++++---- .../settings/SettingsPrimitives.tsx | 12 +- .../hooks/usePinInProgressSessions.test.ts | 28 +++++ web/src/hooks/usePinInProgressSessions.ts | 105 +++++++++++++----- web/src/lib/locales/en.ts | 5 +- web/src/lib/locales/zh-CN.ts | 5 +- web/src/routes/settings/display.tsx | 15 ++- web/src/routes/settings/index.test.tsx | 7 +- 10 files changed, 262 insertions(+), 78 deletions(-) create mode 100644 web/src/hooks/usePinInProgressSessions.test.ts diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md index 47048b1951..5516ae5a6e 100644 --- a/docs/guide/session-jobs.md +++ b/docs/guide/session-jobs.md @@ -161,6 +161,18 @@ Needs a hub/CLI build that includes `job` (soup / feat — global npm releases m Primary running job is enriched onto `GET /api/sessions` as `attachedJob` and pushed on `session-updated` SSE patches. +## Sidebar pin (Settings → Display) + +Attached jobs ship with a **tri-state** "Pin in-progress sessions" control (not a yes/no): + +| Mode | Floats to In progress | +|------|------------------------| +| Off | Nothing | +| Long-running jobs (default) | Sessions with a running attached job (even when the agent is idle) | +| All activity | Jobs **plus** thinking / pending / in-agent background tasks | + +Unset preference defaults to **Long-running jobs** — that is the product stand for this capability. Legacy `true` maps to All activity; legacy `false` maps to Off. + ## Related - [Supported Agents](./agents.md) — flavors and resume diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index fb49c666fa..9aaa00ce79 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -13,7 +13,8 @@ const SEARCH_PLACEHOLDER = 'Search title/path/Agent/machine/ID…' afterEach(() => { cleanup() localStorage.removeItem('hapi-session-preview-limit') - localStorage.removeItem('hapi-pin-in-progress-sessions') + // Explicit off — unset now defaults to `jobs` (session-attached jobs stand). + localStorage.setItem('hapi-pin-in-progress-sessions', 'off') }) function makeSession(overrides: Partial & { id: string }): SessionSummary { @@ -392,7 +393,7 @@ describe('SessionList collapse behavior', () => { } it('keeps a selected running path collapsed across live session-list refreshes', async () => { - localStorage.setItem('hapi-pin-in-progress-sessions', 'true') + localStorage.setItem('hapi-pin-in-progress-sessions', 'all') const baseSessions = [ makeSession({ id: 'session-running', @@ -430,6 +431,7 @@ describe('SessionList collapse behavior', () => { }) it('leaves active sessions in directory groups when pin-in-progress is off', () => { + localStorage.setItem('hapi-pin-in-progress-sessions', 'off') const sessions = [ makeSession({ id: 'session-running', @@ -453,8 +455,8 @@ describe('SessionList collapse behavior', () => { expect(screen.getByTitle('/work/hapi').nextElementSibling?.getAttribute('data-open')).toBe('true') }) - it('pins active sessions into In progress when the preference is on', () => { - localStorage.setItem('hapi-pin-in-progress-sessions', 'true') + it('pins active sessions into In progress when mode is all activity', () => { + localStorage.setItem('hapi-pin-in-progress-sessions', 'all') const sessions = [ makeSession({ id: 'session-running', @@ -478,7 +480,7 @@ describe('SessionList collapse behavior', () => { }) it('keeps project-pinned active sessions in their project group when the preference is on', () => { - localStorage.setItem('hapi-pin-in-progress-sessions', 'true') + localStorage.setItem('hapi-pin-in-progress-sessions', 'all') const sessions = [ makeSession({ id: 'session-pinned-running', @@ -502,6 +504,7 @@ describe('SessionList collapse behavior', () => { }) it('keeps In progress above project-pin groups; project pin stays first inside its group', () => { + // Legacy true → all (tri-state); floater under In progress; project folders stay below. localStorage.setItem('hapi-pin-in-progress-sessions', 'true') const sessions = [ makeSession({ @@ -553,6 +556,41 @@ describe('SessionList collapse behavior', () => { expect(screen.getByRole('button', { name: /Unpinned floater/ })).toBeInTheDocument() }) + it('pins idle sessions with a running attachedJob when mode is jobs (default)', () => { + localStorage.setItem('hapi-pin-in-progress-sessions', 'jobs') + const sessions = [ + makeSession({ + id: 'session-beets', + active: false, + updatedAt: 100, + metadata: { path: '/music', name: 'Music drain', flavor: 'claude' }, + attachedJob: { + key: 'beets', + label: 'beets import', + status: 'running', + remaining: 12, + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1, + }, + }), + makeSession({ + id: 'session-thinking', + active: true, + thinking: true, + updatedAt: 90, + metadata: { path: '/work/hapi', name: 'Thinking agent', flavor: 'codex' }, + }), + ] + render(renderSessionList(sessions, null)) + + expect(screen.getByTitle('In progress')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Music drain/ })).toBeInTheDocument() + // Thinking agent is not a long-running job — stays in directory under jobs mode. + expect(screen.getByTitle('/work/hapi')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Thinking agent/ })).toBeInTheDocument() + }) + it('does not label quiet active sessions as Idle', () => { const sessions = [ makeSession({ @@ -569,8 +607,8 @@ describe('SessionList collapse behavior', () => { expect(screen.queryByTitle('Idle')).toBeNull() }) - it('keeps quiet active sessions in the Active section when pin-in-progress is on', () => { - localStorage.setItem('hapi-pin-in-progress-sessions', 'true') + it('keeps quiet active sessions in directory groups when pin-in-progress is on', () => { + localStorage.setItem('hapi-pin-in-progress-sessions', 'all') const sessions = [ makeSession({ id: 'session-running', @@ -598,17 +636,11 @@ describe('SessionList collapse behavior', () => { expect(screen.getByTitle('In progress')).toBeInTheDocument() expect(screen.getByText(/Running \(1\)/)).toBeInTheDocument() expect(screen.getByText(/pending \(1\)/)).toBeInTheDocument() - // Quiet active sessions float into their own Active section (finished - // executing, still connected) instead of falling into directory groups. - expect(screen.getByTitle('Active sessions')).toBeInTheDocument() - expect(screen.getByText(/Active \(1\)/)).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Quiet task/ })).toBeInTheDocument() - // The directory header survives as an action-only header (copy-path / - // new-session-in-directory) even though every row floated. + expect(screen.queryByText(/Idle \(/)).toBeNull() + // Quiet active stays under its project directory, not an Idle pin bucket. expect(screen.getByTitle('/work/hapi')).toBeInTheDocument() - expect(screen.getByTitle('/work/hapi').nextElementSibling).toBeNull() - expect(screen.getByTitle('/work/other')).toBeInTheDocument() - expect(screen.getByTitle('/work/other').nextElementSibling).toBeNull() + expect(screen.getByRole('button', { name: /Quiet task/ })).toBeInTheDocument() + expect(getProjectPanel().getAttribute('data-open')).toBe('true') }) it('keeps new-session-in-directory actions for projects whose rows all floated', () => { @@ -743,7 +775,7 @@ describe('SessionList collapse behavior', () => { }) it('keeps the running section open while searching even when collapsed', () => { - localStorage.setItem('hapi-pin-in-progress-sessions', 'true') + localStorage.setItem('hapi-pin-in-progress-sessions', 'all') const sessions = [ makeSession({ id: 'session-running', @@ -781,7 +813,7 @@ describe('SessionList collapse behavior', () => { }) it('toggles the running section with the keyboard', () => { - localStorage.setItem('hapi-pin-in-progress-sessions', 'true') + localStorage.setItem('hapi-pin-in-progress-sessions', 'all') const sessions = [ makeSession({ id: 'session-running', diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index d09f346e94..4c8a4b4bb6 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -33,7 +33,10 @@ import { useTranslation } from '@/lib/use-translation' import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit' import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode' import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly' -import { usePinInProgressSessions } from '@/hooks/usePinInProgressSessions' +import { + usePinInProgressSessions, + type PinInProgressMode +} from '@/hooks/usePinInProgressSessions' import { classifySessionAttention, sessionIsUnread } from '@/lib/sessionAttention' import { getSessionLastSeenAt, @@ -88,39 +91,56 @@ const RUNNING_BUCKETS = [ type RunningBucketKey = (typeof RUNNING_BUCKETS)[number]['key'] +function hasRunningAttachedJob(session: SessionSummary): boolean { + return session.attachedJob?.status === 'running' +} + +function hasAgentInProgressActivity(session: SessionSummary): boolean { + if (!session.active) { + return false + } + return session.thinking + || (session.backgroundTaskCount ?? 0) > 0 + || (session.pendingRequestsCount ?? 0) > 0 +} + export function emptyRunningBuckets(): Record { return { working: [], pending: [], active: [], idle: [] } } /** - * Split the connected sessions into the in-progress / active sub-buckets the - * pinned sections render. Pure so the bucketing rules stay testable. + * Split sessions into the in-progress / active sub-buckets the pinned sections + * render. Pure so the bucketing rules stay testable. Quiet connected never + * floats (#1404); keepalive-idle zombies (#1820) also stay out unless they have + * agent activity or a running attached job. */ export function bucketRunningSessions( sessions: SessionSummary[], - pinInProgressSessions: boolean, + pinInProgressMode: PinInProgressMode, compare: (a: SessionSummary, b: SessionSummary) => number = (a, b) => b.updatedAt - a.updatedAt ): Record { const buckets = emptyRunningBuckets() - if (!pinInProgressSessions) { + if (pinInProgressMode === 'off') { return buckets } for (const session of sessions) { if (session.globalPinned || session.pinned) { continue } - if (!session.active) { + if (!isPinnedInProgressSession(session, pinInProgressMode)) { continue } - if (session.thinking || (session.backgroundTaskCount ?? 0) > 0) { + const agentWorking = session.thinking || (session.backgroundTaskCount ?? 0) > 0 + const agentPending = session.active + && (session.pendingRequestsCount ?? 0) > 0 + && !agentWorking + if (agentWorking || hasRunningAttachedJob(session)) { buckets.working.push(session) - } else if ((session.pendingRequestsCount ?? 0) > 0) { + } else if (agentPending) { buckets.pending.push(session) } else if (session.metadata?.lifecycleState === SESSION_LIFECYCLE_IDLE) { - // Keepalive-only: socket up, no agent progress for hours. buckets.idle.push(session) } else { - // Quiet but connected: finished executing, operator will continue. buckets.active.push(session) } } @@ -131,13 +151,20 @@ export function bucketRunningSessions( } /** - * Sessions that warrant the optional pinned top sections. - * Any connected session floats — a session that just finished executing stays - * visible at the top (Active tier) because the operator usually continues the - * conversation; only disconnected sessions fall into directory groups. + * Sessions that float into the pinned In progress section. + * Mode is a degree: off → jobs (outliving attachedJob) → all (jobs + agent activity). */ -function isPinnedInProgressSession(session: SessionSummary): boolean { - return session.active +export function isPinnedInProgressSession( + session: SessionSummary, + mode: PinInProgressMode +): boolean { + if (mode === 'off') { + return false + } + if (mode === 'jobs') { + return hasRunningAttachedJob(session) + } + return hasRunningAttachedJob(session) || hasAgentInProgressActivity(session) } export type SessionTimeRange = { @@ -1238,7 +1265,7 @@ export function SessionList(props: { const lastSeenVersion = useSessionLastSeenVersion() // Transient unread lens — not a Settings preference. Cleared on reload; rows drop as they're seen. const [showUnreadOnly, setShowUnreadOnly] = useState(false) - const { pinInProgressSessions } = usePinInProgressSessions() + const { pinInProgressMode } = usePinInProgressSessions() const { machineFilter, setMachineFilter } = useSessionListMachineFilter() const showDetailedStatus = sessionListStatusMode === 'detailed' const [searchQuery, setSearchQuery] = useState('') @@ -1386,8 +1413,8 @@ export function SessionList(props: { } return b.updatedAt - a.updatedAt } - return bucketRunningSessions(machineFilteredSessions, pinInProgressSessions, byRelevanceOrRecent) - }, [machineFilteredSessions, pinInProgressSessions, searchScoreIndex, hasTextQuery]) + return bucketRunningSessions(machineFilteredSessions, pinInProgressMode, byRelevanceOrRecent) + }, [machineFilteredSessions, pinInProgressMode, searchScoreIndex, hasTextQuery]) const runningSessionTotal = runningSessions.working.length + runningSessions.pending.length const activeSessionTotal = runningSessions.active.length + runningSessions.idle.length @@ -1396,7 +1423,15 @@ export function SessionList(props: { const grouped = groupSessionsByDirectory( machineFilteredSessions.filter((session) => { if (session.globalPinned) return false - if (pinInProgressSessions && !session.pinned && isPinnedInProgressSession(session)) return false + // Project-pinned stay in the project group; only unpinned + // "in progress" sessions float to the In progress section. + if ( + pinInProgressMode !== 'off' + && !session.pinned + && isPinnedInProgressSession(session, pinInProgressMode) + ) { + return false + } return true }) ) @@ -1405,7 +1440,7 @@ export function SessionList(props: { } return grouped }, - [machineFilteredSessions, pinInProgressSessions, searchScoreIndex, hasTextQuery] + [machineFilteredSessions, pinInProgressMode, searchScoreIndex, hasTextQuery] ) // Directory groups whose rows all floated to the pinned sections still // render an action-only header so copy-path / new-session-in-directory @@ -1419,12 +1454,12 @@ export function SessionList(props: { [machineFilteredSessions] ) const actionOnlyGroups = useMemo(() => { - if (!pinInProgressSessions) { + if (pinInProgressMode === 'off') { return [] } const visibleKeys = new Set(groups.map((group) => group.key)) return allDirectoryGroups.filter((group) => !visibleKeys.has(group.key)) - }, [groups, allDirectoryGroups, pinInProgressSessions]) + }, [groups, allDirectoryGroups, pinInProgressMode]) const [collapseOverrides, setCollapseOverrides] = useState>( () => new Map() ) diff --git a/web/src/components/settings/SettingsPrimitives.tsx b/web/src/components/settings/SettingsPrimitives.tsx index 610b906ccf..318879bc22 100644 --- a/web/src/components/settings/SettingsPrimitives.tsx +++ b/web/src/components/settings/SettingsPrimitives.tsx @@ -82,9 +82,17 @@ export function SettingsChoiceGroup(props: { value: T options: ReadonlyArray<{ value: T; label: string; description?: string }> onChange: (value: T) => void - columns?: 2 | 4 | 5 | 6 + columns?: 2 | 3 | 4 | 5 | 6 }) { - const columns = props.columns === 6 ? 'grid-cols-6' : props.columns === 5 ? 'grid-cols-5' : props.columns === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2' + const columns = props.columns === 6 + ? 'grid-cols-6' + : props.columns === 5 + ? 'grid-cols-5' + : props.columns === 4 + ? 'grid-cols-2 sm:grid-cols-4' + : props.columns === 3 + ? 'grid-cols-3' + : 'grid-cols-2' return (
diff --git a/web/src/hooks/usePinInProgressSessions.test.ts b/web/src/hooks/usePinInProgressSessions.test.ts new file mode 100644 index 0000000000..d008ee0a43 --- /dev/null +++ b/web/src/hooks/usePinInProgressSessions.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_PIN_IN_PROGRESS_MODE, + parsePinInProgressMode +} from './usePinInProgressSessions' + +describe('parsePinInProgressMode', () => { + it('defaults unset to jobs (capability stand)', () => { + expect(parsePinInProgressMode(null)).toBe('jobs') + expect(parsePinInProgressMode('')).toBe('jobs') + expect(DEFAULT_PIN_IN_PROGRESS_MODE).toBe('jobs') + }) + + it('migrates legacy boolean strings', () => { + expect(parsePinInProgressMode('true')).toBe('all') + expect(parsePinInProgressMode('false')).toBe('off') + }) + + it('accepts explicit tri-state values', () => { + expect(parsePinInProgressMode('off')).toBe('off') + expect(parsePinInProgressMode('jobs')).toBe('jobs') + expect(parsePinInProgressMode('all')).toBe('all') + }) + + it('falls back to jobs on garbage', () => { + expect(parsePinInProgressMode('maybe')).toBe('jobs') + }) +}) diff --git a/web/src/hooks/usePinInProgressSessions.ts b/web/src/hooks/usePinInProgressSessions.ts index 8f3bfc225a..ec1886dc7b 100644 --- a/web/src/hooks/usePinInProgressSessions.ts +++ b/web/src/hooks/usePinInProgressSessions.ts @@ -1,10 +1,24 @@ import { useCallback, useEffect, useState } from 'react' -export const DEFAULT_PIN_IN_PROGRESS_SESSIONS = false +/** + * Sidebar "In progress" pin policy (ships with session-attached jobs #1404). + * + * Degree of float, not a yes/no: + * - off — everything stays in project directories + * - jobs — only sessions with a running attachedJob (outliving work) + * - all — jobs + agent working/pending (legacy maximalist pin) + * + * Unset / never configured defaults to `jobs` — the product stand for this capability. + */ -function getPinInProgressSessionsStorageKey(): string { - return 'hapi-pin-in-progress-sessions' -} +export type PinInProgressMode = 'off' | 'jobs' | 'all' + +export const PIN_IN_PROGRESS_MODES: readonly PinInProgressMode[] = ['off', 'jobs', 'all'] as const + +/** New default when the preference has never been set. */ +export const DEFAULT_PIN_IN_PROGRESS_MODE: PinInProgressMode = 'jobs' + +export const PIN_IN_PROGRESS_STORAGE_KEY = 'hapi-pin-in-progress-sessions' function isBrowser(): boolean { return typeof window !== 'undefined' && typeof document !== 'undefined' @@ -32,33 +46,60 @@ function safeSetItem(key: string, value: string): void { } } -function safeRemoveItem(key: string): void { - if (!isBrowser()) { - return +/** + * Parse stored value. + * - absent / null → `jobs` (capability default) + * - legacy `true` → `all` + * - legacy `false` → `off` + * - `off` | `jobs` | `all` → as written + */ +export function parsePinInProgressMode(raw: string | null): PinInProgressMode { + if (raw === null || raw === '') { + return DEFAULT_PIN_IN_PROGRESS_MODE } - try { - localStorage.removeItem(key) - } catch { - // Ignore storage errors + if (raw === 'true') { + return 'all' + } + if (raw === 'false') { + return 'off' } + if (raw === 'off' || raw === 'jobs' || raw === 'all') { + return raw + } + return DEFAULT_PIN_IN_PROGRESS_MODE } -function parsePinInProgressSessions(raw: string | null): boolean { - if (raw === 'true') { - return true - } - return DEFAULT_PIN_IN_PROGRESS_SESSIONS +export function getInitialPinInProgressMode(): PinInProgressMode { + return parsePinInProgressMode(safeGetItem(PIN_IN_PROGRESS_STORAGE_KEY)) } +/** @deprecated Use getInitialPinInProgressMode — boolean form treated `all` as true. */ export function getInitialPinInProgressSessions(): boolean { - return parsePinInProgressSessions(safeGetItem(getPinInProgressSessionsStorageKey())) + return getInitialPinInProgressMode() !== 'off' +} + +export function getPinInProgressModeOptions(): ReadonlyArray<{ + value: PinInProgressMode + labelKey: string +}> { + return [ + { value: 'off', labelKey: 'settings.display.pinInProgressMode.off' }, + { value: 'jobs', labelKey: 'settings.display.pinInProgressMode.jobs' }, + { value: 'all', labelKey: 'settings.display.pinInProgressMode.all' }, + ] } export function usePinInProgressSessions(): { + pinInProgressMode: PinInProgressMode + setPinInProgressMode: (value: PinInProgressMode) => void + /** True when mode is not off (any pin bucket may show). */ pinInProgressSessions: boolean + /** @deprecated Prefer setPinInProgressMode. `true`→all, `false`→off. */ setPinInProgressSessions: (value: boolean) => void } { - const [pinInProgressSessions, setPinInProgressSessionsState] = useState(getInitialPinInProgressSessions) + const [pinInProgressMode, setPinInProgressModeState] = useState( + getInitialPinInProgressMode + ) useEffect(() => { if (!isBrowser()) { @@ -66,25 +107,31 @@ export function usePinInProgressSessions(): { } const onStorage = (event: StorageEvent) => { - if (event.key !== getPinInProgressSessionsStorageKey()) { + if (event.key !== PIN_IN_PROGRESS_STORAGE_KEY) { return } - setPinInProgressSessionsState(parsePinInProgressSessions(event.newValue)) + setPinInProgressModeState(parsePinInProgressMode(event.newValue)) } window.addEventListener('storage', onStorage) return () => window.removeEventListener('storage', onStorage) }, []) - const setPinInProgressSessions = useCallback((value: boolean) => { - setPinInProgressSessionsState(value) - - if (value === DEFAULT_PIN_IN_PROGRESS_SESSIONS) { - safeRemoveItem(getPinInProgressSessionsStorageKey()) - } else { - safeSetItem(getPinInProgressSessionsStorageKey(), String(value)) - } + const setPinInProgressMode = useCallback((value: PinInProgressMode) => { + setPinInProgressModeState(value) + // Always persist so an explicit Off is distinct from never-set→jobs default + // after the user has opened Settings and chosen. + safeSetItem(PIN_IN_PROGRESS_STORAGE_KEY, value) }, []) - return { pinInProgressSessions, setPinInProgressSessions } + const setPinInProgressSessions = useCallback((value: boolean) => { + setPinInProgressMode(value ? 'all' : 'off') + }, [setPinInProgressMode]) + + return { + pinInProgressMode, + setPinInProgressMode, + pinInProgressSessions: pinInProgressMode !== 'off', + setPinInProgressSessions + } } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 7344164495..5467947acf 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -876,7 +876,10 @@ export default { 'settings.display.activeSessionsOnly': 'Active sessions only', 'settings.display.activeSessionsOnly.desc': 'Hide inactive sessions in the sidebar. The session you have open stays visible.', 'settings.display.pinInProgressSessions': 'Pin in-progress sessions', - 'settings.display.pinInProgressSessions.desc': 'Move unpinned connected sessions into sections above project folders: running and pending work first, then quiet active sessions (finished executing, still connected). Global pins remain above them. Off keeps everything in directory groups.', + 'settings.display.pinInProgressSessions.desc': 'How loudly sessions float to the top In progress section above project folders. Default is long-running jobs (outliving batch work with a session job meter). Global pins remain above it; quiet active agents stay in project folders unless you choose All activity. Off keeps everything in directory groups.', + 'settings.display.pinInProgressMode.off': 'Off', + 'settings.display.pinInProgressMode.jobs': 'Long-running jobs', + 'settings.display.pinInProgressMode.all': 'All activity', 'settings.display.appBadge': 'Taskbar unread badge', 'settings.display.appBadge.desc': 'Show the number of sessions with new activity on an installed Edge or Chrome PWA. The host browser controls its appearance; this has no effect in a normal browser tab.', 'settings.display.sessionListStatus': 'Session list status hints', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 73b2f73083..07a30ecce0 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -874,7 +874,10 @@ export default { 'settings.display.activeSessionsOnly': '仅显示活跃会话', 'settings.display.activeSessionsOnly.desc': '在侧边栏隐藏非活跃会话;当前打开的会话仍会保留显示。', 'settings.display.pinInProgressSessions': '置顶进行中会话', - 'settings.display.pinInProgressSessions.desc': '将未置顶的已连接会话移到项目文件夹上方的分区:先运行中和待处理,再是安静的活跃会话(已执行完但仍在连接)。全局置顶仍在其上。关闭后全部保留在目录分组中。', + 'settings.display.pinInProgressSessions.desc': '侧边栏顶部「进行中」分区的置顶程度(位于项目目录分组之上)。默认为长时间任务(带会话 job 进度的后台批处理)。全局置顶仍在该分区之上;安静的活跃智能体仍留在项目目录中,除非选择「全部活动」。关闭后全部保留在目录分组中。', + 'settings.display.pinInProgressMode.off': '关闭', + 'settings.display.pinInProgressMode.jobs': '长时间任务', + 'settings.display.pinInProgressMode.all': '全部活动', 'settings.display.appBadge': '任务栏未读角标', 'settings.display.appBadge.desc': '在已安装的 Edge 或 Chrome PWA 图标上显示有新活动的会话数。角标样式由宿主浏览器控制;普通浏览器标签页不受影响。', 'settings.display.sessionListStatus': '会话列表状态提示', diff --git a/web/src/routes/settings/display.tsx b/web/src/routes/settings/display.tsx index 5c71ca24bc..ba244a0e5e 100644 --- a/web/src/routes/settings/display.tsx +++ b/web/src/routes/settings/display.tsx @@ -139,7 +139,7 @@ export default function SettingsDisplayPage() { const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize() const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode() const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly() - const { pinInProgressSessions, setPinInProgressSessions } = usePinInProgressSessions() + const { pinInProgressMode, setPinInProgressMode } = usePinInProgressSessions() const { appBadgeEnabled, setAppBadgeEnabled } = useAppBadgePreference() const { openExternalLinksInNewTab, setOpenExternalLinksInNewTab } = useOpenExternalLinksInNewTab() const { preferences: sessionHeaderMetadata, setPreference: setSessionHeaderMetadata } = useSessionHeaderMetadata() @@ -178,7 +178,18 @@ export default function SettingsDisplayPage() { - + ({ })) vi.mock('@/hooks/usePinInProgressSessions', () => ({ - usePinInProgressSessions: () => ({ pinInProgressSessions: false, setPinInProgressSessions: vi.fn() }), + usePinInProgressSessions: () => ({ + pinInProgressMode: 'off' as const, + setPinInProgressMode: vi.fn(), + pinInProgressSessions: false, + setPinInProgressSessions: vi.fn(), + }), })) vi.mock('@/hooks/useAppBadgePreference', () => ({ From c0fea37d45dd25db0c9a019bf1de69a8fa6a3752 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:37:05 +0000 Subject: [PATCH 14/99] fix(jobs): cold-review pass 3 Blocker + Majors (#1404) CI: bump migration tip expects to SCHEMA 22; stub list attachedJobs mocks; align Codex/OpenCode/ACP fixtures with job steer + session_job tool list. Merge: write jobsAccepted/Transferred redirects after metadata merge so a name/summary copy cannot clobber the post-merge job owner map. MCP: session_job is own-session only (drop sessionId from schema) so auto-approve cannot silently write peer meters. Prefer hapi job run in steer. Co-authored-by: Cursor --- cli/src/codex/utils/appServerConfig.test.ts | 20 ++++++------- .../permission/BasePermissionHandler.ts | 5 ++-- .../modules/common/sessionJobInstruction.ts | 11 +++---- .../modules/sessionJob/sessionJobMcp.test.ts | 10 ++++++- cli/src/modules/sessionJob/sessionJobMcp.ts | 30 +++++++++---------- cli/src/opencode/utils/systemPrompt.test.ts | 2 +- hub/src/sync/sessionCache-merge-jobs.test.ts | 29 ++++++++++++++++++ hub/src/sync/sessionCache.ts | 17 +++++++---- hub/src/web/routes/sessions.test.ts | 5 +++- web/src/components/SessionList.tsx | 9 ++++-- .../hooks/usePinInProgressSessions.test.ts | 22 +++++++++++++- web/src/hooks/usePinInProgressSessions.ts | 10 ++++++- 12 files changed, 124 insertions(+), 46 deletions(-) diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index d45e677f92..71b2d03575 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'] } }; @@ -56,7 +56,7 @@ describe('appServerConfig', () => { command: 'node', args: ['mcp'] }, - developer_instructions: codexSystemPrompt + developer_instructions: getCodexSystemPrompt() }); }); @@ -98,7 +98,7 @@ describe('appServerConfig', () => { } } }, - developer_instructions: codexSystemPrompt + developer_instructions: getCodexSystemPrompt() }); }); @@ -201,7 +201,7 @@ describe('appServerConfig', () => { 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/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index 27924f0388..62bb54e75e 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -38,7 +38,8 @@ const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([ 'mcp__hapi__list_peers', // ACP permission requests often surface MCP tool title, not the snake_case name. 'list peer sessions', - // Own-session progress meter (tiann/hapi#1404) — hub REST only, not peer inject. + // Own-session progress meter (tiann/hapi#1404) — MCP schema has no sessionId; + // tool always targets this chat. Cross-session writes use CLI hapi job (not auto). 'session_job', 'hapi_session_job', 'happy__session_job', @@ -49,7 +50,7 @@ const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([ // 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 / session_job are auto-approved above. +// approval. list_peers / own-session session_job are 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.ts b/cli/src/modules/common/sessionJobInstruction.ts index c400615455..aaec5f96d7 100644 --- a/cli/src/modules/common/sessionJobInstruction.ts +++ b/cli/src/modules/common/sessionJobInstruction.ts @@ -14,11 +14,12 @@ export const SESSION_JOB_INSTRUCTION = [ '(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.', - 'Prefer MCP tool session_job (aliases: mcp__hapi__session_job, hapi_session_job,', - 'functions.hapi__session_job) with action=set before the process starts, then', - 'action=update every ~10m (idle agents cannot heartbeat).', - 'For a supervised shell child: hapi job run "$HAPI_SESSION_ID" ', - '--label -- … (auto-heartbeats + completed/failed on exit).', + 'Prefer supervised CLI for process-shaped work (idle agents cannot heartbeat):', + 'hapi job run "$HAPI_SESSION_ID" --label -- …', + '(auto-heartbeats + completed/failed on exit).', + 'Manual path: MCP tool session_job (aliases: mcp__hapi__session_job,', + 'hapi_session_job, functions.hapi__session_job) action=set, then action=update', + 'every ~10m from a self-heartbeating wrapper — never set-once and walk away.', 'Prefer honest remaining or done+total; omit counts when unknown', '(UI shows "running" + elapsed). Never invent a fake percent.', 'Full contract: hapi job --help.' diff --git a/cli/src/modules/sessionJob/sessionJobMcp.test.ts b/cli/src/modules/sessionJob/sessionJobMcp.test.ts index 87ac653dc6..b9a4fe95de 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.test.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.test.ts @@ -44,13 +44,21 @@ describe('sessionJobMcp', () => { expect(SESSION_JOB_TOOL_DESCRIPTION).toContain('hapi job run') }) - it('set requires label and defaults session to caller id', async () => { + it('set requires label and always targets the caller session id', async () => { + const { setSessionJob } = await import('./sessionJob') const result = await handleSessionJobTool( { action: 'set', jobKey: 'beets', label: 'beets import', remaining: 12 }, 'sid-1' ) expect(result.isError).toBe(false) expect(result.text).toContain('set beets') + expect(setSessionJob).toHaveBeenCalledWith( + expect.objectContaining({ sessionIdPrefix: 'sid-1' }) + ) + }) + + it('description claims own-session only', () => { + expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/Own-session only/i) }) it('rejects update with empty patch', async () => { diff --git a/cli/src/modules/sessionJob/sessionJobMcp.ts b/cli/src/modules/sessionJob/sessionJobMcp.ts index 519f064a9b..7f689f89bd 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.ts @@ -20,26 +20,23 @@ export const SESSION_JOB_TOOL_NAME = 'session_job' * Write for selection, not for humans browsing a README. */ export const SESSION_JOB_TOOL_DESCRIPTION = [ - 'Attach or update a hub-persisted progress meter on a HAPI session for work that', + 'Attach or update a hub-persisted progress meter on THIS HAPI session for work that', 'OUTLIVES this agent turn (nohup / batch import / rclone / compile / long drain /', - 'external daemon). The session list shows the meter while the agent is idle', - '(active:false). Call action=set BEFORE starting that process (or immediately when', - 'you start it). Heartbeat with action=update at least every ~10 minutes while it', - 'runs — an idle agent cannot. Prefer honest remaining or done+total; omit counts', - 'when unknown (UI shows "running" + elapsed). Never invent a percent or ETA.', - 'Finish with action=update status=completed|failed or action=clear.', - 'Default sessionId is this chat ($HAPI_SESSION_ID). Not for in-agent todos,', - 'thinking progress, or short tool calls. For a supervised shell child that', - 'auto-heartbeats, prefer CLI: hapi job run "$HAPI_SESSION_ID" --label … -- .', + 'external daemon). Own-session only (auto-approved) — not for injecting meters onto', + 'peer sessions (use CLI hapi job for that). The session list shows the meter while', + 'the agent is idle (active:false). Prefer CLI for process-shaped work:', + 'hapi job run "$HAPI_SESSION_ID" --label … -- (auto-heartbeats).', + 'Manual: action=set BEFORE starting the process, then action=update at least every', + '~10 minutes from a self-heartbeating wrapper — an idle agent cannot. Prefer honest', + 'remaining or done+total; omit counts when unknown (UI shows "running" + elapsed).', + 'Never invent a percent or ETA. Finish with action=update status=completed|failed', + 'or action=clear. Not for in-agent todos, thinking progress, or short tool calls.', ].join(' ') export const sessionJobInputSchema: z.ZodTypeAny = z.object({ action: z.enum(['set', 'update', 'clear', 'list']).describe( 'set=register/upsert running job; update=heartbeat/progress/status; clear=remove; list=show jobs' ), - sessionId: z.string().trim().min(1).optional().describe( - 'Target session id or prefix. Omit to use this chat ($HAPI_SESSION_ID).' - ), jobKey: z.string().trim().min(1).max(128).optional().describe( 'Stable job key (alnum . _ -). Required for set/update/clear.' ), @@ -61,7 +58,6 @@ export const sessionJobInputSchema: z.ZodTypeAny = z.object({ export type SessionJobToolArgs = { action: 'set' | 'update' | 'clear' | 'list' - sessionId?: string jobKey?: string label?: string status?: 'running' | 'completed' | 'failed' @@ -88,10 +84,12 @@ export async function handleSessionJobTool( args: SessionJobToolArgs, defaultSessionId: string ): Promise<{ text: string; isError: boolean }> { - const sessionIdPrefix = (args.sessionId?.trim() || defaultSessionId || process.env.HAPI_SESSION_ID || '').trim() + // Own-session only — sessionId is not in the schema so auto-approve cannot + // become a silent cross-session write (cold-review pass 3 Major). + const sessionIdPrefix = (defaultSessionId || process.env.HAPI_SESSION_ID || '').trim() if (!sessionIdPrefix) { return { - text: 'sessionId required (or set HAPI_SESSION_ID / call from a HAPI-wrapped session)', + text: 'own session id required (set HAPI_SESSION_ID / call from a HAPI-wrapped session)', isError: true } } diff --git a/cli/src/opencode/utils/systemPrompt.test.ts b/cli/src/opencode/utils/systemPrompt.test.ts index 483c901075..0adc0654f7 100644 --- a/cli/src/opencode/utils/systemPrompt.test.ts +++ b/cli/src/opencode/utils/systemPrompt.test.ts @@ -26,7 +26,7 @@ describe('OpenCode local HAPI instructions', () => { const instructions = await readFile(instructionsPath, 'utf8') expect(instructions).toContain('$name') expect(instructions).toContain('skill_lookup') - expect(instructions).toContain('hapi job set') + expect(instructions).toContain('hapi job run') expect(instructions).toContain(TITLE_INSTRUCTION.trim()) }) }) diff --git a/hub/src/sync/sessionCache-merge-jobs.test.ts b/hub/src/sync/sessionCache-merge-jobs.test.ts index 9171a12fbc..5b814cb418 100644 --- a/hub/src/sync/sessionCache-merge-jobs.test.ts +++ b/hub/src/sync/sessionCache-merge-jobs.test.ts @@ -73,6 +73,35 @@ describe('mergeSessions job redirect through SessionCache (#1404)', () => { expect(cache.resolveAttachedJobSessionId(newSession.id, 'default')).toBe(newSession.id) }) + it('keeps jobsAcceptedFromSessionIds when metadata merge also copies name from old', async () => { + const { store, cache } = setup() + const oldSession = cache.getOrCreateSession( + 'agent-jobs-named-old-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex', name: 'Lidarr drain' }, + null, + 'default' + ) + const newSession = cache.getOrCreateSession( + 'agent-jobs-named-new-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + store.sessionJobs.upsert(oldSession.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 4 + }) + + await cache.mergeSessions(oldSession.id, newSession.id, 'default') + + const refreshed = cache.refreshSession(newSession.id) + expect(refreshed?.metadata?.name).toBe('Lidarr drain') + expect(refreshed?.metadata?.jobsAcceptedFromSessionIds).toContain(oldSession.id) + expect(cache.resolveAttachedJobSessionId(oldSession.id, 'default')).toBe(newSession.id) + }) + it('keeps jobsTransferredToSessionId on a kept-alive source after mergeSessionHistory', async () => { const { store, cache } = setup() const { oldSession, newSession } = makeSessions(cache) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 14bfae8d9b..3f23e8c3c5 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1316,12 +1316,6 @@ export class SessionCache { const movedScratchlist = this.store.scratchlist.transfer(oldSessionId, newSessionId) const movedJobs = this.store.sessionJobs.transfer(oldSessionId, newSessionId) if (movedJobs.moved > 0 || movedJobs.collided > 0) { - // Agents keep addressing $HAPI_SESSION_ID from the pre-merge row. - // Record redirects so job REST routes can follow the live job owner. - this.recordJobsAcceptedFromSession(newSessionId, oldSessionId, namespace) - if (!options.deleteOldSession) { - this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) - } this.emitAttachedJobChanged( newSessionId, this.store.sessionJobs.getPrimaryRunning(newSessionId) @@ -1397,6 +1391,17 @@ export class SessionCache { } } + // Job-owner redirects AFTER metadata merge. Writing them before the + // merge clobbers jobsAcceptedFromSessionIds when mergeSessionMetadata + // rebuilds from the stale pre-merge newStored.metadata snapshot + // (cold-review pass 3 Major — agents heartbeating $HAPI_SESSION_ID 404). + if (movedJobs.moved > 0 || movedJobs.collided > 0) { + this.recordJobsAcceptedFromSession(newSessionId, oldSessionId, namespace) + if (!options.deleteOldSession) { + this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) + } + } + if (newStored.model === null && oldStored.model !== null) { const updated = this.store.sessions.setSessionModel(newSessionId, oldStored.model, namespace, { touchUpdatedAt: false diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index f82520f771..c798a6de21 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -180,7 +180,8 @@ function createApp(session: Session, opts?: { implementCodexPlan: opts?.implementCodexPlan, rewindConversation: opts?.rewindConversation ?? (async () => ({ type: 'success' })), suggestSessionTitle: opts?.suggestSessionTitle ?? (async () => 'Generated title'), - updateSessionSummary: opts?.updateSessionSummary ?? (async () => {}) + updateSessionSummary: opts?.updateSessionSummary ?? (async () => {}), + getPrimaryAttachedJobsBySessionIds: opts?.getPrimaryAttachedJobsBySessionIds ?? (() => new Map()) } as Partial const app = new Hono() @@ -1701,6 +1702,7 @@ describe('sessions routes', () => { return new Map(ids.map((id) => [id, 0])) }, getNextScheduledAtBySessionIds: (_ids: string[]) => new Map(), + getPrimaryAttachedJobsBySessionIds: () => new Map(), resolveSessionAccess: () => ({ ok: false, reason: 'not-found' as const }) } as unknown as Partial @@ -1733,6 +1735,7 @@ describe('sessions routes', () => { getSessionsByNamespace: () => sessions, getFutureScheduledMessageCounts: (ids: string[]) => new Map(ids.map((id) => [id, 0])), getNextScheduledAtBySessionIds: (_ids: string[]) => new Map(), + getPrimaryAttachedJobsBySessionIds: () => new Map(), resolveSessionAccess: () => ({ ok: false, reason: 'not-found' as const }) } as unknown as Partial diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 4c8a4b4bb6..b3921c54c0 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -329,9 +329,14 @@ export function prepareSidebarSessions(sessions: SessionSummary[], selectedSessi // "Active sessions only" view: hide inactive sessions, but never hide the one the // operator currently has open — otherwise toggling the filter would yank the -// selected session out from under them. +// selected session out from under them. Idle sessions with a running attached +// job stay visible too — that is the headline use case for session jobs. export function filterActiveSessionsOnly(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] { - return sessions.filter(session => session.active || session.id === selectedSessionId) + return sessions.filter(session => + session.active + || session.id === selectedSessionId + || hasRunningAttachedJob(session) + ) } // Transient unread lens: hide sessions the operator has already seen. diff --git a/web/src/hooks/usePinInProgressSessions.test.ts b/web/src/hooks/usePinInProgressSessions.test.ts index d008ee0a43..e6acff9ca4 100644 --- a/web/src/hooks/usePinInProgressSessions.test.ts +++ b/web/src/hooks/usePinInProgressSessions.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { DEFAULT_PIN_IN_PROGRESS_MODE, + PIN_IN_PROGRESS_STORAGE_KEY, + getInitialPinInProgressMode, parsePinInProgressMode } from './usePinInProgressSessions' @@ -26,3 +28,21 @@ describe('parsePinInProgressMode', () => { expect(parsePinInProgressMode('maybe')).toBe('jobs') }) }) + +describe('getInitialPinInProgressMode', () => { + afterEach(() => { + localStorage.removeItem(PIN_IN_PROGRESS_STORAGE_KEY) + }) + + it('persists jobs default so later Off is distinct from unset', () => { + localStorage.removeItem(PIN_IN_PROGRESS_STORAGE_KEY) + expect(getInitialPinInProgressMode()).toBe('jobs') + expect(localStorage.getItem(PIN_IN_PROGRESS_STORAGE_KEY)).toBe('jobs') + }) + + it('rewrites legacy false to persisted off', () => { + localStorage.setItem(PIN_IN_PROGRESS_STORAGE_KEY, 'false') + expect(getInitialPinInProgressMode()).toBe('off') + expect(localStorage.getItem(PIN_IN_PROGRESS_STORAGE_KEY)).toBe('off') + }) +}) diff --git a/web/src/hooks/usePinInProgressSessions.ts b/web/src/hooks/usePinInProgressSessions.ts index ec1886dc7b..1c6c1dc356 100644 --- a/web/src/hooks/usePinInProgressSessions.ts +++ b/web/src/hooks/usePinInProgressSessions.ts @@ -70,7 +70,15 @@ export function parsePinInProgressMode(raw: string | null): PinInProgressMode { } export function getInitialPinInProgressMode(): PinInProgressMode { - return parsePinInProgressMode(safeGetItem(PIN_IN_PROGRESS_STORAGE_KEY)) + const raw = safeGetItem(PIN_IN_PROGRESS_STORAGE_KEY) + const mode = parsePinInProgressMode(raw) + // Persist so explicit Off is distinguishable from never-set→jobs. + // Legacy true/false also rewrite to all/off (upstream removed the key on false, + // so those users already look like unset — product stand maps them to jobs). + if (raw === null || raw === '' || raw === 'true' || raw === 'false') { + safeSetItem(PIN_IN_PROGRESS_STORAGE_KEY, mode) + } + return mode } /** @deprecated Use getInitialPinInProgressMode — boolean form treated `all` as true. */ From b4d02a10564e4701c7cde501e5a15909f1154f39 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:52:17 +0000 Subject: [PATCH 15/99] =?UTF-8?q?fix(jobs):=20close=20pass-3=20minors=20?= =?UTF-8?q?=E2=80=94=20stable=20primary,=20own-client=20heartbeats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve JWT/session once for job run heartbeats; order primary job by started_at ASC; cover job redirect 404 + invalid key/body; idle jobs get a Jobs bucket (not Running); treat attachedJob.startedAt as render-relevant. Co-authored-by: Cursor --- .../modules/sessionJob/runSessionJob.test.ts | 30 +++++-- cli/src/modules/sessionJob/runSessionJob.ts | 40 +++++---- cli/src/modules/sessionJob/sessionJob.ts | 33 ++++++-- hub/src/store/migration-v25.test.ts | 11 +++ hub/src/store/sessionJobs.ts | 7 +- hub/src/web/routes/sessions-jobs.test.ts | 82 +++++++++++++++++++ .../SessionList.directory-action.test.tsx | 1 + web/src/components/SessionList.tsx | 11 ++- web/src/hooks/useSSE.test.ts | 17 ++++ web/src/hooks/useSSE.ts | 1 + web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + 12 files changed, 197 insertions(+), 38 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index 66e444a09d..c1b3fc4240 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -2,14 +2,18 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { runSessionJob } from './runSessionJob' -function fakeChild(exitCode: number) { +function fakeChild(exitCode: number, deferExit = false) { const child = new EventEmitter() as EventEmitter & { pid: number killed: boolean + exit: () => void } child.pid = 4242 child.killed = false - queueMicrotask(() => child.emit('exit', exitCode, null)) + child.exit = () => child.emit('exit', exitCode, null) + if (!deferExit) { + queueMicrotask(() => child.exit()) + } return child } @@ -50,7 +54,8 @@ describe('runSessionJob', () => { } const timers: Array<() => void> = [] - const exitCode = await runSessionJob({ + const child = fakeChild(0, true) + const running = runSessionJob({ sessionIdPrefix: 'aaaa', jobKey: 'drain', label: 'drain', @@ -59,7 +64,7 @@ describe('runSessionJob', () => { accessToken: 'token', apiUrl: 'http://127.0.0.1:3006', http: http as never, - spawnImpl: (() => fakeChild(0)) as never, + spawnImpl: (() => child) as never, setIntervalImpl: ((fn: () => void) => { timers.push(fn) return 1 as unknown as NodeJS.Timeout @@ -67,11 +72,24 @@ describe('runSessionJob', () => { clearIntervalImpl: (() => undefined) as never }) + await vi.waitFor(() => expect(http.put).toHaveBeenCalled()) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) + + // Heartbeat ticks reuse resolved client (no extra auth). + expect(timers.length).toBe(1) + timers[0]!() + await vi.waitFor(() => expect(http.patch).toHaveBeenCalled()) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) + + child.exit() + const exitCode = await running expect(exitCode).toBe(0) - expect(http.put).toHaveBeenCalled() - expect(http.patch).toHaveBeenCalled() const lastPatch = http.patch.mock.calls.at(-1)?.[1] as { status?: string } expect(lastPatch.status).toBe('completed') + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) }) it('marks failed on non-zero exit', async () => { diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts index f654b1fcd8..232cdd8163 100644 --- a/cli/src/modules/sessionJob/runSessionJob.ts +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -7,9 +7,11 @@ import { spawn, type ChildProcess } from 'node:child_process' import type { AttachedJobUpsert } from '@hapi/protocol' import { SessionJobError, + resolveSessionJobClient, setSessionJob, updateSessionJob, - type SessionJobClientOptions + type SessionJobClientOptions, + type SessionJobResolvedClient } from './sessionJob' export type RunSessionJobOptions = SessionJobClientOptions & { @@ -45,13 +47,18 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise { void updateSessionJob({ - sessionIdPrefix: options.sessionIdPrefix, + ...clientOpts, jobKey: options.jobKey, body: { detail: options.detail, status: 'running' - }, - apiUrl: options.apiUrl, - accessToken: options.accessToken, - http: options.http + } }).catch(() => { // Best-effort — exit path still marks terminal status. }) @@ -101,12 +105,9 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise( - options: SessionJobClientOptions, - fn: (ctx: { apiUrl: string; jwt: string; sessionId: string; http: AxiosInstance }) => Promise -): Promise { +/** One JWT + session id for the life of a supervised job. */ +export async function resolveSessionJobClient( + options: SessionJobClientOptions +): Promise { + if (options.resolved) { + return options.resolved + } const http = options.http ?? axios const apiUrl = resolveApiUrl(options.apiUrl) const accessToken = resolveAccessToken(options.accessToken) const jwt = await exchangeJwt(apiUrl, accessToken, http) const sessionId = await resolveSessionId(apiUrl, jwt, http, options.sessionIdPrefix) - return fn({ apiUrl, jwt, sessionId, http }) + return { apiUrl, jwt, sessionId } +} + +async function withClient( + options: SessionJobClientOptions, + fn: (ctx: SessionJobResolvedClient & { http: AxiosInstance }) => Promise +): Promise { + const http = options.http ?? axios + const resolved = await resolveSessionJobClient(options) + return fn({ ...resolved, http }) } export async function listSessionJobs( diff --git a/hub/src/store/migration-v25.test.ts b/hub/src/store/migration-v25.test.ts index 43b58ac498..a7afec2e3f 100644 --- a/hub/src/store/migration-v25.test.ts +++ b/hub/src/store/migration-v25.test.ts @@ -87,6 +87,17 @@ describe('Store V26→V27 migration: session_jobs table', () => { expect(primary?.key).toBe('beets') expect(primary?.remaining).toBe(100) + // Stable primary: earliest started_at wins even after a newer job heartbeats. + store.sessionJobs.upsert(session.id, 'newer', { + label: 'sidecar', + status: 'running', + remaining: 1, + startedAt: (primary!.startedAt) + 60_000 + }) + store.sessionJobs.patch(session.id, 'newer', { remaining: 0 }) + expect(store.sessionJobs.getPrimaryRunning(session.id)?.key).toBe('beets') + expect(store.sessionJobs.delete(session.id, 'newer')).toBe(true) + const patched = store.sessionJobs.patch(session.id, 'beets', { remaining: 80 }) expect(patched?.remaining).toBe(80) diff --git a/hub/src/store/sessionJobs.ts b/hub/src/store/sessionJobs.ts index fb933bef19..644fc9415a 100644 --- a/hub/src/store/sessionJobs.ts +++ b/hub/src/store/sessionJobs.ts @@ -83,13 +83,13 @@ export function getSessionJob( return row ? toStored(row) : null } -/** Newest `running` job for a session, or null. */ +/** Earliest-started `running` job for a session, or null (stable list chrome). */ export function getPrimaryRunningJob(db: Database, sessionId: string): StoredSessionJob | null { const row = db.prepare( `SELECT ${JOB_COLUMNS} FROM session_jobs WHERE session_id = ? AND status = 'running' - ORDER BY updated_at DESC, job_key ASC + ORDER BY started_at ASC, job_key ASC LIMIT 1` ).get(sessionId) as DbJobRow | undefined return row ? toStored(row) : null @@ -111,10 +111,11 @@ export function getPrimaryRunningJobsBySessionIds( `SELECT ${JOB_COLUMNS} FROM session_jobs WHERE status = 'running' AND session_id IN (${placeholders}) - ORDER BY updated_at DESC, job_key ASC` + ORDER BY started_at ASC, job_key ASC` ).all(...sessionIds) as DbJobRow[] for (const row of rows) { + // First row per session wins — earliest started_at (stable primary). if (result.has(row.session_id)) continue result.set(row.session_id, toAttachedJob(toStored(row))) } diff --git a/hub/src/web/routes/sessions-jobs.test.ts b/hub/src/web/routes/sessions-jobs.test.ts index 87a0161b5d..8622a34de1 100644 --- a/hub/src/web/routes/sessions-jobs.test.ts +++ b/hub/src/web/routes/sessions-jobs.test.ts @@ -127,4 +127,86 @@ describe('session-attached jobs routes (tiann/hapi#1404)', () => { expect(listed.jobs).toEqual([]) expect(listed.primary).toBeNull() }) + + it('follows jobsAccepted redirect when the pre-merge session id 404s', async () => { + const owner = createSession({ id: '22222222-2222-2222-2222-222222222222' }) + const deletedId = '11111111-1111-1111-1111-111111111111' + const jobs = new Map() + jobs.set('beets', { + key: 'beets', + label: 'beets import', + status: 'running', + remaining: 3, + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + }) + + const engine = { + resolveSessionAccess: (id: string) => { + if (id === owner.id) { + return { ok: true as const, sessionId: owner.id, session: owner } + } + return { ok: false as const, reason: 'not-found' as const } + }, + resolveAttachedJobSessionId: (id: string) => (id === deletedId ? owner.id : id), + listSessionJobs: (sid: string) => (sid === owner.id ? [...jobs.values()] : []), + getPrimaryAttachedJob: (sid: string) => (sid === owner.id ? jobs.get('beets')! : null), + upsertSessionJob: () => ({ outcome: 'session-not-found' as const }), + patchSessionJob: () => null, + deleteSessionJob: () => false + } as unknown as SyncEngine + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine)) + + const res = await app.request(`http://localhost/api/sessions/${deletedId}/jobs`) + expect(res.status).toBe(200) + const body = await res.json() as { primary: AttachedJob | null } + expect(body.primary?.key).toBe('beets') + }) + + it('rejects invalid jobKey and invalid upsert body with 400', async () => { + const session = createSession() + const engine = { + resolveSessionAccess: () => ({ ok: true as const, sessionId: session.id, session }), + resolveAttachedJobSessionId: (id: string) => id, + listSessionJobs: () => [], + getPrimaryAttachedJob: () => null, + upsertSessionJob: () => ({ outcome: 'session-not-found' as const }), + patchSessionJob: () => null, + deleteSessionJob: () => false + } as unknown as SyncEngine + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine)) + + const badKey = await app.request( + `http://localhost/api/sessions/${session.id}/jobs/bad key!`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: 'x' }) + } + ) + expect(badKey.status).toBe(400) + + const badBody = await app.request( + `http://localhost/api/sessions/${session.id}/jobs/ok-key`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ remaining: 1 }) + } + ) + expect(badBody.status).toBe(400) + }) }) diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index 9aaa00ce79..3b5cb89be1 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -585,6 +585,7 @@ describe('SessionList collapse behavior', () => { render(renderSessionList(sessions, null)) expect(screen.getByTitle('In progress')).toBeInTheDocument() + expect(screen.getByText(/Jobs \(1\)/)).toBeInTheDocument() expect(screen.getByRole('button', { name: /Music drain/ })).toBeInTheDocument() // Thinking agent is not a long-running job — stays in directory under jobs mode. expect(screen.getByTitle('/work/hapi')).toBeInTheDocument() diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index b3921c54c0..c62dae2bea 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -80,6 +80,7 @@ type SessionGroup = { } const RUNNING_BUCKETS = [ + { key: 'jobs', labelKey: 'session.item.attachedJob', colorClass: 'text-[var(--app-badge-success-text)]', pulse: true }, { key: 'working', labelKey: 'session.item.running', colorClass: 'text-[var(--app-badge-success-text)]', pulse: true }, { key: 'pending', labelKey: 'session.item.pending', colorClass: 'text-[var(--app-badge-warning-text)]', pulse: true }, { key: 'active', labelKey: 'session.item.active', colorClass: 'text-[var(--app-hint)]', pulse: false }, @@ -105,7 +106,7 @@ function hasAgentInProgressActivity(session: SessionSummary): boolean { } export function emptyRunningBuckets(): Record { - return { working: [], pending: [], active: [], idle: [] } + return { jobs: [], working: [], pending: [], active: [], idle: [] } } /** @@ -134,8 +135,11 @@ export function bucketRunningSessions( const agentPending = session.active && (session.pendingRequestsCount ?? 0) > 0 && !agentWorking - if (agentWorking || hasRunningAttachedJob(session)) { + if (agentWorking) { buckets.working.push(session) + } else if (hasRunningAttachedJob(session)) { + // Idle outliving work — not "Running" agent activity. + buckets.jobs.push(session) } else if (agentPending) { buckets.pending.push(session) } else if (session.metadata?.lifecycleState === SESSION_LIFECYCLE_IDLE) { @@ -1420,7 +1424,8 @@ export function SessionList(props: { } return bucketRunningSessions(machineFilteredSessions, pinInProgressMode, byRelevanceOrRecent) }, [machineFilteredSessions, pinInProgressMode, searchScoreIndex, hasTextQuery]) - const runningSessionTotal = runningSessions.working.length + const runningSessionTotal = runningSessions.jobs.length + + runningSessions.working.length + runningSessions.pending.length const activeSessionTotal = runningSessions.active.length + runningSessions.idle.length const groups = useMemo( diff --git a/web/src/hooks/useSSE.test.ts b/web/src/hooks/useSSE.test.ts index 7917422acb..3a6592e631 100644 --- a/web/src/hooks/useSSE.test.ts +++ b/web/src/hooks/useSSE.test.ts @@ -326,6 +326,23 @@ describe('isRenderIrrelevantPatch', () => { expect(isRenderIrrelevantPatch(current, next)).toBe(false) }) + + it('reports attachedJob.startedAt changes as relevant', () => { + const job = { + key: 'beets', + label: 'beets', + status: 'running' as const, + heartbeatAt: 100, + startedAt: 100, + updatedAt: 100 + } + const current = makeSummary({ attachedJob: job }) + const next = makeSummary({ + attachedJob: { ...job, startedAt: 50 }, + activeAt: 11_000 + }) + expect(isRenderIrrelevantPatch(current, next)).toBe(false) + }) }) describe('isRenderIrrelevantSessionPatch', () => { diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 39236f212c..926705726d 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -168,6 +168,7 @@ export function isRenderIrrelevantPatch(current: SessionSummary, next: SessionSu && current.attachedJob?.unit === next.attachedJob?.unit && current.attachedJob?.detail === next.attachedJob?.detail && current.attachedJob?.heartbeatAt === next.attachedJob?.heartbeatAt + && current.attachedJob?.startedAt === next.attachedJob?.startedAt && (current.attachedJob == null) === (next.attachedJob == null) && current.model === next.model && current.modelReasoningEffort === next.modelReasoningEffort diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 5467947acf..5c669425ac 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -189,6 +189,7 @@ export default { 'session.item.running': 'Running', 'session.item.active': 'Active', 'session.item.idle': 'Idle (keepalive only)', + 'session.item.attachedJob': 'Jobs', 'session.item.permission': 'Permission required', 'session.item.needsInput': 'Needs input', 'session.item.background': 'Background tasks running', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 07a30ecce0..2bb82f9e7e 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -189,6 +189,7 @@ export default { 'session.item.running': '运行中', 'session.item.active': '活跃', 'session.item.idle': '空闲(仅心跳)', + 'session.item.attachedJob': '长时间任务', 'session.item.permission': '需要权限', 'session.item.needsInput': '需要输入', 'session.item.background': '后台任务运行中', From 7df619fb8d15eedfca44000e214cbedea576fa30 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:03:31 +0000 Subject: [PATCH 16/99] fix(jobs): refresh JWT for long job-run heartbeats (#1404) Cache sessionId once but re-exchange JWT before hub's 4h expiry and on 401 retry so days-long supervised jobs keep heartbeating and can still mark completed/failed. Log the first heartbeat failure to stderr. Co-authored-by: Cursor --- .../modules/sessionJob/runSessionJob.test.ts | 86 +++++++++ cli/src/modules/sessionJob/runSessionJob.ts | 21 ++- cli/src/modules/sessionJob/sessionJob.ts | 176 ++++++++++++------ 3 files changed, 224 insertions(+), 59 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index c1b3fc4240..5ff45b2658 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -144,4 +144,90 @@ describe('runSessionJob', () => { 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() + }) }) diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts index 232cdd8163..1345399f56 100644 --- a/cli/src/modules/sessionJob/runSessionJob.ts +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -47,11 +47,14 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise { void updateSessionJob({ ...clientOpts, @@ -79,8 +83,14 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise { - // Best-effort — exit path still marks terminal status. + }).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. @@ -134,8 +144,9 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise { @@ -196,43 +205,89 @@ export async function resolveSessionJobClient( const accessToken = resolveAccessToken(options.accessToken) const jwt = await exchangeJwt(apiUrl, accessToken, http) const sessionId = await resolveSessionId(apiUrl, jwt, http, options.sessionIdPrefix) - return { apiUrl, jwt, sessionId } + return { apiUrl, jwt, sessionId, jwtIssuedAtMs: Date.now() } +} + +async function refreshSessionJobJwt( + resolved: SessionJobResolvedClient, + options: SessionJobClientOptions +): Promise { + const http = options.http ?? axios + const accessToken = resolveAccessToken(options.accessToken) + resolved.jwt = await exchangeJwt(resolved.apiUrl, accessToken, http) + resolved.jwtIssuedAtMs = Date.now() +} + +async function ensureFreshJwt( + resolved: SessionJobResolvedClient, + options: SessionJobClientOptions, + force = false +): Promise { + const age = Date.now() - resolved.jwtIssuedAtMs + if (force || age >= SESSION_JOB_JWT_REFRESH_AFTER_MS) { + await refreshSessionJobJwt(resolved, options) + } } -async function withClient( +type AuthedResponse = { status: number; data?: unknown } + +/** + * Run an authed request; on 401 re-exchange JWT (keep cached sessionId) and retry once. + */ +async function withAuthedRequest( options: SessionJobClientOptions, - fn: (ctx: SessionJobResolvedClient & { http: AxiosInstance }) => Promise + request: (ctx: { apiUrl: string; jwt: string; sessionId: string; http: AxiosInstance }) => Promise, + handle: (response: AuthedResponse, sessionId: string) => T ): Promise { const http = options.http ?? axios const resolved = await resolveSessionJobClient(options) - return fn({ ...resolved, http }) + await ensureFreshJwt(resolved, options) + + const run = async () => request({ + apiUrl: resolved.apiUrl, + jwt: resolved.jwt, + sessionId: resolved.sessionId, + http + }) + + let response = await run() + if (response.status === 401) { + await ensureFreshJwt(resolved, options, true) + response = await run() + } + return handle(response, resolved.sessionId) } export async function listSessionJobs( options: SessionJobClientOptions ): Promise<{ sessionId: string; jobs: AttachedJob[]; primary: AttachedJob | null }> { - return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { - const response = await http.get(`${apiUrl}/api/sessions/${sessionId}/jobs`, { + return withAuthedRequest( + options, + ({ apiUrl, jwt, sessionId, http }) => http.get(`${apiUrl}/api/sessions/${sessionId}/jobs`, { headers: authHeaders(jwt), timeout: 15_000, validateStatus: () => true - }) - if (response.status < 200 || response.status >= 300) { - throw new SessionJobError('request_failed', `list jobs failed: HTTP ${response.status}`) - } - return { - sessionId, - jobs: Array.isArray(response.data?.jobs) ? response.data.jobs : [], - primary: response.data?.primary ?? null + }), + (response, sessionId) => { + if (response.status < 200 || response.status >= 300) { + throw new SessionJobError('request_failed', `list jobs failed: HTTP ${response.status}`) + } + const data = response.data as { jobs?: AttachedJob[]; primary?: AttachedJob | null } | undefined + return { + sessionId, + jobs: Array.isArray(data?.jobs) ? data.jobs : [], + primary: data?.primary ?? null + } } - }) + ) } export async function setSessionJob( options: SessionJobClientOptions & { jobKey: string; body: AttachedJobUpsert } ): Promise<{ sessionId: string; job: AttachedJob }> { - return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { - const response = await http.put( + return withAuthedRequest( + options, + ({ apiUrl, jwt, sessionId, http }) => http.put( `${apiUrl}/api/sessions/${sessionId}/jobs/${encodeURIComponent(options.jobKey)}`, options.body, { @@ -240,25 +295,29 @@ export async function setSessionJob( timeout: 15_000, validateStatus: () => true } - ) - if (response.status === 404) { - throw new SessionJobError('not_found', 'session or job not found') - } - if (response.status < 200 || response.status >= 300 || !response.data?.job) { - const detail = typeof response.data?.error === 'string' - ? response.data.error - : `HTTP ${response.status}` - throw new SessionJobError('request_failed', `set job failed: ${detail}`) + ), + (response, sessionId) => { + if (response.status === 404) { + throw new SessionJobError('not_found', 'session or job not found') + } + const data = response.data as { job?: AttachedJob; error?: string } | undefined + if (response.status < 200 || response.status >= 300 || !data?.job) { + const detail = typeof data?.error === 'string' + ? data.error + : `HTTP ${response.status}` + throw new SessionJobError('request_failed', `set job failed: ${detail}`) + } + return { sessionId, job: data.job } } - return { sessionId, job: response.data.job as AttachedJob } - }) + ) } export async function updateSessionJob( options: SessionJobClientOptions & { jobKey: string; body: AttachedJobPatch } ): Promise<{ sessionId: string; job: AttachedJob }> { - return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { - const response = await http.patch( + return withAuthedRequest( + options, + ({ apiUrl, jwt, sessionId, http }) => http.patch( `${apiUrl}/api/sessions/${sessionId}/jobs/${encodeURIComponent(options.jobKey)}`, options.body, { @@ -266,40 +325,49 @@ export async function updateSessionJob( timeout: 15_000, validateStatus: () => true } - ) - if (response.status === 404) { - throw new SessionJobError('not_found', 'job not found') - } - if (response.status < 200 || response.status >= 300 || !response.data?.job) { - const detail = typeof response.data?.error === 'string' - ? response.data.error - : `HTTP ${response.status}` - throw new SessionJobError('request_failed', `update job failed: ${detail}`) + ), + (response, sessionId) => { + if (response.status === 404) { + throw new SessionJobError('not_found', 'job not found') + } + const data = response.data as { job?: AttachedJob; error?: string } | undefined + if (response.status < 200 || response.status >= 300 || !data?.job) { + const detail = typeof data?.error === 'string' + ? data.error + : `HTTP ${response.status}` + if (response.status === 401) { + throw new SessionJobError('auth_failed', `update job failed: ${detail}`) + } + throw new SessionJobError('request_failed', `update job failed: ${detail}`) + } + return { sessionId, job: data.job } } - return { sessionId, job: response.data.job as AttachedJob } - }) + ) } export async function clearSessionJob( options: SessionJobClientOptions & { jobKey: string } ): Promise<{ sessionId: string }> { - return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { - const response = await http.delete( + return withAuthedRequest( + options, + ({ apiUrl, jwt, sessionId, http }) => http.delete( `${apiUrl}/api/sessions/${sessionId}/jobs/${encodeURIComponent(options.jobKey)}`, { headers: authHeaders(jwt), timeout: 15_000, validateStatus: () => true } - ) - if (response.status === 404) { - throw new SessionJobError('not_found', 'job not found') - } - if (response.status < 200 || response.status >= 300) { - throw new SessionJobError('request_failed', `clear job failed: HTTP ${response.status}`) + ), + (response, sessionId) => { + if (response.status === 404) { + throw new SessionJobError('not_found', 'job not found') + } + if (response.status < 200 || response.status >= 300) { + throw new SessionJobError('request_failed', `clear job failed: HTTP ${response.status}`) + } + return { sessionId } } - return { sessionId } - }) + ) } export function exitCodeForSessionJobError(error: SessionJobError): number { From fe6b5ff12c798afefcc9ff9a78d659e983677bfb Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:09:34 +0000 Subject: [PATCH 17/99] test(jobs): cover proactive JWT refresh; map 401 to auth_failed Co-authored-by: Cursor --- .../modules/sessionJob/runSessionJob.test.ts | 81 +++++++++++++++++++ cli/src/modules/sessionJob/sessionJob.ts | 31 ++++--- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index 5ff45b2658..15d5dbee2f 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -1,5 +1,6 @@ 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) { @@ -230,4 +231,84 @@ describe('runSessionJob', () => { 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) + }) }) diff --git a/cli/src/modules/sessionJob/sessionJob.ts b/cli/src/modules/sessionJob/sessionJob.ts index bedda3bcf7..54cce77646 100644 --- a/cli/src/modules/sessionJob/sessionJob.ts +++ b/cli/src/modules/sessionJob/sessionJob.ts @@ -231,6 +231,20 @@ async function ensureFreshJwt( type AuthedResponse = { status: number; data?: unknown } +function httpStatusError( + action: string, + response: AuthedResponse, + errorDetail?: string +): SessionJobError { + const detail = typeof errorDetail === 'string' && errorDetail.length > 0 + ? errorDetail + : `HTTP ${response.status}` + if (response.status === 401) { + return new SessionJobError('auth_failed', `${action} failed: ${detail}`) + } + return new SessionJobError('request_failed', `${action} failed: ${detail}`) +} + /** * Run an authed request; on 401 re-exchange JWT (keep cached sessionId) and retry once. */ @@ -270,7 +284,7 @@ export async function listSessionJobs( }), (response, sessionId) => { if (response.status < 200 || response.status >= 300) { - throw new SessionJobError('request_failed', `list jobs failed: HTTP ${response.status}`) + throw httpStatusError('list jobs', response) } const data = response.data as { jobs?: AttachedJob[]; primary?: AttachedJob | null } | undefined return { @@ -302,10 +316,7 @@ export async function setSessionJob( } const data = response.data as { job?: AttachedJob; error?: string } | undefined if (response.status < 200 || response.status >= 300 || !data?.job) { - const detail = typeof data?.error === 'string' - ? data.error - : `HTTP ${response.status}` - throw new SessionJobError('request_failed', `set job failed: ${detail}`) + throw httpStatusError('set job', response, data?.error) } return { sessionId, job: data.job } } @@ -332,13 +343,7 @@ export async function updateSessionJob( } const data = response.data as { job?: AttachedJob; error?: string } | undefined if (response.status < 200 || response.status >= 300 || !data?.job) { - const detail = typeof data?.error === 'string' - ? data.error - : `HTTP ${response.status}` - if (response.status === 401) { - throw new SessionJobError('auth_failed', `update job failed: ${detail}`) - } - throw new SessionJobError('request_failed', `update job failed: ${detail}`) + throw httpStatusError('update job', response, data?.error) } return { sessionId, job: data.job } } @@ -363,7 +368,7 @@ export async function clearSessionJob( throw new SessionJobError('not_found', 'job not found') } if (response.status < 200 || response.status >= 300) { - throw new SessionJobError('request_failed', `clear job failed: HTTP ${response.status}`) + throw httpStatusError('clear job', response) } return { sessionId } } From a70535a7f95edd5c532bf262d6ccec52778760e6 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:46:03 +0000 Subject: [PATCH 18/99] fix(jobs): stamp fresh startedAt on job run; prefer supervisor Re-running the same job key was sticky-reusing a prior row's startedAt, so elapsed lied. Drain in-flight heartbeats before terminal status. Align AGENTS/guide: CLI supervisor first, MCP as manual path. Co-authored-by: Cursor --- .../modules/sessionJob/runSessionJob.test.ts | 4 ++++ cli/src/modules/sessionJob/runSessionJob.ts | 11 ++++++++- docs/guide/session-jobs.md | 24 +++++++++---------- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index 15d5dbee2f..9b70ebc6a2 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -74,6 +74,10 @@ describe('runSessionJob', () => { }) await vi.waitFor(() => expect(http.put).toHaveBeenCalled()) + const putBody = http.put.mock.calls[0]?.[1] as { startedAt?: number; status?: string } + expect(putBody.status).toBe('running') + expect(typeof putBody.startedAt).toBe('number') + expect(putBody.startedAt).toBeGreaterThan(0) expect(http.post).toHaveBeenCalledTimes(1) expect(http.get).toHaveBeenCalledTimes(1) diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts index 1345399f56..ceb7202b34 100644 --- a/cli/src/modules/sessionJob/runSessionJob.ts +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -40,6 +40,9 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise = Promise.resolve() const heartbeat = setIntervalFn(() => { - void updateSessionJob({ + inflightHeartbeat = updateSessionJob({ ...clientOpts, jobKey: options.jobKey, body: { @@ -113,6 +117,7 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise((resolve) => { child.on('error', async (error) => { clearIntervalFn(heartbeat) + await inflightHeartbeat.catch(() => undefined) try { await updateSessionJob({ ...clientOpts, @@ -137,6 +142,10 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise undefined) + const terminalStatus = exitCode === 0 ? 'completed' : 'failed' try { await updateSessionJob({ diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md index 5516ae5a6e..7dbbdf28b9 100644 --- a/docs/guide/session-jobs.md +++ b/docs/guide/session-jobs.md @@ -37,18 +37,7 @@ If the operator would reopen the chat only to ask "how's it doing?", it belongs Treat this like `ping_peer` / `inspect_peer`: it is first-class HAPI tooling, not a docs footnote. -### MCP (preferred for agents) - -Tool name: `session_job` (Claude: `mcp__hapi__session_job`; Codex: `functions.hapi__session_job`; OpenCode/ACP: `hapi_session_job`). - -```json -{ "action": "set", "jobKey": "beets", "label": "beets import", - "remaining": 150, "done": 1637, "total": 1787, "unit": "units" } -``` - -Then `action=update` every ~10 minutes; finish with `status=completed|failed` or `action=clear`. Omit `sessionId` to target this chat. - -### CLI supervisor (preferred for shell children) +### CLI supervisor (preferred for process-shaped work) ```bash hapi job run "$HAPI_SESSION_ID" beets \ @@ -60,6 +49,17 @@ hapi job run "$HAPI_SESSION_ID" beets \ `hapi job run` registers the job, heartbeats on a timer while the child runs, then marks `completed`/`failed` from the exit code. An idle agent **cannot** heartbeat - set-once + manual update decays to amber. +### MCP (manual path when the child is not CLI-supervised) + +Tool name: `session_job` (Claude: `mcp__hapi__session_job`; Codex: `functions.hapi__session_job`; OpenCode/ACP: `hapi_session_job`). + +```json +{ "action": "set", "jobKey": "beets", "label": "beets import", + "remaining": 150, "done": 1637, "total": 1787, "unit": "units" } +``` + +Then `action=update` every ~10 minutes; finish with `status=completed|failed` or `action=clear`. Omit `sessionId` to target this chat. + ### CLI manual path ```bash From 8ff6c942f8ec38ef183db272627f2f822575309a Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:47:16 +0000 Subject: [PATCH 19/99] fix(jobs): typecheck-safe assertion for run startedAt Co-authored-by: Cursor --- cli/src/modules/sessionJob/runSessionJob.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index 9b70ebc6a2..4f78dceb3c 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -74,10 +74,10 @@ describe('runSessionJob', () => { }) await vi.waitFor(() => expect(http.put).toHaveBeenCalled()) - const putBody = http.put.mock.calls[0]?.[1] as { startedAt?: number; status?: string } - expect(putBody.status).toBe('running') - expect(typeof putBody.startedAt).toBe('number') - expect(putBody.startedAt).toBeGreaterThan(0) + expect(http.put.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + status: 'running', + startedAt: expect.any(Number) + })) expect(http.post).toHaveBeenCalledTimes(1) expect(http.get).toHaveBeenCalledTimes(1) From b409383524a72f03200b2ff76a4e8fe4eb094fae Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:48:10 +0000 Subject: [PATCH 20/99] fix(jobs): type put mock args so startedAt assertion typechecks Co-authored-by: Cursor --- cli/src/modules/sessionJob/runSessionJob.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index 4f78dceb3c..a1bcc6acd0 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -26,15 +26,15 @@ describe('runSessionJob', () => { status: 200, data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } })), - put: vi.fn(async () => ({ + put: vi.fn(async (_url: string, body: { status?: string; startedAt?: number }) => ({ status: 200, data: { job: { key: 'drain', label: 'drain', - status: 'running', + status: body.status ?? 'running', heartbeatAt: 1, - startedAt: 1, + startedAt: body.startedAt ?? 1, updatedAt: 1 } } From f497545e6b573e42f9e6f1434111a474d43a7152 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:49:32 +0000 Subject: [PATCH 21/99] =?UTF-8?q?fix(jobs):=20address=20Codex=20Majors=20?= =?UTF-8?q?=E2=80=94=20redirect=20ancestry,=20heartbeat=20status,=20ACP=20?= =?UTF-8?q?allowlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inherit jobsAcceptedFromSessionIds across A→B→C merges; heartbeat PATCH omits status so late ticks cannot resurrect running; drop session_job from global name-only auto-approve (bridge/--allowedTools remain the trust path). Co-authored-by: Cursor --- .../permission/BasePermissionHandler.test.ts | 5 ++- .../permission/BasePermissionHandler.ts | 14 +++---- .../modules/sessionJob/runSessionJob.test.ts | 5 ++- cli/src/modules/sessionJob/runSessionJob.ts | 7 ++-- hub/src/sync/sessionCache-merge-jobs.test.ts | 39 +++++++++++++++++++ hub/src/sync/sessionCache.ts | 19 ++++++++- 6 files changed, 71 insertions(+), 18 deletions(-) diff --git a/cli/src/modules/common/permission/BasePermissionHandler.test.ts b/cli/src/modules/common/permission/BasePermissionHandler.test.ts index 3e8ea63108..ebd73f8b37 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.test.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.test.ts @@ -108,7 +108,8 @@ describe('resolveToolAutoApprovalDecision session_job', () => { 'hapi_session_job', 'mcp__hapi__session_job', 'Session-Attached Job' - ])('auto-approves own-session job meter %s', (toolName) => { - expect(resolveToolAutoApprovalDecision('default', toolName, 'call-1')).toBe('approved') + ])('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 62bb54e75e..639b782feb 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -37,20 +37,16 @@ const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([ 'happy__list_peers', 'mcp__hapi__list_peers', // ACP permission requests often surface MCP tool title, not the snake_case name. - 'list peer sessions', - // Own-session progress meter (tiann/hapi#1404) — MCP schema has no sessionId; - // tool always targets this chat. Cross-session writes use CLI hapi job (not auto). - 'session_job', - 'hapi_session_job', - 'happy__session_job', - 'mcp__hapi__session_job', - 'session-attached job' + '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 / own-session session_job are 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/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index a1bcc6acd0..fe52cdda90 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -81,10 +81,13 @@ describe('runSessionJob', () => { expect(http.post).toHaveBeenCalledTimes(1) expect(http.get).toHaveBeenCalledTimes(1) - // Heartbeat ticks reuse resolved client (no extra auth). + // 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 heartbeatBody = http.patch.mock.calls[0]?.[1] as { status?: string } + expect(heartbeatBody.status).toBeUndefined() expect(http.post).toHaveBeenCalledTimes(1) expect(http.get).toHaveBeenCalledTimes(1) diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts index ceb7202b34..238ef301c0 100644 --- a/cli/src/modules/sessionJob/runSessionJob.ts +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -80,13 +80,12 @@ export async function runSessionJob(options: RunSessionJobOptions): 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. inflightHeartbeat = updateSessionJob({ ...clientOpts, jobKey: options.jobKey, - body: { - detail: options.detail, - status: 'running' - } + body: options.detail !== undefined ? { detail: options.detail } : {} }).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). diff --git a/hub/src/sync/sessionCache-merge-jobs.test.ts b/hub/src/sync/sessionCache-merge-jobs.test.ts index 5b814cb418..83b7c2c1c6 100644 --- a/hub/src/sync/sessionCache-merge-jobs.test.ts +++ b/hub/src/sync/sessionCache-merge-jobs.test.ts @@ -73,6 +73,45 @@ describe('mergeSessions job redirect through SessionCache (#1404)', () => { expect(cache.resolveAttachedJobSessionId(newSession.id, 'default')).toBe(newSession.id) }) + it('preserves A→B→C jobsAccepted ancestry so deleted A still resolves on C', async () => { + const { store, cache } = setup() + const a = cache.getOrCreateSession( + 'agent-jobs-a-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + const b = cache.getOrCreateSession( + 'agent-jobs-b-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + const c = cache.getOrCreateSession( + 'agent-jobs-c-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + store.sessionJobs.upsert(a.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 9 + }) + await cache.mergeSessions(a.id, b.id, 'default') + expect(cache.resolveAttachedJobSessionId(a.id, 'default')).toBe(b.id) + + await cache.mergeSessions(b.id, c.id, 'default') + const refreshed = cache.refreshSession(c.id) + expect(refreshed?.metadata?.jobsAcceptedFromSessionIds).toEqual( + expect.arrayContaining([a.id, b.id]) + ) + expect(cache.resolveAttachedJobSessionId(a.id, 'default')).toBe(c.id) + expect(cache.resolveAttachedJobSessionId(b.id, 'default')).toBe(c.id) + expect(store.sessionJobs.getPrimaryRunning(c.id)?.key).toBe('beets') + }) + it('keeps jobsAcceptedFromSessionIds when metadata merge also copies name from old', async () => { const { store, cache } = setup() const oldSession = cache.getOrCreateSession( diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 3f23e8c3c5..ed653fe3f9 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1554,8 +1554,23 @@ export class SessionCache { const prev = Array.isArray(meta.jobsAcceptedFromSessionIds) ? meta.jobsAcceptedFromSessionIds.filter((id): id is string => typeof id === 'string') : [] - if (prev.includes(fromSessionId)) return - meta.jobsAcceptedFromSessionIds = [...prev, fromSessionId] + // Preserve A→B→C ancestry: when B already accepted jobs from A and + // now merges into C, clients still holding A's HAPI_SESSION_ID must + // resolve through C after B is deleted. + const inheritedRaw = this.store.sessions + .getSessionByNamespace(fromSessionId, namespace) + ?.metadata?.jobsAcceptedFromSessionIds + const inherited = Array.isArray(inheritedRaw) + ? inheritedRaw.filter((id): id is string => typeof id === 'string') + : [] + const next = [...new Set([...prev, ...inherited, fromSessionId])] + if ( + next.length === prev.length + && next.every((id) => prev.includes(id)) + ) { + return + } + meta.jobsAcceptedFromSessionIds = next const result = this.store.sessions.updateSessionMetadata( toSessionId, meta, From f274d1a6e7abf6c9d346457ad4bbb6397bd8619e Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:49:49 +0000 Subject: [PATCH 22/99] fix(jobs): type from-session metadata when inheriting job redirects Co-authored-by: Cursor --- hub/src/sync/sessionCache.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index ed653fe3f9..76bc164a38 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1557,9 +1557,10 @@ export class SessionCache { // Preserve A→B→C ancestry: when B already accepted jobs from A and // now merges into C, clients still holding A's HAPI_SESSION_ID must // resolve through C after B is deleted. - const inheritedRaw = this.store.sessions + const fromMeta = this.store.sessions .getSessionByNamespace(fromSessionId, namespace) - ?.metadata?.jobsAcceptedFromSessionIds + ?.metadata as Record | null | undefined + const inheritedRaw = fromMeta?.jobsAcceptedFromSessionIds const inherited = Array.isArray(inheritedRaw) ? inheritedRaw.filter((id): id is string => typeof id === 'string') : [] From bd34c827b03c8a1baa92b4ee19374048bd1bf710 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:58:42 +0000 Subject: [PATCH 23/99] fix(jobs): version attachedJob SSE; keep live job on merge collision Gate list cache on versioned attachedJob patches so dual EventSources cannot resurrect a cleared meter. On transfer key collision, prefer a running source over a terminal target (heartbeats no longer rewrite status). Co-authored-by: Cursor --- hub/src/store/migration-v23.test.ts | 24 +++++++++++++++++++ hub/src/store/sessionJobs.ts | 23 ++++++++++++++++-- hub/src/sync/sessionCache.ts | 6 ++++- shared/src/schemas.sessionPatch.test.ts | 13 +++++++--- shared/src/schemas.ts | 13 +++++++--- shared/src/sessionSummary.test.ts | 2 ++ shared/src/sessionSummary.ts | 3 +++ .../SessionAttentionIndicator.test.tsx | 1 + .../SessionList.directory-action.test.tsx | 1 + .../SessionList.machine-filter.test.tsx | 1 + web/src/components/SessionList.test.ts | 1 + web/src/hooks/useSSE.test.ts | 1 + web/src/hooks/useSSE.ts | 17 ++++++++++--- web/src/lib/sessionAttention.test.ts | 1 + web/src/lib/sessionReference.test.ts | 1 + 15 files changed, 96 insertions(+), 12 deletions(-) diff --git a/hub/src/store/migration-v23.test.ts b/hub/src/store/migration-v23.test.ts index 2705a3c738..7506a15175 100644 --- a/hub/src/store/migration-v23.test.ts +++ b/hub/src/store/migration-v23.test.ts @@ -46,4 +46,28 @@ describe('schema migration v22 to v27', () => { expect(version.user_version).toBe(27) migrated.close() }) + + it('on key collision keeps a running source over a terminal target', () => { + const store = new Store(':memory:') + const from = store.sessions.getOrCreateSession('from', { path: '/a' }, null, 'default') + const to = store.sessions.getOrCreateSession('to', { path: '/b' }, null, 'default') + store.sessionJobs.upsert(to.id, 'beets', { + label: 'stale', + status: 'completed', + remaining: 0 + }, 1_000) + store.sessionJobs.upsert(from.id, 'beets', { + label: 'live', + status: 'running', + remaining: 3 + }, 2_000) + const result = store.sessionJobs.transfer(from.id, to.id) + expect(result.collided).toBe(1) + expect(result.moved).toBe(1) + const primary = store.sessionJobs.getPrimaryRunning(to.id) + expect(primary?.label).toBe('live') + expect(primary?.status).toBe('running') + expect(store.sessionJobs.list(from.id)).toHaveLength(0) + store.close() + }) }) diff --git a/hub/src/store/sessionJobs.ts b/hub/src/store/sessionJobs.ts index 644fc9415a..098444189f 100644 --- a/hub/src/store/sessionJobs.ts +++ b/hub/src/store/sessionJobs.ts @@ -249,6 +249,9 @@ export function transferSessionJobs( fromSessionId: string, toSessionId: string ): { moved: number; collided: number } { + if (fromSessionId === toSessionId) { + return { moved: 0, collided: 0 } + } const rows = listSessionJobs(db, fromSessionId) let moved = 0 let collided = 0 @@ -256,8 +259,24 @@ export function transferSessionJobs( for (const job of rows) { const existing = getSessionJob(db, toSessionId, job.key) if (existing) { - db.prepare('DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?') - .run(fromSessionId, job.key) + // Prefer a live source over a terminal target (or newer stamp). + // Redirected heartbeats omit status, so discarding a running source + // cannot be repaired by a later heartbeat. + const sourceWins = + (job.status === 'running' && existing.status !== 'running') + || (job.status === existing.status && job.updatedAt > existing.updatedAt) + if (sourceWins) { + db.prepare('DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?') + .run(toSessionId, job.key) + db.prepare( + `UPDATE session_jobs SET session_id = ? + WHERE session_id = ? AND job_key = ?` + ).run(toSessionId, fromSessionId, job.key) + moved += 1 + } else { + db.prepare('DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?') + .run(fromSessionId, job.key) + } collided += 1 continue } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 76bc164a38..9a846e9a8e 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -764,11 +764,15 @@ export class SessionCache { const namespace = cached?.namespace ?? this.store.sessions.getSession(sessionId)?.namespace if (!namespace) return + // Clear uses wall clock so it outranks any in-flight heartbeat stamp. + const version = attachedJob?.updatedAt ?? Date.now() this.publisher.emit({ type: 'session-updated', sessionId, namespace, - data: { attachedJob } satisfies SessionPatch + data: { + attachedJob: { version, value: attachedJob } + } satisfies SessionPatch }) } diff --git a/shared/src/schemas.sessionPatch.test.ts b/shared/src/schemas.sessionPatch.test.ts index 6515ea5c66..4801a53038 100644 --- a/shared/src/schemas.sessionPatch.test.ts +++ b/shared/src/schemas.sessionPatch.test.ts @@ -102,7 +102,7 @@ describe('SessionPatchSchema structured patches (closes #884 follow-up)', () => expect(SessionPatchSchema.safeParse(fullSession).success).toBe(false); }); - it('accepts attachedJob payload or null (tiann/hapi#1404)', () => { + it('accepts versioned attachedJob payload or null (tiann/hapi#1404)', () => { const job = AttachedJobSchema.parse({ key: 'beets', label: 'beets import', @@ -114,8 +114,15 @@ describe('SessionPatchSchema structured patches (closes #884 follow-up)', () => startedAt: 1_000, updatedAt: 2_000 }) - expect(SessionPatchSchema.safeParse({ attachedJob: job }).success).toBe(true) - expect(SessionPatchSchema.safeParse({ attachedJob: null }).success).toBe(true) + expect(SessionPatchSchema.safeParse({ + attachedJob: { version: job.updatedAt, value: job } + }).success).toBe(true) + expect(SessionPatchSchema.safeParse({ + attachedJob: { version: 3_000, value: null } + }).success).toBe(true) + // Bare job / bare null are rejected — dual-SSE needs a watermark. + expect(SessionPatchSchema.safeParse({ attachedJob: job }).success).toBe(false) + expect(SessionPatchSchema.safeParse({ attachedJob: null }).success).toBe(false) }); it('rejects fake percent-only attached jobs without counters', () => { diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 52d32adb9e..ea03d7e2d0 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -453,6 +453,14 @@ export const AttachedJobPatchSchema = z.object({ export type AttachedJobPatch = z.infer +// Dual SSE (global + per-session) has no shared delivery order. Version = +// job.updatedAt for a live primary, or Date.now() when cleared to null, so a +// lagged running heartbeat cannot resurrect a finished meter. +const VersionedAttachedJobPatchSchema = z.object({ + version: z.number(), + value: AttachedJobSchema.nullable() +}) + export const SessionPatchSchema = z.object({ active: z.boolean().optional(), thinking: z.boolean().optional(), @@ -488,9 +496,8 @@ export const SessionPatchSchema = z.object({ scratchlistUpdatedAt: z.number().optional(), // tiann/hapi#1404 — session-attached long-running jobs. Unlike // scratchlist (watermark → refetch), the list row needs the progress - // payload inline, so patches carry the primary running job (or null - // when cleared / none remain). - attachedJob: AttachedJobSchema.nullable().optional() + // payload inline. Versioned like todos so dual-SSE reorder is safe. + attachedJob: VersionedAttachedJobPatchSchema.optional() }).strict() export type SessionPatch = z.infer diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index c2a7f0b2b4..eb6f5b7ead 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -343,10 +343,12 @@ describe('summary derivation helpers', () => { } const summary = toSessionSummary(makeSession(), { attachedJob: job }) expect(summary.attachedJob).toEqual(job) + expect(summary.attachedJobUpdatedAt).toBe(job.updatedAt) }) it('defaults attachedJob to null', () => { expect(toSessionSummary(makeSession()).attachedJob).toBeNull() + expect(toSessionSummary(makeSession()).attachedJobUpdatedAt).toBe(0) }) it('toSessionSummaryMetadata returns null for null metadata', () => { diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 6ded7a20d1..1684664abd 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -77,6 +77,8 @@ export type SessionSummary = { * Independent of agent `active` / thinking — work that outlives the agent. */ attachedJob: AttachedJob | null + /** Watermark for versioned `attachedJob` SSE patches (dual EventSource race). */ + attachedJobUpdatedAt: number model: string | null modelReasoningEffort?: string | null effort: string | null @@ -233,6 +235,7 @@ export function toSessionSummary( futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: extras?.attachedJob ?? null, + attachedJobUpdatedAt: extras?.attachedJob?.updatedAt ?? 0, model: session.model, modelReasoningEffort: session.modelReasoningEffort, effort: session.effort diff --git a/web/src/components/SessionAttentionIndicator.test.tsx b/web/src/components/SessionAttentionIndicator.test.tsx index bbf540153c..eaa0654355 100644 --- a/web/src/components/SessionAttentionIndicator.test.tsx +++ b/web/src/components/SessionAttentionIndicator.test.tsx @@ -30,6 +30,7 @@ function makeSummary(overrides: Partial & { id: string }): Sessi futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index 3b5cb89be1..278374d6f6 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -35,6 +35,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.machine-filter.test.tsx b/web/src/components/SessionList.machine-filter.test.tsx index 4fc1477d44..587ede6166 100644 --- a/web/src/components/SessionList.machine-filter.test.tsx +++ b/web/src/components/SessionList.machine-filter.test.tsx @@ -30,6 +30,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index 1c37f67fd1..f3689fc119 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -43,6 +43,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides diff --git a/web/src/hooks/useSSE.test.ts b/web/src/hooks/useSSE.test.ts index 3a6592e631..f849241eb2 100644 --- a/web/src/hooks/useSSE.test.ts +++ b/web/src/hooks/useSSE.test.ts @@ -180,6 +180,7 @@ function makeSummary(overrides: Partial = {}): SessionSummary { futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 926705726d..443b6d1196 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -170,6 +170,7 @@ export function isRenderIrrelevantPatch(current: SessionSummary, next: SessionSu && current.attachedJob?.heartbeatAt === next.attachedJob?.heartbeatAt && current.attachedJob?.startedAt === next.attachedJob?.startedAt && (current.attachedJob == null) === (next.attachedJob == null) + && (current.attachedJobUpdatedAt ?? 0) === (next.attachedJobUpdatedAt ?? 0) && current.model === next.model && current.modelReasoningEffort === next.modelReasoningEffort && current.effort === next.effort @@ -499,6 +500,7 @@ export function useSSE(options: { const summary = { ...toSessionSummary(session), attachedJob: existing?.attachedJob ?? null, + attachedJobUpdatedAt: existing?.attachedJobUpdatedAt ?? 0, futureScheduledMessageCount: existing?.futureScheduledMessageCount ?? 0, nextScheduledAt: existing?.nextScheduledAt ?? null } @@ -544,9 +546,8 @@ export function useSSE(options: { backgroundTaskCount: Object.prototype.hasOwnProperty.call(patch, 'backgroundTaskCount') ? patch.backgroundTaskCount ?? 0 : current.backgroundTaskCount, - attachedJob: Object.prototype.hasOwnProperty.call(patch, 'attachedJob') - ? patch.attachedJob ?? null - : current.attachedJob ?? null, + attachedJob: current.attachedJob ?? null, + attachedJobUpdatedAt: current.attachedJobUpdatedAt ?? 0, model: Object.prototype.hasOwnProperty.call(patch, 'model') ? patch.model ?? null : current.model, modelReasoningEffort: Object.prototype.hasOwnProperty.call(patch, 'modelReasoningEffort') ? patch.modelReasoningEffort ?? null @@ -572,6 +573,16 @@ export function useSSE(options: { nextSummary.metadata = toSessionSummaryMetadata(patch.metadata.value) nextSummary.metadataVersion = patch.metadata.version } + if ( + patch.attachedJob !== undefined + && isNewerVersionedPatch( + patch.attachedJob.version, + current.attachedJobUpdatedAt ?? 0 + ) + ) { + nextSummary.attachedJob = patch.attachedJob.value + nextSummary.attachedJobUpdatedAt = patch.attachedJob.version + } patched = true // The keep-alive patch repeats every field every ~10s per active diff --git a/web/src/lib/sessionAttention.test.ts b/web/src/lib/sessionAttention.test.ts index a503ac98a9..690475641b 100644 --- a/web/src/lib/sessionAttention.test.ts +++ b/web/src/lib/sessionAttention.test.ts @@ -23,6 +23,7 @@ function makeSummary(overrides: Partial & { id: string }): Sessi futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides diff --git a/web/src/lib/sessionReference.test.ts b/web/src/lib/sessionReference.test.ts index 407f296646..58bdd3ab57 100644 --- a/web/src/lib/sessionReference.test.ts +++ b/web/src/lib/sessionReference.test.ts @@ -29,6 +29,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides, From 87c7ea64cc86fe626375adcd2d04cbc5e4a71c01 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:08:23 +0000 Subject: [PATCH 24/99] fix(jobs): monotonic attachedJob emit watermark (not primary.updatedAt) Primary switches can go backwards in updatedAt and strand the list cache. Emit versions are Math.max(Date.now(), prev+1); REST list stamps a fresh wall-clock watermark so null jobs do not reset the client gate to 0. Co-authored-by: Cursor --- hub/src/sync/sessionCache.ts | 9 +++++++-- hub/src/web/routes/sessions.ts | 6 +++++- shared/src/schemas.ts | 6 +++--- shared/src/sessionSummary.ts | 14 +++++++++++--- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 9a846e9a8e..2421864264 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -756,6 +756,10 @@ export class SessionCache { * tiann/hapi#1404 — emit primary attached job (or null) so session-list * caches update inline without a dedicated refetch. */ + /** Monotonic emit watermark per session — never follows primary.updatedAt + * (primary switches can go backwards and would strand the web cache). */ + private attachedJobEmitVersion = new Map() + emitAttachedJobChanged( sessionId: string, attachedJob: import('@hapi/protocol').AttachedJob | null @@ -764,8 +768,9 @@ export class SessionCache { const namespace = cached?.namespace ?? this.store.sessions.getSession(sessionId)?.namespace if (!namespace) return - // Clear uses wall clock so it outranks any in-flight heartbeat stamp. - const version = attachedJob?.updatedAt ?? Date.now() + const prev = this.attachedJobEmitVersion.get(sessionId) ?? 0 + const version = Math.max(Date.now(), prev + 1) + this.attachedJobEmitVersion.set(sessionId, version) this.publisher.emit({ type: 'session-updated', sessionId, diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 74ff6508fe..14608f0579 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -121,9 +121,13 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho const scheduledCounts = engine.getFutureScheduledMessageCounts(sessionRecords.map((session) => session.id)) const nextScheduledAt = engine.getNextScheduledAtBySessionIds(sessionRecords.map((session) => session.id)) const attachedJobs = engine.getPrimaryAttachedJobsBySessionIds(sessionRecords.map((session) => session.id)) + // Fresh wall-clock watermark so a REST refetch never resets the client + // SSE gate to 0 (which would let a lagged running patch resurrect). + const listJobWatermark = Date.now() const sessions = sessionRecords.map((session) => { const summary = toSessionSummary(session, { - attachedJob: attachedJobs.get(session.id) ?? null + attachedJob: attachedJobs.get(session.id) ?? null, + attachedJobUpdatedAt: listJobWatermark }) return { ...summary, diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index ea03d7e2d0..5371df4137 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -453,9 +453,9 @@ export const AttachedJobPatchSchema = z.object({ export type AttachedJobPatch = z.infer -// Dual SSE (global + per-session) has no shared delivery order. Version = -// job.updatedAt for a live primary, or Date.now() when cleared to null, so a -// lagged running heartbeat cannot resurrect a finished meter. +// Dual SSE (global + per-session) has no shared delivery order. Version is a +// monotonic per-session emit watermark (not primary.updatedAt — primary +// switches can go backwards). Lagged heartbeats cannot resurrect a clear. const VersionedAttachedJobPatchSchema = z.object({ version: z.number(), value: AttachedJobSchema.nullable() diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 1684664abd..efa9d4d5ad 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -212,8 +212,14 @@ export function toSessionSummaryMetadata(metadata: Metadata | null | undefined): export function toSessionSummary( session: Session, - extras?: { attachedJob?: AttachedJob | null } + extras?: { + attachedJob?: AttachedJob | null + /** Explicit SSE/list watermark; required when attachedJob is null so + * a REST refetch does not reset the client gate to 0. */ + attachedJobUpdatedAt?: number + } ): SessionSummary { + const attachedJob = extras?.attachedJob ?? null return { hasConversationContent: session.hasConversationContent ?? false, id: session.id, @@ -234,8 +240,10 @@ export function toSessionSummary( backgroundTaskCount: session.backgroundTaskCount ?? 0, futureScheduledMessageCount: 0, nextScheduledAt: null, - attachedJob: extras?.attachedJob ?? null, - attachedJobUpdatedAt: extras?.attachedJob?.updatedAt ?? 0, + attachedJob, + attachedJobUpdatedAt: extras?.attachedJobUpdatedAt + ?? attachedJob?.updatedAt + ?? 0, model: session.model, modelReasoningEffort: session.modelReasoningEffort, effort: session.effort From 4f0c8e1d32f549327de82b4ca15d508744682b10 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:18:59 +0000 Subject: [PATCH 25/99] fix(jobs): one attachedJob watermark allocator for REST and SSE GET /sessions and emitAttachedJobChanged share allocateAttachedJobVersion so equal-ms terminal patches are not rejected after a list refetch. Also preserve real Unix signal exit codes in hapi job run. Co-authored-by: Cursor --- cli/src/modules/sessionJob/runSessionJob.ts | 4 +++- hub/src/sync/sessionCache.ts | 15 ++++++++++----- hub/src/sync/syncEngine.ts | 5 +++++ hub/src/web/routes/sessions.ts | 6 ++---- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts index 238ef301c0..512c607e68 100644 --- a/cli/src/modules/sessionJob/runSessionJob.ts +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -4,6 +4,7 @@ */ import { spawn, type ChildProcess } from 'node:child_process' +import { constants as osConstants } from 'node:os' import type { AttachedJobUpsert } from '@hapi/protocol' import { SessionJobError, @@ -131,7 +132,8 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise { clearIntervalFn(heartbeat) if (signal) { - resolve(128 + (signal === 'SIGINT' ? 2 : signal === 'SIGTERM' ? 15 : 1)) + const signalNumber = osConstants.signals[signal] ?? 1 + resolve(128 + signalNumber) return } resolve(code ?? 1) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 2421864264..784dc2b59a 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -756,10 +756,17 @@ export class SessionCache { * tiann/hapi#1404 — emit primary attached job (or null) so session-list * caches update inline without a dedicated refetch. */ - /** Monotonic emit watermark per session — never follows primary.updatedAt - * (primary switches can go backwards and would strand the web cache). */ + /** Monotonic watermark per session — shared by REST list snapshots and SSE + * emits so equal-ms terminal patches are not rejected after a refetch. */ private attachedJobEmitVersion = new Map() + allocateAttachedJobVersion(sessionId: string): number { + const prev = this.attachedJobEmitVersion.get(sessionId) ?? 0 + const version = Math.max(Date.now(), prev + 1) + this.attachedJobEmitVersion.set(sessionId, version) + return version + } + emitAttachedJobChanged( sessionId: string, attachedJob: import('@hapi/protocol').AttachedJob | null @@ -768,9 +775,7 @@ export class SessionCache { const namespace = cached?.namespace ?? this.store.sessions.getSession(sessionId)?.namespace if (!namespace) return - const prev = this.attachedJobEmitVersion.get(sessionId) ?? 0 - const version = Math.max(Date.now(), prev + 1) - this.attachedJobEmitVersion.set(sessionId, version) + const version = this.allocateAttachedJobVersion(sessionId) this.publisher.emit({ type: 'session-updated', sessionId, diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 871ac38cd1..40149fde7a 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -817,6 +817,11 @@ export class SyncEngine { return this.store.sessionJobs.getPrimaryRunningBySessionIds(sessionIds) } + /** Shared REST/SSE watermark allocator for attachedJob patches. */ + allocateAttachedJobVersion(sessionId: string): number { + return this.sessionCache.allocateAttachedJobVersion(sessionId) + } + upsertSessionJob( sessionId: string, jobKey: string, diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 14608f0579..d5d81beb68 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -121,13 +121,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho const scheduledCounts = engine.getFutureScheduledMessageCounts(sessionRecords.map((session) => session.id)) const nextScheduledAt = engine.getNextScheduledAtBySessionIds(sessionRecords.map((session) => session.id)) const attachedJobs = engine.getPrimaryAttachedJobsBySessionIds(sessionRecords.map((session) => session.id)) - // Fresh wall-clock watermark so a REST refetch never resets the client - // SSE gate to 0 (which would let a lagged running patch resurrect). - const listJobWatermark = Date.now() const sessions = sessionRecords.map((session) => { const summary = toSessionSummary(session, { attachedJob: attachedJobs.get(session.id) ?? null, - attachedJobUpdatedAt: listJobWatermark + // Same allocator as SSE emits — equal-ms terminal patches stay applyable. + attachedJobUpdatedAt: engine.allocateAttachedJobVersion(session.id) }) return { ...summary, From aefdda67778f26431ba436445f42975b2192c396 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:19:30 +0000 Subject: [PATCH 26/99] fix(jobs): stub allocateAttachedJobVersion in sessions route mocks Co-authored-by: Cursor --- hub/src/web/routes/sessions-jobs.test.ts | 7 +++++++ hub/src/web/routes/sessions.test.ts | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/hub/src/web/routes/sessions-jobs.test.ts b/hub/src/web/routes/sessions-jobs.test.ts index 8622a34de1..e8408b3797 100644 --- a/hub/src/web/routes/sessions-jobs.test.ts +++ b/hub/src/web/routes/sessions-jobs.test.ts @@ -48,6 +48,13 @@ describe('session-attached jobs routes (tiann/hapi#1404)', () => { return map }, getPrimaryAttachedJob: () => [...jobs.values()].find((j) => j.status === 'running') ?? null, + allocateAttachedJobVersion: (() => { + let n = 0 + return () => { + n += 1 + return Date.now() + n + } + })(), listSessionJobs: () => [...jobs.values()], upsertSessionJob: (_sid: string, key: string, body: AttachedJobUpsert) => { const now = Date.now() diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index c798a6de21..7eaae8164f 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -181,7 +181,8 @@ function createApp(session: Session, opts?: { rewindConversation: opts?.rewindConversation ?? (async () => ({ type: 'success' })), suggestSessionTitle: opts?.suggestSessionTitle ?? (async () => 'Generated title'), updateSessionSummary: opts?.updateSessionSummary ?? (async () => {}), - getPrimaryAttachedJobsBySessionIds: opts?.getPrimaryAttachedJobsBySessionIds ?? (() => new Map()) + getPrimaryAttachedJobsBySessionIds: opts?.getPrimaryAttachedJobsBySessionIds ?? (() => new Map()), + allocateAttachedJobVersion: opts?.allocateAttachedJobVersion ?? (() => Date.now()) } as Partial const app = new Hono() @@ -1703,6 +1704,7 @@ describe('sessions routes', () => { }, getNextScheduledAtBySessionIds: (_ids: string[]) => new Map(), getPrimaryAttachedJobsBySessionIds: () => new Map(), + allocateAttachedJobVersion: () => Date.now(), resolveSessionAccess: () => ({ ok: false, reason: 'not-found' as const }) } as unknown as Partial @@ -1736,6 +1738,7 @@ describe('sessions routes', () => { getFutureScheduledMessageCounts: (ids: string[]) => new Map(ids.map((id) => [id, 0])), getNextScheduledAtBySessionIds: (_ids: string[]) => new Map(), getPrimaryAttachedJobsBySessionIds: () => new Map(), + allocateAttachedJobVersion: () => Date.now(), resolveSessionAccess: () => ({ ok: false, reason: 'not-found' as const }) } as unknown as Partial From 3c118706fe8857db804c2180ab9435e81a316930 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:29:34 +0000 Subject: [PATCH 27/99] fix(jobs): always record merge redirects even with zero jobs transferred Agents may attach the first outliving job after merge while still holding the pre-merge HAPI_SESSION_ID; redirects must exist before that first set. Co-authored-by: Cursor --- hub/src/sync/sessionCache-merge-jobs.test.ts | 22 ++++++++++++++++++++ hub/src/sync/sessionCache.ts | 10 ++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/hub/src/sync/sessionCache-merge-jobs.test.ts b/hub/src/sync/sessionCache-merge-jobs.test.ts index 83b7c2c1c6..5f9dbddf0f 100644 --- a/hub/src/sync/sessionCache-merge-jobs.test.ts +++ b/hub/src/sync/sessionCache-merge-jobs.test.ts @@ -73,6 +73,28 @@ describe('mergeSessions job redirect through SessionCache (#1404)', () => { expect(cache.resolveAttachedJobSessionId(newSession.id, 'default')).toBe(newSession.id) }) + it('records job redirects even when the source has no jobs yet', async () => { + const { store, cache } = setup() + const { oldSession, newSession } = makeSessions(cache) + + await cache.mergeSessions(oldSession.id, newSession.id, 'default') + + expect(store.sessions.getSession(oldSession.id)).toBeNull() + const refreshed = cache.refreshSession(newSession.id) + expect(refreshed?.metadata?.jobsAcceptedFromSessionIds).toContain(oldSession.id) + expect(cache.resolveAttachedJobSessionId(oldSession.id, 'default')).toBe(newSession.id) + + // First job attach after merge still lands on the canonical session + // when the agent keeps the pre-merge HAPI_SESSION_ID. + const upserted = store.sessionJobs.upsert( + cache.resolveAttachedJobSessionId(oldSession.id, 'default')!, + 'late', + { label: 'late attach', status: 'running', remaining: 1 } + ) + expect(upserted.outcome).toBe('upserted') + expect(store.sessionJobs.getPrimaryRunning(newSession.id)?.key).toBe('late') + }) + it('preserves A→B→C jobsAccepted ancestry so deleted A still resolves on C', async () => { const { store, cache } = setup() const a = cache.getOrCreateSession( diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 784dc2b59a..9613ab1c9d 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1409,11 +1409,11 @@ export class SessionCache { // merge clobbers jobsAcceptedFromSessionIds when mergeSessionMetadata // rebuilds from the stale pre-merge newStored.metadata snapshot // (cold-review pass 3 Major — agents heartbeating $HAPI_SESSION_ID 404). - if (movedJobs.moved > 0 || movedJobs.collided > 0) { - this.recordJobsAcceptedFromSession(newSessionId, oldSessionId, namespace) - if (!options.deleteOldSession) { - this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) - } + // Always record redirects even when the source had zero jobs yet — the + // first post-merge set/update still uses retained $HAPI_SESSION_ID. + this.recordJobsAcceptedFromSession(newSessionId, oldSessionId, namespace) + if (!options.deleteOldSession) { + this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) } if (newStored.model === null && oldStored.model !== null) { From f612f0d76ab591c061557074a61a745ec74bbfc6 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:37:14 +0000 Subject: [PATCH 28/99] fix(jobs): install source redirect before merge awaits; allow empty update Point jobsTransferredToSession immediately after transfer so mid-merge heartbeats follow the new owner during scratchlist I/O. Empty CLI/MCP update bodies are heartbeat-only (hub stamps heartbeatAt). Co-authored-by: Cursor --- cli/src/commands/job.ts | 4 +--- cli/src/modules/sessionJob/sessionJobMcp.ts | 7 +------ hub/src/sync/sessionCache.ts | 17 ++++++++--------- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts index b91d189254..b3753a86da 100644 --- a/cli/src/commands/job.ts +++ b/cli/src/commands/job.ts @@ -358,9 +358,7 @@ export async function handleJobCommand(args: string[]): Promise { ...(parsed.unit !== undefined ? { unit: parsed.unit } : {}), ...(parsed.detail !== undefined ? { detail: parsed.detail } : {}) } - if (Object.keys(body).length === 0) { - throw new SessionJobError('bad_args', 'update requires at least one field') - } + // Empty body is a heartbeat-only update; hub stamps heartbeatAt. const result = await updateSessionJob({ sessionIdPrefix: parsed.sessionIdPrefix, jobKey: parsed.jobKey, diff --git a/cli/src/modules/sessionJob/sessionJobMcp.ts b/cli/src/modules/sessionJob/sessionJobMcp.ts index 7f689f89bd..e5119663b9 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.ts @@ -148,12 +148,7 @@ export async function handleSessionJobTool( ...(args.unit !== undefined ? { unit: args.unit } : {}), ...(args.detail !== undefined ? { detail: args.detail } : {}) } - if (Object.keys(body).length === 0) { - return { - text: 'update requires at least one of label/status/done/total/remaining/unit/detail', - isError: true - } - } + // Empty body is a heartbeat-only update; hub stamps heartbeatAt. const result = await updateSessionJob({ sessionIdPrefix, jobKey, body }) return { text: `updated ${formatJobLine(result.job)} on ${result.sessionId}`, diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 9613ab1c9d..ba3cefcd24 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1329,6 +1329,11 @@ export class SessionCache { // promise that scratchlist survives reloads. const movedScratchlist = this.store.scratchlist.transfer(oldSessionId, newSessionId) const movedJobs = this.store.sessionJobs.transfer(oldSessionId, newSessionId) + // Install the source→target redirect BEFORE any await below. Merge can + // spend time on scratchlist attachment I/O while the old session row + // still exists; without this pointer, retained $HAPI_SESSION_ID hits + // the emptied source and terminal PATCHes 404. + this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) if (movedJobs.moved > 0 || movedJobs.collided > 0) { this.emitAttachedJobChanged( newSessionId, @@ -1405,16 +1410,10 @@ export class SessionCache { } } - // Job-owner redirects AFTER metadata merge. Writing them before the - // merge clobbers jobsAcceptedFromSessionIds when mergeSessionMetadata - // rebuilds from the stale pre-merge newStored.metadata snapshot - // (cold-review pass 3 Major — agents heartbeating $HAPI_SESSION_ID 404). - // Always record redirects even when the source had zero jobs yet — the - // first post-merge set/update still uses retained $HAPI_SESSION_ID. + // Acceptor list AFTER metadata merge (writing before clobbers when + // mergeSessionMetadata rebuilds from the stale pre-merge snapshot). + // Source transfer pointer was installed immediately after job transfer. this.recordJobsAcceptedFromSession(newSessionId, oldSessionId, namespace) - if (!options.deleteOldSession) { - this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) - } if (newStored.model === null && oldStored.model !== null) { const updated = this.store.sessions.setSessionModel(newSessionId, oldStored.model, namespace, { From 8fd194edf976af3b28da8a494a8a239deea42b92 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:48:13 +0000 Subject: [PATCH 29/99] fix(jobs): expect empty MCP update as heartbeat Co-authored-by: Cursor --- cli/src/modules/sessionJob/sessionJobMcp.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/cli/src/modules/sessionJob/sessionJobMcp.test.ts b/cli/src/modules/sessionJob/sessionJobMcp.test.ts index b9a4fe95de..3fca228405 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.test.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.test.ts @@ -61,12 +61,20 @@ describe('sessionJobMcp', () => { expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/Own-session only/i) }) - it('rejects update with empty patch', async () => { + it('treats empty update as a heartbeat-only patch', async () => { + const { updateSessionJob } = await import('./sessionJob') const result = await handleSessionJobTool( { action: 'update', jobKey: 'beets' }, 'sid-1' ) - expect(result.isError).toBe(true) - expect(result.text).toMatch(/at least one/i) + expect(result.isError).toBe(false) + expect(result.text).toContain('updated') + expect(updateSessionJob).toHaveBeenCalledWith( + expect.objectContaining({ + sessionIdPrefix: 'sid-1', + jobKey: 'beets', + body: {} + }) + ) }) }) From 969c91fc7ed006c604e6a133b4f1564a80ce255f Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:51:23 +0000 Subject: [PATCH 30/99] fix(jobs): treat merge redirect metadata as hub-owned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI update-metadata must not forge or erase jobsAcceptedFromSessionIds / jobsTransferredToSessionId — same strip/restore as supersede/clear links. Co-authored-by: Cursor --- .../handlers/cli/sessionHandlers.test.ts | 30 +++++++++++++------ .../socket/handlers/cli/sessionHandlers.ts | 9 +++++- shared/src/schemas.ts | 1 + 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.test.ts b/hub/src/socket/handlers/cli/sessionHandlers.test.ts index cec001db17..c034bd9953 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.test.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.test.ts @@ -438,9 +438,14 @@ describe('cli session handlers', () => { expect(uuids).toEqual(['msg-1', 'msg-2']) }) - it.each(['supersededBySessionId', 'opencodeClearOperation'] as const)( + it.each([ + ['supersededBySessionId', 'foreign-session'], + ['opencodeClearOperation', { replacementSessionId: 'foreign-session', state: 'reserved', updatedAt: Date.now() }], + ['jobsAcceptedFromSessionIds', ['foreign-session']], + ['jobsTransferredToSessionId', 'foreign-session'], + ] as const)( 'ignores a forged hub-owned %s addition from CLI metadata', - (field) => { + (field, forged) => { const store = new Store(':memory:') const session = store.sessions.getOrCreateSession('forged-clear-link', { path: '/tmp/project' }, null, 'default') const socket = new FakeSocket() @@ -454,20 +459,21 @@ describe('cli session handlers', () => { expectedVersion: session.metadataVersion, metadata: { path: '/tmp/project', - [field]: field === 'supersededBySessionId' - ? 'foreign-session' - : { replacementSessionId: 'foreign-session', state: 'reserved', updatedAt: Date.now() } + [field]: forged } }, () => {}) expect(store.sessions.getSessionByNamespace(session.id, 'default')?.metadata).not.toHaveProperty(field) } ) - it('preserves existing hub-owned clear metadata across CLI metadata updates', () => { + it('preserves existing hub-owned clear and job-redirect metadata across CLI metadata updates', () => { const store = new Store(':memory:') const operation = { replacementSessionId: 'owned-target', state: 'completed', updatedAt: Date.now() } const session = store.sessions.getOrCreateSession('preserve-clear-link', { - supersededBySessionId: 'owned-target', opencodeClearOperation: operation + supersededBySessionId: 'owned-target', + opencodeClearOperation: operation, + jobsAcceptedFromSessionIds: ['old-session'], + jobsTransferredToSessionId: 'merge-target', }, null, 'default') const socket = new FakeSocket() registerSessionHandlers(socket as unknown as CliSocketWithData, { @@ -481,11 +487,17 @@ describe('cli session handlers', () => { metadata: { lifecycleState: 'archived', supersededBySessionId: 'forged-target', - opencodeClearOperation: { replacementSessionId: 'forged-target', state: 'reserved', updatedAt: 0 } + opencodeClearOperation: { replacementSessionId: 'forged-target', state: 'reserved', updatedAt: 0 }, + jobsAcceptedFromSessionIds: ['forged-session'], + jobsTransferredToSessionId: 'forged-target', } }, () => {}) expect(store.sessions.getSessionByNamespace(session.id, 'default')?.metadata).toMatchObject({ - supersededBySessionId: 'owned-target', opencodeClearOperation: operation, lifecycleState: 'archived' + supersededBySessionId: 'owned-target', + opencodeClearOperation: operation, + jobsAcceptedFromSessionIds: ['old-session'], + jobsTransferredToSessionId: 'merge-target', + lifecycleState: 'archived', }) }) }) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index dd1c24a463..89e90f2dfc 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -68,7 +68,14 @@ const updateStateSchema = z.object({ agentState: z.unknown().nullable() }) -const HUB_OWNED_METADATA_KEYS = ['supersededBySessionId', 'opencodeClearOperation'] as const +// Hub-only merge/clear links. CLI update-metadata must not forge or erase them +// (same strip/restore as supersededBySessionId — see sessionHandlers.test.ts). +const HUB_OWNED_METADATA_KEYS = [ + 'supersededBySessionId', + 'opencodeClearOperation', + 'jobsAcceptedFromSessionIds', + 'jobsTransferredToSessionId', +] as const function preserveHubOwnedMetadata(incoming: unknown, current: unknown): unknown { if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return incoming diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 5371df4137..71edcd1fef 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -139,6 +139,7 @@ export const MetadataSchema = z.object({ // pre-merge $HAPI_SESSION_ID), and a kept-alive source points at the // post-merge owner. Must be declared here — SessionCache.refreshSession // parses via MetadataSchema and strips unknown keys (tiann/hapi#1404). + // Hub-owned: CLI update-metadata cannot forge/erase (HUB_OWNED_METADATA_KEYS). jobsAcceptedFromSessionIds: z.array(z.string()).optional(), jobsTransferredToSessionId: z.string().optional(), // Durable in-progress state for runner-backed OpenCode /clear. From 5f9e9b7901ce0582d3ddc3db3601be4144b54750 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:03:36 +0000 Subject: [PATCH 31/99] fix(jobs): reconcile REST list against attachedJob watermark Slow /api/sessions can finish after SSE clear/progress and resurrect a stale attachedJob. Also prefer newer terminal results on merge key collisions. Co-authored-by: Cursor --- hub/src/store/migration-v23.test.ts | 25 +++++ hub/src/store/sessionJobs.ts | 14 ++- .../queries/reconcileAttachedJobs.test.ts | 101 ++++++++++++++++++ .../hooks/queries/reconcileAttachedJobs.ts | 34 ++++++ web/src/hooks/queries/useSessions.ts | 10 +- 5 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 web/src/hooks/queries/reconcileAttachedJobs.test.ts create mode 100644 web/src/hooks/queries/reconcileAttachedJobs.ts diff --git a/hub/src/store/migration-v23.test.ts b/hub/src/store/migration-v23.test.ts index 7506a15175..8e22b13744 100644 --- a/hub/src/store/migration-v23.test.ts +++ b/hub/src/store/migration-v23.test.ts @@ -70,4 +70,29 @@ describe('schema migration v22 to v27', () => { expect(store.sessionJobs.list(from.id)).toHaveLength(0) store.close() }) + + it('on key collision prefers a newer terminal source over an older terminal target', () => { + const store = new Store(':memory:') + const from = store.sessions.getOrCreateSession('from-term', { path: '/a' }, null, 'default') + const to = store.sessions.getOrCreateSession('to-term', { path: '/b' }, null, 'default') + store.sessionJobs.upsert(to.id, 'beets', { + label: 'old-complete', + status: 'completed', + remaining: 0 + }, 1_000) + store.sessionJobs.upsert(from.id, 'beets', { + label: 'new-fail', + status: 'failed', + remaining: 0 + }, 2_000) + const result = store.sessionJobs.transfer(from.id, to.id) + expect(result.collided).toBe(1) + expect(result.moved).toBe(1) + const kept = store.sessionJobs.list(to.id) + expect(kept).toHaveLength(1) + expect(kept[0]?.label).toBe('new-fail') + expect(kept[0]?.status).toBe('failed') + expect(store.sessionJobs.list(from.id)).toHaveLength(0) + store.close() + }) }) diff --git a/hub/src/store/sessionJobs.ts b/hub/src/store/sessionJobs.ts index 098444189f..88f32d2219 100644 --- a/hub/src/store/sessionJobs.ts +++ b/hub/src/store/sessionJobs.ts @@ -259,12 +259,16 @@ export function transferSessionJobs( for (const job of rows) { const existing = getSessionJob(db, toSessionId, job.key) if (existing) { - // Prefer a live source over a terminal target (or newer stamp). - // Redirected heartbeats omit status, so discarding a running source - // cannot be repaired by a later heartbeat. + // Prefer a live source over a terminal target. When both are + // running or both terminal (incl. completed vs failed), prefer + // the newer updatedAt — otherwise a later terminal result loses + // to an older one with a different status. Redirected heartbeats + // omit status, so discarding a running source cannot be repaired. + const sourceRunning = job.status === 'running' + const targetRunning = existing.status === 'running' const sourceWins = - (job.status === 'running' && existing.status !== 'running') - || (job.status === existing.status && job.updatedAt > existing.updatedAt) + (sourceRunning && !targetRunning) + || (sourceRunning === targetRunning && job.updatedAt > existing.updatedAt) if (sourceWins) { db.prepare('DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?') .run(toSessionId, job.key) diff --git a/web/src/hooks/queries/reconcileAttachedJobs.test.ts b/web/src/hooks/queries/reconcileAttachedJobs.test.ts new file mode 100644 index 0000000000..66c0a18b9d --- /dev/null +++ b/web/src/hooks/queries/reconcileAttachedJobs.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import type { SessionSummary, SessionsResponse } from '@/types/api' +import { reconcileAttachedJobsFromCache } from './reconcileAttachedJobs' + +function makeSummary(overrides: Partial & { id: string }): SessionSummary { + return { + active: false, + thinking: false, + activeAt: 0, + updatedAt: 0, + metadata: null, + metadataVersion: 0, + agentStateVersion: 0, + todosUpdatedAt: 0, + todoProgress: null, + pendingRequestsCount: 0, + pendingRequestKinds: [], + pendingRequests: [], + backgroundTaskCount: 0, + futureScheduledMessageCount: 0, + nextScheduledAt: null, + attachedJob: null, + attachedJobUpdatedAt: 0, + model: null, + effort: null, + ...overrides, + } +} + +describe('reconcileAttachedJobsFromCache', () => { + it('keeps a newer cached clear over a stale REST snapshot that still has the job', () => { + const id = 'sess-1' + const cached: SessionsResponse = { + sessions: [ + makeSummary({ + id, + attachedJob: null, + attachedJobUpdatedAt: 20, + }), + ], + } + const fetched: SessionsResponse = { + sessions: [ + makeSummary({ + id, + attachedJob: { + key: 'batch', + label: 'batch', + status: 'running', + startedAt: 1, + heartbeatAt: 1, + updatedAt: 10, + }, + attachedJobUpdatedAt: 10, + }), + ], + } + const next = reconcileAttachedJobsFromCache(fetched, cached) + expect(next.sessions[0]?.attachedJob).toBeNull() + expect(next.sessions[0]?.attachedJobUpdatedAt).toBe(20) + }) + + it('accepts a fresher REST snapshot', () => { + const id = 'sess-1' + const cached: SessionsResponse = { + sessions: [ + makeSummary({ + id, + attachedJob: { + key: 'batch', + label: 'batch', + status: 'running', + startedAt: 1, + heartbeatAt: 1, + updatedAt: 10, + }, + attachedJobUpdatedAt: 10, + }), + ], + } + const fetched: SessionsResponse = { + sessions: [ + makeSummary({ + id, + attachedJob: null, + attachedJobUpdatedAt: 30, + }), + ], + } + const next = reconcileAttachedJobsFromCache(fetched, cached) + expect(next.sessions[0]?.attachedJob).toBeNull() + expect(next.sessions[0]?.attachedJobUpdatedAt).toBe(30) + }) + + it('passes through when there is no cache', () => { + const fetched: SessionsResponse = { + sessions: [makeSummary({ id: 'a', attachedJobUpdatedAt: 1 })], + } + expect(reconcileAttachedJobsFromCache(fetched, undefined)).toBe(fetched) + }) +}) diff --git a/web/src/hooks/queries/reconcileAttachedJobs.ts b/web/src/hooks/queries/reconcileAttachedJobs.ts new file mode 100644 index 0000000000..372a311cbb --- /dev/null +++ b/web/src/hooks/queries/reconcileAttachedJobs.ts @@ -0,0 +1,34 @@ +import type { SessionSummary, SessionsResponse } from '@/types/api' + +/** + * Keep a fresher attachedJob from an in-flight SSE cache when a slower + * /api/sessions response would otherwise clobber it (clear/progress race). + */ +export function reconcileAttachedJobsFromCache( + fetched: SessionsResponse, + cached: SessionsResponse | undefined +): SessionsResponse { + if (!cached?.sessions?.length) { + return fetched + } + const cachedById = new Map(cached.sessions.map((session) => [session.id, session])) + return { + ...fetched, + sessions: fetched.sessions.map((session) => { + const previous = cachedById.get(session.id) + if (!previous) { + return session + } + const previousAt = previous.attachedJobUpdatedAt ?? 0 + const fetchedAt = session.attachedJobUpdatedAt ?? 0 + if (previousAt <= fetchedAt) { + return session + } + return { + ...session, + attachedJob: previous.attachedJob, + attachedJobUpdatedAt: previous.attachedJobUpdatedAt, + } satisfies SessionSummary + }), + } +} diff --git a/web/src/hooks/queries/useSessions.ts b/web/src/hooks/queries/useSessions.ts index 2786b7edda..4bcda81bb4 100644 --- a/web/src/hooks/queries/useSessions.ts +++ b/web/src/hooks/queries/useSessions.ts @@ -1,7 +1,8 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import type { ApiClient } from '@/api/client' -import type { SessionSummary } from '@/types/api' +import type { SessionSummary, SessionsResponse } from '@/types/api' import { queryKeys } from '@/lib/query-keys' +import { reconcileAttachedJobsFromCache } from './reconcileAttachedJobs' export type UseSessionsOptions = { enabled?: boolean @@ -13,13 +14,16 @@ export function useSessions(api: ApiClient | null, options: UseSessionsOptions = error: string | null refetch: () => Promise } { + const queryClient = useQueryClient() const query = useQuery({ queryKey: queryKeys.sessions, queryFn: async () => { if (!api) { throw new Error('API unavailable') } - return await api.getSessions() + const fetched = await api.getSessions() + const cached = queryClient.getQueryData(queryKeys.sessions) + return reconcileAttachedJobsFromCache(fetched, cached) }, enabled: Boolean(api) && (options.enabled ?? true), }) From 28070b5441b3adda7240fb70db0ea4e388a2fec9 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:18:12 +0000 Subject: [PATCH 32/99] fix(jobs): retry terminal status write; reject startedAt on update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supervisor exit is the only running→terminal transition — retry transient hub failures. --started-at / startedAt only apply to set, not update/run. Co-authored-by: Cursor --- cli/src/commands/job.test.ts | 9 +++ cli/src/commands/job.ts | 8 +++ .../modules/sessionJob/runSessionJob.test.ts | 64 +++++++++++++++++++ cli/src/modules/sessionJob/runSessionJob.ts | 55 ++++++++++++---- .../modules/sessionJob/sessionJobMcp.test.ts | 9 +++ cli/src/modules/sessionJob/sessionJobMcp.ts | 4 ++ 6 files changed, 136 insertions(+), 13 deletions(-) diff --git a/cli/src/commands/job.test.ts b/cli/src/commands/job.test.ts index e3785f1561..58822c5ff7 100644 --- a/cli/src/commands/job.test.ts +++ b/cli/src/commands/job.test.ts @@ -59,6 +59,15 @@ describe('parseJobArgs', () => { ]) 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/) + }) }) describe('resolveSessionByPrefix', () => { diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts index b3753a86da..358d17cd64 100644 --- a/cli/src/commands/job.ts +++ b/cli/src/commands/job.ts @@ -209,6 +209,10 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { 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') + } + return result } @@ -285,6 +289,10 @@ export async function handleJobCommand(args: string[]): Promise { 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, diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index fe52cdda90..6dfe3cd454 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -100,6 +100,70 @@ describe('runSessionJob', () => { expect(http.get).toHaveBeenCalledTimes(1) }) + 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('marks failed on non-zero exit', async () => { const http = { post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts index 512c607e68..6822a82448 100644 --- a/cli/src/modules/sessionJob/runSessionJob.ts +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -29,9 +29,42 @@ export type RunSessionJobOptions = SessionJobClientOptions & { spawnImpl?: typeof spawn setIntervalImpl?: typeof setInterval clearIntervalImpl?: typeof clearInterval + /** Injected for tests (terminal-status retry backoff). */ + sleepImpl?: (ms: number) => 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 + 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, + ...(options.detail !== undefined ? { detail: options.detail } : {}), + }, + }) + return + } catch (error) { + lastError = error + 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) { @@ -71,6 +104,8 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise new Promise((resolve) => setTimeout(resolve, ms))) const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS const child: ChildProcess = spawnFn(options.command[0]!, options.command.slice(1), { @@ -114,19 +149,11 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise((resolve) => { child.on('error', async (error) => { clearIntervalFn(heartbeat) - await inflightHeartbeat.catch(() => undefined) - try { - await updateSessionJob({ - ...clientOpts, - jobKey: options.jobKey, - body: { status: 'failed', detail: error.message } - }) - } catch { - // ignore - } + spawnErrorDetail = error.message resolve(127) }) child.on('exit', (code, signal) => { @@ -149,10 +176,12 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise { }) ) }) + + it('rejects startedAt on update', async () => { + const result = await handleSessionJobTool( + { action: 'update', jobKey: 'beets', startedAt: 1_785_304_595_000 }, + 'sid-1' + ) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/startedAt is only valid with action=set/) + }) }) diff --git a/cli/src/modules/sessionJob/sessionJobMcp.ts b/cli/src/modules/sessionJob/sessionJobMcp.ts index e5119663b9..ddf8f3589b 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.ts @@ -112,6 +112,10 @@ export async function handleSessionJobTool( } const jobKey = args.jobKey.trim() + if (args.startedAt !== undefined && args.action !== 'set') { + return { text: 'startedAt is only valid with action=set', isError: true } + } + if (args.action === 'clear') { const result = await clearSessionJob({ sessionIdPrefix, jobKey }) return { text: `cleared ${jobKey} on ${result.sessionId}`, isError: false } From 05ce40f69ea70573aa2a7dc703ecbe39ab732668 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:29:51 +0000 Subject: [PATCH 33/99] fix(jobs): keep both live jobs on same-key merge Dual-running collisions remap the source key and record jobKeyRedirects so pre-merge supervisors cannot terminal-mark the winner. Also raise the flaky claudeRemote first-result test timeout under CI load. Co-authored-by: Cursor --- .../handlers/cli/sessionHandlers.test.ts | 4 + .../socket/handlers/cli/sessionHandlers.ts | 1 + hub/src/store/migration-v23.test.ts | 51 ++++++++++ hub/src/store/sessionJobs.ts | 64 +++++++++++-- hub/src/store/sessionJobsStore.ts | 3 +- hub/src/sync/sessionCache-merge-jobs.test.ts | 67 ++++++++++++++ hub/src/sync/sessionCache.ts | 92 +++++++++++++++++++ hub/src/sync/syncEngine.ts | 15 +++ hub/src/web/routes/sessions-jobs.test.ts | 3 + hub/src/web/routes/sessions.ts | 57 +++++++++--- shared/src/schemas.clear.test.ts | 8 +- shared/src/schemas.ts | 4 + 12 files changed, 346 insertions(+), 23 deletions(-) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.test.ts b/hub/src/socket/handlers/cli/sessionHandlers.test.ts index c034bd9953..4db9022f03 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.test.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.test.ts @@ -443,6 +443,7 @@ describe('cli session handlers', () => { ['opencodeClearOperation', { replacementSessionId: 'foreign-session', state: 'reserved', updatedAt: Date.now() }], ['jobsAcceptedFromSessionIds', ['foreign-session']], ['jobsTransferredToSessionId', 'foreign-session'], + ['jobKeyRedirects', { 'foreign-session/beets': 'beets.foreign' }], ] as const)( 'ignores a forged hub-owned %s addition from CLI metadata', (field, forged) => { @@ -474,6 +475,7 @@ describe('cli session handlers', () => { opencodeClearOperation: operation, jobsAcceptedFromSessionIds: ['old-session'], jobsTransferredToSessionId: 'merge-target', + jobKeyRedirects: { 'old-session/beets': 'beets.oldsess1' }, }, null, 'default') const socket = new FakeSocket() registerSessionHandlers(socket as unknown as CliSocketWithData, { @@ -490,6 +492,7 @@ describe('cli session handlers', () => { opencodeClearOperation: { replacementSessionId: 'forged-target', state: 'reserved', updatedAt: 0 }, jobsAcceptedFromSessionIds: ['forged-session'], jobsTransferredToSessionId: 'forged-target', + jobKeyRedirects: { 'forged/beets': 'beets.forged' }, } }, () => {}) expect(store.sessions.getSessionByNamespace(session.id, 'default')?.metadata).toMatchObject({ @@ -497,6 +500,7 @@ describe('cli session handlers', () => { opencodeClearOperation: operation, jobsAcceptedFromSessionIds: ['old-session'], jobsTransferredToSessionId: 'merge-target', + jobKeyRedirects: { 'old-session/beets': 'beets.oldsess1' }, lifecycleState: 'archived', }) }) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 89e90f2dfc..ac117bbf3b 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -75,6 +75,7 @@ const HUB_OWNED_METADATA_KEYS = [ 'opencodeClearOperation', 'jobsAcceptedFromSessionIds', 'jobsTransferredToSessionId', + 'jobKeyRedirects', ] as const function preserveHubOwnedMetadata(incoming: unknown, current: unknown): unknown { diff --git a/hub/src/store/migration-v23.test.ts b/hub/src/store/migration-v23.test.ts index 8e22b13744..473a397190 100644 --- a/hub/src/store/migration-v23.test.ts +++ b/hub/src/store/migration-v23.test.ts @@ -95,4 +95,55 @@ describe('schema migration v22 to v27', () => { expect(store.sessionJobs.list(from.id)).toHaveLength(0) store.close() }) + + it('on dual-running same-key collision keeps both under remapped source key', () => { + const store = new Store(':memory:') + const fromId = 'aaaaaaaa-1111-1111-1111-111111111111' + const toId = 'bbbbbbbb-2222-2222-2222-222222222222' + const from = store.sessions.getOrCreateSession( + 'tag-from-dual', + { path: '/a' }, + null, + 'default', + undefined, + undefined, + undefined, + fromId + ) + const to = store.sessions.getOrCreateSession( + 'tag-to-dual', + { path: '/b' }, + null, + 'default', + undefined, + undefined, + undefined, + toId + ) + expect(from.id).toBe(fromId) + expect(to.id).toBe(toId) + store.sessionJobs.upsert(to.id, 'beets', { + label: 'target-live', + status: 'running', + remaining: 9 + }, 1_000) + store.sessionJobs.upsert(from.id, 'beets', { + label: 'source-live', + status: 'running', + remaining: 3 + }, 2_000) + const result = store.sessionJobs.transfer(from.id, to.id) + expect(result.collided).toBe(1) + expect(result.moved).toBe(1) + expect(result.keyRedirects).toEqual([ + { fromKey: 'beets', toKey: 'beets.aaaaaaaa' } + ]) + const onTarget = store.sessionJobs.list(to.id) + expect(onTarget).toHaveLength(2) + expect(onTarget.map((j) => j.key).sort()).toEqual(['beets', 'beets.aaaaaaaa']) + expect(store.sessionJobs.get(to.id, 'beets')?.label).toBe('target-live') + expect(store.sessionJobs.get(to.id, 'beets.aaaaaaaa')?.label).toBe('source-live') + expect(store.sessionJobs.list(from.id)).toHaveLength(0) + store.close() + }) }) diff --git a/hub/src/store/sessionJobs.ts b/hub/src/store/sessionJobs.ts index 88f32d2219..a2952a4eef 100644 --- a/hub/src/store/sessionJobs.ts +++ b/hub/src/store/sessionJobs.ts @@ -240,6 +240,40 @@ export function deleteSessionJob(db: Database, sessionId: string, jobKey: string return result.changes > 0 } +export type SessionJobKeyRedirect = { + fromKey: string + toKey: string +} + +export type TransferSessionJobsResult = { + moved: number + collided: number + /** Source keys remapped on the target so two live supervisors stay isolated. */ + keyRedirects: SessionJobKeyRedirect[] +} + +const JOB_KEY_MAX = 128 + +/** Allocate `base.` (then `.N`) that fits JOB_KEY_MAX and is free on target. */ +export function allocateRemappedJobKey( + db: Database, + toSessionId: string, + fromSessionId: string, + fromKey: string +): string { + const short = fromSessionId.replace(/-/g, '').slice(0, 8) || 'src' + const suffix0 = `.${short}` + const base = fromKey.slice(0, Math.max(1, JOB_KEY_MAX - suffix0.length)) + let candidate = `${base}${suffix0}` + let n = 0 + while (getSessionJob(db, toSessionId, candidate)) { + n += 1 + const suffix = `.${short}.${n}` + candidate = `${fromKey.slice(0, Math.max(1, JOB_KEY_MAX - suffix.length))}${suffix}` + } + return candidate +} + /** * Re-point jobs during session merge (same contract as scratchlist transfer). * Call BEFORE deleteSession so CASCADE does not race the move. @@ -248,27 +282,39 @@ export function transferSessionJobs( db: Database, fromSessionId: string, toSessionId: string -): { moved: number; collided: number } { +): TransferSessionJobsResult { if (fromSessionId === toSessionId) { - return { moved: 0, collided: 0 } + return { moved: 0, collided: 0, keyRedirects: [] } } const rows = listSessionJobs(db, fromSessionId) let moved = 0 let collided = 0 + const keyRedirects: SessionJobKeyRedirect[] = [] for (const job of rows) { const existing = getSessionJob(db, toSessionId, job.key) if (existing) { - // Prefer a live source over a terminal target. When both are - // running or both terminal (incl. completed vs failed), prefer - // the newer updatedAt — otherwise a later terminal result loses - // to an older one with a different status. Redirected heartbeats - // omit status, so discarding a running source cannot be repaired. const sourceRunning = job.status === 'running' const targetRunning = existing.status === 'running' + // Two live supervisors still PATCH the pre-merge key via session + // redirect. Collapsing them would let the loser terminal-mark the + // winner — keep both under distinct keys and record a key remap. + if (sourceRunning && targetRunning) { + const toKey = allocateRemappedJobKey(db, toSessionId, fromSessionId, job.key) + db.prepare( + `UPDATE session_jobs SET session_id = ?, job_key = ? + WHERE session_id = ? AND job_key = ?` + ).run(toSessionId, toKey, fromSessionId, job.key) + keyRedirects.push({ fromKey: job.key, toKey }) + moved += 1 + collided += 1 + continue + } + // Prefer a live source over a terminal target. When both are + // terminal (incl. completed vs failed), prefer the newer updatedAt. const sourceWins = (sourceRunning && !targetRunning) - || (sourceRunning === targetRunning && job.updatedAt > existing.updatedAt) + || (!sourceRunning && !targetRunning && job.updatedAt > existing.updatedAt) if (sourceWins) { db.prepare('DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?') .run(toSessionId, job.key) @@ -291,5 +337,5 @@ export function transferSessionJobs( moved += 1 } - return { moved, collided } + return { moved, collided, keyRedirects } } diff --git a/hub/src/store/sessionJobsStore.ts b/hub/src/store/sessionJobsStore.ts index 8394730e5b..9f6a75384c 100644 --- a/hub/src/store/sessionJobsStore.ts +++ b/hub/src/store/sessionJobsStore.ts @@ -12,6 +12,7 @@ import { toAttachedJob, transferSessionJobs, upsertSessionJob, + type TransferSessionJobsResult, type UpsertSessionJobResult } from './sessionJobs' @@ -61,7 +62,7 @@ export class SessionJobsStore { return deleteSessionJob(this.db, sessionId, jobKey) } - transfer(fromSessionId: string, toSessionId: string): { moved: number; collided: number } { + transfer(fromSessionId: string, toSessionId: string): TransferSessionJobsResult { return transferSessionJobs(this.db, fromSessionId, toSessionId) } } diff --git a/hub/src/sync/sessionCache-merge-jobs.test.ts b/hub/src/sync/sessionCache-merge-jobs.test.ts index 5f9dbddf0f..f3a44adfec 100644 --- a/hub/src/sync/sessionCache-merge-jobs.test.ts +++ b/hub/src/sync/sessionCache-merge-jobs.test.ts @@ -188,4 +188,71 @@ describe('mergeSessions job redirect through SessionCache (#1404)', () => { expect(cache.resolveAttachedJobSessionId(oldSession.id, 'default')).toBe(newSession.id) }) + + it('remaps dual-running same-key jobs and routes PATCH via jobKeyRedirects', async () => { + const { store, cache } = setup() + const oldId = 'aaaaaaaa-1111-1111-1111-111111111111' + const newId = 'bbbbbbbb-2222-2222-2222-222222222222' + const oldSession = cache.getOrCreateSession( + 'tag-dual-old', + { path: '/a', host: 'local', flavor: 'codex' }, + null, + 'default', + undefined, + undefined, + undefined, + oldId + ) + const newSession = cache.getOrCreateSession( + 'tag-dual-new', + { path: '/b', host: 'local', flavor: 'codex' }, + null, + 'default', + undefined, + undefined, + undefined, + newId + ) + expect(oldSession.id).toBe(oldId) + expect(newSession.id).toBe(newId) + + store.sessionJobs.upsert(newSession.id, 'beets', { + label: 'target-live', + status: 'running', + remaining: 9 + }, 1_000) + store.sessionJobs.upsert(oldSession.id, 'beets', { + label: 'source-live', + status: 'running', + remaining: 3 + }, 2_000) + + await cache.mergeSessionHistory(oldSession.id, newSession.id, 'default', { + mergeAgentState: false + }) + + const onTarget = store.sessionJobs.list(newSession.id) + expect(onTarget).toHaveLength(2) + const refreshed = cache.refreshSession(newSession.id) + expect(refreshed?.metadata?.jobKeyRedirects).toEqual({ + [`${oldId}/beets`]: 'beets.aaaaaaaa' + }) + expect( + cache.resolveAttachedJobKey(oldId, newId, 'beets', 'default') + ).toBe('beets.aaaaaaaa') + expect( + cache.resolveAttachedJobKey(newId, newId, 'beets', 'default') + ).toBe('beets') + + // Terminal update via pre-merge session id + original key touches only the remapped row. + const patched = store.sessionJobs.patch( + newId, + cache.resolveAttachedJobKey(oldId, newId, 'beets', 'default'), + { status: 'completed' }, + 3_000 + ) + expect(patched?.key).toBe('beets.aaaaaaaa') + expect(patched?.status).toBe('completed') + expect(store.sessionJobs.get(newId, 'beets')?.status).toBe('running') + }) }) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index ba3cefcd24..f9b2d48b70 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1334,6 +1334,12 @@ export class SessionCache { // still exists; without this pointer, retained $HAPI_SESSION_ID hits // the emptied source and terminal PATCHes 404. this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) + this.recordJobKeyRedirects( + newSessionId, + oldSessionId, + movedJobs.keyRedirects, + namespace + ) if (movedJobs.moved > 0 || movedJobs.collided > 0) { this.emitAttachedJobChanged( newSessionId, @@ -1629,6 +1635,71 @@ export class SessionCache { } } + /** + * Persist key remaps from dual-running same-key merges, and inherit any + * redirects the source already held (A→B→C). + */ + private recordJobKeyRedirects( + toSessionId: string, + fromSessionId: string, + redirects: Array<{ fromKey: string; toKey: string }>, + namespace: string + ): void { + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = this.store.sessions.getSessionByNamespace(toSessionId, namespace) + if (!latest) return + const meta = (latest.metadata && typeof latest.metadata === 'object' + ? { ...(latest.metadata as Record) } + : {}) as Record + const prevRaw = meta.jobKeyRedirects + const next: Record = {} + if (prevRaw && typeof prevRaw === 'object' && !Array.isArray(prevRaw)) { + for (const [k, v] of Object.entries(prevRaw as Record)) { + if (typeof v === 'string' && v.trim()) next[k] = v + } + } + const fromMeta = this.store.sessions + .getSessionByNamespace(fromSessionId, namespace) + ?.metadata as Record | null | undefined + const inheritedRaw = fromMeta?.jobKeyRedirects + if (inheritedRaw && typeof inheritedRaw === 'object' && !Array.isArray(inheritedRaw)) { + for (const [k, v] of Object.entries(inheritedRaw as Record)) { + if (typeof v === 'string' && v.trim()) next[k] = v + } + } + for (const { fromKey, toKey } of redirects) { + next[`${fromSessionId}/${fromKey}`] = toKey + } + const prevKeys = Object.keys( + prevRaw && typeof prevRaw === 'object' && !Array.isArray(prevRaw) + ? (prevRaw as Record) + : {} + ) + const nextKeys = Object.keys(next) + const unchanged = + prevKeys.length === nextKeys.length + && nextKeys.every((k) => (prevRaw as Record | undefined)?.[k] === next[k]) + if (unchanged) return + if (nextKeys.length === 0) { + delete meta.jobKeyRedirects + } else { + meta.jobKeyRedirects = next + } + const result = this.store.sessions.updateSessionMetadata( + toSessionId, + meta, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + this.refreshSession(toSessionId) + return + } + if (result.result !== 'version-mismatch') return + } + } + /** * Follow job-owner redirects after session merge/dedup so agents that still * hold the pre-merge `$HAPI_SESSION_ID` can heartbeat. @@ -1675,6 +1746,27 @@ export class SessionCache { return null } + /** + * Map a pre-merge job key onto the post-merge owner key when dual-running + * same-key merge remapped the source row. + */ + resolveAttachedJobKey( + requestedSessionId: string, + ownerSessionId: string, + jobKey: string, + namespace: string + ): string { + const access = this.resolveSessionAccess(ownerSessionId, namespace) + if (!access.ok) return jobKey + const meta = access.session.metadata as Record | null | undefined + const redirects = meta?.jobKeyRedirects + if (!redirects || typeof redirects !== 'object' || Array.isArray(redirects)) { + return jobKey + } + const mapped = (redirects as Record)[`${requestedSessionId}/${jobKey}`] + return typeof mapped === 'string' && mapped.trim() ? mapped : jobKey + } + private mergeSessionMetadata(oldMetadata: unknown | null, newMetadata: unknown | null): unknown | null { if (!oldMetadata || typeof oldMetadata !== 'object') { return newMetadata diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 40149fde7a..df2c480026 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -380,6 +380,21 @@ export class SyncEngine { return this.sessionCache.resolveAttachedJobSessionId(sessionId, namespace) } + /** Follow dual-running same-key remaps after merge (tiann/hapi#1404). */ + resolveAttachedJobKey( + requestedSessionId: string, + ownerSessionId: string, + jobKey: string, + namespace: string + ): string { + return this.sessionCache.resolveAttachedJobKey( + requestedSessionId, + ownerSessionId, + jobKey, + namespace + ) + } + getActiveSessions(): Session[] { return this.sessionCache.getActiveSessions() } diff --git a/hub/src/web/routes/sessions-jobs.test.ts b/hub/src/web/routes/sessions-jobs.test.ts index e8408b3797..a9d3cd4d56 100644 --- a/hub/src/web/routes/sessions-jobs.test.ts +++ b/hub/src/web/routes/sessions-jobs.test.ts @@ -36,6 +36,7 @@ describe('session-attached jobs routes (tiann/hapi#1404)', () => { const engine = { resolveSessionAccess: () => ({ ok: true as const, sessionId: session.id, session }), resolveAttachedJobSessionId: (id: string) => id, + resolveAttachedJobKey: (_requested: string, _owner: string, jobKey: string) => jobKey, getSessionsByNamespace: () => [session], getFutureScheduledMessageCounts: () => new Map(), getNextScheduledAtBySessionIds: () => new Map(), @@ -157,6 +158,7 @@ describe('session-attached jobs routes (tiann/hapi#1404)', () => { return { ok: false as const, reason: 'not-found' as const } }, resolveAttachedJobSessionId: (id: string) => (id === deletedId ? owner.id : id), + resolveAttachedJobKey: (_requested: string, _owner: string, jobKey: string) => jobKey, listSessionJobs: (sid: string) => (sid === owner.id ? [...jobs.values()] : []), getPrimaryAttachedJob: (sid: string) => (sid === owner.id ? jobs.get('beets')! : null), upsertSessionJob: () => ({ outcome: 'session-not-found' as const }), @@ -182,6 +184,7 @@ describe('session-attached jobs routes (tiann/hapi#1404)', () => { const engine = { resolveSessionAccess: () => ({ ok: true as const, sessionId: session.id, session }), resolveAttachedJobSessionId: (id: string) => id, + resolveAttachedJobKey: (_requested: string, _owner: string, jobKey: string) => jobKey, listSessionJobs: () => [], getPrimaryAttachedJob: () => null, upsertSessionJob: () => ({ outcome: 'session-not-found' as const }), diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index d5d81beb68..7de8342a0f 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1308,17 +1308,21 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho function resolveJobOwnerSession( c: Context, engine: SyncEngine - ): { sessionId: string; session: Session } | Response { + ): { requestedSessionId: string; sessionId: string; session: Session } | Response { + const rawId = c.req.param('id') ?? '' const sessionResult = requireSessionFromParam(c, engine) if (sessionResult instanceof Response) { // Session may already be deleted after merge — still try acceptor redirect. - const rawId = c.req.param('id') ?? '' const namespace = c.get('namespace') const redirected = engine.resolveAttachedJobSessionId(rawId, namespace) if (redirected !== rawId) { const access = engine.resolveSessionAccess(redirected, namespace) if (access.ok) { - return { sessionId: access.sessionId, session: access.session } + return { + requestedSessionId: rawId, + sessionId: access.sessionId, + session: access.session, + } } } return sessionResult @@ -1326,13 +1330,39 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho const namespace = c.get('namespace') const ownerId = engine.resolveAttachedJobSessionId(sessionResult.sessionId, namespace) if (ownerId === sessionResult.sessionId) { - return sessionResult + return { + requestedSessionId: sessionResult.sessionId, + sessionId: sessionResult.sessionId, + session: sessionResult.session, + } } const access = engine.resolveSessionAccess(ownerId, namespace) if (!access.ok) { - return sessionResult + return { + requestedSessionId: sessionResult.sessionId, + sessionId: sessionResult.sessionId, + session: sessionResult.session, + } } - return { sessionId: access.sessionId, session: access.session } + return { + requestedSessionId: sessionResult.sessionId, + sessionId: access.sessionId, + session: access.session, + } + } + + function resolveJobKey( + c: Context, + engine: SyncEngine, + owner: { requestedSessionId: string; sessionId: string }, + jobKey: string + ): string { + return engine.resolveAttachedJobKey( + owner.requestedSessionId, + owner.sessionId, + jobKey, + c.get('namespace') + ) } app.get('/sessions/:id/jobs', (c) => { @@ -1359,10 +1389,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (sessionResult instanceof Response) { return sessionResult } - const jobKey = c.req.param('jobKey') - if (!jobKey || !JOB_KEY_RE.test(jobKey)) { + const rawJobKey = c.req.param('jobKey') + if (!rawJobKey || !JOB_KEY_RE.test(rawJobKey)) { return c.json({ error: 'Invalid jobKey (1-128 chars: alnum, . _ -)' }, 400) } + const jobKey = resolveJobKey(c, engine, sessionResult, rawJobKey) const body = await c.req.json().catch(() => null) const parsed = AttachedJobUpsertSchema.safeParse(body) if (!parsed.success) { @@ -1384,10 +1415,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (sessionResult instanceof Response) { return sessionResult } - const jobKey = c.req.param('jobKey') - if (!jobKey || !JOB_KEY_RE.test(jobKey)) { + const rawJobKey = c.req.param('jobKey') + if (!rawJobKey || !JOB_KEY_RE.test(rawJobKey)) { return c.json({ error: 'Invalid jobKey (1-128 chars: alnum, . _ -)' }, 400) } + const jobKey = resolveJobKey(c, engine, sessionResult, rawJobKey) const body = await c.req.json().catch(() => null) const parsed = AttachedJobPatchSchema.safeParse(body) if (!parsed.success) { @@ -1409,10 +1441,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (sessionResult instanceof Response) { return sessionResult } - const jobKey = c.req.param('jobKey') - if (!jobKey || !JOB_KEY_RE.test(jobKey)) { + const rawJobKey = c.req.param('jobKey') + if (!rawJobKey || !JOB_KEY_RE.test(rawJobKey)) { return c.json({ error: 'Invalid jobKey (1-128 chars: alnum, . _ -)' }, 400) } + const jobKey = resolveJobKey(c, engine, sessionResult, rawJobKey) const removed = engine.deleteSessionJob(sessionResult.sessionId, jobKey) if (!removed) { return c.json({ error: 'Job not found' }, 404) diff --git a/shared/src/schemas.clear.test.ts b/shared/src/schemas.clear.test.ts index b8197221c7..5d9eb46c13 100644 --- a/shared/src/schemas.clear.test.ts +++ b/shared/src/schemas.clear.test.ts @@ -15,10 +15,16 @@ describe('fresh-session clear schema contract', () => { path: '/tmp/project', host: 'host', jobsAcceptedFromSessionIds: ['old-session-id'], - jobsTransferredToSessionId: 'new-session-id' + jobsTransferredToSessionId: 'new-session-id', + jobKeyRedirects: { + 'old-session-id/beets': 'beets.oldsess1', + }, }) expect(parsed.jobsAcceptedFromSessionIds).toEqual(['old-session-id']) expect(parsed.jobsTransferredToSessionId).toBe('new-session-id') + expect(parsed.jobKeyRedirects).toEqual({ + 'old-session-id/beets': 'beets.oldsess1', + }) }) it('accepts cleared as an additive session-end reason', () => { diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 71edcd1fef..bc2060cb38 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -142,6 +142,10 @@ export const MetadataSchema = z.object({ // Hub-owned: CLI update-metadata cannot forge/erase (HUB_OWNED_METADATA_KEYS). jobsAcceptedFromSessionIds: z.array(z.string()).optional(), jobsTransferredToSessionId: z.string().optional(), + // When merge remaps a live source job key (same-key dual-running), map + // `${fromSessionId}/${fromKey}` → toKey on the post-merge owner so + // pre-merge supervisors keep PATCHing the right row. + jobKeyRedirects: z.record(z.string(), z.string()).optional(), // Durable in-progress state for runner-backed OpenCode /clear. opencodeClearOperation: OpencodeClearOperationSchema.optional(), preferredPermissionMode: PermissionModeSchema.optional(), From 4686d868b746d298cec00cafb6ae1defb786a4ad Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:49:38 +0000 Subject: [PATCH 34/99] fix(jobs): hard-refuse MCP set; louder stale heartbeat chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Process-shaped work must use Shell hapi job run. MCP set is refused with the run recipe (wardrobe freeze). Stale rows show "no heartbeat · age". Co-authored-by: Cursor --- .../common/sessionJobInstruction.test.ts | 3 +- .../modules/common/sessionJobInstruction.ts | 9 +- .../modules/sessionJob/sessionJobMcp.test.ts | 44 ++++------ cli/src/modules/sessionJob/sessionJobMcp.ts | 85 ++++++++++--------- docs/guide/session-jobs.md | 20 +++-- web/src/components/SessionRowSummary.tsx | 9 +- web/src/lib/attachedJob.test.ts | 27 ++++-- web/src/lib/attachedJob.ts | 22 +++-- 8 files changed, 125 insertions(+), 94 deletions(-) diff --git a/cli/src/modules/common/sessionJobInstruction.test.ts b/cli/src/modules/common/sessionJobInstruction.test.ts index 45ebbd72cd..12085a4d65 100644 --- a/cli/src/modules/common/sessionJobInstruction.test.ts +++ b/cli/src/modules/common/sessionJobInstruction.test.ts @@ -5,11 +5,12 @@ import { } from './sessionJobInstruction' describe('sessionJobInstruction', () => { - it('prefers MCP session_job + job run supervisor and forbids fake percent', () => { + 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') }) diff --git a/cli/src/modules/common/sessionJobInstruction.ts b/cli/src/modules/common/sessionJobInstruction.ts index aaec5f96d7..18515f2a2b 100644 --- a/cli/src/modules/common/sessionJobInstruction.ts +++ b/cli/src/modules/common/sessionJobInstruction.ts @@ -14,12 +14,11 @@ export const SESSION_JOB_INSTRUCTION = [ '(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.', - 'Prefer supervised CLI for process-shaped work (idle agents cannot heartbeat):', - 'hapi job run "$HAPI_SESSION_ID" --label -- …', + 'REQUIRED for process-shaped work (idle agents cannot heartbeat):', + 'Shell → hapi job run "$HAPI_SESSION_ID" --label -- …', '(auto-heartbeats + completed/failed on exit).', - 'Manual path: MCP tool session_job (aliases: mcp__hapi__session_job,', - 'hapi_session_job, functions.hapi__session_job) action=set, then action=update', - 'every ~10m from a self-heartbeating wrapper — never set-once and walk away.', + '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.', 'Prefer honest remaining or done+total; omit counts when unknown', '(UI shows "running" + elapsed). Never invent a fake percent.', 'Full contract: hapi job --help.' diff --git a/cli/src/modules/sessionJob/sessionJobMcp.test.ts b/cli/src/modules/sessionJob/sessionJobMcp.test.ts index f168f51899..1bef390ba3 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.test.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { handleSessionJobTool, SESSION_JOB_TOOL_DESCRIPTION } from './sessionJobMcp' +import { + handleSessionJobTool, + SESSION_JOB_RUN_RECIPE, + SESSION_JOB_SET_REFUSED_TEXT, + SESSION_JOB_TOOL_DESCRIPTION +} from './sessionJobMcp' vi.mock('./sessionJob', () => ({ SessionJobError: class SessionJobError extends Error { @@ -9,18 +14,9 @@ vi.mock('./sessionJob', () => ({ this.code = code } }, - setSessionJob: vi.fn(async () => ({ - sessionId: 'sid-1', - job: { - key: 'beets', - label: 'beets import', - status: 'running', - remaining: 12, - heartbeatAt: 1, - startedAt: 1, - updatedAt: 1 - } - })), + setSessionJob: vi.fn(async () => { + throw new Error('setSessionJob must not be called from MCP') + }), updateSessionJob: vi.fn(async () => ({ sessionId: 'sid-1', job: { @@ -38,27 +34,23 @@ vi.mock('./sessionJob', () => ({ })) describe('sessionJobMcp', () => { - it('description steers outliving batch work and honest progress', () => { + it('description steers to job run and forbids MCP set', () => { expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/OUTLIVES/i) - expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/Never invent a percent/i) + expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/do NOT use action=set/i) expect(SESSION_JOB_TOOL_DESCRIPTION).toContain('hapi job run') + expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/Own-session only/i) }) - it('set requires label and always targets the caller session id', async () => { + it('hard-refuses action=set and never calls setSessionJob', async () => { const { setSessionJob } = await import('./sessionJob') const result = await handleSessionJobTool( { action: 'set', jobKey: 'beets', label: 'beets import', remaining: 12 }, 'sid-1' ) - expect(result.isError).toBe(false) - expect(result.text).toContain('set beets') - expect(setSessionJob).toHaveBeenCalledWith( - expect.objectContaining({ sessionIdPrefix: 'sid-1' }) - ) - }) - - it('description claims own-session only', () => { - expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/Own-session only/i) + expect(result.isError).toBe(true) + expect(result.text).toBe(SESSION_JOB_SET_REFUSED_TEXT) + expect(result.text).toContain(SESSION_JOB_RUN_RECIPE) + expect(setSessionJob).not.toHaveBeenCalled() }) it('treats empty update as a heartbeat-only patch', async () => { @@ -84,6 +76,6 @@ describe('sessionJobMcp', () => { 'sid-1' ) expect(result.isError).toBe(true) - expect(result.text).toMatch(/startedAt is only valid with action=set/) + expect(result.text).toMatch(/startedAt is not valid over MCP/) }) }) diff --git a/cli/src/modules/sessionJob/sessionJobMcp.ts b/cli/src/modules/sessionJob/sessionJobMcp.ts index ddf8f3589b..63f6251468 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.ts @@ -1,50 +1,64 @@ /** * MCP surface for session-attached jobs (tiann/hapi#1404). * Same discovery class as ping_peer / inspect_peer — tool catalog, not docs-only. + * + * Hard contract: MCP cannot create a long-lived bar (action=set is refused). + * Create meters with Shell + `hapi job run` (babysitter). MCP is for + * update / clear / list after a supervisor or CLI wrapper owns heartbeats. */ import { z } from 'zod' -import type { AttachedJob, AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' +import type { AttachedJob, AttachedJobPatch } from '@hapi/protocol' import { SessionJobError, clearSessionJob, listSessionJobs, - setSessionJob, updateSessionJob } from './sessionJob' export const SESSION_JOB_TOOL_NAME = 'session_job' +/** Exact Shell recipe agents should run instead of MCP set. */ +export const SESSION_JOB_RUN_RECIPE = + 'hapi job run "$HAPI_SESSION_ID" --label "