diff --git a/.github/scripts/verify-station.mjs b/.github/scripts/verify-station.mjs index cf35b2e..2c17e3f 100644 --- a/.github/scripts/verify-station.mjs +++ b/.github/scripts/verify-station.mjs @@ -86,6 +86,8 @@ export async function verifyAgentChat({ origin, fetchImpl = fetch, log = console ['/agent/chat.css', 'text/css', '--paper'], ['/agent/chat-core.mjs', 'text/javascript', 'class Conversation'], ['/agent/chat.mjs', 'text/javascript', 'chat-core.mjs'], + ['/agent/markdown.mjs', 'text/javascript', 'renderMarkdown'], + ['/agent/vendor/commonmark.min.js', 'application/javascript', 'commonmark'], ['/agent/mark.svg', 'image/svg+xml', ' { diff --git a/.github/scripts/verify-station.test.mjs b/.github/scripts/verify-station.test.mjs index 2113ab6..e024bc3 100644 --- a/.github/scripts/verify-station.test.mjs +++ b/.github/scripts/verify-station.test.mjs @@ -120,6 +120,8 @@ function chatAsset(path) { '/agent/chat.css': ['text/css', ':root { --paper: white; }'], '/agent/chat-core.mjs': ['text/javascript', 'export class Conversation {}'], '/agent/chat.mjs': ['text/javascript', 'import "./chat-core.mjs";'], + '/agent/markdown.mjs': ['text/javascript', 'export function renderMarkdown() {}'], + '/agent/vendor/commonmark.min.js': ['application/javascript', 'commonmark'], '/agent/mark.svg': ['image/svg+xml', ''], }; const [mime, text] = values[path]; @@ -139,7 +141,7 @@ test('production verification checks the chat webapp and every asset on the same paths.push(url.pathname); return chatAsset(url.pathname); } }); - assert.deepEqual(paths.sort(), ['/agent/', '/agent/chat.css', '/agent/chat-core.mjs', '/agent/chat.mjs', '/agent/mark.svg'].sort()); + assert.deepEqual(paths.sort(), ['/agent/', '/agent/chat.css', '/agent/chat-core.mjs', '/agent/chat.mjs', '/agent/markdown.mjs', '/agent/vendor/commonmark.min.js', '/agent/mark.svg'].sort()); }); test('production verification rejects an undeployed page, SPA fallback and wrong JavaScript MIME', async () => { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95d1164..4cb13d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,9 @@ jobs: - name: Run core tests run: ctest --test-dir build --output-on-failure + - name: Verify pinned terminal assets + run: python3 scripts/setup-terminal-assets.py --verify + - name: Build Swift package run: swift build -c release @@ -99,7 +102,7 @@ jobs: node webapp/tests/codec.test.mjs node webapp/tests/nginx.test.js node --test webapp/tests/screen-share.test.mjs webapp/tests/rtc-channel.test.mjs - node --test webapp/tests/agent-chat.test.mjs + node --test webapp/tests/agent-chat.test.mjs webapp/tests/agent-markdown.test.mjs node --test webapp/tests/stoa-session.test.mjs - name: Verify Stoa and Agent webapps diff --git a/Config/terminal-assets.json b/Config/terminal-assets.json new file mode 100644 index 0000000..3acf574 --- /dev/null +++ b/Config/terminal-assets.json @@ -0,0 +1,40 @@ +{ + "atem_commit": "396a0f3e0a68b99e6527594511706add9b046a1b", + "packages": [ + { + "name": "@xterm/xterm", + "version": "6.0.0", + "url": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "files": { + "package/lib/xterm.js": "xterm.js", + "package/css/xterm.css": "xterm.css", + "package/LICENSE": "xterm-LICENSE" + } + }, + { + "name": "@xterm/addon-fit", + "version": "0.11.0", + "url": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "files": { + "package/lib/addon-fit.js": "addon-fit.js", + "package/LICENSE": "addon-fit-LICENSE" + } + } + ], + "atem_assets": { + "atem-unicode.cjs": "2892c88e7e91898fe38fa682b75284a27250efa95a6f0c02b8b073c74c82a0da", + "unicode-width-0.1.14.json": "1de5b2f92418719364e85507348c4aecdb53e34758a009ba13e696f5d2154f98" + }, + "asset_sha256": { + "addon-fit-LICENSE": "e256f01188af527e4d06d21d06fbf785ae9c50d4b328bf03cbe0ba7f0aa4228f", + "addon-fit.js": "ba3ea256ce0620a0992a197d6c9baea64823fc93d8da07a9e366ca9943c18527", + "atem-unicode.cjs": "2892c88e7e91898fe38fa682b75284a27250efa95a6f0c02b8b073c74c82a0da", + "atem-unicode.js": "dece792785b991b9a08c00b8c2ec40371491b6b7ca25be5b32963af3c756be50", + "unicode-width-0.1.14.json": "1de5b2f92418719364e85507348c4aecdb53e34758a009ba13e696f5d2154f98", + "xterm-LICENSE": "b569f629d00f2626a8100df2a1798210535621e42164dfd426a6fe5aac7b0ccd", + "xterm.css": "854a7c0fb70e8b1a083c16797ab827299fb18744f5ad34f227b48337e33293c6", + "xterm.js": "14903579ff54664cd72f8e8699e6961a6272c21863ec1c3b118cdc8af5d4a972" + } +} diff --git a/Package.swift b/Package.swift index 1a77d5a..07cd8f0 100644 --- a/Package.swift +++ b/Package.swift @@ -49,7 +49,7 @@ let package = Package( ], path: "Sources/Menubar", resources: [.copy("Resources/transcription-model.json"), .copy("Resources/whisper-large-v3-turbo-coreml-v1.json"), - .copy("Resources/whisper-large-v3-coreml-v1.json")], + .copy("Resources/whisper-large-v3-coreml-v1.json"), .copy("Resources/RemoteTerminal")], linkerSettings: [ .unsafeFlags(["-Xlinker", "-sectcreate", "-Xlinker", "__TEXT", "-Xlinker", "__info_plist", "-Xlinker", executableInfoPlist]), diff --git a/README.md b/README.md index 35924c4..8cf5e09 100644 --- a/README.md +++ b/README.md @@ -252,11 +252,30 @@ Messages carry only IDs, status, and descriptions -- no images or file lists flo ### Remote Agent Control Open **Connections > Clients & Agents**, select a connected Atem and an agent, -and open its remote-control window to send text or control keys. Input goes to -the selected client and agent; the agent's output remains in its Atem terminal. -The `agentInput` payload contains `agentId`, `kind` (`text` or `key`), and the -corresponding `text` or `key` field. Relay routing identifiers belong to the -transport envelope. Voice continues through the existing voice-coding flow. +then click **Open Terminal**. The **Terminals** button also opens the session +chooser without requiring an active agent row. The resizable window shows the +original Claude/Codex terminal, including live output, colors and prompts. +It starts in viewing mode; **Take Control** enables typing, control keys, +multiline paste and resizing. **Release** returns local ownership. Closing the +window keeps the agent running; **Stop Agent** asks before ending its process. + +This development branch requires the Atem session-v1 r6.2 host protocol (Atem PR #69), +in addition to terminal rendering. Launch the original CLI with +`atem agent launch claude` or `atem agent launch codex`, pair and verify once, +then select its exact session. A shared lease governs typing, resize, voice, +prompt submission, interrupt and stop. Reconnect returns to viewing mode, +queries durable receipts and never resends uncertain input. See the +[integration handoff](designs/protocol/atem-implementation-handoff.md) for host +requirements and current validation limits. + +See [remote terminal control](docs/remote-terminal.md) for setup, ownership, +clipboard, limitations and validation. + +**Open in Browser** is disabled on this development branch. Browser companions +require the section 9 security contract and a common host lease; there is no +unbound chat fallback. The `/agent/` webapp and its offline conformance tests +remain available for that follow-up. Use **Open Terminal** for current host +access. See the [joint integration handoff](designs/protocol/atem-implementation-handoff.md). ### Local Audio Recording @@ -413,7 +432,7 @@ relay-server/ ## Configuration Settings > Credentials manages named API keys and passwords in this Mac's -protected Keychain, with Global or Git-project scopes and Touch ID for saves, +protected Keychain, with Global or Git-project scopes and Touch ID or Mac-password authentication for saves, updates, copies and deletion. The project picker combines encrypted suggestions from verified, unlocked Atem devices with saved scopes; another Git remote can always be entered manually. Original capitalization is retained, while identity @@ -422,8 +441,9 @@ retrieval is not implemented. See the [credentials guide](docs/credentials.md) for setup and current availability. Atem can run on another machine. Pair with `atem pair`, compare the safety code -on both machines, then click **Codes Match** and approve Touch ID on the Mac. -Check `atem cred status` for verification; a connected or SSO-paired session +on both machines, then click **Codes Match** and approve Touch ID on the Mac, +or choose **Use Mac Password...** to authenticate in macOS's system dialog. +Check `atem status` for verification; a connected or SSO-paired session alone does not establish device trust. `atem cred unlock` requests key access from this Mac, and `atem cred projects` sends Git-project suggestions. Use a provisioned Astation build for protected storage; the ad hoc development app diff --git a/Sources/Menubar/AstationApp.swift b/Sources/Menubar/AstationApp.swift index 007ab16..ee39e72 100644 --- a/Sources/Menubar/AstationApp.swift +++ b/Sources/Menubar/AstationApp.swift @@ -155,6 +155,9 @@ class AstationApp: NSObject, NSApplicationDelegate { hubManager.sendHandler = { [weak webSocketServer] message, clientId in webSocketServer?.sendMessageToClient(message, clientId: clientId) } + hubManager.sessionsRemovedHandler = { [weak webSocketServer] sessionIds in + webSocketServer?.disconnectClients(authenticatedWith: sessionIds) + } // Start network monitoring to detect IP changes NetworkMonitor.shared.startMonitoring() diff --git a/Sources/Menubar/AstationHubManager.swift b/Sources/Menubar/AstationHubManager.swift index f3fc8c9..3f01108 100644 --- a/Sources/Menubar/AstationHubManager.swift +++ b/Sources/Menubar/AstationHubManager.swift @@ -6,7 +6,11 @@ import Network // Hub Manager - Contains all business logic for Agora projects, tokens, etc. class AstationHubManager: ObservableObject { @Published var connectedClients: [ConnectedClient] = [] { - didSet { refreshDirectMigrationContexts() } + didSet { + refreshDirectMigrationContexts() + refreshSessionConnections() + terminalConnectionsDidChange?() + } } @Published var projects: [AgoraProject] = [] @Published var selectedProject: AgoraProject? @@ -22,6 +26,7 @@ class AstationHubManager: ObservableObject { private var credentialProjectReports: [String: AtemReportedProjects] = [:] /// User-pinned active client ID. Overrides focus-based routing when set. @Published var pinnedClientId: String? + @Published private(set) var remoteInputStatusMessage: String? private var hubStartTime = Date() let sessionStore: SsoSessionStore @@ -94,6 +99,7 @@ class AstationHubManager: ObservableObject { } private let migrationContextLock = NSLock() private var directMigrationContexts: [String: MigrationContext] = [:] + private var directSessionContexts: [String: AtemSessionContext] = [:] let atemE2EIdentity: AtemE2EIdentity private lazy var atemVerification = AtemVerificationController( identity: atemE2EIdentity, @@ -102,7 +108,11 @@ class AstationHubManager: ObservableObject { isCurrent: { [weak self] route in self?.verificationRoute(clientId: route.clientId, relayConnectionId: self?.identityRelayAuthentication.connectionId(for: route.clientId)) == route }, send: { [weak self] message, route in self?.sendMessage(message, to: route.clientId, expectedRelayConnectionId: route.clientId.hasPrefix("relay-") ? route.connectionId : nil) - if case .deviceVerified = message { self?.requestRelayEncryptionState() } + if case .deviceVerified = message { + self?.remoteTrustDidChange() + self?.requestRelayEncryptionState() + self?.requestAgentList(from: route.clientId) + } }, approve: AtemVerificationUI.approve, saveRecovery: AtemVerificationUI.saveRecovery, @@ -119,6 +129,7 @@ class AstationHubManager: ObservableObject { }, send: { [weak self] message, route in self?.sendMessage(message, to: route.clientId, expectedRelayConnectionId: route.clientId.hasPrefix("relay-") ? route.connectionId : nil) + if case .unlockDenied(_, let revoked) = message, revoked { self?.remoteTrustDidChange() } }, approve: AtemVerificationUI.unlock ) @@ -135,6 +146,25 @@ class AstationHubManager: ObservableObject { /// Callback for sending a message to a specific client by ID. /// Set by AstationApp after wiring up the WebSocket server. var sendHandler: ((AstationMessage, String) -> Void)? + var terminalMessageHandler: ((RemoteTerminalMessage, RemoteTerminalConnection) -> Void)? + var sessionMessageHandler: ((AtemSessionMessage, RemoteTerminalConnection) -> Void)? + var sessionStateDidChange: ((String) -> Void)? + var terminalConnectionsDidChange: (() -> Void)? + var remoteAgentInputHandler: ((RemoteTerminalConnection, String?, String, String) -> Bool)? + var sessionsRemovedHandler: (([String]) -> Void)? + private var nativeSessionClients: [String: AtemSessionClient] = [:] + private var sessionTimer: Timer? + private let sessionJournal: AtemSessionJournal + lazy var browserAgentChat = BrowserAgentChat( + currentRoute: { [weak self] in self?.browserChatConnection(for: $0) }, + sendAgent: { [weak self] message, route in + guard let self, self.browserChatConnection(for: route.clientId) == route, self.sendHandler != nil else { return false } + self.sendMessage(.agentChat(message), to: route.clientId, + expectedRelayConnectionId: route.clientId.hasPrefix("relay-") ? route.connectionId : nil) + return true + }, + sendBrowser: { [weak self] frame, route in self?.sendBrowserChatFrame(frame, to: route) } + ) init( skipProjectLoad: Bool = false, @@ -143,6 +173,7 @@ class AstationHubManager: ObservableObject { relayIdentityKeyManager: RelayIdentityKeyManager = RelayIdentityKeyManager(), relayIdentityRepairDefaults: UserDefaults = .standard, atemE2EIdentity: AtemE2EIdentity? = nil, + sessionJournal: AtemSessionJournal = .persistent(), loadMigrationKey: @escaping (String, String?) throws -> AccountDataKey? = { try DataEncryptionKeyManager.shared.load(dataAccount: $0, kid: $1) }, @@ -155,6 +186,7 @@ class AstationHubManager: ObservableObject { self.relayIdentityKeyManager = relayIdentityKeyManager self.relayIdentityRepairDefaults = relayIdentityRepairDefaults self.atemE2EIdentity = atemE2EIdentity ?? AtemE2EIdentity(storage: AtemIdentityKeychainStorage(astationId: AstationIdentity.shared.id)) + self.sessionJournal = sessionJournal self.loadMigrationKey = loadMigrationKey self.dispatchMigration = dispatchMigration self.tokenProvider = SsoTokenProvider( @@ -168,6 +200,8 @@ class AstationHubManager: ObservableObject { } deviceSessionStore.onSessionsRemoved = { [weak self] sessionIds in self?.sendRelayUnbind(sessionIds: sessionIds) + self?.sessionsRemovedHandler?(sessionIds) + self?.terminalConnectionsDidChange?() } setupCore() setupRTCManager() @@ -200,6 +234,7 @@ class AstationHubManager: ObservableObject { } deinit { + sessionTimer?.invalidate() sessionExpiryTimer?.invalidate() identityRelayPathMonitor?.cancel() identityRelayTask?.cancel(with: .goingAway, reason: nil) @@ -573,6 +608,7 @@ class AstationHubManager: ObservableObject { ) self.broadcastInstanceList() + self.requestAgentList(from: client.id) } } @@ -661,10 +697,10 @@ class AstationHubManager: ObservableObject { } return nil - case .userCommand(let command, let context): - Log.info(" User command from \(clientId): \(command)") - handleUserCommand(command, context: context) - return nil + case .userCommand, .agentInput, .voiceCommand, .voiceToggle, .voiceRequest, .agentListRequest: + var error = AtemSessionMessage(op: "error") + error.code = "unsupported_version" + return .session(error) case .statusUpdate(let status, let data): Log.info(" Status update from \(clientId): \(status)") @@ -696,13 +732,41 @@ class AstationHubManager: ObservableObject { handleMarkTaskResult(taskId: taskId, success: success, message: message) return nil - case .agentListResponse(let agents): - Log.info("[AstationHub] Agent list from \(clientId.prefix(8))…: \(agents.count) agent(s)") + case .agentListResponse: + // r6.2's authenticated session registry is the only agent list. + return nil + + case .session(let message): + guard let captured = captureSessionContext(clientId: clientId, relayConnectionId: relayConnectionId) else { return nil } + DispatchQueue.main.async { [weak self] in + guard let self, self.sessionContext(for: clientId) == captured else { return } + self.nativeSessionClients[clientId]?.receive(message, from: captured.route) + self.sessionMessageHandler?(message, captured.route) + } + return nil + + case .terminal(let message): + // Direct client IDs are unique socket generations. Relay context is + // captured from the authenticated envelope before asynchronous delivery. + guard let captured = captureSessionContext(clientId: clientId, relayConnectionId: relayConnectionId) else { return nil } DispatchQueue.main.async { - self.agentsByClientId[clientId] = agents + guard self.sessionContext(for: clientId) == captured else { return } + self.terminalMessageHandler?(message, captured.route) + } + return nil + + case .agentChat(let event): + let source = RemoteTerminalConnection(clientId: clientId, connectionId: relayConnectionId ?? clientId) + DispatchQueue.main.async { [weak self] in + guard let self, self.terminalConnection(for: clientId) == source else { return } + self.browserAgentChat.receiveAgent(event, from: source) } return nil + case .browserChat: + // Browser capabilities are accepted only on the explicit relay bridge. + return nil + case .credentialProjectListResponse(let report): guard let context = captureMigrationContext(clientId: clientId, relayConnectionId: relayConnectionId) else { return nil } DispatchQueue.main.async { @@ -945,8 +1009,6 @@ class AstationHubManager: ObservableObject { rtcManager.muteMic(!voiceActive) } - let message = AstationMessage.voiceToggle(active: voiceActive) - broadcastHandler?(message) Log.info("[AstationHub] Voice toggled: \(voiceActive ? "active" : "muted")") } @@ -986,15 +1048,9 @@ class AstationHubManager: ObservableObject { // Focus follows most-recent activity self.updateFocus(activeClientId: clientId) + self.requestAgentList(from: clientId) } - // Ask this Atem to send its current agent list. - sendMessage( - AstationMessage.agentListRequest, - to: clientId, - expectedRelayConnectionId: relayConnectionId - ) - // Push credentials to the newly connected Atem. sendCredentials(toClientId: clientId, relayConnectionId: relayConnectionId) } @@ -1074,10 +1130,44 @@ class AstationHubManager: ObservableObject { } - /// Ask a specific Atem to push its current agent list. + /// Refresh the one registry shared by Connections and terminal windows. func requestAgentList(from clientId: String) { - sendHandler?(AstationMessage.agentListRequest, clientId) - Log.info("[AstationHub] Requested agent list from \(clientId.prefix(8))…") + sessionClient(for: clientId)?.refresh() + } + + func sessionClient(for clientId: String, atemId: String? = nil) -> AtemSessionClient? { + dispatchPrecondition(condition: .onQueue(.main)) + guard let context = sessionContext(for: clientId, atemId: atemId) else { return nil } + let id = context.route.clientId + if let client = nativeSessionClients[id] { client.connectionChanged(); return client } + let client = AtemSessionClient(context: { [weak self] in self?.sessionContext(for: id) }, + send: { [weak self] message, route in self?.sendSessionMessage(message, to: route) ?? false }, + journal: sessionJournal) + nativeSessionClients[id] = client + client.onChange = { [weak self, weak client] in + guard let self, let client else { return } + self.agentsByClientId[id] = client.hasRegistrySnapshot + ? client.registry.values.sorted { $0.session_id < $1.session_id }.map(AtemAgentInfo.init(session:)) : [] + self.sessionStateDidChange?(id) + } + client.start() + if sessionTimer == nil { + sessionTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + guard let self else { return } + for client in Array(self.nativeSessionClients.values) { client.tick() } + } + } + return client + } + + private func refreshSessionConnections() { + for (id, client) in Array(nativeSessionClients) { + client.connectionChanged() + if !connectedClients.contains(where: { $0.id == id }) { + nativeSessionClients.removeValue(forKey: id) + agentsByClientId.removeValue(forKey: id) + } + } } // MARK: - Voice Command Routing @@ -1085,48 +1175,142 @@ class AstationHubManager: ObservableObject { /// Send a voice command to the focused Atem instance. /// Called by the transcription pipeline when speech-to-text produces text. func sendVoiceCommand(text: String, isFinal: Bool) { - guard let clientId = routeToFocusedAtem() else { - Log.info(" No Atem connected — voice command dropped: \(text)") - return - } - - let message = AstationMessage.voiceCommand(text: text, isFinal: isFinal) - sendHandler?(message, clientId) - Log.info(" Voice command → \(clientId): \(text)\(isFinal ? " [final]" : "")") + guard isFinal else { return } + sendScopedAgentInput(clientId: routeToFocusedAtem(), agentId: nil, kind: "text", value: text) } // MARK: - Remote Agent Control - /// Send a text instruction to an Atem agent (written to its PTY stdin + Enter). - /// When no client or agent is specified, the focused Atem and its focused agent are used. - func sendAgentText(_ text: String, agentId: String? = nil, clientId: String? = nil) { - guard let targetClientId = clientId ?? routeToFocusedAtem() else { - Log.info("[AgentInput] No Atem connected — text dropped: \(text)") - return - } - guard connectedClients.contains(where: { $0.id == targetClientId }) else { - Log.info("[AgentInput] Target Atem offline — text dropped: \(targetClientId)") - return - } - let message = AstationMessage.agentInput(agentId: agentId, kind: "text", text: text, key: nil) - sendHandler?(message, targetClientId) - Log.info("[AgentInput] text → \(targetClientId): \(text)") + func openAgentInBrowser(clientId: String, agent: AtemAgentInfo) throws -> URL { + dispatchPrecondition(condition: .onQueue(.main)) + if let reason = remoteAccessBlockReason(for: clientId) { throw reason } + throw BrowserAgentChat.OpenError.sessionProtocolRequired } - /// Send a control key to an Atem agent (written raw to its PTY). - /// `key` is one of: enter, esc, ctrl-c, up, down, y, n. - func sendAgentKey(_ key: String, agentId: String? = nil, clientId: String? = nil) { - guard let targetClientId = clientId ?? routeToFocusedAtem() else { - Log.info("[AgentInput] No Atem connected — key dropped: \(key)") - return + private func sendBrowserChatFrame(_ frame: BrowserChatFrame, to route: RemoteTerminalConnection) { + guard identityRelayVerified, let task = identityRelayTask, + route.clientId.hasPrefix("relay-browser-"), + let data = try? JSONEncoder().encode(AstationMessage.browserChat(frame)), + let payload = try? JSONSerialization.jsonObject(with: data), + let envelope = try? JSONSerialization.data(withJSONObject: [ + "atem_id": String(route.clientId.dropFirst(6)), "connection_id": route.connectionId, "payload": payload + ]), let text = String(data: envelope, encoding: .utf8) else { return } + task.send(.string(text)) { _ in } + } + + func terminalConnection(for clientId: String, atemId: String? = nil) -> RemoteTerminalConnection? { + dispatchPrecondition(condition: .onQueue(.main)) + let sameTransport: (ConnectedClient) -> Bool = { + $0.id.hasPrefix("relay-") == clientId.hasPrefix("relay-") + } + guard let client = connectedClients.first(where: { $0.id == clientId }) + ?? connectedClients.first(where: { atemId != nil && $0.atemId == atemId && sameTransport($0) }), + client.clientType == "Atem", atemId == nil || client.atemId == atemId, + let sessionId = client.authenticationSessionId, + let authenticatedSession = deviceSessionStore.get(sessionId: sessionId), + authenticatedSession.isValid, authenticatedSession.atemId == client.atemId, + let deviceId = client.atemId, + (try? atemE2EIdentity.isVerified(account: e2eDataAccount, deviceId: deviceId)) == true else { return nil } + if client.id.hasPrefix("relay-") { + guard identityRelayVerified, + let generation = identityRelayAuthentication.connectionId(for: client.id), + identityRelayAuthentication.isAuthenticated(clientId: client.id, connectionId: generation) else { return nil } + return RemoteTerminalConnection(clientId: client.id, connectionId: generation) + } + return RemoteTerminalConnection(clientId: client.id, connectionId: client.id) + } + + func remoteAccessBlockReason(for clientId: String) -> RemoteAccessBlockReason? { + dispatchPrecondition(condition: .onQueue(.main)) + guard let client = connectedClients.first(where: { $0.id == clientId }), client.clientType == "Atem" else { return .offline } + guard let sessionId = client.authenticationSessionId, + let session = deviceSessionStore.get(sessionId: sessionId), session.isValid, + let deviceId = client.atemId, session.atemId == deviceId else { return .authenticationRequired } + do { + guard try atemE2EIdentity.isVerified(account: e2eDataAccount, deviceId: deviceId) else { return .verificationRequired } + } catch { return .identityUnavailable } + return terminalConnection(for: clientId) == nil ? .authenticationRequired : nil + } + + private func remoteTrustDidChange() { + refreshDirectMigrationContexts() + refreshSessionConnections() + agentsByClientId = agentsByClientId.filter { terminalConnection(for: $0.key) != nil } + objectWillChange.send() + terminalConnectionsDidChange?() + } + + @discardableResult + func sendTerminalMessage(_ message: RemoteTerminalMessage, to route: RemoteTerminalConnection) -> Bool { + dispatchPrecondition(condition: .onQueue(.main)) + guard terminalConnection(for: route.clientId) == route, sendHandler != nil else { return false } + guard case .bound(_, let binding) = message, + let client = nativeSessionClients[route.clientId], client.phase == .ready, + binding.attachment_gen == client.welcome?.attachment_gen, + binding == client.binding(for: binding.target, mutation: binding.lease_epoch != nil) else { return false } + sendMessage(.terminal(message), to: route.clientId, + expectedRelayConnectionId: route.clientId.hasPrefix("relay-") ? route.connectionId : nil) + return true + } + + func sessionContext(for clientId: String, atemId: String? = nil) -> AtemSessionContext? { + guard let route = terminalConnection(for: clientId, atemId: atemId), + let client = connectedClients.first(where: { $0.id == route.clientId }), + let device = client.atemId, let session = client.authenticationSessionId else { return nil } + return AtemSessionContext(route: route, account: e2eDataAccount, device: device, pairingSession: session) + } + + @discardableResult + func sendSessionMessage(_ message: AtemSessionMessage, to route: RemoteTerminalConnection) -> Bool { + dispatchPrecondition(condition: .onQueue(.main)) + guard sessionContext(for: route.clientId)?.route == route, sendHandler != nil, + (try? message.validate()) != nil else { return false } + sendMessage(.session(message), to: route.clientId, + expectedRelayConnectionId: route.clientId.hasPrefix("relay-") ? route.connectionId : nil) + return true + } + + private func browserChatConnection(for clientId: String) -> RemoteTerminalConnection? { + // No unbound chat fallback, including before the first native hello. + nil + } + + private func captureSessionContext(clientId: String, relayConnectionId: String?) -> AtemSessionContext? { + if Thread.isMainThread { + guard let context = sessionContext(for: clientId), + relayConnectionId == nil || relayConnectionId == context.route.connectionId else { return nil } + return context } - guard connectedClients.contains(where: { $0.id == targetClientId }) else { - Log.info("[AgentInput] Target Atem offline — key dropped: \(targetClientId)") - return + guard !clientId.hasPrefix("relay-"), relayConnectionId == nil else { return nil } + migrationContextLock.lock(); defer { migrationContextLock.unlock() } + return directSessionContexts[clientId] + } + + /// Legacy requests lack the exact process target and current control lease. + /// Keep callers informed rather than falling back to an unscoped mutation. + @discardableResult + func sendAgentText(_ text: String, agentId: String? = nil, clientId: String? = nil) -> Bool { + sendScopedAgentInput(clientId: clientId ?? routeToFocusedAtem(), agentId: agentId, kind: "text", value: text) + } + + @discardableResult + func sendAgentKey(_ key: String, agentId: String? = nil, clientId: String? = nil) -> Bool { + sendScopedAgentInput(clientId: clientId ?? routeToFocusedAtem(), agentId: agentId, kind: "key", value: key) + } + + @discardableResult + private func sendScopedAgentInput(clientId: String?, agentId: String?, kind: String, value: String) -> Bool { + dispatchPrecondition(condition: .onQueue(.main)) + if let clientId, let route = terminalConnection(for: clientId), + remoteAgentInputHandler?(route, agentId, kind, value) == true { + remoteInputStatusMessage = nil + return true } - let message = AstationMessage.agentInput(agentId: agentId, kind: "key", text: nil, key: key) - sendHandler?(message, targetClientId) - Log.info("[AgentInput] key → \(targetClientId): \(key)") + remoteInputStatusMessage = clientId.flatMap { remoteAccessBlockReason(for: $0)?.localizedDescription } + ?? "Open Terminal and choose Take Control for this agent before sending voice or shortcut input." + if clientId == nil { remoteInputStatusMessage = RemoteAccessBlockReason.offline.localizedDescription } + Log.info("[AgentInput] Rejected input without a session control lease") + return false } // MARK: - Mark Task Routing @@ -1273,12 +1457,9 @@ class AstationHubManager: ObservableObject { // MARK: - Dev Console Command Dispatch - /// Send a userCommand to a specific Atem instance. - func sendCommandToClient(_ command: String, action: String, clientId: String) { - let context = ["action": action] - let message = AstationMessage.userCommand(command: command, context: context) - sendHandler?(message, clientId) - Log.info("[AstationHub] Sent command [\(action)] to \(clientId.prefix(8))...: \(command.prefix(80))") + func sendCommandToClient(_ command: String, action: String, clientId: String) -> Bool { + remoteInputStatusMessage = "Remote console commands were removed. Open Terminal, select a session and take control." + return false } // MARK: - Relay Pairing @@ -1339,7 +1520,7 @@ class AstationHubManager: ObservableObject { case .string(let text): NetworkDebugLogger.logWebSocket(direction: "recv", context: "relay \(clientId)", message: text) if let data = text.data(using: .utf8), - let msg = try? JSONDecoder().decode(AstationMessage.self, from: data) { + let msg = try? AstationMessage.decodePacket(data) { DispatchQueue.main.async { let response = self?.handleMessage(msg, from: clientId) // If there's a response, send it back via relay @@ -1480,6 +1661,7 @@ class AstationHubManager: ObservableObject { self?.handleRelayControlFrame(frame, task: task) } } else if let data = text.data(using: .utf8), + let rawPacket = try? AtemSessionJSON.validatePacket(data), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let atemId = envelope["atem_id"] as? String, DeviceAuthentication.isValidAtemId(atemId), @@ -1499,14 +1681,21 @@ class AstationHubManager: ObservableObject { connectionId: connectionId ) } - } else if let payloadObj = envelope["payload"], - let payloadData = try? JSONSerialization.data(withJSONObject: payloadObj), - let msg = try? JSONDecoder().decode(AstationMessage.self, from: payloadData) { + } else if let rawPayload = try? rawPacket["payload"]?.canonical(allowOtherNumbers: true), + let payloadData = rawPayload.data(using: .utf8), + let msg = try? AstationMessage.decodePacket(payloadData) { DispatchQueue.main.async { guard let self, self.identityRelayTask === task else { Log.debug("[AstationHub] Ignored message from a replaced identity relay socket") return } + if case .browserChat(let frame) = msg { + guard self.identityRelayVerified else { return } + self.browserAgentChat.receive(frame, from: RemoteTerminalConnection( + clientId: relayClientId, connectionId: connectionId)) + return + } + guard !relayClientId.hasPrefix("relay-browser-") else { return } self.handleIdentityRelayMessage( msg, clientId: relayClientId, @@ -1561,6 +1750,8 @@ class AstationHubManager: ObservableObject { .filter { $0.id.hasPrefix("relay-") } .forEach { removeClient(withId: $0.id) } identityRelayAuthentication.removeAll() + browserAgentChat.invalidateAll() + terminalConnectionsDidChange?() let wasVerified = identityRelayVerified identityRelayTask = nil identityRelayVerified = false @@ -1785,6 +1976,8 @@ class AstationHubManager: ObservableObject { identityRelayReconnectGeneration &+= 1 connectedClients.filter { $0.id.hasPrefix("relay-") }.forEach { removeClient(withId: $0.id) } identityRelayAuthentication.removeAll() + browserAgentChat.invalidateAll() + terminalConnectionsDidChange?() task?.cancel(with: .goingAway, reason: nil) } @@ -2030,13 +2223,16 @@ class AstationHubManager: ObservableObject { private func refreshDirectMigrationContexts() { let account = e2eDataAccount var contexts: [String: MigrationContext] = [:] + var sessions: [String: AtemSessionContext] = [:] for client in connectedClients where !client.id.hasPrefix("relay-") { if let route = Self.directVerificationRoute(client) { contexts[client.id] = MigrationContext(account: account, route: route) } + if let context = sessionContext(for: client.id) { sessions[client.id] = context } } migrationContextLock.lock() directMigrationContexts = contexts + directSessionContexts = sessions migrationContextLock.unlock() } @@ -2263,13 +2459,18 @@ class AstationHubManager: ObservableObject { connectionId: String ) { dispatchPrecondition(condition: .onQueue(.main)) + // A browser proves a local capability; it is never paired as an Atem device. + if clientId.hasPrefix("relay-browser-") { return } switch event { case "connected": if identityRelayAuthentication.connect(clientId: clientId, connectionId: connectionId) { + terminalConnectionsDidChange?() removeClient(withId: clientId) } + requestIdentityRelayAuthentication(clientId: clientId, connectionId: connectionId) case "disconnected": if identityRelayAuthentication.disconnect(clientId: clientId, connectionId: connectionId) { + terminalConnectionsDidChange?() removeClient(withId: clientId) } default: @@ -2277,6 +2478,36 @@ class AstationHubManager: ObservableObject { } } + private func requestIdentityRelayAuthentication( + clientId: String, + connectionId: String, + hostname: String = "unknown" + ) { + guard identityRelayVerified, + !identityRelayAuthentication.isAuthenticated(clientId: clientId, connectionId: connectionId) else { return } + // A restored relay socket and a racing hello must use the same proof. + let challenge: String + if let pending = identityRelayAuthentication.challenge(clientId: clientId, connectionId: connectionId) { + challenge = pending + } else { + challenge = DeviceAuthentication.makeChallenge() + guard identityRelayAuthentication.issueChallenge( + clientId: clientId, connectionId: connectionId, challenge: challenge + ) else { + Log.warn("[AstationHub] Relay authentication challenge limit reached") + return + } + } + sendHandler?(.statusUpdate(status: "auth_required", data: [ + "astation_id": AstationIdentity.shared.id, + "challenge": challenge, + "transport": "relay", + "protocol": DeviceAuthentication.protocolVersion, + "hostname": hostname + ]), clientId) + Log.info("[AstationHub] Relay authentication required for \(hostname)") + } + private func handleIdentityRelayMessage( _ msg: AstationMessage, clientId: String, @@ -2297,24 +2528,7 @@ class AstationHubManager: ObservableObject { return } let hostname = DeviceAuthentication.deviceLabel(data["hostname"] ?? "unknown") - let challenge = DeviceAuthentication.makeChallenge() - guard identityRelayAuthentication.issueChallenge( - clientId: clientId, - connectionId: connectionId, - challenge: challenge - ) else { - sendHandler?(.error(message: "Too many pending relay authentication requests"), clientId) - Log.warn("[AstationHub] Relay authentication challenge limit reached") - return - } - sendHandler?(.statusUpdate(status: "auth_required", data: [ - "astation_id": AstationIdentity.shared.id, - "challenge": challenge, - "transport": "relay", - "protocol": DeviceAuthentication.protocolVersion, - "hostname": hostname - ]), clientId) - Log.info("[AstationHub] Relay authentication required for \(hostname)") + requestIdentityRelayAuthentication(clientId: clientId, connectionId: connectionId, hostname: hostname) return } @@ -2341,6 +2555,7 @@ class AstationHubManager: ObservableObject { func broadcastToAuthenticatedIdentityRelayClients(_ message: AstationMessage) { dispatchPrecondition(condition: .onQueue(.main)) + if message.isPrivateAgentTraffic { return } for clientId in identityRelayAuthentication.authenticatedClientIds { sendHandler?(message, clientId) } @@ -2384,6 +2599,7 @@ class AstationHubManager: ObservableObject { atemId: atemId, connectionId: connectionId, hostname: session.hostname, + sessionId: session.id, response: .statusUpdate(status: "authenticated", data: [ "method": "session_proof", "session_id": sessionId, @@ -2430,6 +2646,7 @@ class AstationHubManager: ObservableObject { atemId: atemId, connectionId: connectionId, hostname: hostname, + sessionId: session.id, response: .auth(info: [ "status": "granted", "session_id": session.id, @@ -2444,6 +2661,7 @@ class AstationHubManager: ObservableObject { atemId: String, connectionId: String, hostname: String, + sessionId: String, response: AstationMessage ) { dispatchPrecondition(condition: .onQueue(.main)) @@ -2463,7 +2681,8 @@ class AstationHubManager: ObservableObject { clientType: "Atem", connectedAt: Date(), hostname: "relay:\(hostname)", - atemId: atemId + atemId: atemId, + authenticationSessionId: sessionId ), relayConnectionId: connectionId ) @@ -2516,6 +2735,7 @@ struct ConnectedClient: Identifiable { let connectedAt: Date var hostname: String = "unknown" var atemId: String? + var authenticationSessionId: String? var tag: String = "" var lastActivity: Date = Date() var isFocused: Bool = false diff --git a/Sources/Menubar/AstationMessage.swift b/Sources/Menubar/AstationMessage.swift index 73a8a37..d6e9ab3 100644 --- a/Sources/Menubar/AstationMessage.swift +++ b/Sources/Menubar/AstationMessage.swift @@ -2,6 +2,13 @@ import Foundation // JSON message protocol for communication between Atem and Astation enum AstationMessage: Codable { + static func decodePacket(_ data: Data) throws -> AstationMessage { + let root = try AtemSessionJSON.validatePacket(data) + let decoder = JSONDecoder() + decoder.userInfo[AtemSessionJSON.rawRootKey] = root + return try decoder.decode(Self.self, from: data) + } + case projectListRequest case projectListResponse(projects: [AgoraProject], timestamp: Date) @@ -56,6 +63,10 @@ enum AstationMessage: Codable { // Remote agent control (Astation → Atem): text or key input to the agent PTY. case agentInput(agentId: String?, kind: String, text: String?, key: String?) + case terminal(RemoteTerminalMessage) + case session(AtemSessionMessage) + case agentChat(AgentChatWire) + case browserChat(BrowserChatFrame) case encryptionMode(accountState: AtemSignedWire) case keyRequest(publicKey: String) @@ -75,6 +86,13 @@ enum AstationMessage: Codable { case storageKeyAbandon(abandon: AtemSignedWire) case encryptionMigrationComplete(mode: String, kid: String, completion: AtemSignedWire? = nil) + var isPrivateAgentTraffic: Bool { + switch self { + case .terminal, .session, .agentChat, .browserChat: return true + default: return false + } + } + // Custom encoding/decoding to handle the enum cases private enum CodingKeys: String, CodingKey { case type @@ -112,6 +130,9 @@ enum AstationMessage: Codable { case credentialProjectListRequest, credentialProjectListResponse case credentialSync case agentInput + case terminal + case session + case agentChat, browserChat case encryptionMode case keyRequest case keyGrant @@ -305,6 +326,23 @@ enum AstationMessage: Codable { try dc.encodeIfPresent(text, forKey: .text) try dc.encodeIfPresent(key, forKey: .key) + case .terminal(let message): + try container.encode(MessageType.terminal, forKey: .type) + try container.encode(message, forKey: .data) + + case .session(let message): + try message.validate() + try container.encode(MessageType.session, forKey: .type) + try container.encode(message, forKey: .data) + + case .agentChat(let message): + try container.encode(MessageType.agentChat, forKey: .type) + try container.encode(message, forKey: .data) + + case .browserChat(let frame): + try container.encode(MessageType.browserChat, forKey: .type) + try container.encode(frame, forKey: .data) + case .encryptionMode(let accountState): try container.encode(MessageType.encryptionMode, forKey: .type) var dc = container.nestedContainer(keyedBy: EncryptionKeys.self, forKey: .data) @@ -594,6 +632,24 @@ enum AstationMessage: Codable { let key = try dc.decodeIfPresent(String.self, forKey: .key) self = .agentInput(agentId: agentId, kind: kind, text: text, key: key) + case .terminal: + self = .terminal(try container.decode(RemoteTerminalMessage.self, forKey: .data)) + + case .session: + let message = try container.decode(AtemSessionMessage.self, forKey: .data) + try message.validate() + self = .session(message) + + case .agentChat: + let message = try container.decode(AgentChatWire.self, forKey: .data) + if message.op == "error" { + try AtemSessionWire.check(message.code.map { !$0.isEmpty && $0.utf8.count <= 128 } == true) + } + self = .agentChat(message) + + case .browserChat: + self = .browserChat(try container.decode(BrowserChatFrame.self, forKey: .data)) + case .encryptionMode: let dc = try container.nestedContainer(keyedBy: EncryptionKeys.self, forKey: .data) self = .encryptionMode( @@ -792,11 +848,12 @@ struct AtemAgentInfo: Codable, Identifiable { let kind: String /// Protocol string from Rust: "Acp" or "Pty". let agentProtocol: String - /// Status string from Rust: "Idle", "Thinking", "WaitingForInput", "Disconnected". + /// Semantic registry state. Missing or unfamiliar values never imply readiness. let status: String let sessionIds: [String] let acpUrl: String? let ptyPid: UInt32? + var capabilities: [String] = [] enum CodingKeys: String, CodingKey { case id, name, kind, status @@ -804,6 +861,20 @@ struct AtemAgentInfo: Codable, Identifiable { case sessionIds = "session_ids" case acpUrl = "acp_url" case ptyPid = "pty_pid" + case capabilities + } + + init(session: AtemSessionDescriptor) { + id = session.session_id + kind = session.provider == "claude" ? "Claude Code" : session.provider == "codex" ? "Codex" : session.provider + name = session.cwd_label.isEmpty ? kind : session.cwd_label + agentProtocol = "Pty" + status = session.state != "running" ? "Disconnected" + : ["ready": "Idle", "working": "Thinking", "waiting": "WaitingForInput"][session.activity] ?? "Unknown" + sessionIds = [session.session_id] + acpUrl = nil + ptyPid = nil + capabilities = session.capabilities } init(from decoder: Decoder) throws { @@ -822,10 +893,13 @@ struct AtemAgentInfo: Codable, Identifiable { kind = "Unknown" } agentProtocol = (try? c.decode(String.self, forKey: .agentProtocol)) ?? "Pty" - status = (try? c.decode(String.self, forKey: .status)) ?? "Idle" + let reportedStatus = (try? c.decode(String.self, forKey: .status)) ?? "Unknown" + status = ["Idle", "Thinking", "WaitingForInput", "Disconnected", "Unknown"].contains(reportedStatus) + ? reportedStatus : "Unknown" sessionIds = (try? c.decode([String].self, forKey: .sessionIds)) ?? [] acpUrl = try? c.decodeIfPresent(String.self, forKey: .acpUrl) ptyPid = try? c.decodeIfPresent(UInt32.self, forKey: .ptyPid) + capabilities = (try? c.decode([String].self, forKey: .capabilities)) ?? [] } func encode(to encoder: Encoder) throws { @@ -835,6 +909,7 @@ struct AtemAgentInfo: Codable, Identifiable { try c.encode(kind, forKey: .kind) try c.encode(agentProtocol, forKey: .agentProtocol) try c.encode(status, forKey: .status) + if !capabilities.isEmpty { try c.encode(capabilities, forKey: .capabilities) } try c.encode(sessionIds, forKey: .sessionIds) try c.encodeIfPresent(acpUrl, forKey: .acpUrl) try c.encodeIfPresent(ptyPid, forKey: .ptyPid) diff --git a/Sources/Menubar/AstationWebSocketServer.swift b/Sources/Menubar/AstationWebSocketServer.swift index f9b4019..408ade5 100644 --- a/Sources/Menubar/AstationWebSocketServer.swift +++ b/Sources/Menubar/AstationWebSocketServer.swift @@ -14,6 +14,7 @@ class AstationWebSocketServer { private let sessionStore: SessionStore private let localBootstrapStore: LocalBootstrapStore? private var authenticatedClients: Set = [] // Client IDs that have been authenticated + private var authenticatedSessionIds: [String: String] = [:] private var pendingAuthentication: [String: DirectAuthenticationContext] = [:] private var pairingClients: Set = [] @@ -102,6 +103,7 @@ class AstationWebSocketServer { ws.onClose.whenComplete { _ in self.connectedClients.removeValue(forKey: clientId) self.authenticatedClients.remove(clientId) + self.authenticatedSessionIds.removeValue(forKey: clientId) self.pendingAuthentication.removeValue(forKey: clientId) self.pairingClients.remove(clientId) self.hubManager.removeClient(withId: clientId) @@ -126,8 +128,8 @@ class AstationWebSocketServer { NetworkDebugLogger.logWebSocket(direction: "recv", context: "local \(clientId.prefix(8))", message: text) guard let data = text.data(using: .utf8), - let message = try? JSONDecoder().decode(AstationMessage.self, from: data) else { - Log.error("❌ Failed to decode message from \(clientId.prefix(8)): \(text.prefix(100))") + let message = try? AstationMessage.decodePacket(data) else { + Log.error("❌ Failed to decode message from \(clientId.prefix(8)) (\(text.utf8.count) bytes)") return } @@ -138,6 +140,11 @@ class AstationWebSocketServer { return } + if message.isPrivateAgentTraffic, !hasCurrentSession(clientId) { + _ = ws.close(code: .policyViolation) + return + } + if case .statusUpdate(let status, let messageData) = message, status == "session_verify_request" { // This legacy control message is deliberately protected by v2 authentication. handleSessionVerifyRequest(messageData, from: clientId) @@ -206,7 +213,7 @@ class AstationWebSocketServer { ] ) sendMessage(successMsg, to: clientId) - registerClient(clientId, hostname: session.hostname, atemId: atemId) + registerClient(clientId, hostname: session.hostname, atemId: atemId, sessionId: session.id) Log.info("Client \(clientId.prefix(8)) authenticated via LAN session proof") return @@ -275,17 +282,19 @@ class AstationWebSocketServer { "token": session.token, "protocol": DeviceAuthentication.protocolVersion ]), to: clientId) - registerClient(clientId, hostname: hostname, atemId: atemId) + registerClient(clientId, hostname: hostname, atemId: atemId, sessionId: session.id) Log.info("Loopback client \(clientId.prefix(8)) authenticated without interactive pairing") } - private func registerClient(_ clientId: String, hostname: String, atemId: String?) { + private func registerClient(_ clientId: String, hostname: String, atemId: String?, sessionId: String) { + authenticatedSessionIds[clientId] = sessionId hubManager.addClient(ConnectedClient( id: clientId, clientType: "Atem", connectedAt: Date(), hostname: hostname, - atemId: atemId + atemId: atemId, + authenticationSessionId: sessionId )) } @@ -375,7 +384,7 @@ class AstationWebSocketServer { "token": session.token, "protocol": DeviceAuthentication.protocolVersion ]), to: clientId) - self.registerClient(clientId, hostname: hostname, atemId: atemId) + self.registerClient(clientId, hostname: hostname, atemId: atemId, sessionId: session.id) Log.info("✅ Pairing approved for \(hostname) (\(clientId.prefix(8)))") } else { self.sendMessage(.error(message: "Pairing denied by user"), to: clientId) @@ -387,6 +396,10 @@ class AstationWebSocketServer { } private func sendMessage(_ message: AstationMessage, to clientId: String) { + if message.isPrivateAgentTraffic, !hasCurrentSession(clientId) { + _ = connectedClients[clientId]?.close(code: .policyViolation) + return + } guard let ws = connectedClients[clientId], let data = try? JSONEncoder().encode(message), let text = String(data: data, encoding: .utf8) else { @@ -397,6 +410,12 @@ class AstationWebSocketServer { ws.send(text) NetworkDebugLogger.logWebSocket(direction: "send", context: "local \(clientId)", message: text) } + + private func hasCurrentSession(_ clientId: String) -> Bool { + guard authenticatedClients.contains(clientId), let sessionId = authenticatedSessionIds[clientId], + let session = sessionStore.get(sessionId: sessionId) else { return false } + return session.isValid + } func sendMessageToClient(_ message: AstationMessage, clientId: String) { executeOnStateEventLoop { @@ -404,6 +423,15 @@ class AstationWebSocketServer { } } + func disconnectClients(authenticatedWith sessionIds: [String]) { + executeOnStateEventLoop { + let removed = Set(sessionIds) + for (clientId, sessionId) in self.authenticatedSessionIds where removed.contains(sessionId) { + _ = self.connectedClients[clientId]?.close(code: .policyViolation) + } + } + } + func broadcastMessage(_ message: AstationMessage) { executeOnStateEventLoop { self.broadcastMessageOnEventLoop(message) @@ -411,6 +439,7 @@ class AstationWebSocketServer { } private func broadcastMessageOnEventLoop(_ message: AstationMessage) { + if message.isPrivateAgentTraffic { return } guard let data = try? JSONEncoder().encode(message), let text = String(data: data, encoding: .utf8) else { Log.error("Failed to encode broadcast message") diff --git a/Sources/Menubar/AtemAuthenticationLayout.swift b/Sources/Menubar/AtemAuthenticationLayout.swift index 49948af..21fda76 100644 --- a/Sources/Menubar/AtemAuthenticationLayout.swift +++ b/Sources/Menubar/AtemAuthenticationLayout.swift @@ -52,11 +52,12 @@ enum AtemAuthenticationLayout { return card(stack, accented: true) } - static func touchID(_ authentication: LAAuthenticationView, status: NSTextField) -> NSView { + static func touchID(_ authentication: LAAuthenticationView, status: NSTextField, + passwordButton: NSButton) -> NSView { let size = authentication.fittingSize authentication.widthAnchor.constraint(equalToConstant: size.width).isActive = true authentication.heightAnchor.constraint(equalToConstant: size.height).isActive = true - let title = NSTextField(labelWithString: "Touch ID") + let title = NSTextField(labelWithString: "Verify on This Mac") title.font = .systemFont(ofSize: 13, weight: .semibold) status.font = .systemFont(ofSize: 12) status.textColor = .secondaryLabelColor @@ -64,7 +65,9 @@ enum AtemAuthenticationLayout { status.widthAnchor.constraint(equalToConstant: status.preferredMaxLayoutWidth).isActive = true // Reserve room for failure guidance so the window does not jump during authentication. status.heightAnchor.constraint(greaterThanOrEqualToConstant: 60).isActive = true - let text = NSStackView(views: [title, status]) + passwordButton.bezelStyle = .rounded + passwordButton.controlSize = .small + let text = NSStackView(views: [title, status, passwordButton]) text.orientation = .vertical text.alignment = .leading text.spacing = 6 diff --git a/Sources/Menubar/AtemOwnerAuthentication.swift b/Sources/Menubar/AtemOwnerAuthentication.swift new file mode 100644 index 0000000..b27e6f9 --- /dev/null +++ b/Sources/Menubar/AtemOwnerAuthentication.swift @@ -0,0 +1,70 @@ +import Foundation +import LocalAuthentication + +/// The embedded context cannot present password entry; fallback owns a fresh one. +final class AtemOwnerAuthentication { + private let biometricContext: LAContext + private let makePasswordContext: () -> LAContext + private var passwordContext: LAContext? + private var attempt: UUID? + private var biometricInvalidated = false + private var cancelled = false + + init(context: LAContext, makePasswordContext: @escaping () -> LAContext = { LAContext() }) { + biometricContext = context + self.makePasswordContext = makePasswordContext + context.localizedFallbackTitle = "" + context.touchIDAuthenticationAllowableReuseDuration = 0 + } + + @discardableResult + func biometrics(reason: String, completion: @escaping (Bool) -> Void) -> Bool { + guard !cancelled, !biometricInvalidated else { return false } + return evaluate(biometricContext, policy: .deviceOwnerAuthenticationWithBiometrics, + reason: reason, completion: completion) + } + + @discardableResult + func password(reason: String, completion: @escaping (Bool) -> Void) -> Bool { + guard !cancelled else { return false } + attempt = nil + invalidateBiometrics() + passwordContext?.invalidate() + let context = makePasswordContext() + passwordContext = context + context.localizedFallbackTitle = "Use Password..." + context.touchIDAuthenticationAllowableReuseDuration = 0 + return evaluate(context, policy: .deviceOwnerAuthentication, reason: reason, completion: completion) + } + + private func evaluate(_ context: LAContext, policy: LAPolicy, reason: String, + completion: @escaping (Bool) -> Void) -> Bool { + var error: NSError? + guard context.canEvaluatePolicy(policy, error: &error) else { return false } + let id = UUID() + attempt = id + context.evaluatePolicy(policy, localizedReason: reason) { [weak self] accepted, _ in + DispatchQueue.main.async { + guard let self, !self.cancelled, self.attempt == id else { return } + self.attempt = nil + completion(accepted) + } + } + return true + } + + func cancel() { + guard !cancelled else { return } + cancelled = true + attempt = nil + invalidateBiometrics() + passwordContext?.invalidate() + passwordContext = nil + } + + private func invalidateBiometrics() { + guard !biometricInvalidated else { return } + biometricInvalidated = true + biometricContext.invalidate() + } +} diff --git a/Sources/Menubar/AtemSafetyCodePrompt.swift b/Sources/Menubar/AtemSafetyCodePrompt.swift index 8f18ce3..35c358f 100644 --- a/Sources/Menubar/AtemSafetyCodePrompt.swift +++ b/Sources/Menubar/AtemSafetyCodePrompt.swift @@ -6,23 +6,27 @@ final class AtemSafetyCodePrompt: NSObject, NSWindowDelegate { let alert: NSAlert let safetyCode: NSTextField let authenticationView: LAAuthenticationView + let passwordButton = NSButton(title: "Use Mac Password...", target: nil, action: nil) let status = NSTextField(wrappingLabelWithString: - "After comparing the codes, click Codes Match to enable Touch ID.") - private let context: LAContext + "After comparing the codes, click Codes Match, then use Touch ID or your Mac password.") + private let authentication: AtemOwnerAuthentication private var completion: ((Bool) -> Void)? private var shown = false private var codesMatched = false + private var usingPassword = false var isActive: Bool { completion != nil } - init(request: AtemVerificationApproval, context: LAContext, completion: @escaping (Bool) -> Void) { + init(request: AtemVerificationApproval, context: LAContext, + makePasswordContext: @escaping () -> LAContext = { LAContext() }, completion: @escaping (Bool) -> Void) { alert = AtemVerificationUI.verificationAlert() safetyCode = NSTextField(labelWithString: request.safetyCode) - self.context = context + authentication = AtemOwnerAuthentication(context: context, makePasswordContext: makePasswordContext) self.completion = completion - context.localizedFallbackTitle = "" - context.touchIDAuthenticationAllowableReuseDuration = 0 authenticationView = LAAuthenticationView(context: context, controlSize: .regular) super.init() + passwordButton.target = self + passwordButton.action = #selector(usePassword) + passwordButton.isEnabled = false safetyCode.font = .monospacedSystemFont(ofSize: 26, weight: .semibold) safetyCode.alignment = .center @@ -31,7 +35,7 @@ final class AtemSafetyCodePrompt: NSObject, NSWindowDelegate { alert.accessoryView = AtemAuthenticationLayout.content([ AtemAuthenticationLayout.details([("Device", AtemVerificationUI.displayText(request.deviceName))]), AtemAuthenticationLayout.code(safetyCode), - AtemAuthenticationLayout.touchID(authenticationView, status: status), + AtemAuthenticationLayout.touchID(authenticationView, status: status, passwordButton: passwordButton), AtemAuthenticationLayout.note("If the codes differ, choose Deny and pair again.") ]) } @@ -53,30 +57,48 @@ final class AtemSafetyCodePrompt: NSObject, NSWindowDelegate { guard isActive, shown, !codesMatched else { return } codesMatched = true alert.buttons[0].isEnabled = false - status.stringValue = "Place your finger on Touch ID to verify this device." + passwordButton.isEnabled = true + status.stringValue = "Use Touch ID to verify this device, or choose Use Mac Password." // Keep comparison explicit; evaluating on presentation could accept an incidental match. DispatchQueue.main.async { [weak self] in self?.authenticate() } } private func authenticate() { - guard isActive, codesMatched else { return } - var error: NSError? - guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { - status.stringValue = "Touch ID is unavailable. Unlock this Mac and enable Touch ID, then retry pairing. Verification cannot be approved." - return + guard isActive, codesMatched, !usingPassword else { return } + if !authentication.biometrics(reason: "verify this Atem's safety code", completion: { [weak self] accepted in + guard let self, self.isActive else { return } + if accepted { self.finish(true) } + else { self.status.stringValue = "Touch ID did not complete. Choose Use Mac Password to continue, or Deny." } + }) { + status.stringValue = "Touch ID is unavailable. Choose Use Mac Password to verify with your Mac login password." } - context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, - localizedReason: "verify this Atem's safety code") { [weak self] accepted, _ in - DispatchQueue.main.async { self?.finish(accepted) } + } + + @objc private func usePassword() { + guard isActive, shown, codesMatched, passwordButton.isEnabled else { return } + usingPassword = true + passwordButton.isEnabled = false + status.stringValue = "Authenticate in the macOS dialog. Choose Use Password if Touch ID is offered." + if !authentication.password(reason: "verify this Atem's safety code", completion: { [weak self] accepted in + guard let self, self.isActive else { return } + if accepted { self.finish(true) } + else { self.passwordFailed() } + }) { + passwordFailed() } } + private func passwordFailed() { + passwordButton.isEnabled = true + status.stringValue = "Authentication did not complete. Choose Use Mac Password to retry, or Deny." + } + @objc private func deny() { finish(false) } func finish(_ accepted: Bool) { guard let callback = completion else { return } completion = nil - context.invalidate() + authentication.cancel() alert.window.close() callback(accepted && codesMatched) } diff --git a/Sources/Menubar/AtemSessionClient.swift b/Sources/Menubar/AtemSessionClient.swift new file mode 100644 index 0000000..d2e0025 --- /dev/null +++ b/Sources/Menubar/AtemSessionClient.swift @@ -0,0 +1,498 @@ +import Foundation + +struct AtemSessionContext: Equatable { + let route: RemoteTerminalConnection + let account: String + let device: String + let pairingSession: String +} + +/// Compatibility mode only: this context must come from the hub's existing +/// authenticated, safety-code-verified connection. Section 9 features stay off. +final class AtemSessionClient { + enum Phase { case offline, connecting, ready, failed } + private(set) var phase: Phase = .offline + private(set) var status = "Connect a verified Atem to use sessions." + private(set) var context: AtemSessionContext? + private(set) var welcome: AtemSessionMessage? + private(set) var registry: [String: AtemSessionDescriptor] = [:] + private(set) var latestReceipt: AtemSessionReceipt? + var onChange: (() -> Void)? + + private struct Pending { + let message: AtemSessionMessage + let sent: TimeInterval + let previousDeadline: TimeInterval? + } + private struct Lease { + let target: AtemSessionTarget + let epoch: UInt64 + let deadline: TimeInterval + let renewAt: TimeInterval + } + private let currentContext: () -> AtemSessionContext? + private let sendMessage: (AtemSessionMessage, RemoteTerminalConnection) -> Bool + private let now: () -> TimeInterval + private let makeID: () -> String + let journal: AtemSessionJournal + private var pending: [String: Pending] = [:] + private var leases: [String: Lease] = [:] + private var highestLeaseEpoch: [String: UInt64] = [:] + private var revokedLeaseEpoch: [String: UInt64] = [:] + private var authorizationDeadline: TimeInterval = 0 + private(set) var runtimeEpoch: UInt64? + private var runtimeGeneration: String? + private(set) var sequence: UInt64? + private var resyncing = true + private var tombstones: [String: Set] = [:] + private var started = false + private var lastReceiptQuery: TimeInterval = 0 + var hasRegistrySnapshot: Bool { phase == .ready && !resyncing } + + init(context: @escaping () -> AtemSessionContext?, + send: @escaping (AtemSessionMessage, RemoteTerminalConnection) -> Bool, + journal: AtemSessionJournal = AtemSessionJournal(), + makeID: @escaping () -> String = { UUID().uuidString.lowercased() }, + now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { + currentContext = context + sendMessage = send + self.journal = journal + self.now = now + self.makeID = makeID + } + + func start() { started = true; connectionChanged() } + + func connectionChanged() { + guard started else { return } + let current = currentContext() + guard current != context else { return } + context = current + welcome = nil + registry = [:] + latestReceipt = nil + leases = [:] + highestLeaseEpoch = [:] + revokedLeaseEpoch = [:] + pending = [:] + sequence = nil + runtimeEpoch = nil + runtimeGeneration = nil + tombstones = [:] + resyncing = true + phase = current == nil ? .offline : .connecting + status = current == nil ? "Atem is offline. Unconfirmed requests will only be queried, never replayed." + : "Opening an authenticated Atem session..." + guard current != nil else { changed(); return } + guard !journal.loadFailed else { fail("Session recovery metadata is unreadable. Remote control is disabled."); return } + _ = request(AtemSessionMessage(op: "hello")) + changed() + } + + func target(sessionId: String, process: String) -> AtemSessionTarget? { + guard phase == .ready, context == currentContext(), now() < authorizationDeadline, !resyncing, + let context, let welcome, let trust = welcome.host_trust_gen, + let descriptor = registry[sessionId], descriptor.process_gen == process, descriptor.state != "removed" else { return nil } + return AtemSessionTarget(account_id: context.account, atem_device_id: context.device, + host_trust_gen: trust, session_id: sessionId, process_gen: process) + } + + func binding(for target: AtemSessionTarget, mutation: Bool) -> AtemSessionBinding? { + guard target == self.target(sessionId: target.session_id, process: target.process_gen), + let attachment = welcome?.attachment_gen else { return nil } + if mutation { + guard welcome?.permissions?.contains("control") == true, registry[target.session_id]?.state == "running", + let lease = leases[target.session_id], lease.target == target, now() < lease.deadline else { return nil } + return AtemSessionBinding(target: target, attachment_gen: attachment, lease_epoch: lease.epoch) + } + return AtemSessionBinding(target: target, attachment_gen: attachment) + } + + func controls(_ target: AtemSessionTarget) -> Bool { binding(for: target, mutation: true) != nil } + func controlDeadline(for target: AtemSessionTarget) -> TimeInterval? { leases[target.session_id]?.deadline } + + func acquiring(_ target: AtemSessionTarget) -> Bool { + pending.values.contains { $0.message.op == "acquire" && $0.message.binding?.target == target } + } + + func acquire(_ target: AtemSessionTarget, takeover: Bool = true) { + guard welcome?.permissions?.contains("control") == true, let binding = binding(for: target, mutation: false), + registry[target.session_id]?.state == "running", !acquiring(target) else { return } + if let old = leases[target.session_id], now() >= old.deadline { + leases.removeValue(forKey: target.session_id) + pending = pending.filter { $0.value.message.binding?.target != target || !["acquire", "renew"].contains($0.value.message.op) } + } + var message = AtemSessionMessage(op: "acquire") + message.binding = binding + message.takeover = takeover + _ = request(message, deadline: leases[target.session_id]?.deadline) + status = "Requesting control of this session..." + changed() + } + + func release(_ target: AtemSessionTarget) { + let binding = binding(for: target, mutation: true) + leases.removeValue(forKey: target.session_id) + pending = pending.filter { $0.value.message.binding?.target != target || !["acquire", "renew"].contains($0.value.message.op) } + if let binding { + var message = AtemSessionMessage(op: "release") + message.binding = binding + _ = request(message) + } + status = "Viewing. Control has been released." + changed() + } + + func releaseAll() { + let targets = leases.values.map(\.target) + for target in targets { release(target) } + } + + func renew(_ target: AtemSessionTarget) { + guard let binding = binding(for: target, mutation: true), + !pending.values.contains(where: { $0.message.op == "renew" && $0.message.binding?.target == target }), + let deadline = leases[target.session_id]?.deadline else { return } + var message = AtemSessionMessage(op: "renew") + message.binding = binding + _ = request(message, deadline: deadline) + } + + @discardableResult + func submit(_ target: AtemSessionTarget, kind: String, text: String? = nil) -> String? { + guard pending.count < 64, let binding = binding(for: target, mutation: true), ["prompt", "interrupt", "stop"].contains(kind) else { return nil } + if kind == "prompt", registry[target.session_id]?.capabilities.contains("chat-v1") != true { + status = "This agent accepts terminal input only. Type or paste in its terminal." + changed() + return nil + } + let payload: AtemSessionJSON = kind == "prompt" ? .object(["text": .string(text ?? "")]) : .object([:]) + var message = AtemSessionMessage(op: "submit") + message.binding = binding + message.request_id = UUID().uuidString.lowercased() + message.kind = kind + message.payload = payload + do { + try message.validate() + let record = AtemSessionRequestRecord(requestId: message.request_id!, target: target, kind: kind, digest: try payload.digest()) + // Persist the identity before sending. Reconnect uses receipt_query; + // prompt text is never stored or automatically submitted again. + try journal.update { state in + if state.requests.count >= 4096, let oldestFinal = state.requests.firstIndex(where: { $0.receipt?.state.final == true }) { + state.requests.remove(at: oldestFinal) + } + state.requests.append(record) + } + } catch { status = "Could not save this request safely. Nothing was sent."; changed(); return nil } + let delivered = request(message) + latestReceipt = nil + status = delivered ? "Submitted. Waiting for a durable receipt." + : "Delivery is uncertain. Reconnect will query the request without resending it." + changed() + return message.request_id + } + + func refresh() { + // A second hello on a welcomed attachment is unauthorized in r6.2. + // Failed attachments recover only through a new authenticated connection. + guard phase == .ready else { return } + resyncing = true + if !pending.values.contains(where: { $0.message.op == "list" }) { _ = request(AtemSessionMessage(op: "list")) } + } + + func receive(_ message: AtemSessionMessage, from source: RemoteTerminalConnection) { + guard context?.route == source, context == currentContext(), phase != .failed else { return } + do { + try message.validate() + if message.op == "welcome" { + guard phase == .connecting, let re = message.re, pending[re]?.message.op == "hello" else { return } + try acceptWelcome(message); changed(); return + } + if message.op == "error", message.re == nil { + status = "Atem: " + (message.code ?? "request failed") + if phase == .connecting || ["unauthorized", "paused", "unsupported_version"].contains(message.code ?? "") { + fail(status + ". Reconnect with matching Astation and Atem builds.") + } + changed(); return + } + if phase == .connecting, message.op == "error", let re = message.re, pending[re]?.message.op == "hello" { + fail("Atem could not open a session: " + (message.code ?? "request failed")); changed(); return + } + guard phase == .ready, now() < authorizationDeadline else { return } + switch message.op { + case "sessions", "session_changed": try acceptRegistry(message) + case "lease": acceptLease(message) + case "lease_changed": acceptLeaseChange(message) + case "lease_released": + if let re = message.re, let operation = pending.removeValue(forKey: re), operation.message.op == "release" { break } + case "receipt": + if let re = message.re { + guard let operation = pending[re], operation.message.op == "submit", + operation.message.request_id == message.request_id, + operation.message.binding?.target == message.target, + operation.message.kind == message.kind else { return } + pending.removeValue(forKey: re) + } + if let receipt = message.receipt { try acceptReceipt(receipt) } + case "receipts": + guard let re = message.re, let operation = pending.removeValue(forKey: re), operation.message.op == "receipt_query" else { return } + for receipt in message.receipts ?? [] { + guard operation.message.binding?.target == receipt.target, + operation.message.request_ids?.contains(receipt.request_id) == true else { throw AtemSessionJSON.Invalid.json } + try acceptReceipt(receipt) + } + case "error": + guard let re = message.re, let operation = pending.removeValue(forKey: re) else { return } + if let target = operation.message.binding?.target, + ["acquire", "renew", "release"].contains(operation.message.op) { leases.removeValue(forKey: target.session_id) } + status = "Atem: " + (message.code ?? "request failed") + handleRefusal(code: message.code ?? "request failed", target: operation.message.binding?.target) + if message.code == "busy", operation.message.op == "submit" { + status = "Atem is busy. Checking the receipt without resending this request." + } + default: break + } + } catch { fail("Atem sent an invalid or inconsistent session message. Reconnect to continue.") } + changed() + } + + func tick() { + connectionChanged() + guard phase == .ready else { + if phase == .connecting, pending.values.contains(where: { now() - $0.sent >= 8 }) { + fail("This Atem did not open a session. Update Atem to enable remote control.") + changed() + } + return + } + let time = now() + if time >= authorizationDeadline { fail("Session authorization expired. Reconnect the verified Atem."); changed(); return } + for (session, lease) in leases { + if time >= lease.deadline { + leases.removeValue(forKey: session) + status = "Control expired. Take Control explicitly to type again." + changed() + } else if time >= lease.renewAt, !pending.values.contains(where: { $0.message.op == "renew" && $0.message.binding?.target == lease.target }) { + renew(lease.target) + } + } + let timedOut = pending.filter { time - $0.value.sent >= 15 } + for (id, operation) in timedOut { + pending.removeValue(forKey: id) + if operation.message.op == "submit" { status = "Receipt delayed. The request will not be replayed." } + } + if time - lastReceiptQuery >= 5 { queryUnresolvedReceipts() } + } + + private func acceptWelcome(_ message: AtemSessionMessage) throws { + guard phase == .connecting, let re = message.re, let hello = pending.removeValue(forKey: re), hello.message.op == "hello", + let context, message.account_id == context.account, message.atem_device_id == context.device, + message.client_kind == "native", message.permissions?.contains("view") == true, + let remaining = message.authorization_ms_left, remaining > 0, remaining <= 86_400_000, + let trust = message.host_trust_gen else { throw AtemSessionJSON.Invalid.json } + try journal.update { state in + if let index = state.hosts.firstIndex(where: { $0.account == context.account && $0.device == context.device }) { + let previous = state.hosts[index] + try AtemSessionWire.check(trust >= previous.trust) + if trust > previous.trust { state.hosts[index] = AtemSessionHostFence(account: context.account, device: context.device, trust: trust) } + } else { state.hosts.append(AtemSessionHostFence(account: context.account, device: context.device, trust: trust)) } + } + if let fence = journal.snapshot.hosts.first(where: { $0.account == context.account && $0.device == context.device }) { + runtimeEpoch = fence.runtimeEpoch + runtimeGeneration = fence.runtimeGeneration + } + authorizationDeadline = min(hello.sent + Double(remaining) / 1000, now() + Double(remaining) / 1000) + try AtemSessionWire.check(now() < authorizationDeadline) + welcome = message + // Sequence numbers, removals and authority belong to this attachment; + // only the durable trust/runtime floor survives a new welcome. + registry = [:] + sequence = nil + tombstones = [:] + leases = [:] + highestLeaseEpoch = [:] + revokedLeaseEpoch = [:] + resyncing = true + latestReceipt = nil + phase = .ready + status = "Viewing. Select a session and take control explicitly." + refresh() + queryUnresolvedReceipts() + } + + private func acceptRegistry(_ message: AtemSessionMessage) throws { + guard let epoch = message.runtime_epoch, let generation = message.runtime_gen, let incoming = message.seq else { return } + if let runtimeEpoch, epoch < runtimeEpoch { return } + if epoch == runtimeEpoch, let runtimeGeneration, generation != runtimeGeneration { throw AtemSessionJSON.Invalid.json } + if generation != welcome?.runtime_gen { + if let runtimeEpoch, epoch > runtimeEpoch { + try persistRuntime(epoch: epoch, generation: generation) + // The old attachment cannot be welcomed again. Wait for the + // host's replacement authenticated connection before hello. + registry = [:] + sequence = nil + tombstones = [:] + resyncing = true + fail("Atem restarted. Reconnect to open a fresh session attachment.") + } + return + } + let previousRegistry = registry + let newEpoch = epoch != runtimeEpoch + if newEpoch { + try persistRuntime(epoch: epoch, generation: generation) + sequence = nil + leases = [:] + highestLeaseEpoch = [:] + revokedLeaseEpoch = [:] + pending = pending.filter { !["acquire", "renew", "release"].contains($0.value.message.op) } + registry = [:] + tombstones = [:] + resyncing = true + } + if message.op == "sessions" { + guard sequence.map({ incoming >= $0 }) != false else { return } + let descriptors = message.sessions ?? [] + try AtemSessionWire.check(Set(descriptors.map(\.session_id)).count == descriptors.count) + var next: [String: AtemSessionDescriptor] = [:] + for descriptor in descriptors { + if tombstones[descriptor.session_id]?.contains(descriptor.process_gen) == true { continue } + if descriptor.state == "removed" { tombstones[descriptor.session_id, default: []].insert(descriptor.process_gen) } + else { next[descriptor.session_id] = descriptor } + } + registry = next + sequence = incoming + resyncing = false + pending = pending.filter { $0.value.message.op != "list" } + } else { + guard !newEpoch, !resyncing, let previous = sequence else { refresh(); return } + guard incoming > previous else { return } + guard incoming == previous + 1 else { refresh(); return } + guard let descriptor = message.descriptor else { return } + sequence = incoming + if descriptor.state == "removed" { + registry.removeValue(forKey: descriptor.session_id) + tombstones[descriptor.session_id, default: []].insert(descriptor.process_gen) + } else if tombstones[descriptor.session_id]?.contains(descriptor.process_gen) != true { registry[descriptor.session_id] = descriptor } + } + for (id, lease) in leases where target(sessionId: id, process: lease.target.process_gen) != lease.target || registry[id]?.state != "running" { + leases.removeValue(forKey: id) + } + for (id, descriptor) in previousRegistry where registry[id]?.process_gen != descriptor.process_gen { + highestLeaseEpoch.removeValue(forKey: id) + revokedLeaseEpoch.removeValue(forKey: id) + pending = pending.filter { $0.value.message.binding?.target.session_id != id || !["acquire", "renew"].contains($0.value.message.op) } + } + try AtemSessionWire.check(tombstones.values.reduce(0) { $0 + $1.count } <= 4096) + } + + private func persistRuntime(epoch: UInt64, generation: String) throws { + guard let context, let trust = welcome?.host_trust_gen else { throw AtemSessionJSON.Invalid.json } + try journal.update { state in + guard let index = state.hosts.firstIndex(where: { $0.account == context.account && $0.device == context.device && $0.trust == trust }) else { throw AtemSessionJSON.Invalid.json } + state.hosts[index].runtimeEpoch = epoch + state.hosts[index].runtimeGeneration = generation + } + runtimeEpoch = epoch + runtimeGeneration = generation + } + + private func acceptLease(_ message: AtemSessionMessage) { + guard let re = message.re, let operation = pending.removeValue(forKey: re), ["acquire", "renew"].contains(operation.message.op), + let binding = message.binding, binding.attachment_gen == welcome?.attachment_gen, + binding.target == operation.message.binding?.target, + binding.target == target(sessionId: binding.target.session_id, process: binding.target.process_gen), + let epoch = binding.lease_epoch, epoch >= (highestLeaseEpoch[binding.target.session_id] ?? 0), + revokedLeaseEpoch[binding.target.session_id].map({ epoch > $0 }) != false, + operation.message.op != "renew" || epoch == operation.message.binding?.lease_epoch else { return } + if let previous = operation.previousDeadline, now() >= previous { + leases.removeValue(forKey: binding.target.session_id) + status = "Control confirmation arrived after expiry. Take Control again." + return + } + var deadline = min(operation.sent + Double(message.ms_left ?? 0) / 1000, authorizationDeadline) + if operation.message.op == "acquire", let previous = operation.previousDeadline { deadline = min(previous, deadline) } + guard now() < deadline else { leases.removeValue(forKey: binding.target.session_id); return } + highestLeaseEpoch[binding.target.session_id] = epoch + leases[binding.target.session_id] = Lease(target: binding.target, epoch: epoch, deadline: deadline, renewAt: operation.sent + 5) + status = "Controlling this session. Local input on Atem takes priority." + } + + private func acceptLeaseChange(_ message: AtemSessionMessage) { + guard let id = message.session_id, let epoch = message.lease_epoch, + registry[id]?.process_gen == message.process_gen, epoch >= (highestLeaseEpoch[id] ?? 0) else { return } + highestLeaseEpoch[id] = epoch + if message.controller?.controller_session != welcome?.controller_session { + revokedLeaseEpoch[id] = epoch + pending = pending.filter { $0.value.message.binding?.target.session_id != id || !["acquire", "renew"].contains($0.value.message.op) } + } + if let lease = leases[id], epoch > lease.epoch || message.controller?.controller_session != welcome?.controller_session { + leases.removeValue(forKey: id) + pending = pending.filter { $0.value.message.binding?.target.session_id != id || !["acquire", "renew"].contains($0.value.message.op) } + status = "Viewing. Control changed on Atem." + } + } + + private func acceptReceipt(_ receipt: AtemSessionReceipt) throws { + guard let context, receipt.target.account_id == context.account, receipt.target.atem_device_id == context.device, + receipt.target.host_trust_gen == welcome?.host_trust_gen, + let index = journal.snapshot.requests.firstIndex(where: { $0.requestId == receipt.request_id }) else { return } + let record = journal.snapshot.requests[index] + try AtemSessionWire.check(record.target == receipt.target && record.kind == receipt.kind) + if let previous = record.receipt { + if receipt.updated_at < previous.updated_at { return } + try AtemSessionWire.check(previous.state.permits(receipt.state)) + if previous.state.final { try AtemSessionWire.check(previous == receipt); return } + } + try journal.update { $0.requests[index].receipt = receipt } + guard journal.snapshot.requests.last(where: { $0.target == receipt.target })?.requestId == receipt.request_id else { return } + latestReceipt = receipt + status = receipt.state == .outcome_unknown ? "Outcome unknown. Inspect the agent before submitting another request." + : "Request " + receipt.state.rawValue.replacingOccurrences(of: "_", with: " ") + "." + } + + func handleRefusal(code: String, target: AtemSessionTarget?) { + if let target, ["forbidden", "session_closed", "stale_lease", "stale_target", "unknown_session"].contains(code) { + leases.removeValue(forKey: target.session_id) + pending = pending.filter { $0.value.message.binding?.target != target || !["acquire", "renew"].contains($0.value.message.op) } + } + status = "Atem: " + code + if ["unauthorized", "paused", "unsupported_version"].contains(code) { + fail("Session access refused (" + code + "). Reconnect the verified Atem with matching builds.") + } else if ["session_closed", "stale_target", "unknown_session"].contains(code) { refresh() } + changed() + } + + private func queryUnresolvedReceipts() { + guard let context, let trust = welcome?.host_trust_gen, let attachment = welcome?.attachment_gen else { return } + lastReceiptQuery = now() + let unresolved = journal.snapshot.requests.filter { + $0.target.account_id == context.account && $0.target.atem_device_id == context.device + && $0.target.host_trust_gen == trust && $0.receipt?.state.final != true + } + for (target, records) in Dictionary(grouping: unresolved, by: \.target) { + if pending.values.contains(where: { $0.message.op == "receipt_query" && $0.message.binding?.target == target }) { continue } + for offset in stride(from: 0, to: records.count, by: 64) { + var message = AtemSessionMessage(op: "receipt_query") + message.binding = AtemSessionBinding(target: target, attachment_gen: attachment) + message.request_ids = Array(records[offset.. Bool { + var message = original + message.id = makeID() + guard let context, context == currentContext(), pending.count < 64, (try? message.validate()) != nil else { return false } + pending[message.id] = Pending(message: message, sent: now(), previousDeadline: deadline) + return sendMessage(message, context.route) + } + private func fail(_ text: String) { + phase = .failed + leases = [:] + pending = [:] + status = text + } + private func changed() { onChange?() } +} diff --git a/Sources/Menubar/AtemSessionJSON.swift b/Sources/Menubar/AtemSessionJSON.swift new file mode 100644 index 0000000..a240f4c --- /dev/null +++ b/Sources/Menubar/AtemSessionJSON.swift @@ -0,0 +1,274 @@ +import CryptoKit +import Foundation + +/// Session payloads use the integer-only JCS domain. The wire scanner also +/// rejects duplicate keys before Foundation can silently discard them. +indirect enum AtemSessionJSON: Codable, Equatable { + case object(Object), array([AtemSessionJSON]), string(String) + case integer(Int64), bool(Bool), null + case otherNumber(String) + + static let maximumInteger: Int64 = 9_007_199_254_740_991 + enum Invalid: Error { case json, limit, duplicateKey, number } + static let rawRootKey = CodingUserInfoKey(rawValue: "atemSessionRawJSON")! + + init(from decoder: Decoder) throws { + // Foundation keyed containers normalize Unicode keys. Recover arbitrary + // payloads from the scanned bytes before that normalization can alter JCS. + if var raw = decoder.userInfo[Self.rawRootKey] as? Self { + for key in decoder.codingPath { + if case .array(let values) = raw, let index = key.intValue, values.indices.contains(index) { + raw = values[index] + } else if let field = raw[key.stringValue] { raw = field } + else { throw Invalid.json } + } + self = raw + return + } + let value = try decoder.singleValueContainer() + if value.decodeNil() { self = .null } + else if let bool = try? value.decode(Bool.self) { self = .bool(bool) } + else if let integer = try? value.decode(Int64.self), integer >= -Self.maximumInteger, integer <= Self.maximumInteger { self = .integer(integer) } + else if let string = try? value.decode(String.self) { self = .string(string) } + else if let array = try? value.decode([Self].self) { self = .array(array) } + else if let object = try? value.decode(Object.self) { self = .object(object) } + else { throw Invalid.json } + } + + func encode(to encoder: Encoder) throws { + var value = encoder.singleValueContainer() + switch self { + case .null: try value.encodeNil() + case .bool(let bool): try value.encode(bool) + case .integer(let integer): + guard integer >= -Self.maximumInteger, integer <= Self.maximumInteger else { throw Invalid.number } + try value.encode(integer) + case .string(let string): try value.encode(string) + case .array(let array): try value.encode(array) + case .object(let object): try value.encode(object) + case .otherNumber: throw Invalid.number + } + } + + subscript(_ key: String) -> Self? { + guard case .object(let object) = self else { return nil } + return object[key] + } + + var string: String? { if case .string(let text) = self { return text }; return nil } + + /// Swift String equality normalizes Unicode. JCS object keys must retain + /// distinct Unicode spellings, so lookups and duplicate checks use bytes. + struct Object: Codable, Equatable, ExpressibleByDictionaryLiteral { + typealias Key = String + typealias Value = AtemSessionJSON + private var entries: [(String, AtemSessionJSON)] = [] + private var indices: [Data: Int] = [:] + init() {} + init(dictionaryLiteral elements: (String, AtemSessionJSON)...) { + for (key, value) in elements { self[key] = value } + } + var count: Int { entries.count } + var keys: [String] { entries.map { $0.0 } } + subscript(_ key: String) -> AtemSessionJSON? { + get { indices[Data(key.utf8)].map { entries[$0].1 } } + set { + let bytes = Data(key.utf8) + if let index = indices[bytes] { + if let newValue { entries[index].1 = newValue } + else { + entries.remove(at: index) + indices.removeValue(forKey: bytes) + for position in index.. Bool { + lhs.count == rhs.count && lhs.entries.allSatisfy { rhs[$0.0] == $0.1 } + } + private struct FieldKey: CodingKey { + let stringValue: String + var intValue: Int? { nil } + init(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: FieldKey.self) + for key in c.allKeys { self[key.stringValue] = try c.decode(AtemSessionJSON.self, forKey: key) } + } + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: FieldKey.self) + for (key, value) in entries { try c.encode(value, forKey: FieldKey(stringValue: key)) } + } + } + + func canonical(allowOtherNumbers: Bool = false) throws -> String { + switch self { + case .null: return "null" + case .bool(let bool): return bool ? "true" : "false" + case .integer(let integer): + guard integer >= -Self.maximumInteger, integer <= Self.maximumInteger else { throw Invalid.number } + return String(integer) + case .string(let string): + var result = "\"" + for scalar in string.unicodeScalars { + switch scalar.value { + case 8: result += "\\b" + case 9: result += "\\t" + case 10: result += "\\n" + case 12: result += "\\f" + case 13: result += "\\r" + case 34: result += "\\\"" + case 92: result += "\\\\" + case 0..<32: result += String(format: "\\u%04x", scalar.value) + default: result.unicodeScalars.append(scalar) + } + } + return result + "\"" + case .array(let values): return "[" + (try values.map { try $0.canonical(allowOtherNumbers: allowOtherNumbers) }).joined(separator: ",") + "]" + case .object(let values): + let keys = values.keys.sorted { $0.utf16.lexicographicallyPrecedes($1.utf16) } + return "{" + (try keys.map { try Self.string($0).canonical() + ":" + values[$0]!.canonical(allowOtherNumbers: allowOtherNumbers) }).joined(separator: ",") + "}" + case .otherNumber(let token): + guard allowOtherNumbers else { throw Invalid.number } + return token + } + } + + func digest() throws -> String { + SHA256.hash(data: Data(try canonical().utf8)).map { String(format: "%02x", $0) }.joined() + } + + static func parse(_ data: Data, integersOnly: Bool = true, limit: Int = 1_048_576) throws -> Self { + guard data.count <= limit else { throw Invalid.limit } + var parser = Parser(bytes: Array(data), integersOnly: integersOnly) + let value = try parser.value(depth: 0) + parser.space() + guard parser.index == parser.bytes.count else { throw Invalid.json } + return value + } + + /// Validate the original bytes, including relay envelopes, before a JSON + /// round trip can erase duplicate keys or non-canonical number spellings. + @discardableResult + static func validatePacket(_ data: Data) throws -> Self { + let root = try parse(data, integersOnly: false, limit: 2_097_152) + let packet = root["payload"] ?? root + if packet["type"]?.string == "session" { + guard let body = packet["data"], try body.canonical(allowOtherNumbers: true).utf8.count <= 1_048_576 else { throw Invalid.limit } + try integers(body, keys: ["v", "host_trust_gen", "authorization_ms_left", "server_time", "runtime_epoch", "seq", "ms_left", "lease_epoch", "updated_at"]) + try integers(body["binding"], keys: ["lease_epoch"]) + for target in [body["binding"]?["target"], body["target"]] { try integers(target, keys: ["host_trust_gen"]) } + try integers(body["controller"], keys: ["lease_epoch"]) + var descriptors: [AtemSessionJSON] = [] + if let descriptor = body["descriptor"] { descriptors.append(descriptor) } + if case .array(let values)? = body["sessions"] { descriptors += values } + for descriptor in descriptors { + try integers(descriptor, keys: ["activity_since", "created_at"]) + try integers(descriptor["controller"], keys: ["lease_epoch"]) + } + if case .array(let values)? = body["receipts"] { + for receipt in values { + try integers(receipt, keys: ["updated_at"]) + try integers(receipt["target"], keys: ["host_trust_gen"]) + } + } + if let payload = body["payload"], try payload.canonical().utf8.count > 65_536 { throw Invalid.limit } + } else if packet["type"]?.string == "terminal", let body = packet["data"], body["binding"] != nil { + try integers(body, keys: ["v", "sequence", "offset", "rows", "cols"]) + try integers(body["binding"], keys: ["lease_epoch"]) + try integers(body["binding"]?["target"], keys: ["host_trust_gen"]) + } + return root + } + + private static func integers(_ object: AtemSessionJSON?, keys: [String]) throws { + for key in keys { + if let value = object?[key] { + guard case .integer(let number) = value, number >= 0 else { throw Invalid.number } + } + } + } + + private struct Parser { + let bytes: [UInt8] + let integersOnly: Bool + var index = 0 + mutating func space() { while index < bytes.count && [9, 10, 13, 32].contains(bytes[index]) { index += 1 } } + mutating func consume(_ byte: UInt8) -> Bool { + space() + guard index < bytes.count, bytes[index] == byte else { return false } + index += 1 + return true + } + mutating func value(depth: Int) throws -> AtemSessionJSON { + space() + guard depth <= 64, index < bytes.count else { throw Invalid.limit } + switch bytes[index] { + case 123: + index += 1 + var object = Object() + if consume(125) { return .object(object) } + repeat { + let key = try string() + guard object[key] == nil else { throw Invalid.duplicateKey } + guard consume(58) else { throw Invalid.json } + object[key] = try value(depth: depth + 1) + if consume(125) { return .object(object) } + } while consume(44) + throw Invalid.json + case 91: + index += 1 + var array: [AtemSessionJSON] = [] + if consume(93) { return .array(array) } + repeat { + array.append(try value(depth: depth + 1)) + if consume(93) { return .array(array) } + } while consume(44) + throw Invalid.json + case 34: return .string(try string()) + case 116: try literal("true"); return .bool(true) + case 102: try literal("false"); return .bool(false) + case 110: try literal("null"); return .null + default: + let start = index + while index < bytes.count && ![9, 10, 13, 32, 44, 93, 125].contains(bytes[index]) { index += 1 } + let token = String(decoding: bytes[start..= -AtemSessionJSON.maximumInteger, integer <= AtemSessionJSON.maximumInteger { + return .integer(integer) + } + guard !integersOnly else { throw Invalid.number } + return .otherNumber(token) + } + } + mutating func string() throws -> String { + space() + guard index < bytes.count, bytes[index] == 34 else { throw Invalid.json } + let start = index + index += 1 + while index < bytes.count { + let byte = bytes[index] + index += 1 + if byte == 34 { + let data = Data(bytes[start.. Void + + init(load: () throws -> Snapshot = { Snapshot() }, save: @escaping (Snapshot) throws -> Void = { _ in }) { + self.persist = save + do { + let loaded = try load() + guard loaded.version == 1, loaded.requests.count <= 4096, loaded.hosts.count <= 128, + Set(loaded.requests.map(\.requestId)).count == loaded.requests.count, + loaded.requests.allSatisfy({ AtemSessionWire.uuid($0.requestId) && $0.target.valid + && $0.digest.range(of: #"^[0-9a-f]{64}$"#, options: .regularExpression) != nil + && ["prompt", "interrupt", "stop"].contains($0.kind) && $0.receipt?.valid != false + && ($0.receipt == nil || ($0.receipt?.target == $0.target && $0.receipt?.request_id == $0.requestId && $0.receipt?.kind == $0.kind)) }), + Set(loaded.hosts.map { $0.account + "\u{0}" + $0.device }).count == loaded.hosts.count, + loaded.hosts.allSatisfy({ AtemSessionWire.opaque($0.account) && AtemSessionWire.opaque($0.device) + && $0.trust <= AtemSessionWire.maximumCounter + && ($0.runtimeEpoch == nil) == ($0.runtimeGeneration == nil) + && $0.runtimeEpoch.map { $0 <= AtemSessionWire.maximumCounter } != false + && $0.runtimeGeneration.map(AtemSessionWire.uuid) != false }) else { throw AtemSessionJSON.Invalid.json } + snapshot = loaded + } catch { snapshot = Snapshot(); loadFailed = true } + } + + static func persistent(at file: URL? = nil) -> AtemSessionJournal { + let url = file ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Astation", isDirectory: true).appendingPathComponent("remote-session-receipts.json") + let directory = url.deletingLastPathComponent() + return AtemSessionJournal(load: { + guard FileManager.default.fileExists(atPath: url.path) else { return Snapshot() } + let data = try Data(contentsOf: url) + guard data.count <= 4_194_304 else { throw AtemSessionJSON.Invalid.limit } + _ = try AtemSessionJSON.parse(data, limit: 4_194_304) + return try JSONDecoder().decode(Snapshot.self, from: data) + }, save: { state in + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(state) + guard data.count <= 4_194_304 else { throw AtemSessionJSON.Invalid.limit } + // RecoveryKit.save uses a restricted temporary file, fsync, and rename. + try RecoveryKit.save(String(decoding: data, as: UTF8.self), to: url) + let descriptor = Darwin.open(directory.path, O_RDONLY) + guard descriptor >= 0 else { throw POSIXError(.EIO) } + defer { Darwin.close(descriptor) } + guard fsync(descriptor) == 0 else { throw POSIXError(.EIO) } + }) + } + + func update(_ mutation: (inout Snapshot) throws -> Void) throws { + guard !loadFailed else { throw AtemSessionJSON.Invalid.json } + var next = snapshot + try mutation(&next) + guard next.requests.count <= 4096, next.hosts.count <= 128 else { throw AtemSessionJSON.Invalid.limit } + do { try persist(next) } + catch { + // Rename may have succeeded before a directory fsync failed. Stop + // further writes until reload so an older in-memory fence cannot win. + loadFailed = true + throw error + } + snapshot = next + } +} diff --git a/Sources/Menubar/AtemSessionMessage.swift b/Sources/Menubar/AtemSessionMessage.swift new file mode 100644 index 0000000..468eda9 --- /dev/null +++ b/Sources/Menubar/AtemSessionMessage.swift @@ -0,0 +1,214 @@ +import Foundation + +enum AtemSessionWire { + static let maximumCounter = UInt64(AtemSessionJSON.maximumInteger) + static func uuid(_ value: String) -> Bool { + value.range(of: #"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"#, options: .regularExpression) != nil + } + static func opaque(_ value: String) -> Bool { + value.utf8.count <= 128 && value.range(of: #"^[A-Za-z0-9._:-]+$"#, options: .regularExpression) != nil + } + static func session(_ value: String) -> Bool { + value.range(of: #"^ses-[a-z2-7]{12}$"#, options: .regularExpression) != nil + } + static func check(_ condition: Bool) throws { if !condition { throw AtemSessionJSON.Invalid.json } } +} + +struct AtemSessionTarget: Codable, Equatable, Hashable { + let account_id: String + let atem_device_id: String + let host_trust_gen: UInt64 + let session_id: String + let process_gen: String + + var valid: Bool { + AtemSessionWire.opaque(account_id) && AtemSessionWire.opaque(atem_device_id) + && host_trust_gen <= AtemSessionWire.maximumCounter + && AtemSessionWire.session(session_id) && AtemSessionWire.uuid(process_gen) + } +} + +struct AtemSessionBinding: Codable, Equatable { + let target: AtemSessionTarget + let attachment_gen: String + var lease_epoch: UInt64? + var valid: Bool { + target.valid && AtemSessionWire.uuid(attachment_gen) + && lease_epoch.map { $0 <= AtemSessionWire.maximumCounter } != false + } +} + +struct AtemSessionController: Codable, Equatable { + let controller_session: String + let lease_epoch: UInt64 + var valid: Bool { AtemSessionWire.uuid(controller_session) && lease_epoch <= AtemSessionWire.maximumCounter } +} + +struct AtemSessionDescriptor: Codable, Equatable { + let session_id: String + let process_gen: String + let provider: String + let cwd_label: String + let state: String + let activity: String + let activity_since: UInt64 + let controller: AtemSessionController? + let capabilities: [String] + let created_at: UInt64 + + enum CodingKeys: String, CodingKey { + case session_id, process_gen, provider, cwd_label, state, activity, activity_since, controller, capabilities, created_at + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + try AtemSessionWire.check(c.contains(.controller)) + session_id = try c.decode(String.self, forKey: .session_id) + process_gen = try c.decode(String.self, forKey: .process_gen) + provider = try c.decode(String.self, forKey: .provider) + cwd_label = try c.decode(String.self, forKey: .cwd_label) + state = try c.decode(String.self, forKey: .state) + activity = try c.decode(String.self, forKey: .activity) + activity_since = try c.decode(UInt64.self, forKey: .activity_since) + controller = try c.decodeIfPresent(AtemSessionController.self, forKey: .controller) + capabilities = try c.decode([String].self, forKey: .capabilities) + created_at = try c.decode(UInt64.self, forKey: .created_at) + try AtemSessionWire.check(valid) + } + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(session_id, forKey: .session_id) + try c.encode(process_gen, forKey: .process_gen) + try c.encode(provider, forKey: .provider) + try c.encode(cwd_label, forKey: .cwd_label) + try c.encode(state, forKey: .state) + try c.encode(activity, forKey: .activity) + try c.encode(activity_since, forKey: .activity_since) + try c.encode(controller, forKey: .controller) + try c.encode(capabilities, forKey: .capabilities) + try c.encode(created_at, forKey: .created_at) + } + var valid: Bool { + AtemSessionWire.session(session_id) && AtemSessionWire.uuid(process_gen) + && !provider.isEmpty && provider.utf8.count <= 128 && cwd_label.utf8.count <= 256 + && ["running", "exited", "interrupted", "recovery_required", "closed", "removed"].contains(state) + && ["ready", "working", "waiting", "unknown"].contains(activity) + && activity_since <= AtemSessionWire.maximumCounter && created_at <= AtemSessionWire.maximumCounter + && controller?.valid != false && capabilities.count <= 32 + && capabilities.allSatisfy { !$0.isEmpty && $0.utf8.count <= 128 } + } +} + +enum AtemReceiptState: String, Codable { + case accepted, executing, completed, rejected, outcome_unknown + var final: Bool { self == .completed || self == .rejected || self == .outcome_unknown } + func permits(_ next: Self) -> Bool { + self == next || (self == .accepted && [.executing, .rejected, .completed, .outcome_unknown].contains(next)) + || (self == .executing && next.final) + } +} + +struct AtemSessionReceipt: Codable, Equatable { + let request_id: String + let target: AtemSessionTarget + let kind: String + let state: AtemReceiptState + let detail: String? + let updated_at: UInt64 + var valid: Bool { + AtemSessionWire.uuid(request_id) && target.valid && ["prompt", "interrupt", "stop"].contains(kind) + && (detail?.utf8.count ?? 0) <= 512 && updated_at <= AtemSessionWire.maximumCounter + } +} + +/// The session family is flat inside AstationMessage.data. Authentication +/// context is supplied separately by the hub, never decoded from this object. +struct AtemSessionMessage: Codable, Equatable { + var v = 1 + var op: String + var id: String = UUID().uuidString.lowercased() + var re: String? + var binding: AtemSessionBinding? + var account_id: String? + var atem_device_id: String? + var host_trust_gen: UInt64? + var runtime_gen: String? + var controller_session: String? + var attachment_gen: String? + var client_kind: String? + var permissions: [String]? + var authorization_ms_left: UInt64? + var server_time: UInt64? + var runtime_epoch: UInt64? + var seq: UInt64? + var sessions: [AtemSessionDescriptor]? + var descriptor: AtemSessionDescriptor? + var takeover: Bool? + var ms_left: UInt64? + var session_id: String? + var process_gen: String? + var controller: AtemSessionController? + var lease_epoch: UInt64? + var reason: String? + var request_id: String? + var target: AtemSessionTarget? + var kind: String? + var payload: AtemSessionJSON? + var state: AtemReceiptState? + var detail: String? + var updated_at: UInt64? + var request_ids: [String]? + var receipts: [AtemSessionReceipt]? + var code: String? + + func validate() throws { + try AtemSessionWire.check(v == 1 && AtemSessionWire.uuid(id) && re.map(AtemSessionWire.uuid) != false) + try AtemSessionWire.check(binding?.valid != false && target?.valid != false && (detail?.utf8.count ?? 0) <= 512) + for counter in [host_trust_gen, authorization_ms_left, server_time, runtime_epoch, seq, ms_left, lease_epoch, updated_at] { + try AtemSessionWire.check(counter.map { $0 <= AtemSessionWire.maximumCounter } != false) + } + switch op { + case "hello", "list": break + case "welcome": + try AtemSessionWire.check(account_id.map(AtemSessionWire.opaque) == true && atem_device_id.map(AtemSessionWire.opaque) == true + && host_trust_gen != nil && runtime_gen.map(AtemSessionWire.uuid) == true + && controller_session.map(AtemSessionWire.uuid) == true && attachment_gen.map(AtemSessionWire.uuid) == true + && ["native", "browser"].contains(client_kind ?? "") && permissions != nil + && permissions!.allSatisfy { ["view", "control", "approve"].contains($0) } + && authorization_ms_left != nil && server_time != nil) + case "sessions", "session_changed": + try AtemSessionWire.check(runtime_gen.map(AtemSessionWire.uuid) == true && runtime_epoch != nil && seq != nil) + if op == "sessions" { + try AtemSessionWire.check(sessions != nil && sessions!.count <= 1024 && sessions!.allSatisfy(\.valid)) + } else { try AtemSessionWire.check(descriptor?.valid == true) } + case "acquire": try AtemSessionWire.check(binding != nil && binding?.lease_epoch == nil && takeover != nil) + case "renew", "release": try AtemSessionWire.check(binding?.lease_epoch != nil) + case "lease": try AtemSessionWire.check(re != nil && binding?.lease_epoch != nil && ms_left != nil && ms_left! <= 15_000) + case "lease_released": try AtemSessionWire.check(re != nil) + case "lease_changed": + try AtemSessionWire.check(session_id.map(AtemSessionWire.session) == true && process_gen.map(AtemSessionWire.uuid) == true + && lease_epoch != nil && controller?.valid != false + && ["granted", "takeover", "local_input", "expired", "released", "revoked", "process_ended"].contains(reason ?? "")) + case "submit": + try AtemSessionWire.check(binding?.lease_epoch != nil && request_id.map(AtemSessionWire.uuid) == true + && ["prompt", "interrupt", "stop"].contains(kind ?? "") && payload != nil) + let canonical = try payload!.canonical() + try AtemSessionWire.check(canonical.utf8.count <= 65_536) + if kind == "prompt" { + guard case .object(let fields) = payload!, let text = fields["text"]?.string else { throw AtemSessionJSON.Invalid.json } + try AtemSessionWire.check(fields.count == 1 && text.utf8.count <= 32_768) + } else { try AtemSessionWire.check(payload == .object([:])) } + case "receipt": try AtemSessionWire.check(receipt?.valid == true) + case "receipt_query": + try AtemSessionWire.check(binding != nil && binding?.lease_epoch == nil && request_ids != nil + && request_ids!.count <= 64 && request_ids!.allSatisfy(AtemSessionWire.uuid)) + case "receipts": try AtemSessionWire.check(re != nil && receipts != nil && receipts!.count <= 64 && receipts!.allSatisfy(\.valid)) + case "error": try AtemSessionWire.check(code != nil && !code!.isEmpty && code!.utf8.count <= 128) + default: throw AtemSessionJSON.Invalid.json + } + } + + var receipt: AtemSessionReceipt? { + guard let request_id, let target, let kind, let state, let updated_at else { return nil } + return AtemSessionReceipt(request_id: request_id, target: target, kind: kind, state: state, detail: detail, updated_at: updated_at) + } +} diff --git a/Sources/Menubar/AtemUnlockPrompt.swift b/Sources/Menubar/AtemUnlockPrompt.swift index 3a42b77..378c37f 100644 --- a/Sources/Menubar/AtemUnlockPrompt.swift +++ b/Sources/Menubar/AtemUnlockPrompt.swift @@ -5,21 +5,24 @@ import LocalAuthenticationEmbeddedUI final class AtemUnlockPrompt: NSObject, NSWindowDelegate { let alert: NSAlert let authenticationView: LAAuthenticationView + let passwordButton = NSButton(title: "Use Mac Password...", target: nil, action: nil) let status = NSTextField(wrappingLabelWithString: - "Place your finger on Touch ID to unlock this device's storage key.") - private let context: LAContext + "Use Touch ID to unlock this device's storage key, or choose Use Mac Password.") + private let authentication: AtemOwnerAuthentication private var completion: ((AtemUnlockDecision) -> Void)? private var shown = false + private var usingPassword = false var isActive: Bool { completion != nil } - init(request: AtemUnlockApproval, context: LAContext, completion: @escaping (AtemUnlockDecision) -> Void) { + init(request: AtemUnlockApproval, context: LAContext, + makePasswordContext: @escaping () -> LAContext = { LAContext() }, completion: @escaping (AtemUnlockDecision) -> Void) { alert = AtemVerificationUI.unlockAlert() - self.context = context + authentication = AtemOwnerAuthentication(context: context, makePasswordContext: makePasswordContext) self.completion = completion - context.localizedFallbackTitle = "" - context.touchIDAuthenticationAllowableReuseDuration = 0 authenticationView = LAAuthenticationView(context: context, controlSize: .regular) super.init() + passwordButton.target = self + passwordButton.action = #selector(usePassword) let requested = DateFormatter.localizedString(from: request.requestTime, dateStyle: .medium, timeStyle: .medium) let last = request.lastUnlock.map { @@ -32,7 +35,7 @@ final class AtemUnlockPrompt: NSObject, NSWindowDelegate { ("Last unlock", last), ("Boot ID", AtemVerificationUI.displayText(request.bootId)) ]), - AtemAuthenticationLayout.touchID(authenticationView, status: status), + AtemAuthenticationLayout.touchID(authenticationView, status: status, passwordButton: passwordButton), AtemAuthenticationLayout.note("Deny and Revoke removes this device's verification.") ]) } @@ -53,25 +56,42 @@ final class AtemUnlockPrompt: NSObject, NSWindowDelegate { } private func authenticate() { - guard isActive else { return } - var error: NSError? - guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { - status.stringValue = "Touch ID is unavailable. Unlock this Mac and enable Touch ID, then retry the Atem command. This request cannot be approved." - return + guard isActive, !usingPassword else { return } + if !authentication.biometrics(reason: "unlock this Atem's storage key", completion: { [weak self] accepted in + guard let self, self.isActive else { return } + if accepted { self.finish(.approve) } + else { self.status.stringValue = "Touch ID did not complete. Choose Use Mac Password to continue, or Deny." } + }) { + status.stringValue = "Touch ID is unavailable. Choose Use Mac Password to unlock with your Mac login password." } - context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, - localizedReason: "unlock this Atem's storage key") { [weak self] accepted, _ in - DispatchQueue.main.async { self?.finish(accepted ? .approve : .deny) } + } + + @objc private func usePassword() { + guard isActive, shown, passwordButton.isEnabled else { return } + usingPassword = true + passwordButton.isEnabled = false + status.stringValue = "Authenticate in the macOS dialog. Choose Use Password if Touch ID is offered." + if !authentication.password(reason: "unlock this Atem's storage key", completion: { [weak self] accepted in + guard let self, self.isActive else { return } + if accepted { self.finish(.approve) } + else { self.passwordFailed() } + }) { + passwordFailed() } } + private func passwordFailed() { + passwordButton.isEnabled = true + status.stringValue = "Authentication did not complete. Choose Use Mac Password to retry, or Deny." + } + @objc private func deny() { finish(.deny) } @objc private func revoke() { finish(.revoke) } func finish(_ decision: AtemUnlockDecision) { guard let callback = completion else { return } completion = nil - context.invalidate() + authentication.cancel() alert.window.close() callback(decision) } diff --git a/Sources/Menubar/BrowserAgentChat.swift b/Sources/Menubar/BrowserAgentChat.swift new file mode 100644 index 0000000..5ea8833 --- /dev/null +++ b/Sources/Menubar/BrowserAgentChat.swift @@ -0,0 +1,525 @@ +import CryptoKit +import Foundation + +enum AgentChatTimestamp: Codable, Equatable { + case milliseconds(Double) + case iso8601(String) + + init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer() + if let number = try? value.decode(Double.self) { self = .milliseconds(number) } + else { self = .iso8601(try value.decode(String.self)) } + } + + func encode(to encoder: Encoder) throws { + var value = encoder.singleValueContainer() + switch self { + case .milliseconds(let number): try value.encode(number) + case .iso8601(let text): try value.encode(text) + } + } + + var date: Date? { + switch self { + case .milliseconds(let number): + guard number.isFinite, number >= 0, number <= 8_640_000_000_000_000 else { return nil } + return Date(timeIntervalSince1970: number / 1000) + case .iso8601(let text): + guard text.utf8.count <= 64 else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: text) ?? ISO8601DateFormatter().date(from: text) + } + } +} + +struct AgentChatPart: Codable, Equatable { + var id: String + var kind: String + var markdown: String? + var name: String? + var summary: String? + + var contentBytes: Int { + if id == "atem:truncated", kind == "text", markdown == "\u{2026} (truncated)" { return 0 } + return (markdown?.utf8.count ?? 0) + (name?.utf8.count ?? 0) + (summary?.utf8.count ?? 0) + } + + var isValid: Bool { + guard !id.isEmpty, id.utf8.count <= 128 else { return false } + switch kind { + case "text": return markdown != nil && name == nil && summary == nil + case "tool": return markdown == nil && name != nil && summary != nil + default: return false + } + } +} + +struct AgentChatStatus: Codable, Equatable { + var state: String + var since: AgentChatTimestamp + var detail: String? + + var isValid: Bool { + ["ready", "working", "waiting", "unknown"].contains(state) + && since.date != nil && (detail?.utf8.count ?? 0) <= 4096 + } +} + +struct AgentChatWire: Codable, Equatable { + var v = 1 + var op: String + var conversationId: String + var agentId: String + var generation: String? + var sequence: UInt64? + var requestId: String? + var messageId: String? + var text: String? + var state: String? + var since: AgentChatTimestamp? + var detail: String? + var status: AgentChatStatus? + var role: String? + var origin: String? + var turnId: String? + var createdAt: AgentChatTimestamp? + var parts: [AgentChatPart]? + var part: AgentChatPart? + var history: Bool? + var before: String? + var outcome: String? + var code: String? + var binding: AtemSessionBinding? +} + +struct BrowserChatFrame: Codable { + let sessionId: String + let nonce: String + let sequence: UInt64 + let body: String + let mac: String + + static func signingData(_ direction: String, sessionId: String, nonce: String, + sequence: UInt64, body: String) -> Data { + var data = Data() + for field in ["astation-browser-chat-\(direction)-v1", sessionId, nonce, String(sequence), body] { + let bytes = Data(field.utf8) + var length = UInt32(bytes.count).bigEndian + data.append(Data(bytes: &length, count: 4)) + data.append(bytes) + } + return data + } + + func open(key: SymmetricKey) -> [String: Any]? { + guard sequence > 0, sequence <= 9_007_199_254_740_991, nonce.utf8.count <= 128, + !nonce.isEmpty, body.utf8.count <= 131_072, + let signature = Data(base64Encoded: mac), signature.count == 32, + HMAC.isValidAuthenticationCode(signature, authenticating: + Self.signingData("request", sessionId: sessionId, nonce: nonce, sequence: sequence, body: body), using: key), + let bytes = Data(base64Encoded: body), + let object = try? JSONSerialization.jsonObject(with: bytes) as? [String: Any] else { return nil } + return object + } + + static func response(key: SymmetricKey, sessionId: String, nonce: String, + sequence: UInt64, object: [String: Any]) -> BrowserChatFrame? { + guard let data = try? JSONSerialization.data(withJSONObject: object) else { return nil } + let body = data.base64EncodedString() + let signature = HMAC.authenticationCode(for: + signingData("response", sessionId: sessionId, nonce: nonce, sequence: sequence, body: body), using: key) + return BrowserChatFrame(sessionId: sessionId, nonce: nonce, sequence: sequence, + body: body, mac: Data(signature).base64EncodedString()) + } +} + +/// A local capability grants a browser access to one authenticated agent, never a device identity. +final class BrowserAgentChat { + private final class Session { + let id = UUID().uuidString.lowercased() + let key = SymmetricKey(size: .bits256) + var conversationId = "" + let route: RemoteTerminalConnection + let agentId: String + let agentName: String + let hostname: String + let capable: Bool + let expiresAt: Date + var browser: RemoteTerminalConnection? + var nonce = "" + var usedNonces: Set = [] + var received: UInt64 = 0 + var sent: UInt64 = 0 + var generation: String? + var hostSequence: UInt64 = 0 + var openingAt: Date? + var before: String? + var closed = true + var activity: AgentChatStatus? + var activityAt: Date? + var pendingRequest: String? + var pendingAt: Date? + var accepted = false + var deliveryUnknown = false + var blocked = false + var notice = "" + var requestIds: Set = [] + var messages: [String: AgentChatWire] = [:] + var transcriptBytes = 0 + var serializedBytes = 0 + var messageSizes: [String: Int] = [:] + + init(route: RemoteTerminalConnection, agent: AtemAgentInfo, hostname: String, expiresAt: Date) { + self.route = route + agentId = agent.id + agentName = agent.kind + self.hostname = hostname + capable = agent.capabilities.contains("chat-v1") + self.expiresAt = expiresAt + } + } + + enum OpenError: Error, LocalizedError { + case offline, invalidURL, tooManySessions, sessionProtocolRequired + var errorDescription: String? { + switch self { + case .offline: return "Connect Astation and this Atem to open a browser conversation." + case .invalidURL: return "The relay URL must use HTTPS (or localhost HTTP for development)." + case .tooManySessions: return "Eight browser sessions are already open. Wait for a session to expire before opening another." + case .sessionProtocolRequired: return "Use Open Terminal for this Atem. Browser access to managed sessions requires the secure companion protocol." + } + } + } + + private var sessions: [String: Session] = [:] + private let currentRoute: (String) -> RemoteTerminalConnection? + private let sendAgent: (AgentChatWire, RemoteTerminalConnection) -> Bool + private let sendBrowser: (BrowserChatFrame, RemoteTerminalConnection) -> Void + private let now: () -> Date + + init(currentRoute: @escaping (String) -> RemoteTerminalConnection?, + sendAgent: @escaping (AgentChatWire, RemoteTerminalConnection) -> Bool, + sendBrowser: @escaping (BrowserChatFrame, RemoteTerminalConnection) -> Void, + now: @escaping () -> Date = Date.init) { + self.currentRoute = currentRoute + self.sendAgent = sendAgent + self.sendBrowser = sendBrowser + self.now = now + } + + func createLink(relayURL: String, stationId: String, clientId: String, + agent: AtemAgentInfo, hostname: String) throws -> URL { + tick() + sessions = sessions.filter { !$0.value.blocked } + guard let route = currentRoute(clientId) else { throw OpenError.offline } + guard sessions.count < 8 else { throw OpenError.tooManySessions } + guard var url = URLComponents(string: relayURL) else { throw OpenError.invalidURL } + if url.scheme?.lowercased() == "wss" { url.scheme = "https" } + if url.scheme?.lowercased() == "ws" { url.scheme = "http" } + guard url.host != nil, + url.scheme == "https" || (url.scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(url.host ?? "")), + url.user == nil, url.password == nil else { throw OpenError.invalidURL } + let session = Session(route: route, agent: agent, hostname: hostname, + expiresAt: now().addingTimeInterval(3600)) + let secret = session.key.withUnsafeBytes { Data($0).base64EncodedString() } + .replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + var fragment = URLComponents() + fragment.queryItems = [URLQueryItem(name: "station", value: stationId), + URLQueryItem(name: "session", value: session.id), URLQueryItem(name: "key", value: secret)] + url.path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + url.path = (url.path.isEmpty ? "" : "/" + url.path) + "/agent/" + url.query = nil + url.percentEncodedFragment = fragment.percentEncodedQuery + guard let link = url.url else { throw OpenError.invalidURL } + sessions[session.id] = session + return link + } + + func receive(_ frame: BrowserChatFrame, from source: RemoteTerminalConnection) { + guard let session = sessions[frame.sessionId], now() < session.expiresAt, + source.clientId == "relay-browser-\(session.id)", + let body = frame.open(key: session.key), let operation = body["op"] as? String else { return } + if frame.nonce != session.nonce { + guard operation == "open", frame.sequence == 1, session.usedNonces.count < 32, + !session.usedNonces.contains(frame.nonce) else { return } + session.usedNonces.insert(frame.nonce) + session.nonce = frame.nonce + session.received = 0 + session.sent = 0 + session.browser = source + } + guard session.browser == source, frame.sequence > session.received else { return } + session.received = frame.sequence + switch operation { + case "open", "reopen": reopen(session) + case "history": + guard let before = body["before"] as? String, session.messages[before] != nil else { return } + reopen(session, before: before) + case "refresh": context(session) + case "submit": submit(body, session: session) + default: break + } + } + + private func reopen(_ session: Session, before: String? = nil) { + guard session.capable, !session.blocked, isCurrent(session) else { context(session); return } + // Fresh IDs keep delayed events from an earlier open out of the new sequence. + closeHost(session) + session.conversationId = UUID().uuidString.lowercased() + session.openingAt = now() + session.before = before + session.closed = true + session.hostSequence = 0 + session.notice = session.deliveryUnknown ? "Delivery is unknown. Check the terminal; prompts are never resent." : "" + context(session) + if !sendAgent(AgentChatWire(op: "open", conversationId: session.conversationId, + agentId: session.agentId, before: before), session.route) { + block(session, "The agent connection changed. Reopen it from Astation.") + } + } + + private func submit(_ body: [String: Any], session: Session) { + if let id = body["requestId"] as? String, session.requestIds.contains(id) { context(session); return } + guard session.capable, !session.blocked, !session.closed, !session.deliveryUnknown, + session.openingAt == nil, isCurrent(session), let generation = session.generation, + session.activity?.state == "ready", let activityAt = session.activityAt, + now().timeIntervalSince(activityAt) <= 30, + session.pendingRequest == nil, let requestId = body["requestId"] as? String, + session.requestIds.count < 256, UUID(uuidString: requestId) != nil, + let text = body["text"] as? String, Self.validPrompt(text) else { + emit(session, ["op": "notice", "text": "This prompt was not sent. Wait for Ready or check the terminal.", + "requestId": body["requestId"] as? String ?? "", "delivery": "failed"]) + return + } + session.pendingRequest = requestId + session.requestIds.insert(requestId) + session.pendingAt = now() + session.accepted = false + session.notice = "" + if !sendAgent(AgentChatWire(op: "submit", conversationId: session.conversationId, + agentId: session.agentId, generation: generation, requestId: requestId, text: text), session.route) { + block(session, "The connection changed. Delivery is unknown; the prompt will not be resent.") + } + } + + static func validPrompt(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmed.isEmpty && text.utf8.count <= 32_768 + && trimmed.first != "/" && trimmed.first != "!" + && !text.unicodeScalars.contains { + ($0.value < 32 && $0.value != 9 && $0.value != 10) || (127...159).contains($0.value) + } + } + + func receiveAgent(_ event: AgentChatWire, from source: RemoteTerminalConnection) { + guard event.v == 1, let session = sessions.values.first(where: { + $0.conversationId == event.conversationId && $0.agentId == event.agentId && $0.route == source + }), now() < session.expiresAt, session.capable, !session.blocked, isCurrent(session), + let encoded = try? JSONEncoder().encode(event), encoded.count <= 1_048_576, + (event.text?.utf8.count ?? 0) <= 131_072 else { return } + if event.op == "close", event.generation == nil, event.sequence == nil { + session.closed = true + session.openingAt = nil + session.activity = nil + if session.pendingRequest != nil, !session.accepted { session.deliveryUnknown = true } + forward(event, session) + if session.notice.isEmpty { session.notice = "The host closed this conversation. Reopen the view to load history." } + context(session) + return + } + if event.op == "error", event.generation == nil, event.sequence == nil, + session.openingAt != nil, event.text != nil { + session.notice = event.text ?? "Chat is unavailable." + session.openingAt = nil + session.closed = true + forward(event, session) + context(session) + return + } + guard let sequence = event.sequence, sequence <= 9_007_199_254_740_991 else { return } + if event.op == "opened" { + guard session.openingAt != nil, sequence == 0, Self.identifier(event.generation), + let status = event.status, status.isValid else { return } + if let pinned = session.generation, event.generation != pinned { + block(session, "The agent process changed. Open a new browser session from Astation.") + return + } + session.generation = event.generation + session.hostSequence = 0 + session.openingAt = nil + session.closed = false + session.activity = status + session.activityAt = now() + // Ready after reopen permits new work only when earlier delivery was confirmed. + if status.state == "ready", session.accepted { + session.pendingRequest = nil + session.pendingAt = nil + session.deliveryUnknown = false + } + } else { + guard !session.closed, session.openingAt == nil else { return } + guard event.generation == session.generation else { + block(session, "The agent process changed. Open a new browser session from Astation.") + return + } + guard sequence > session.hostSequence else { return } + guard sequence == session.hostSequence + 1 else { + session.notice = "Conversation events are missing. Reopen the view; prompts will not be resent." + session.closed = true + if session.pendingRequest != nil, !session.accepted { session.deliveryUnknown = true } + context(session) + return + } + guard validate(event, session: session) else { + block(session, "The agent sent an invalid conversation event. Use Open Terminal or reopen from Astation.") + return + } + session.hostSequence = sequence + if event.op == "status", let since = event.since { + session.activity = AgentChatStatus(state: event.state ?? "unknown", since: since, detail: event.detail) + session.activityAt = now() + } + if event.op == "error" { session.notice = event.text ?? "" } + if event.requestId == session.pendingRequest, session.pendingRequest != nil { + if event.op == "accepted" || (event.op == "message" && event.history == true && event.role == "user" && event.origin == "remote") { + session.accepted = true + session.deliveryUnknown = false + session.notice = "" + if event.history == true, session.activity?.state == "ready" { session.pendingRequest = nil; session.pendingAt = nil } + } + if event.op == "complete" || event.op == "error" { + if event.op == "error", event.text?.localizedCaseInsensitiveContains("delivery unknown") == true { + session.deliveryUnknown = true + } else { + session.pendingRequest = nil + session.pendingAt = nil + session.deliveryUnknown = false + } + } + } + } + forward(event, session) + context(session) + } + + private static func identifier(_ value: String?) -> Bool { + guard let value else { return false } + return !value.isEmpty && value.utf8.count <= 128 + } + + private func validate(_ event: AgentChatWire, session: Session) -> Bool { + if let requestId = event.requestId, !Self.identifier(requestId) { return false } + switch event.op { + case "accepted": return Self.identifier(event.requestId) + case "complete": return Self.identifier(event.turnId) && ["done", "interrupted"].contains(event.outcome ?? "") + case "status": + guard let since = event.since else { return false } + return AgentChatStatus(state: event.state ?? "", since: since, detail: event.detail).isValid + case "error": return event.text != nil + case "message", "delta": + guard Self.identifier(event.messageId), let id = event.messageId else { return false } + let previous = session.messages[id] + var message = event + if event.op == "delta" { + guard let old = previous, let part = event.part, part.isValid else { return false } + message = old + if !(old.parts ?? []).contains(where: { $0.id == part.id }) { message.parts?.append(part) } + } else { + guard ["user", "assistant"].contains(event.role ?? ""), Self.identifier(event.turnId), + event.createdAt?.date != nil, event.role != "user" || ["local", "remote"].contains(event.origin ?? ""), + previous == nil || (previous?.role == event.role && previous?.turnId == event.turnId) else { return false } + } + guard let parts = message.parts, parts.count <= 4096, parts.allSatisfy({ $0.isValid }), + Set(parts.map(\.id)).count == parts.count else { return false } + let size = parts.reduce(0) { $0 + $1.contentBytes } + let oldSize = previous?.parts?.reduce(0) { $0 + $1.contentBytes } ?? 0 + let oldSerializedSize = session.messageSizes[id] ?? 0 + guard size <= 131_072, session.transcriptBytes - oldSize + size <= 8_388_608, + previous != nil || session.messages.count < 512, + let encoded = try? JSONEncoder().encode(message), encoded.count <= 1_048_576, + session.serializedBytes - oldSerializedSize + encoded.count <= 16_777_216 else { return false } + session.messages[id] = message + session.transcriptBytes += size - oldSize + session.serializedBytes += encoded.count - oldSerializedSize + session.messageSizes[id] = encoded.count + return true + default: return true + } + } + + func tick() { + for session in Array(sessions.values) { + if now() >= session.expiresAt { + block(session, "This browser session expired. Open a new session from Astation.") + sessions.removeValue(forKey: session.id) + } else if !session.blocked { + if !isCurrent(session) { + block(session, "The authenticated agent connection changed. Reopen the agent from Astation.") + } else if let at = session.pendingAt, !session.accepted, !session.deliveryUnknown, + now().timeIntervalSince(at) > 12 { + session.deliveryUnknown = true + session.notice = "Delivery is unknown. Check the terminal; this prompt will not be resent." + emit(session, ["op": "notice", "text": session.notice, "blocked": true, + "requestId": session.pendingRequest ?? "", "delivery": "unknown"]) + context(session) + } else if let at = session.openingAt, now().timeIntervalSince(at) > 10 { + session.openingAt = nil + session.notice = "The agent did not open the conversation. Use Open Terminal or update Atem." + context(session) + } + } + } + } + + func invalidateAll() { + for session in sessions.values { block(session, "Astation disconnected. Open a new browser session after it reconnects.") } + sessions.removeAll() + } + + private func isCurrent(_ session: Session) -> Bool { currentRoute(session.route.clientId) == session.route } + + private func closeHost(_ session: Session) { + guard !session.conversationId.isEmpty, isCurrent(session) else { return } + _ = sendAgent(AgentChatWire(op: "close", conversationId: session.conversationId, agentId: session.agentId), session.route) + } + + private func block(_ session: Session, _ reason: String) { + closeHost(session) + session.blocked = true + session.notice = reason + emit(session, ["op": "notice", "text": reason, "blocked": true, + "requestId": session.pendingRequest ?? "", "delivery": session.accepted ? "delivered" : "unknown"]) + context(session) + } + + private func context(_ session: Session) { + let connected = !session.blocked && isCurrent(session) + let reason = session.notice.isEmpty && !session.capable + ? "Chat requires an updated Atem and a supported agent with active hooks. Use Open Terminal for this agent." + : session.notice + let object: [String: Any] = ["op": "context", "agentName": session.agentName, "hostname": session.hostname, + "agentId": session.agentId, "conversationId": session.conversationId, + "connected": connected, "supported": session.capable, + "enabled": connected && session.capable && !session.closed && !session.deliveryUnknown, + "closed": session.closed, + "opening": session.openingAt != nil, "canReopen": connected && session.capable && session.openingAt == nil, + "notice": reason, "pending": session.pendingRequest != nil, "pendingRequestId": session.pendingRequest ?? "", + "before": session.before ?? "", "deliveryUnknown": session.deliveryUnknown] + emit(session, object) + } + + private func forward(_ event: AgentChatWire, _ session: Session) { + guard let data = try? JSONEncoder().encode(event), let object = try? JSONSerialization.jsonObject(with: data) else { return } + emit(session, ["op": "event", "event": object]) + } + + private func emit(_ session: Session, _ object: [String: Any]) { + guard let route = session.browser, session.sent < 9_007_199_254_740_991 else { return } + session.sent += 1 + guard let frame = BrowserChatFrame.response(key: session.key, sessionId: session.id, nonce: session.nonce, + sequence: session.sent, object: object) else { return } + sendBrowser(frame, route) + } +} diff --git a/Sources/Menubar/ConnectionsView.swift b/Sources/Menubar/ConnectionsView.swift index 28beafb..8916dfb 100644 --- a/Sources/Menubar/ConnectionsView.swift +++ b/Sources/Menubar/ConnectionsView.swift @@ -84,7 +84,7 @@ enum AtemClientListModel { struct ConnectionsView: View { @ObservedObject var hubManager: AstationHubManager - let onRemoteControl: (String, AtemAgentInfo) -> Void + let onRemoteControl: (String, AtemAgentInfo?) -> Void /// Which client's agents are shown on the right panel. @State private var selectedClientId: String? @@ -358,7 +358,7 @@ private struct OfflineClientRow: View { private struct AgentListPanel: View { @ObservedObject var hubManager: AstationHubManager let clientId: String? - let onRemoteControl: (String, AtemAgentInfo) -> Void + let onRemoteControl: (String, AtemAgentInfo?) -> Void private var agents: [AtemAgentInfo] { guard let id = clientId else { return [] } @@ -372,6 +372,10 @@ private struct AgentListPanel: View { private var clientIsOnline: Bool { selectedClient != nil } + private var accessBlockReason: RemoteAccessBlockReason? { + clientId.flatMap { hubManager.remoteAccessBlockReason(for: $0) } + } + private var panelTitle: String { guard let c = selectedClient else { return clientId.map { String($0.prefix(8)) + "…" } ?? "No client selected" @@ -391,9 +395,18 @@ private struct AgentListPanel: View { .font(.caption) .foregroundColor(.secondary) if clientIsOnline, let id = clientId { + Button { + onRemoteControl(id, nil) + } label: { + Label("Terminals", systemImage: "terminal") + } + .buttonStyle(.borderless) + .font(.caption) + .disabled(accessBlockReason != nil) Button("Refresh") { hubManager.requestAgentList(from: id) } .buttonStyle(.borderless) .font(.caption) + .disabled(accessBlockReason != nil) } } } @@ -404,6 +417,14 @@ private struct AgentListPanel: View { Divider() // ── Body ────────────────────────────────────────────────────── + if let message = hubManager.remoteInputStatusMessage { + Text(message) + .font(.caption) + .foregroundColor(.secondary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.08)) + } if clientId == nil { VStack(spacing: 8) { Image(systemName: "arrow.left") @@ -414,6 +435,21 @@ private struct AgentListPanel: View { .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let reason = accessBlockReason { + VStack(spacing: 12) { + Image(systemName: "lock.shield") + .font(.system(size: 32)) + .foregroundColor(.secondary) + Text(reason == .verificationRequired ? "Verify this Atem" : "Remote access unavailable") + .font(.headline) + Text(reason.localizedDescription) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .textSelection(.enabled) + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if agents.isEmpty { VStack(spacing: 10) { Image(systemName: "cpu") @@ -440,6 +476,20 @@ private struct AgentListPanel: View { ForEach(agents) { agent in AgentRow( agent: agent, + onOpenInBrowser: { + guard let clientId else { return } + do { + let url = try hubManager.openAgentInBrowser(clientId: clientId, agent: agent) + if !NSWorkspace.shared.open(url) { + throw BrowserAgentChat.OpenError.invalidURL + } + } catch { + let alert = NSAlert() + alert.messageText = "Could not open agent chat" + alert.informativeText = error.localizedDescription + alert.runModal() + } + }, onRemoteControl: { guard let clientId else { return } onRemoteControl(clientId, agent) @@ -460,14 +510,16 @@ private struct AgentListPanel: View { private struct AgentRow: View { let agent: AtemAgentInfo + let onOpenInBrowser: () -> Void let onRemoteControl: () -> Void private var statusColor: Color { switch agent.status { - case "Idle": return Color(NSColor.tertiaryLabelColor) + case "Idle": return .green case "Thinking": return .yellow case "WaitingForInput": return .green case "Disconnected": return .red + case "Unknown": return Color(NSColor.tertiaryLabelColor) default: return Color(NSColor.tertiaryLabelColor) } } @@ -510,6 +562,11 @@ private struct AgentRow: View { } HStack(spacing: 10) { + Text(agent.name + " [" + String(agent.id.suffix(12)) + "]") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .help(agent.id) Label(endpointLabel, systemImage: agent.agentProtocol == "Acp" ? "network" : "terminal") .font(.caption) .foregroundColor(.secondary) @@ -526,10 +583,20 @@ private struct AgentRow: View { Text(agent.status) .font(.caption) .foregroundColor(statusColor) + .help(agent.status == "Unknown" + ? "Atem has no reliable activity report for this agent. Its terminal is still available." + : "Activity reported by Atem's agent integration.") } - Button(action: onRemoteControl) { - Label("Remote Agent Control", systemImage: "keyboard") + HStack(spacing: 14) { + Button(action: onRemoteControl) { + Label("Open Terminal", systemImage: "terminal") + } + Button(action: onOpenInBrowser) { + Label("Open in Browser", systemImage: "arrow.up.right.square") + } + .disabled(true) + .help("Browser access requires the secure companion protocol.") } .buttonStyle(.borderless) .controlSize(.small) diff --git a/Sources/Menubar/CredentialsViewController.swift b/Sources/Menubar/CredentialsViewController.swift index b3177ad..42a1bf1 100644 --- a/Sources/Menubar/CredentialsViewController.swift +++ b/Sources/Menubar/CredentialsViewController.swift @@ -81,7 +81,7 @@ final class CredentialEditor: NSObject { let projectButton = NSButton(checkboxWithTitle: "Use project scope", target: nil, action: nil) let input = CredentialValueInput() let status = NSTextField(wrappingLabelWithString: "") - let saveButton = NSButton(title: "Save with Touch ID", target: nil, action: nil) + let saveButton = NSButton(title: "Save Securely", target: nil, action: nil) let entry: LocalCredentialMetadata? let revision: UUID? private let savedProjects: [String] @@ -186,7 +186,7 @@ final class CredentialsViewController: NSViewController, NSTableViewDataSource, init(account: @escaping () -> String = { AstationIdentity.shared.id }, makeStore: @escaping (String) -> LocalCredentialStore = { LocalCredentialStore(account: $0) }, - authenticate: @escaping LocalCredentialActions.Authenticate = LocalCredentialActions.touchID, + authenticate: @escaping LocalCredentialActions.Authenticate = LocalCredentialActions.deviceOwner, reportedProjects: @escaping () -> [String] = { [] }, requestProjects: @escaping () -> Void = {}, projectUpdates: AnyPublisher<[String], Never>? = nil, @@ -212,7 +212,7 @@ final class CredentialsViewController: NSViewController, NSTableViewDataSource, view = NSView() let title = NSTextField(labelWithString: "Credentials") title.font = .boldSystemFont(ofSize: 22) - let detail = NSTextField(wrappingLabelWithString: "API keys and passwords saved in this Mac's protected Keychain. Choose Global or a Git project. Changes and copies require Touch ID.") + let detail = NSTextField(wrappingLabelWithString: "API keys and passwords saved in this Mac's protected Keychain. Choose Global or a Git project. Changes and copies require Touch ID or your Mac login password.") detail.textColor = .secondaryLabelColor let availability = NSTextField(wrappingLabelWithString: "Available on this Mac. Atem credential retrieval is not available in this version.") availability.font = .systemFont(ofSize: 11) @@ -324,7 +324,7 @@ final class CredentialsViewController: NSViewController, NSTableViewDataSource, do { let input = try form.validatedInput(), store = self.store form.saveButton.isEnabled = false - form.status.stringValue = "Approve with Touch ID to save." + form.status.stringValue = "Approve with Touch ID or your Mac login password to save." self.actions.perform(account: store.account, reason: "save this credential", operation: { try store.save(name: input.name, project: input.project, value: input.value, updating: form.entry?.id, expectedRevision: form.revision) @@ -374,7 +374,7 @@ final class CredentialsViewController: NSViewController, NSTableViewDataSource, let alert = NSAlert() alert.messageText = "Delete this credential?" alert.informativeText = "This removes \(selected.name) from \(selected.scopeLabel) on this Mac." - alert.addButton(withTitle: "Delete with Touch ID") + alert.addButton(withTitle: "Authenticate and Delete") alert.addButton(withTitle: "Cancel") pendingDeleteAlert = alert updateButtons() diff --git a/Sources/Menubar/DevConsoleController.swift b/Sources/Menubar/DevConsoleController.swift index 0c7a509..92e874b 100644 --- a/Sources/Menubar/DevConsoleController.swift +++ b/Sources/Menubar/DevConsoleController.swift @@ -250,7 +250,10 @@ class DevConsoleController: NSObject, NSWindowDelegate { return } - hubManager.sendCommandToClient(command, action: action, clientId: targetId) + guard hubManager.sendCommandToClient(command, action: action, clientId: targetId) else { + appendLog(hubManager.remoteInputStatusMessage ?? "Open Terminal to control a session.") + return + } appendLog("Sent [\(action)] to \(targetId.prefix(8))...: \(command.prefix(80))") // Clear input diff --git a/Sources/Menubar/LocalCredentialActions.swift b/Sources/Menubar/LocalCredentialActions.swift index ec0df12..c3172de 100644 --- a/Sources/Menubar/LocalCredentialActions.swift +++ b/Sources/Menubar/LocalCredentialActions.swift @@ -10,20 +10,24 @@ final class LocalCredentialActions { private var finishCancellation: (() -> Void)? var isBusy: Bool { attempt != nil } - init(account: @escaping () -> String, authenticate: @escaping Authenticate = LocalCredentialActions.touchID) { + init(account: @escaping () -> String, authenticate: @escaping Authenticate = LocalCredentialActions.deviceOwner) { self.account = account self.authenticate = authenticate } - static func touchID(reason: String, completion: @escaping (Bool) -> Void) -> (() -> Void) { - let context = LAContext() - context.localizedFallbackTitle = "" + static func deviceOwner(reason: String, completion: @escaping (Bool) -> Void) -> (() -> Void) { + deviceOwner(context: LAContext(), reason: reason, completion: completion) + } + + static func deviceOwner(context: LAContext, reason: String, completion: @escaping (Bool) -> Void) -> (() -> Void) { + context.localizedFallbackTitle = "Use Password..." + context.touchIDAuthenticationAllowableReuseDuration = 0 var error: NSError? - guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { DispatchQueue.main.async { completion(false) } return { context.invalidate() } } - context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { accepted, _ in + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { accepted, _ in DispatchQueue.main.async { completion(accepted) } } return { context.invalidate() } diff --git a/Sources/Menubar/LocalCredentialStore.swift b/Sources/Menubar/LocalCredentialStore.swift index 4beb39b..f599bbe 100644 --- a/Sources/Menubar/LocalCredentialStore.swift +++ b/Sources/Menubar/LocalCredentialStore.swift @@ -15,8 +15,8 @@ enum LocalCredentialError: LocalizedError { case .missing: return "This credential no longer exists. Refresh and try again." case .corrupt: return "The credential store is unreadable. Its saved data was retained." case .newer: return "Update Astation to read this credential store. Its saved data was retained." - case .denied: return "Touch ID was cancelled or unavailable. Nothing was changed or copied." - case .busy: return "Finish the current Touch ID request first." + case .denied: return "Authentication was cancelled or unavailable. Nothing was changed or copied." + case .busy: return "Finish the current authentication request first." case .accountChanged: return "This Mac's identity changed. Reopen Credentials and try again." case .keychain(errSecMissingEntitlement): return "Run a provisioned Astation build to use protected credential storage." case .keychain: return "Protected credential storage is unavailable. Unlock this Mac and retry." diff --git a/Sources/Menubar/NetworkDebugLogger.swift b/Sources/Menubar/NetworkDebugLogger.swift index 543f477..8d2bf7f 100644 --- a/Sources/Menubar/NetworkDebugLogger.swift +++ b/Sources/Menubar/NetworkDebugLogger.swift @@ -110,14 +110,21 @@ enum NetworkDebugLogger { "accesstoken", "apikey", "appcertificate", "authtoken", "authorization", "bearer", "bootstraptoken", "cookie", "credential", "encryptionkey", "otp", "pairingcode", "password", "proof", "refreshtoken", "secret", "session", - "sessionid", "sessiontoken", "token" + "sessionid", "sessiontoken", "token", "key", "mac" ] private static func sanitizeUnstructuredText(_ text: String) -> String { + // Malformed or truncated terminal envelopes must not leak raw PTY data. + if text.range(of: #""type"\s*:\s*"terminal""#, options: .regularExpression) != nil { + return "" + } + if text.range(of: #""type"\s*:\s*"(session|agentChat|browserChat)""#, options: .regularExpression) != nil { + return "" + } let replacements = [ (#"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+"#, "Bearer "), ( - #"(?i)\b(access[_-]?token|api[_-]?key|auth[_-]?token|bootstrap[_-]?token|otp|pairing[_-]?code|password|proof|refresh[_-]?token|secret|session[_-]?id|session[_-]?token|token)\b[\"']?\s*[:=]\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;&]+)"#, + #"(?i)\b(access[_-]?token|api[_-]?key|auth[_-]?token|bootstrap[_-]?token|otp|pairing[_-]?code|password|proof|refresh[_-]?token|secret|session[_-]?id|session[_-]?token|token|key|mac)\b[\"']?\s*[:=]\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;&]+)"#, "$1=" ) ] @@ -138,6 +145,10 @@ enum NetworkDebugLogger { private static func sanitizeJSONObject(_ value: Any) -> Any { if let dictionary = value as? [String: Any] { + if let type = dictionary["type"] as? String, ["terminal", "session", "agentChat", "browserChat"].contains(type) { + let operation = (dictionary["data"] as? [String: Any])?["op"] as? String ?? "unknown" + return ["type": type, "data": ["op": operation, "content": ""]] + } return dictionary.reduce(into: [String: Any]()) { result, entry in let normalizedKey = normalizedSensitiveKey(entry.key) result[entry.key] = sensitiveKeys.contains(normalizedKey) @@ -151,18 +162,16 @@ enum NetworkDebugLogger { return value } - private static func sanitizeURL(_ url: URL?) -> String { + static func sanitizeURL(_ url: URL?) -> String { guard let url else { return "(nil)" } - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let items = components.queryItems else { - return url.absoluteString - } - components.queryItems = items.map { item in + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return "" } + if components.fragment != nil { components.fragment = "" } + components.queryItems = components.queryItems?.map { item in let normalizedName = normalizedSensitiveKey(item.name) guard sensitiveKeys.contains(normalizedName) else { return item } return URLQueryItem(name: item.name, value: "") } - return components.string ?? url.absoluteString + return components.string ?? "" } private static func normalizedSensitiveKey(_ key: String) -> String { diff --git a/Sources/Menubar/RemoteAccessBlockReason.swift b/Sources/Menubar/RemoteAccessBlockReason.swift new file mode 100644 index 0000000..5180f72 --- /dev/null +++ b/Sources/Menubar/RemoteAccessBlockReason.swift @@ -0,0 +1,18 @@ +import Foundation + +enum RemoteAccessBlockReason: Error, LocalizedError, Equatable { + case offline, authenticationRequired, verificationRequired, identityUnavailable + + var errorDescription: String? { + switch self { + case .offline: + return "Atem is offline. Reconnect to view its sessions." + case .authenticationRequired: + return "Atem needs to authenticate its current connection. Reconnect Atem using its existing pairing." + case .verificationRequired: + return "This Atem is paired but not verified. On its host, run atem pair verify, compare safety codes, and approve on this Mac." + case .identityUnavailable: + return "Astation's verification identity is unavailable. Unlock this Mac and check Settings > Security before retrying." + } + } +} diff --git a/Sources/Menubar/RemoteControlWindowController.swift b/Sources/Menubar/RemoteControlWindowController.swift index eb4af2e..fab1888 100644 --- a/Sources/Menubar/RemoteControlWindowController.swift +++ b/Sources/Menubar/RemoteControlWindowController.swift @@ -1,142 +1,303 @@ -import Cocoa -import Foundation +import AppKit -/// Remote control panel for the v1 "up lane": send text + control keys to the -/// focused Atem's agent. Voice stays on the existing PTT/hands-free path. -class RemoteControlWindowController: NSObject, NSWindowDelegate, NSTextFieldDelegate { +/// One addressed terminal window. Closing it releases the view, not the agent. +final class RemoteControlWindowController: NSObject, NSWindowDelegate { private var window: NSWindow? private let hubManager: AstationHubManager - private var targetLabel: NSTextField! - private var inputField: NSTextField! - private var sendButton: NSButton! private var targetClientId: String? - private var targetAgentId: String? - private var targetAgentName: String? + private var targetAtemId: String? + private var renderer: RemoteTerminalRenderer? + private var timeoutTimer: Timer? + private let targetLabel = NSTextField(labelWithString: "") + private let statusLabel = NSTextField(wrappingLabelWithString: "") + private let modeLabel = NSTextField(labelWithString: "VIEWING") + private let sessionPicker = NSPopUpButton() + private var viewButton: NSButton! + private var controlButton: NSButton! + private var releaseButton: NSButton! + private var stopButton: NSButton! + private var pasteButton: NSButton! + private let promptField = NSTextField() + private let receiptLabel = NSTextField(labelWithString: "") + private var sendPromptButton: NSButton! + private var displayedSessions: [RemoteTerminalSession] = [] + private var lastInputEpoch = "" + private var lastInputEnabled = false - /// Control keys exposed in the key bar: (button title, wire key name). - private let keys: [(String, String)] = [ - ("⏎ Enter", "enter"), - ("Esc", "esc"), - ("Ctrl-C", "ctrl-c"), - ("↑", "up"), - ("↓", "down"), - ("y", "y"), - ("n", "n"), - ] + private lazy var offlineSessions = AtemSessionClient(context: { nil }, send: { _, _ in false }) + private var sessions: AtemSessionClient { + guard let id = targetClientId else { return offlineSessions } + return hubManager.sessionClient(for: id, atemId: targetAtemId) ?? offlineSessions + } + + private lazy var terminal = RemoteTerminalController( + currentConnection: { [weak self] in + guard let self, let id = self.targetClientId else { return nil } + return self.hubManager.terminalConnection(for: id, atemId: self.targetAtemId) + }, + send: { [weak self] message, route in self?.hubManager.sendTerminalMessage(message, to: route) ?? false } + ) init(hubManager: AstationHubManager) { self.hubManager = hubManager super.init() + terminal.sessionClient = sessions + hubManager.sessionStateDidChange = { [weak self] id in + guard let self, self.sessions.context?.route.clientId == id else { return } + self.terminal.sessionClient = self.sessions + self.terminal.sessionStateChanged() + self.refresh() + } + terminal.onChange = { [weak self] in self?.refresh() } + hubManager.terminalMessageHandler = { [weak self] message, source in self?.terminal.receive(message, from: source) } + hubManager.terminalConnectionsDidChange = { [weak self] in + guard let self else { return } + self.terminal.sessionClient = self.sessions + self.terminal.connectionChanged() + self.terminal.sessionStateChanged() + } + hubManager.remoteAgentInputHandler = { [weak self] route, agentId, kind, value in + guard let self, route == self.terminal.connection, + route == self.hubManager.terminalConnection(for: route.clientId), + agentId == nil || agentId == self.terminal.session?.agentId else { return false } + return self.terminal.sendScopedInput(kind: kind, value: value) + } } - func showWindow(clientId: String, agent: AtemAgentInfo) { + func showWindow(clientId: String, agent: AtemAgentInfo?) { + terminal.close() targetClientId = clientId - targetAgentId = agent.id - targetAgentName = agent.name.isEmpty ? agent.kind : agent.name - - if let existingWindow = window { - existingWindow.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - refreshTarget() - return - } + targetAtemId = hubManager.connectedClients.first(where: { $0.id == clientId })?.atemId + terminal.sessionClient = sessions + targetLabel.stringValue = hubManager.connectedClients.first(where: { $0.id == clientId })?.hostname ?? "Atem" + if window == nil { createWindow() } + sessions.start() + terminal.open(preferredTerminalId: nil, preferredAgentId: agent?.id) + window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 460, height: 200), - styleMask: [.titled, .closable, .miniaturizable], - backing: .buffered, - defer: false - ) - window.title = "Remote Agent Control" + private func createWindow() { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 700), + styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) + window.title = "Remote Terminal" + window.minSize = NSSize(width: 720, height: 420) window.center() window.delegate = self window.isReleasedWhenClosed = false + self.window = window - let contentView = NSView(frame: window.contentView!.bounds) - contentView.autoresizingMask = [.width, .height] - - // Target Atem label - targetLabel = NSTextField(labelWithString: "") - targetLabel.font = NSFont.systemFont(ofSize: 11) - targetLabel.textColor = .secondaryLabelColor - targetLabel.frame = NSRect(x: 20, y: 165, width: 420, height: 18) - contentView.addSubview(targetLabel) + let content = NSView() + window.contentView = content + targetLabel.font = .systemFont(ofSize: 13, weight: .semibold) + targetLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + targetLabel.lineBreakMode = .byTruncatingMiddle + sessionPicker.target = self + sessionPicker.action = #selector(selectSession) + sessionPicker.toolTip = "Select the original Claude or Codex process on this Atem" + sessionPicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 190).isActive = true + let refreshButton = button("Refresh", symbol: "arrow.clockwise", action: #selector(refreshSessions)) + let titleRow = NSStackView(views: [targetLabel, spacer(), sessionPicker, refreshButton]) + titleRow.orientation = .horizontal + titleRow.spacing = 10 + modeLabel.font = .monospacedSystemFont(ofSize: 10, weight: .semibold) + modeLabel.textColor = .secondaryLabelColor + viewButton = button("View", symbol: "eye", action: #selector(viewSession)) + controlButton = button("Take Control", symbol: "keyboard", action: #selector(takeControl)) + releaseButton = button("Release", symbol: "keyboard.badge.ellipsis", action: #selector(releaseControl)) + let copyButton = button("Copy", symbol: "doc.on.doc", action: #selector(copySelection)) + pasteButton = button("Paste", symbol: "doc.on.clipboard", action: #selector(paste)) + stopButton = button("Stop Agent", symbol: "stop.circle", action: #selector(stopAgent)) + stopButton.toolTip = "Stop this original agent process on the remote machine" + let controls = NSStackView(views: [modeLabel, viewButton, controlButton, releaseButton, spacer(), copyButton, pasteButton, stopButton]) + controls.orientation = .horizontal + controls.spacing = 8 + statusLabel.font = .systemFont(ofSize: 11) + statusLabel.textColor = .secondaryLabelColor + statusLabel.setContentCompressionResistancePriority(.required, for: .vertical) + promptField.placeholderString = "Send a prompt to this agent" + promptField.target = self + promptField.action = #selector(sendPrompt) + promptField.font = .systemFont(ofSize: 13) + sendPromptButton = button("Send", symbol: "arrow.up.circle.fill", action: #selector(sendPrompt)) + let promptRow = NSStackView(views: [promptField, sendPromptButton]) + promptRow.orientation = .horizontal + promptRow.spacing = 8 + receiptLabel.font = .monospacedSystemFont(ofSize: 10, weight: .regular) + receiptLabel.textColor = .secondaryLabelColor + receiptLabel.lineBreakMode = .byTruncatingTail + let renderer = RemoteTerminalRenderer() + self.renderer = renderer + renderer.onAction = { [weak self, weak renderer] action in + guard let self, let renderer, self.renderer === renderer else { return } + self.handleRenderer(action) + } + renderer.onFailure = { [weak self, weak renderer] in + guard let self, let renderer, self.renderer === renderer else { return } + self.terminal.rendererFailed() + } + for view in [titleRow, controls, renderer.webView, promptRow, receiptLabel, statusLabel] { + view.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(view) + } + NSLayoutConstraint.activate([ + titleRow.topAnchor.constraint(equalTo: content.topAnchor, constant: 14), + titleRow.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), + titleRow.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16), + controls.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 12), + controls.leadingAnchor.constraint(equalTo: titleRow.leadingAnchor), + controls.trailingAnchor.constraint(equalTo: titleRow.trailingAnchor), + renderer.webView.topAnchor.constraint(equalTo: controls.bottomAnchor, constant: 14), + renderer.webView.leadingAnchor.constraint(equalTo: content.leadingAnchor), + renderer.webView.trailingAnchor.constraint(equalTo: content.trailingAnchor), + renderer.webView.bottomAnchor.constraint(equalTo: promptRow.topAnchor, constant: -12), + promptRow.leadingAnchor.constraint(equalTo: titleRow.leadingAnchor), + promptRow.trailingAnchor.constraint(equalTo: titleRow.trailingAnchor), + promptRow.bottomAnchor.constraint(equalTo: receiptLabel.topAnchor, constant: -6), + receiptLabel.leadingAnchor.constraint(equalTo: titleRow.leadingAnchor), + receiptLabel.trailingAnchor.constraint(equalTo: titleRow.trailingAnchor), + receiptLabel.bottomAnchor.constraint(equalTo: statusLabel.topAnchor, constant: -6), + statusLabel.leadingAnchor.constraint(equalTo: titleRow.leadingAnchor), + statusLabel.trailingAnchor.constraint(equalTo: titleRow.trailingAnchor), + statusLabel.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -12) + ]) + if timeoutTimer == nil { timeoutTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.terminal.connectionChanged() + self?.terminal.checkTimeouts() + } } + lastInputEpoch = "" + lastInputEnabled = false + renderer.load() + } - // Text input + Send - inputField = NSTextField(frame: NSRect(x: 20, y: 120, width: 320, height: 26)) - inputField.placeholderString = "Type an instruction, press Enter or Send" - inputField.delegate = self - contentView.addSubview(inputField) + private func button(_ title: String, symbol: String, action: Selector) -> NSButton { + let result = NSButton(title: title, target: self, action: action) + result.bezelStyle = .rounded + result.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) + result.imagePosition = .imageLeading + result.controlSize = .small + return result + } - sendButton = NSButton(title: "Send", target: self, action: #selector(sendText)) - sendButton.bezelStyle = .rounded - sendButton.frame = NSRect(x: 350, y: 118, width: 90, height: 30) - sendButton.keyEquivalent = "\r" - contentView.addSubview(sendButton) + private func spacer() -> NSView { + let view = NSView() + view.setContentHuggingPriority(.defaultLow, for: .horizontal) + return view + } - // Key bar - let keyBarLabel = NSTextField(labelWithString: "Keys:") - keyBarLabel.font = NSFont.systemFont(ofSize: 11) - keyBarLabel.frame = NSRect(x: 20, y: 78, width: 40, height: 18) - contentView.addSubview(keyBarLabel) + private var inputEpoch: String { + guard let stream = terminal.stream, let lease = terminal.leaseId else { return "" } + return stream.streamId + ":" + lease + } - var x: CGFloat = 20 - let y: CGFloat = 40 - for (title, keyName) in keys { - let btn = NSButton(title: title, target: self, action: #selector(sendKey(_:))) - btn.bezelStyle = .rounded - btn.font = NSFont.systemFont(ofSize: 11) - let width: CGFloat = title.count > 3 ? 70 : 44 - btn.frame = NSRect(x: x, y: y, width: width, height: 28) - btn.identifier = NSUserInterfaceItemIdentifier(keyName) - contentView.addSubview(btn) - x += width + 6 + private func refresh() { + guard window != nil else { return } + if displayedSessions != terminal.sessions { + displayedSessions = terminal.sessions + sessionPicker.removeAllItems() + for session in displayedSessions { + let name = session.provider == "claude" ? "Claude Code" : session.provider == "codex" ? "Codex" : session.provider + let identifier = String(session.terminalId.prefix(12)) + let detail = session.label.flatMap { $0.isEmpty ? nil : $0 + " [" + identifier + "]" } ?? identifier + sessionPicker.addItem(withTitle: name + " - " + detail + (session.exitStatus == nil ? " - live" : " - exited")) + sessionPicker.lastItem?.toolTip = session.terminalId + " / " + session.generation + } + } + if let index = displayedSessions.firstIndex(where: { $0.id == terminal.session?.id }) { sessionPicker.selectItem(at: index) } + else { sessionPicker.select(nil) } + sessionPicker.isEnabled = !displayedSessions.isEmpty + let blockReason = targetClientId.flatMap { hubManager.remoteAccessBlockReason(for: $0) } + statusLabel.stringValue = terminal.phase == .offline ? blockReason?.localizedDescription ?? terminal.status : terminal.status + statusLabel.textColor = terminal.phase == .failed ? .systemOrange : .secondaryLabelColor + modeLabel.stringValue = terminal.canInput ? "CONTROLLING" : terminal.phase == .offline ? "OFFLINE" : "VIEWING" + modeLabel.textColor = terminal.canInput ? .controlAccentColor : .secondaryLabelColor + viewButton.isEnabled = terminal.session?.isSupported == true && terminal.connection != nil + && [.ready, .failed, .viewing, .exited].contains(terminal.phase) + && terminal.session.flatMap { sessions.target(sessionId: $0.terminalId, process: $0.generation) } != nil + controlButton.isEnabled = terminal.canTakeControl + releaseButton.isEnabled = terminal.canRelease + stopButton.isEnabled = terminal.canInput + pasteButton.isEnabled = terminal.canInput + let supportsPrompts = terminal.session.flatMap { sessions.registry[$0.terminalId] }?.capabilities.contains("chat-v1") == true + promptField.isEnabled = terminal.canInput && supportsPrompts + sendPromptButton.isEnabled = terminal.canInput && supportsPrompts + promptField.placeholderString = supportsPrompts ? "Send a prompt to this agent" : "Type or paste in this agent's terminal" + if let receipt = sessions.latestReceipt, receipt.target.session_id == terminal.session?.terminalId, + receipt.target.process_gen == terminal.session?.generation { + receiptLabel.stringValue = receipt.kind.capitalized + " - " + receipt.state.rawValue.replacingOccurrences(of: "_", with: " ") + + (receipt.detail.map { " - " + $0 } ?? "") + receiptLabel.toolTip = receipt.request_id + } else { receiptLabel.stringValue = sessions.status; receiptLabel.toolTip = nil } + if renderer?.isReady == true && (lastInputEnabled != terminal.canInput || lastInputEpoch != inputEpoch) { + lastInputEnabled = terminal.canInput + lastInputEpoch = inputEpoch + renderer?.setControl(terminal.canInput, epoch: inputEpoch) } - - window.contentView = contentView - window.makeFirstResponder(inputField) - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - self.window = window - refreshTarget() } - private func refreshTarget() { - if let clientId = targetClientId, - let client = hubManager.connectedClients.first(where: { $0.id == clientId }) { - let clientName = client.hostname - targetLabel.stringValue = "Target: \(targetAgentName ?? "Agent") on \(clientName)" - targetLabel.textColor = .secondaryLabelColor - } else { - targetLabel.stringValue = "⚠ Target Atem is offline — input will be dropped" - targetLabel.textColor = .systemOrange + private func handleRenderer(_ action: RemoteTerminalRendererAction) { + switch action { + case .ready: + terminal.render = { [weak self] frame, token in self?.renderer?.render(frame, token: token) } + terminal.rendererAvailable() + renderer?.setControl(terminal.canInput, epoch: inputEpoch) + case .input(let bytes, let epoch): + if epoch == inputEpoch { terminal.input(bytes) } + case .consumed(let token): terminal.consumed(token) + case .size(let rows, let cols): terminal.resize(rows: rows, cols: cols) + case .copy(let text): + guard !text.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + case .paste(let epoch): if epoch == inputEpoch { paste(nil) } + case .oversizedPaste: terminal.input(Data(repeating: 0, count: 16 * 1024 + 1)) } } - @objc private func sendText() { - let text = inputField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } - hubManager.sendAgentText(text, agentId: targetAgentId, clientId: targetClientId) - inputField.stringValue = "" - refreshTarget() + @objc private func refreshSessions() { sessions.refresh(); terminal.refresh() } + @objc private func sendPrompt() { + let text = promptField.stringValue + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + terminal.sendScopedInput(kind: "text", value: text) else { return } + promptField.stringValue = "" + refresh() } - - @objc private func sendKey(_ sender: NSButton) { - guard let keyName = sender.identifier?.rawValue else { return } - hubManager.sendAgentKey(keyName, agentId: targetAgentId, clientId: targetClientId) - refreshTarget() + @objc private func selectSession() { + let index = sessionPicker.indexOfSelectedItem + if displayedSessions.indices.contains(index) { terminal.select(displayedSessions[index]) } + } + @objc private func viewSession() { terminal.view() } + @objc private func takeControl() { terminal.takeControl() } + @objc private func releaseControl() { terminal.release() } + @objc private func copySelection() { renderer?.copySelection() } + @objc private func paste(_ sender: Any?) { + guard terminal.canInput, let text = NSPasteboard.general.string(forType: .string) else { return } + if text.utf8.count + 12 > 16 * 1024 { + terminal.input(Data(repeating: 0, count: 16 * 1024 + 1)) + } else { renderer?.paste(text, epoch: inputEpoch) } } - // Enter in the text field sends. - func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { - if selector == #selector(NSResponder.insertNewline(_:)) { - sendText() - return true + @objc private func stopAgent() { + guard terminal.canInput, let window, let session = terminal.session else { return } + let originalStream = terminal.stream + let alert = NSAlert() + alert.messageText = "Stop \(session.provider == "claude" ? "Claude Code" : "Codex") on \(targetLabel.stringValue)?" + alert.informativeText = "This stops the original agent process. Closing or releasing this window keeps it running." + alert.alertStyle = .warning + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Stop Agent") + alert.beginSheetModal(for: window) { [weak self] result in + guard result == .alertSecondButtonReturn, let self, self.terminal.stream == originalStream else { return } + self.terminal.stop() } - return false } func windowWillClose(_ notification: Notification) { + terminal.close() + terminal.render = nil + sessions.releaseAll() + renderer = nil window = nil + displayedSessions = [] } } diff --git a/Sources/Menubar/RemoteTerminalController.swift b/Sources/Menubar/RemoteTerminalController.swift new file mode 100644 index 0000000..9719c0a --- /dev/null +++ b/Sources/Menubar/RemoteTerminalController.swift @@ -0,0 +1,686 @@ +import Foundation + +/// Main-thread protocol state. Rendering and transport are injected so credit, +/// ownership and reconnect behavior can be tested without a window or relay. +final class RemoteTerminalController { + enum Phase: Equatable { + case closed, offline, listing, ready, attaching, restoring, viewing, controlling, stopping, exited, failed + } + + private(set) var phase: Phase = .closed + private(set) var status = "Choose a terminal to view." + private(set) var sessions: [RemoteTerminalSession] = [] + private(set) var session: RemoteTerminalSession? + private(set) var connection: RemoteTerminalConnection? + private(set) var stream: RemoteTerminalStream? + private(set) var leaseId: String? + private(set) var pendingWrites: [UInt64: TimeInterval] = [:] + private(set) var receivedOffset: UInt64 = 0 + private(set) var acknowledgedOffset: UInt64 = 0 + private(set) var nextSequence: UInt64 = 0 + private(set) var queuedBytes = 0 + var onChange: (() -> Void)? + var render: ((RemoteTerminalFrame, String) -> Void)? + var sessionClient: AtemSessionClient? + + private var protocolTarget: AtemSessionTarget? { + guard let session else { return nil } + return sessionClient?.target(sessionId: session.terminalId, process: session.generation) + } + + var canTakeControl: Bool { + if let client = sessionClient { + return client.welcome?.permissions?.contains("control") == true + && phase == .viewing && !wantsControl && session?.exitStatus == nil + && protocolTarget.map { client.registry[$0.session_id]?.state == "running" && !client.acquiring($0) } == true + } + return phase == .viewing && !wantsControl && session?.exitStatus == nil && session?.supportsExpiringControl == true + } + var canInput: Bool { + if let client = sessionClient { + return phase == .controlling && stream != nil && restored && protocolTarget.map(client.controls) == true + } + return phase == .controlling && leaseId != nil && wantsControl && leaseConfirmedAt.map { now() - $0 < 15 } == true + } + var canRelease: Bool { wantsControl || leaseId != nil } + + private let currentConnection: () -> RemoteTerminalConnection? + private let sendMessage: (RemoteTerminalMessage, RemoteTerminalConnection) -> Bool + private let now: () -> TimeInterval + private var isOpen = false + private var preferredTerminalId: String? + private var preferredAgentId: String? + private var listingSince: TimeInterval? + private var attachSince: TimeInterval? + private var controlSince: TimeInterval? + private var stopSince: TimeInterval? + private var autoView = false + private var reconnectSession: RemoteTerminalSession? + private var wantsControl = false + private var leaseConfirmedAt: TimeInterval? + private var renewalSince: TimeInterval? + private var restored = false + private var receivedFirstFrame = false + private var transactionOpen = false + private var transactionBytes = 0 + private var frameSize: (rows: Int, cols: Int)? + private var frames: [(frame: RemoteTerminalFrame, count: Int)] = [] + private var rendering: (token: String, since: TimeInterval)? + private var desiredSize: (rows: Int, cols: Int)? + private var sentSize: (rows: Int, cols: Int)? + private struct AttachmentRequest { + let connection: RemoteTerminalConnection + let sessionId: String + var cancelled: Bool + } + private var attachmentRequests: [AttachmentRequest] = [] + private var sessionAttachment: String? + + init(currentConnection: @escaping () -> RemoteTerminalConnection?, + send: @escaping (RemoteTerminalMessage, RemoteTerminalConnection) -> Bool, + now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { + self.currentConnection = currentConnection + self.sendMessage = send + self.now = now + } + + func open(preferredTerminalId: String?, preferredAgentId: String? = nil) { + close() + self.preferredTerminalId = preferredTerminalId + self.preferredAgentId = preferredAgentId + isOpen = true + connectionChanged() + } + + func close() { + detach() + isOpen = false + connection = nil + sessions = [] + session = nil + reconnectSession = nil + listingSince = nil + phase = .closed + changed() + } + + func connectionChanged() { + guard isOpen else { return } + let current = currentConnection() + guard current != connection || phase == .closed else { return } + let uncertain = !pendingWrites.isEmpty + let previous = session ?? reconnectSession + detach() + reconnectSession = previous + connection = current + sessions = [] + listingSince = nil + if current == nil { + phase = .offline + status = uncertain ? "Disconnected. Some input has an unknown outcome; it will not be resent." + : "Atem is offline. Reconnect will open in viewing mode." + } else { + refresh(autoView: true) + if uncertain { status = "Reconnecting in viewing mode. Unconfirmed input will not be resent." } + } + changed() + } + + func refresh(autoView: Bool = false) { + guard isOpen, connection != nil else { connectionChanged(); return } + self.autoView = autoView + listingSince = now() + if stream == nil { phase = .listing; status = "Looking for shared terminals..." } + if let client = sessionClient { + if !client.hasRegistrySnapshot { client.refresh() } + sessionStateChanged() + changed() + return + } + if !send(.list) { disconnectedSend() } + changed() + } + + func select(_ selected: RemoteTerminalSession) { + guard sessions.contains(selected), selected != session else { return } + detach() + session = selected + preferredTerminalId = selected.terminalId + preferredAgentId = selected.agentId + reconnectSession = nil + phase = .ready + status = selected.isSupported ? "Choose View to open this terminal." : "This terminal uses an unsupported renderer." + changed() + } + + func view() { + guard let session, session.isSupported, connection != nil, listingSince == nil else { return } + guard sessionClient == nil || protocolTarget != nil else { + status = sessionClient?.status ?? "Waiting for the session registry." + autoView = true + changed() + return + } + detach() + guard attachmentRequests.count < 16, let connection else { + fail("Too many unanswered attach requests. Reconnect Atem before retrying."); return + } + phase = .attaching + status = "Opening terminal in viewing mode..." + attachSince = now() + attachmentRequests.append(AttachmentRequest(connection: connection, sessionId: session.id, cancelled: false)) + if !send(.subscribe(terminalId: session.terminalId, generation: session.generation)) { disconnectedSend() } + changed() + } + + func takeControl() { + guard canTakeControl, let stream else { return } + if let client = sessionClient, let target = protocolTarget { + wantsControl = true + client.acquire(target) + sessionStateChanged() + return + } + wantsControl = true + controlSince = now() + status = "Requesting control..." + if !send(.control(stream, take: true)) { disconnectedSend() } + changed() + } + + func release() { + guard let stream, canRelease else { return } + if let client = sessionClient, let target = protocolTarget { + wantsControl = false + leaseId = nil + phase = .viewing + client.release(target) + sessionStateChanged() + return + } + wantsControl = false + leaseId = nil + leaseConfirmedAt = nil + renewalSince = nil + controlSince = nil + sentSize = nil + phase = .viewing + status = pendingWrites.isEmpty ? "Viewing. Local typing is available on Atem." : "Viewing. Waiting for outstanding input results." + if !send(.control(stream, take: false)) { disconnectedSend() } + changed() + } + + func input(_ bytes: Data) { + guard canInput, let stream, let leaseId, !bytes.isEmpty else { return } + guard bytes.count <= 16 * 1024 else { + status = "Paste is too large. The limit is 16 KiB; nothing was sent." + changed() + return + } + guard pendingWrites.count < 64, nextSequence < (sessionClient == nil ? UInt64.max : AtemSessionWire.maximumCounter) else { + fail("Input queue is full. Open a fresh view; unconfirmed input will not be resent.") + return + } + let sequence = nextSequence + pendingWrites[sequence] = now() + nextSequence += 1 + if !send(.input(stream, leaseId: leaseId, sequence: sequence, bytes: bytes.base64EncodedString())) { + disconnectedSend() + } else { + status = "Controlling. Waiting for input to reach the terminal..." + } + changed() + } + + @discardableResult + func sendScopedInput(kind: String, value: String) -> Bool { + guard canInput else { return false } + if let client = sessionClient, let target = protocolTarget { + if kind == "text" { + guard !value.isEmpty else { return false } + return client.submit(target, kind: "prompt", text: value) != nil + } + if kind == "key", value == "ctrl-c" { return client.submit(target, kind: "interrupt") != nil } + } + let bytes: Data + if kind == "text" { + guard !value.isEmpty, value.utf8.count < 16 * 1024 else { return false } + bytes = Data((value + "\r").utf8) + } else if kind == "key" { + let keys = ["enter": "\r", "esc": "\u{1b}", "ctrl-c": "\u{3}", + "up": "\u{1b}[A", "down": "\u{1b}[B", "y": "y", "n": "n"] + guard let key = keys[value] else { return false } + bytes = Data(key.utf8) + } else { return false } + let before = nextSequence + input(bytes) + return nextSequence > before && canInput + } + + func resize(rows: Int, cols: Int) { + desiredSize = (max(2, min(200, rows)), max(2, min(400, cols))) + sendResizeIfNeeded() + } + + func stop() { + guard canInput, let stream, let leaseId else { return } + if let client = sessionClient, let target = protocolTarget { + guard client.submit(target, kind: "stop") != nil else { return } + phase = .stopping + stopSince = now() + status = "Stopping this agent. Waiting for its durable receipt..." + changed() + return + } + phase = .stopping + stopSince = now() + status = "Stopping this agent. Waiting for its final screen..." + if !send(.stop(stream, leaseId: leaseId)) { disconnectedSend() } + changed() + } + + func receive(_ message: RemoteTerminalMessage, from source: RemoteTerminalConnection) { + if case .bound(let inner, let binding) = message { + guard source == connection, binding.attachment_gen == sessionClient?.welcome?.attachment_gen, + binding.target == protocolTarget else { return } + receivePayload(inner, from: source) + return + } + if sessionClient != nil { + switch message { + // r6.2 host events carry no binding. The authenticated connection, + // outstanding subscribe and exact process/stream fence delivery. + case .attached, .frame, .written, .exited, .error, .unsupported: break + default: return + } + } + receivePayload(message, from: source) + } + + private func receivePayload(_ message: RemoteTerminalMessage, from source: RemoteTerminalConnection) { + if case .attached(let attached, let streamId) = message, + let index = attachmentRequests.firstIndex(where: { $0.connection == source && $0.sessionId == attached.id }) { + let request = attachmentRequests.remove(at: index) + if request.cancelled { + // Close/selection can race with Attached, before a stream ID exists. + // Release that late subscription without touching its successor. + let replaced = attachmentRequests.contains { $0.connection == source && $0.sessionId == attached.id } + || (stream?.terminalId == attached.terminalId && stream?.generation == attached.generation) + if !replaced, source == currentConnection(), !streamId.isEmpty, streamId.utf8.count <= 128 { + let unsubscribe = RemoteTerminalMessage.unsubscribe(RemoteTerminalStream(terminalId: attached.terminalId, + generation: attached.generation, streamId: streamId)) + if let client = sessionClient { + if let target = client.target(sessionId: attached.terminalId, process: attached.generation), + let binding = client.binding(for: target, mutation: false) { + _ = sendMessage(.bound(unsubscribe, binding), source) + } + } else { _ = sendMessage(unsubscribe, source) } + } + return + } + } + if case .error(let terminalId, let errorStream, _, _) = message, + errorStream == nil, + let index = attachmentRequests.firstIndex(where: { $0.connection == source + && (terminalId == nil || $0.sessionId.hasPrefix((terminalId ?? "") + ":")) }), + attachmentRequests[index].cancelled { + attachmentRequests.remove(at: index) + return + } + guard isOpen, source == connection, source == currentConnection() else { return } + switch message { + case .sessions(let available): + guard listingSince != nil else { return } + listingSince = nil + guard available.count <= (sessionClient == nil ? 64 : 1024) else { fail("Atem reported too many terminals."); return } + sessions = available + if stream != nil { changed(); return } + let previous = reconnectSession ?? session + let preferred: RemoteTerminalSession? + if let preferredAgentId { + preferred = available.first(where: { $0.agentId == preferredAgentId }) + } else if let preferredTerminalId { + preferred = available.first(where: { $0.terminalId == preferredTerminalId }) + } else { preferred = available.first } + session = available.first(where: { $0.id == previous?.id }) ?? preferred + let shouldView = autoView && (previous == nil || session?.id == previous?.id) + autoView = false + reconnectSession = nil + phase = .ready + if available.isEmpty { + status = "No shared terminal. On this host, run atem agent launch claude or codex." + } else if session == nil { + status = "This agent's original terminal was not reported. Select a terminal explicitly." + } else if previous != nil && session?.id != previous?.id { + status = "The previous process ended or restarted. Choose a terminal and View." + } else { status = "Choose View to open a terminal." } + if shouldView { view() } + case .attached(let attached, let streamId): + guard phase == .attaching, attached.id == session?.id else { return } + guard attached.isSupported, !streamId.isEmpty, streamId.utf8.count <= 128 else { + fail("Atem returned an unsupported terminal session."); return + } + session = attached + stream = RemoteTerminalStream(terminalId: attached.terminalId, generation: attached.generation, streamId: streamId) + phase = .restoring + status = "Restoring the current terminal screen..." + attachSince = now() + case .frame(let frame): + guard frame.stream == stream else { return } + receiveFrame(frame) + case .controller(let scope, let lease): + guard sessionClient == nil else { return } + guard scope == stream, restored, phase != .exited else { return } + if let lease { + guard !lease.isEmpty, lease.utf8.count <= 128 else { fail("Invalid control lease."); return } + guard wantsControl else { + _ = send(.control(scope, take: false)) + return + } + if let confirmed = leaseConfirmedAt, now() - confirmed >= 15 { + release() + status = "Control expired before renewal was confirmed. Take Control to type again." + break + } + guard session?.supportsExpiringControl == true, + leaseId == nil || leaseId == lease else { + fail("The terminal control lease changed unexpectedly. Take Control again from a fresh view.") + return + } + // Only an outstanding acquire/renew may extend the local deadline. + if let since = renewalSince ?? controlSince { + leaseConfirmedAt = since + renewalSince = nil + controlSince = nil + } + guard leaseConfirmedAt.map({ now() - $0 < 15 }) == true else { + release() + status = "Control confirmation arrived too late. Take Control again." + break + } + leaseId = lease + if phase != .stopping { + phase = .controlling + status = "Controlling. Keyboard input goes to this agent." + sendResizeIfNeeded() + } + } else { + leaseId = nil + leaseConfirmedAt = nil + renewalSince = nil + controlSince = nil + wantsControl = false + sentSize = nil + if phase != .stopping { + phase = .viewing + status = "Viewing. Control was released or reclaimed on Atem." + } + } + case .written(let scope, let sequence, let error): + guard scope == stream, pendingWrites.removeValue(forKey: sequence) != nil else { return } + if let error { fail("Input was not confirmed: " + String(error.prefix(240))); return } + if canInput && pendingWrites.isEmpty { status = "Written to terminal. Waiting for the agent's response." } + case .exited(let terminalId, let generation, let exitStatus): + guard terminalId == session?.terminalId, generation == session?.generation, stream != nil else { return } + session?.exitStatus = exitStatus + leaseId = nil + leaseConfirmedAt = nil + renewalSince = nil + wantsControl = false + stopSince = nil + attachSince = nil + phase = .exited + status = "Agent exited: " + String(exitStatus.prefix(160)) + if !pendingWrites.isEmpty { status += ". Some input has an unknown outcome." } + pendingWrites = [:] + case .error(let terminalId, let errorStream, let code, let message): + guard errorStream == nil || errorStream == stream?.streamId else { return } + if let index = attachmentRequests.firstIndex(where: { $0.connection == source + && (terminalId == nil || $0.sessionId.hasPrefix(terminalId! + ":")) }) { + attachmentRequests.remove(at: index) + } + guard terminalId == nil || terminalId == session?.terminalId else { return } + if let client = sessionClient { + // A registry refresh disables target(), but a refusal must + // still fence the selected process's cached lease. + let target = session.flatMap { session -> AtemSessionTarget? in + guard let context = client.context, let trust = client.welcome?.host_trust_gen else { return nil } + return AtemSessionTarget(account_id: context.account, atem_device_id: context.device, + host_trust_gen: trust, session_id: session.terminalId, process_gen: session.generation) + } + client.handleRefusal(code: code, target: target) + } + fail("Atem: " + String(message.prefix(300))) + case .unsupported: + fail("This Atem uses an unsupported terminal protocol. Update Astation and Atem together.") + default: break // Host requests are never interpreted as viewer commands. + } + changed() + } + + func rendererAvailable() { pumpRenderer() } + + func consumed(_ token: String) { + guard rendering?.token == token, let first = frames.first, let stream else { return } + rendering = nil + frames.removeFirst() + queuedBytes -= first.count + acknowledgedOffset = first.frame.offset + UInt64(first.count) + guard send(.ack(stream, offset: acknowledgedOffset)) else { disconnectedSend(); return } + if !restored && first.frame.complete { + restored = true + attachSince = nil + phase = session?.exitStatus == nil ? .viewing : .exited + status = phase == .exited ? "Agent exited. Showing its final screen." + : sessionClient != nil || session?.supportsExpiringControl == true ? "Viewing. Take Control to type in this terminal." + : "Viewing. Update Atem to enable control with expiring session leases." + changed() + } + pumpRenderer() + } + + func rendererFailed() { + render = nil + if isOpen { fail("The terminal renderer stopped. Reopen the window to load a fresh view.") } + } + + func checkTimeouts() { + let time = now() + if sessionClient == nil, let confirmed = leaseConfirmedAt, time - confirmed >= 15 { + release() + status = "Control expired. Viewing continues; Take Control to type again." + changed() + } else if sessionClient == nil, phase == .controlling, wantsControl, renewalSince == nil, + let confirmed = leaseConfirmedAt, time - confirmed >= 5, + let stream, let leaseId { + renewalSince = time + if !send(.renew(stream, leaseId: leaseId)) { disconnectedSend() } + } + if let since = listingSince, time - since >= 8 { + listingSince = nil + if stream == nil { + phase = .failed + status = "No terminal response. Check the connection, update Atem, or Refresh." + } else { status = "The session list did not respond. The current terminal remains open." } + changed() + } + if let since = attachSince, time - since >= 15 { fail("Terminal restore timed out. Choose View to try again.") } + if let since = rendering?.since, time - since >= 15 { fail("Terminal rendering timed out. Choose View to try again.") } + if let since = controlSince, time - since >= 8 { fail("Control request timed out. Choose View to reconnect safely.") } + if let oldest = pendingWrites.values.min(), time - oldest >= 10 { + fail("Input acknowledgment timed out. Its outcome is unknown; it will not be resent.") + } + if let since = stopSince, time - since >= 15 { + fail("Stop was not confirmed. Refresh the session list to check the process.") + } + } + + private func receiveFrame(_ frame: RemoteTerminalFrame) { + guard frame.offset == receivedOffset, frame.bytes.utf8.count <= 21_848, + let bytes = Data(base64Encoded: frame.bytes), !bytes.isEmpty, bytes.count <= 16 * 1024, + (2...200).contains(frame.rows), (2...400).contains(frame.cols) else { + fail("Invalid or missing terminal frame. Choose View to restore a fresh screen."); return + } + if !receivedFirstFrame && !frame.reset { fail("The terminal restore is missing its initial snapshot."); return } + if frame.reset { + guard !transactionOpen else { fail("Terminal reset interrupted a screen transaction."); return } + frameSize = (frame.rows, frame.cols) + } + guard frameSize?.rows == frame.rows, frameSize?.cols == frame.cols else { + fail("Terminal dimensions changed without a reset."); return + } + if !transactionOpen { transactionBytes = 0 } + transactionBytes += bytes.count + let (end, overflow) = receivedOffset.addingReportingOverflow(UInt64(bytes.count)) + guard !overflow, sessionClient == nil || end <= AtemSessionWire.maximumCounter, + transactionBytes <= 1024 * 1024, queuedBytes + bytes.count <= 128 * 1024, + frames.count < 128 else { fail("Terminal frame limits were exceeded. Choose View to reconnect."); return } + receivedFirstFrame = true + transactionOpen = !frame.complete + receivedOffset = end + queuedBytes += bytes.count + frames.append((frame, bytes.count)) + pumpRenderer() + } + + private func pumpRenderer() { + guard rendering == nil, let frame = frames.first?.frame, let render else { return } + let token = UUID().uuidString + rendering = (token, now()) + render(frame, token) + } + + private func sendResizeIfNeeded() { + guard canInput, let stream, let leaseId, let size = desiredSize, + sentSize?.rows != size.rows || sentSize?.cols != size.cols else { return } + sentSize = size + if !send(.resize(stream, leaseId: leaseId, rows: size.rows, cols: size.cols)) { disconnectedSend() } + } + + private func send(_ message: RemoteTerminalMessage) -> Bool { + guard let connection, connection == currentConnection() else { return false } + if let client = sessionClient { + guard client.phase == .ready else { return false } + let terminalId: String + let generation: String + let mutation: Bool + switch message { + case .subscribe(let id, let process): terminalId = id; generation = process; mutation = false + case .unsubscribe(let stream), .ack(let stream, _): terminalId = stream.terminalId; generation = stream.generation; mutation = false + case .input(let stream, _, _, _), .resize(let stream, _, _, _), .stop(let stream, _): + terminalId = stream.terminalId; generation = stream.generation; mutation = true + default: return false + } + guard let target = client.target(sessionId: terminalId, process: generation), + let binding = client.binding(for: target, mutation: mutation) else { return false } + return sendMessage(.bound(message, binding), connection) + } + return sendMessage(message, connection) + } + + func sessionStateChanged() { + guard isOpen, let client = sessionClient else { return } + if sessionAttachment != client.welcome?.attachment_gen { + sessionAttachment = client.welcome?.attachment_gen + let previous = session ?? reconnectSession + detach() + attachmentRequests = [] + sessions = [] + session = nil + reconnectSession = previous + listingSince = now() + autoView = true + phase = .listing + } + guard client.hasRegistrySnapshot else { + if client.phase == .failed { fail(client.status) } + else { status = client.status; changed() } + return + } + let available = client.registry.values.filter { $0.capabilities.contains("terminal-v1") } + .sorted { $0.session_id < $1.session_id }.map { descriptor in + RemoteTerminalSession(terminalId: descriptor.session_id, generation: descriptor.process_gen, + provider: descriptor.provider, rows: 24, cols: 80, + exitStatus: descriptor.state == "exited" || descriptor.state == "closed" ? descriptor.state : nil, + rendering: "vt100-screen-diff", widthProfile: "atem-unicode-width-0.1.14", + capabilities: descriptor.capabilities, agentId: descriptor.session_id, label: descriptor.cwd_label) + } + if listingSince != nil || available != sessions { + if let session, !available.contains(where: { $0.id == session.id }), stream != nil { + detach() + self.session = nil + phase = .ready + } + listingSince = listingSince ?? now() + guard let source = connection else { return } + receivePayload(.sessions(available), from: source) + } + if phase == .ready, autoView, protocolTarget != nil { + autoView = false + view() + return + } + if let target = protocolTarget, let binding = client.binding(for: target, mutation: true), + restored, stream != nil, phase != .stopping { + leaseId = binding.attachment_gen + ":" + String(binding.lease_epoch!) + wantsControl = true + phase = .controlling + status = client.status + sendResizeIfNeeded() + } else if restored, phase != .exited, phase != .stopping { + leaseId = nil + wantsControl = protocolTarget.map(client.acquiring) == true + phase = .viewing + status = client.status + } + changed() + } + + private func detach() { + if let client = sessionClient, let target = protocolTarget, client.controls(target) || client.acquiring(target) { client.release(target) } + if let stream { _ = send(.unsubscribe(stream)) } + if stream == nil, attachSince != nil, let connection, let session, + let index = attachmentRequests.lastIndex(where: { $0.connection == connection && $0.sessionId == session.id }) { + attachmentRequests[index].cancelled = true + } + attachmentRequests.removeAll { $0.connection != currentConnection() } + stream = nil + leaseId = nil + leaseConfirmedAt = nil + renewalSince = nil + wantsControl = false + restored = false + receivedFirstFrame = false + transactionOpen = false + transactionBytes = 0 + frameSize = nil + receivedOffset = 0 + acknowledgedOffset = 0 + nextSequence = 0 + pendingWrites = [:] + frames = [] + queuedBytes = 0 + rendering = nil + sentSize = nil + attachSince = nil + controlSince = nil + stopSince = nil + } + + private func fail(_ message: String) { + let uncertain = !pendingWrites.isEmpty + detach() + listingSince = nil + phase = .failed + status = message + if uncertain && !message.contains("unknown") { status += " Unconfirmed input will not be resent." } + changed() + } + + private func disconnectedSend() { + if currentConnection() != connection { connectionChanged() } + else { fail("The terminal connection could not send. Reopen a view; unconfirmed input will not be resent.") } + } + + private func changed() { onChange?() } +} diff --git a/Sources/Menubar/RemoteTerminalMessage.swift b/Sources/Menubar/RemoteTerminalMessage.swift new file mode 100644 index 0000000..04b04da --- /dev/null +++ b/Sources/Menubar/RemoteTerminalMessage.swift @@ -0,0 +1,275 @@ +import Foundation + +struct RemoteTerminalConnection: Equatable { + let clientId: String + let connectionId: String +} + +struct RemoteTerminalSession: Codable, Equatable, Identifiable { + let terminalId: String + let generation: String + let provider: String + var rows: Int + var cols: Int + var exitStatus: String? + let rendering: String + let widthProfile: String + var capabilities: [String] = [] + var agentId: String? + var label: String? + + var id: String { terminalId + ":" + generation } + var supportsExpiringControl: Bool { capabilities.contains("terminal-lease-v1") } + var isSupported: Bool { + rendering == "vt100-screen-diff" && widthProfile == "atem-unicode-width-0.1.14" + && (2...200).contains(rows) && (2...400).contains(cols) + && !terminalId.isEmpty && !generation.isEmpty + && terminalId.utf8.count <= 128 && generation.utf8.count <= 128 + && provider.utf8.count <= 128 && capabilities.count <= 16 + && capabilities.allSatisfy({ !$0.isEmpty && $0.utf8.count <= 128 }) + && (agentId?.utf8.count ?? 0) <= 128 && (label?.utf8.count ?? 0) <= 256 + } + + enum CodingKeys: String, CodingKey { + case terminalId = "terminal_id", generation, provider, rows, cols + case exitStatus = "exit_status", rendering, widthProfile = "width_profile" + case capabilities, agentId = "agent_id", label + } +} + +extension RemoteTerminalSession { + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + terminalId = try values.decode(String.self, forKey: .terminalId) + generation = try values.decode(String.self, forKey: .generation) + provider = try values.decode(String.self, forKey: .provider) + rows = try values.decode(Int.self, forKey: .rows) + cols = try values.decode(Int.self, forKey: .cols) + exitStatus = try values.decodeIfPresent(String.self, forKey: .exitStatus) + rendering = try values.decode(String.self, forKey: .rendering) + widthProfile = try values.decode(String.self, forKey: .widthProfile) + capabilities = try values.decodeIfPresent([String].self, forKey: .capabilities) ?? [] + agentId = try values.decodeIfPresent(String.self, forKey: .agentId) + label = try values.decodeIfPresent(String.self, forKey: .label) + } +} + +struct RemoteTerminalStream: Equatable { + let terminalId: String + let generation: String + let streamId: String +} + +struct RemoteTerminalFrame: Equatable { + let stream: RemoteTerminalStream + let offset: UInt64 + let bytes: String + let reset: Bool + let complete: Bool + let rows: Int + let cols: Int +} + +/// Flat serde-compatible data inside {"type":"terminal","data":...}. +enum RemoteTerminalMessage: Codable, Equatable { + indirect case bound(RemoteTerminalMessage, AtemSessionBinding) + case list + case sessions([RemoteTerminalSession]) + case subscribe(terminalId: String, generation: String) + case unsubscribe(RemoteTerminalStream) + case ack(RemoteTerminalStream, offset: UInt64) + case control(RemoteTerminalStream, take: Bool) + case renew(RemoteTerminalStream, leaseId: String) + case input(RemoteTerminalStream, leaseId: String, sequence: UInt64, bytes: String) + case resize(RemoteTerminalStream, leaseId: String, rows: Int, cols: Int) + case stop(RemoteTerminalStream, leaseId: String) + case attached(RemoteTerminalSession, streamId: String) + case frame(RemoteTerminalFrame) + case controller(RemoteTerminalStream, leaseId: String?) + case written(RemoteTerminalStream, sequence: UInt64, error: String?) + case exited(terminalId: String, generation: String, status: String) + case error(terminalId: String?, streamId: String? = nil, code: String = "unsupported_version", message: String) + case unsupported(version: Int, operation: String) + + private enum Keys: String, CodingKey { + case v, op, sessions, session, generation, offset, take, sequence, bytes, rows, cols + case reset, complete, error, status, message, code + case terminalId = "terminal_id", streamId = "stream_id", leaseId = "lease_id", binding + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: Keys.self) + let version = try c.decode(Int.self, forKey: .v) + let op = try c.decode(String.self, forKey: .op) + guard version == 1 else { self = .unsupported(version: version, operation: op); return } + switch op { + case "list": self = .list + case "sessions": self = .sessions(try c.decode([RemoteTerminalSession].self, forKey: .sessions)) + case "subscribe": + self = .subscribe(terminalId: try c.decode(String.self, forKey: .terminalId), + generation: try c.decode(String.self, forKey: .generation)) + case "unsubscribe": self = .unsubscribe(try Self.stream(c)) + case "ack": self = .ack(try Self.stream(c), offset: try c.decode(UInt64.self, forKey: .offset)) + case "control": self = .control(try Self.stream(c), take: try c.decode(Bool.self, forKey: .take)) + case "renew": self = .renew(try Self.stream(c), leaseId: try c.decode(String.self, forKey: .leaseId)) + case "input": + self = .input(try Self.stream(c), leaseId: try Self.lease(c), + sequence: try c.decode(UInt64.self, forKey: .sequence), + bytes: try c.decode(String.self, forKey: .bytes)) + case "resize": + self = .resize(try Self.stream(c), leaseId: try Self.lease(c), + rows: try c.decode(Int.self, forKey: .rows), cols: try c.decode(Int.self, forKey: .cols)) + case "stop": self = .stop(try Self.stream(c), leaseId: try Self.lease(c)) + case "attached": + self = .attached(try c.decode(RemoteTerminalSession.self, forKey: .session), + streamId: try c.decode(String.self, forKey: .streamId)) + case "frame": + self = .frame(RemoteTerminalFrame( + stream: try Self.stream(c), offset: try c.decode(UInt64.self, forKey: .offset), + bytes: try c.decode(String.self, forKey: .bytes), reset: try c.decode(Bool.self, forKey: .reset), + complete: try c.decode(Bool.self, forKey: .complete), rows: try c.decode(Int.self, forKey: .rows), + cols: try c.decode(Int.self, forKey: .cols))) + case "controller": self = .controller(try Self.stream(c), leaseId: try c.decodeIfPresent(String.self, forKey: .leaseId)) + case "written": + self = .written(try Self.stream(c), sequence: try c.decode(UInt64.self, forKey: .sequence), + error: try c.decodeIfPresent(String.self, forKey: .error)) + case "exited": + self = .exited(terminalId: try c.decode(String.self, forKey: .terminalId), + generation: try c.decode(String.self, forKey: .generation), + status: try c.decode(String.self, forKey: .status)) + case "error": + let code = try c.decode(String.self, forKey: .code) + try AtemSessionWire.check(!code.isEmpty && code.utf8.count <= 128) + self = .error(terminalId: try c.decodeIfPresent(String.self, forKey: .terminalId), + streamId: try c.decodeIfPresent(String.self, forKey: .streamId), + code: code, + message: try c.decode(String.self, forKey: .message)) + default: self = .unsupported(version: version, operation: op) + } + if let binding = try c.decodeIfPresent(AtemSessionBinding.self, forKey: .binding) { + try validate(binding: binding) + self = .bound(self, binding) + } + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: Keys.self) + try c.encode(1, forKey: .v) + switch self { + case .bound(let message, let binding): + try message.validate(binding: binding) + switch message { + case .input(let stream, _, let sequence, let bytes): + try c.encode("input", forKey: .op); try Self.encode(stream, into: &c) + try c.encode(sequence, forKey: .sequence); try c.encode(bytes, forKey: .bytes) + case .resize(let stream, _, let rows, let cols): + try c.encode("resize", forKey: .op); try Self.encode(stream, into: &c) + try c.encode(rows, forKey: .rows); try c.encode(cols, forKey: .cols) + case .stop(let stream, _): + try c.encode("stop", forKey: .op); try Self.encode(stream, into: &c) + case .bound, .control, .renew: throw AtemSessionJSON.Invalid.json + default: try message.encode(to: encoder) + } + try c.encode(binding, forKey: .binding) + case .list: try c.encode("list", forKey: .op) + case .sessions(let sessions): + try c.encode("sessions", forKey: .op); try c.encode(sessions, forKey: .sessions) + case .subscribe(let terminalId, let generation): + try c.encode("subscribe", forKey: .op) + try c.encode(terminalId, forKey: .terminalId); try c.encode(generation, forKey: .generation) + case .unsubscribe(let stream): + try c.encode("unsubscribe", forKey: .op); try Self.encode(stream, into: &c) + case .ack(let stream, let offset): + try c.encode("ack", forKey: .op); try Self.encode(stream, into: &c); try c.encode(offset, forKey: .offset) + case .control(let stream, let take): + try c.encode("control", forKey: .op); try Self.encode(stream, into: &c); try c.encode(take, forKey: .take) + case .renew(let stream, let leaseId): + try c.encode("renew", forKey: .op); try Self.encode(stream, into: &c) + try c.encode(leaseId, forKey: .leaseId) + case .input(let stream, let leaseId, let sequence, let bytes): + try c.encode("input", forKey: .op); try Self.encode(stream, into: &c) + try c.encode(leaseId, forKey: .leaseId); try c.encode(sequence, forKey: .sequence) + try c.encode(bytes, forKey: .bytes) + case .resize(let stream, let leaseId, let rows, let cols): + try c.encode("resize", forKey: .op); try Self.encode(stream, into: &c) + try c.encode(leaseId, forKey: .leaseId); try c.encode(rows, forKey: .rows); try c.encode(cols, forKey: .cols) + case .stop(let stream, let leaseId): + try c.encode("stop", forKey: .op); try Self.encode(stream, into: &c); try c.encode(leaseId, forKey: .leaseId) + case .attached(let session, let streamId): + try c.encode("attached", forKey: .op); try c.encode(session, forKey: .session) + try c.encode(streamId, forKey: .streamId) + case .frame(let frame): + try c.encode("frame", forKey: .op); try Self.encode(frame.stream, into: &c) + try c.encode(frame.offset, forKey: .offset); try c.encode(frame.bytes, forKey: .bytes) + try c.encode(frame.reset, forKey: .reset); try c.encode(frame.complete, forKey: .complete) + try c.encode(frame.rows, forKey: .rows); try c.encode(frame.cols, forKey: .cols) + case .controller(let stream, let leaseId): + try c.encode("controller", forKey: .op); try Self.encode(stream, into: &c) + try c.encode(leaseId, forKey: .leaseId) + case .written(let stream, let sequence, let error): + try c.encode("written", forKey: .op); try Self.encode(stream, into: &c) + try c.encode(sequence, forKey: .sequence); try c.encode(error, forKey: .error) + case .exited(let terminalId, let generation, let status): + try c.encode("exited", forKey: .op); try c.encode(terminalId, forKey: .terminalId) + try c.encode(generation, forKey: .generation); try c.encode(status, forKey: .status) + case .error(let terminalId, let streamId, let code, let message): + try c.encode("error", forKey: .op); try c.encode(terminalId, forKey: .terminalId) + try c.encodeIfPresent(streamId, forKey: .streamId); try c.encode(code, forKey: .code) + try c.encode(message, forKey: .message) + case .unsupported: + throw EncodingError.invalidValue(self, .init(codingPath: encoder.codingPath, + debugDescription: "Unsupported terminal messages cannot be sent")) + } + } + + private func validate(binding: AtemSessionBinding) throws { + try AtemSessionWire.check(binding.valid) + let id: String + let process: String + switch self { + case .subscribe(let terminal, let generation): + id = terminal; process = generation + try AtemSessionWire.check(binding.lease_epoch == nil) + case .unsubscribe(let stream), .ack(let stream, _): + id = stream.terminalId; process = stream.generation + try AtemSessionWire.check(binding.lease_epoch == nil) + case .frame(let frame): id = frame.stream.terminalId; process = frame.stream.generation + case .input(let stream, _, let sequence, _): + id = stream.terminalId; process = stream.generation + try AtemSessionWire.check(binding.lease_epoch != nil && sequence <= AtemSessionWire.maximumCounter) + case .resize(let stream, _, _, _), .stop(let stream, _): + id = stream.terminalId; process = stream.generation + try AtemSessionWire.check(binding.lease_epoch != nil) + case .attached(let session, _): id = session.terminalId; process = session.generation + case .written(let stream, let sequence, _): + id = stream.terminalId; process = stream.generation + try AtemSessionWire.check(sequence <= AtemSessionWire.maximumCounter) + case .exited(let terminal, let generation, _): id = terminal; process = generation + case .controller(let stream, _): id = stream.terminalId; process = stream.generation + case .error(let terminal, _, _, _): + try AtemSessionWire.check(terminal == nil || terminal == binding.target.session_id) + return + default: throw AtemSessionJSON.Invalid.json + } + try AtemSessionWire.check(id == binding.target.session_id && process == binding.target.process_gen) + if case .ack(_, let offset) = self { try AtemSessionWire.check(offset <= AtemSessionWire.maximumCounter && binding.lease_epoch == nil) } + if case .frame(let frame) = self { try AtemSessionWire.check(frame.offset <= AtemSessionWire.maximumCounter) } + } + + private static func stream(_ c: KeyedDecodingContainer) throws -> RemoteTerminalStream { + RemoteTerminalStream(terminalId: try c.decode(String.self, forKey: .terminalId), + generation: try c.decode(String.self, forKey: .generation), + streamId: try c.decode(String.self, forKey: .streamId)) + } + + private static func lease(_ c: KeyedDecodingContainer) throws -> String { + if let binding = try c.decodeIfPresent(AtemSessionBinding.self, forKey: .binding), binding.lease_epoch != nil { return "" } + return try c.decode(String.self, forKey: .leaseId) + } + + private static func encode(_ stream: RemoteTerminalStream, into c: inout KeyedEncodingContainer) throws { + try c.encode(stream.terminalId, forKey: .terminalId) + try c.encode(stream.generation, forKey: .generation) + try c.encode(stream.streamId, forKey: .streamId) + } +} diff --git a/Sources/Menubar/RemoteTerminalRenderer.swift b/Sources/Menubar/RemoteTerminalRenderer.swift new file mode 100644 index 0000000..8d1061a --- /dev/null +++ b/Sources/Menubar/RemoteTerminalRenderer.swift @@ -0,0 +1,127 @@ +import AppKit +import WebKit + +enum RemoteTerminalAssets { + static var pageURL: URL? { + Bundle.module.url(forResource: "index", withExtension: "html", subdirectory: "RemoteTerminal") + } + + static func validate() throws { + let files = ["index.html", "terminal.js", "terminal.css", "xterm.js", "xterm.css", "addon-fit.js", + "atem-unicode.js", "atem-unicode.cjs", "unicode-width-0.1.14.json", "xterm-LICENSE", "addon-fit-LICENSE"] + guard let directory = pageURL?.deletingLastPathComponent() else { throw CocoaError(.fileReadNoSuchFile) } + for file in files { + if try Data(contentsOf: directory.appendingPathComponent(file)).isEmpty { throw CocoaError(.fileReadCorruptFile) } + } + } +} + +enum RemoteTerminalRendererAction { + case ready + case input(Data, epoch: String) + case consumed(String) + case size(rows: Int, cols: Int) + case copy(String) + case paste(epoch: String) + case oversizedPaste +} + +private final class WeakTerminalScriptHandler: NSObject, WKScriptMessageHandler { + weak var renderer: RemoteTerminalRenderer? + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + renderer?.receive(message) + } +} + +final class RemoteTerminalRenderer: NSObject, WKNavigationDelegate, WKUIDelegate { + let webView: WKWebView + private(set) var isReady = false + var onAction: ((RemoteTerminalRendererAction) -> Void)? + var onFailure: (() -> Void)? + private let handler: WeakTerminalScriptHandler + private let pageURL: URL? + + override init() { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + configuration.mediaTypesRequiringUserActionForPlayback = .all + handler = WeakTerminalScriptHandler() + configuration.userContentController.add(handler, name: "terminal") + webView = WKWebView(frame: .zero, configuration: configuration) + pageURL = RemoteTerminalAssets.pageURL + super.init() + handler.renderer = self + webView.navigationDelegate = self + webView.uiDelegate = self + webView.setValue(false, forKey: "drawsBackground") + } + + func load() { + isReady = false + guard let pageURL else { onFailure?(); return } + do { try RemoteTerminalAssets.validate() } catch { onFailure?(); return } + webView.loadFileURL(pageURL, allowingReadAccessTo: pageURL.deletingLastPathComponent()) + } + + func render(_ frame: RemoteTerminalFrame, token: String) { + call("applyFrame", arguments: [["bytes": frame.bytes, "reset": frame.reset, + "rows": frame.rows, "cols": frame.cols, "streamId": frame.stream.streamId], token]) + } + + func setControl(_ enabled: Bool, epoch: String) { + guard isReady else { return } + call("setControl", arguments: [enabled, epoch]) + } + + func paste(_ text: String, epoch: String) { call("paste", arguments: [text, epoch]) } + func copySelection() { call("copySelection", arguments: []) } + + private func call(_ method: String, arguments: [Any]) { + guard isReady, let data = try? JSONSerialization.data(withJSONObject: arguments), + let json = String(data: data, encoding: .utf8) else { return } + webView.evaluateJavaScript("window.astationTerminal.\(method)(...\(json))") { [weak self] _, error in + if error != nil { self?.onFailure?() } + } + } + + fileprivate func receive(_ message: WKScriptMessage) { + guard message.frameInfo.isMainFrame, + message.frameInfo.request.url?.standardizedFileURL == pageURL?.standardizedFileURL, + let body = message.body as? [String: Any], let op = body["op"] as? String else { return } + switch op { + case "ready": isReady = true; onAction?(.ready) + case "input": + guard let encoded = body["bytes"] as? String, encoded.utf8.count <= 21_848, + let bytes = Data(base64Encoded: encoded), bytes.count <= 16 * 1024, + let epoch = body["epoch"] as? String else { return } + onAction?(.input(bytes, epoch: epoch)) + case "consumed": + if let token = body["token"] as? String { onAction?(.consumed(token)) } + case "size": + if let rows = body["rows"] as? Int, let cols = body["cols"] as? Int, + rows > 0, cols > 0, rows <= 100_000, cols <= 100_000 { onAction?(.size(rows: rows, cols: cols)) } + case "copy": + if let text = body["text"] as? String, text.utf8.count <= 1024 * 1024 { onAction?(.copy(text)) } + case "paste": + if let epoch = body["epoch"] as? String { onAction?(.paste(epoch: epoch)) } + case "oversizedPaste": onAction?(.oversizedPaste) + default: break + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + decisionHandler(navigationAction.targetFrame?.isMainFrame == true + && navigationAction.request.url?.standardizedFileURL == pageURL?.standardizedFileURL ? .allow : .cancel) + } + + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { nil } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { failed() } + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { failed() } + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { failed() } + + private func failed() { isReady = false; onFailure?() } +} diff --git a/Sources/Menubar/Resources/RemoteTerminal/addon-fit-LICENSE b/Sources/Menubar/Resources/RemoteTerminal/addon-fit-LICENSE new file mode 100644 index 0000000..8f17892 --- /dev/null +++ b/Sources/Menubar/Resources/RemoteTerminal/addon-fit-LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Sources/Menubar/Resources/RemoteTerminal/addon-fit.js b/Sources/Menubar/Resources/RemoteTerminal/addon-fit.js new file mode 100644 index 0000000..9b25be3 --- /dev/null +++ b/Sources/Menubar/Resources/RemoteTerminal/addon-fit.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})())); +//# sourceMappingURL=addon-fit.js.map \ No newline at end of file diff --git a/Sources/Menubar/Resources/RemoteTerminal/atem-unicode.cjs b/Sources/Menubar/Resources/RemoteTerminal/atem-unicode.cjs new file mode 100644 index 0000000..806ad4c --- /dev/null +++ b/Sources/Menubar/Resources/RemoteTerminal/atem-unicode.cjs @@ -0,0 +1,31 @@ +// xterm 6.0.0 provider matching vt100 0.15.2 / unicode-width 0.1.14. +// Load with allowProposedApi: true, before applying any terminal-v1 frames. +const ranges = require('./unicode-width-0.1.14.json'); + +class AtemUnicode { + version = 'atem-unicode-width-0.1.14'; + + wcwidth(codepoint) { + let low = 0; + let high = ranges.length - 1; + while (low <= high) { + const middle = (low + high) >>> 1; + const [start, end, width] = ranges[middle]; + if (codepoint < start) high = middle - 1; + else if (codepoint > end) low = middle + 1; + else return width; + } + return 1; + } + + charProperties(codepoint, preceding) { + let width = this.wcwidth(codepoint); + const previousWidth = (preceding >>> 1) & 3; + const join = width === 0 && previousWidth > 0; + if (join) width = previousWidth; + // xterm 6 UnicodeCharProperties: kind << 3 | width << 1 | join. + return (width << 1) | Number(join); + } +} + +module.exports = { AtemUnicode }; diff --git a/Sources/Menubar/Resources/RemoteTerminal/atem-unicode.js b/Sources/Menubar/Resources/RemoteTerminal/atem-unicode.js new file mode 100644 index 0000000..f64beda --- /dev/null +++ b/Sources/Menubar/Resources/RemoteTerminal/atem-unicode.js @@ -0,0 +1,41 @@ +(() => { +const widths = [[0,31,0],[127,159,0],[173,173,0],[768,879,0],[1155,1161,0],[1425,1469,0],[1471,1471,0],[1473,1474,0],[1476,1477,0],[1479,1479,0],[1541,1541,0],[1552,1562,0],[1564,1564,0],[1611,1631,0],[1648,1648,0],[1750,1756,0],[1759,1764,0],[1767,1768,0],[1770,1773,0],[1807,1807,0],[1809,1809,0],[1840,1866,0],[1958,1968,0],[2027,2035,0],[2045,2045,0],[2070,2073,0],[2075,2083,0],[2085,2087,0],[2089,2093,0],[2137,2139,0],[2192,2193,0],[2200,2207,0],[2250,2306,0],[2362,2362,0],[2364,2364,0],[2369,2376,0],[2381,2381,0],[2385,2391,0],[2402,2403,0],[2433,2433,0],[2492,2492,0],[2494,2494,0],[2497,2500,0],[2509,2509,0],[2519,2519,0],[2530,2531,0],[2558,2558,0],[2561,2562,0],[2620,2620,0],[2625,2626,0],[2631,2632,0],[2635,2637,0],[2641,2641,0],[2672,2673,0],[2677,2677,0],[2689,2690,0],[2748,2748,0],[2753,2757,0],[2759,2760,0],[2765,2765,0],[2786,2787,0],[2810,2815,0],[2817,2817,0],[2876,2876,0],[2878,2879,0],[2881,2884,0],[2893,2893,0],[2901,2903,0],[2914,2915,0],[2946,2946,0],[3006,3006,0],[3008,3008,0],[3021,3021,0],[3031,3031,0],[3072,3072,0],[3076,3076,0],[3132,3132,0],[3134,3136,0],[3142,3144,0],[3146,3149,0],[3157,3158,0],[3170,3171,0],[3201,3201,0],[3260,3260,0],[3263,3264,0],[3266,3266,0],[3270,3272,0],[3274,3277,0],[3285,3286,0],[3298,3299,0],[3328,3329,0],[3387,3388,0],[3390,3390,0],[3393,3396,0],[3405,3406,0],[3415,3415,0],[3426,3427,0],[3457,3457,0],[3530,3530,0],[3535,3535,0],[3538,3540,0],[3542,3542,0],[3551,3551,0],[3633,3633,0],[3636,3642,0],[3655,3662,0],[3761,3761,0],[3764,3772,0],[3784,3790,0],[3864,3865,0],[3893,3893,0],[3895,3895,0],[3897,3897,0],[3953,3966,0],[3968,3972,0],[3974,3975,0],[3981,3991,0],[3993,4028,0],[4038,4038,0],[4141,4144,0],[4146,4151,0],[4153,4154,0],[4157,4158,0],[4184,4185,0],[4190,4192,0],[4209,4212,0],[4226,4226,0],[4229,4230,0],[4237,4237,0],[4253,4253,0],[4352,4447,2],[4448,4607,0],[4957,4959,0],[5906,5908,0],[5938,5939,0],[5970,5971,0],[6002,6003,0],[6052,6052,2],[6068,6069,0],[6071,6077,0],[6086,6086,0],[6089,6099,0],[6104,6104,3],[6109,6109,0],[6155,6159,0],[6277,6278,0],[6313,6313,0],[6432,6434,0],[6439,6440,0],[6450,6450,0],[6457,6459,0],[6679,6680,0],[6683,6683,0],[6742,6742,0],[6744,6750,0],[6752,6752,0],[6754,6754,0],[6757,6764,0],[6771,6780,0],[6783,6783,0],[6832,6862,0],[6912,6915,0],[6964,6973,0],[6978,6979,0],[7019,7027,0],[7040,7041,0],[7074,7077,0],[7080,7081,0],[7083,7085,0],[7142,7142,0],[7144,7145,0],[7149,7149,0],[7151,7153,0],[7212,7219,0],[7222,7223,0],[7376,7378,0],[7380,7392,0],[7394,7400,0],[7405,7405,0],[7412,7412,0],[7416,7417,0],[7616,7679,0],[8203,8207,0],[8234,8238,0],[8288,8303,0],[8400,8432,0],[8986,8987,2],[9001,9002,2],[9193,9196,2],[9200,9200,2],[9203,9203,2],[9725,9726,2],[9748,9749,2],[9800,9811,2],[9855,9855,2],[9875,9875,2],[9889,9889,2],[9898,9899,2],[9917,9918,2],[9924,9925,2],[9934,9934,2],[9940,9940,2],[9962,9962,2],[9970,9971,2],[9973,9973,2],[9978,9978,2],[9981,9981,2],[9989,9989,2],[9994,9995,2],[10024,10024,2],[10060,10060,2],[10062,10062,2],[10067,10069,2],[10071,10071,2],[10133,10135,2],[10160,10160,2],[10175,10175,2],[11035,11036,2],[11088,11088,2],[11093,11093,2],[11503,11505,0],[11744,11775,0],[11904,11929,2],[11931,12019,2],[12032,12245,2],[12272,12329,2],[12330,12335,0],[12336,12350,2],[12353,12438,2],[12441,12442,0],[12443,12543,2],[12549,12591,2],[12593,12643,2],[12644,12644,0],[12645,12686,2],[12688,12771,2],[12783,12830,2],[12832,12871,2],[12880,19903,2],[19968,42124,2],[42128,42182,2],[42607,42610,0],[42612,42621,0],[42654,42655,0],[42736,42737,0],[43010,43010,0],[43014,43014,0],[43019,43019,0],[43045,43046,0],[43052,43052,0],[43204,43205,0],[43232,43249,0],[43258,43258,0],[43263,43263,0],[43302,43309,0],[43335,43345,0],[43360,43388,2],[43392,43394,0],[43443,43443,0],[43446,43449,0],[43452,43453,0],[43493,43493,0],[43561,43566,0],[43569,43570,0],[43573,43574,0],[43587,43587,0],[43596,43596,0],[43644,43644,0],[43696,43696,0],[43698,43700,0],[43703,43704,0],[43710,43711,0],[43713,43713,0],[43756,43757,0],[43766,43766,0],[44005,44005,0],[44008,44008,0],[44013,44013,0],[44032,55203,2],[55216,55238,0],[55243,55291,0],[55296,57343,0],[63744,64255,2],[64286,64286,0],[65024,65039,0],[65040,65049,2],[65056,65071,0],[65072,65106,2],[65108,65126,2],[65128,65131,2],[65279,65279,0],[65281,65376,2],[65438,65440,0],[65504,65510,2],[65520,65528,0],[66045,66045,0],[66272,66272,0],[66422,66426,0],[68097,68099,0],[68101,68102,0],[68108,68111,0],[68152,68154,0],[68159,68159,0],[68325,68326,0],[68900,68903,0],[69291,69292,0],[69373,69375,0],[69446,69456,0],[69506,69509,0],[69633,69633,0],[69688,69702,0],[69744,69744,0],[69747,69748,0],[69759,69761,0],[69811,69814,0],[69817,69818,0],[69826,69826,0],[69888,69890,0],[69927,69931,0],[69933,69940,0],[70003,70003,0],[70016,70017,0],[70070,70078,0],[70082,70083,0],[70089,70092,0],[70095,70095,0],[70191,70193,0],[70196,70196,0],[70198,70199,0],[70206,70206,0],[70209,70209,0],[70367,70367,0],[70371,70378,0],[70400,70401,0],[70459,70460,0],[70462,70462,0],[70464,70464,0],[70487,70487,0],[70502,70508,0],[70512,70516,0],[70712,70719,0],[70722,70724,0],[70726,70726,0],[70750,70750,0],[70832,70832,0],[70835,70840,0],[70842,70842,0],[70845,70845,0],[70847,70848,0],[70850,70851,0],[71087,71087,0],[71090,71093,0],[71100,71101,0],[71103,71104,0],[71132,71133,0],[71219,71226,0],[71229,71229,0],[71231,71232,0],[71339,71339,0],[71341,71341,0],[71344,71349,0],[71351,71351,0],[71453,71455,0],[71458,71461,0],[71463,71467,0],[71727,71735,0],[71737,71738,0],[71984,71984,0],[71995,71996,0],[71998,71999,0],[72001,72001,0],[72003,72003,0],[72148,72151,0],[72154,72155,0],[72160,72160,0],[72193,72202,0],[72243,72248,0],[72250,72254,0],[72263,72263,0],[72273,72278,0],[72281,72283,0],[72324,72342,0],[72344,72345,0],[72752,72758,0],[72760,72765,0],[72767,72767,0],[72850,72871,0],[72874,72880,0],[72882,72883,0],[72885,72886,0],[73009,73014,0],[73018,73018,0],[73020,73021,0],[73023,73031,0],[73104,73105,0],[73109,73109,0],[73111,73111,0],[73459,73460,0],[73472,73474,0],[73526,73530,0],[73536,73536,0],[73538,73538,0],[78912,78912,0],[78919,78933,0],[92912,92916,0],[92976,92982,0],[94031,94031,0],[94095,94098,0],[94176,94179,2],[94180,94180,0],[94192,94193,2],[94208,100343,2],[100352,101589,2],[101632,101640,2],[110576,110579,2],[110581,110587,2],[110589,110590,2],[110592,110882,2],[110898,110898,2],[110928,110930,2],[110933,110933,2],[110948,110951,2],[110960,111355,2],[113821,113822,0],[113824,113827,0],[118528,118573,0],[118576,118598,0],[119141,119141,0],[119143,119145,0],[119150,119170,0],[119173,119179,0],[119210,119213,0],[119362,119364,0],[121344,121398,0],[121403,121452,0],[121461,121461,0],[121476,121476,0],[121499,121503,0],[121505,121519,0],[122880,122886,0],[122888,122904,0],[122907,122913,0],[122915,122916,0],[122918,122922,0],[123023,123023,0],[123184,123190,0],[123566,123566,0],[123628,123631,0],[124140,124143,0],[125136,125142,0],[125252,125258,0],[126980,126980,2],[127183,127183,2],[127374,127374,2],[127377,127386,2],[127488,127490,2],[127504,127547,2],[127552,127560,2],[127568,127569,2],[127584,127589,2],[127744,127776,2],[127789,127797,2],[127799,127868,2],[127870,127891,2],[127904,127946,2],[127951,127955,2],[127968,127984,2],[127988,127988,2],[127992,128062,2],[128064,128064,2],[128066,128252,2],[128255,128317,2],[128331,128334,2],[128336,128359,2],[128378,128378,2],[128405,128406,2],[128420,128420,2],[128507,128591,2],[128640,128709,2],[128716,128716,2],[128720,128722,2],[128725,128727,2],[128732,128735,2],[128747,128748,2],[128756,128764,2],[128992,129003,2],[129008,129008,2],[129292,129338,2],[129340,129349,2],[129351,129535,2],[129648,129660,2],[129664,129672,2],[129680,129725,2],[129727,129733,2],[129742,129755,2],[129760,129768,2],[129776,129784,2],[131072,196605,2],[196608,262141,2],[917504,921599,0]]; +const require = name => { + if (name !== './unicode-width-0.1.14.json') throw new Error('Unknown module'); + return widths; +}; +const module = { exports: {} }; +// xterm 6.0.0 provider matching vt100 0.15.2 / unicode-width 0.1.14. +// Load with allowProposedApi: true, before applying any terminal-v1 frames. +const ranges = require('./unicode-width-0.1.14.json'); + +class AtemUnicode { + version = 'atem-unicode-width-0.1.14'; + + wcwidth(codepoint) { + let low = 0; + let high = ranges.length - 1; + while (low <= high) { + const middle = (low + high) >>> 1; + const [start, end, width] = ranges[middle]; + if (codepoint < start) high = middle - 1; + else if (codepoint > end) low = middle + 1; + else return width; + } + return 1; + } + + charProperties(codepoint, preceding) { + let width = this.wcwidth(codepoint); + const previousWidth = (preceding >>> 1) & 3; + const join = width === 0 && previousWidth > 0; + if (join) width = previousWidth; + // xterm 6 UnicodeCharProperties: kind << 3 | width << 1 | join. + return (width << 1) | Number(join); + } +} + +module.exports = { AtemUnicode }; + +window.AtemUnicode = module.exports.AtemUnicode; +})(); diff --git a/Sources/Menubar/Resources/RemoteTerminal/index.html b/Sources/Menubar/Resources/RemoteTerminal/index.html new file mode 100644 index 0000000..c5fbc38 --- /dev/null +++ b/Sources/Menubar/Resources/RemoteTerminal/index.html @@ -0,0 +1,18 @@ + + + + + + + Remote Terminal + + + + +
+ + + + + + diff --git a/Sources/Menubar/Resources/RemoteTerminal/terminal.css b/Sources/Menubar/Resources/RemoteTerminal/terminal.css new file mode 100644 index 0000000..0578d4f --- /dev/null +++ b/Sources/Menubar/Resources/RemoteTerminal/terminal.css @@ -0,0 +1,6 @@ +:root { color-scheme: dark; background: #14191d; } +html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; } +#viewport { height: 100%; width: 100%; overflow: auto; box-sizing: border-box; padding: 12px; } +#terminal { width: 100%; height: 100%; } +.xterm { padding: 0; } +.xterm-viewport { scrollbar-width: none; } diff --git a/Sources/Menubar/Resources/RemoteTerminal/terminal.js b/Sources/Menubar/Resources/RemoteTerminal/terminal.js new file mode 100644 index 0000000..c9e2e87 --- /dev/null +++ b/Sources/Menubar/Resources/RemoteTerminal/terminal.js @@ -0,0 +1,113 @@ +/* The bridge accepts only locally initiated actions. Host output is always bytes. */ +(() => { + 'use strict'; + const post = (op, data = {}) => window.webkit.messageHandlers.terminal.postMessage({ op, ...data }); + let term; + let fit; + let displayedStream = ''; + let enabled = false; + let epoch = ''; + let measureTimer; + function createTerminal() { + term = new Terminal({ + allowProposedApi: true, scrollback: 0, cols: 120, rows: 40, + disableStdin: true, cursorBlink: false, screenReaderMode: true, + fontFamily: 'Menlo, Monaco, monospace', fontSize: 13, lineHeight: 1.12, + theme: { background: '#14191d', foreground: '#e5e9ed', cursor: '#5ac5b6', selectionBackground: '#315867' } + }); + fit = new FitAddon.FitAddon(); + term.loadAddon(fit); + term.unicode.register(new AtemUnicode()); + term.unicode.activeVersion = 'atem-unicode-width-0.1.14'; + term.open(document.getElementById('terminal')); + } + const base64 = bytes => { + let raw = ''; + for (const byte of bytes) raw += String.fromCharCode(byte); + return btoa(raw); + }; + const send = bytes => { + if (!enabled || bytes.length === 0) return; + if (bytes.length > 16384) { post('oversizedPaste'); return; } + post('input', { bytes: base64(bytes), epoch }); + }; + function wireInput() { + term.onData(data => send(new TextEncoder().encode(data))); + term.onBinary(data => send(Uint8Array.from(data, character => character.charCodeAt(0)))); + term.attachCustomKeyEventHandler(event => { + if (event.type !== 'keydown') return true; + if (event.metaKey && !event.altKey && !event.ctrlKey) { + if (event.key.toLowerCase() === 'c') { + if (term.hasSelection()) post('copy', { text: term.getSelection() }); + return false; + } + if (event.key.toLowerCase() === 'v') { if (enabled) post('paste', { epoch }); return false; } + if (event.key.toLowerCase() === 'a') { term.selectAll(); return false; } + } + return true; + }); + } + createTerminal(); + wireInput(); + // Prevent browser paste from bypassing the native size check and ownership epoch. + document.addEventListener('paste', event => { + event.preventDefault(); + event.stopImmediatePropagation(); + if (enabled && event.isTrusted) post('paste', { epoch }); + }, true); + document.addEventListener('contextmenu', event => event.preventDefault()); + + function measure() { + const size = fit.proposeDimensions(); + if (size) post('size', { rows: size.rows, cols: size.cols }); + } + new ResizeObserver(() => { + clearTimeout(measureTimer); + measureTimer = setTimeout(measure, 120); + }).observe(document.getElementById('viewport')); + + window.astationTerminal = { + setControl(value, newEpoch) { + enabled = Boolean(value); + epoch = newEpoch; + term.options.disableStdin = !enabled; + term.options.cursorBlink = enabled; + if (enabled) { term.focus(); measure(); } + }, + applyFrame(frame, token) { + if (displayedStream !== frame.streamId) { + // Discard the old emulator and its pending writes across subscriptions. + term.dispose(); + document.getElementById('terminal').replaceChildren(); + enabled = false; + epoch = ''; + createTerminal(); + wireInput(); + displayedStream = frame.streamId; + } + const raw = atob(frame.bytes); + const bytes = Uint8Array.from(raw, character => character.charCodeAt(0)); + if (frame.reset) { term.reset(); term.resize(frame.cols, frame.rows); } + term.write(bytes, () => post('consumed', { token })); + }, + paste(text, expectedEpoch) { + if (!enabled || epoch !== expectedEpoch) return; + // xterm wraps this atomic operation in the CLI's active bracketed-paste mode. + if (new TextEncoder().encode(text).length + 12 > 16384) { post('oversizedPaste'); return; } + term.paste(text); + }, + copySelection() { post('copy', { text: term.getSelection() }); }, + clear() { term.reset(); }, + // Read-only hooks used by native renderer regression tests. + inspect() { + return { + text: Array.from({ length: term.rows }, (_, row) => term.buffer.active.getLine(row).translateToString(true)).join('\n'), + rows: term.rows, cols: term.cols, unicode: term.unicode.activeVersion, + scrollback: term.options.scrollback, enabled, + firstForeground: term.buffer.active.getLine(0).getCell(0).getFgColor() + }; + } + }; + post('ready'); + measure(); +})(); diff --git a/Sources/Menubar/Resources/RemoteTerminal/unicode-width-0.1.14.json b/Sources/Menubar/Resources/RemoteTerminal/unicode-width-0.1.14.json new file mode 100644 index 0000000..e70f5a4 --- /dev/null +++ b/Sources/Menubar/Resources/RemoteTerminal/unicode-width-0.1.14.json @@ -0,0 +1 @@ +[[0,31,0],[127,159,0],[173,173,0],[768,879,0],[1155,1161,0],[1425,1469,0],[1471,1471,0],[1473,1474,0],[1476,1477,0],[1479,1479,0],[1541,1541,0],[1552,1562,0],[1564,1564,0],[1611,1631,0],[1648,1648,0],[1750,1756,0],[1759,1764,0],[1767,1768,0],[1770,1773,0],[1807,1807,0],[1809,1809,0],[1840,1866,0],[1958,1968,0],[2027,2035,0],[2045,2045,0],[2070,2073,0],[2075,2083,0],[2085,2087,0],[2089,2093,0],[2137,2139,0],[2192,2193,0],[2200,2207,0],[2250,2306,0],[2362,2362,0],[2364,2364,0],[2369,2376,0],[2381,2381,0],[2385,2391,0],[2402,2403,0],[2433,2433,0],[2492,2492,0],[2494,2494,0],[2497,2500,0],[2509,2509,0],[2519,2519,0],[2530,2531,0],[2558,2558,0],[2561,2562,0],[2620,2620,0],[2625,2626,0],[2631,2632,0],[2635,2637,0],[2641,2641,0],[2672,2673,0],[2677,2677,0],[2689,2690,0],[2748,2748,0],[2753,2757,0],[2759,2760,0],[2765,2765,0],[2786,2787,0],[2810,2815,0],[2817,2817,0],[2876,2876,0],[2878,2879,0],[2881,2884,0],[2893,2893,0],[2901,2903,0],[2914,2915,0],[2946,2946,0],[3006,3006,0],[3008,3008,0],[3021,3021,0],[3031,3031,0],[3072,3072,0],[3076,3076,0],[3132,3132,0],[3134,3136,0],[3142,3144,0],[3146,3149,0],[3157,3158,0],[3170,3171,0],[3201,3201,0],[3260,3260,0],[3263,3264,0],[3266,3266,0],[3270,3272,0],[3274,3277,0],[3285,3286,0],[3298,3299,0],[3328,3329,0],[3387,3388,0],[3390,3390,0],[3393,3396,0],[3405,3406,0],[3415,3415,0],[3426,3427,0],[3457,3457,0],[3530,3530,0],[3535,3535,0],[3538,3540,0],[3542,3542,0],[3551,3551,0],[3633,3633,0],[3636,3642,0],[3655,3662,0],[3761,3761,0],[3764,3772,0],[3784,3790,0],[3864,3865,0],[3893,3893,0],[3895,3895,0],[3897,3897,0],[3953,3966,0],[3968,3972,0],[3974,3975,0],[3981,3991,0],[3993,4028,0],[4038,4038,0],[4141,4144,0],[4146,4151,0],[4153,4154,0],[4157,4158,0],[4184,4185,0],[4190,4192,0],[4209,4212,0],[4226,4226,0],[4229,4230,0],[4237,4237,0],[4253,4253,0],[4352,4447,2],[4448,4607,0],[4957,4959,0],[5906,5908,0],[5938,5939,0],[5970,5971,0],[6002,6003,0],[6052,6052,2],[6068,6069,0],[6071,6077,0],[6086,6086,0],[6089,6099,0],[6104,6104,3],[6109,6109,0],[6155,6159,0],[6277,6278,0],[6313,6313,0],[6432,6434,0],[6439,6440,0],[6450,6450,0],[6457,6459,0],[6679,6680,0],[6683,6683,0],[6742,6742,0],[6744,6750,0],[6752,6752,0],[6754,6754,0],[6757,6764,0],[6771,6780,0],[6783,6783,0],[6832,6862,0],[6912,6915,0],[6964,6973,0],[6978,6979,0],[7019,7027,0],[7040,7041,0],[7074,7077,0],[7080,7081,0],[7083,7085,0],[7142,7142,0],[7144,7145,0],[7149,7149,0],[7151,7153,0],[7212,7219,0],[7222,7223,0],[7376,7378,0],[7380,7392,0],[7394,7400,0],[7405,7405,0],[7412,7412,0],[7416,7417,0],[7616,7679,0],[8203,8207,0],[8234,8238,0],[8288,8303,0],[8400,8432,0],[8986,8987,2],[9001,9002,2],[9193,9196,2],[9200,9200,2],[9203,9203,2],[9725,9726,2],[9748,9749,2],[9800,9811,2],[9855,9855,2],[9875,9875,2],[9889,9889,2],[9898,9899,2],[9917,9918,2],[9924,9925,2],[9934,9934,2],[9940,9940,2],[9962,9962,2],[9970,9971,2],[9973,9973,2],[9978,9978,2],[9981,9981,2],[9989,9989,2],[9994,9995,2],[10024,10024,2],[10060,10060,2],[10062,10062,2],[10067,10069,2],[10071,10071,2],[10133,10135,2],[10160,10160,2],[10175,10175,2],[11035,11036,2],[11088,11088,2],[11093,11093,2],[11503,11505,0],[11744,11775,0],[11904,11929,2],[11931,12019,2],[12032,12245,2],[12272,12329,2],[12330,12335,0],[12336,12350,2],[12353,12438,2],[12441,12442,0],[12443,12543,2],[12549,12591,2],[12593,12643,2],[12644,12644,0],[12645,12686,2],[12688,12771,2],[12783,12830,2],[12832,12871,2],[12880,19903,2],[19968,42124,2],[42128,42182,2],[42607,42610,0],[42612,42621,0],[42654,42655,0],[42736,42737,0],[43010,43010,0],[43014,43014,0],[43019,43019,0],[43045,43046,0],[43052,43052,0],[43204,43205,0],[43232,43249,0],[43258,43258,0],[43263,43263,0],[43302,43309,0],[43335,43345,0],[43360,43388,2],[43392,43394,0],[43443,43443,0],[43446,43449,0],[43452,43453,0],[43493,43493,0],[43561,43566,0],[43569,43570,0],[43573,43574,0],[43587,43587,0],[43596,43596,0],[43644,43644,0],[43696,43696,0],[43698,43700,0],[43703,43704,0],[43710,43711,0],[43713,43713,0],[43756,43757,0],[43766,43766,0],[44005,44005,0],[44008,44008,0],[44013,44013,0],[44032,55203,2],[55216,55238,0],[55243,55291,0],[55296,57343,0],[63744,64255,2],[64286,64286,0],[65024,65039,0],[65040,65049,2],[65056,65071,0],[65072,65106,2],[65108,65126,2],[65128,65131,2],[65279,65279,0],[65281,65376,2],[65438,65440,0],[65504,65510,2],[65520,65528,0],[66045,66045,0],[66272,66272,0],[66422,66426,0],[68097,68099,0],[68101,68102,0],[68108,68111,0],[68152,68154,0],[68159,68159,0],[68325,68326,0],[68900,68903,0],[69291,69292,0],[69373,69375,0],[69446,69456,0],[69506,69509,0],[69633,69633,0],[69688,69702,0],[69744,69744,0],[69747,69748,0],[69759,69761,0],[69811,69814,0],[69817,69818,0],[69826,69826,0],[69888,69890,0],[69927,69931,0],[69933,69940,0],[70003,70003,0],[70016,70017,0],[70070,70078,0],[70082,70083,0],[70089,70092,0],[70095,70095,0],[70191,70193,0],[70196,70196,0],[70198,70199,0],[70206,70206,0],[70209,70209,0],[70367,70367,0],[70371,70378,0],[70400,70401,0],[70459,70460,0],[70462,70462,0],[70464,70464,0],[70487,70487,0],[70502,70508,0],[70512,70516,0],[70712,70719,0],[70722,70724,0],[70726,70726,0],[70750,70750,0],[70832,70832,0],[70835,70840,0],[70842,70842,0],[70845,70845,0],[70847,70848,0],[70850,70851,0],[71087,71087,0],[71090,71093,0],[71100,71101,0],[71103,71104,0],[71132,71133,0],[71219,71226,0],[71229,71229,0],[71231,71232,0],[71339,71339,0],[71341,71341,0],[71344,71349,0],[71351,71351,0],[71453,71455,0],[71458,71461,0],[71463,71467,0],[71727,71735,0],[71737,71738,0],[71984,71984,0],[71995,71996,0],[71998,71999,0],[72001,72001,0],[72003,72003,0],[72148,72151,0],[72154,72155,0],[72160,72160,0],[72193,72202,0],[72243,72248,0],[72250,72254,0],[72263,72263,0],[72273,72278,0],[72281,72283,0],[72324,72342,0],[72344,72345,0],[72752,72758,0],[72760,72765,0],[72767,72767,0],[72850,72871,0],[72874,72880,0],[72882,72883,0],[72885,72886,0],[73009,73014,0],[73018,73018,0],[73020,73021,0],[73023,73031,0],[73104,73105,0],[73109,73109,0],[73111,73111,0],[73459,73460,0],[73472,73474,0],[73526,73530,0],[73536,73536,0],[73538,73538,0],[78912,78912,0],[78919,78933,0],[92912,92916,0],[92976,92982,0],[94031,94031,0],[94095,94098,0],[94176,94179,2],[94180,94180,0],[94192,94193,2],[94208,100343,2],[100352,101589,2],[101632,101640,2],[110576,110579,2],[110581,110587,2],[110589,110590,2],[110592,110882,2],[110898,110898,2],[110928,110930,2],[110933,110933,2],[110948,110951,2],[110960,111355,2],[113821,113822,0],[113824,113827,0],[118528,118573,0],[118576,118598,0],[119141,119141,0],[119143,119145,0],[119150,119170,0],[119173,119179,0],[119210,119213,0],[119362,119364,0],[121344,121398,0],[121403,121452,0],[121461,121461,0],[121476,121476,0],[121499,121503,0],[121505,121519,0],[122880,122886,0],[122888,122904,0],[122907,122913,0],[122915,122916,0],[122918,122922,0],[123023,123023,0],[123184,123190,0],[123566,123566,0],[123628,123631,0],[124140,124143,0],[125136,125142,0],[125252,125258,0],[126980,126980,2],[127183,127183,2],[127374,127374,2],[127377,127386,2],[127488,127490,2],[127504,127547,2],[127552,127560,2],[127568,127569,2],[127584,127589,2],[127744,127776,2],[127789,127797,2],[127799,127868,2],[127870,127891,2],[127904,127946,2],[127951,127955,2],[127968,127984,2],[127988,127988,2],[127992,128062,2],[128064,128064,2],[128066,128252,2],[128255,128317,2],[128331,128334,2],[128336,128359,2],[128378,128378,2],[128405,128406,2],[128420,128420,2],[128507,128591,2],[128640,128709,2],[128716,128716,2],[128720,128722,2],[128725,128727,2],[128732,128735,2],[128747,128748,2],[128756,128764,2],[128992,129003,2],[129008,129008,2],[129292,129338,2],[129340,129349,2],[129351,129535,2],[129648,129660,2],[129664,129672,2],[129680,129725,2],[129727,129733,2],[129742,129755,2],[129760,129768,2],[129776,129784,2],[131072,196605,2],[196608,262141,2],[917504,921599,0]] diff --git a/Sources/Menubar/Resources/RemoteTerminal/xterm-LICENSE b/Sources/Menubar/Resources/RemoteTerminal/xterm-LICENSE new file mode 100644 index 0000000..4472336 --- /dev/null +++ b/Sources/Menubar/Resources/RemoteTerminal/xterm-LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) +Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com) +Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Sources/Menubar/Resources/RemoteTerminal/xterm.css b/Sources/Menubar/Resources/RemoteTerminal/xterm.css new file mode 100644 index 0000000..819654e --- /dev/null +++ b/Sources/Menubar/Resources/RemoteTerminal/xterm.css @@ -0,0 +1,285 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * @license MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +/** + * Default styles for xterm.js + */ + +.xterm { + cursor: text; + position: relative; + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; +} + +.xterm.focus, +.xterm:focus { + outline: none; +} + +.xterm .xterm-helpers { + position: absolute; + top: 0; + /** + * The z-index of the helpers must be higher than the canvases in order for + * IMEs to appear on top. + */ + z-index: 5; +} + +.xterm .xterm-helper-textarea { + padding: 0; + border: 0; + margin: 0; + /* Move textarea out of the screen to the far left, so that the cursor is not visible */ + position: absolute; + opacity: 0; + left: -9999em; + top: 0; + width: 0; + height: 0; + z-index: -5; + /** Prevent wrapping so the IME appears against the textarea at the correct position */ + white-space: nowrap; + overflow: hidden; + resize: none; +} + +.xterm .composition-view { + /* TODO: Composition position got messed up somewhere */ + background: #000; + color: #FFF; + display: none; + position: absolute; + white-space: nowrap; + z-index: 1; +} + +.xterm .composition-view.active { + display: block; +} + +.xterm .xterm-viewport { + /* On OS X this is required in order for the scroll bar to appear fully opaque */ + background-color: #000; + overflow-y: scroll; + cursor: default; + position: absolute; + right: 0; + left: 0; + top: 0; + bottom: 0; +} + +.xterm .xterm-screen { + position: relative; +} + +.xterm .xterm-screen canvas { + position: absolute; + left: 0; + top: 0; +} + +.xterm-char-measure-element { + display: inline-block; + visibility: hidden; + position: absolute; + top: 0; + left: -9999em; + line-height: normal; +} + +.xterm.enable-mouse-events { + /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ + cursor: default; +} + +.xterm.xterm-cursor-pointer, +.xterm .xterm-cursor-pointer { + cursor: pointer; +} + +.xterm.column-select.focus { + /* Column selection mode */ + cursor: crosshair; +} + +.xterm .xterm-accessibility:not(.debug), +.xterm .xterm-message { + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + z-index: 10; + color: transparent; + pointer-events: none; +} + +.xterm .xterm-accessibility-tree:not(.debug) *::selection { + color: transparent; +} + +.xterm .xterm-accessibility-tree { + font-family: monospace; + user-select: text; + white-space: pre; +} + +.xterm .xterm-accessibility-tree > div { + transform-origin: left; + width: fit-content; +} + +.xterm .live-region { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.xterm-dim { + /* Dim should not apply to background, so the opacity of the foreground color is applied + * explicitly in the generated class and reset to 1 here */ + opacity: 1 !important; +} + +.xterm-underline-1 { text-decoration: underline; } +.xterm-underline-2 { text-decoration: double underline; } +.xterm-underline-3 { text-decoration: wavy underline; } +.xterm-underline-4 { text-decoration: dotted underline; } +.xterm-underline-5 { text-decoration: dashed underline; } + +.xterm-overline { + text-decoration: overline; +} + +.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } +.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } +.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } +.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } +.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } + +.xterm-strikethrough { + text-decoration: line-through; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration { + z-index: 6; + position: absolute; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { + z-index: 7; +} + +.xterm-decoration-overview-ruler { + z-index: 8; + position: absolute; + top: 0; + right: 0; + pointer-events: none; +} + +.xterm-decoration-top { + z-index: 2; + position: relative; +} + + + +/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */ + +/* xterm.js customization: Override xterm's cursor style */ +.xterm .xterm-scrollable-element > .scrollbar { + cursor: default; +} + +/* Arrows */ +.xterm .xterm-scrollable-element > .scrollbar > .scra { + cursor: pointer; + font-size: 11px !important; +} + +.xterm .xterm-scrollable-element > .visible { + opacity: 1; + + /* Background rule added for IE9 - to allow clicks on dom node */ + background:rgba(0,0,0,0); + + transition: opacity 100ms linear; + /* In front of peek view */ + z-index: 11; +} +.xterm .xterm-scrollable-element > .invisible { + opacity: 0; + pointer-events: none; +} +.xterm .xterm-scrollable-element > .invisible.fade { + transition: opacity 800ms linear; +} + +/* Scrollable Content Inset Shadow */ +.xterm .xterm-scrollable-element > .shadow { + position: absolute; + display: none; +} +.xterm .xterm-scrollable-element > .shadow.top { + display: block; + top: 0; + left: 3px; + height: 3px; + width: 100%; + box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset; +} +.xterm .xterm-scrollable-element > .shadow.left { + display: block; + top: 3px; + left: 0; + height: 100%; + width: 3px; + box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; +} +.xterm .xterm-scrollable-element > .shadow.top-left-corner { + display: block; + top: 0; + left: 0; + height: 3px; + width: 3px; +} +.xterm .xterm-scrollable-element > .shadow.top.left { + box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; +} diff --git a/Sources/Menubar/Resources/RemoteTerminal/xterm.js b/Sources/Menubar/Resources/RemoteTerminal/xterm.js new file mode 100644 index 0000000..e47e2dd --- /dev/null +++ b/Sources/Menubar/Resources/RemoteTerminal/xterm.js @@ -0,0 +1,2 @@ +!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={2840:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(7721),o=i(4292),a=i(7150),l=i(7098),h=i(6501),c=i(7093);let d=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="";const r=this._coreBrowserService.mainDocument;this._accessibilityContainer=r.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=r.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this._register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this._register(this._terminal.onScroll((()=>this._refreshRows()))),this._register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this._register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this._register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this._register(this._terminal.onKey((e=>this._handleKey(e.key)))),this._register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this._register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._register((0,c.addDisposableListener)(r,"selectionchange",(()=>this._handleSelectionChange()))),this._register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRowsDimensions(),this._refreshRows(),this._register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.textContent=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let n=t=this._terminal.cols&&(++s,n=0),{row:s,column:n}},n=r(t),o=r(i);if(n&&o){if(n.row>o.row||n.row===o.row&&n.column>=o.column)throw new Error("invalid range");this._terminal.select(n.column,n.row,(o.row-n.row)*this._terminal.cols-n.column+o.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7174:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(7710);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},1718:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserTerminal=void 0;const s=i(7861),r=i(7721),n=i(3285),o=i(4017),a=i(4196),l=i(9925),h=i(3618),c=i(3955),d=i(4792),u=i(945),_=i(9574),f=i(9820),p=i(9784),g=i(5783),m=i(2079),v=i(7098),S=i(9078),b=i(4103),C=i(5777),y=i(701),w=i(6107),E=i(3534),D=i(706),L=i(8693),R=i(4720),A=i(6501),T=i(2486),k=i(2840),M=i(8906),O=i(802),I=i(7093),P=i(7150);class x extends C.CoreTerminal{get linkifier(){return this._linkifier.value}get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this._linkifier=this._register(new P.MutableDisposable),this.browser=y,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this._register(new P.MutableDisposable),this._onCursorMove=this._register(new O.Emitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this._register(new O.Emitter),this.onKey=this._onKey.event,this._onRender=this._register(new O.Emitter),this.onRender=this._onRender.event,this._onSelectionChange=this._register(new O.Emitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this._register(new O.Emitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this._register(new O.Emitter),this.onBell=this._onBell.event,this._onFocus=this._register(new O.Emitter),this._onBlur=this._register(new O.Emitter),this._onA11yCharEmitter=this._register(new O.Emitter),this._onA11yTabEmitter=this._register(new O.Emitter),this._onWillOpen=this._register(new O.Emitter),this._setup(),this._decorationService=this._instantiationService.createInstance(R.DecorationService),this._instantiationService.setService(A.IDecorationService,this._decorationService),this._linkProviderService=this._instantiationService.createInstance(f.LinkProviderService),this._instantiationService.setService(v.ILinkProviderService,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(n.OscLinkProvider)),this._register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this._register(this._inputHandler.onRequestRefreshRows((e=>this.refresh(e?.start??0,e?.end??this.rows-1)))),this._register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this._register(this._inputHandler.onRequestReset((()=>this.reset()))),this._register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this._register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this._register(O.Event.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(O.Event.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(O.Event.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(O.Event.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this._register((0,P.toDisposable)((()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${E.C0.ESC}]${i};${(0,L.toRgbString)(s)}${E.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.channels.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.channels.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(k.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(E.C0.ESC+"[I"),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(E.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this._register((0,I.addDisposableListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this._register((0,I.addDisposableListener)(this.textarea,"paste",e)),this._register((0,I.addDisposableListener)(this.element,"paste",e)),y.isFirefox?this._register((0,I.addDisposableListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this._register((0,I.addDisposableListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),y.isLinux&&this._register((0,I.addDisposableListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this._register((0,I.addDisposableListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this._register((0,I.addDisposableListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this._register((0,I.addDisposableListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this._register((0,I.addDisposableListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this._register((0,I.addDisposableListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this._register((0,I.addDisposableListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this._register((0,I.addDisposableListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this._register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register((0,I.addDisposableListener)(this.screenElement,"mousemove",(e=>this.updateCursorStyle(e)))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);const i=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",r.promptLabel.get()),y.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",(()=>i.readOnly=this.optionsService.rawOptions.disableStdin))),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(_.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??"undefined"!=typeof window?window.document:null)),this._instantiationService.setService(v.ICoreBrowserService,this._coreBrowserService),this._register((0,I.addDisposableListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this._register((0,I.addDisposableListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(d.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(v.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(S.ThemeService),this._instantiationService.setService(v.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(u.CharacterJoinerService),this._instantiationService.setService(v.ICharacterJoinerService,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(v.IRenderService,this._renderService),this._register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(h.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(v.IMouseService,this._mouseService);const s=this._linkifier.value=this._register(this._instantiationService.createInstance(M.Linkifier,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this._register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this._register(this.onBlur((()=>this._renderService.handleBlur()))),this._register(this.onFocus((()=>this._renderService.handleFocus()))),this._viewport=this._register(this._instantiationService.createInstance(o.Viewport,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines((e=>{super.scrollLines(e,!1),this.refresh(0,this.rows-1)}))),this._selectionService=this._register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,s)),this._instantiationService.setService(v.ISelectionService,this._selectionService),this._register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this._register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this._register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this._register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this._register(O.Event.any(this._onScroll.event,this._inputHandler.onScroll)((()=>{this._selectionService.refresh(),this._viewport?.queueSync()}))),this._register(this._instantiationService.createInstance(a.BufferDecorationRenderer,this.screenElement)),this._register((0,I.addDisposableListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(k.AccessibilityManager,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRuler.width&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRuler",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(c.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(e._customWheelEventHandler&&!1===e._customWheelEventHandler(t))return!1;const i=t.deltaY;if(0===i)return!1;if(0===e.coreMouseService.consumeWheelEvent(t,e._renderService?.dimensions?.device?.cell?.height,e._coreBrowserService?.dpr))return!1;r=i<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},r={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this._register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",r.mousemove),s.mousemove=r.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",r.wheel,{passive:!1}),s.wheel=r.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(s.mouseup=r.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=r.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this._register((0,I.addDisposableListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this._register((0,I.addDisposableListener)(t,"wheel",(t=>{if(!s.wheel){if(this._customWheelEventHandler&&!1===this._customWheelEventHandler(t))return!1;if(!this.buffer.hasScrollback){if(0===t.deltaY)return!1;if(0===e.coreMouseService.consumeWheelEvent(t,e._renderService?.dimensions?.device?.cell?.height,e._coreBrowserService?.dpr))return this.cancel(t,!0);const i=E.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t.deltaY<0?"A":"B");return this.coreService.triggerDataEvent(i,!0),this.cancel(t,!0)}}}),{passive:!1}))}refresh(e,t){this._renderService?.refreshRows(e,t)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this._customWheelEventHandler=e}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,D.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==E.C0.ETX&&i.key!==E.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure()}clear(){if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const n=i(7150),o=i(6501),a=i(7098),l=i(802),h=i(7093);let c=class extends n.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this._register(new l.Emitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this._register(new l.Emitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this._register((0,n.toDisposable)((()=>{(0,n.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()}))),this._register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0}))),this._register((0,h.addDisposableListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this._register((0,h.addDisposableListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register((0,h.addDisposableListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register((0,h.addDisposableListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,(t=>{if(this._isMouseOut)return;const r=t?.map((e=>({link:e})));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);var i,s;t&&this._mouseDownLink&&(i=this._mouseDownLink.link,s=this._currentLink.link,i.text===s.text&&i.range.start.x===s.range.start.x&&i.range.start.y===s.range.start.y&&i.range.end.x===s.range.end.x&&i.range.end.y===s.range.end.y)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,n.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=c,t.Linkifier=c=s([r(1,a.IMouseService),r(2,a.IRenderService),r(3,o.IBufferService),r(4,a.ILinkProviderService)],c)},7721:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0;let i="Terminal input";const s={get:()=>i,set:e=>i=e};t.promptLabel=s;let r="Too much output to announce, navigate to rows manually to read";const n={get:()=>r,set:e=>r=e};t.tooMuchOutput=n},3285:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(3055),o=i(6501);let a=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=new n.CellData,a=i.getTrimmedLength();let h=-1,c=-1,d=!1;for(let t=0;tr?r.activate(e,t,n):l(0,t),hover:(e,t)=>r?.hover?.(e,t,n),leave:(e,t)=>r?.leave?.(e,t,n)})}d=!1,o.hasExtendedAttrs()&&o.extended.urlId?(c=t,h=o.extended.urlId):(c=-1,h=-1)}}t(s)}};function l(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch{}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a,t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},4852:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._renderCallback=e,this._coreBrowserService=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},4292:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=performance.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},9302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_ANSI_COLORS=void 0;const s=i(4103);t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const r=t[i/36%6|0],n=t[i/6%6|0],o=t[i%6];e.push({css:s.channels.toCss(r,n,o),rgba:s.channels.toRgba(r,n,o)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:s.channels.toCss(i,i,i),rgba:s.channels.toRgba(i,i,i)})}return e})())},4017:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(7098),o=i(7150),a=i(6501),l=i(7093),h=i(8234),c=i(802),d=i(9881);let u=class extends o.Disposable{constructor(e,t,i,s,r,n,a,u){super(),this._bufferService=i,this._optionsService=a,this._renderService=u,this._onRequestScrollLines=this._register(new c.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1;const _=this._register(new d.Scrollable({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:e=>(0,l.scheduleAtNextAnimationFrame)(s.window,e)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",(()=>{_.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)}))),this._scrollableElement=this._register(new h.SmoothScrollableElement(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,...this._getChangeOptions()},_)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","overviewRuler"],(()=>this._scrollableElement.updateOptions(this._getChangeOptions())))),this._register(r.onProtocolChange((e=>{this._scrollableElement.updateOptions({handleMouseWheel:!(16&e)})}))),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(c.Event.runAndSubscribe(n.onChangeColors,(()=>{this._scrollableElement.getDomNode().style.backgroundColor=n.colors.background.css}))),e.appendChild(this._scrollableElement.getDomNode()),this._register((0,o.toDisposable)((()=>this._scrollableElement.getDomNode().remove()))),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register((0,o.toDisposable)((()=>this._styleElement.remove()))),this._register(c.Event.runAndSubscribe(n.onChangeColors,(()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .scrollbar > .slider {",` background: ${n.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .scrollbar > .slider:hover {",` background: ${n.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .scrollbar > .slider.active {",` background: ${n.colors.scrollbarSliderActiveBackground.css};`,"}"].join("\n")}))),this._register(this._bufferService.onResize((()=>this.queueSync()))),this._register(this._bufferService.buffers.onBufferActivate((()=>{this._latestYDisp=void 0,this.queueSync()}))),this._register(this._bufferService.onScroll((()=>this._sync()))),this._register(this._scrollableElement.onScroll((e=>this._handleScroll(e))))}scrollLines(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,verticalScrollbarSize:this._optionsService.rawOptions.overviewRuler?.width||14}}queueSync(e){void 0!==e&&(this._latestYDisp=e),void 0===this._queuedAnimationFrame&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback((()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)})))}_sync(e=this._bufferService.buffer.ydisp){this._renderService&&!this._isSyncing&&(this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1)}_handleScroll(e){if(!this._renderService)return;if(this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;const t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),i=t-this._bufferService.buffer.ydisp;0!==i&&(this._latestYDisp=t,this._onRequestScrollLines.fire(i)),this._isHandlingScroll=!1}};t.Viewport=u,t.Viewport=u=s([r(2,a.IBufferService),r(3,n.ICoreBrowserService),r(4,a.ICoreMouseService),r(5,n.IThemeService),r(6,a.IOptionsService),r(7,n.IRenderService)],u)},4196:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(7098),o=i(7150),a=i(6501);let l=class extends o.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this._register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this._register(this._coreBrowserService.onDprChange((()=>this._queueRefresh()))),this._register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this._register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this._register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this._register((0,o.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(i.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,i.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=l,t.BufferDecorationRenderer=l=s([r(1,a.IBufferService),r(2,n.ICoreBrowserService),r(3,a.IDecorationService),r(4,n.IRenderService)],l)},957:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},9925:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(957),o=i(7098),a=i(7150),l=i(6501),h={full:0,left:0,center:0,right:0},c={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let u=class extends a.Disposable{get _width(){return this._optionsService.options.overviewRuler?.width||0}constructor(e,t,i,s,r,o,l,h){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._themeService=l,this._coreBrowserService=h,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register((0,a.toDisposable)((()=>this._canvas?.remove())));const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this._register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0)))),this._register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this._register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this._register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())}))),this._register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this._register(this._coreBrowserService.onDprChange((()=>this._queueRefresh(!0)))),this._register(this._optionsService.onSpecificOptionChange("overviewRuler",(()=>this._queueRefresh(!0)))),this._register(this._themeService.onChangeColors((()=>this._queueRefresh()))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);c.full=this._canvas.width,c.left=e,c.center=t,c.right=e,this._refreshDrawHeightConstants(),d.full=1,d.left=1,d.center=1+c.left,d.right=1+c.left+c.center}_refreshDrawHeightConstants(){h.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);h.left=t,h.center=t,h.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1,this._renderRulerOutline();const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.overviewRuler.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.overviewRuler.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-h[e.position||"full"]/2),c[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+h[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=u,t.OverviewRulerRenderer=u=s([r(2,l.IBufferService),r(3,l.IDecorationService),r(4,o.IRenderService),r(5,l.IOptionsService),r(6,o.IThemeService),r(7,o.ICoreBrowserService)],u)},3618:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(7098),o=i(6501),a=i(3534);let l=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(20===e.keyCode||229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=l,t.CompositionHelper=l=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],l)},5251:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,l,h){if(!o)return;const c=i(e,t,s);return c?(c[0]=Math.ceil((c[0]+(h?a/2:0))/a),c[1]=Math.ceil(c[1]/l),c[0]=Math.min(Math.max(c[0],1),r+(h?1:0)),c[1]=Math.min(Math.max(c[1],1),n),c):void 0}},9686:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=function(e,t,i,s){const o=i.buffer.x,c=i.buffer.y;if(!i.buffer.hasScrollback)return function(e,t,i,s,o,c){return 0===r(t,s,o,c).length?"":h(a(e,t,e,t-n(t,o),!1,o).length,l("D",c))}(o,c,0,t,i,s)+r(c,t,i,s)+function(e,t,i,s,o,c){let d;d=r(t,s,o,c).length>0?s-n(s,o):t;const u=s,_=function(e,t,i,s,o,a){let l;return l=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&le?"D":"C",h(Math.abs(o-e),l(d,s));d=c>t?"D":"C";const u=Math.abs(c-t);return h(function(e,t){return t.cols-e}(c>t?e:o,i)+(u-1)*i.cols+1+((c>t?o:e)-1),l(d,s))};const s=i(3534);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),c=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,l="";for(;(o!==i||a!==s)&&a>=0&&an.cols-1?(l+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(l+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return l+n.buffer.translateBufferLineToString(a,!1,e,o)}function l(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function h(e,t){e=Math.floor(e);let i="";for(let s=0;s=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(1433),o=i(2744),a=i(9176),l=i(6181),h=i(2274),c=i(7098),d=i(4103),u=i(7150),_=i(6501),f=i(802),p="xterm-dom-renderer-owner-",g="xterm-rows",m="xterm-fg-",v="xterm-bg-",S="xterm-focus",b="xterm-selection";let C=1,y=class extends u.Disposable{constructor(e,t,i,s,r,a,c,d,_,m,v,S,y,w){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=c,this._charSizeService=_,this._optionsService=m,this._bufferService=v,this._coreService=S,this._coreBrowserService=y,this._themeService=w,this._terminalClass=C++,this._rowElements=[],this._selectionRenderModel=(0,h.createSelectionRenderModel)(),this.onRequestRedraw=this._register(new f.Emitter).event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add(g),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add(b),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,l.createRenderDimensions)(),this._updateDimensions(),this._register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this._register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=d.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(p+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this._register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this._register((0,u.toDisposable)((()=>{this._element.classList.remove(p+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(this._document,this._helperContainer),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${g} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${g} { pointer-events: none; color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${g} .xterm-dim { color: ${d.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${g}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .${g}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .${g}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${b} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${b} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${b} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${m}${i} { color: ${s.css}; }${this._terminalSelector} .${m}${i}.xterm-dim { color: ${d.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${v}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR} { color: ${d.color.opaque(e.background).css}; }${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${d.color.multiplyOpacity(d.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${v}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(S),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(S),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,i),!this._selectionRenderModel.hasSelection)return;const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,n=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow,a=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,l=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,l));const h=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,h)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=this._document.createElement("div"),n=t*this.dimensions.css.cell.width;let o=this.dimensions.css.cell.width*(i-t);return n+o>this.dimensions.css.canvas.width&&(o=this.dimensions.css.canvas.width-n),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${n}px`,r.style.width=`${o}px`,r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,o=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let l=e;l<=t;l++){const e=l+i.ydisp,t=this._rowElements[l],h=i.lines.get(e);if(!t||!h)break;t.replaceChildren(...this._rowFactory.createRow(h,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${p}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,l=a.ybase+a.y,h=Math.min(a.x,r-1),c=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,u=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const _=o+a.ydisp,f=this._rowElements[o],p=a.lines.get(_);if(!f||!p)break;f.replaceChildren(...this._rowFactory.createRow(p,_,_===l,d,u,h,c,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=y,t.DomRenderer=y=s([r(7,_.IInstantiationService),r(8,c.ICharSizeService),r(9,_.IOptionsService),r(10,_.IBufferService),r(11,_.ICoreService),r(12,c.ICoreBrowserService),r(13,c.IThemeService)],y)},1433:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(9176),o=i(8938),a=i(3055),l=i(6501),h=i(4103),c=i(7098),d=i(945),u=i(6181),_=i(5451);let f=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,l,c,u,f,g){const m=[],v=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let b,C=e.getNoBgTrimmedLength();i&&C=O,U=x,F=this._workCell;if(v.length>0&&x===v[0][0]&&N){const s=v.shift(),r=this._isCellInSelection(s[0],t);for(E=s[0]+1;E=s[1],N?(B=!0,F=new d.JoinedCellData(this._workCell,e.translateToString(!0,s[0],s[1]),s[1]-s[0]),U=s[1]-1,C=F.getWidth()):O=s[1]}const W=this._isCellInSelection(x,t),H=i&&x===a,K=P&&x>=f&&x<=g;let z=!1;this._decorationService.forEachDecorationAtCell(x,t,void 0,(e=>{z=!0}));let j=F.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===j&&(F.isUnderline()||F.isOverline())&&(j=" "),M=C*c-u.get(j,F.isBold(),F.isItalic()),b){if(y&&(W&&k||!W&&!k&&F.bg===D)&&(W&&k&&S.selectionForeground||F.fg===L)&&F.extended.ext===R&&K===A&&M===T&&!H&&!B&&!z&&N){F.isInvisible()?w+=o.WHITESPACE_CELL_CHAR:w+=j,y++;continue}y&&(b.textContent=w),b=this._document.createElement("span"),y=0,w=""}else b=this._document.createElement("span");if(D=F.bg,L=F.fg,R=F.extended.ext,A=K,T=M,k=W,B&&a>=x&&a<=U&&(a=x),!this._coreService.isCursorHidden&&H&&this._coreService.isCursorInitialized)if(I.push("xterm-cursor"),this._coreBrowserService.isFocused)l&&I.push("xterm-cursor-blink"),I.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":I.push("xterm-cursor-outline");break;case"block":I.push("xterm-cursor-block");break;case"bar":I.push("xterm-cursor-bar");break;case"underline":I.push("xterm-cursor-underline")}if(F.isBold()&&I.push("xterm-bold"),F.isItalic()&&I.push("xterm-italic"),F.isDim()&&I.push("xterm-dim"),w=F.isInvisible()?o.WHITESPACE_CELL_CHAR:F.getChars()||o.WHITESPACE_CELL_CHAR,F.isUnderline()&&(I.push(`xterm-underline-${F.extended.underlineStyle}`)," "===w&&(w=" "),!F.isUnderlineColorDefault()))if(F.isUnderlineColorRGB())b.style.textDecorationColor=`rgb(${_.AttributeData.toColorRGB(F.getUnderlineColor()).join(",")})`;else{let e=F.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&F.isBold()&&e<8&&(e+=8),b.style.textDecorationColor=S.ansi[e].css}F.isOverline()&&(I.push("xterm-overline")," "===w&&(w=" ")),F.isStrikethrough()&&I.push("xterm-strikethrough"),K&&(b.style.textDecoration="underline");let $=F.getFgColor(),V=F.getFgColorMode(),G=F.getBgColor(),q=F.getBgColorMode();const X=!!F.isInverse();if(X){const e=$;$=G,G=e;const t=V;V=q,q=t}let Y,Z,J,Q=!1;switch(this._decorationService.forEachDecorationAtCell(x,t,void 0,(e=>{"top"!==e.options.layer&&Q||(e.backgroundColorRGB&&(q=50331648,G=e.backgroundColorRGB.rgba>>8&16777215,Y=e.backgroundColorRGB),e.foregroundColorRGB&&(V=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,Z=e.foregroundColorRGB),Q="top"===e.options.layer)})),!Q&&W&&(Y=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,G=Y.rgba>>8&16777215,q=50331648,Q=!0,S.selectionForeground&&(V=50331648,$=S.selectionForeground.rgba>>8&16777215,Z=S.selectionForeground)),Q&&I.push("xterm-decoration-top"),q){case 16777216:case 33554432:J=S.ansi[G],I.push(`xterm-bg-${G}`);break;case 50331648:J=h.channels.toColor(G>>16,G>>8&255,255&G),this._addStyle(b,`background-color:#${p((G>>>0).toString(16),"0",6)}`);break;default:X?(J=S.foreground,I.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):J=S.background}switch(Y||F.isDim()&&(Y=h.color.multiplyOpacity(J,.5)),V){case 16777216:case 33554432:F.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(b,J,S.ansi[$],F,Y,void 0)||I.push(`xterm-fg-${$}`);break;case 50331648:const e=h.channels.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(b,J,e,F,Y,Z)||this._addStyle(b,`color:#${p($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(b,J,S.foreground,F,Y,Z)||X&&I.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}I.length&&(b.className=I.join(" "),I.length=0),H||B||z||!N?b.textContent=w:y++,M!==this.defaultSpacing&&(b.style.letterSpacing=`${M}px`),m.push(b),x=U}return b&&y&&(b.textContent=w),m}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,u.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=h.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function p(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e,t){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.classList.add("xterm-width-cache-measure-container"),this._container.setAttribute("aria-hidden","true"),this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const i=e.createElement("span");i.classList.add("xterm-char-measure-element");const s=e.createElement("span");s.classList.add("xterm-char-measure-element"),s.style.fontWeight="bold";const r=e.createElement("span");r.classList.add("xterm-char-measure-element"),r.style.fontStyle="italic";const n=e.createElement("span");n.classList.add("xterm-char-measure-element"),n.style.fontWeight="bold",n.style.fontStyle="italic",this._measureElements=[i,s,r,n],this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),this._container.appendChild(n),t.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),n>0&&this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},9176:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.INVERTED_DEFAULT_COLOR=void 0,t.INVERTED_DEFAULT_COLOR=257},6181:(e,t)=>{function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,n){return 1===t&&r>Math.ceil(1.5*n)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},2274:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,n=t[1]-r,o=i[1]-r,a=Math.max(n,0),l=Math.min(o,e.rows-1);a>=e.rows||l<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=n,this.viewportEndRow=o,this.viewportCappedStartRow=a,this.viewportCappedEndRow=l,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}},5959:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},4792:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(6501),o=i(7150),a=i(802);let l=class extends o.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this._register(new a.Emitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new d(this._optionsService))}catch{this._measureStrategy=this._register(new c(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=l,t.CharSizeService=l=s([r(2,n.IOptionsService)],l);class h extends o.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class c extends h{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends h{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},945:function(e,t,i){var s,r=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},n=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const o=i(5451),a=i(8938),l=i(3055),h=i(6501);class c extends o.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let d=s=class{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new l.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,o,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,o,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0;const s=i(802),r=i(7093),n=i(7150);class o extends n.Disposable{constructor(e,t,i){super(),this._textarea=e,this._window=t,this.mainDocument=i,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=this._register(new a(this._window)),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._onWindowChange=this._register(new s.Emitter),this.onWindowChange=this._onWindowChange.event,this._register(this.onWindowChange((e=>this._screenDprMonitor.setWindow(e)))),this._register(s.Event.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register((0,r.addDisposableListener)(this._textarea,"focus",(()=>this._isFocused=!0))),this._register((0,r.addDisposableListener)(this._textarea,"blur",(()=>this._isFocused=!1)))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}t.CoreBrowserService=o;class a extends n.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new n.MutableDisposable),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register((0,n.toDisposable)((()=>this.clearListener())))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,r.addDisposableListener)(this._parentWindow,"resize",(()=>this._setDprAndFireIfDiffers()))}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},9820:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(7150);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this._register((0,s.toDisposable)((()=>this.linkProviders.length=0)))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},9784:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(7098),o=i(5251);let a=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a,t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},5783:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(4852),o=i(7098),a=i(7150),l=i(6168),h=i(6501),c=i(802);let d=class extends a.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,o,h,d,_){super(),this._rowCount=e,this._optionsService=i,this._charSizeService=s,this._coreService=r,this._coreBrowserService=d,this._renderer=this._register(new a.MutableDisposable),this._pausedResizeTask=new l.DebouncedIdleTask,this._observerDisposable=this._register(new a.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new c.Emitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new c.Emitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new c.Emitter),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new c.Emitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new n.RenderDebouncer(((e,t)=>this._renderRows(e,t)),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new u(this._coreBrowserService,this._coreService,(()=>this._fullRefresh())),this._register((0,a.toDisposable)((()=>this._syncOutputHandler.dispose()))),this._register(this._coreBrowserService.onDprChange((()=>this.handleDevicePixelRatioChange()))),this._register(h.onResize((()=>this._fullRefresh()))),this._register(h.buffers.onBufferActivate((()=>this._renderer.value?.clear()))),this._register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this._register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this._register(o.onDecorationRegistered((()=>this._fullRefresh()))),this._register(o.onDecorationRemoved((()=>this._fullRefresh()))),this._register(this._optionsService.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],(()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()}))),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(h.buffer.y,h.buffer.y,!0)))),this._register(_.onChangeColors((()=>this._fullRefresh()))),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange((e=>this._registerIntersectionObserver(e,t))))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});i.observe(t),this._observerDisposable.value=(0,a.toDisposable)((()=>i.disconnect()))}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const s=this._syncOutputHandler.flush();s&&(e=Math.min(e,s.start),t=Math.max(t,s.end)),i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){this._renderer.value&&(this._coreService.decPrivateModes.synchronizedOutput?this._syncOutputHandler.bufferRows(e,t):(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0))}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value?.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d,t.RenderService=d=s([r(2,h.IOptionsService),r(3,o.ICharSizeService),r(4,h.ICoreService),r(5,h.IDecorationService),r(6,h.IBufferService),r(7,o.ICoreBrowserService),r(8,o.IThemeService)],d);class u{constructor(e,t,i){this._coreBrowserService=e,this._coreService=t,this._onTimeout=i,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),void 0===this._timeout&&(this._timeout=this._coreBrowserService.window.setTimeout((()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()}),1e3))}flush(){if(void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;const e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}}},2079:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(5251),o=i(9686),a=i(5959),l=i(7098),h=i(7150),c=i(701),d=i(9384),u=i(3055),_=i(6501),f=i(802),p=String.fromCharCode(160),g=new RegExp(p,"g");let m=class extends h.Disposable{constructor(e,t,i,s,r,n,o,l,c){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=l,this._coreBrowserService=c,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this._register(new f.Emitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this._register(new f.Emitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this._register(new f.Emitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this._register(new f.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this._register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this._register((0,h.toDisposable)((()=>{this._removeMouseDownListeners()}))),this._register(this._bufferService.onResize((e=>{e.rowsChanged&&this.clearSelection()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(g," "))).join(c.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),c.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,d.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return c.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(c.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),l=a;const h=e[0]-a;let c=0,d=0,u=0,_=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;l1&&(_+=s-1,l+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(c++,t--):e>1&&(u+=e-1,a-=e-1),a--,t--}for(;i1&&(_+=e-1,l+=e-1),l++,i++}}l++;let f=a+h-c+u,p=Math.min(this._bufferService.cols,l-a+c+d-u-_);if(t||""!==o.slice(a,l).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,p+=e}}}if(s&&f+p===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(p+=t.length)}}return{start:f,length:p}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,d.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=m,t.SelectionService=m=s([r(3,_.IBufferService),r(4,_.ICoreService),r(5,l.IMouseService),r(6,_.IOptionsService),r(7,l.IRenderService),r(8,l.ICoreBrowserService)],m)},7098:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(6201);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService")},9078:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=void 0;const n=i(7174),o=i(9302),a=i(4103),l=i(7150),h=i(6501),c=i(802),d=a.css.toColor("#ffffff"),u=a.css.toColor("#000000"),_=a.css.toColor("#ffffff"),f=u,p={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},g=d;let m=class extends l.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this._register(new c.Emitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:d,background:u,cursor:_,cursorAccent:f,selectionForeground:void 0,selectionBackgroundTransparent:p,selectionBackgroundOpaque:a.color.blend(u,p),selectionInactiveBackgroundTransparent:p,selectionInactiveBackgroundOpaque:a.color.blend(u,p),scrollbarSliderBackground:a.color.opacity(d,.2),scrollbarSliderHoverBackground:a.color.opacity(d,.4),scrollbarSliderActiveBackground:a.color.opacity(d,.5),overviewRulerBorder:d,ansi:o.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this._register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const t=this._colors;if(t.foreground=v(e.foreground,d),t.background=v(e.background,u),t.cursor=a.color.blend(t.background,v(e.cursor,_)),t.cursorAccent=a.color.blend(t.background,v(e.cursorAccent,f)),t.selectionBackgroundTransparent=v(e.selectionBackground,p),t.selectionBackgroundOpaque=a.color.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=v(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=a.color.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?v(e.selectionForeground,a.NULL_COLOR):void 0,t.selectionForeground===a.NULL_COLOR&&(t.selectionForeground=void 0),a.color.isOpaque(t.selectionBackgroundTransparent)){const e=.3;t.selectionBackgroundTransparent=a.color.opacity(t.selectionBackgroundTransparent,e)}if(a.color.isOpaque(t.selectionInactiveBackgroundTransparent)){const e=.3;t.selectionInactiveBackgroundTransparent=a.color.opacity(t.selectionInactiveBackgroundTransparent,e)}if(t.scrollbarSliderBackground=v(e.scrollbarSliderBackground,a.color.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=v(e.scrollbarSliderHoverBackground,a.color.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=v(e.scrollbarSliderActiveBackground,a.color.opacity(t.foreground,.5)),t.overviewRulerBorder=v(e.overviewRulerBorder,g),t.ansi=o.DEFAULT_ANSI_COLORS.slice(),t.ansi[0]=v(e.black,o.DEFAULT_ANSI_COLORS[0]),t.ansi[1]=v(e.red,o.DEFAULT_ANSI_COLORS[1]),t.ansi[2]=v(e.green,o.DEFAULT_ANSI_COLORS[2]),t.ansi[3]=v(e.yellow,o.DEFAULT_ANSI_COLORS[3]),t.ansi[4]=v(e.blue,o.DEFAULT_ANSI_COLORS[4]),t.ansi[5]=v(e.magenta,o.DEFAULT_ANSI_COLORS[5]),t.ansi[6]=v(e.cyan,o.DEFAULT_ANSI_COLORS[6]),t.ansi[7]=v(e.white,o.DEFAULT_ANSI_COLORS[7]),t.ansi[8]=v(e.brightBlack,o.DEFAULT_ANSI_COLORS[8]),t.ansi[9]=v(e.brightRed,o.DEFAULT_ANSI_COLORS[9]),t.ansi[10]=v(e.brightGreen,o.DEFAULT_ANSI_COLORS[10]),t.ansi[11]=v(e.brightYellow,o.DEFAULT_ANSI_COLORS[11]),t.ansi[12]=v(e.brightBlue,o.DEFAULT_ANSI_COLORS[12]),t.ansi[13]=v(e.brightMagenta,o.DEFAULT_ANSI_COLORS[13]),t.ansi[14]=v(e.brightCyan,o.DEFAULT_ANSI_COLORS[14]),t.ansi[15]=v(e.brightWhite,o.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const i=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(7150),r=i(802);class n extends s.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this._register(new r.Emitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this._register(new r.Emitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this._register(new r.Emitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},4103:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0,t.toPaddedHex=d,t.contrastRatio=u;let i=0,s=0,r=0,n=0;var o,a,l,h,c;function d(e){const t=e.toString(16);return t.length<2?"0"+t:t}function u(e,t){return e>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(o||(t.channels=o={})),function(e){function t(e,t){return n=Math.round(255*t),[i,s,r]=c.toChannels(e.rgba),{css:o.toCss(i,s,r,n),rgba:o.toRgba(i,s,r,n)}}e.blend=function(e,t){if(n=(255&t.rgba)/255,1===n)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,l=t.rgba>>16&255,h=t.rgba>>8&255,c=e.rgba>>24&255,d=e.rgba>>16&255,u=e.rgba>>8&255;return i=c+Math.round((a-c)*n),s=d+Math.round((l-d)*n),r=u+Math.round((h-u)*n),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},e.isOpaque=function(e){return!(255&~e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=c.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=c.toChannels(t),{css:o.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return n=255&e.rgba,t(e,n*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n=parseInt(e.slice(4,5).repeat(2),16),o.toColor(i,s,r,n);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const l=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return i=parseInt(l[1]),s=parseInt(l[2]),r=parseInt(l[3]),n=Math.round(255*(void 0===l[5]?1:parseFloat(l[5]))),o.toColor(i,s,r,n);if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,n]=t.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,n),css:e}}}(l||(t.css=l={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(h||(t.rgb=h={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,l=t>>8&255,c=u(h.relativeLuminance2(o,a,l),h.relativeLuminance2(s,r,n));for(;c0||a>0||l>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),l-=Math.max(0,Math.ceil(.1*l)),c=u(h.relativeLuminance2(o,a,l),h.relativeLuminance2(s,r,n));return(o<<24|a<<16|l<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,l=t>>8&255,c=u(h.relativeLuminance2(o,a,l),h.relativeLuminance2(s,r,n));for(;c>>0}e.blend=function(e,t){if(n=(255&t)/255,1===n)return t;const a=t>>24&255,l=t>>16&255,h=t>>8&255,c=e>>24&255,d=e>>16&255,u=e>>8&255;return i=c+Math.round((a-c)*n),s=d+Math.round((l-d)*n),r=u+Math.round((h-u)*n),o.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=h.relativeLuminance(e>>8),n=h.relativeLuminance(i>>8);if(u(r,n)>8));if(ou(r,h.relativeLuminance(t>>8))?n:t}return n}const o=a(e,i,s),l=u(r,h.relativeLuminance(o>>8));if(lu(r,h.relativeLuminance(n>>8))?o:n}return o}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(c||(t.rgba=c={}))},5777:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(6501),r=i(6025),n=i(7276),o=i(9640),a=i(56),l=i(4071),h=i(7792),c=i(6415),d=i(5746),u=i(5882),_=i(2486),f=i(3562),p=i(8811),g=i(802),m=i(7150);let v=!1;class S extends m.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new g.Emitter),this._onScroll.event((e=>{this._onScrollApi?.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this._register(new m.MutableDisposable),this._onBinary=this._register(new g.Emitter),this.onBinary=this._onBinary.event,this._onData=this._register(new g.Emitter),this.onData=this._onData.event,this._onLineFeed=this._register(new g.Emitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this._register(new g.Emitter),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new g.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new g.Emitter),this._instantiationService=new r.InstantiationService,this.optionsService=this._register(new a.OptionsService(e)),this._instantiationService.setService(s.IOptionsService,this.optionsService),this._bufferService=this._register(this._instantiationService.createInstance(o.BufferService)),this._instantiationService.setService(s.IBufferService,this._bufferService),this._logService=this._register(this._instantiationService.createInstance(n.LogService)),this._instantiationService.setService(s.ILogService,this._logService),this.coreService=this._register(this._instantiationService.createInstance(l.CoreService)),this._instantiationService.setService(s.ICoreService,this.coreService),this.coreMouseService=this._register(this._instantiationService.createInstance(h.CoreMouseService)),this._instantiationService.setService(s.ICoreMouseService,this.coreMouseService),this.unicodeService=this._register(this._instantiationService.createInstance(c.UnicodeService)),this._instantiationService.setService(s.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(d.CharsetService),this._instantiationService.setService(s.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(p.OscLinkService),this._instantiationService.setService(s.IOscLinkService,this._oscLinkService),this._inputHandler=this._register(new _.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this._register(g.Event.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(this._inputHandler),this._register(g.Event.forward(this._bufferService.onResize,this._onResize)),this._register(g.Event.forward(this.coreService.onData,this._onData)),this._register(g.Event.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom(!0)))),this._register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this._register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this._register(this._bufferService.onScroll((()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this._register(new f.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this._register(g.Event.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=s.LogLevelEnum.WARN&&!v&&(this._logService.warn("writeSync is unreliable and will be removed soon."),v=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,o.MINIMUM_COLS),t=Math.max(t,o.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(u.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,u.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,m.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},2486:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0,t.isValidColorIndex=R;const n=i(3534),o=i(6760),a=i(6717),l=i(7150),h=i(726),c=i(6107),d=i(8938),u=i(3055),_=i(5451),f=i(6501),p=i(6415),g=i(1346),m=i(9823),v=i(8693),S=i(802),b={"(":0,")":1,"*":2,"+":3,"-":1,".":2},C=131072;function y(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var w;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(w||(t.WindowsOptionsReportType=w={}));let E=0;class D extends l.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,l,d,u,_=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=l,this._coreMouseService=d,this._unicodeService=u,this._parser=_,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new h.StringToUtf32,this._utf8Decoder=new h.Utf8ToUtf32,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=c.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=c.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this._register(new S.Emitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new S.Emitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new S.Emitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new S.Emitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new S.Emitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new S.Emitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new S.Emitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new S.Emitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new S.Emitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new S.Emitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new S.Emitter),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new S.Emitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new S.Emitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new L(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new g.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new g.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new g.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new g.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new g.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new g.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new g.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new g.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new g.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new g.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new g.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new g.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new m.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=f.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>C&&(n=this._parseStack.position+C)}if(this._logService.logLevel<=f.LogLevelEnum.DEBUG&&this._logService.debug("parsing data "+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`)),this._logService.logLevel===f.LogLevelEnum.TRACE&&this._logService.trace("parsing data (codes)","string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthC)for(let t=n;t0&&2===f.getWidth(this._activeBuffer.x-1)&&f.setCellFromCodepoint(this._activeBuffer.x-1,0,1,_);let g=this._parser.precedingJoinState;for(let m=t;ma)if(l){const e=f;let t=this._activeBuffer.x-v;for(this._activeBuffer.x=v,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),f=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),v>0&&f instanceof c.BufferLine&&f.copyCellsFrom(e,t,0,v,!1);t=0;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,_)}else if(u&&(f.insertCells(this._activeBuffer.x,r-v,this._activeBuffer.getNullCell(_)),2===f.getWidth(a-1)&&f.setCellFromCodepoint(a-1,d.NULL_CELL_CODE,d.NULL_CELL_WIDTH,_)),f.setCellFromCodepoint(this._activeBuffer.x++,s,r,_),r>0)for(;--r;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,_)}this._parser.precedingJoinState=g,this._activeBuffer.x0&&0===f.getWidth(this._activeBuffer.x)&&!f.hasContent(this._activeBuffer.x)&&f.setCellFromCodepoint(this._activeBuffer.x,0,1,_),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!y(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new m.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new g.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(i=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,i-1);i--;){const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);if(e?.getTrimmedLength())break}for(;i>=0;i--)this._bufferService.scroll(this._eraseAttrData())}else{for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0)}break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let l=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,_=e.params[0];return f=_,p=t?2===_?4:4===_?u(o.modes.insertMode):12===_?3:20===_?u(d.convertEol):0:1===_?u(i.applicationCursorKeys):3===_?d.windowOptions.setWinLines?80===l?2:132===l?1:0:0:6===_?u(i.origin):7===_?u(i.wraparound):8===_?3:9===_?u("X10"===s):12===_?u(d.cursorBlink):25===_?u(!o.isCursorHidden):45===_?u(i.reverseWraparound):66===_?u(i.applicationKeypad):67===_?4:1e3===_?u("VT200"===s):1002===_?u("DRAG"===s):1003===_?u("ANY"===s):1004===_?u(i.sendFocus):1005===_?4:1006===_?u("SGR"===r):1015===_?4:1016===_?u("SGR_PIXELS"===r):1048===_?1:47===_||1047===_||1049===_?u(h===c):2004===_?u(i.bracketedPasteMode):2026===_?u(i.synchronizedOutput):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${p}$y`),!0;var f,p}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=_.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=c.DEFAULT_ATTR_DATA.fg,e.bg=c.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&c.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&c.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&c.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&c.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=c.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=0===e.length?1:e.params[0];if(0===t)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar"}const e=t%2==1;this._coreService.decPrivateModes.cursorBlink=e}return!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!y(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(w.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(w.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(R(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,v.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.indexOf(";");if(-1===t)return!0;const i=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(i,s):!i.trim()&&this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,v.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=c.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=c.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=D;let L=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(E=e,e=t,t=E),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function R(e){return 0<=e&&e<256}L=s([r(0,f.IBufferService)],L)},7710:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},701:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isNode="undefined"!=typeof process&&"title"in process;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},3087:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;const s=i(6168);let r=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[],this._insertedValues=[],this._flushInsertedTask=new s.IdleTaskQueue,this._isFlushingInserted=!1,this._deletedIndices=[],this._flushDeletedTask=new s.IdleTaskQueue,this._isFlushingDeleted=!1}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),0===this._insertedValues.length&&this._flushInsertedTask.enqueue((()=>this._flushInserted())),this._insertedValues.push(e)}_flushInserted(){const e=this._insertedValues.sort(((e,t)=>this._getKey(e)-this._getKey(t)));let t=0,i=0;const s=new Array(this._array.length+this._insertedValues.length);for(let r=0;r=this._array.length||this._getKey(e[t])<=this._getKey(this._array[i])?(s[r]=e[t],t++):s[r]=this._array[i++];this._array=s,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(r=this._search(t),-1===r)return!1;if(this._getKey(this._array[r])!==t)return!1;do{if(this._array[r]===e)return 0===this._deletedIndices.length&&this._flushDeletedTask.enqueue((()=>this._flushDeleted())),this._deletedIndices.push(r),!0}while(++re-t));let t=0;const i=new Array(this._array.length-e.length);let s=0;for(let r=0;r0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),0!==this._array.length&&(r=this._search(e),!(r<0||r>=this._array.length)&&this._getKey(this._array[r])===e))do{yield this._array[r]}while(++r=this._array.length)&&this._getKey(this._array[r])===e))do{t(this._array[r])}while(++r=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},6168:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(701);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},5882:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)};const s=i(8938)},5451:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},1073:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(5639),r=i(6168),n=i(5451),o=i(6107),a=i(732),l=i(3055),h=i(8938),c=i(8158),d=i(6760);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=l.CellData.fromCharData([0,h.NULL_CELL_CHAR,h.NULL_CELL_WIDTH,h.NULL_CELL_CODE]),this._whitespaceCell=l.CellData.fromCharData([0,h.WHITESPACE_CELL_CHAR,h.WHITESPACE_CELL_WIDTH,h.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=this._optionsService.rawOptions.reflowCursorLine,s=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA),i);if(s.length>0){const i=(0,a.reflowLargerCreateNewLayout)(this.lines,s);(0,a.reflowLargerApplyNewLayout)(this.lines,i.layout),this._reflowLargerAdjustViewport(e,t,i.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;l--){let h=this.lines.get(l);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&l>0;)h=this.lines.get(--l),c.unshift(h);if(!i){const e=this.ybase+this.y;if(e>=l&&e0&&(r.push({start:l+c.length+n,newLines:p}),n+=p.length),c.push(...p);let g=u.length-1,m=u[g];0===m&&(g--,m=u[g]);let v=c.length-_-1,S=d;for(;v>=0;){const e=Math.min(S,m);if(void 0===c[g])break;if(c[g].copyCellsFrom(c[v],S-e,m-e,e,!0),m-=e,0===m&&(g--,m=u[g]),S-=e,0===S){v--;const e=Math.max(v,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;h--)if(a&&a.start>s+l){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(h--,a.newLines[e]);h++,e.push({index:s+1,amount:a.newLines.length}),l+=a.newLines.length,a=r[++o]}else this.lines.set(h,t[s--]);let h=0;for(let t=e.length-1;t>=0;t--)e[t].index+=h,this.lines.onInsertEmitter.fire(e[t]),h+=e[t].amount;const c=Math.max(0,i+n-this.lines.maxLength);c>0&&this.lines.onTrimEmitter.fire(c)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},6107:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(5451),r=i(3055),n=i(8938),o=i(726);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class l{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,o.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&s)+(0,o.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e,t,i,s){t=t??0,i=i??this.length,e&&(i=Math.min(i,this.getTrimmedLength())),s&&(s.length=0);let r="";for(;t>22||1}return s&&s.push(t),r}}t.BufferLine=l},9384:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},732:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.reflowLargerGetLinesToRemove=function(e,t,s,r,n,o){const a=[];for(let l=0;l=l&&r0&&(e>u||0===d[e].getTrimmedLength());e--)g++;g>0&&(a.push(l+d.length-g),a.push(g)),l+=d.length-1}return a},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,l=0;for(;lh&&(o-=h,a++);const c=2===e[a].getWidth(o-1);c&&o--;const d=c?s-1:s;r.push(d),l+=d}return r},t.getWrappedLineTrimmedLength=i},4097:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(7150),r=i(1073),n=i(802);class o extends s.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this._register(new n.Emitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new r.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new r.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},3055:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(726),r=i(8938),n=i(5451);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},8938:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=t.DEFAULT_COLOR<<9|256,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},8158:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(802),r=i(7150);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.Emitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.dispose)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},6760:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},3534:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},706:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:o.key=e.ctrlKey?"\b":s.C0.DEL,e.altKey&&(o.key=s.C0.ESC+o.key);break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"D":t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"C":t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"A":t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"B":t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=t?.[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o};const s=i(3534),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']}},726:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,l=0,h=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const l=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,c=l-o;for(;h=i)return 0;if(n=e[h++],128!=(192&n)){h--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===l?r<128?h--:t[a++]=r:3===l?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const c=i-4;let d=h;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(l=(31&s)<<6|63&r,l<128){d--;continue}t[a++]=l}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(l=(15&s)<<12|(63&r)<<6|63&n,l<2048||l>=55296&&l<=57343||65279===l)continue;t[a++]=l}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(l=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,l<65536||l>1114111)continue;t[a++]=l}}return a}}},7428:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(6415),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],n=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let o;t.UnicodeV6=class{constructor(){if(this.version="6",!o){o=new Uint8Array(65536),o.fill(1),o[0]=0,o.fill(0,1,32),o.fill(0,127,160),o.fill(2,4352,4448),o[9001]=2,o[9002]=2,o.fill(2,11904,42192),o[12351]=1,o.fill(2,44032,55204),o.fill(2,63744,64256),o.fill(2,65040,65050),o.fill(2,65072,65136),o.fill(2,65280,65377),o.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},3562:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(7150),r=i(802);class n extends s.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this._register(new r.Emitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>performance.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,performance.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},8693:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`};const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}},1263:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},9823:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(726),r=i(7262),n=i(1263),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},6717:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(7150),r=i(7262),n=i(1346),o=i(9823);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(l,0,2,0),e.add(l,8,5,8),e.add(l,6,0,6),e.add(l,11,0,11),e.add(l,13,13,13),e}();class h extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this._register(new n.OscParser),this._dcsParser=this._register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingJoinState=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const h=this._escHandlers[this._collect<<8|r];let c=h?h.length-1:-1;for(;c>=0&&(s=h[c](),!0!==s);c--)if(s instanceof Promise)return this._preserveStack(4,h,c,n,i),s;c<0&&this._escHandlerFb(this._collect<<8|r),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(1263),r=i(726),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},7262:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},3027:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(793),r=i(3055);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},793:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(3055);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},5101:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(3235),r=i(7150),n=i(802);class o extends r.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new n.Emitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},6097:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},4335:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},9640:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(7150),o=i(4097),a=i(6501),l=i(802);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let h=class extends n.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this._register(new l.Emitter),this.onResize=this._onResize.event,this._onScroll=this._register(new l.Emitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this._register(new o.BufferSet(e,this)),this._register(this.buffers.onBufferActivate((e=>{this._onScroll.fire(e.activeBuffer.ydisp)})))}resize(e,t){const i=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:i,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t){const i=this.buffer;if(e<0){if(0===i.ydisp)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);const s=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),s!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))}};t.BufferService=h,t.BufferService=h=s([r(0,a.IOptionsService)],h)},5746:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},7792:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(6501),o=i(7150),a=i(802),l={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function h(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const c=String.fromCharCode,d={DEFAULT:e=>{const t=[h(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${c(t[0])}${c(t[1])}${c(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${h(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${h(e,!0)};${e.x};${e.y}${t}`}};let u=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._coreService=t,this._optionsService=i,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._wheelPartialScroll=0,this._onProtocolChange=this._register(new a.Emitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(l))this.addProtocol(e,l[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null,this._wheelPartialScroll=0}consumeWheelEvent(e,t,i){if(0===e.deltaY||e.shiftKey)return 0;if(void 0===t||void 0===i)return 0;const s=t/i;let r=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(r/=s+0,Math.abs(e.deltaY)<50&&(r*=.3),this._wheelPartialScroll+=r,r=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(r*=this._bufferService.rows),r}_applyScrollModifier(e,t){return t.altKey||t.ctrlKey||t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=u,t.CoreMouseService=u=s([r(0,n.IBufferService),r(1,n.ICoreService),r(2,n.IOptionsService)],u)},4071:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(7453),o=i(7150),a=i(6501),l=i(802),h=Object.freeze({insertMode:!1}),c=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,wraparound:!0});let d=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this._register(new l.Emitter),this.onData=this._onData.event,this._onUserInput=this._register(new l.Emitter),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new l.Emitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new l.Emitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(h),this.decPrivateModes=(0,n.clone)(c)}reset(){this.modes=(0,n.clone)(h),this.decPrivateModes=(0,n.clone)(c)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d,t.CoreService=d=s([r(0,a.IBufferService),r(1,a.ILogService),r(2,a.IOptionsService)],d)},4720:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(4103),r=i(7150),n=i(3087),o=i(802);let a=0,l=0;class h extends r.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new n.SortedList((e=>e?.marker.line)),this._onDecorationRegistered=this._register(new o.Emitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new o.Emitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this._register((0,r.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new c(e);if(t){const e=t.marker.onDispose((()=>t.dispose())),i=t.onDispose((()=>{i.dispose(),t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())}));this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){let s=0,r=0;for(const n of this._decorations.getKeyIterator(t))s=n.options.x??0,r=s+(n.options.width??1),e>=s&&e{a=t.options.x??0,l=a+(t.options.width??1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(6501),r=i(6201);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id._id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7276:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.LogService=void 0,t.setTraceLogger=function(e){l=e},t.traceCall=function(e,t,i){if("function"!=typeof i.value)throw new Error("not supported");const s=i.value;i.value=function(...e){if(l.logLevel!==o.LogLevelEnum.TRACE)return s.apply(this,e);l.trace(`GlyphRenderer#${s.name}(${e.map((e=>JSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return l.trace(`GlyphRenderer#${s.name} return`,t),t}};const n=i(7150),o=i(6501),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let l,h=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),l=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(7150),r=i(701),n=i(802);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:r.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRuler:{}};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends s.Disposable{constructor(e){super(),this._onOptionChange=this._register(new n.Emitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this._register((0,s.toDisposable)((()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null})))}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},8811:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(6501);let o=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o,t.OscLinkService=o=s([r(0,n.IBufferService)],o)},6201:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.serviceRegistry=void 0,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r._id=e,t.serviceRegistry.set(e,r),r};const i="di$target",s="di$dependencies";t.serviceRegistry=new Map},6501:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(6201);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},6415:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(7428),r=i(802);class n{static extractShouldJoin(e){return!!(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}constructor(){this._providers=Object.create(null),this._active="",this._onChange=new r.Emitter,this.onChange=this._onChange.event;const e=new s.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let r=0;r=s)return t+this.wcwidth(o);const i=e.charCodeAt(r);56320<=i&&i<=57343?o=1024*(o-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(o,i);let l=n.extractWidth(a);n.extractShouldJoin(a)&&(l-=n.extractWidth(i)),t+=l,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=n},4333:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isAndroid=t.isElectron=t.isWebkitWebView=t.isSafari=t.isChrome=t.isWebKit=t.isFirefox=t.onDidChangeFullscreen=t.onDidChangeZoomLevel=void 0,t.addMatchMediaChangeListener=o,t.setZoomLevel=function(e,t){n.INSTANCE.setZoomLevel(e,t)},t.getZoomLevel=function(e){return n.INSTANCE.getZoomLevel(e)},t.getZoomFactor=function(e){return n.INSTANCE.getZoomFactor(e)},t.setZoomFactor=function(e,t){n.INSTANCE.setZoomFactor(e,t)},t.setFullscreen=function(e,t){n.INSTANCE.setFullscreen(e,t)},t.isFullscreen=function(e){return n.INSTANCE.isFullscreen(e)},t.isStandalone=function(){return l},t.isWCOEnabled=function(){return navigator?.windowControlsOverlay?.visible},t.getWCOBoundingRect=function(){return navigator?.windowControlsOverlay?.getTitlebarAreaRect()};const s=i(4693),r=i(802);class n{constructor(){this.mapWindowIdToZoomLevel=new Map,this._onDidChangeZoomLevel=new r.Emitter,this.onDidChangeZoomLevel=this._onDidChangeZoomLevel.event,this.mapWindowIdToZoomFactor=new Map,this._onDidChangeFullscreen=new r.Emitter,this.onDidChangeFullscreen=this._onDidChangeFullscreen.event,this.mapWindowIdToFullScreen=new Map}static{this.INSTANCE=new n}getZoomLevel(e){return this.mapWindowIdToZoomLevel.get(this.getWindowId(e))??0}setZoomLevel(e,t){if(this.getZoomLevel(t)===e)return;const i=this.getWindowId(t);this.mapWindowIdToZoomLevel.set(i,e),this._onDidChangeZoomLevel.fire(i)}getZoomFactor(e){return this.mapWindowIdToZoomFactor.get(this.getWindowId(e))??1}setZoomFactor(e,t){this.mapWindowIdToZoomFactor.set(this.getWindowId(t),e)}setFullscreen(e,t){if(this.isFullscreen(t)===e)return;const i=this.getWindowId(t);this.mapWindowIdToFullScreen.set(i,e),this._onDidChangeFullscreen.fire(i)}isFullscreen(e){return!!this.mapWindowIdToFullScreen.get(this.getWindowId(e))}getWindowId(e){return e.vscodeWindowId}}function o(e,t,i){"string"==typeof t&&(t=e.matchMedia(t)),t.addEventListener("change",i)}t.onDidChangeZoomLevel=n.INSTANCE.onDidChangeZoomLevel,t.onDidChangeFullscreen=n.INSTANCE.onDidChangeFullscreen;const a="object"==typeof navigator?navigator.userAgent:"";t.isFirefox=a.indexOf("Firefox")>=0,t.isWebKit=a.indexOf("AppleWebKit")>=0,t.isChrome=a.indexOf("Chrome")>=0,t.isSafari=!t.isChrome&&a.indexOf("Safari")>=0,t.isWebkitWebView=!t.isChrome&&!t.isSafari&&t.isWebKit,t.isElectron=a.indexOf("Electron/")>=0,t.isAndroid=a.indexOf("Android")>=0;let l=!1;if("function"==typeof s.mainWindow.matchMedia){const e=s.mainWindow.matchMedia("(display-mode: standalone) or (display-mode: window-controls-overlay)"),t=s.mainWindow.matchMedia("(display-mode: fullscreen)");l=e.matches,o(s.mainWindow,e,(({matches:e})=>{l&&t.matches||(l=e)}))}},7745:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.BrowserFeatures=t.KeyboardSupport=void 0;const o=n(i(4333)),a=i(4693),l=n(i(8163));var h;!function(e){e[e.Always=0]="Always",e[e.FullScreen=1]="FullScreen",e[e.None=2]="None"}(h||(t.KeyboardSupport=h={}));const c="object"==typeof navigator?navigator:{};t.BrowserFeatures={clipboard:{writeText:l.isNative||document.queryCommandSupported&&document.queryCommandSupported("copy")||!!(c&&c.clipboard&&c.clipboard.writeText),readText:l.isNative||!!(c&&c.clipboard&&c.clipboard.readText)},keyboard:l.isNative||o.isStandalone()?h.Always:c.keyboard||o.isSafari?h.FullScreen:h.None,touch:"ontouchstart"in a.mainWindow||c.maxTouchPoints>0,pointerEvents:a.mainWindow.PointerEvent&&("ontouchstart"in a.mainWindow||navigator.maxTouchPoints>0)}},7093:function(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),n=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),o=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&r(t,e,i);return n(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.SafeTriangle=t.DragAndDropObserver=t.ModifierKeyEmitter=t.DetectedFullscreenMode=t.Namespace=t.EventHelper=t.EventType=t.sharedMutationObserver=t.Dimension=t.WindowIntervalTimer=t.scheduleAtNextAnimationFrame=t.runAtThisOrScheduleAtNextAnimationFrame=t.WindowIdleValue=t.addStandardDisposableGenericMouseUpListener=t.addStandardDisposableGenericMouseDownListener=t.addStandardDisposableListener=t.onDidUnregisterWindow=t.onWillUnregisterWindow=t.onDidRegisterWindow=t.hasWindow=t.getWindowById=t.getWindowId=t.getWindowsCount=t.getWindows=t.getDocument=t.getWindow=t.registerWindow=void 0,t.clearNode=function(e){for(;e.firstChild;)e.firstChild.remove()},t.clearNodeRecursively=function e(t){for(;t.firstChild;){const i=t.firstChild;i.remove(),e(i)}},t.addDisposableListener=C,t.addDisposableGenericMouseDownListener=w,t.addDisposableGenericMouseMoveListener=function(e,i,s){return C(e,g.isIOS&&l.BrowserFeatures.pointerEvents?t.EventType.POINTER_MOVE:t.EventType.MOUSE_MOVE,i,s)},t.addDisposableGenericMouseUpListener=E,t.runWhenWindowIdle=function(e,t,i){return(0,d._runWhenIdle)(e,t,i)},t.disposableWindowInterval=function(e,t,i,s){let r=0;const n=e.setInterval((()=>{r++,("number"==typeof s&&r>=s||!0===t())&&o.dispose()}),i),o=(0,p.toDisposable)((()=>{e.clearInterval(n)}));return o},t.measure=function(e,i){return(0,t.scheduleAtNextAnimationFrame)(e,i,1e4)},t.modify=function(e,i){return(0,t.scheduleAtNextAnimationFrame)(e,i,-1e4)},t.addDisposableThrottledListener=function(e,t,i,s,r){return new T(e,t,i,s,r)},t.getComputedStyle=k,t.getClientArea=function e(i,s){const r=(0,t.getWindow)(i),n=r.document;if(i!==n.body)return new O(i.clientWidth,i.clientHeight);if(g.isIOS&&r?.visualViewport)return new O(r.visualViewport.width,r.visualViewport.height);if(r?.innerWidth&&r.innerHeight)return new O(r.innerWidth,r.innerHeight);if(n.body&&n.body.clientWidth&&n.body.clientHeight)return new O(n.body.clientWidth,n.body.clientHeight);if(n.documentElement&&n.documentElement.clientWidth&&n.documentElement.clientHeight)return new O(n.documentElement.clientWidth,n.documentElement.clientHeight);if(s)return e(s);throw new Error("Unable to figure out browser width and height")},t.getTopLeftOffset=I,t.size=function(e,t,i){"number"==typeof t&&(e.style.width=`${t}px`),"number"==typeof i&&(e.style.height=`${i}px`)},t.position=function(e,t,i,s,r,n="absolute"){"number"==typeof t&&(e.style.top=`${t}px`),"number"==typeof i&&(e.style.right=`${i}px`),"number"==typeof s&&(e.style.bottom=`${s}px`),"number"==typeof r&&(e.style.left=`${r}px`),e.style.position=n},t.getDomNodePagePosition=function(e){const i=e.getBoundingClientRect(),s=(0,t.getWindow)(e);return{left:i.left+s.scrollX,top:i.top+s.scrollY,width:i.width,height:i.height}},t.getDomNodeZoomLevel=function(e){let t=e,i=1;do{const e=k(t).zoom;null!=e&&"1"!==e&&(i*=e),t=t.parentElement}while(null!==t&&t!==t.ownerDocument.documentElement);return i},t.getTotalWidth=P,t.getContentWidth=function(e){const t=M.getBorderLeftWidth(e)+M.getBorderRightWidth(e),i=M.getPaddingLeft(e)+M.getPaddingRight(e);return e.offsetWidth-t-i},t.getTotalScrollWidth=x,t.getContentHeight=function(e){const t=M.getBorderTopWidth(e)+M.getBorderBottomWidth(e),i=M.getPaddingTop(e)+M.getPaddingBottom(e);return e.offsetHeight-t-i},t.getTotalHeight=function(e){const t=M.getMarginTop(e)+M.getMarginBottom(e);return e.offsetHeight+t},t.getLargestChildWidth=function(e,t){const i=t.map((t=>Math.max(x(t),P(t))+function(e,t){if(null===e)return 0;const i=I(e),s=I(t);return i.left-s.left}(t,e)||0));return Math.max(...i)},t.isAncestor=B,t.setParentFlowTo=function(e,t){e.dataset[N]=t.id},t.isAncestorUsingFlowTo=function(e,t){let i=e;for(;i;){if(i===t)return!0;if(Q(i)){const e=U(i);if(e){i=e;continue}}i=i.parentNode}return!1},t.findParentWithClass=F,t.hasParentWithClass=function(e,t,i){return!!F(e,t,i)},t.isShadowRoot=W,t.isInShadowDOM=function(e){return!!H(e)},t.getShadowRoot=H,t.getActiveElement=K,t.isActiveElement=function(e){return K()===e},t.isAncestorOfActiveElement=function(e){return B(K(),e)},t.isActiveDocument=function(e){return e.ownerDocument===z()},t.getActiveDocument=z,t.getActiveWindow=function(){const e=z();return e.defaultView?.window??v.mainWindow},t.isGlobalStylesheet=function(e){return j.has(e)},t.createStyleSheet2=function(){return new $},t.createStyleSheet=V,t.cloneGlobalStylesheets=function(e){const t=new p.DisposableStore;for(const[i,s]of j)t.add(G(i,s,e));return t},t.createMetaElement=function(e=v.mainWindow.document.head){return q("meta",e)},t.createLinkElement=function(e=v.mainWindow.document.head){return q("link",e)},t.createCSSRule=function e(t,i,s=Y()){if(s&&i){s.sheet?.insertRule(`${t} {${i}}`,0);for(const r of j.get(s)??[])e(t,i,r)}},t.removeCSSRulesContainingSelector=function e(t,i=Y()){if(!i)return;const s=Z(i),r=[];for(let e=0;e=0;e--)i.sheet?.deleteRule(r[e]);for(const s of j.get(i)??[])e(t,s)},t.isHTMLElement=Q,t.isHTMLAnchorElement=function(e){return e instanceof HTMLAnchorElement||e instanceof(0,t.getWindow)(e).HTMLAnchorElement},t.isHTMLSpanElement=function(e){return e instanceof HTMLSpanElement||e instanceof(0,t.getWindow)(e).HTMLSpanElement},t.isHTMLTextAreaElement=function(e){return e instanceof HTMLTextAreaElement||e instanceof(0,t.getWindow)(e).HTMLTextAreaElement},t.isHTMLInputElement=function(e){return e instanceof HTMLInputElement||e instanceof(0,t.getWindow)(e).HTMLInputElement},t.isHTMLButtonElement=function(e){return e instanceof HTMLButtonElement||e instanceof(0,t.getWindow)(e).HTMLButtonElement},t.isHTMLDivElement=function(e){return e instanceof HTMLDivElement||e instanceof(0,t.getWindow)(e).HTMLDivElement},t.isSVGElement=function(e){return e instanceof SVGElement||e instanceof(0,t.getWindow)(e).SVGElement},t.isMouseEvent=function(e){return e instanceof MouseEvent||e instanceof(0,t.getWindow)(e).MouseEvent},t.isKeyboardEvent=function(e){return e instanceof KeyboardEvent||e instanceof(0,t.getWindow)(e).KeyboardEvent},t.isPointerEvent=function(e){return e instanceof PointerEvent||e instanceof(0,t.getWindow)(e).PointerEvent},t.isDragEvent=function(e){return e instanceof DragEvent||e instanceof(0,t.getWindow)(e).DragEvent},t.isEventLike=function(e){const t=e;return!(!t||"function"!=typeof t.preventDefault||"function"!=typeof t.stopPropagation)},t.saveParentsScrollTop=function(e){const t=[];for(let i=0;e&&e.nodeType===e.ELEMENT_NODE;i++)t[i]=e.scrollTop,e=e.parentNode;return t},t.restoreParentsScrollTop=function(e,t){for(let i=0;e&&e.nodeType===e.ELEMENT_NODE;i++)e.scrollTop!==t[i]&&(e.scrollTop=t[i]),e=e.parentNode},t.trackFocus=function(e){return new ee(e)},t.after=function(e,t){return e.after(t),t},t.append=te,t.prepend=function(e,t){return e.insertBefore(t,e.firstChild),t},t.reset=function(e,...t){e.innerText="",te(e,...t)},t.$=ne,t.join=function(e,t){const i=[];return e.forEach(((e,s)=>{s>0&&(t instanceof Node?i.push(t.cloneNode()):i.push(document.createTextNode(t))),i.push(e)})),i},t.setVisibility=function(e,...t){e?oe(...t):ae(...t)},t.show=oe,t.hide=ae,t.removeTabIndexAndUpdateFocus=function(e){if(e&&e.hasAttribute("tabIndex")){if(e.ownerDocument.activeElement===e){const t=function(e){for(;e&&e.nodeType===e.ELEMENT_NODE;){if(Q(e)&&e.hasAttribute("tabIndex"))return e;e=e.parentNode}return null}(e.parentElement);t?.focus()}e.removeAttribute("tabindex")}},t.finalHandler=function(e){return t=>{t.preventDefault(),t.stopPropagation(),e(t)}},t.domContentLoaded=function(e){return new Promise((t=>{if("complete"===e.document.readyState||e.document&&null!==e.document.body)t(void 0);else{const i=()=>{e.window.removeEventListener("DOMContentLoaded",i,!1),t()};e.window.addEventListener("DOMContentLoaded",i,!1)}}))},t.computeScreenAwareSize=function(e,t){const i=e.devicePixelRatio*t;return Math.max(1,Math.floor(i))/e.devicePixelRatio},t.windowOpenNoOpener=function(e){v.mainWindow.open(e,"_blank","noopener")},t.windowOpenPopup=function(e){const t=Math.floor(v.mainWindow.screenLeft+v.mainWindow.innerWidth/2-le/2),i=Math.floor(v.mainWindow.screenTop+v.mainWindow.innerHeight/2-he/2);v.mainWindow.open(e,"_blank",`width=${le},height=${he},top=${i},left=${t}`)},t.windowOpenWithSuccess=function(e,t=!0){const i=v.mainWindow.open();return!!i&&(t&&(i.opener=null),i.location.href=e,!0)},t.animate=function(e,i){const s=()=>{i(),r=(0,t.scheduleAtNextAnimationFrame)(e,s)};let r=(0,t.scheduleAtNextAnimationFrame)(e,s);return(0,p.toDisposable)((()=>r.dispose()))},t.asCSSPropertyValue=function(e){return`'${e.replace(/'/g,"%27")}'`},t.asCssValueWithDefault=function e(t,i){if(void 0!==t){const s=t.match(/^\s*var\((.+)\)$/);if(s){const t=s[1].split(",",2);return 2===t.length&&(i=e(t[1].trim(),i)),`var(${t[0]}, ${i})`}return t}return i},t.detectFullscreen=function(e){return e.document.fullscreenElement||e.document.webkitFullscreenElement||e.document.webkitIsFullScreen?{mode:ce.DOCUMENT,guess:!1}:e.innerHeight===e.screen.height?{mode:ce.BROWSER,guess:!1}:(g.isMacintosh||g.isLinux)&&e.outerHeight===e.screen.height&&e.outerWidth===e.screen.width?{mode:ce.BROWSER,guess:!0}:null},t.multibyteAwareBtoa=function(e){return btoa(function(e){const t=new Uint16Array(e.length);for(let i=0;i0&&(o.className=a.join(" "));const l={};if(r.groups.name&&(l[r.groups.name]=o),s)for(const e of s)Q(e)?o.appendChild(e):"string"==typeof e?o.append(e):"root"in e&&(Object.assign(l,e),o.appendChild(e.root));for(const[e,t]of Object.entries(i))if("className"!==e)if("style"===e)for(const[e,i]of Object.entries(t))o.style.setProperty(fe(e),"number"==typeof i?i+"px":""+i);else"tabIndex"===e?o.tabIndex=t:o.setAttribute(fe(e),t.toString());return l.root=o,l},t.svgElem=function(e,...t){let i,s;Array.isArray(t[0])?(i={},s=t[0]):(i=t[0]||{},s=t[1]);const r=_e.exec(e);if(!r||!r.groups)throw new Error("Bad use of h");const n=r.groups.tag||"div",o=document.createElementNS("http://www.w3.org/2000/svg",n);r.groups.id&&(o.id=r.groups.id);const a=[];if(r.groups.class)for(const e of r.groups.class.split("."))""!==e&&a.push(e);if(void 0!==i.className)for(const e of i.className.split("."))""!==e&&a.push(e);a.length>0&&(o.className=a.join(" "));const l={};if(r.groups.name&&(l[r.groups.name]=o),s)for(const e of s)Q(e)?o.appendChild(e):"string"==typeof e?o.append(e):"root"in e&&(Object.assign(l,e),o.appendChild(e.root));for(const[e,t]of Object.entries(i))if("className"!==e)if("style"===e)for(const[e,i]of Object.entries(t))o.style.setProperty(fe(e),"number"==typeof i?i+"px":""+i);else"tabIndex"===e?o.tabIndex=t:o.setAttribute(fe(e),t.toString());return l.root=o,l},t.copyAttributes=pe,t.trackAttributes=function(e,i,s){pe(e,i,s);const r=new p.DisposableStore;return r.add(t.sharedMutationObserver.observe(e,r,{attributes:!0,attributeFilter:s})((t=>{for(const s of t)"attributes"===s.type&&s.attributeName&&ge(e,i,s.attributeName)}))),r};const a=o(i(4333)),l=i(7745),h=i(5394),c=i(5964),d=i(1758),u=i(9807),_=o(i(802)),f=i(7883),p=i(7150),g=o(i(8163)),m=i(6304),v=i(4693),S=i(7704);s=function(){const e=new Map;(0,v.ensureCodeWindow)(v.mainWindow,1);const i={window:v.mainWindow,disposables:new p.DisposableStore};e.set(v.mainWindow.vscodeWindowId,i);const s=new _.Emitter,r=new _.Emitter,n=new _.Emitter;return{onDidRegisterWindow:s.event,onWillUnregisterWindow:n.event,onDidUnregisterWindow:r.event,registerWindow(i){if(e.has(i.vscodeWindowId))return p.Disposable.None;const o=new p.DisposableStore,a={window:i,disposables:o.add(new p.DisposableStore)};return e.set(i.vscodeWindowId,a),o.add((0,p.toDisposable)((()=>{e.delete(i.vscodeWindowId),r.fire(i)}))),o.add(C(i,t.EventType.BEFORE_UNLOAD,(()=>{n.fire(i)}))),s.fire(a),o},getWindows:()=>e.values(),getWindowsCount:()=>e.size,getWindowId:e=>e.vscodeWindowId,hasWindow:t=>e.has(t),getWindowById:function(t,s){return("number"==typeof t?e.get(t):void 0)??(s?i:void 0)},getWindow(e){const t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView.window;const i=e;return i?.view?i.view.window:v.mainWindow},getDocument(e){const i=e;return(0,t.getWindow)(i).document}}}(),t.registerWindow=s.registerWindow,t.getWindow=s.getWindow,t.getDocument=s.getDocument,t.getWindows=s.getWindows,t.getWindowsCount=s.getWindowsCount,t.getWindowId=s.getWindowId,t.getWindowById=s.getWindowById,t.hasWindow=s.hasWindow,t.onDidRegisterWindow=s.onDidRegisterWindow,t.onWillUnregisterWindow=s.onWillUnregisterWindow,t.onDidUnregisterWindow=s.onDidUnregisterWindow;class b{constructor(e,t,i,s){this._node=e,this._type=t,this._handler=i,this._options=s||!1,this._node.addEventListener(this._type,this._handler,this._options)}dispose(){this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function C(e,t,i,s){return new b(e,t,i,s)}function y(e,t){return function(i){return t(new c.StandardMouseEvent(e,i))}}function w(e,i,s){return C(e,g.isIOS&&l.BrowserFeatures.pointerEvents?t.EventType.POINTER_DOWN:t.EventType.MOUSE_DOWN,i,s)}function E(e,i,s){return C(e,g.isIOS&&l.BrowserFeatures.pointerEvents?t.EventType.POINTER_UP:t.EventType.MOUSE_UP,i,s)}t.addStandardDisposableListener=function(e,i,s,r){let n=s;return"click"===i||"mousedown"===i||"contextmenu"===i?n=y((0,t.getWindow)(e),s):"keydown"!==i&&"keypress"!==i&&"keyup"!==i||(n=function(e){return function(t){return e(new h.StandardKeyboardEvent(t))}}(s)),C(e,i,n,r)},t.addStandardDisposableGenericMouseDownListener=function(e,i,s){return w(e,y((0,t.getWindow)(e),i),s)},t.addStandardDisposableGenericMouseUpListener=function(e,i,s){return E(e,y((0,t.getWindow)(e),i),s)};class D extends d.AbstractIdleValue{constructor(e,t){super(e,t)}}t.WindowIdleValue=D;class L extends d.IntervalTimer{constructor(e){super(),this.defaultTarget=e&&(0,t.getWindow)(e)}cancelAndSet(e,t,i){return super.cancelAndSet(e,t,i??this.defaultTarget)}}t.WindowIntervalTimer=L;class R{constructor(e,t=0){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){(0,u.onUnexpectedError)(e)}}static sort(e,t){return t.priority-e.priority}}!function(){const e=new Map,i=new Map,s=new Map,r=new Map;t.scheduleAtNextAnimationFrame=(n,o,a=0)=>{const l=(0,t.getWindowId)(n),h=new R(o,a);let c=e.get(l);return c||(c=[],e.set(l,c)),c.push(h),s.get(l)||(s.set(l,!0),n.requestAnimationFrame((()=>(t=>{s.set(t,!1);const n=e.get(t)??[];for(i.set(t,n),e.set(t,[]),r.set(t,!0);n.length>0;)n.sort(R.sort),n.shift().execute();r.set(t,!1)})(l)))),h},t.runAtThisOrScheduleAtNextAnimationFrame=(e,s,n)=>{const o=(0,t.getWindowId)(e);if(r.get(o)){const e=new R(s,n);let t=i.get(o);return t||(t=[],i.set(o,t)),t.push(e),e}return(0,t.scheduleAtNextAnimationFrame)(e,s,n)}}();const A=function(e,t){return t};class T extends p.Disposable{constructor(e,t,i,s=A,r=8){super();let n=null,o=0;const a=this._register(new d.TimeoutTimer),l=()=>{o=(new Date).getTime(),i(n),n=null};this._register(C(e,t,(e=>{n=s(n,e);const t=(new Date).getTime()-o;t>=r?(a.cancel(),l()):a.setIfNotSet(l,r-t)})))}}function k(e){return(0,t.getWindow)(e).getComputedStyle(e,null)}class M{static convertToPixels(e,t){return parseFloat(t)||0}static getDimension(e,t,i){const s=k(e),r=s?s.getPropertyValue(t):"0";return M.convertToPixels(e,r)}static getBorderLeftWidth(e){return M.getDimension(e,"border-left-width","borderLeftWidth")}static getBorderRightWidth(e){return M.getDimension(e,"border-right-width","borderRightWidth")}static getBorderTopWidth(e){return M.getDimension(e,"border-top-width","borderTopWidth")}static getBorderBottomWidth(e){return M.getDimension(e,"border-bottom-width","borderBottomWidth")}static getPaddingLeft(e){return M.getDimension(e,"padding-left","paddingLeft")}static getPaddingRight(e){return M.getDimension(e,"padding-right","paddingRight")}static getPaddingTop(e){return M.getDimension(e,"padding-top","paddingTop")}static getPaddingBottom(e){return M.getDimension(e,"padding-bottom","paddingBottom")}static getMarginLeft(e){return M.getDimension(e,"margin-left","marginLeft")}static getMarginTop(e){return M.getDimension(e,"margin-top","marginTop")}static getMarginRight(e){return M.getDimension(e,"margin-right","marginRight")}static getMarginBottom(e){return M.getDimension(e,"margin-bottom","marginBottom")}}class O{static{this.None=new O(0,0)}constructor(e,t){this.width=e,this.height=t}with(e=this.width,t=this.height){return e!==this.width||t!==this.height?new O(e,t):this}static is(e){return"object"==typeof e&&"number"==typeof e.height&&"number"==typeof e.width}static lift(e){return e instanceof O?e:new O(e.width,e.height)}static equals(e,t){return e===t||!(!e||!t)&&e.width===t.width&&e.height===t.height}}function I(e){let t=e.offsetParent,i=e.offsetTop,s=e.offsetLeft;for(;null!==(e=e.parentNode)&&e!==e.ownerDocument.body&&e!==e.ownerDocument.documentElement;){i-=e.scrollTop;const r=W(e)?null:k(e);r&&(s-="rtl"!==r.direction?e.scrollLeft:-e.scrollLeft),e===t&&(s+=M.getBorderLeftWidth(e),i+=M.getBorderTopWidth(e),i+=e.offsetTop,s+=e.offsetLeft,t=e.offsetParent)}return{left:s,top:i}}function P(e){const t=M.getMarginLeft(e)+M.getMarginRight(e);return e.offsetWidth+t}function x(e){const t=M.getMarginLeft(e)+M.getMarginRight(e);return e.scrollWidth+t}function B(e,t){return Boolean(t?.contains(e))}t.Dimension=O;const N="parentFlowToElementId";function U(e){const t=e.dataset[N];return"string"==typeof t?e.ownerDocument.getElementById(t):null}function F(e,t,i){for(;e&&e.nodeType===e.ELEMENT_NODE;){if(e.classList.contains(t))return e;if(i)if("string"==typeof i){if(e.classList.contains(i))return null}else if(e===i)return null;e=e.parentNode}return null}function W(e){return e&&!!e.host&&!!e.mode}function H(e){for(;e.parentNode;){if(e===e.ownerDocument?.body)return null;e=e.parentNode}return W(e)?e:null}function K(){let e=z().activeElement;for(;e?.shadowRoot;)e=e.shadowRoot.activeElement;return e}function z(){return(0,t.getWindowsCount)()<=1?v.mainWindow.document:Array.from((0,t.getWindows)()).map((({window:e})=>e.document)).find((e=>e.hasFocus()))??v.mainWindow.document}const j=new Map;class ${constructor(){this._currentCssStyle="",this._styleSheet=void 0}setStyle(e){e!==this._currentCssStyle&&(this._currentCssStyle=e,this._styleSheet?this._styleSheet.innerText=e:this._styleSheet=V(v.mainWindow.document.head,(t=>t.innerText=e)))}dispose(){this._styleSheet&&(this._styleSheet.remove(),this._styleSheet=void 0)}}function V(e=v.mainWindow.document.head,i,s){const r=document.createElement("style");if(r.type="text/css",r.media="screen",i?.(r),e.appendChild(r),s&&s.add((0,p.toDisposable)((()=>r.remove()))),e===v.mainWindow.document.head){const e=new Set;j.set(r,e);for(const{window:i,disposables:n}of(0,t.getWindows)()){if(i===v.mainWindow)continue;const t=n.add(G(r,e,i));s?.add(t)}}return r}function G(e,i,s){const r=new p.DisposableStore,n=e.cloneNode(!0);s.document.head.appendChild(n),r.add((0,p.toDisposable)((()=>n.remove())));for(const t of Z(e))n.sheet?.insertRule(t.cssText,n.sheet?.cssRules.length);return r.add(t.sharedMutationObserver.observe(e,r,{childList:!0})((()=>{n.textContent=e.textContent}))),i.add(n),r.add((0,p.toDisposable)((()=>i.delete(n)))),r}function q(e,t=v.mainWindow.document.head){const i=document.createElement(e);return t.appendChild(i),i}t.sharedMutationObserver=new class{constructor(){this.mutationObservers=new Map}observe(e,t,i){let s=this.mutationObservers.get(e);s||(s=new Map,this.mutationObservers.set(e,s));const r=(0,m.hash)(i);let n=s.get(r);if(n)n.users+=1;else{const o=new _.Emitter,a=new MutationObserver((e=>o.fire(e)));a.observe(e,i);const l=n={users:1,observer:a,onDidMutate:o.event};t.add((0,p.toDisposable)((()=>{l.users-=1,0===l.users&&(o.dispose(),a.disconnect(),s?.delete(r),0===s?.size&&this.mutationObservers.delete(e))}))),s.set(r,n)}return n.onDidMutate}};let X=null;function Y(){return X||(X=V()),X}function Z(e){return e?.sheet?.rules?e.sheet.rules:e?.sheet?.cssRules?e.sheet.cssRules:[]}function J(e){return"string"==typeof e.selectorText}function Q(e){return e instanceof HTMLElement||e instanceof(0,t.getWindow)(e).HTMLElement}t.EventType={CLICK:"click",AUXCLICK:"auxclick",DBLCLICK:"dblclick",MOUSE_UP:"mouseup",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_MOVE:"mousemove",MOUSE_OUT:"mouseout",MOUSE_ENTER:"mouseenter",MOUSE_LEAVE:"mouseleave",MOUSE_WHEEL:"wheel",POINTER_UP:"pointerup",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_LEAVE:"pointerleave",CONTEXT_MENU:"contextmenu",WHEEL:"wheel",KEY_DOWN:"keydown",KEY_PRESS:"keypress",KEY_UP:"keyup",LOAD:"load",BEFORE_UNLOAD:"beforeunload",UNLOAD:"unload",PAGE_SHOW:"pageshow",PAGE_HIDE:"pagehide",PASTE:"paste",ABORT:"abort",ERROR:"error",RESIZE:"resize",SCROLL:"scroll",FULLSCREEN_CHANGE:"fullscreenchange",WK_FULLSCREEN_CHANGE:"webkitfullscreenchange",SELECT:"select",CHANGE:"change",SUBMIT:"submit",RESET:"reset",FOCUS:"focus",FOCUS_IN:"focusin",FOCUS_OUT:"focusout",BLUR:"blur",INPUT:"input",STORAGE:"storage",DRAG_START:"dragstart",DRAG:"drag",DRAG_ENTER:"dragenter",DRAG_LEAVE:"dragleave",DRAG_OVER:"dragover",DROP:"drop",DRAG_END:"dragend",ANIMATION_START:a.isWebKit?"webkitAnimationStart":"animationstart",ANIMATION_END:a.isWebKit?"webkitAnimationEnd":"animationend",ANIMATION_ITERATION:a.isWebKit?"webkitAnimationIteration":"animationiteration"},t.EventHelper={stop:(e,t)=>(e.preventDefault(),t&&e.stopPropagation(),e)};class ee extends p.Disposable{static hasFocusWithin(e){if(Q(e)){const t=H(e);return B(t?t.activeElement:e.ownerDocument.activeElement,e)}{const t=e;return B(t.document.activeElement,t.document)}}constructor(e){super(),this._onDidFocus=this._register(new _.Emitter),this.onDidFocus=this._onDidFocus.event,this._onDidBlur=this._register(new _.Emitter),this.onDidBlur=this._onDidBlur.event;let i=ee.hasFocusWithin(e),s=!1;const r=()=>{s=!1,i||(i=!0,this._onDidFocus.fire())},n=()=>{i&&(s=!0,(Q(e)?(0,t.getWindow)(e):e).setTimeout((()=>{s&&(s=!1,i=!1,this._onDidBlur.fire())}),0))};this._refreshStateHandler=()=>{ee.hasFocusWithin(e)!==i&&(i?n():r())},this._register(C(e,t.EventType.FOCUS,r,!0)),this._register(C(e,t.EventType.BLUR,n,!0)),Q(e)&&(this._register(C(e,t.EventType.FOCUS_IN,(()=>this._refreshStateHandler()))),this._register(C(e,t.EventType.FOCUS_OUT,(()=>this._refreshStateHandler()))))}refreshState(){this._refreshStateHandler()}}function te(e,...t){if(e.append(...t),1===t.length&&"string"!=typeof t[0])return t[0]}const ie=/([\w\-]+)?(#([\w\-]+))?((\.([\w\-]+))*)/;var se;function re(e,t,i,...s){const r=ie.exec(t);if(!r)throw new Error("Bad use of emmet");const n=r[1]||"div";let o;return o=e!==se.HTML?document.createElementNS(e,n):document.createElement(n),r[3]&&(o.id=r[3]),r[4]&&(o.className=r[4].replace(/\./g," ").trim()),i&&Object.entries(i).forEach((([e,t])=>{void 0!==t&&(/^on\w+$/.test(e)?o[e]=t:"selected"===e?t&&o.setAttribute(e,"true"):o.setAttribute(e,t))})),o.append(...s),o}function ne(e,t,...i){return re(se.HTML,e,t,...i)}function oe(...e){for(const t of e)t.style.display="",t.removeAttribute("aria-hidden")}function ae(...e){for(const t of e)t.style.display="none",t.setAttribute("aria-hidden","true")}!function(e){e.HTML="http://www.w3.org/1999/xhtml",e.SVG="http://www.w3.org/2000/svg"}(se||(t.Namespace=se={})),ne.SVG=function(e,t,...i){return re(se.SVG,e,t,...i)};const le=780,he=640;var ce;!function(e){e[e.DOCUMENT=1]="DOCUMENT",e[e.BROWSER=2]="BROWSER"}(ce||(t.DetectedFullscreenMode=ce={}));class de extends _.Emitter{constructor(){super(),this._subscriptions=new p.DisposableStore,this._keyStatus={altKey:!1,shiftKey:!1,ctrlKey:!1,metaKey:!1},this._subscriptions.add(_.Event.runAndSubscribe(t.onDidRegisterWindow,(({window:e,disposables:t})=>this.registerListeners(e,t)),{window:v.mainWindow,disposables:this._subscriptions}))}registerListeners(e,t){t.add(C(e,"keydown",(e=>{if(e.defaultPrevented)return;const t=new h.StandardKeyboardEvent(e);if(t.keyCode!==f.KeyCode.Alt||!e.repeat){if(e.altKey&&!this._keyStatus.altKey)this._keyStatus.lastKeyPressed="alt";else if(e.ctrlKey&&!this._keyStatus.ctrlKey)this._keyStatus.lastKeyPressed="ctrl";else if(e.metaKey&&!this._keyStatus.metaKey)this._keyStatus.lastKeyPressed="meta";else if(e.shiftKey&&!this._keyStatus.shiftKey)this._keyStatus.lastKeyPressed="shift";else{if(t.keyCode===f.KeyCode.Alt)return;this._keyStatus.lastKeyPressed=void 0}this._keyStatus.altKey=e.altKey,this._keyStatus.ctrlKey=e.ctrlKey,this._keyStatus.metaKey=e.metaKey,this._keyStatus.shiftKey=e.shiftKey,this._keyStatus.lastKeyPressed&&(this._keyStatus.event=e,this.fire(this._keyStatus))}}),!0)),t.add(C(e,"keyup",(e=>{e.defaultPrevented||(!e.altKey&&this._keyStatus.altKey?this._keyStatus.lastKeyReleased="alt":!e.ctrlKey&&this._keyStatus.ctrlKey?this._keyStatus.lastKeyReleased="ctrl":!e.metaKey&&this._keyStatus.metaKey?this._keyStatus.lastKeyReleased="meta":!e.shiftKey&&this._keyStatus.shiftKey?this._keyStatus.lastKeyReleased="shift":this._keyStatus.lastKeyReleased=void 0,this._keyStatus.lastKeyPressed!==this._keyStatus.lastKeyReleased&&(this._keyStatus.lastKeyPressed=void 0),this._keyStatus.altKey=e.altKey,this._keyStatus.ctrlKey=e.ctrlKey,this._keyStatus.metaKey=e.metaKey,this._keyStatus.shiftKey=e.shiftKey,this._keyStatus.lastKeyReleased&&(this._keyStatus.event=e,this.fire(this._keyStatus)))}),!0)),t.add(C(e.document.body,"mousedown",(()=>{this._keyStatus.lastKeyPressed=void 0}),!0)),t.add(C(e.document.body,"mouseup",(()=>{this._keyStatus.lastKeyPressed=void 0}),!0)),t.add(C(e.document.body,"mousemove",(e=>{e.buttons&&(this._keyStatus.lastKeyPressed=void 0)}),!0)),t.add(C(e,"blur",(()=>{this.resetKeyStatus()})))}get keyStatus(){return this._keyStatus}get isModifierPressed(){return this._keyStatus.altKey||this._keyStatus.ctrlKey||this._keyStatus.metaKey||this._keyStatus.shiftKey}resetKeyStatus(){this.doResetKeyStatus(),this.fire(this._keyStatus)}doResetKeyStatus(){this._keyStatus={altKey:!1,shiftKey:!1,ctrlKey:!1,metaKey:!1}}static getInstance(){return de.instance||(de.instance=new de),de.instance}dispose(){super.dispose(),this._subscriptions.dispose()}}t.ModifierKeyEmitter=de;class ue extends p.Disposable{constructor(e,t){super(),this.element=e,this.callbacks=t,this.counter=0,this.dragStartTime=0,this.registerListeners()}registerListeners(){this.callbacks.onDragStart&&this._register(C(this.element,t.EventType.DRAG_START,(e=>{this.callbacks.onDragStart?.(e)}))),this.callbacks.onDrag&&this._register(C(this.element,t.EventType.DRAG,(e=>{this.callbacks.onDrag?.(e)}))),this._register(C(this.element,t.EventType.DRAG_ENTER,(e=>{this.counter++,this.dragStartTime=e.timeStamp,this.callbacks.onDragEnter?.(e)}))),this._register(C(this.element,t.EventType.DRAG_OVER,(e=>{e.preventDefault(),this.callbacks.onDragOver?.(e,e.timeStamp-this.dragStartTime)}))),this._register(C(this.element,t.EventType.DRAG_LEAVE,(e=>{this.counter--,0===this.counter&&(this.dragStartTime=0,this.callbacks.onDragLeave?.(e))}))),this._register(C(this.element,t.EventType.DRAG_END,(e=>{this.counter=0,this.dragStartTime=0,this.callbacks.onDragEnd?.(e)}))),this._register(C(this.element,t.EventType.DROP,(e=>{this.counter=0,this.dragStartTime=0,this.callbacks.onDrop?.(e)})))}}t.DragAndDropObserver=ue;const _e=/(?[\w\-]+)?(?:#(?[\w\-]+))?(?(?:\.(?:[\w\-]+))*)(?:@(?(?:[\w\_])+))?/;function fe(e){return e.replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase()}function pe(e,t,i){for(const{name:s,value:r}of e.attributes)i&&!i.includes(s)||t.setAttribute(s,r)}function ge(e,t,i){const s=e.getAttribute(i);s?t.setAttribute(i,s):t.removeAttribute(i)}t.SafeTriangle=class{constructor(e,t,i){this.originX=e,this.originY=t,this.triangles=[];const{top:s,left:r,right:n,bottom:o}=i.getBoundingClientRect(),a=this.triangles;let l=0;a[l++]=r,a[l++]=s,a[l++]=n,a[l++]=s,a[l++]=r,a[l++]=s,a[l++]=r,a[l++]=o,a[l++]=n,a[l++]=s,a[l++]=n,a[l++]=o,a[l++]=r,a[l++]=o,a[l++]=n,a[l++]=o}contains(e,t){const{triangles:i,originX:s,originY:r}=this;for(let n=0;n<4;n++)if((0,S.isPointWithinTriangle)(e,t,s,r,i[2*n],i[2*n+1],i[2*n+2],i[2*n+3]))return!0;return!1}}},9675:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FastDomNode=void 0,t.createFastDomNode=function(e){return new i(e)};class i{constructor(e){this.domNode=e,this._maxWidth="",this._width="",this._height="",this._top="",this._left="",this._bottom="",this._right="",this._paddingTop="",this._paddingLeft="",this._paddingBottom="",this._paddingRight="",this._fontFamily="",this._fontWeight="",this._fontSize="",this._fontStyle="",this._fontFeatureSettings="",this._fontVariationSettings="",this._textDecoration="",this._lineHeight="",this._letterSpacing="",this._className="",this._display="",this._position="",this._visibility="",this._color="",this._backgroundColor="",this._layerHint=!1,this._contain="none",this._boxShadow=""}setMaxWidth(e){const t=s(e);this._maxWidth!==t&&(this._maxWidth=t,this.domNode.style.maxWidth=this._maxWidth)}setWidth(e){const t=s(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){const t=s(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){const t=s(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){const t=s(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){const t=s(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){const t=s(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setPaddingTop(e){const t=s(e);this._paddingTop!==t&&(this._paddingTop=t,this.domNode.style.paddingTop=this._paddingTop)}setPaddingLeft(e){const t=s(e);this._paddingLeft!==t&&(this._paddingLeft=t,this.domNode.style.paddingLeft=this._paddingLeft)}setPaddingBottom(e){const t=s(e);this._paddingBottom!==t&&(this._paddingBottom=t,this.domNode.style.paddingBottom=this._paddingBottom)}setPaddingRight(e){const t=s(e);this._paddingRight!==t&&(this._paddingRight=t,this.domNode.style.paddingRight=this._paddingRight)}setFontFamily(e){this._fontFamily!==e&&(this._fontFamily=e,this.domNode.style.fontFamily=this._fontFamily)}setFontWeight(e){this._fontWeight!==e&&(this._fontWeight=e,this.domNode.style.fontWeight=this._fontWeight)}setFontSize(e){const t=s(e);this._fontSize!==t&&(this._fontSize=t,this.domNode.style.fontSize=this._fontSize)}setFontStyle(e){this._fontStyle!==e&&(this._fontStyle=e,this.domNode.style.fontStyle=this._fontStyle)}setFontFeatureSettings(e){this._fontFeatureSettings!==e&&(this._fontFeatureSettings=e,this.domNode.style.fontFeatureSettings=this._fontFeatureSettings)}setFontVariationSettings(e){this._fontVariationSettings!==e&&(this._fontVariationSettings=e,this.domNode.style.fontVariationSettings=this._fontVariationSettings)}setTextDecoration(e){this._textDecoration!==e&&(this._textDecoration=e,this.domNode.style.textDecoration=this._textDecoration)}setLineHeight(e){const t=s(e);this._lineHeight!==t&&(this._lineHeight=t,this.domNode.style.lineHeight=this._lineHeight)}setLetterSpacing(e){const t=s(e);this._letterSpacing!==t&&(this._letterSpacing=t,this.domNode.style.letterSpacing=this._letterSpacing)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setDisplay(e){this._display!==e&&(this._display=e,this.domNode.style.display=this._display)}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setVisibility(e){this._visibility!==e&&(this._visibility=e,this.domNode.style.visibility=this._visibility)}setColor(e){this._color!==e&&(this._color=e,this.domNode.style.color=this._color)}setBackgroundColor(e){this._backgroundColor!==e&&(this._backgroundColor=e,this.domNode.style.backgroundColor=this._backgroundColor)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,this.domNode.style.transform=this._layerHint?"translate3d(0px, 0px, 0px)":"")}setBoxShadow(e){this._boxShadow!==e&&(this._boxShadow=e,this.domNode.style.boxShadow=e)}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}removeAttribute(e){this.domNode.removeAttribute(e)}appendChild(e){this.domNode.appendChild(e.domNode)}removeChild(e){this.domNode.removeChild(e.domNode)}}function s(e){return"number"==typeof e?`${e}px`:e}t.FastDomNode=i},8328:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.GlobalPointerMoveMonitor=void 0;const o=n(i(7093)),a=i(7150);t.GlobalPointerMoveMonitor=class{constructor(){this._hooks=new a.DisposableStore,this._pointerMoveCallback=null,this._onStopCallback=null}dispose(){this.stopMonitoring(!1),this._hooks.dispose()}stopMonitoring(e,t){if(!this.isMonitoring())return;this._hooks.clear(),this._pointerMoveCallback=null;const i=this._onStopCallback;this._onStopCallback=null,e&&i&&i(t)}isMonitoring(){return!!this._pointerMoveCallback}startMonitoring(e,t,i,s,r){this.isMonitoring()&&this.stopMonitoring(!1),this._pointerMoveCallback=s,this._onStopCallback=r;let n=e;try{e.setPointerCapture(t),this._hooks.add((0,a.toDisposable)((()=>{try{e.releasePointerCapture(t)}catch(e){}})))}catch(t){n=o.getWindow(e)}this._hooks.add(o.addDisposableListener(n,o.EventType.POINTER_MOVE,(e=>{e.buttons===i?(e.preventDefault(),this._pointerMoveCallback(e)):this.stopMonitoring(!0)}))),this._hooks.add(o.addDisposableListener(n,o.EventType.POINTER_UP,(e=>this.stopMonitoring(!0))))}}},6609:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IframeUtils=void 0,t.parentOriginHash=async function(e,t){if(!crypto.subtle)throw new Error("'crypto.subtle' is not available so webviews will not work. This is likely because the editor is not running in a secure context (https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).");const i=JSON.stringify({parentOrigin:e,salt:t}),s=(new TextEncoder).encode(i);return function(e){const t=Array.from(new Uint8Array(e)).map((e=>e.toString(16).padStart(2,"0"))).join("");return BigInt(`0x${t}`).toString(32).padStart(52,"0")}(await crypto.subtle.digest("sha-256",s))};const i=new WeakMap;function s(e){if(!e.parent||e.parent===e)return null;try{const t=e.location,i=e.parent.location;if("null"!==t.origin&&"null"!==i.origin&&t.origin!==i.origin)return null}catch(e){return null}return e.parent}t.IframeUtils=class{static getSameOriginWindowChain(e){let t=i.get(e);if(!t){t=[],i.set(e,t);let r,n=e;do{r=s(n),r?t.push({window:new WeakRef(n),iframeElement:n.frameElement||null}):t.push({window:new WeakRef(n),iframeElement:null}),n=r}while(n)}return t.slice(0)}static getPositionOfChildWindowRelativeToAncestorWindow(e,t){if(!t||e===t)return{top:0,left:0};let i=0,s=0;const r=this.getSameOriginWindowChain(e);for(const e of r){const r=e.window.deref();if(i+=r?.scrollY??0,s+=r?.scrollX??0,r===t)break;if(!e.iframeElement)break;const n=e.iframeElement.getBoundingClientRect();i+=n.top,s+=n.left}return{top:i,left:s}}}},5394:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.StandardKeyboardEvent=void 0,t.printKeyboardEvent=function(e){const t=[];return e.ctrlKey&&t.push("ctrl"),e.shiftKey&&t.push("shift"),e.altKey&&t.push("alt"),e.metaKey&&t.push("meta"),`modifiers: [${t.join(",")}], code: ${e.code}, keyCode: ${e.keyCode}, key: ${e.key}`},t.printStandardKeyboardEvent=function(e){const t=[];return e.ctrlKey&&t.push("ctrl"),e.shiftKey&&t.push("shift"),e.altKey&&t.push("alt"),e.metaKey&&t.push("meta"),`modifiers: [${t.join(",")}], code: ${e.code}, keyCode: ${e.keyCode} ('${a.KeyCodeUtils.toString(e.keyCode)}')`};const o=n(i(4333)),a=i(7883),l=i(2811),h=n(i(8163)),c=h.isMacintosh?a.KeyMod.WinCtrl:a.KeyMod.CtrlCmd,d=a.KeyMod.Alt,u=a.KeyMod.Shift,_=h.isMacintosh?a.KeyMod.CtrlCmd:a.KeyMod.WinCtrl;t.StandardKeyboardEvent=class{constructor(e){this._standardKeyboardEventBrand=!0;const t=e;this.browserEvent=t,this.target=t.target,this.ctrlKey=t.ctrlKey,this.shiftKey=t.shiftKey,this.altKey=t.altKey,this.metaKey=t.metaKey,this.altGraphKey=t.getModifierState?.("AltGraph"),this.keyCode=function(e){if(e.charCode){const t=String.fromCharCode(e.charCode).toUpperCase();return a.KeyCodeUtils.fromString(t)}const t=e.keyCode;if(3===t)return a.KeyCode.PauseBreak;if(o.isFirefox)switch(t){case 59:return a.KeyCode.Semicolon;case 60:if(h.isLinux)return a.KeyCode.IntlBackslash;break;case 61:return a.KeyCode.Equal;case 107:return a.KeyCode.NumpadAdd;case 109:return a.KeyCode.NumpadSubtract;case 173:return a.KeyCode.Minus;case 224:if(h.isMacintosh)return a.KeyCode.Meta}else if(o.isWebKit){if(h.isMacintosh&&93===t)return a.KeyCode.Meta;if(!h.isMacintosh&&92===t)return a.KeyCode.Meta}return a.EVENT_KEY_CODE_MAP[t]||a.KeyCode.Unknown}(t),this.code=t.code,this.ctrlKey=this.ctrlKey||this.keyCode===a.KeyCode.Ctrl,this.altKey=this.altKey||this.keyCode===a.KeyCode.Alt,this.shiftKey=this.shiftKey||this.keyCode===a.KeyCode.Shift,this.metaKey=this.metaKey||this.keyCode===a.KeyCode.Meta,this._asKeybinding=this._computeKeybinding(),this._asKeyCodeChord=this._computeKeyCodeChord()}preventDefault(){this.browserEvent&&this.browserEvent.preventDefault&&this.browserEvent.preventDefault()}stopPropagation(){this.browserEvent&&this.browserEvent.stopPropagation&&this.browserEvent.stopPropagation()}toKeyCodeChord(){return this._asKeyCodeChord}equals(e){return this._asKeybinding===e}_computeKeybinding(){let e=a.KeyCode.Unknown;this.keyCode!==a.KeyCode.Ctrl&&this.keyCode!==a.KeyCode.Shift&&this.keyCode!==a.KeyCode.Alt&&this.keyCode!==a.KeyCode.Meta&&(e=this.keyCode);let t=0;return this.ctrlKey&&(t|=c),this.altKey&&(t|=d),this.shiftKey&&(t|=u),this.metaKey&&(t|=_),t|=e,t}_computeKeyCodeChord(){let e=a.KeyCode.Unknown;return this.keyCode!==a.KeyCode.Ctrl&&this.keyCode!==a.KeyCode.Shift&&this.keyCode!==a.KeyCode.Alt&&this.keyCode!==a.KeyCode.Meta&&(e=this.keyCode),new l.KeyCodeChord(this.ctrlKey,this.shiftKey,this.altKey,this.metaKey,e)}}},5964:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.StandardWheelEvent=t.DragMouseEvent=t.StandardMouseEvent=void 0;const o=n(i(4333)),a=i(6609),l=n(i(8163));class h{constructor(e,t){this.timestamp=Date.now(),this.browserEvent=t,this.leftButton=0===t.button,this.middleButton=1===t.button,this.rightButton=2===t.button,this.buttons=t.buttons,this.target=t.target,this.detail=t.detail||1,"dblclick"===t.type&&(this.detail=2),this.ctrlKey=t.ctrlKey,this.shiftKey=t.shiftKey,this.altKey=t.altKey,this.metaKey=t.metaKey,"number"==typeof t.pageX?(this.posx=t.pageX,this.posy=t.pageY):(this.posx=t.clientX+this.target.ownerDocument.body.scrollLeft+this.target.ownerDocument.documentElement.scrollLeft,this.posy=t.clientY+this.target.ownerDocument.body.scrollTop+this.target.ownerDocument.documentElement.scrollTop);const i=a.IframeUtils.getPositionOfChildWindowRelativeToAncestorWindow(e,t.view);this.posx-=i.left,this.posy-=i.top}preventDefault(){this.browserEvent.preventDefault()}stopPropagation(){this.browserEvent.stopPropagation()}}t.StandardMouseEvent=h,t.DragMouseEvent=class extends h{constructor(e,t){super(e,t),this.dataTransfer=t.dataTransfer}},t.StandardWheelEvent=class{constructor(e,t=0,i=0){this.browserEvent=e||null,this.target=e?e.target||e.targetNode||e.srcElement:null,this.deltaY=i,this.deltaX=t;let s=!1;if(o.isChrome){const e=navigator.userAgent.match(/Chrome\/(\d+)/);s=(e?parseInt(e[1]):123)<=122}if(e){const t=e,i=e,r=e.view?.devicePixelRatio||1;if(void 0!==t.wheelDeltaY)this.deltaY=s?t.wheelDeltaY/(120*r):t.wheelDeltaY/120;else if(void 0!==i.VERTICAL_AXIS&&i.axis===i.VERTICAL_AXIS)this.deltaY=-i.detail/3;else if("wheel"===e.type){const t=e;t.deltaMode===t.DOM_DELTA_LINE?o.isFirefox&&!l.isMacintosh?this.deltaY=-e.deltaY/3:this.deltaY=-e.deltaY:this.deltaY=-e.deltaY/40}if(void 0!==t.wheelDeltaX)o.isSafari&&l.isWindows?this.deltaX=-t.wheelDeltaX/120:this.deltaX=s?t.wheelDeltaX/(120*r):t.wheelDeltaX/120;else if(void 0!==i.HORIZONTAL_AXIS&&i.axis===i.HORIZONTAL_AXIS)this.deltaX=-e.detail/3;else if("wheel"===e.type){const t=e;t.deltaMode===t.DOM_DELTA_LINE?o.isFirefox&&!l.isMacintosh?this.deltaX=-e.deltaX/3:this.deltaX=-e.deltaX:this.deltaX=-e.deltaX/40}0===this.deltaY&&0===this.deltaX&&e.wheelDelta&&(this.deltaY=s?e.wheelDelta/(120*r):e.wheelDelta/120)}}preventDefault(){this.browserEvent?.preventDefault()}stopPropagation(){this.browserEvent?.stopPropagation()}}},8594:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},o=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.Gesture=t.EventType=void 0;const a=o(i(7093)),l=i(4693),h=o(i(3058)),c=i(4838),d=i(802),u=i(7150),_=i(6317);var f;!function(e){e.Tap="-xterm-gesturetap",e.Change="-xterm-gesturechange",e.Start="-xterm-gesturestart",e.End="-xterm-gesturesend",e.Contextmenu="-xterm-gesturecontextmenu"}(f||(t.EventType=f={}));class p extends u.Disposable{static{this.SCROLL_FRICTION=-.005}static{this.HOLD_DELAY=700}static{this.CLEAR_TAP_COUNT_TIME=400}constructor(){super(),this.dispatched=!1,this.targets=new _.LinkedList,this.ignoreTargets=new _.LinkedList,this.activeTouches={},this.handle=null,this._lastSetTapCountTime=0,this._register(d.Event.runAndSubscribe(a.onDidRegisterWindow,(({window:e,disposables:t})=>{t.add(a.addDisposableListener(e.document,"touchstart",(e=>this.onTouchStart(e)),{passive:!1})),t.add(a.addDisposableListener(e.document,"touchend",(t=>this.onTouchEnd(e,t)))),t.add(a.addDisposableListener(e.document,"touchmove",(e=>this.onTouchMove(e)),{passive:!1}))}),{window:l.mainWindow,disposables:this._store}))}static addTarget(e){if(!p.isTouchDevice())return u.Disposable.None;p.INSTANCE||(p.INSTANCE=(0,u.markAsSingleton)(new p));const t=p.INSTANCE.targets.push(e);return(0,u.toDisposable)(t)}static ignoreTarget(e){if(!p.isTouchDevice())return u.Disposable.None;p.INSTANCE||(p.INSTANCE=(0,u.markAsSingleton)(new p));const t=p.INSTANCE.ignoreTargets.push(e);return(0,u.toDisposable)(t)}static isTouchDevice(){return"ontouchstart"in l.mainWindow||navigator.maxTouchPoints>0}dispose(){this.handle&&(this.handle.dispose(),this.handle=null),super.dispose()}onTouchStart(e){const t=Date.now();this.handle&&(this.handle.dispose(),this.handle=null);for(let i=0,s=e.targetTouches.length;i=p.HOLD_DELAY&&Math.abs(o.initialPageX-h.tail(o.rollingPageX))<30&&Math.abs(o.initialPageY-h.tail(o.rollingPageY))<30){const e=this.newGestureEvent(f.Contextmenu,o.initialTarget);e.pageX=h.tail(o.rollingPageX),e.pageY=h.tail(o.rollingPageY),this.dispatchEvent(e)}else if(1===s){const t=h.tail(o.rollingPageX),s=h.tail(o.rollingPageY),r=h.tail(o.rollingTimestamps)-o.rollingTimestamps[0],n=t-o.rollingPageX[0],a=s-o.rollingPageY[0],l=[...this.targets].filter((e=>o.initialTarget instanceof Node&&e.contains(o.initialTarget)));this.inertia(e,l,i,Math.abs(n)/r,n>0?1:-1,t,Math.abs(a)/r,a>0?1:-1,s)}this.dispatchEvent(this.newGestureEvent(f.End,o.initialTarget)),delete this.activeTouches[n.identifier]}this.dispatched&&(t.preventDefault(),t.stopPropagation(),this.dispatched=!1)}newGestureEvent(e,t){const i=document.createEvent("CustomEvent");return i.initEvent(e,!1,!0),i.initialTarget=t,i.tapCount=0,i}dispatchEvent(e){if(e.type===f.Tap){const t=(new Date).getTime();let i=0;i=t-this._lastSetTapCountTime>p.CLEAR_TAP_COUNT_TIME?1:2,this._lastSetTapCountTime=t,e.tapCount=i}else e.type!==f.Change&&e.type!==f.Contextmenu||(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(const t of this.ignoreTargets)if(t.contains(e.initialTarget))return;const t=[];for(const i of this.targets)if(i.contains(e.initialTarget)){let s=0,r=e.initialTarget;for(;r&&r!==i;)s++,r=r.parentElement;t.push([s,i])}t.sort(((e,t)=>e[0]-t[0]));for(const[i,s]of t)s.dispatchEvent(e),this.dispatched=!0}}inertia(e,t,i,s,r,n,o,l,h){this.handle=a.scheduleAtNextAnimationFrame(e,(()=>{const a=Date.now(),c=a-i;let d=0,u=0,_=!0;s+=p.SCROLL_FRICTION*c,o+=p.SCROLL_FRICTION*c,s>0&&(_=!1,d=r*s*c),o>0&&(_=!1,u=l*o*c);const g=this.newGestureEvent(f.Change);g.translationX=d,g.translationY=u,t.forEach((e=>e.dispatchEvent(g))),_||this.inertia(e,t,a,s,r,n+d,o,l,h+u)}))}onTouchMove(e){const t=Date.now();for(let i=0,s=e.changedTouches.length;i3&&(r.rollingPageX.shift(),r.rollingPageY.shift(),r.rollingTimestamps.shift()),r.rollingPageX.push(s.pageX),r.rollingPageY.push(s.pageY),r.rollingTimestamps.push(t)}this.dispatched&&(e.preventDefault(),e.stopPropagation(),this.dispatched=!1)}}t.Gesture=p,n([c.memoize],p,"isTouchDevice",null)},8801:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.AbstractScrollbar=void 0;const o=n(i(7093)),a=i(9675),l=i(8328),h=i(8974),c=i(79),d=i(8286),u=n(i(8163));class _ extends d.Widget{constructor(e){super(),this._lazyRender=e.lazyRender,this._host=e.host,this._scrollable=e.scrollable,this._scrollByPage=e.scrollByPage,this._scrollbarState=e.scrollbarState,this._visibilityController=this._register(new c.ScrollbarVisibilityController(e.visibility,"visible scrollbar "+e.extraScrollbarClassName,"invisible scrollbar "+e.extraScrollbarClassName)),this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._pointerMoveMonitor=this._register(new l.GlobalPointerMoveMonitor),this._shouldRender=!0,this.domNode=(0,a.createFastDomNode)(document.createElement("div")),this.domNode.setAttribute("role","presentation"),this.domNode.setAttribute("aria-hidden","true"),this._visibilityController.setDomNode(this.domNode),this.domNode.setPosition("absolute"),this._register(o.addDisposableListener(this.domNode.domNode,o.EventType.POINTER_DOWN,(e=>this._domNodePointerDown(e))))}_createArrow(e){const t=this._register(new h.ScrollbarArrow(e));this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode)}_createSlider(e,t,i,s){this.slider=(0,a.createFastDomNode)(document.createElement("div")),this.slider.setClassName("slider"),this.slider.setPosition("absolute"),this.slider.setTop(e),this.slider.setLeft(t),"number"==typeof i&&this.slider.setWidth(i),"number"==typeof s&&this.slider.setHeight(s),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(o.addDisposableListener(this.slider.domNode,o.EventType.POINTER_DOWN,(e=>{0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}))),this.onclick(this.slider.domNode,(e=>{e.leftButton&&e.stopPropagation()}))}_onElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_onElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_onElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._onPointerDown(e)}delegatePointerDown(e){const t=this.domNode.domNode.getClientRects()[0].top,i=t+this._scrollbarState.getSliderPosition(),s=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),r=this._sliderPointerPosition(e);i<=r&&r<=s?0===e.button&&(e.preventDefault(),this._sliderPointerDown(e)):this._onPointerDown(e)}_onPointerDown(e){let t,i;if(e.target===this.domNode.domNode&&"number"==typeof e.offsetX&&"number"==typeof e.offsetY)t=e.offsetX,i=e.offsetY;else{const s=o.getDomNodePagePosition(this.domNode.domNode);t=e.pageX-s.left,i=e.pageY-s.top}const s=this._pointerDownRelativePosition(t,i);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(s):this._scrollbarState.getDesiredScrollPositionFromOffset(s)),0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!(e.target&&e.target instanceof Element))return;const t=this._sliderPointerPosition(e),i=this._sliderOrthogonalPointerPosition(e),s=this._scrollbarState.clone();this.slider.toggleClassName("active",!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,(e=>{const r=this._sliderOrthogonalPointerPosition(e),n=Math.abs(r-i);if(u.isWindows&&n>140)return void this._setDesiredScrollPositionNow(s.getScrollPosition());const o=this._sliderPointerPosition(e)-t;this._setDesiredScrollPositionNow(s.getDesiredScrollPositionFromDelta(o))}),(()=>{this.slider.toggleClassName("active",!1),this._host.onDragEnd()})),this._host.onDragStart()}_setDesiredScrollPositionNow(e){const t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}}t.AbstractScrollbar=_},151:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.HorizontalScrollbar=void 0;const s=i(8801),r=i(8245),n=i(9881);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.horizontalHasArrows?t.arrowSize:0,t.horizontal===n.ScrollbarVisibility.Hidden?0:t.horizontalScrollbarSize,t.vertical===n.ScrollbarVisibility.Hidden?0:t.verticalScrollbarSize,s.width,s.scrollWidth,o.scrollLeft),visibility:t.horizontal,extraScrollbarClassName:"horizontal",scrollable:e,scrollByPage:t.scrollByPage}),t.horizontalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(Math.floor((t.horizontalScrollbarSize-t.horizontalSliderSize)/2),0,void 0,t.horizontalSliderSize)}_updateSlider(e,t){this.slider.setWidth(e),this.slider.setLeft(t)}_renderDomNode(e,t){this.domNode.setWidth(e),this.domNode.setHeight(t),this.domNode.setLeft(0),this.domNode.setBottom(0)}onDidScroll(e){return this._shouldRender=this._onElementScrollSize(e.scrollWidth)||this._shouldRender,this._shouldRender=this._onElementScrollPosition(e.scrollLeft)||this._shouldRender,this._shouldRender=this._onElementSize(e.width)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return e}_sliderPointerPosition(e){return e.pageX}_sliderOrthogonalPointerPosition(e){return e.pageY}_updateScrollbarSize(e){this.slider.setHeight(e)}writeScrollPosition(e,t){e.scrollLeft=t}updateOptions(e){this.updateScrollbarSize(e.horizontal===n.ScrollbarVisibility.Hidden?0:e.horizontalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(e.vertical===n.ScrollbarVisibility.Hidden?0:e.verticalScrollbarSize),this._visibilityController.setVisibility(e.horizontal),this._scrollByPage=e.scrollByPage}}t.HorizontalScrollbar=o},8234:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.DomScrollableElement=t.SmoothScrollableElement=t.ScrollableElement=t.AbstractScrollableElement=t.MouseWheelClassifier=void 0;const o=i(4333),a=n(i(7093)),l=i(9675),h=i(5964),c=i(151),d=i(5473),u=i(8286),_=i(1758),f=i(802),p=i(7150),g=n(i(8163)),m=i(9881);class v{constructor(e,t,i){this.timestamp=e,this.deltaX=t,this.deltaY=i,this.score=0}}class S{static{this.INSTANCE=new S}constructor(){this._capacity=5,this._memory=[],this._front=-1,this._rear=-1}isPhysicalMouseWheel(){if(-1===this._front&&-1===this._rear)return!1;let e=1,t=0,i=1,s=this._rear;for(;;){const r=s===this._front?e:Math.pow(2,-i);if(e-=r,t+=this._memory[s].score*r,s===this._front)break;s=(this._capacity+s-1)%this._capacity,i++}return t<=.5}acceptStandardWheelEvent(e){if(o.isChrome){const t=a.getWindow(e.browserEvent),i=(0,o.getZoomFactor)(t);this.accept(Date.now(),e.deltaX*i,e.deltaY*i)}else this.accept(Date.now(),e.deltaX,e.deltaY)}accept(e,t,i){let s=null;const r=new v(e,t,i);-1===this._front&&-1===this._rear?(this._memory[0]=r,this._front=0,this._rear=0):(s=this._memory[this._rear],this._rear=(this._rear+1)%this._capacity,this._rear===this._front&&(this._front=(this._front+1)%this._capacity),this._memory[this._rear]=r),r.score=this._computeScore(r,s)}_computeScore(e,t){if(Math.abs(e.deltaX)>0&&Math.abs(e.deltaY)>0)return 1;let i=.5;if(this._isAlmostInt(e.deltaX)&&this._isAlmostInt(e.deltaY)||(i+=.25),t){const s=Math.abs(e.deltaX),r=Math.abs(e.deltaY),n=Math.abs(t.deltaX),o=Math.abs(t.deltaY),a=Math.max(Math.min(s,n),1),l=Math.max(Math.min(r,o),1),h=Math.max(s,n),c=Math.max(r,o);h%a==0&&c%l==0&&(i-=.5)}return Math.min(Math.max(i,0),1)}_isAlmostInt(e){return Math.abs(Math.round(e)-e)<.01}}t.MouseWheelClassifier=S;class b extends u.Widget{get options(){return this._options}constructor(e,t,i){super(),this._onScroll=this._register(new f.Emitter),this.onScroll=this._onScroll.event,this._onWillScroll=this._register(new f.Emitter),this.onWillScroll=this._onWillScroll.event,this._options=function(e){const t={lazyRender:void 0!==e.lazyRender&&e.lazyRender,className:void 0!==e.className?e.className:"",useShadows:void 0===e.useShadows||e.useShadows,handleMouseWheel:void 0===e.handleMouseWheel||e.handleMouseWheel,flipAxes:void 0!==e.flipAxes&&e.flipAxes,consumeMouseWheelIfScrollbarIsNeeded:void 0!==e.consumeMouseWheelIfScrollbarIsNeeded&&e.consumeMouseWheelIfScrollbarIsNeeded,alwaysConsumeMouseWheel:void 0!==e.alwaysConsumeMouseWheel&&e.alwaysConsumeMouseWheel,scrollYToX:void 0!==e.scrollYToX&&e.scrollYToX,mouseWheelScrollSensitivity:void 0!==e.mouseWheelScrollSensitivity?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:void 0!==e.fastScrollSensitivity?e.fastScrollSensitivity:5,scrollPredominantAxis:void 0===e.scrollPredominantAxis||e.scrollPredominantAxis,mouseWheelSmoothScroll:void 0===e.mouseWheelSmoothScroll||e.mouseWheelSmoothScroll,arrowSize:void 0!==e.arrowSize?e.arrowSize:11,listenOnDomNode:void 0!==e.listenOnDomNode?e.listenOnDomNode:null,horizontal:void 0!==e.horizontal?e.horizontal:m.ScrollbarVisibility.Auto,horizontalScrollbarSize:void 0!==e.horizontalScrollbarSize?e.horizontalScrollbarSize:10,horizontalSliderSize:void 0!==e.horizontalSliderSize?e.horizontalSliderSize:0,horizontalHasArrows:void 0!==e.horizontalHasArrows&&e.horizontalHasArrows,vertical:void 0!==e.vertical?e.vertical:m.ScrollbarVisibility.Auto,verticalScrollbarSize:void 0!==e.verticalScrollbarSize?e.verticalScrollbarSize:10,verticalHasArrows:void 0!==e.verticalHasArrows&&e.verticalHasArrows,verticalSliderSize:void 0!==e.verticalSliderSize?e.verticalSliderSize:0,scrollByPage:void 0!==e.scrollByPage&&e.scrollByPage};return t.horizontalSliderSize=void 0!==e.horizontalSliderSize?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=void 0!==e.verticalSliderSize?e.verticalSliderSize:t.verticalScrollbarSize,g.isMacintosh&&(t.className+=" mac"),t}(t),this._scrollable=i,this._register(this._scrollable.onScroll((e=>{this._onWillScroll.fire(e),this._onDidScroll(e),this._onScroll.fire(e)})));const s={onMouseWheel:e=>this._onMouseWheel(e),onDragStart:()=>this._onDragStart(),onDragEnd:()=>this._onDragEnd()};this._verticalScrollbar=this._register(new d.VerticalScrollbar(this._scrollable,this._options,s)),this._horizontalScrollbar=this._register(new c.HorizontalScrollbar(this._scrollable,this._options,s)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=(0,l.createFastDomNode)(document.createElement("div")),this._leftShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=(0,l.createFastDomNode)(document.createElement("div")),this._topShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=(0,l.createFastDomNode)(document.createElement("div")),this._topLeftShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode||this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this.onmouseover(this._listenOnDomNode,(e=>this._onMouseOver(e))),this.onmouseleave(this._listenOnDomNode,(e=>this._onMouseLeave(e))),this._hideTimeout=this._register(new _.TimeoutTimer),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}dispose(){this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getOverviewRulerLayoutInfo(){return{parent:this._domNode,insertBefore:this._verticalScrollbar.domNode.domNode}}delegateVerticalScrollbarPointerDown(e){this._verticalScrollbar.delegatePointerDown(e)}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}updateClassName(e){this._options.className=e,g.isMacintosh&&(this._options.className+=" mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){void 0!==e.handleMouseWheel&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),void 0!==e.mouseWheelScrollSensitivity&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),void 0!==e.fastScrollSensitivity&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),void 0!==e.scrollPredominantAxis&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),void 0!==e.horizontal&&(this._options.horizontal=e.horizontal),void 0!==e.vertical&&(this._options.vertical=e.vertical),void 0!==e.horizontalScrollbarSize&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),void 0!==e.verticalScrollbarSize&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),void 0!==e.scrollByPage&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}setRevealOnScroll(e){this._revealOnScroll=e}delegateScrollFromMouseWheelEvent(e){this._onMouseWheel(new h.StandardWheelEvent(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),e)){const e=e=>{this._onMouseWheel(new h.StandardWheelEvent(e))};this._mouseWheelToDispose.push(a.addDisposableListener(this._listenOnDomNode,a.EventType.MOUSE_WHEEL,e,{passive:!1}))}}_onMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;const t=S.INSTANCE;t.acceptStandardWheelEvent(e);let i=!1;if(e.deltaY||e.deltaX){let s=e.deltaY*this._options.mouseWheelScrollSensitivity,r=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&r+s===0?r=s=0:Math.abs(s)>=Math.abs(r)?r=0:s=0),this._options.flipAxes&&([s,r]=[r,s]);const n=!g.isMacintosh&&e.browserEvent&&e.browserEvent.shiftKey;!this._options.scrollYToX&&!n||r||(r=s,s=0),e.browserEvent&&e.browserEvent.altKey&&(r*=this._options.fastScrollSensitivity,s*=this._options.fastScrollSensitivity);const o=this._scrollable.getFutureScrollPosition();let a={};if(s){const e=50*s,t=o.scrollTop-(e<0?Math.floor(e):Math.ceil(e));this._verticalScrollbar.writeScrollPosition(a,t)}if(r){const e=50*r,t=o.scrollLeft-(e<0?Math.floor(e):Math.ceil(e));this._horizontalScrollbar.writeScrollPosition(a,t)}a=this._scrollable.validateScrollPosition(a),(o.scrollLeft!==a.scrollLeft||o.scrollTop!==a.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(a):this._scrollable.setScrollPositionNow(a),i=!0)}let s=i;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_onDidScroll(e){this._shouldRender=this._horizontalScrollbar.onDidScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.onDidScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){const e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,i=e.scrollLeft>0,s=i?" left":"",r=t?" top":"",n=i||t?" top-left-corner":"";this._leftShadowDomNode.setClassName(`shadow${s}`),this._topShadowDomNode.setClassName(`shadow${r}`),this._topLeftShadowDomNode.setClassName(`shadow${n}${r}${s}`)}}_onDragStart(){this._isDragging=!0,this._reveal()}_onDragEnd(){this._isDragging=!1,this._hide()}_onMouseLeave(e){this._mouseIsOver=!1,this._hide()}_onMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){this._mouseIsOver||this._isDragging||(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){this._mouseIsOver||this._isDragging||this._hideTimeout.cancelAndSet((()=>this._hide()),500)}}t.AbstractScrollableElement=b,t.ScrollableElement=class extends b{constructor(e,t){(t=t||{}).mouseWheelSmoothScroll=!1;const i=new m.Scrollable({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)});super(e,t,i),this._register(i)}setScrollPosition(e){this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}},t.SmoothScrollableElement=class extends b{constructor(e,t,i){super(e,t,i)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}},t.DomScrollableElement=class extends b{constructor(e,t){(t=t||{}).mouseWheelSmoothScroll=!1;const i=new m.Scrollable({forceIntegerValues:!1,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)});super(e,t,i),this._register(i),this._element=e,this._register(this.onScroll((e=>{e.scrollTopChanged&&(this._element.scrollTop=e.scrollTop),e.scrollLeftChanged&&(this._element.scrollLeft=e.scrollLeft)}))),this.scanDomNode()}setScrollPosition(e){this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}scanDomNode(){this.setScrollDimensions({width:this._element.clientWidth,scrollWidth:this._element.scrollWidth,height:this._element.clientHeight,scrollHeight:this._element.scrollHeight}),this.setScrollPosition({scrollLeft:this._element.scrollLeft,scrollTop:this._element.scrollTop})}}},8974:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarArrow=t.ARROW_IMG_SIZE=void 0;const o=i(8328),a=i(8286),l=i(1758),h=n(i(7093));t.ARROW_IMG_SIZE=11;class c extends a.Widget{constructor(e){super(),this._onActivate=e.onActivate,this.bgDomNode=document.createElement("div"),this.bgDomNode.className="arrow-background",this.bgDomNode.style.position="absolute",this.bgDomNode.style.width=e.bgWidth+"px",this.bgDomNode.style.height=e.bgHeight+"px",void 0!==e.top&&(this.bgDomNode.style.top="0px"),void 0!==e.left&&(this.bgDomNode.style.left="0px"),void 0!==e.bottom&&(this.bgDomNode.style.bottom="0px"),void 0!==e.right&&(this.bgDomNode.style.right="0px"),this.domNode=document.createElement("div"),this.domNode.className=e.className,this.domNode.style.position="absolute",this.domNode.style.width=t.ARROW_IMG_SIZE+"px",this.domNode.style.height=t.ARROW_IMG_SIZE+"px",void 0!==e.top&&(this.domNode.style.top=e.top+"px"),void 0!==e.left&&(this.domNode.style.left=e.left+"px"),void 0!==e.bottom&&(this.domNode.style.bottom=e.bottom+"px"),void 0!==e.right&&(this.domNode.style.right=e.right+"px"),this._pointerMoveMonitor=this._register(new o.GlobalPointerMoveMonitor),this._register(h.addStandardDisposableListener(this.bgDomNode,h.EventType.POINTER_DOWN,(e=>this._arrowPointerDown(e)))),this._register(h.addStandardDisposableListener(this.domNode,h.EventType.POINTER_DOWN,(e=>this._arrowPointerDown(e)))),this._pointerdownRepeatTimer=this._register(new h.WindowIntervalTimer),this._pointerdownScheduleRepeatTimer=this._register(new l.TimeoutTimer)}_arrowPointerDown(e){e.target&&e.target instanceof Element&&(this._onActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet((()=>{this._pointerdownRepeatTimer.cancelAndSet((()=>this._onActivate()),1e3/24,h.getWindow(e))}),200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,(e=>{}),(()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()})),e.preventDefault())}}t.ScrollbarArrow=c},8245:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarState=void 0;class i{constructor(e,t,i,s,r,n){this._scrollbarSize=Math.round(t),this._oppositeScrollbarSize=Math.round(i),this._arrowSize=Math.round(e),this._visibleSize=s,this._scrollSize=r,this._scrollPosition=n,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new i(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(e){const t=Math.round(e);return this._visibleSize!==t&&(this._visibleSize=t,this._refreshComputedValues(),!0)}setScrollSize(e){const t=Math.round(e);return this._scrollSize!==t&&(this._scrollSize=t,this._refreshComputedValues(),!0)}setScrollPosition(e){const t=Math.round(e);return this._scrollPosition!==t&&(this._scrollPosition=t,this._refreshComputedValues(),!0)}setScrollbarSize(e){this._scrollbarSize=Math.round(e)}setOppositeScrollbarSize(e){this._oppositeScrollbarSize=Math.round(e)}static _computeValues(e,t,i,s,r){const n=Math.max(0,i-e),o=Math.max(0,n-2*t),a=s>0&&s>i;if(!a)return{computedAvailableSize:Math.round(n),computedIsNeeded:a,computedSliderSize:Math.round(o),computedSliderRatio:0,computedSliderPosition:0};const l=Math.round(Math.max(20,Math.floor(i*o/s))),h=(o-l)/(s-i),c=r*h;return{computedAvailableSize:Math.round(n),computedIsNeeded:a,computedSliderSize:Math.round(l),computedSliderRatio:h,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){const e=i._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=e.computedAvailableSize,this._computedIsNeeded=e.computedIsNeeded,this._computedSliderSize=e.computedSliderSize,this._computedSliderRatio=e.computedSliderRatio,this._computedSliderPosition=e.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize-this._computedSliderSize/2;return Math.round(t/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize;let i=this._scrollPosition;return t{Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarVisibilityController=void 0;const s=i(1758),r=i(7150),n=i(9881);class o extends r.Disposable{constructor(e,t,i){super(),this._visibility=e,this._visibleClassName=t,this._invisibleClassName=i,this._domNode=null,this._isVisible=!1,this._isNeeded=!1,this._rawShouldBeVisible=!1,this._shouldBeVisible=!1,this._revealTimer=this._register(new s.TimeoutTimer)}setVisibility(e){this._visibility!==e&&(this._visibility=e,this._updateShouldBeVisible())}setShouldBeVisible(e){this._rawShouldBeVisible=e,this._updateShouldBeVisible()}_applyVisibilitySetting(){return this._visibility!==n.ScrollbarVisibility.Hidden&&(this._visibility===n.ScrollbarVisibility.Visible||this._rawShouldBeVisible)}_updateShouldBeVisible(){const e=this._applyVisibilitySetting();this._shouldBeVisible!==e&&(this._shouldBeVisible=e,this.ensureVisibility())}setIsNeeded(e){this._isNeeded!==e&&(this._isNeeded=e,this.ensureVisibility())}setDomNode(e){this._domNode=e,this._domNode.setClassName(this._invisibleClassName),this.setShouldBeVisible(!1)}ensureVisibility(){this._isNeeded?this._shouldBeVisible?this._reveal():this._hide(!0):this._hide(!1)}_reveal(){this._isVisible||(this._isVisible=!0,this._revealTimer.setIfNotSet((()=>{this._domNode?.setClassName(this._visibleClassName)}),0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?" fade":"")))}}t.ScrollbarVisibilityController=o},5473:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.VerticalScrollbar=void 0;const s=i(8801),r=i(8245),n=i(9881);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.verticalHasArrows?t.arrowSize:0,t.vertical===n.ScrollbarVisibility.Hidden?0:t.verticalScrollbarSize,0,s.height,s.scrollHeight,o.scrollTop),visibility:t.vertical,extraScrollbarClassName:"vertical",scrollable:e,scrollByPage:t.scrollByPage}),t.verticalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(0,Math.floor((t.verticalScrollbarSize-t.verticalSliderSize)/2),t.verticalSliderSize,void 0)}_updateSlider(e,t){this.slider.setHeight(e),this.slider.setTop(t)}_renderDomNode(e,t){this.domNode.setWidth(t),this.domNode.setHeight(e),this.domNode.setRight(0),this.domNode.setTop(0)}onDidScroll(e){return this._shouldRender=this._onElementScrollSize(e.scrollHeight)||this._shouldRender,this._shouldRender=this._onElementScrollPosition(e.scrollTop)||this._shouldRender,this._shouldRender=this._onElementSize(e.height)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return t}_sliderPointerPosition(e){return e.pageY}_sliderOrthogonalPointerPosition(e){return e.pageX}_updateScrollbarSize(e){this.slider.setWidth(e)}writeScrollPosition(e,t){e.scrollTop=t}updateOptions(e){this.updateScrollbarSize(e.vertical===n.ScrollbarVisibility.Hidden?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}}t.VerticalScrollbar=o},8286:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.Widget=void 0;const o=n(i(7093)),a=i(5394),l=i(5964),h=i(8594),c=i(7150);class d extends c.Disposable{onclick(e,t){this._register(o.addDisposableListener(e,o.EventType.CLICK,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onmousedown(e,t){this._register(o.addDisposableListener(e,o.EventType.MOUSE_DOWN,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onmouseover(e,t){this._register(o.addDisposableListener(e,o.EventType.MOUSE_OVER,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onmouseleave(e,t){this._register(o.addDisposableListener(e,o.EventType.MOUSE_LEAVE,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onkeydown(e,t){this._register(o.addDisposableListener(e,o.EventType.KEY_DOWN,(e=>t(new a.StandardKeyboardEvent(e)))))}onkeyup(e,t){this._register(o.addDisposableListener(e,o.EventType.KEY_UP,(e=>t(new a.StandardKeyboardEvent(e)))))}oninput(e,t){this._register(o.addDisposableListener(e,o.EventType.INPUT,t))}onblur(e,t){this._register(o.addDisposableListener(e,o.EventType.BLUR,t))}onfocus(e,t){this._register(o.addDisposableListener(e,o.EventType.FOCUS,t))}onchange(e,t){this._register(o.addDisposableListener(e,o.EventType.CHANGE,t))}ignoreGesture(e){return h.Gesture.ignoreTarget(e)}}t.Widget=d},4693:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.mainWindow=void 0,t.ensureCodeWindow=function(e,t){},t.mainWindow="object"==typeof window?window:globalThis},3058:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Permutation=t.CallbackIterable=t.ArrayQueue=t.booleanComparator=t.numberComparator=t.CompareResult=void 0,t.tail=function(e,t=0){return e[e.length-(1+t)]},t.tail2=function(e){if(0===e.length)throw new Error("Invalid tail call");return[e.slice(0,e.length-1),e[e.length-1]]},t.equals=function(e,t,i=(e,t)=>e===t){if(e===t)return!0;if(!e||!t)return!1;if(e.length!==t.length)return!1;for(let s=0,r=e.length;si(e[s],t)))},t.binarySearch2=n,t.quickSelect=function e(t,i,s){if((t|=0)>=i.length)throw new TypeError("invalid index");const r=i[Math.floor(i.length*Math.random())],n=[],o=[],a=[];for(const e of i){const t=s(e,r);t<0?n.push(e):t>0?o.push(e):a.push(e)}return t{(async()=>{const o=e.length,l=e.slice(0,i).sort(t);for(let h=i,c=Math.min(i+r,o);hi&&await new Promise((e=>setTimeout(e))),n&&n.isCancellationRequested)throw new s.CancellationError;a(e,t,l,h,c)}return l})().then(o,l)}))},t.coalesce=function(e){return e.filter((e=>!!e))},t.coalesceInPlace=function(e){let t=0;for(let i=0;i0},t.distinct=function(e,t=e=>e){const i=new Set;return e.filter((e=>{const s=t(e);return!i.has(s)&&(i.add(s),!0)}))},t.uniqueFilter=function(e){const t=new Set;return i=>{const s=e(i);return!t.has(s)&&(t.add(s),!0)}},t.firstOrDefault=function(e,t){return e.length>0?e[0]:t},t.lastOrDefault=function(e,t){return e.length>0?e[e.length-1]:t},t.commonPrefixLength=function(e,t,i=(e,t)=>e===t){let s=0;for(let r=0,n=Math.min(e.length,t.length);rt;e--)s.push(e);return s},t.index=function(e,t,i){return e.reduce(((e,s)=>(e[t(s)]=i?i(s):s,e)),Object.create(null))},t.insert=function(e,t){return e.push(t),()=>l(e,t)},t.remove=l,t.arrayInsert=function(e,t,i){const s=e.slice(0,t),r=e.slice(t);return s.concat(i,r)},t.shuffle=function(e,t){let i;if("number"==typeof t){let e=t;i=()=>{const t=179426549*Math.sin(e++);return t-Math.floor(t)}}else i=Math.random;for(let t=e.length-1;t>0;t-=1){const s=Math.floor(i()*(t+1)),r=e[t];e[t]=e[s],e[s]=r}},t.pushToStart=function(e,t){const i=e.indexOf(t);i>-1&&(e.splice(i,1),e.unshift(t))},t.pushToEnd=function(e,t){const i=e.indexOf(t);i>-1&&(e.splice(i,1),e.push(t))},t.pushMany=function(e,t){for(const i of t)e.push(i)},t.mapArrayOrNot=function(e,t){return Array.isArray(e)?e.map(t):t(e)},t.asArray=function(e){return Array.isArray(e)?e:[e]},t.getRandomElement=function(e){return e[Math.floor(Math.random()*e.length)]},t.insertInto=h,t.splice=function(e,t,i,s){const r=c(e,t);let n=e.splice(r,i);return void 0===n&&(n=[]),h(e,r,s),n},t.compareBy=function(e,t){return(i,s)=>t(e(i),e(s))},t.tieBreakComparators=function(...e){return(t,i)=>{for(const s of e){const e=s(t,i);if(!d.isNeitherLessOrGreaterThan(e))return e}return d.neitherLessOrGreaterThan}},t.reverseOrder=function(e){return(t,i)=>-e(t,i)};const s=i(9807),r=i(8297);function n(e,t){let i=0,s=e-1;for(;i<=s;){const e=(i+s)/2|0,r=t(e);if(r<0)i=e+1;else{if(!(r>0))return e;s=e-1}}return-(i+1)}function o(e,t,i){const s=[];function r(e,t,i){if(0===t&&0===i.length)return;const r=s[s.length-1];r&&r.start+r.deleteCount===e?(r.deleteCount+=t,r.toInsert.push(...i)):s.push({start:e,deleteCount:t,toInsert:i})}let n=0,o=0;for(;;){if(n===e.length){r(n,0,t.slice(o));break}if(o===t.length){r(n,e.length-n,[]);break}const s=e[n],a=t[o],l=i(s,a);0===l?(n+=1,o+=1):l<0?(r(n,1,[]),n+=1):l>0&&(r(n,0,[a]),o+=1)}return s}function a(e,t,i,s,n){for(const o=i.length;st(n,e)<0));i.splice(e,0,n)}}}function l(e,t){const i=e.indexOf(t);if(i>-1)return e.splice(i,1),t}function h(e,t,i){const s=c(e,t),r=e.length,n=i.length;e.length=r+n;for(let t=r-1;t>=s;t--)e[t+n]=e[t];for(let t=0;t0},e.isNeitherLessOrGreaterThan=function(e){return 0===e},e.greaterThan=1,e.lessThan=-1,e.neitherLessOrGreaterThan=0}(d||(t.CompareResult=d={})),t.numberComparator=(e,t)=>e-t,t.booleanComparator=(e,i)=>(0,t.numberComparator)(e?1:0,i?1:0),t.ArrayQueue=class{constructor(e){this.items=e,this.firstIdx=0,this.lastIdx=this.items.length-1}get length(){return this.lastIdx-this.firstIdx+1}takeWhile(e){let t=this.firstIdx;for(;t=0&&e(this.items[t]);)t--;const i=t===this.lastIdx?null:this.items.slice(t+1,this.lastIdx+1);return this.lastIdx=t,i}peek(){if(0!==this.length)return this.items[this.firstIdx]}peekLast(){if(0!==this.length)return this.items[this.lastIdx]}dequeue(){const e=this.items[this.firstIdx];return this.firstIdx++,e}removeLast(){const e=this.items[this.lastIdx];return this.lastIdx--,e}takeCount(e){const t=this.items.slice(this.firstIdx,this.firstIdx+e);return this.firstIdx+=e,t}};class u{static{this.empty=new u((e=>{}))}constructor(e){this.iterate=e}forEach(e){this.iterate((t=>(e(t),!0)))}toArray(){const e=[];return this.iterate((t=>(e.push(t),!0))),e}filter(e){return new u((t=>this.iterate((i=>!e(i)||t(i)))))}map(e){return new u((t=>this.iterate((i=>t(e(i))))))}some(e){let t=!1;return this.iterate((i=>(t=e(i),!t))),t}findFirst(e){let t;return this.iterate((i=>!e(i)||(t=i,!1))),t}findLast(e){let t;return this.iterate((i=>(e(i)&&(t=i),!0))),t}findLastMaxBy(e){let t,i=!0;return this.iterate((s=>((i||d.isGreaterThan(e(s,t)))&&(i=!1,t=s),!0))),t}}t.CallbackIterable=u;class _{constructor(e){this._indexMap=e}static createSortPermutation(e,t){const i=Array.from(e.keys()).sort(((i,s)=>t(e[i],e[s])));return new _(i)}apply(e){return e.map(((t,i)=>e[this._indexMap[i]]))}inverse(){const e=this._indexMap.slice();for(let t=0;t{function i(e,t,i=e.length-1){for(let s=i;s>=0;s--)if(t(e[s]))return s;return-1}function s(e,t,i=0,s=e.length){let r=i,n=s;for(;r=0&&(i=r)}return i},t.findFirstMin=function(e,t){return o(e,((e,i)=>-t(e,i)))},t.findMaxIdx=function(e,t){if(0===e.length)return-1;let i=0;for(let s=1;s0&&(i=s);return i},t.mapFindFirst=function(e,t){for(const i of e){const e=t(i);if(void 0!==e)return e}};class n{static{this.assertInvariants=!1}constructor(e){this._array=e,this._findLastMonotonousLastIdx=0}findLastMonotonous(e){if(n.assertInvariants){if(this._prevFindLastPredicate)for(const t of this._array)if(this._prevFindLastPredicate(t)&&!e(t))throw new Error("MonotonousArray: current predicate must be weaker than (or equal to) the previous predicate.");this._prevFindLastPredicate=e}const t=s(this._array,e,this._findLastMonotonousLastIdx);return this._findLastMonotonousLastIdx=t+1,-1===t?void 0:this._array[t]}}function o(e,t){if(0===e.length)return;let i=e[0];for(let s=1;s0&&(i=r)}return i}t.MonotonousArray=n},1758:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AsyncIterableSource=t.CancelableAsyncIterableObject=t.AsyncIterableObject=t.LazyStatefulPromise=t.StatefulPromise=t.Promises=t.DeferredPromise=t.IntervalCounter=t.TaskSequentializer=t.GlobalIdleValue=t.AbstractIdleValue=t._runWhenIdle=t.runWhenGlobalIdle=t.ThrottledWorker=t.RunOnceWorker=t.ProcessTimeRunOnceScheduler=t.RunOnceScheduler=t.IntervalTimer=t.TimeoutTimer=t.LimitedQueue=t.Queue=t.Limiter=t.AutoOpenBarrier=t.Barrier=t.ThrottledDelayer=t.Delayer=t.SequencerByKey=t.Sequencer=t.Throttler=void 0,t.isThenable=c,t.createCancelablePromise=d,t.raceCancellation=function(e,t,i){return new Promise(((s,r)=>{const n=t.onCancellationRequested((()=>{n.dispose(),s(i)}));e.then(s,r).finally((()=>n.dispose()))}))},t.raceCancellationError=function(e,t){return new Promise(((i,s)=>{const n=t.onCancellationRequested((()=>{n.dispose(),s(new r.CancellationError)}));e.then(i,s).finally((()=>n.dispose()))}))},t.raceCancellablePromises=async function(e){let t=-1;const i=e.map(((e,i)=>e.then((e=>(t=i,e)))));try{return await Promise.race(i)}finally{e.forEach(((e,i)=>{i!==t&&e.cancel()}))}},t.raceTimeout=function(e,t,i){let s;const r=setTimeout((()=>{s?.(void 0),i?.()}),t);return Promise.race([e.finally((()=>clearTimeout(r))),new Promise((e=>s=e))])},t.asPromise=function(e){return new Promise(((t,i)=>{const s=e();c(s)?s.then(t,i):t(s)}))},t.promiseWithResolvers=u,t.timeout=g,t.disposableTimeout=function(e,t=0,i){const s=setTimeout((()=>{e(),i&&r.dispose()}),t),r=(0,o.toDisposable)((()=>{clearTimeout(s),i?.deleteAndLeak(r)}));return i?.add(r),r},t.sequence=function(e){const t=[];let i=0;const s=e.length;return Promise.resolve(null).then((function r(n){null!=n&&t.push(n);const o=i!!e,i=null){let s=0;const r=e.length,n=()=>{if(s>=r)return Promise.resolve(i);const o=e[s++];return Promise.resolve(o()).then((e=>t(e)?Promise.resolve(e):n()))};return n()},t.firstParallel=function(e,t=e=>!!e,i=null){if(0===e.length)return Promise.resolve(i);let s=e.length;const r=()=>{s=-1;for(const t of e)t.cancel?.()};return new Promise(((n,o)=>{for(const a of e)a.then((e=>{--s>=0&&t(e)?(r(),n(e)):0===s&&n(i)})).catch((e=>{--s>=0&&(r(),o(e))}))}))},t.retry=async function(e,t,i){let s;for(let r=0;r{const s=t.token.onCancellationRequested((()=>{s.dispose(),t.dispose(),e.reject(new r.CancellationError)}));try{for await(const s of i){if(t.token.isCancellationRequested)return;e.emitOne(s)}s.dispose(),t.dispose()}catch(i){s.dispose(),t.dispose(),e.reject(i)}}))};const s=i(8447),r=i(9807),n=i(802),o=i(7150),a=i(8163),l=i(5015),h=i(626);function c(e){return!!e&&"function"==typeof e.then}function d(e){const t=new s.CancellationTokenSource,i=e(t.token),n=new Promise(((e,s)=>{const n=t.token.onCancellationRequested((()=>{n.dispose(),s(new r.CancellationError)}));Promise.resolve(i).then((i=>{n.dispose(),t.dispose(),e(i)}),(e=>{n.dispose(),t.dispose(),s(e)}))}));return new class{cancel(){t.cancel(),t.dispose()}then(e,t){return n.then(e,t)}catch(e){return this.then(void 0,e)}finally(e){return n.finally(e)}}}function u(){let e,t;return{promise:new Promise(((i,s)=>{e=i,t=s})),resolve:e,reject:t}}class _{constructor(){this.isDisposed=!1,this.activePromise=null,this.queuedPromise=null,this.queuedPromiseFactory=null}queue(e){if(this.isDisposed)return Promise.reject(new Error("Throttler is disposed"));if(this.activePromise){if(this.queuedPromiseFactory=e,!this.queuedPromise){const e=()=>{if(this.queuedPromise=null,this.isDisposed)return;const e=this.queue(this.queuedPromiseFactory);return this.queuedPromiseFactory=null,e};this.queuedPromise=new Promise((t=>{this.activePromise.then(e,e).then(t)}))}return new Promise(((e,t)=>{this.queuedPromise.then(e,t)}))}return this.activePromise=e(),new Promise(((e,t)=>{this.activePromise.then((t=>{this.activePromise=null,e(t)}),(e=>{this.activePromise=null,t(e)}))}))}dispose(){this.isDisposed=!0}}t.Throttler=_,t.Sequencer=class{constructor(){this.current=Promise.resolve(null)}queue(e){return this.current=this.current.then((()=>e()),(()=>e()))}},t.SequencerByKey=class{constructor(){this.promiseMap=new Map}queue(e,t){const i=(this.promiseMap.get(e)??Promise.resolve()).catch((()=>{})).then(t).finally((()=>{this.promiseMap.get(e)===i&&this.promiseMap.delete(e)}));return this.promiseMap.set(e,i),i}};class f{constructor(e){this.defaultDelay=e,this.deferred=null,this.completionPromise=null,this.doResolve=null,this.doReject=null,this.task=null}trigger(e,t=this.defaultDelay){this.task=e,this.cancelTimeout(),this.completionPromise||(this.completionPromise=new Promise(((e,t)=>{this.doResolve=e,this.doReject=t})).then((()=>{if(this.completionPromise=null,this.doResolve=null,this.task){const e=this.task;return this.task=null,e()}})));const i=()=>{this.deferred=null,this.doResolve?.(null)};return this.deferred=t===l.MicrotaskDelay?(e=>{let t=!0;return queueMicrotask((()=>{t&&(t=!1,e())})),{isTriggered:()=>t,dispose:()=>{t=!1}}})(i):((e,t)=>{let i=!0;const s=setTimeout((()=>{i=!1,t()}),e);return{isTriggered:()=>i,dispose:()=>{clearTimeout(s),i=!1}}})(t,i),this.completionPromise}isTriggered(){return!!this.deferred?.isTriggered()}cancel(){this.cancelTimeout(),this.completionPromise&&(this.doReject?.(new r.CancellationError),this.completionPromise=null)}cancelTimeout(){this.deferred?.dispose(),this.deferred=null}dispose(){this.cancel()}}t.Delayer=f,t.ThrottledDelayer=class{constructor(e){this.delayer=new f(e),this.throttler=new _}trigger(e,t){return this.delayer.trigger((()=>this.throttler.queue(e)),t)}isTriggered(){return this.delayer.isTriggered()}cancel(){this.delayer.cancel()}dispose(){this.delayer.dispose(),this.throttler.dispose()}};class p{constructor(){this._isOpen=!1,this._promise=new Promise(((e,t)=>{this._completePromise=e}))}isOpen(){return this._isOpen}open(){this._isOpen=!0,this._completePromise(!0)}wait(){return this._promise}}function g(e,t){return t?new Promise(((i,s)=>{const n=setTimeout((()=>{o.dispose(),i()}),e),o=t.onCancellationRequested((()=>{clearTimeout(n),o.dispose(),s(new r.CancellationError)}))})):d((t=>g(e,t)))}t.Barrier=p,t.AutoOpenBarrier=class extends p{constructor(e){super(),this._timeout=setTimeout((()=>this.open()),e)}open(){clearTimeout(this._timeout),super.open()}};class m{constructor(e){this._size=0,this._isDisposed=!1,this.maxDegreeOfParalellism=e,this.outstandingPromises=[],this.runningPromises=0,this._onDrained=new n.Emitter}whenIdle(){return this.size>0?n.Event.toPromise(this.onDrained):Promise.resolve()}get onDrained(){return this._onDrained.event}get size(){return this._size}queue(e){if(this._isDisposed)throw new Error("Object has been disposed");return this._size++,new Promise(((t,i)=>{this.outstandingPromises.push({factory:e,c:t,e:i}),this.consume()}))}consume(){for(;this.outstandingPromises.length&&this.runningPromisesthis.consumed()),(()=>this.consumed()))}}consumed(){this._isDisposed||(this.runningPromises--,0==--this._size&&this._onDrained.fire(),this.outstandingPromises.length>0&&this.consume())}clear(){if(this._isDisposed)throw new Error("Object has been disposed");this.outstandingPromises.length=0,this._size=this.runningPromises}dispose(){this._isDisposed=!0,this.outstandingPromises.length=0,this._size=0,this._onDrained.dispose()}}t.Limiter=m,t.Queue=class extends m{constructor(){super(1)}},t.LimitedQueue=class{constructor(){this.sequentializer=new C,this.tasks=0}queue(e){return this.sequentializer.isRunning()?this.sequentializer.queue((()=>this.sequentializer.run(this.tasks++,e()))):this.sequentializer.run(this.tasks++,e())}},t.TimeoutTimer=class{constructor(e,t){this._isDisposed=!1,this._token=-1,"function"==typeof e&&"number"==typeof t&&this.setIfNotSet(e,t)}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new r.BugIndicatingError("Calling 'cancelAndSet' on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout((()=>{this._token=-1,e()}),t)}setIfNotSet(e,t){if(this._isDisposed)throw new r.BugIndicatingError("Calling 'setIfNotSet' on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout((()=>{this._token=-1,e()}),t))}},t.IntervalTimer=class{constructor(){this.disposable=void 0,this.isDisposed=!1}cancel(){this.disposable?.dispose(),this.disposable=void 0}cancelAndSet(e,t,i=globalThis){if(this.isDisposed)throw new r.BugIndicatingError("Calling 'cancelAndSet' on a disposed IntervalTimer");this.cancel();const s=i.setInterval((()=>{e()}),t);this.disposable=(0,o.toDisposable)((()=>{i.clearInterval(s),this.disposable=void 0}))}dispose(){this.cancel(),this.isDisposed=!0}};class v{constructor(e,t){this.timeoutToken=-1,this.runner=e,this.timeout=t,this.timeoutHandler=this.onTimeout.bind(this)}dispose(){this.cancel(),this.runner=null}cancel(){this.isScheduled()&&(clearTimeout(this.timeoutToken),this.timeoutToken=-1)}schedule(e=this.timeout){this.cancel(),this.timeoutToken=setTimeout(this.timeoutHandler,e)}get delay(){return this.timeout}set delay(e){this.timeout=e}isScheduled(){return-1!==this.timeoutToken}flush(){this.isScheduled()&&(this.cancel(),this.doRun())}onTimeout(){this.timeoutToken=-1,this.runner&&this.doRun()}doRun(){this.runner?.()}}t.RunOnceScheduler=v,t.ProcessTimeRunOnceScheduler=class{constructor(e,t){t%1e3!=0&&console.warn(`ProcessTimeRunOnceScheduler resolution is 1s, ${t}ms is not a multiple of 1000ms.`),this.runner=e,this.timeout=t,this.counter=0,this.intervalToken=-1,this.intervalHandler=this.onInterval.bind(this)}dispose(){this.cancel(),this.runner=null}cancel(){this.isScheduled()&&(clearInterval(this.intervalToken),this.intervalToken=-1)}schedule(e=this.timeout){e%1e3!=0&&console.warn(`ProcessTimeRunOnceScheduler resolution is 1s, ${e}ms is not a multiple of 1000ms.`),this.cancel(),this.counter=Math.ceil(e/1e3),this.intervalToken=setInterval(this.intervalHandler,1e3)}isScheduled(){return-1!==this.intervalToken}onInterval(){this.counter--,this.counter>0||(clearInterval(this.intervalToken),this.intervalToken=-1,this.runner?.())}},t.RunOnceWorker=class extends v{constructor(e,t){super(e,t),this.units=[]}work(e){this.units.push(e),this.isScheduled()||this.schedule()}doRun(){const e=this.units;this.units=[],this.runner?.(e)}dispose(){this.units=[],super.dispose()}};class S extends o.Disposable{constructor(e,t){super(),this.options=e,this.handler=t,this.pendingWork=[],this.throttler=this._register(new o.MutableDisposable),this.disposed=!1}get pending(){return this.pendingWork.length}work(e){if(this.disposed)return!1;if("number"==typeof this.options.maxBufferedWork)if(this.throttler.value){if(this.pending+e.length>this.options.maxBufferedWork)return!1}else if(this.pending+e.length-this.options.maxWorkChunkSize>this.options.maxBufferedWork)return!1;for(const t of e)this.pendingWork.push(t);return this.throttler.value||this.doWork(),!0}doWork(){this.handler(this.pendingWork.splice(0,this.options.maxWorkChunkSize)),this.pendingWork.length>0&&(this.throttler.value=new v((()=>{this.throttler.clear(),this.doWork()}),this.options.throttleDelay),this.throttler.value.schedule())}dispose(){super.dispose(),this.disposed=!0}}t.ThrottledWorker=S,"function"!=typeof globalThis.requestIdleCallback||"function"!=typeof globalThis.cancelIdleCallback?t._runWhenIdle=(e,t)=>{(0,a.setTimeout0)((()=>{if(i)return;const e=Date.now()+15,s={didTimeout:!0,timeRemaining:()=>Math.max(0,e-Date.now())};t(Object.freeze(s))}));let i=!1;return{dispose(){i||(i=!0)}}}:t._runWhenIdle=(e,t,i)=>{const s=e.requestIdleCallback(t,"number"==typeof i?{timeout:i}:void 0);let r=!1;return{dispose(){r||(r=!0,e.cancelIdleCallback(s))}}},t.runWhenGlobalIdle=e=>(0,t._runWhenIdle)(globalThis,e);class b{constructor(e,i){this._didRun=!1,this._executor=()=>{try{this._value=i()}catch(e){this._error=e}finally{this._didRun=!0}},this._handle=(0,t._runWhenIdle)(e,(()=>this._executor()))}dispose(){this._handle.dispose()}get value(){if(this._didRun||(this._handle.dispose(),this._executor()),this._error)throw this._error;return this._value}get isInitialized(){return this._didRun}}t.AbstractIdleValue=b,t.GlobalIdleValue=class extends b{constructor(e){super(globalThis,e)}};class C{isRunning(e){return"number"==typeof e?this._running?.taskId===e:!!this._running}get running(){return this._running?.promise}cancelRunning(){this._running?.cancel()}run(e,t,i){return this._running={taskId:e,cancel:()=>i?.(),promise:t},t.then((()=>this.doneRunning(e)),(()=>this.doneRunning(e))),t}doneRunning(e){this._running&&e===this._running.taskId&&(this._running=void 0,this.runQueued())}runQueued(){if(this._queued){const e=this._queued;this._queued=void 0,e.run().then(e.promiseResolve,e.promiseReject)}}queue(e){if(this._queued)this._queued.run=e;else{const{promise:t,resolve:i,reject:s}=u();this._queued={run:e,promise:t,promiseResolve:i,promiseReject:s}}return this._queued.promise}hasQueued(){return!!this._queued}async join(){return this._queued?.promise??this._running?.promise}}var y,w,E;t.TaskSequentializer=C,t.IntervalCounter=class{constructor(e,t=()=>Date.now()){this.interval=e,this.nowFn=t,this.lastIncrementTime=0,this.value=0}increment(){const e=this.nowFn();return e-this.lastIncrementTime>this.interval&&(this.lastIncrementTime=e,this.value=0),this.value++,this.value}},function(e){e[e.Resolved=0]="Resolved",e[e.Rejected=1]="Rejected"}(y||(y={}));class D{get isRejected(){return this.outcome?.outcome===y.Rejected}get isResolved(){return this.outcome?.outcome===y.Resolved}get isSettled(){return!!this.outcome}get value(){return this.outcome?.outcome===y.Resolved?this.outcome?.value:void 0}constructor(){this.p=new Promise(((e,t)=>{this.completeCallback=e,this.errorCallback=t}))}complete(e){return new Promise((t=>{this.completeCallback(e),this.outcome={outcome:y.Resolved,value:e},t()}))}error(e){return new Promise((t=>{this.errorCallback(e),this.outcome={outcome:y.Rejected,value:e},t()}))}cancel(){return this.error(new r.CancellationError)}}t.DeferredPromise=D,function(e){e.settled=async function(e){let t;const i=await Promise.all(e.map((e=>e.then((e=>e),(e=>{t||(t=e)})))));if(void 0!==t)throw t;return i},e.withAsyncBody=function(e){return new Promise((async(t,i)=>{try{await e(t,i)}catch(e){i(e)}}))}}(w||(t.Promises=w={}));class L{get value(){return this._value}get error(){return this._error}get isResolved(){return this._isResolved}constructor(e){this._value=void 0,this._error=void 0,this._isResolved=!1,this.promise=e.then((e=>(this._value=e,this._isResolved=!0,e)),(e=>{throw this._error=e,this._isResolved=!0,e}))}requireValue(){if(!this._isResolved)throw new r.BugIndicatingError("Promise is not resolved yet");if(this._error)throw this._error;return this._value}}t.StatefulPromise=L,t.LazyStatefulPromise=class{constructor(e){this._compute=e,this._promise=new h.Lazy((()=>new L(this._compute())))}requireValue(){return this._promise.value.requireValue()}getPromise(){return this._promise.value.promise}get currentValue(){return this._promise.rawValue?.value}},function(e){e[e.Initial=0]="Initial",e[e.DoneOK=1]="DoneOK",e[e.DoneError=2]="DoneError"}(E||(E={}));class R{static fromArray(e){return new R((t=>{t.emitMany(e)}))}static fromPromise(e){return new R((async t=>{t.emitMany(await e)}))}static fromPromises(e){return new R((async t=>{await Promise.all(e.map((async e=>t.emitOne(await e))))}))}static merge(e){return new R((async t=>{await Promise.all(e.map((async e=>{for await(const i of e)t.emitOne(i)})))}))}static{this.EMPTY=R.fromArray([])}constructor(e,t){this._state=E.Initial,this._results=[],this._error=null,this._onReturn=t,this._onStateChanged=new n.Emitter,queueMicrotask((async()=>{const t={emitOne:e=>this.emitOne(e),emitMany:e=>this.emitMany(e),reject:e=>this.reject(e)};try{await Promise.resolve(e(t)),this.resolve()}catch(e){this.reject(e)}finally{t.emitOne=void 0,t.emitMany=void 0,t.reject=void 0}}))}[Symbol.asyncIterator](){let e=0;return{next:async()=>{for(;;){if(this._state===E.DoneError)throw this._error;if(e(this._onReturn?.(),{done:!0,value:void 0})}}static map(e,t){return new R((async i=>{for await(const s of e)i.emitOne(t(s))}))}map(e){return R.map(this,e)}static filter(e,t){return new R((async i=>{for await(const s of e)t(s)&&i.emitOne(s)}))}filter(e){return R.filter(this,e)}static coalesce(e){return R.filter(e,(e=>!!e))}coalesce(){return R.coalesce(this)}static async toPromise(e){const t=[];for await(const i of e)t.push(i);return t}toPromise(){return R.toPromise(this)}emitOne(e){this._state===E.Initial&&(this._results.push(e),this._onStateChanged.fire())}emitMany(e){this._state===E.Initial&&(this._results=this._results.concat(e),this._onStateChanged.fire())}resolve(){this._state===E.Initial&&(this._state=E.DoneOK,this._onStateChanged.fire())}reject(e){this._state===E.Initial&&(this._state=E.DoneError,this._error=e,this._onStateChanged.fire())}}t.AsyncIterableObject=R;class A extends R{constructor(e,t){super(t),this._source=e}cancel(){this._source.cancel()}}t.CancelableAsyncIterableObject=A,t.AsyncIterableSource=class{constructor(e){let t,i;this._deferred=new D,this._asyncIterable=new R((e=>{if(!t)return i&&e.emitMany(i),this._errorFn=t=>e.reject(t),this._emitFn=t=>e.emitOne(t),this._deferred.p;e.reject(t)}),e),this._emitFn=e=>{i||(i=[]),i.push(e)},this._errorFn=e=>{t||(t=e)}}get asyncIterable(){return this._asyncIterable}resolve(){this._deferred.complete()}reject(e){this._errorFn(e),this._deferred.complete()}emitOne(e){this._emitFn(e)}}},8447:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CancellationTokenSource=t.CancellationToken=void 0,t.cancelOnDispose=function(e){const t=new a;return e.add({dispose(){t.cancel()}}),t.token};const s=i(802),r=Object.freeze((function(e,t){const i=setTimeout(e.bind(t),0);return{dispose(){clearTimeout(i)}}}));var n;!function(e){e.isCancellationToken=function(t){return t===e.None||t===e.Cancelled||t instanceof o||!(!t||"object"!=typeof t)&&"boolean"==typeof t.isCancellationRequested&&"function"==typeof t.onCancellationRequested},e.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:s.Event.None}),e.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:r})}(n||(t.CancellationToken=n={}));class o{constructor(){this._isCancelled=!1,this._emitter=null}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?r:(this._emitter||(this._emitter=new s.Emitter),this._emitter.event)}dispose(){this._emitter&&(this._emitter.dispose(),this._emitter=null)}}class a{constructor(e){this._token=void 0,this._parentListener=void 0,this._parentListener=e&&e.onCancellationRequested(this.cancel,this)}get token(){return this._token||(this._token=new o),this._token}cancel(){this._token?this._token instanceof o&&this._token.cancel():this._token=n.Cancelled}dispose(e=!1){e&&this.cancel(),this._parentListener?.dispose(),this._token?this._token instanceof o&&this._token.dispose():this._token=n.None}}t.CancellationTokenSource=a},4869:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.CharCode=void 0,function(e){e[e.Null=0]="Null",e[e.Backspace=8]="Backspace",e[e.Tab=9]="Tab",e[e.LineFeed=10]="LineFeed",e[e.CarriageReturn=13]="CarriageReturn",e[e.Space=32]="Space",e[e.ExclamationMark=33]="ExclamationMark",e[e.DoubleQuote=34]="DoubleQuote",e[e.Hash=35]="Hash",e[e.DollarSign=36]="DollarSign",e[e.PercentSign=37]="PercentSign",e[e.Ampersand=38]="Ampersand",e[e.SingleQuote=39]="SingleQuote",e[e.OpenParen=40]="OpenParen",e[e.CloseParen=41]="CloseParen",e[e.Asterisk=42]="Asterisk",e[e.Plus=43]="Plus",e[e.Comma=44]="Comma",e[e.Dash=45]="Dash",e[e.Period=46]="Period",e[e.Slash=47]="Slash",e[e.Digit0=48]="Digit0",e[e.Digit1=49]="Digit1",e[e.Digit2=50]="Digit2",e[e.Digit3=51]="Digit3",e[e.Digit4=52]="Digit4",e[e.Digit5=53]="Digit5",e[e.Digit6=54]="Digit6",e[e.Digit7=55]="Digit7",e[e.Digit8=56]="Digit8",e[e.Digit9=57]="Digit9",e[e.Colon=58]="Colon",e[e.Semicolon=59]="Semicolon",e[e.LessThan=60]="LessThan",e[e.Equals=61]="Equals",e[e.GreaterThan=62]="GreaterThan",e[e.QuestionMark=63]="QuestionMark",e[e.AtSign=64]="AtSign",e[e.A=65]="A",e[e.B=66]="B",e[e.C=67]="C",e[e.D=68]="D",e[e.E=69]="E",e[e.F=70]="F",e[e.G=71]="G",e[e.H=72]="H",e[e.I=73]="I",e[e.J=74]="J",e[e.K=75]="K",e[e.L=76]="L",e[e.M=77]="M",e[e.N=78]="N",e[e.O=79]="O",e[e.P=80]="P",e[e.Q=81]="Q",e[e.R=82]="R",e[e.S=83]="S",e[e.T=84]="T",e[e.U=85]="U",e[e.V=86]="V",e[e.W=87]="W",e[e.X=88]="X",e[e.Y=89]="Y",e[e.Z=90]="Z",e[e.OpenSquareBracket=91]="OpenSquareBracket",e[e.Backslash=92]="Backslash",e[e.CloseSquareBracket=93]="CloseSquareBracket",e[e.Caret=94]="Caret",e[e.Underline=95]="Underline",e[e.BackTick=96]="BackTick",e[e.a=97]="a",e[e.b=98]="b",e[e.c=99]="c",e[e.d=100]="d",e[e.e=101]="e",e[e.f=102]="f",e[e.g=103]="g",e[e.h=104]="h",e[e.i=105]="i",e[e.j=106]="j",e[e.k=107]="k",e[e.l=108]="l",e[e.m=109]="m",e[e.n=110]="n",e[e.o=111]="o",e[e.p=112]="p",e[e.q=113]="q",e[e.r=114]="r",e[e.s=115]="s",e[e.t=116]="t",e[e.u=117]="u",e[e.v=118]="v",e[e.w=119]="w",e[e.x=120]="x",e[e.y=121]="y",e[e.z=122]="z",e[e.OpenCurlyBrace=123]="OpenCurlyBrace",e[e.Pipe=124]="Pipe",e[e.CloseCurlyBrace=125]="CloseCurlyBrace",e[e.Tilde=126]="Tilde",e[e.NoBreakSpace=160]="NoBreakSpace",e[e.U_Combining_Grave_Accent=768]="U_Combining_Grave_Accent",e[e.U_Combining_Acute_Accent=769]="U_Combining_Acute_Accent",e[e.U_Combining_Circumflex_Accent=770]="U_Combining_Circumflex_Accent",e[e.U_Combining_Tilde=771]="U_Combining_Tilde",e[e.U_Combining_Macron=772]="U_Combining_Macron",e[e.U_Combining_Overline=773]="U_Combining_Overline",e[e.U_Combining_Breve=774]="U_Combining_Breve",e[e.U_Combining_Dot_Above=775]="U_Combining_Dot_Above",e[e.U_Combining_Diaeresis=776]="U_Combining_Diaeresis",e[e.U_Combining_Hook_Above=777]="U_Combining_Hook_Above",e[e.U_Combining_Ring_Above=778]="U_Combining_Ring_Above",e[e.U_Combining_Double_Acute_Accent=779]="U_Combining_Double_Acute_Accent",e[e.U_Combining_Caron=780]="U_Combining_Caron",e[e.U_Combining_Vertical_Line_Above=781]="U_Combining_Vertical_Line_Above",e[e.U_Combining_Double_Vertical_Line_Above=782]="U_Combining_Double_Vertical_Line_Above",e[e.U_Combining_Double_Grave_Accent=783]="U_Combining_Double_Grave_Accent",e[e.U_Combining_Candrabindu=784]="U_Combining_Candrabindu",e[e.U_Combining_Inverted_Breve=785]="U_Combining_Inverted_Breve",e[e.U_Combining_Turned_Comma_Above=786]="U_Combining_Turned_Comma_Above",e[e.U_Combining_Comma_Above=787]="U_Combining_Comma_Above",e[e.U_Combining_Reversed_Comma_Above=788]="U_Combining_Reversed_Comma_Above",e[e.U_Combining_Comma_Above_Right=789]="U_Combining_Comma_Above_Right",e[e.U_Combining_Grave_Accent_Below=790]="U_Combining_Grave_Accent_Below",e[e.U_Combining_Acute_Accent_Below=791]="U_Combining_Acute_Accent_Below",e[e.U_Combining_Left_Tack_Below=792]="U_Combining_Left_Tack_Below",e[e.U_Combining_Right_Tack_Below=793]="U_Combining_Right_Tack_Below",e[e.U_Combining_Left_Angle_Above=794]="U_Combining_Left_Angle_Above",e[e.U_Combining_Horn=795]="U_Combining_Horn",e[e.U_Combining_Left_Half_Ring_Below=796]="U_Combining_Left_Half_Ring_Below",e[e.U_Combining_Up_Tack_Below=797]="U_Combining_Up_Tack_Below",e[e.U_Combining_Down_Tack_Below=798]="U_Combining_Down_Tack_Below",e[e.U_Combining_Plus_Sign_Below=799]="U_Combining_Plus_Sign_Below",e[e.U_Combining_Minus_Sign_Below=800]="U_Combining_Minus_Sign_Below",e[e.U_Combining_Palatalized_Hook_Below=801]="U_Combining_Palatalized_Hook_Below",e[e.U_Combining_Retroflex_Hook_Below=802]="U_Combining_Retroflex_Hook_Below",e[e.U_Combining_Dot_Below=803]="U_Combining_Dot_Below",e[e.U_Combining_Diaeresis_Below=804]="U_Combining_Diaeresis_Below",e[e.U_Combining_Ring_Below=805]="U_Combining_Ring_Below",e[e.U_Combining_Comma_Below=806]="U_Combining_Comma_Below",e[e.U_Combining_Cedilla=807]="U_Combining_Cedilla",e[e.U_Combining_Ogonek=808]="U_Combining_Ogonek",e[e.U_Combining_Vertical_Line_Below=809]="U_Combining_Vertical_Line_Below",e[e.U_Combining_Bridge_Below=810]="U_Combining_Bridge_Below",e[e.U_Combining_Inverted_Double_Arch_Below=811]="U_Combining_Inverted_Double_Arch_Below",e[e.U_Combining_Caron_Below=812]="U_Combining_Caron_Below",e[e.U_Combining_Circumflex_Accent_Below=813]="U_Combining_Circumflex_Accent_Below",e[e.U_Combining_Breve_Below=814]="U_Combining_Breve_Below",e[e.U_Combining_Inverted_Breve_Below=815]="U_Combining_Inverted_Breve_Below",e[e.U_Combining_Tilde_Below=816]="U_Combining_Tilde_Below",e[e.U_Combining_Macron_Below=817]="U_Combining_Macron_Below",e[e.U_Combining_Low_Line=818]="U_Combining_Low_Line",e[e.U_Combining_Double_Low_Line=819]="U_Combining_Double_Low_Line",e[e.U_Combining_Tilde_Overlay=820]="U_Combining_Tilde_Overlay",e[e.U_Combining_Short_Stroke_Overlay=821]="U_Combining_Short_Stroke_Overlay",e[e.U_Combining_Long_Stroke_Overlay=822]="U_Combining_Long_Stroke_Overlay",e[e.U_Combining_Short_Solidus_Overlay=823]="U_Combining_Short_Solidus_Overlay",e[e.U_Combining_Long_Solidus_Overlay=824]="U_Combining_Long_Solidus_Overlay",e[e.U_Combining_Right_Half_Ring_Below=825]="U_Combining_Right_Half_Ring_Below",e[e.U_Combining_Inverted_Bridge_Below=826]="U_Combining_Inverted_Bridge_Below",e[e.U_Combining_Square_Below=827]="U_Combining_Square_Below",e[e.U_Combining_Seagull_Below=828]="U_Combining_Seagull_Below",e[e.U_Combining_X_Above=829]="U_Combining_X_Above",e[e.U_Combining_Vertical_Tilde=830]="U_Combining_Vertical_Tilde",e[e.U_Combining_Double_Overline=831]="U_Combining_Double_Overline",e[e.U_Combining_Grave_Tone_Mark=832]="U_Combining_Grave_Tone_Mark",e[e.U_Combining_Acute_Tone_Mark=833]="U_Combining_Acute_Tone_Mark",e[e.U_Combining_Greek_Perispomeni=834]="U_Combining_Greek_Perispomeni",e[e.U_Combining_Greek_Koronis=835]="U_Combining_Greek_Koronis",e[e.U_Combining_Greek_Dialytika_Tonos=836]="U_Combining_Greek_Dialytika_Tonos",e[e.U_Combining_Greek_Ypogegrammeni=837]="U_Combining_Greek_Ypogegrammeni",e[e.U_Combining_Bridge_Above=838]="U_Combining_Bridge_Above",e[e.U_Combining_Equals_Sign_Below=839]="U_Combining_Equals_Sign_Below",e[e.U_Combining_Double_Vertical_Line_Below=840]="U_Combining_Double_Vertical_Line_Below",e[e.U_Combining_Left_Angle_Below=841]="U_Combining_Left_Angle_Below",e[e.U_Combining_Not_Tilde_Above=842]="U_Combining_Not_Tilde_Above",e[e.U_Combining_Homothetic_Above=843]="U_Combining_Homothetic_Above",e[e.U_Combining_Almost_Equal_To_Above=844]="U_Combining_Almost_Equal_To_Above",e[e.U_Combining_Left_Right_Arrow_Below=845]="U_Combining_Left_Right_Arrow_Below",e[e.U_Combining_Upwards_Arrow_Below=846]="U_Combining_Upwards_Arrow_Below",e[e.U_Combining_Grapheme_Joiner=847]="U_Combining_Grapheme_Joiner",e[e.U_Combining_Right_Arrowhead_Above=848]="U_Combining_Right_Arrowhead_Above",e[e.U_Combining_Left_Half_Ring_Above=849]="U_Combining_Left_Half_Ring_Above",e[e.U_Combining_Fermata=850]="U_Combining_Fermata",e[e.U_Combining_X_Below=851]="U_Combining_X_Below",e[e.U_Combining_Left_Arrowhead_Below=852]="U_Combining_Left_Arrowhead_Below",e[e.U_Combining_Right_Arrowhead_Below=853]="U_Combining_Right_Arrowhead_Below",e[e.U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below=854]="U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below",e[e.U_Combining_Right_Half_Ring_Above=855]="U_Combining_Right_Half_Ring_Above",e[e.U_Combining_Dot_Above_Right=856]="U_Combining_Dot_Above_Right",e[e.U_Combining_Asterisk_Below=857]="U_Combining_Asterisk_Below",e[e.U_Combining_Double_Ring_Below=858]="U_Combining_Double_Ring_Below",e[e.U_Combining_Zigzag_Above=859]="U_Combining_Zigzag_Above",e[e.U_Combining_Double_Breve_Below=860]="U_Combining_Double_Breve_Below",e[e.U_Combining_Double_Breve=861]="U_Combining_Double_Breve",e[e.U_Combining_Double_Macron=862]="U_Combining_Double_Macron",e[e.U_Combining_Double_Macron_Below=863]="U_Combining_Double_Macron_Below",e[e.U_Combining_Double_Tilde=864]="U_Combining_Double_Tilde",e[e.U_Combining_Double_Inverted_Breve=865]="U_Combining_Double_Inverted_Breve",e[e.U_Combining_Double_Rightwards_Arrow_Below=866]="U_Combining_Double_Rightwards_Arrow_Below",e[e.U_Combining_Latin_Small_Letter_A=867]="U_Combining_Latin_Small_Letter_A",e[e.U_Combining_Latin_Small_Letter_E=868]="U_Combining_Latin_Small_Letter_E",e[e.U_Combining_Latin_Small_Letter_I=869]="U_Combining_Latin_Small_Letter_I",e[e.U_Combining_Latin_Small_Letter_O=870]="U_Combining_Latin_Small_Letter_O",e[e.U_Combining_Latin_Small_Letter_U=871]="U_Combining_Latin_Small_Letter_U",e[e.U_Combining_Latin_Small_Letter_C=872]="U_Combining_Latin_Small_Letter_C",e[e.U_Combining_Latin_Small_Letter_D=873]="U_Combining_Latin_Small_Letter_D",e[e.U_Combining_Latin_Small_Letter_H=874]="U_Combining_Latin_Small_Letter_H",e[e.U_Combining_Latin_Small_Letter_M=875]="U_Combining_Latin_Small_Letter_M",e[e.U_Combining_Latin_Small_Letter_R=876]="U_Combining_Latin_Small_Letter_R",e[e.U_Combining_Latin_Small_Letter_T=877]="U_Combining_Latin_Small_Letter_T",e[e.U_Combining_Latin_Small_Letter_V=878]="U_Combining_Latin_Small_Letter_V",e[e.U_Combining_Latin_Small_Letter_X=879]="U_Combining_Latin_Small_Letter_X",e[e.LINE_SEPARATOR=8232]="LINE_SEPARATOR",e[e.PARAGRAPH_SEPARATOR=8233]="PARAGRAPH_SEPARATOR",e[e.NEXT_LINE=133]="NEXT_LINE",e[e.U_CIRCUMFLEX=94]="U_CIRCUMFLEX",e[e.U_GRAVE_ACCENT=96]="U_GRAVE_ACCENT",e[e.U_DIAERESIS=168]="U_DIAERESIS",e[e.U_MACRON=175]="U_MACRON",e[e.U_ACUTE_ACCENT=180]="U_ACUTE_ACCENT",e[e.U_CEDILLA=184]="U_CEDILLA",e[e.U_MODIFIER_LETTER_LEFT_ARROWHEAD=706]="U_MODIFIER_LETTER_LEFT_ARROWHEAD",e[e.U_MODIFIER_LETTER_RIGHT_ARROWHEAD=707]="U_MODIFIER_LETTER_RIGHT_ARROWHEAD",e[e.U_MODIFIER_LETTER_UP_ARROWHEAD=708]="U_MODIFIER_LETTER_UP_ARROWHEAD",e[e.U_MODIFIER_LETTER_DOWN_ARROWHEAD=709]="U_MODIFIER_LETTER_DOWN_ARROWHEAD",e[e.U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING=722]="U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING",e[e.U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING=723]="U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING",e[e.U_MODIFIER_LETTER_UP_TACK=724]="U_MODIFIER_LETTER_UP_TACK",e[e.U_MODIFIER_LETTER_DOWN_TACK=725]="U_MODIFIER_LETTER_DOWN_TACK",e[e.U_MODIFIER_LETTER_PLUS_SIGN=726]="U_MODIFIER_LETTER_PLUS_SIGN",e[e.U_MODIFIER_LETTER_MINUS_SIGN=727]="U_MODIFIER_LETTER_MINUS_SIGN",e[e.U_BREVE=728]="U_BREVE",e[e.U_DOT_ABOVE=729]="U_DOT_ABOVE",e[e.U_RING_ABOVE=730]="U_RING_ABOVE",e[e.U_OGONEK=731]="U_OGONEK",e[e.U_SMALL_TILDE=732]="U_SMALL_TILDE",e[e.U_DOUBLE_ACUTE_ACCENT=733]="U_DOUBLE_ACUTE_ACCENT",e[e.U_MODIFIER_LETTER_RHOTIC_HOOK=734]="U_MODIFIER_LETTER_RHOTIC_HOOK",e[e.U_MODIFIER_LETTER_CROSS_ACCENT=735]="U_MODIFIER_LETTER_CROSS_ACCENT",e[e.U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR=741]="U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR",e[e.U_MODIFIER_LETTER_HIGH_TONE_BAR=742]="U_MODIFIER_LETTER_HIGH_TONE_BAR",e[e.U_MODIFIER_LETTER_MID_TONE_BAR=743]="U_MODIFIER_LETTER_MID_TONE_BAR",e[e.U_MODIFIER_LETTER_LOW_TONE_BAR=744]="U_MODIFIER_LETTER_LOW_TONE_BAR",e[e.U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR=745]="U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR",e[e.U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK=746]="U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK",e[e.U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK=747]="U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK",e[e.U_MODIFIER_LETTER_UNASPIRATED=749]="U_MODIFIER_LETTER_UNASPIRATED",e[e.U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD=751]="U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_UP_ARROWHEAD=752]="U_MODIFIER_LETTER_LOW_UP_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD=753]="U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD=754]="U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_RING=755]="U_MODIFIER_LETTER_LOW_RING",e[e.U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT=756]="U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT",e[e.U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT=757]="U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT",e[e.U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT=758]="U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT",e[e.U_MODIFIER_LETTER_LOW_TILDE=759]="U_MODIFIER_LETTER_LOW_TILDE",e[e.U_MODIFIER_LETTER_RAISED_COLON=760]="U_MODIFIER_LETTER_RAISED_COLON",e[e.U_MODIFIER_LETTER_BEGIN_HIGH_TONE=761]="U_MODIFIER_LETTER_BEGIN_HIGH_TONE",e[e.U_MODIFIER_LETTER_END_HIGH_TONE=762]="U_MODIFIER_LETTER_END_HIGH_TONE",e[e.U_MODIFIER_LETTER_BEGIN_LOW_TONE=763]="U_MODIFIER_LETTER_BEGIN_LOW_TONE",e[e.U_MODIFIER_LETTER_END_LOW_TONE=764]="U_MODIFIER_LETTER_END_LOW_TONE",e[e.U_MODIFIER_LETTER_SHELF=765]="U_MODIFIER_LETTER_SHELF",e[e.U_MODIFIER_LETTER_OPEN_SHELF=766]="U_MODIFIER_LETTER_OPEN_SHELF",e[e.U_MODIFIER_LETTER_LOW_LEFT_ARROW=767]="U_MODIFIER_LETTER_LOW_LEFT_ARROW",e[e.U_GREEK_LOWER_NUMERAL_SIGN=885]="U_GREEK_LOWER_NUMERAL_SIGN",e[e.U_GREEK_TONOS=900]="U_GREEK_TONOS",e[e.U_GREEK_DIALYTIKA_TONOS=901]="U_GREEK_DIALYTIKA_TONOS",e[e.U_GREEK_KORONIS=8125]="U_GREEK_KORONIS",e[e.U_GREEK_PSILI=8127]="U_GREEK_PSILI",e[e.U_GREEK_PERISPOMENI=8128]="U_GREEK_PERISPOMENI",e[e.U_GREEK_DIALYTIKA_AND_PERISPOMENI=8129]="U_GREEK_DIALYTIKA_AND_PERISPOMENI",e[e.U_GREEK_PSILI_AND_VARIA=8141]="U_GREEK_PSILI_AND_VARIA",e[e.U_GREEK_PSILI_AND_OXIA=8142]="U_GREEK_PSILI_AND_OXIA",e[e.U_GREEK_PSILI_AND_PERISPOMENI=8143]="U_GREEK_PSILI_AND_PERISPOMENI",e[e.U_GREEK_DASIA_AND_VARIA=8157]="U_GREEK_DASIA_AND_VARIA",e[e.U_GREEK_DASIA_AND_OXIA=8158]="U_GREEK_DASIA_AND_OXIA",e[e.U_GREEK_DASIA_AND_PERISPOMENI=8159]="U_GREEK_DASIA_AND_PERISPOMENI",e[e.U_GREEK_DIALYTIKA_AND_VARIA=8173]="U_GREEK_DIALYTIKA_AND_VARIA",e[e.U_GREEK_DIALYTIKA_AND_OXIA=8174]="U_GREEK_DIALYTIKA_AND_OXIA",e[e.U_GREEK_VARIA=8175]="U_GREEK_VARIA",e[e.U_GREEK_OXIA=8189]="U_GREEK_OXIA",e[e.U_GREEK_DASIA=8190]="U_GREEK_DASIA",e[e.U_IDEOGRAPHIC_FULL_STOP=12290]="U_IDEOGRAPHIC_FULL_STOP",e[e.U_LEFT_CORNER_BRACKET=12300]="U_LEFT_CORNER_BRACKET",e[e.U_RIGHT_CORNER_BRACKET=12301]="U_RIGHT_CORNER_BRACKET",e[e.U_LEFT_BLACK_LENTICULAR_BRACKET=12304]="U_LEFT_BLACK_LENTICULAR_BRACKET",e[e.U_RIGHT_BLACK_LENTICULAR_BRACKET=12305]="U_RIGHT_BLACK_LENTICULAR_BRACKET",e[e.U_OVERLINE=8254]="U_OVERLINE",e[e.UTF8_BOM=65279]="UTF8_BOM",e[e.U_FULLWIDTH_SEMICOLON=65307]="U_FULLWIDTH_SEMICOLON",e[e.U_FULLWIDTH_COMMA=65292]="U_FULLWIDTH_COMMA"}(i||(t.CharCode=i={}))},9087:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.SetWithKey=void 0,t.groupBy=function(e,t){const i=Object.create(null);for(const s of e){const e=t(s);let r=i[e];r||(r=i[e]=[]),r.push(s)}return i},t.diffSets=function(e,t){const i=[],s=[];for(const s of e)t.has(s)||i.push(s);for(const i of t)e.has(i)||s.push(i);return{removed:i,added:s}},t.diffMaps=function(e,t){const i=[],s=[];for(const[s,r]of e)t.has(s)||i.push(r);for(const[i,r]of t)e.has(i)||s.push(r);return{removed:i,added:s}},t.intersection=function(e,t){const i=new Set;for(const s of t)e.has(s)&&i.add(s);return i};class s{static{i=Symbol.toStringTag}constructor(e,t){this.toKey=t,this._map=new Map,this[i]="SetWithKey";for(const t of e)this.add(t)}get size(){return this._map.size}add(e){const t=this.toKey(e);return this._map.set(t,e),this}delete(e){return this._map.delete(this.toKey(e))}has(e){return this._map.has(this.toKey(e))}*entries(){for(const e of this._map.values())yield[e,e]}keys(){return this.values()}*values(){for(const e of this._map.values())yield e}clear(){this._map.clear()}forEach(e,t){this._map.forEach((i=>e.call(t,i,i,this)))}[Symbol.iterator](){return this.values()}}t.SetWithKey=s},4838:(e,t)=>{function i(e){return(t,i,s)=>{let r=null,n=null;if("function"==typeof s.value?(r="value",n=s.value):"function"==typeof s.get&&(r="get",n=s.get),!n)throw new Error("not supported");s[r]=e(n,i)}}Object.defineProperty(t,"__esModule",{value:!0}),t.memoize=function(e,t,i){let s=null,r=null;if("function"==typeof i.value?(s="value",r=i.value,0!==r.length&&console.warn("Memoize should only be used in functions with zero parameters")):"function"==typeof i.get&&(s="get",r=i.get),!r)throw new Error("not supported");const n=`$memoize$${t}`;i[s]=function(...e){return this.hasOwnProperty(n)||Object.defineProperty(this,n,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,e)}),this[n]}},t.debounce=function(e,t,s){return i(((i,r)=>{const n=`$debounce$${r}`,o=`$debounce$result$${r}`;return function(...r){this[o]||(this[o]=s?s():void 0),clearTimeout(this[n]),t&&(this[o]=t(this[o],...r),r=[this[o]]),this[n]=setTimeout((()=>{i.apply(this,r),this[o]=s?s():void 0}),e)}}))},t.throttle=function(e,t,s){return i(((i,r)=>{const n=`$throttle$timer$${r}`,o=`$throttle$result$${r}`,a=`$throttle$lastRun$${r}`,l=`$throttle$pending$${r}`;return function(...r){if(this[o]||(this[o]=s?s():void 0),null!==this[a]&&void 0!==this[a]||(this[a]=-Number.MAX_VALUE),t&&(this[o]=t(this[o],...r)),this[l])return;const h=this[a]+e;h<=Date.now()?(this[a]=Date.now(),i.apply(this,[this[o]]),this[o]=s?s():void 0):(this[l]=!0,this[n]=setTimeout((()=>{this[l]=!1,this[a]=Date.now(),i.apply(this,[this[o]]),this[o]=s?s():void 0}),h-Date.now()))}}))}},9807:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BugIndicatingError=t.ErrorNoTelemetry=t.ExpectedError=t.NotSupportedError=t.NotImplementedError=t.ReadonlyError=t.CancellationError=t.errorHandler=t.ErrorHandler=void 0,t.setUnexpectedErrorHandler=function(e){t.errorHandler.setUnexpectedErrorHandler(e)},t.isSigPipeError=function(e){if(!e||"object"!=typeof e)return!1;const t=e;return"EPIPE"===t.code&&"WRITE"===t.syscall?.toUpperCase()},t.onUnexpectedError=function(e){r(e)||t.errorHandler.onUnexpectedError(e)},t.onUnexpectedExternalError=function(e){r(e)||t.errorHandler.onUnexpectedExternalError(e)},t.transformErrorForSerialization=function(e){if(e instanceof Error){const{name:t,message:i}=e;return{$isError:!0,name:t,message:i,stack:e.stacktrace||e.stack,noTelemetry:c.isErrorNoTelemetry(e)}}return e},t.transformErrorFromSerialization=function(e){let t;return e.noTelemetry?t=new c:(t=new Error,t.name=e.name),t.message=e.message,t.stack=e.stack,t},t.isCancellationError=r,t.canceled=function(){const e=new Error(s);return e.name=e.message,e},t.illegalArgument=function(e){return e?new Error(`Illegal argument: ${e}`):new Error("Illegal argument")},t.illegalState=function(e){return e?new Error(`Illegal state: ${e}`):new Error("Illegal state")},t.getErrorMessage=function(e){return e?e.message?e.message:e.stack?e.stack.split("\n")[0]:String(e):"Error"};class i{constructor(){this.listeners=[],this.unexpectedErrorHandler=function(e){setTimeout((()=>{if(e.stack){if(c.isErrorNoTelemetry(e))throw new c(e.message+"\n\n"+e.stack);throw new Error(e.message+"\n\n"+e.stack)}throw e}),0)}}addListener(e){return this.listeners.push(e),()=>{this._removeListener(e)}}emit(e){this.listeners.forEach((t=>{t(e)}))}_removeListener(e){this.listeners.splice(this.listeners.indexOf(e),1)}setUnexpectedErrorHandler(e){this.unexpectedErrorHandler=e}getUnexpectedErrorHandler(){return this.unexpectedErrorHandler}onUnexpectedError(e){this.unexpectedErrorHandler(e),this.emit(e)}onUnexpectedExternalError(e){this.unexpectedErrorHandler(e)}}t.ErrorHandler=i,t.errorHandler=new i;const s="Canceled";function r(e){return e instanceof n||e instanceof Error&&e.name===s&&e.message===s}class n extends Error{constructor(){super(s),this.name=this.message}}t.CancellationError=n;class o extends TypeError{constructor(e){super(e?`${e} is read-only and cannot be changed`:"Cannot change read-only property")}}t.ReadonlyError=o;class a extends Error{constructor(e){super("NotImplemented"),e&&(this.message=e)}}t.NotImplementedError=a;class l extends Error{constructor(e){super("NotSupported"),e&&(this.message=e)}}t.NotSupportedError=l;class h extends Error{constructor(){super(...arguments),this.isExpected=!0}}t.ExpectedError=h;class c extends Error{constructor(e){super(e),this.name="CodeExpectedError"}static fromError(e){if(e instanceof c)return e;const t=new c;return t.message=e.message,t.stack=e.stack,t}static isErrorNoTelemetry(e){return"CodeExpectedError"===e.name}}t.ErrorNoTelemetry=c;class d extends Error{constructor(e){super(e||"An unexpected bug occurred."),Object.setPrototypeOf(this,d.prototype)}}t.BugIndicatingError=d},802:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ValueWithChangeEvent=t.Relay=t.EventBufferer=t.DynamicListEventMultiplexer=t.EventMultiplexer=t.MicrotaskEmitter=t.DebounceEmitter=t.PauseableEmitter=t.AsyncEmitter=t.createEventDeliveryQueue=t.Emitter=t.ListenerRefusalError=t.ListenerLeakError=t.EventProfiling=t.Event=void 0,t.setGlobalLeakWarningThreshold=function(e){const t=c;return c=e,{dispose(){c=t}}};const s=i(9807),r=i(8841),n=i(7150),o=i(6317),a=i(9725);var l;!function(e){function t(e){return(t,i=null,s)=>{let r,n=!1;return r=e((e=>{if(!n)return r?r.dispose():n=!0,t.call(i,e)}),null,s),n&&r.dispose(),r}}function i(e,t,i){return r(((i,s=null,r)=>e((e=>i.call(s,t(e))),null,r)),i)}function s(e,t,i){return r(((i,s=null,r)=>e((e=>t(e)&&i.call(s,e)),null,r)),i)}function r(e,t){let i;const s=new m({onWillAddFirstListener(){i=e(s.fire,s)},onDidRemoveLastListener(){i?.dispose()}});return t?.add(s),s.event}function o(e,t,i=100,s=!1,r=!1,n,o){let a,l,h,c,d=0;const u=new m({leakWarningThreshold:n,onWillAddFirstListener(){a=e((e=>{d++,l=t(l,e),s&&!h&&(u.fire(l),l=void 0),c=()=>{const e=l;l=void 0,h=void 0,(!s||d>1)&&u.fire(e),d=0},"number"==typeof i?(clearTimeout(h),h=setTimeout(c,i)):void 0===h&&(h=0,queueMicrotask(c))}))},onWillRemoveListener(){r&&d>0&&c?.()},onDidRemoveLastListener(){c=void 0,a.dispose()}});return o?.add(u),u.event}e.None=()=>n.Disposable.None,e.defer=function(e,t){return o(e,(()=>{}),0,void 0,!0,void 0,t)},e.once=t,e.map=i,e.forEach=function(e,t,i){return r(((i,s=null,r)=>e((e=>{t(e),i.call(s,e)}),null,r)),i)},e.filter=s,e.signal=function(e){return e},e.any=function(...e){return(t,i=null,s)=>{return r=(0,n.combinedDisposable)(...e.map((e=>e((e=>t.call(i,e)))))),(o=s)instanceof Array?o.push(r):o&&o.add(r),r;var r,o}},e.reduce=function(e,t,s,r){let n=s;return i(e,(e=>(n=t(n,e),n)),r)},e.debounce=o,e.accumulate=function(t,i=0,s){return e.debounce(t,((e,t)=>e?(e.push(t),e):[t]),i,void 0,!0,void 0,s)},e.latch=function(e,t=(e,t)=>e===t,i){let r,n=!0;return s(e,(e=>{const i=n||!t(e,r);return n=!1,r=e,i}),i)},e.split=function(t,i,s){return[e.filter(t,i,s),e.filter(t,(e=>!i(e)),s)]},e.buffer=function(e,t=!1,i=[],s){let r=i.slice(),n=e((e=>{r?r.push(e):a.fire(e)}));s&&s.add(n);const o=()=>{r?.forEach((e=>a.fire(e))),r=null},a=new m({onWillAddFirstListener(){n||(n=e((e=>a.fire(e))),s&&s.add(n))},onDidAddFirstListener(){r&&(t?setTimeout(o):o())},onDidRemoveLastListener(){n&&n.dispose(),n=null}});return s&&s.add(a),a.event},e.chain=function(e,t){return(i,s,r)=>{const n=t(new l);return e((function(e){const t=n.evaluate(e);t!==a&&i.call(s,t)}),void 0,r)}};const a=Symbol("HaltChainable");class l{constructor(){this.steps=[]}map(e){return this.steps.push(e),this}forEach(e){return this.steps.push((t=>(e(t),t))),this}filter(e){return this.steps.push((t=>e(t)?t:a)),this}reduce(e,t){let i=t;return this.steps.push((t=>(i=e(i,t),i))),this}latch(e=(e,t)=>e===t){let t,i=!0;return this.steps.push((s=>{const r=i||!e(s,t);return i=!1,t=s,r?s:a})),this}evaluate(e){for(const t of this.steps)if((e=t(e))===a)break;return e}}e.fromNodeEventEmitter=function(e,t,i=e=>e){const s=(...e)=>r.fire(i(...e)),r=new m({onWillAddFirstListener:()=>e.on(t,s),onDidRemoveLastListener:()=>e.removeListener(t,s)});return r.event},e.fromDOMEventEmitter=function(e,t,i=e=>e){const s=(...e)=>r.fire(i(...e)),r=new m({onWillAddFirstListener:()=>e.addEventListener(t,s),onDidRemoveLastListener:()=>e.removeEventListener(t,s)});return r.event},e.toPromise=function(e){return new Promise((i=>t(e)(i)))},e.fromPromise=function(e){const t=new m;return e.then((e=>{t.fire(e)}),(()=>{t.fire(void 0)})).finally((()=>{t.dispose()})),t.event},e.forward=function(e,t){return e((e=>t.fire(e)))},e.runAndSubscribe=function(e,t,i){return t(i),e((e=>t(e)))};class h{constructor(e,t){this._observable=e,this._counter=0,this._hasChanged=!1;const i={onWillAddFirstListener:()=>{e.addObserver(this)},onDidRemoveLastListener:()=>{e.removeObserver(this)}};this.emitter=new m(i),t&&t.add(this.emitter)}beginUpdate(e){this._counter++}handlePossibleChange(e){}handleChange(e,t){this._hasChanged=!0}endUpdate(e){this._counter--,0===this._counter&&(this._observable.reportChanges(),this._hasChanged&&(this._hasChanged=!1,this.emitter.fire(this._observable.get())))}}e.fromObservable=function(e,t){return new h(e,t).emitter.event},e.fromObservableLight=function(e){return(t,i,s)=>{let r=0,o=!1;const a={beginUpdate(){r++},endUpdate(){r--,0===r&&(e.reportChanges(),o&&(o=!1,t.call(i)))},handlePossibleChange(){},handleChange(){o=!0}};e.addObserver(a),e.reportChanges();const l={dispose(){e.removeObserver(a)}};return s instanceof n.DisposableStore?s.add(l):Array.isArray(s)&&s.push(l),l}}}(l||(t.Event=l={}));class h{static{this.all=new Set}static{this._idPool=0}constructor(e){this.listenerCount=0,this.invocationCount=0,this.elapsedOverall=0,this.durations=[],this.name=`${e}_${h._idPool++}`,h.all.add(this)}start(e){this._stopWatch=new a.StopWatch,this.listenerCount=e}stop(){if(this._stopWatch){const e=this._stopWatch.elapsed();this.durations.push(e),this.elapsedOverall+=e,this.invocationCount+=1,this._stopWatch=void 0}}}t.EventProfiling=h;let c=-1;class d{static{this._idPool=1}constructor(e,t,i=(d._idPool++).toString(16).padStart(3,"0")){this._errorHandler=e,this.threshold=t,this.name=i,this._warnCountdown=0}dispose(){this._stacks?.clear()}check(e,t){const i=this.threshold;if(i<=0||t{const t=this._stacks.get(e.value)||0;this._stacks.set(e.value,t-1)}}getMostFrequentStack(){if(!this._stacks)return;let e,t=0;for(const[i,s]of this._stacks)(!e||t0||this._options?.leakWarningThreshold?new d(e?.onListenerError??s.onUnexpectedError,this._options?.leakWarningThreshold??c):void 0,this._perfMon=this._options?._profName?new h(this._options._profName):void 0,this._deliveryQueue=this._options?.deliveryQueue}dispose(){this._disposed||(this._disposed=!0,this._deliveryQueue?.current===this&&this._deliveryQueue.reset(),this._listeners&&(this._listeners=void 0,this._size=0),this._options?.onDidRemoveLastListener?.(),this._leakageMon?.dispose())}get event(){return this._event??=(e,t,i)=>{if(this._leakageMon&&this._size>this._leakageMon.threshold**2){const e=`[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;console.warn(e);const t=this._leakageMon.getMostFrequentStack()??["UNKNOWN stack",-1],i=new f(`${e}. HINT: Stack shows most frequent listener (${t[1]}-times)`,t[0]);return(this._options?.onListenerError||s.onUnexpectedError)(i),n.Disposable.None}if(this._disposed)return n.Disposable.None;t&&(e=e.bind(t));const r=new g(e);let o;this._leakageMon&&this._size>=Math.ceil(.2*this._leakageMon.threshold)&&(r.stack=u.create(),o=this._leakageMon.check(r.stack,this._size+1)),this._listeners?this._listeners instanceof g?(this._deliveryQueue??=new v,this._listeners=[this._listeners,r]):this._listeners.push(r):(this._options?.onWillAddFirstListener?.(this),this._listeners=r,this._options?.onDidAddFirstListener?.(this)),this._size++;const a=(0,n.toDisposable)((()=>{o?.(),this._removeListener(r)}));return i instanceof n.DisposableStore?i.add(a):Array.isArray(i)&&i.push(a),a},this._event}_removeListener(e){if(this._options?.onWillRemoveListener?.(this),!this._listeners)return;if(1===this._size)return this._listeners=void 0,this._options?.onDidRemoveLastListener?.(this),void(this._size=0);const t=this._listeners,i=t.indexOf(e);if(-1===i)throw console.log("disposed?",this._disposed),console.log("size?",this._size),console.log("arr?",JSON.stringify(this._listeners)),new Error("Attempted to dispose unknown listener");this._size--,t[i]=void 0;const s=this._deliveryQueue.current===this;if(2*this._size<=t.length){let e=0;for(let i=0;i0}}t.Emitter=m,t.createEventDeliveryQueue=()=>new v;class v{constructor(){this.i=-1,this.end=0}enqueue(e,t,i){this.i=0,this.end=i,this.current=e,this.value=t}reset(){this.i=this.end,this.current=void 0,this.value=void 0}}t.AsyncEmitter=class extends m{async fireAsync(e,t,i){if(this._listeners)for(this._asyncDeliveryQueue||(this._asyncDeliveryQueue=new o.LinkedList),((e,t)=>{if(e instanceof g)t(e);else for(let i=0;ithis._asyncDeliveryQueue.push([t.value,e])));this._asyncDeliveryQueue.size>0&&!t.isCancellationRequested;){const[e,r]=this._asyncDeliveryQueue.shift(),n=[],o={...r,token:t,waitUntil:t=>{if(Object.isFrozen(n))throw new Error("waitUntil can NOT be called asynchronous");i&&(t=i(t,e)),n.push(t)}};try{e(o)}catch(e){(0,s.onUnexpectedError)(e);continue}Object.freeze(n),await Promise.allSettled(n).then((e=>{for(const t of e)"rejected"===t.status&&(0,s.onUnexpectedError)(t.reason)}))}}};class S extends m{get isPaused(){return 0!==this._isPaused}constructor(e){super(e),this._isPaused=0,this._eventQueue=new o.LinkedList,this._mergeFn=e?.merge}pause(){this._isPaused++}resume(){if(0!==this._isPaused&&0==--this._isPaused)if(this._mergeFn){if(this._eventQueue.size>0){const e=Array.from(this._eventQueue);this._eventQueue.clear(),super.fire(this._mergeFn(e))}}else for(;!this._isPaused&&0!==this._eventQueue.size;)super.fire(this._eventQueue.shift())}fire(e){this._size&&(0!==this._isPaused?this._eventQueue.push(e):super.fire(e))}}t.PauseableEmitter=S,t.DebounceEmitter=class extends S{constructor(e){super(e),this._delay=e.delay??100}fire(e){this._handle||(this.pause(),this._handle=setTimeout((()=>{this._handle=void 0,this.resume()}),this._delay)),super.fire(e)}},t.MicrotaskEmitter=class extends m{constructor(e){super(e),this._queuedEvents=[],this._mergeFn=e?.merge}fire(e){this.hasListeners()&&(this._queuedEvents.push(e),1===this._queuedEvents.length&&queueMicrotask((()=>{this._mergeFn?super.fire(this._mergeFn(this._queuedEvents)):this._queuedEvents.forEach((e=>super.fire(e))),this._queuedEvents=[]})))}};class b{constructor(){this.hasListeners=!1,this.events=[],this.emitter=new m({onWillAddFirstListener:()=>this.onFirstListenerAdd(),onDidRemoveLastListener:()=>this.onLastListenerRemove()})}get event(){return this.emitter.event}add(e){const t={event:e,listener:null};return this.events.push(t),this.hasListeners&&this.hook(t),(0,n.toDisposable)((0,r.createSingleCallFunction)((()=>{this.hasListeners&&this.unhook(t);const e=this.events.indexOf(t);this.events.splice(e,1)})))}onFirstListenerAdd(){this.hasListeners=!0,this.events.forEach((e=>this.hook(e)))}onLastListenerRemove(){this.hasListeners=!1,this.events.forEach((e=>this.unhook(e)))}hook(e){e.listener=e.event((e=>this.emitter.fire(e)))}unhook(e){e.listener?.dispose(),e.listener=null}dispose(){this.emitter.dispose();for(const e of this.events)e.listener?.dispose();this.events=[]}}t.EventMultiplexer=b,t.DynamicListEventMultiplexer=class{constructor(e,t,i,s){this._store=new n.DisposableStore;const r=this._store.add(new b),o=this._store.add(new n.DisposableMap);function a(e){o.set(e,r.add(s(e)))}for(const t of e)a(t);this._store.add(t((e=>{a(e)}))),this._store.add(i((e=>{o.deleteAndDispose(e)}))),this.event=r.event}dispose(){this._store.dispose()}},t.EventBufferer=class{constructor(){this.data=[]}wrapEvent(e,t,i){return(s,r,n)=>e((e=>{const n=this.data[this.data.length-1];if(!t)return void(n?n.buffers.push((()=>s.call(r,e))):s.call(r,e));const o=n;o?(o.items??=[],o.items.push(e),0===o.buffers.length&&n.buffers.push((()=>{o.reducedResult??=i?o.items.reduce(t,i):o.items.reduce(t),s.call(r,o.reducedResult)}))):s.call(r,t(i,e))}),void 0,n)}bufferEvents(e){const t={buffers:new Array};this.data.push(t);const i=e();return this.data.pop(),t.buffers.forEach((e=>e())),i}},t.Relay=class{constructor(){this.listening=!1,this.inputEvent=l.None,this.inputEventListener=n.Disposable.None,this.emitter=new m({onDidAddFirstListener:()=>{this.listening=!0,this.inputEventListener=this.inputEvent(this.emitter.fire,this.emitter)},onDidRemoveLastListener:()=>{this.listening=!1,this.inputEventListener.dispose()}}),this.event=this.emitter.event}set input(e){this.inputEvent=e,this.listening&&(this.inputEventListener.dispose(),this.inputEventListener=e(this.emitter.fire,this.emitter))}dispose(){this.inputEventListener.dispose(),this.emitter.dispose()}},t.ValueWithChangeEvent=class{static const(e){return new C(e)}constructor(e){this._value=e,this._onDidChange=new m,this.onDidChange=this._onDidChange.event}get value(){return this._value}set value(e){e!==this._value&&(this._value=e,this._onDidChange.fire(void 0))}};class C{constructor(e){this.value=e,this.onDidChange=l.None}}},8841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSingleCallFunction=function(e,t){const i=this;let s,r=!1;return function(){if(r)return s;if(r=!0,t)try{s=e.apply(i,arguments)}finally{t()}else s=e.apply(i,arguments);return s}}},6304:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.StringSHA1=t.Hasher=void 0,t.hash=function(e){return a(e,0)},t.doHash=a,t.numberHash=l,t.stringHash=h,t.toHexString=_;const o=n(i(1316));function a(e,t){switch(typeof e){case"object":return null===e?l(349,t):Array.isArray(e)?(i=e,s=l(104579,s=t),i.reduce(((e,t)=>a(t,e)),s)):function(e,t){return t=l(181387,t),Object.keys(e).sort().reduce(((t,i)=>(t=h(i,t),a(e[i],t))),t)}(e,t);case"string":return h(e,t);case"boolean":return function(e,t){return l(e?433:863,t)}(e,t);case"number":return l(e,t);case"undefined":return l(937,t);default:return l(617,t)}var i,s}function l(e,t){return(t<<5)-t+e|0}function h(e,t){t=l(149417,t);for(let i=0,s=e.length;i>>s)>>>0}function u(e,t=0,i=e.byteLength,s=0){for(let r=0;re.toString(16).padStart(2,"0"))).join(""):function(e,t,i="0"){for(;e.length>>0).toString(16),t/4)}t.Hasher=class{constructor(){this._value=0}get value(){return this._value}hash(e){return this._value=a(e,this._value),this._value}},function(e){e[e.BLOCK_SIZE=64]="BLOCK_SIZE",e[e.UNICODE_REPLACEMENT=65533]="UNICODE_REPLACEMENT"}(c||(c={}));class f{static{this._bigBlock32=new DataView(new ArrayBuffer(320))}constructor(){this._h0=1732584193,this._h1=4023233417,this._h2=2562383102,this._h3=271733878,this._h4=3285377520,this._buff=new Uint8Array(c.BLOCK_SIZE+3),this._buffDV=new DataView(this._buff.buffer),this._buffLen=0,this._totalLen=0,this._leftoverHighSurrogate=0,this._finished=!1}update(e){const t=e.length;if(0===t)return;const i=this._buff;let s,r,n=this._buffLen,a=this._leftoverHighSurrogate;for(0!==a?(s=a,r=-1,a=0):(s=e.charCodeAt(0),r=0);;){let l=s;if(o.isHighSurrogate(s)){if(!(r+1>>6,e[t++]=128|(63&i)>>>0):i<65536?(e[t++]=224|(61440&i)>>>12,e[t++]=128|(4032&i)>>>6,e[t++]=128|(63&i)>>>0):(e[t++]=240|(1835008&i)>>>18,e[t++]=128|(258048&i)>>>12,e[t++]=128|(4032&i)>>>6,e[t++]=128|(63&i)>>>0),t>=c.BLOCK_SIZE&&(this._step(),t-=c.BLOCK_SIZE,this._totalLen+=c.BLOCK_SIZE,e[0]=e[c.BLOCK_SIZE+0],e[1]=e[c.BLOCK_SIZE+1],e[2]=e[c.BLOCK_SIZE+2]),t}digest(){return this._finished||(this._finished=!0,this._leftoverHighSurrogate&&(this._leftoverHighSurrogate=0,this._buffLen=this._push(this._buff,this._buffLen,c.UNICODE_REPLACEMENT)),this._totalLen+=this._buffLen,this._wrapUp()),_(this._h0)+_(this._h1)+_(this._h2)+_(this._h3)+_(this._h4)}_wrapUp(){this._buff[this._buffLen++]=128,u(this._buff,this._buffLen),this._buffLen>56&&(this._step(),u(this._buff));const e=8*this._totalLen;this._buffDV.setUint32(56,Math.floor(e/4294967296),!1),this._buffDV.setUint32(60,e%4294967296,!1),this._step()}_step(){const e=f._bigBlock32,t=this._buffDV;for(let i=0;i<64;i+=4)e.setUint32(i,t.getUint32(i,!1),!1);for(let t=64;t<320;t+=4)e.setUint32(t,d(e.getUint32(t-12,!1)^e.getUint32(t-32,!1)^e.getUint32(t-56,!1)^e.getUint32(t-64,!1),1),!1);let i,s,r,n=this._h0,o=this._h1,a=this._h2,l=this._h3,h=this._h4;for(let t=0;t<80;t++)t<20?(i=o&a|~o&l,s=1518500249):t<40?(i=o^a^l,s=1859775393):t<60?(i=o&a|o&l|a&l,s=2400959708):(i=o^a^l,s=3395469782),r=d(n,5)+i+h+s+e.getUint32(4*t,!1)&4294967295,h=l,l=a,a=d(o,30),o=n,n=r;this._h0=this._h0+n&4294967295,this._h1=this._h1+o&4294967295,this._h2=this._h2+a&4294967295,this._h3=this._h3+l&4294967295,this._h4=this._h4+h&4294967295}}t.StringSHA1=f},4218:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.Iterable=void 0,function(e){function t(e){return e&&"object"==typeof e&&"function"==typeof e[Symbol.iterator]}e.is=t;const i=Object.freeze([]);function*s(e){yield e}e.empty=function(){return i},e.single=s,e.wrap=function(e){return t(e)?e:s(e)},e.from=function(e){return e||i},e.reverse=function*(e){for(let t=e.length-1;t>=0;t--)yield e[t]},e.isEmpty=function(e){return!e||!0===e[Symbol.iterator]().next().done},e.first=function(e){return e[Symbol.iterator]().next().value},e.some=function(e,t){let i=0;for(const s of e)if(t(s,i++))return!0;return!1},e.find=function(e,t){for(const i of e)if(t(i))return i},e.filter=function*(e,t){for(const i of e)t(i)&&(yield i)},e.map=function*(e,t){let i=0;for(const s of e)yield t(s,i++)},e.flatMap=function*(e,t){let i=0;for(const s of e)yield*t(s,i++)},e.concat=function*(...e){for(const t of e)yield*t},e.reduce=function(e,t,i){let s=i;for(const i of e)s=t(s,i);return s},e.slice=function*(e,t,i=e.length){for(t<0&&(t+=e.length),i<0?i+=e.length:i>e.length&&(i=e.length);tr}]},e.asyncToArray=async function(e){const t=[];for await(const i of e)t.push(i);return Promise.resolve(t)}}(i||(t.Iterable=i={}))},7883:(e,t)=>{var i,s;Object.defineProperty(t,"__esModule",{value:!0}),t.KeyMod=t.KeyCodeUtils=t.ScanCodeUtils=t.NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE=t.EVENT_KEY_CODE_MAP=t.ScanCode=t.KeyCode=void 0,t.KeyChord=function(e,t){return(e|(65535&t)<<16>>>0)>>>0},function(e){e[e.DependsOnKbLayout=-1]="DependsOnKbLayout",e[e.Unknown=0]="Unknown",e[e.Backspace=1]="Backspace",e[e.Tab=2]="Tab",e[e.Enter=3]="Enter",e[e.Shift=4]="Shift",e[e.Ctrl=5]="Ctrl",e[e.Alt=6]="Alt",e[e.PauseBreak=7]="PauseBreak",e[e.CapsLock=8]="CapsLock",e[e.Escape=9]="Escape",e[e.Space=10]="Space",e[e.PageUp=11]="PageUp",e[e.PageDown=12]="PageDown",e[e.End=13]="End",e[e.Home=14]="Home",e[e.LeftArrow=15]="LeftArrow",e[e.UpArrow=16]="UpArrow",e[e.RightArrow=17]="RightArrow",e[e.DownArrow=18]="DownArrow",e[e.Insert=19]="Insert",e[e.Delete=20]="Delete",e[e.Digit0=21]="Digit0",e[e.Digit1=22]="Digit1",e[e.Digit2=23]="Digit2",e[e.Digit3=24]="Digit3",e[e.Digit4=25]="Digit4",e[e.Digit5=26]="Digit5",e[e.Digit6=27]="Digit6",e[e.Digit7=28]="Digit7",e[e.Digit8=29]="Digit8",e[e.Digit9=30]="Digit9",e[e.KeyA=31]="KeyA",e[e.KeyB=32]="KeyB",e[e.KeyC=33]="KeyC",e[e.KeyD=34]="KeyD",e[e.KeyE=35]="KeyE",e[e.KeyF=36]="KeyF",e[e.KeyG=37]="KeyG",e[e.KeyH=38]="KeyH",e[e.KeyI=39]="KeyI",e[e.KeyJ=40]="KeyJ",e[e.KeyK=41]="KeyK",e[e.KeyL=42]="KeyL",e[e.KeyM=43]="KeyM",e[e.KeyN=44]="KeyN",e[e.KeyO=45]="KeyO",e[e.KeyP=46]="KeyP",e[e.KeyQ=47]="KeyQ",e[e.KeyR=48]="KeyR",e[e.KeyS=49]="KeyS",e[e.KeyT=50]="KeyT",e[e.KeyU=51]="KeyU",e[e.KeyV=52]="KeyV",e[e.KeyW=53]="KeyW",e[e.KeyX=54]="KeyX",e[e.KeyY=55]="KeyY",e[e.KeyZ=56]="KeyZ",e[e.Meta=57]="Meta",e[e.ContextMenu=58]="ContextMenu",e[e.F1=59]="F1",e[e.F2=60]="F2",e[e.F3=61]="F3",e[e.F4=62]="F4",e[e.F5=63]="F5",e[e.F6=64]="F6",e[e.F7=65]="F7",e[e.F8=66]="F8",e[e.F9=67]="F9",e[e.F10=68]="F10",e[e.F11=69]="F11",e[e.F12=70]="F12",e[e.F13=71]="F13",e[e.F14=72]="F14",e[e.F15=73]="F15",e[e.F16=74]="F16",e[e.F17=75]="F17",e[e.F18=76]="F18",e[e.F19=77]="F19",e[e.F20=78]="F20",e[e.F21=79]="F21",e[e.F22=80]="F22",e[e.F23=81]="F23",e[e.F24=82]="F24",e[e.NumLock=83]="NumLock",e[e.ScrollLock=84]="ScrollLock",e[e.Semicolon=85]="Semicolon",e[e.Equal=86]="Equal",e[e.Comma=87]="Comma",e[e.Minus=88]="Minus",e[e.Period=89]="Period",e[e.Slash=90]="Slash",e[e.Backquote=91]="Backquote",e[e.BracketLeft=92]="BracketLeft",e[e.Backslash=93]="Backslash",e[e.BracketRight=94]="BracketRight",e[e.Quote=95]="Quote",e[e.OEM_8=96]="OEM_8",e[e.IntlBackslash=97]="IntlBackslash",e[e.Numpad0=98]="Numpad0",e[e.Numpad1=99]="Numpad1",e[e.Numpad2=100]="Numpad2",e[e.Numpad3=101]="Numpad3",e[e.Numpad4=102]="Numpad4",e[e.Numpad5=103]="Numpad5",e[e.Numpad6=104]="Numpad6",e[e.Numpad7=105]="Numpad7",e[e.Numpad8=106]="Numpad8",e[e.Numpad9=107]="Numpad9",e[e.NumpadMultiply=108]="NumpadMultiply",e[e.NumpadAdd=109]="NumpadAdd",e[e.NUMPAD_SEPARATOR=110]="NUMPAD_SEPARATOR",e[e.NumpadSubtract=111]="NumpadSubtract",e[e.NumpadDecimal=112]="NumpadDecimal",e[e.NumpadDivide=113]="NumpadDivide",e[e.KEY_IN_COMPOSITION=114]="KEY_IN_COMPOSITION",e[e.ABNT_C1=115]="ABNT_C1",e[e.ABNT_C2=116]="ABNT_C2",e[e.AudioVolumeMute=117]="AudioVolumeMute",e[e.AudioVolumeUp=118]="AudioVolumeUp",e[e.AudioVolumeDown=119]="AudioVolumeDown",e[e.BrowserSearch=120]="BrowserSearch",e[e.BrowserHome=121]="BrowserHome",e[e.BrowserBack=122]="BrowserBack",e[e.BrowserForward=123]="BrowserForward",e[e.MediaTrackNext=124]="MediaTrackNext",e[e.MediaTrackPrevious=125]="MediaTrackPrevious",e[e.MediaStop=126]="MediaStop",e[e.MediaPlayPause=127]="MediaPlayPause",e[e.LaunchMediaPlayer=128]="LaunchMediaPlayer",e[e.LaunchMail=129]="LaunchMail",e[e.LaunchApp2=130]="LaunchApp2",e[e.Clear=131]="Clear",e[e.MAX_VALUE=132]="MAX_VALUE"}(i||(t.KeyCode=i={})),function(e){e[e.DependsOnKbLayout=-1]="DependsOnKbLayout",e[e.None=0]="None",e[e.Hyper=1]="Hyper",e[e.Super=2]="Super",e[e.Fn=3]="Fn",e[e.FnLock=4]="FnLock",e[e.Suspend=5]="Suspend",e[e.Resume=6]="Resume",e[e.Turbo=7]="Turbo",e[e.Sleep=8]="Sleep",e[e.WakeUp=9]="WakeUp",e[e.KeyA=10]="KeyA",e[e.KeyB=11]="KeyB",e[e.KeyC=12]="KeyC",e[e.KeyD=13]="KeyD",e[e.KeyE=14]="KeyE",e[e.KeyF=15]="KeyF",e[e.KeyG=16]="KeyG",e[e.KeyH=17]="KeyH",e[e.KeyI=18]="KeyI",e[e.KeyJ=19]="KeyJ",e[e.KeyK=20]="KeyK",e[e.KeyL=21]="KeyL",e[e.KeyM=22]="KeyM",e[e.KeyN=23]="KeyN",e[e.KeyO=24]="KeyO",e[e.KeyP=25]="KeyP",e[e.KeyQ=26]="KeyQ",e[e.KeyR=27]="KeyR",e[e.KeyS=28]="KeyS",e[e.KeyT=29]="KeyT",e[e.KeyU=30]="KeyU",e[e.KeyV=31]="KeyV",e[e.KeyW=32]="KeyW",e[e.KeyX=33]="KeyX",e[e.KeyY=34]="KeyY",e[e.KeyZ=35]="KeyZ",e[e.Digit1=36]="Digit1",e[e.Digit2=37]="Digit2",e[e.Digit3=38]="Digit3",e[e.Digit4=39]="Digit4",e[e.Digit5=40]="Digit5",e[e.Digit6=41]="Digit6",e[e.Digit7=42]="Digit7",e[e.Digit8=43]="Digit8",e[e.Digit9=44]="Digit9",e[e.Digit0=45]="Digit0",e[e.Enter=46]="Enter",e[e.Escape=47]="Escape",e[e.Backspace=48]="Backspace",e[e.Tab=49]="Tab",e[e.Space=50]="Space",e[e.Minus=51]="Minus",e[e.Equal=52]="Equal",e[e.BracketLeft=53]="BracketLeft",e[e.BracketRight=54]="BracketRight",e[e.Backslash=55]="Backslash",e[e.IntlHash=56]="IntlHash",e[e.Semicolon=57]="Semicolon",e[e.Quote=58]="Quote",e[e.Backquote=59]="Backquote",e[e.Comma=60]="Comma",e[e.Period=61]="Period",e[e.Slash=62]="Slash",e[e.CapsLock=63]="CapsLock",e[e.F1=64]="F1",e[e.F2=65]="F2",e[e.F3=66]="F3",e[e.F4=67]="F4",e[e.F5=68]="F5",e[e.F6=69]="F6",e[e.F7=70]="F7",e[e.F8=71]="F8",e[e.F9=72]="F9",e[e.F10=73]="F10",e[e.F11=74]="F11",e[e.F12=75]="F12",e[e.PrintScreen=76]="PrintScreen",e[e.ScrollLock=77]="ScrollLock",e[e.Pause=78]="Pause",e[e.Insert=79]="Insert",e[e.Home=80]="Home",e[e.PageUp=81]="PageUp",e[e.Delete=82]="Delete",e[e.End=83]="End",e[e.PageDown=84]="PageDown",e[e.ArrowRight=85]="ArrowRight",e[e.ArrowLeft=86]="ArrowLeft",e[e.ArrowDown=87]="ArrowDown",e[e.ArrowUp=88]="ArrowUp",e[e.NumLock=89]="NumLock",e[e.NumpadDivide=90]="NumpadDivide",e[e.NumpadMultiply=91]="NumpadMultiply",e[e.NumpadSubtract=92]="NumpadSubtract",e[e.NumpadAdd=93]="NumpadAdd",e[e.NumpadEnter=94]="NumpadEnter",e[e.Numpad1=95]="Numpad1",e[e.Numpad2=96]="Numpad2",e[e.Numpad3=97]="Numpad3",e[e.Numpad4=98]="Numpad4",e[e.Numpad5=99]="Numpad5",e[e.Numpad6=100]="Numpad6",e[e.Numpad7=101]="Numpad7",e[e.Numpad8=102]="Numpad8",e[e.Numpad9=103]="Numpad9",e[e.Numpad0=104]="Numpad0",e[e.NumpadDecimal=105]="NumpadDecimal",e[e.IntlBackslash=106]="IntlBackslash",e[e.ContextMenu=107]="ContextMenu",e[e.Power=108]="Power",e[e.NumpadEqual=109]="NumpadEqual",e[e.F13=110]="F13",e[e.F14=111]="F14",e[e.F15=112]="F15",e[e.F16=113]="F16",e[e.F17=114]="F17",e[e.F18=115]="F18",e[e.F19=116]="F19",e[e.F20=117]="F20",e[e.F21=118]="F21",e[e.F22=119]="F22",e[e.F23=120]="F23",e[e.F24=121]="F24",e[e.Open=122]="Open",e[e.Help=123]="Help",e[e.Select=124]="Select",e[e.Again=125]="Again",e[e.Undo=126]="Undo",e[e.Cut=127]="Cut",e[e.Copy=128]="Copy",e[e.Paste=129]="Paste",e[e.Find=130]="Find",e[e.AudioVolumeMute=131]="AudioVolumeMute",e[e.AudioVolumeUp=132]="AudioVolumeUp",e[e.AudioVolumeDown=133]="AudioVolumeDown",e[e.NumpadComma=134]="NumpadComma",e[e.IntlRo=135]="IntlRo",e[e.KanaMode=136]="KanaMode",e[e.IntlYen=137]="IntlYen",e[e.Convert=138]="Convert",e[e.NonConvert=139]="NonConvert",e[e.Lang1=140]="Lang1",e[e.Lang2=141]="Lang2",e[e.Lang3=142]="Lang3",e[e.Lang4=143]="Lang4",e[e.Lang5=144]="Lang5",e[e.Abort=145]="Abort",e[e.Props=146]="Props",e[e.NumpadParenLeft=147]="NumpadParenLeft",e[e.NumpadParenRight=148]="NumpadParenRight",e[e.NumpadBackspace=149]="NumpadBackspace",e[e.NumpadMemoryStore=150]="NumpadMemoryStore",e[e.NumpadMemoryRecall=151]="NumpadMemoryRecall",e[e.NumpadMemoryClear=152]="NumpadMemoryClear",e[e.NumpadMemoryAdd=153]="NumpadMemoryAdd",e[e.NumpadMemorySubtract=154]="NumpadMemorySubtract",e[e.NumpadClear=155]="NumpadClear",e[e.NumpadClearEntry=156]="NumpadClearEntry",e[e.ControlLeft=157]="ControlLeft",e[e.ShiftLeft=158]="ShiftLeft",e[e.AltLeft=159]="AltLeft",e[e.MetaLeft=160]="MetaLeft",e[e.ControlRight=161]="ControlRight",e[e.ShiftRight=162]="ShiftRight",e[e.AltRight=163]="AltRight",e[e.MetaRight=164]="MetaRight",e[e.BrightnessUp=165]="BrightnessUp",e[e.BrightnessDown=166]="BrightnessDown",e[e.MediaPlay=167]="MediaPlay",e[e.MediaRecord=168]="MediaRecord",e[e.MediaFastForward=169]="MediaFastForward",e[e.MediaRewind=170]="MediaRewind",e[e.MediaTrackNext=171]="MediaTrackNext",e[e.MediaTrackPrevious=172]="MediaTrackPrevious",e[e.MediaStop=173]="MediaStop",e[e.Eject=174]="Eject",e[e.MediaPlayPause=175]="MediaPlayPause",e[e.MediaSelect=176]="MediaSelect",e[e.LaunchMail=177]="LaunchMail",e[e.LaunchApp2=178]="LaunchApp2",e[e.LaunchApp1=179]="LaunchApp1",e[e.SelectTask=180]="SelectTask",e[e.LaunchScreenSaver=181]="LaunchScreenSaver",e[e.BrowserSearch=182]="BrowserSearch",e[e.BrowserHome=183]="BrowserHome",e[e.BrowserBack=184]="BrowserBack",e[e.BrowserForward=185]="BrowserForward",e[e.BrowserStop=186]="BrowserStop",e[e.BrowserRefresh=187]="BrowserRefresh",e[e.BrowserFavorites=188]="BrowserFavorites",e[e.ZoomToggle=189]="ZoomToggle",e[e.MailReply=190]="MailReply",e[e.MailForward=191]="MailForward",e[e.MailSend=192]="MailSend",e[e.MAX_VALUE=193]="MAX_VALUE"}(s||(t.ScanCode=s={}));class r{constructor(){this._keyCodeToStr=[],this._strToKeyCode=Object.create(null)}define(e,t){this._keyCodeToStr[e]=t,this._strToKeyCode[t.toLowerCase()]=e}keyCodeToStr(e){return this._keyCodeToStr[e]}strToKeyCode(e){return this._strToKeyCode[e.toLowerCase()]||i.Unknown}}const n=new r,o=new r,a=new r;t.EVENT_KEY_CODE_MAP=new Array(230),t.NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE={};const l=[],h=Object.create(null),c=Object.create(null);var d,u;t.ScanCodeUtils={lowerCaseToEnum:e=>c[e]||s.None,toEnum:e=>h[e]||s.None,toString:e=>l[e]||"None"},function(e){e.toString=function(e){return n.keyCodeToStr(e)},e.fromString=function(e){return n.strToKeyCode(e)},e.toUserSettingsUS=function(e){return o.keyCodeToStr(e)},e.toUserSettingsGeneral=function(e){return a.keyCodeToStr(e)},e.fromUserSettings=function(e){return o.strToKeyCode(e)||a.strToKeyCode(e)},e.toElectronAccelerator=function(e){if(e>=i.Numpad0&&e<=i.NumpadDivide)return null;switch(e){case i.UpArrow:return"Up";case i.DownArrow:return"Down";case i.LeftArrow:return"Left";case i.RightArrow:return"Right"}return n.keyCodeToStr(e)}}(d||(t.KeyCodeUtils=d={})),function(e){e[e.CtrlCmd=2048]="CtrlCmd",e[e.Shift=1024]="Shift",e[e.Alt=512]="Alt",e[e.WinCtrl=256]="WinCtrl"}(u||(t.KeyMod=u={}))},2811:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ResolvedKeybinding=t.ResolvedChord=t.Keybinding=t.ScanCodeChord=t.KeyCodeChord=void 0,t.decodeKeybinding=function(e,t){if("number"==typeof e){if(0===e)return null;const i=(65535&e)>>>0,s=(4294901760&e)>>>16;return new c(0!==s?[a(i,t),a(s,t)]:[a(i,t)])}{const i=[];for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.Lazy=void 0,t.Lazy=class{constructor(e){this.executor=e,this._didRun=!1}get hasValue(){return this._didRun}get value(){if(!this._didRun)try{this._value=this.executor()}catch(e){this._error=e}finally{this._didRun=!0}if(this._error)throw this._error;return this._value}get rawValue(){return this._value}}},7150:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DisposableMap=t.ImmortalReference=t.AsyncReferenceCollection=t.ReferenceCollection=t.SafeDisposable=t.RefCountedDisposable=t.MandatoryMutableDisposable=t.MutableDisposable=t.Disposable=t.DisposableStore=t.DisposableTracker=void 0,t.setDisposableTracker=function(e){l=e},t.trackDisposable=c,t.markAsDisposed=d,t.markAsSingleton=function(e){return l?.markAsSingleton(e),e},t.isDisposable=_,t.dispose=f,t.disposeIfDisposable=function(e){for(const t of e)_(t)&&t.dispose();return[]},t.combinedDisposable=function(...e){const t=p((()=>f(e)));return function(e,t){if(l)for(const i of e)l.setParent(i,t)}(e,t),t},t.toDisposable=p,t.disposeOnReturn=function(e){const t=new g;try{e(t)}finally{t.dispose()}};const s=i(3058),r=i(9087),n=i(2608),o=i(8841),a=i(4218);let l=null;class h{constructor(){this.livingDisposables=new Map}static{this.idx=0}getDisposableData(e){let t=this.livingDisposables.get(e);return t||(t={parent:null,source:null,isSingleton:!1,value:e,idx:h.idx++},this.livingDisposables.set(e,t)),t}trackDisposable(e){const t=this.getDisposableData(e);t.source||(t.source=(new Error).stack)}setParent(e,t){this.getDisposableData(e).parent=t}markAsDisposed(e){this.livingDisposables.delete(e)}markAsSingleton(e){this.getDisposableData(e).isSingleton=!0}getRootParent(e,t){const i=t.get(e);if(i)return i;const s=e.parent?this.getRootParent(this.getDisposableData(e.parent),t):e;return t.set(e,s),s}getTrackedDisposables(){const e=new Map;return[...this.livingDisposables.entries()].filter((([,t])=>null!==t.source&&!this.getRootParent(t,e).isSingleton)).flatMap((([e])=>e))}computeLeakingDisposables(e=10,t){let i;if(t)i=t;else{const e=new Map,t=[...this.livingDisposables.values()].filter((t=>null!==t.source&&!this.getRootParent(t,e).isSingleton));if(0===t.length)return;const s=new Set(t.map((e=>e.value)));if(i=t.filter((e=>!(e.parent&&s.has(e.parent)))),0===i.length)throw new Error("There are cyclic diposable chains!")}if(!i)return;function o(e){const t=e.source.split("\n").map((e=>e.trim().replace("at ",""))).filter((e=>""!==e));return function(e,t){for(;e.length>0&&t.some((t=>"string"==typeof t?t===e[0]:e[0].match(t)));)e.shift()}(t,["Error",/^trackDisposable \(.*\)$/,/^DisposableTracker.trackDisposable \(.*\)$/]),t.reverse()}const a=new n.SetMap;for(const e of i){const t=o(e);for(let i=0;i<=t.length;i++)a.add(t.slice(0,i).join("\n"),e)}i.sort((0,s.compareBy)((e=>e.idx),s.numberComparator));let l="",h=0;for(const t of i.slice(0,e)){h++;const e=o(t),s=[];for(let t=0;to(e)[t])),(e=>e));delete h[e[t]];for(const[e,t]of Object.entries(h))s.unshift(` - stacktraces of ${t.length} other leaks continue with ${e}`);s.unshift(n)}l+=`\n\n\n==================== Leaking disposable ${h}/${i.length}: ${t.value.constructor.name} ====================\n${s.join("\n")}\n============================================================\n\n`}return i.length>e&&(l+=`\n\n\n... and ${i.length-e} more leaking disposables\n\n`),{leaks:i,details:l}}}function c(e){return l?.trackDisposable(e),e}function d(e){l?.markAsDisposed(e)}function u(e,t){l?.setParent(e,t)}function _(e){return"object"==typeof e&&null!==e&&"function"==typeof e.dispose&&0===e.dispose.length}function f(e){if(a.Iterable.is(e)){const t=[];for(const i of e)if(i)try{i.dispose()}catch(e){t.push(e)}if(1===t.length)throw t[0];if(t.length>1)throw new AggregateError(t,"Encountered errors while disposing of store");return Array.isArray(e)?[]:e}if(e)return e.dispose(),e}function p(e){const t=c({dispose:(0,o.createSingleCallFunction)((()=>{d(t),e()}))});return t}t.DisposableTracker=h;class g{static{this.DISABLE_DISPOSED_WARNING=!1}constructor(){this._toDispose=new Set,this._isDisposed=!1,c(this)}dispose(){this._isDisposed||(d(this),this._isDisposed=!0,this.clear())}get isDisposed(){return this._isDisposed}clear(){if(0!==this._toDispose.size)try{f(this._toDispose)}finally{this._toDispose.clear()}}add(e){if(!e)return e;if(e===this)throw new Error("Cannot register a disposable on itself!");return u(e,this),this._isDisposed?g.DISABLE_DISPOSED_WARNING||console.warn(new Error("Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!").stack):this._toDispose.add(e),e}delete(e){if(e){if(e===this)throw new Error("Cannot dispose a disposable on itself!");this._toDispose.delete(e),e.dispose()}}deleteAndLeak(e){e&&this._toDispose.has(e)&&(this._toDispose.delete(e),u(e,null))}}t.DisposableStore=g;class m{static{this.None=Object.freeze({dispose(){}})}constructor(){this._store=new g,c(this),u(this._store,this)}dispose(){d(this),this._store.dispose()}_register(e){if(e===this)throw new Error("Cannot register a disposable on itself!");return this._store.add(e)}}t.Disposable=m;class v{constructor(){this._isDisposed=!1,c(this)}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),e&&u(e,this),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,d(this),this._value?.dispose(),this._value=void 0}clearAndLeak(){const e=this._value;return this._value=void 0,e&&u(e,null),e}}t.MutableDisposable=v,t.MandatoryMutableDisposable=class{constructor(e){this._disposable=new v,this._isDisposed=!1,this._disposable.value=e}get value(){return this._disposable.value}set value(e){this._isDisposed||e===this._disposable.value||(this._disposable.value=e)}dispose(){this._isDisposed=!0,this._disposable.dispose()}},t.RefCountedDisposable=class{constructor(e){this._disposable=e,this._counter=1}acquire(){return this._counter++,this}release(){return 0==--this._counter&&this._disposable.dispose(),this}},t.SafeDisposable=class{constructor(){this.dispose=()=>{},this.unset=()=>{},this.isset=()=>!1,c(this)}set(e){let t=e;return this.unset=()=>t=void 0,this.isset=()=>void 0!==t,this.dispose=()=>{t&&(t(),t=void 0,d(this))},this}},t.ReferenceCollection=class{constructor(){this.references=new Map}acquire(e,...t){let i=this.references.get(e);i||(i={counter:0,object:this.createReferencedObject(e,...t)},this.references.set(e,i));const{object:s}=i,r=(0,o.createSingleCallFunction)((()=>{0==--i.counter&&(this.destroyReferencedObject(e,i.object),this.references.delete(e))}));return i.counter++,{object:s,dispose:r}}},t.AsyncReferenceCollection=class{constructor(e){this.referenceCollection=e}async acquire(e,...t){const i=this.referenceCollection.acquire(e,...t);try{return{object:await i.object,dispose:()=>i.dispose()}}catch(e){throw i.dispose(),e}}},t.ImmortalReference=class{constructor(e){this.object=e}dispose(){}};class S{constructor(){this._store=new Map,this._isDisposed=!1,c(this)}dispose(){d(this),this._isDisposed=!0,this.clearAndDisposeAll()}clearAndDisposeAll(){if(this._store.size)try{f(this._store.values())}finally{this._store.clear()}}has(e){return this._store.has(e)}get size(){return this._store.size}get(e){return this._store.get(e)}set(e,t,i=!1){this._isDisposed&&console.warn(new Error("Trying to add a disposable to a DisposableMap that has already been disposed of. The added object will be leaked!").stack),i||this._store.get(e)?.dispose(),this._store.set(e,t)}deleteAndDispose(e){this._store.get(e)?.dispose(),this._store.delete(e)}deleteAndLeak(e){const t=this._store.get(e);return this._store.delete(e),t}keys(){return this._store.keys()}values(){return this._store.values()}[Symbol.iterator](){return this._store[Symbol.iterator]()}}t.DisposableMap=S},6317:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkedList=void 0;class i{static{this.Undefined=new i(void 0)}constructor(e){this.element=e,this.next=i.Undefined,this.prev=i.Undefined}}class s{constructor(){this._first=i.Undefined,this._last=i.Undefined,this._size=0}get size(){return this._size}isEmpty(){return this._first===i.Undefined}clear(){let e=this._first;for(;e!==i.Undefined;){const t=e.next;e.prev=i.Undefined,e.next=i.Undefined,e=t}this._first=i.Undefined,this._last=i.Undefined,this._size=0}unshift(e){return this._insert(e,!1)}push(e){return this._insert(e,!0)}_insert(e,t){const s=new i(e);if(this._first===i.Undefined)this._first=s,this._last=s;else if(t){const e=this._last;this._last=s,s.prev=e,e.next=s}else{const e=this._first;this._first=s,s.next=e,e.prev=s}this._size+=1;let r=!1;return()=>{r||(r=!0,this._remove(s))}}shift(){if(this._first!==i.Undefined){const e=this._first.element;return this._remove(this._first),e}}pop(){if(this._last!==i.Undefined){const e=this._last.element;return this._remove(this._last),e}}_remove(e){if(e.prev!==i.Undefined&&e.next!==i.Undefined){const t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===i.Undefined&&e.next===i.Undefined?(this._first=i.Undefined,this._last=i.Undefined):e.next===i.Undefined?(this._last=this._last.prev,this._last.next=i.Undefined):e.prev===i.Undefined&&(this._first=this._first.next,this._first.prev=i.Undefined);this._size-=1}*[Symbol.iterator](){let e=this._first;for(;e!==i.Undefined;)yield e.element,e=e.next}}t.LinkedList=s},2608:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.SetMap=t.BidirectionalMap=t.CounterSet=t.Touch=void 0,t.getOrSet=function(e,t,i){let s=e.get(t);return void 0===s&&(s=i,e.set(t,s)),s},t.mapToString=function(e){const t=[];return e.forEach(((e,i)=>{t.push(`${i} => ${e}`)})),`Map(${e.size}) {${t.join(", ")}}`},t.setToString=function(e){const t=[];return e.forEach((e=>{t.push(e)})),`Set(${e.size}) {${t.join(", ")}}`},t.mapsStrictEqualIgnoreOrder=function(e,t){if(e===t)return!0;if(e.size!==t.size)return!1;for(const[i,s]of e)if(!t.has(i)||t.get(i)!==s)return!1;for(const[i]of t)if(!e.has(i))return!1;return!0},function(e){e[e.None=0]="None",e[e.AsOld=1]="AsOld",e[e.AsNew=2]="AsNew"}(i||(t.Touch=i={})),t.CounterSet=class{constructor(){this.map=new Map}add(e){return this.map.set(e,(this.map.get(e)||0)+1),this}delete(e){let t=this.map.get(e)||0;return 0!==t&&(t--,0===t?this.map.delete(e):this.map.set(e,t),!0)}has(e){return this.map.has(e)}},t.BidirectionalMap=class{constructor(e){if(this._m1=new Map,this._m2=new Map,e)for(const[t,i]of e)this.set(t,i)}clear(){this._m1.clear(),this._m2.clear()}set(e,t){this._m1.set(e,t),this._m2.set(t,e)}get(e){return this._m1.get(e)}getKey(e){return this._m2.get(e)}delete(e){const t=this._m1.get(e);return void 0!==t&&(this._m1.delete(e),this._m2.delete(t),!0)}forEach(e,t){this._m1.forEach(((i,s)=>{e.call(t,i,s,this)}))}keys(){return this._m1.keys()}values(){return this._m1.values()}},t.SetMap=class{constructor(){this.map=new Map}add(e,t){let i=this.map.get(e);i||(i=new Set,this.map.set(e,i)),i.add(t)}delete(e,t){const i=this.map.get(e);i&&(i.delete(t),0===i.size&&this.map.delete(e))}forEach(e,t){const i=this.map.get(e);i&&i.forEach(t)}get(e){return this.map.get(e)||new Set}}},7704:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SlidingWindowAverage=t.MovingAverage=t.Counter=void 0,t.clamp=function(e,t,i){return Math.min(Math.max(e,t),i)},t.rot=function(e,t){return(t+e%t)%t},t.isPointWithinTriangle=function(e,t,i,s,r,n,o,a){const l=o-i,h=a-s,c=r-i,d=n-s,u=e-i,_=t-s,f=l*l+h*h,p=l*c+h*d,g=l*u+h*_,m=c*c+d*d,v=c*u+d*_,S=1/(f*m-p*p),b=(m*g-p*v)*S,C=(f*v-p*g)*S;return b>=0&&C>=0&&b+C<1},t.Counter=class{constructor(){this._next=0}getNext(){return this._next++}},t.MovingAverage=class{constructor(){this._n=1,this._val=0}update(e){return this._val=this._val+(e-this._val)/this._n,this._n+=1,this._val}get value(){return this._val}},t.SlidingWindowAverage=class{constructor(e){this._n=0,this._val=0,this._values=[],this._index=0,this._sum=0,this._values=new Array(e),this._values.fill(0,0,e)}update(e){const t=this._values[this._index];return this._values[this._index]=e,this._index=(this._index+1)%this._values.length,this._sum-=t,this._sum+=e,this._n{Object.defineProperty(t,"__esModule",{value:!0}),t.isAndroid=t.isEdge=t.isSafari=t.isFirefox=t.isChrome=t.OS=t.OperatingSystem=t.setTimeout0=t.setTimeout0IsFaster=t.translationsConfigFile=t.platformLocale=t.locale=t.Language=t.language=t.userAgent=t.platform=t.isCI=t.isMobile=t.isIOS=t.webWorkerOrigin=t.isWebWorker=t.isWeb=t.isElectron=t.isNative=t.isLinuxSnap=t.isLinux=t.isMacintosh=t.isWindows=t.Platform=t.LANGUAGE_DEFAULT=void 0,t.PlatformToString=function(e){switch(e){case C.Web:return"Web";case C.Mac:return"Mac";case C.Linux:return"Linux";case C.Windows:return"Windows"}},t.isLittleEndian=function(){if(!L){L=!0;const e=new Uint8Array(2);e[0]=1,e[1]=2;const t=new Uint16Array(e.buffer);D=513===t[0]}return D},t.isBigSurOrNewer=function(e){return parseFloat(e)>=20},t.LANGUAGE_DEFAULT="en";let i,s,r,n=!1,o=!1,a=!1,l=!1,h=!1,c=!1,d=!1,u=!1,_=!1,f=!1,p=t.LANGUAGE_DEFAULT,g=t.LANGUAGE_DEFAULT;const m=globalThis;let v;void 0!==m.vscode&&void 0!==m.vscode.process?v=m.vscode.process:"undefined"!=typeof process&&"string"==typeof process?.versions?.node&&(v=process);const S="string"==typeof v?.versions?.electron,b=S&&"renderer"===v?.type;if("object"==typeof v){n="win32"===v.platform,o="darwin"===v.platform,a="linux"===v.platform,l=a&&!!v.env.SNAP&&!!v.env.SNAP_REVISION,d=S,_=!!v.env.CI||!!v.env.BUILD_ARTIFACTSTAGINGDIRECTORY,i=t.LANGUAGE_DEFAULT,p=t.LANGUAGE_DEFAULT;const e=v.env.VSCODE_NLS_CONFIG;if(e)try{const r=JSON.parse(e);i=r.userLocale,g=r.osLocale,p=r.resolvedLanguage||t.LANGUAGE_DEFAULT,s=r.languagePack?.translationsConfigFile}catch(e){}h=!0}else"object"!=typeof navigator||b?console.error("Unable to resolve platform."):(r=navigator.userAgent,n=r.indexOf("Windows")>=0,o=r.indexOf("Macintosh")>=0,u=(r.indexOf("Macintosh")>=0||r.indexOf("iPad")>=0||r.indexOf("iPhone")>=0)&&!!navigator.maxTouchPoints&&navigator.maxTouchPoints>0,a=r.indexOf("Linux")>=0,f=r?.indexOf("Mobi")>=0,c=!0,p=globalThis._VSCODE_NLS_LANGUAGE||t.LANGUAGE_DEFAULT,i=navigator.language.toLowerCase(),g=i);var C;!function(e){e[e.Web=0]="Web",e[e.Mac=1]="Mac",e[e.Linux=2]="Linux",e[e.Windows=3]="Windows"}(C||(t.Platform=C={}));let y=C.Web;var w,E;o?y=C.Mac:n?y=C.Windows:a&&(y=C.Linux),t.isWindows=n,t.isMacintosh=o,t.isLinux=a,t.isLinuxSnap=l,t.isNative=h,t.isElectron=d,t.isWeb=c,t.isWebWorker=c&&"function"==typeof m.importScripts,t.webWorkerOrigin=t.isWebWorker?m.origin:void 0,t.isIOS=u,t.isMobile=f,t.isCI=_,t.platform=y,t.userAgent=r,t.language=p,function(e){e.value=function(){return t.language},e.isDefaultVariant=function(){return 2===t.language.length?"en"===t.language:t.language.length>=3&&"e"===t.language[0]&&"n"===t.language[1]&&"-"===t.language[2]},e.isDefault=function(){return"en"===t.language}}(w||(t.Language=w={})),t.locale=i,t.platformLocale=g,t.translationsConfigFile=s,t.setTimeout0IsFaster="function"==typeof m.postMessage&&!m.importScripts,t.setTimeout0=(()=>{if(t.setTimeout0IsFaster){const e=[];m.addEventListener("message",(t=>{if(t.data&&t.data.vscodeScheduleAsyncWork)for(let i=0,s=e.length;i{const s=++t;e.push({id:s,callback:i}),m.postMessage({vscodeScheduleAsyncWork:s},"*")}}return e=>setTimeout(e)})(),function(e){e[e.Windows=1]="Windows",e[e.Macintosh=2]="Macintosh",e[e.Linux=3]="Linux"}(E||(t.OperatingSystem=E={})),t.OS=o||u?E.Macintosh:n?E.Windows:E.Linux;let D=!0,L=!1;t.isChrome=!!(t.userAgent&&t.userAgent.indexOf("Chrome")>=0),t.isFirefox=!!(t.userAgent&&t.userAgent.indexOf("Firefox")>=0),t.isSafari=!!(!t.isChrome&&t.userAgent&&t.userAgent.indexOf("Safari")>=0),t.isEdge=!!(t.userAgent&&t.userAgent.indexOf("Edg/")>=0),t.isAndroid=!!(t.userAgent&&t.userAgent.indexOf("Android")>=0)},9881:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SmoothScrollingOperation=t.SmoothScrollingUpdate=t.Scrollable=t.ScrollState=t.ScrollbarVisibility=void 0;const s=i(802),r=i(7150);var n;!function(e){e[e.Auto=1]="Auto",e[e.Hidden=2]="Hidden",e[e.Visible=3]="Visible"}(n||(t.ScrollbarVisibility=n={}));class o{constructor(e,t,i,s,r,n,o){this._forceIntegerValues=e,this._scrollStateBrand=void 0,this._forceIntegerValues&&(t|=0,i|=0,s|=0,r|=0,n|=0,o|=0),this.rawScrollLeft=s,this.rawScrollTop=o,t<0&&(t=0),s+t>i&&(s=i-t),s<0&&(s=0),r<0&&(r=0),o+r>n&&(o=n-r),o<0&&(o=0),this.width=t,this.scrollWidth=i,this.scrollLeft=s,this.height=r,this.scrollHeight=n,this.scrollTop=o}equals(e){return this.rawScrollLeft===e.rawScrollLeft&&this.rawScrollTop===e.rawScrollTop&&this.width===e.width&&this.scrollWidth===e.scrollWidth&&this.scrollLeft===e.scrollLeft&&this.height===e.height&&this.scrollHeight===e.scrollHeight&&this.scrollTop===e.scrollTop}withScrollDimensions(e,t){return new o(this._forceIntegerValues,void 0!==e.width?e.width:this.width,void 0!==e.scrollWidth?e.scrollWidth:this.scrollWidth,t?this.rawScrollLeft:this.scrollLeft,void 0!==e.height?e.height:this.height,void 0!==e.scrollHeight?e.scrollHeight:this.scrollHeight,t?this.rawScrollTop:this.scrollTop)}withScrollPosition(e){return new o(this._forceIntegerValues,this.width,this.scrollWidth,void 0!==e.scrollLeft?e.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,void 0!==e.scrollTop?e.scrollTop:this.rawScrollTop)}createScrollEvent(e,t){const i=this.width!==e.width,s=this.scrollWidth!==e.scrollWidth,r=this.scrollLeft!==e.scrollLeft,n=this.height!==e.height,o=this.scrollHeight!==e.scrollHeight,a=this.scrollTop!==e.scrollTop;return{inSmoothScrolling:t,oldWidth:e.width,oldScrollWidth:e.scrollWidth,oldScrollLeft:e.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:e.height,oldScrollHeight:e.scrollHeight,oldScrollTop:e.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:i,scrollWidthChanged:s,scrollLeftChanged:r,heightChanged:n,scrollHeightChanged:o,scrollTopChanged:a}}}t.ScrollState=o;class a extends r.Disposable{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new s.Emitter),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new o(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){const i=this._state.withScrollDimensions(e,t);this._setState(i,Boolean(this._smoothScrolling)),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){const t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(0===this._smoothScrollDuration)return this.setScrollPositionNow(e);if(this._smoothScrolling){e={scrollLeft:void 0===e.scrollLeft?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:void 0===e.scrollTop?this._smoothScrolling.to.scrollTop:e.scrollTop};const i=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===i.scrollLeft&&this._smoothScrolling.to.scrollTop===i.scrollTop)return;let s;s=t?new c(this._smoothScrolling.from,i,this._smoothScrolling.startTime,this._smoothScrolling.duration):this._smoothScrolling.combine(this._state,i,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{const t=this._state.withScrollPosition(e);this._smoothScrolling=c.start(this._state,t,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame((()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())}))}hasPendingScrollAnimation(){return Boolean(this._smoothScrolling)}_performSmoothScrolling(){if(!this._smoothScrolling)return;const e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);return this._setState(t,!0),this._smoothScrolling?e.isDone?(this._smoothScrolling.dispose(),void(this._smoothScrolling=null)):void(this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame((()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())}))):void 0}_setState(e,t){const i=this._state;i.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(i,t)))}}t.Scrollable=a;class l{constructor(e,t,i){this.scrollLeft=e,this.scrollTop=t,this.isDone=i}}function h(e,t){const i=t-e;return function(t){return e+i*(1-(s=1-t,Math.pow(s,3)));var s}}t.SmoothScrollingUpdate=l;class c{constructor(e,t,i,s){this.from=e,this.to=t,this.duration=s,this.startTime=i,this.animationFrameDisposable=null,this._initAnimations()}_initAnimations(){this.scrollLeft=this._initAnimation(this.from.scrollLeft,this.to.scrollLeft,this.to.width),this.scrollTop=this._initAnimation(this.from.scrollTop,this.to.scrollTop,this.to.height)}_initAnimation(e,t,i){if(Math.abs(e-t)>2.5*i){let o,a;return e{Object.defineProperty(t,"__esModule",{value:!0}),t.StopWatch=void 0;const i=globalThis.performance&&"function"==typeof globalThis.performance.now;class s{static create(e){return new s(e)}constructor(e){this._now=i&&!1===e?Date.now:globalThis.performance.now.bind(globalThis.performance),this._startTime=this._now(),this._stopTime=-1}stop(){this._stopTime=this._now()}reset(){this._startTime=this._now(),this._stopTime=-1}elapsed(){return-1!==this._stopTime?this._stopTime-this._startTime:this._now()-this._startTime}}t.StopWatch=s},1316:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.noBreakWhitespace=t.CodePointIterator=void 0,t.isFalsyOrWhitespace=function(e){return!e||"string"!=typeof e||0===e.trim().length},t.format=function(e,...t){return 0===t.length?e:e.replace(n,(function(e,i){const s=parseInt(i,10);return isNaN(s)||s<0||s>=t.length?e:t[s]}))},t.format2=function(e,t){return 0===Object.keys(t).length?e:e.replace(o,((e,i)=>t[i]??e))},t.htmlAttributeEncodeValue=function(e){return e.replace(/[<>"'&]/g,(e=>{switch(e){case"<":return"<";case">":return">";case'"':return""";case"'":return"'";case"&":return"&"}return e}))},t.escape=function(e){return e.replace(/[<>&]/g,(function(e){switch(e){case"<":return"<";case">":return">";case"&":return"&";default:return e}}))},t.escapeRegExpCharacters=a,t.count=function(e,t){let i=0,s=e.indexOf(t);for(;-1!==s;)i++,s=e.indexOf(t,s+t.length);return i},t.truncate=function(e,t,i="…"){return e.length<=t?e:`${e.substr(0,t)}${i}`},t.truncateMiddle=function(e,t,i="…"){if(e.length<=t)return e;const s=Math.ceil(t/2)-i.length/2,r=Math.floor(t/2)-i.length/2;return`${e.substr(0,s)}${i}${e.substr(e.length-r)}`},t.trim=function(e,t=" "){return h(l(e,t),t)},t.ltrim=l,t.rtrim=h,t.convertSimple2RegExpPattern=function(e){return e.replace(/[\-\\\{\}\+\?\|\^\$\.\,\[\]\(\)\#\s]/g,"\\$&").replace(/[\*]/g,".*")},t.stripWildcards=function(e){return e.replace(/\*/g,"")},t.createRegExp=function(e,t,i={}){if(!e)throw new Error("Cannot create regex from empty string");t||(e=a(e)),i.wholeWord&&(/\B/.test(e.charAt(0))||(e="\\b"+e),/\B/.test(e.charAt(e.length-1))||(e+="\\b"));let s="";return i.global&&(s+="g"),i.matchCase||(s+="i"),i.multiline&&(s+="m"),i.unicode&&(s+="u"),new RegExp(e,s)},t.regExpLeadsToEndlessLoop=function(e){return"^"!==e.source&&"^$"!==e.source&&"$"!==e.source&&"^\\s*$"!==e.source&&!(!e.exec("")||0!==e.lastIndex)},t.splitLines=function(e){return e.split(/\r\n|\r|\n/)},t.splitLinesIncludeSeparators=function(e){const t=[],i=e.split(/(\r\n|\r|\n)/);for(let e=0;e=0;i--){const t=e.charCodeAt(i);if(t!==s.CharCode.Space&&t!==s.CharCode.Tab)return i}return-1},t.replaceAsync=function(e,t,i){const s=[];let r=0;for(const n of e.matchAll(t)){if(s.push(e.slice(r,n.index)),void 0===n.index)throw new Error("match.index should be defined");r=n.index+n[0].length,s.push(i(n[0],...n.slice(1),n.index,e,n.groups))}return s.push(e.slice(r)),Promise.all(s).then((e=>e.join("")))},t.compare=function(e,t){return et?1:0},t.compareSubstring=c,t.compareIgnoreCase=function(e,t){return d(e,t,0,e.length,0,t.length)},t.compareSubstringIgnoreCase=d,t.isAsciiDigit=function(e){return e>=s.CharCode.Digit0&&e<=s.CharCode.Digit9},t.isLowerAsciiLetter=u,t.isUpperAsciiLetter=function(e){return e>=s.CharCode.A&&e<=s.CharCode.Z},t.equalsIgnoreCase=function(e,t){return e.length===t.length&&0===d(e,t)},t.startsWithIgnoreCase=function(e,t){const i=t.length;return!(t.length>e.length)&&0===d(e,t,0,i)},t.commonPrefixLength=function(e,t){const i=Math.min(e.length,t.length);let s;for(s=0;sn)return 1}const o=s-i,a=n-r;return oa?1:0}function d(e,t,i=0,s=e.length,r=0,n=t.length){for(;i=128||a>=128)return c(e.toLowerCase(),t.toLowerCase(),i,s,r,n);u(o)&&(o-=32),u(a)&&(a-=32);const l=o-a;if(0!==l)return l}const o=s-i,a=n-r;return oa?1:0}function u(e){return e>=s.CharCode.a&&e<=s.CharCode.z}function _(e){return 55296<=e&&e<=56319}function f(e){return 56320<=e&&e<=57343}function p(e,t){return t-56320+(e-55296<<10)+65536}function g(e,t,i){const s=e.charCodeAt(i);if(_(s)&&i+11){const s=e.charCodeAt(t-2);if(_(s))return p(s,i)}return i}(this._str,this._offset);return this._offset-=e>=r.Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN?2:1,e}nextCodePoint(){const e=g(this._str,this._len,this._offset);return this._offset+=e>=r.Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN?2:1,e}eol(){return this._offset>=this._len}},t.noBreakWhitespace=" "},5015:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.MicrotaskDelay=void 0,t.MicrotaskDelay=Symbol("MicrotaskDelay")},8960:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.Constants=void 0,t.toUint8=function(e){return e<0?0:e>i.MAX_UINT_8?i.MAX_UINT_8:0|e},t.toUint32=function(e){return e<0?0:e>i.MAX_UINT_32?i.MAX_UINT_32:0|e},function(e){e[e.MAX_SAFE_SMALL_INTEGER=1073741824]="MAX_SAFE_SMALL_INTEGER",e[e.MIN_SAFE_SMALL_INTEGER=-1073741824]="MIN_SAFE_SMALL_INTEGER",e[e.MAX_UINT_8=255]="MAX_UINT_8",e[e.MAX_UINT_16=65535]="MAX_UINT_16",e[e.MAX_UINT_32=4294967295]="MAX_UINT_32",e[e.UNICODE_SUPPLEMENTARY_PLANE_BEGIN=65536]="UNICODE_SUPPLEMENTARY_PLANE_BEGIN"}(i||(t.Constants=i={}))}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(7721),r=i(1718),n=i(7150),o=i(3027),a=i(5101),l=i(6097),h=i(4335),c=["cols","rows"];let d=0;class u extends n.Disposable{constructor(e){super(),this._core=this._register(new r.CoreBrowserTerminal(e)),this._addonManager=this._register(new o.AddonManager),this._publicOptions={...this._core.options};const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(c.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new l.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new h.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this._register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,synchronizedOutputMode:e.synchronizedOutput,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._checkProposedApi(),this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return t.promptLabel.get()},set promptLabel(e){t.promptLabel.set(e)},get tooMuchOutput(){return t.tooMuchOutput.get()},set tooMuchOutput(e){t.tooMuchOutput.set(e)}}}_verifyIntegers(...e){for(d of e)if(d===1/0||isNaN(d)||d%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(d of e)if(d&&(d===1/0||isNaN(d)||d%1!=0||d<0))throw new Error("This API only accepts positive integers")}}e.Terminal=u})(),s})())); +//# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/Sources/Menubar/StatusBarController.swift b/Sources/Menubar/StatusBarController.swift index b4f563d..075ee13 100644 --- a/Sources/Menubar/StatusBarController.swift +++ b/Sources/Menubar/StatusBarController.swift @@ -30,10 +30,7 @@ class StatusBarController: NSObject, NSMenuDelegate { self.dictationManager = dictationManager ?? VoiceDictationManager(transcription: recordingManager.transcription, resolveTarget: { [weak hubManager] in hubManager?.routeToFocusedAtem() }, sendText: { [weak hubManager] text, target in - guard let hubManager, hubManager.connectedClients.contains(where: { $0.id == target && $0.clientType == "Atem" }), - let send = hubManager.sendHandler else { return false } - send(.voiceCommand(text: text, isFinal: true), target) - return true + hubManager?.sendAgentText(text, clientId: target) ?? false }) hubManager.rtcManager.microphoneDeviceUID = { [weak recordingManager] in recordingManager?.settings.microphoneUID } super.init() diff --git a/Sources/Menubar/TranscriptionValidation.swift b/Sources/Menubar/TranscriptionValidation.swift index cf1e5ff..f5b918f 100644 --- a/Sources/Menubar/TranscriptionValidation.swift +++ b/Sources/Menubar/TranscriptionValidation.swift @@ -12,6 +12,8 @@ enum TranscriptionValidation { } print("PASS: bundled \(model.name) manifest.") } + try RemoteTerminalAssets.validate() + print("PASS: bundled remote terminal assets.") return 0 } catch { print("FAIL: \(error.localizedDescription)") diff --git a/Sources/Menubar/VoiceCodingManager.swift b/Sources/Menubar/VoiceCodingManager.swift index f4f59c6..404c5d0 100644 --- a/Sources/Menubar/VoiceCodingManager.swift +++ b/Sources/Menubar/VoiceCodingManager.swift @@ -616,13 +616,10 @@ class VoiceCodingManager: NSObject { return } - let message = AstationMessage.voiceRequest( - sessionId: sessionId, - accumulatedText: accumulatedText, - relayUrl: hubManager.stationRelayUrl - ) - hubManager.sendHandler?(message, atemId) - Log.info("[VoiceCoding] Sent voiceRequest to \(atemId.prefix(8))...: \(accumulatedText.prefix(60))...") + let submitted = hubManager.sendAgentText(accumulatedText, clientId: atemId) + cleanup() + updateStage(submitted ? "Voice: Submitted to the controlled session" + : "Voice: Open Terminal and take control first", autoHideAfter: 3.0) } private func recycleHandsFreeSession() { diff --git a/Tests/AstationTests/AgentInputRoutingTests.swift b/Tests/AstationTests/AgentInputRoutingTests.swift index aa5328e..d8d043a 100644 --- a/Tests/AstationTests/AgentInputRoutingTests.swift +++ b/Tests/AstationTests/AgentInputRoutingTests.swift @@ -1,139 +1,116 @@ +import CryptoKit import XCTest @testable import Menubar -/// Tests for HubManager.sendAgentText / sendAgentKey routing + the agentInput -/// payload they emit. Uses a stubbed sendHandler to capture messages. +@MainActor final class AgentInputRoutingTests: XCTestCase { - private func makeHub(withClient: Bool) -> (AstationHubManager, [(AstationMessage, String)]) { - let hub = AstationHubManager(skipProjectLoad: true) - if withClient { - hub.connectedClients = [ - ConnectedClient(id: "atem-1", clientType: "Atem", connectedAt: Date()) - ] + func testVerifiedInputUsesScopedControllerInsteadOfLegacyWireMessage() throws { + let f = try ScopedInputFixture() + var captured: [(RemoteTerminalConnection, String?, String, String)] = [] + f.hub.remoteAgentInputHandler = { route, agentId, kind, value in + captured.append((route, agentId, kind, value)) + return true } - return (hub, []) + f.hub.sendAgentText("inspect logs", agentId: "agent-9", clientId: "direct-two") + f.hub.sendAgentKey("ctrl-c", agentId: "agent-9", clientId: "direct-two") + XCTAssertEqual(captured.map { $0.0.clientId }, ["direct-two", "direct-two"]) + XCTAssertEqual(captured.map { $0.1 }, ["agent-9", "agent-9"]) + XCTAssertEqual(captured.map { $0.2 }, ["text", "key"]) + XCTAssertEqual(captured.map { $0.3 }, ["inspect logs", "ctrl-c"]) + XCTAssertTrue(f.sent.isEmpty, "legacy lease-less mutations must never be sent") + XCTAssertNil(f.hub.remoteInputStatusMessage) } - func testSendAgentTextRoutesToConnectedAtem() { - let hub = AstationHubManager(skipProjectLoad: true) - hub.connectedClients = [ConnectedClient(id: "atem-1", clientType: "Atem", connectedAt: Date())] - - var captured: [(AstationMessage, String)] = [] - hub.sendHandler = { msg, clientId in captured.append((msg, clientId)) } - - hub.sendAgentText("print working directory") - - XCTAssertEqual(captured.count, 1) - XCTAssertEqual(captured[0].1, "atem-1") - guard case let .agentInput(agentId, kind, text, key) = captured[0].0 else { - return XCTFail("expected agentInput") - } - XCTAssertNil(agentId) - XCTAssertEqual(kind, "text") - XCTAssertEqual(text, "print working directory") - XCTAssertNil(key) + func testPinnedTargetUsesTheSameVerifiedScopedRoute() throws { + let f = try ScopedInputFixture() + f.hub.pinnedClientId = "direct-two" + var target: RemoteTerminalConnection? + f.hub.remoteAgentInputHandler = { route, _, _, _ in target = route; return true } + f.hub.sendAgentText("route me") + XCTAssertEqual(target?.clientId, "direct-two") } - func testSendAgentKeyRoutesToConnectedAtem() { - let hub = AstationHubManager(skipProjectLoad: true) - hub.connectedClients = [ConnectedClient(id: "atem-1", clientType: "Atem", connectedAt: Date())] - - var captured: [(AstationMessage, String)] = [] - hub.sendHandler = { msg, clientId in captured.append((msg, clientId)) } - - hub.sendAgentKey("ctrl-c") - - XCTAssertEqual(captured.count, 1) - guard case let .agentInput(_, kind, text, key) = captured[0].0 else { - return XCTFail("expected agentInput") - } - XCTAssertEqual(kind, "key") - XCTAssertNil(text) - XCTAssertEqual(key, "ctrl-c") + func testVerifiedHostWithoutHeldControllerFailsWithActionableHint() throws { + let f = try ScopedInputFixture() + f.hub.remoteAgentInputHandler = { _, _, _, _ in false } + f.hub.sendAgentText("do not send", clientId: "direct-one") + XCTAssertTrue(f.sent.isEmpty) + XCTAssertTrue(f.hub.remoteInputStatusMessage?.contains("Take Control") == true) } - func testSendAgentTextWithExplicitAgentId() { - let hub = AstationHubManager(skipProjectLoad: true) - hub.connectedClients = [ConnectedClient(id: "atem-1", clientType: "Atem", connectedAt: Date())] - - var captured: [(AstationMessage, String)] = [] - hub.sendHandler = { msg, clientId in captured.append((msg, clientId)) } - - hub.sendAgentText("hi", agentId: "agent-9") - - guard case let .agentInput(agentId, _, _, _) = captured[0].0 else { - return XCTFail("expected agentInput") - } - XCTAssertEqual(agentId, "agent-9") + func testUnverifiedHostNeverInvokesControllerAndDoesNotSend() throws { + let f = try ScopedInputFixture(verified: false) + var invoked = false + f.hub.remoteAgentInputHandler = { _, _, _, _ in invoked = true; return true } + f.hub.sendAgentText("do not send", clientId: "direct-one") + f.hub.sendAgentKey("ctrl-c", clientId: "direct-one") + XCTAssertFalse(invoked) + XCTAssertTrue(f.sent.isEmpty) + XCTAssertTrue(f.hub.remoteInputStatusMessage?.contains("atem pair verify") == true) } - func testSendAgentTextDroppedWhenNoAtem() { - let hub = AstationHubManager(skipProjectLoad: true) - hub.connectedClients = [] // no Atems - - var captured: [(AstationMessage, String)] = [] - hub.sendHandler = { msg, clientId in captured.append((msg, clientId)) } - - hub.sendAgentText("nobody home") - hub.sendAgentKey("enter") - - XCTAssertTrue(captured.isEmpty, "messages should be dropped when no Atem is connected") + func testRevokedPairingCannotReuseScopedController() throws { + let f = try ScopedInputFixture() + var invoked = false + f.hub.remoteAgentInputHandler = { _, _, _, _ in invoked = true; return true } + let sessionId = try XCTUnwrap(f.hub.connectedClients.first?.authenticationSessionId) + f.hub.deviceSessionStore.delete(sessionId: sessionId) + f.hub.sendAgentText("do not send", clientId: "direct-one") + XCTAssertFalse(invoked) + XCTAssertTrue(f.sent.isEmpty) } - func testRoutingPrefersPinnedClient() { - let hub = AstationHubManager(skipProjectLoad: true) - hub.connectedClients = [ - ConnectedClient(id: "atem-1", clientType: "Atem", connectedAt: Date()), - ConnectedClient(id: "atem-2", clientType: "Atem", connectedAt: Date()), - ] - hub.pinnedClientId = "atem-2" - - var captured: [(AstationMessage, String)] = [] - hub.sendHandler = { msg, clientId in captured.append((msg, clientId)) } - - hub.sendAgentText("route me") - XCTAssertEqual(captured.first?.1, "atem-2", "pinned client should win") + func testFinalVoiceInputRequiresScopedControllerAndPartialInputDoesNothing() throws { + let f = try ScopedInputFixture() + f.hub.pinnedClientId = "direct-two" + var captured: [String] = [] + f.hub.remoteAgentInputHandler = { _, _, kind, value in captured.append(kind + ":" + value); return true } + f.hub.sendVoiceCommand(text: "partial", isFinal: false) + XCTAssertTrue(captured.isEmpty) + f.hub.sendVoiceCommand(text: "finished", isFinal: true) + XCTAssertEqual(captured, ["text:finished"]) + XCTAssertTrue(f.sent.isEmpty) } - func testRemoteControlRoutesToExplicitClientAndAgent() { - let hub = AstationHubManager(skipProjectLoad: true) - hub.connectedClients = [ - ConnectedClient(id: "atem-1", clientType: "Atem", connectedAt: Date()), - ConnectedClient(id: "atem-2", clientType: "Atem", connectedAt: Date()), - ] - hub.pinnedClientId = "atem-1" - - var captured: [(AstationMessage, String)] = [] - hub.sendHandler = { msg, clientId in captured.append((msg, clientId)) } - - hub.sendAgentText("inspect logs", agentId: "agent-9", clientId: "atem-2") - hub.sendAgentKey("ctrl-c", agentId: "agent-9", clientId: "atem-2") + func testOfflineExplicitTargetDoesNotFallbackToAnotherHost() throws { + let f = try ScopedInputFixture() + var invoked = false + f.hub.remoteAgentInputHandler = { _, _, _, _ in invoked = true; return true } + f.hub.sendAgentText("do not send", agentId: "agent", clientId: "offline") + f.hub.sendAgentKey("enter", agentId: "agent", clientId: "offline") + XCTAssertFalse(invoked) + XCTAssertTrue(f.sent.isEmpty) + } +} - XCTAssertEqual(captured.map(\.1), ["atem-2", "atem-2"]) - guard case let .agentInput(textAgentId, textKind, text, _) = captured[0].0, - case let .agentInput(keyAgentId, keyKind, _, key) = captured[1].0 else { - return XCTFail("expected agentInput messages") +@MainActor +private final class ScopedInputFixture { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let hub: AstationHubManager + var sent: [AstationMessage] = [] + + init(verified: Bool = true) throws { + let identity = AtemE2EIdentity(storage: TestAtemStorage(), crypto: TestAtemCryptography()) + let sessions = SessionStore(storageURL: directory.appendingPathComponent("sessions.json")) + if verified { + let keys = try identity.verificationKeys(nonce: Data(repeating: 1, count: 32)) + try identity.markRecoverySaved(signingPublicKey: keys.signPub) + _ = try identity.completeVerification(account: AstationIdentity.shared.id, deviceId: "host", + reveal: AtemVerifyReveal(devicePub: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation, + deviceSignPub: Curve25519.Signing.PrivateKey().publicKey.rawRepresentation, + unlockAuthPub: Curve25519.Signing.PrivateKey().publicKey.rawRepresentation, + nonce: Data(repeating: 2, count: 32)), transcript: Data(repeating: 3, count: 32), + expectedSigningKey: keys.signPub, key: nil) + } + hub = AstationHubManager(skipProjectLoad: true, deviceSessionStore: sessions, + sessionStore: SsoSessionStore(storageURL: directory.appendingPathComponent("sso.enc")), atemE2EIdentity: identity) + hub.connectedClients = ["direct-one", "direct-two"].map { clientId in + let session = sessions.create(hostname: "Host", atemId: "host") + return ConnectedClient(id: clientId, clientType: "Atem", connectedAt: Date(), + atemId: "host", authenticationSessionId: session.id) } - XCTAssertEqual(textAgentId, "agent-9") - XCTAssertEqual(textKind, "text") - XCTAssertEqual(text, "inspect logs") - XCTAssertEqual(keyAgentId, "agent-9") - XCTAssertEqual(keyKind, "key") - XCTAssertEqual(key, "ctrl-c") + hub.sendHandler = { [weak self] message, _ in self?.sent.append(message) } } - func testRemoteControlDropsInputForOfflineExplicitClient() { - let hub = AstationHubManager(skipProjectLoad: true) - hub.connectedClients = [ - ConnectedClient(id: "atem-online", clientType: "Atem", connectedAt: Date()) - ] - - var captured: [(AstationMessage, String)] = [] - hub.sendHandler = { msg, clientId in captured.append((msg, clientId)) } - - hub.sendAgentText("do not send", agentId: "agent-9", clientId: "atem-offline") - hub.sendAgentKey("enter", agentId: "agent-9", clientId: "atem-offline") - - XCTAssertTrue(captured.isEmpty) - } + deinit { try? FileManager.default.removeItem(at: directory) } } diff --git a/Tests/AstationTests/AtemSessionTests.swift b/Tests/AstationTests/AtemSessionTests.swift new file mode 100644 index 0000000..3ec2c80 --- /dev/null +++ b/Tests/AstationTests/AtemSessionTests.swift @@ -0,0 +1,710 @@ +import XCTest +@testable import Menubar + +private let sessionID = "ses-aaaaaaaaaaaa" +private let processID = "22222222-2222-4222-8222-222222222222" +private let attachmentID = "33333333-3333-4333-8333-333333333333" +private let runtimeID = "55555555-5555-4555-8555-555555555555" +private let controllerID = "66666666-6666-4666-8666-666666666666" + +private final class SessionDisk { + var state = AtemSessionJournal.Snapshot() + var fail = false + func journal() -> AtemSessionJournal { + AtemSessionJournal(load: { self.state }, save: { state in + if self.fail { throw CocoaError(.fileWriteUnknown) } + self.state = state + }) + } +} + +private final class SessionFixture { + var time: TimeInterval = 0 + var source: AtemSessionContext? = AtemSessionContext(route: RemoteTerminalConnection(clientId: "host", connectionId: "socket-1"), + account: "account-a", device: "host-a", pairingSession: "pairing-1") + var sent: [AtemSessionMessage] = [] + var nextID: String? + var canSend = true + let disk: SessionDisk + lazy var client = AtemSessionClient(context: { self.source }, send: { message, _ in + self.sent.append(message) + return self.canSend + }, journal: disk.journal(), makeID: { + defer { self.nextID = nil } + return self.nextID ?? UUID().uuidString.lowercased() + }, now: { self.time }) + + init(disk: SessionDisk = SessionDisk()) { self.disk = disk } + var route: RemoteTerminalConnection { source!.route } + var target: AtemSessionTarget { AtemSessionTarget(account_id: "account-a", atem_device_id: "host-a", host_trust_gen: 1, session_id: sessionID, process_gen: processID) } + + func handshake(trust: UInt64 = 1, runtime: String = runtimeID, attachment: String = attachmentID, + permissions: [String] = ["view", "control"]) throws { + if sent.isEmpty { client.start() } + let hello = try XCTUnwrap(sent.last(where: { $0.op == "hello" })) + var reply = AtemSessionMessage(op: "welcome") + reply.re = hello.id + reply.account_id = "account-a" + reply.atem_device_id = "host-a" + reply.host_trust_gen = trust + reply.runtime_gen = runtime + reply.controller_session = controllerID + reply.attachment_gen = attachment + reply.client_kind = "native" + reply.permissions = permissions + reply.authorization_ms_left = 60_000 + reply.server_time = 1_760_500_000_000 + try receive(reply) + } + + func ready() throws { + try handshake() + try snapshot() + } + + func snapshot(epoch: UInt64 = 7, sequence: UInt64 = 1, runtime: String = runtimeID, + descriptors: [AtemSessionDescriptor]? = nil) throws { + var reply = AtemSessionMessage(op: "sessions") + reply.runtime_gen = runtime + reply.runtime_epoch = epoch + reply.seq = sequence + reply.sessions = try descriptors ?? [Self.descriptor()] + try receive(reply) + } + + static func descriptor(state: String = "running", process: String = processID) throws -> AtemSessionDescriptor { + let json: [String: Any] = ["session_id": sessionID, "process_gen": process, "provider": "claude", "cwd_label": "Atem", + "state": state, "activity": "ready", "activity_since": 1_760_500_000_000, + "controller": NSNull(), "capabilities": ["terminal-v1", "chat-v1"], "created_at": 1_760_490_000_000] + return try JSONDecoder().decode(AtemSessionDescriptor.self, from: JSONSerialization.data(withJSONObject: json)) + } + + func takeControl(epoch: UInt64 = 1) throws { + client.acquire(target) + try lease(replyTo: XCTUnwrap(sent.last(where: { $0.op == "acquire" })).id, epoch: epoch) + } + + func lease(replyTo: String, epoch: UInt64 = 1, remaining: UInt64 = 15_000) throws { + var reply = AtemSessionMessage(op: "lease") + reply.re = replyTo + reply.binding = AtemSessionBinding(target: target, attachment_gen: attachmentID, lease_epoch: epoch) + reply.ms_left = remaining + try receive(reply) + } + + func receive(_ message: AtemSessionMessage, route: RemoteTerminalConnection? = nil) throws { + let bytes = try JSONEncoder().encode(AstationMessage.session(message)) + guard case .session(let decoded) = try AstationMessage.decodePacket(bytes) else { throw AtemSessionJSON.Invalid.json } + client.receive(decoded, from: route ?? self.route) + } + + func receipt(_ request: String, state: AtemReceiptState, time: UInt64 = 100) throws { + var reply = AtemSessionMessage(op: "receipt") + reply.request_id = request + reply.target = target + reply.kind = "prompt" + reply.state = state + reply.updated_at = time + try receive(reply) + } +} + +final class AtemSessionWireTests: XCTestCase { + private func fixture(_ name: String) throws -> [String: Any] { + let url = try XCTUnwrap(Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures/atem-session-v1")) + return try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + } + + func testSharedDigestAndRawRejectionVectorsUseActualParser() throws { + let file = try fixture("digest-vectors") + for vector in try XCTUnwrap(file["vectors"] as? [[String: Any]]) { + let data = try JSONSerialization.data(withJSONObject: XCTUnwrap(vector["payload"])) + let payload = try AtemSessionJSON.parse(data) + XCTAssertEqual(try payload.canonical(), vector["canonical"] as? String, vector["name"] as? String ?? "") + XCTAssertEqual(try payload.digest(), vector["sha256"] as? String) + } + for vector in try XCTUnwrap(file["must_reject"] as? [[String: Any]]) { + let data: Data + if let raw = vector["payload_json"] as? String { data = Data(raw.utf8) } + else { + let hex = try XCTUnwrap(vector["payload_hex"] as? String) + data = Data(stride(from: 0, to: hex.count, by: 2).map { + let start = hex.index(hex.startIndex, offsetBy: $0) + return UInt8(hex[start.. = ["controls", "input_enabled", "deadline_ms", "registry", "registry_state", "runtime_epoch", "seq", "sent_list"] + XCTAssertTrue(Set(expected.keys).isSubset(of: supported), location) + if let controls = expected["controls"] as? Bool { XCTAssertEqual(f.client.controls(f.target), controls, location) } + if let input = expected["input_enabled"] as? Bool { XCTAssertEqual(f.client.binding(for: f.target, mutation: true) != nil, input, location) } + if let deadline = expected["deadline_ms"] as? Double { XCTAssertEqual(try XCTUnwrap(f.client.controlDeadline(for: f.target)) * 1000, deadline, accuracy: 0.001, location) } + if let ids = expected["registry"] as? [String] { XCTAssertEqual(f.client.registry.keys.sorted(), ids.sorted(), location) } + if let epoch = expected["runtime_epoch"] as? UInt64 { XCTAssertEqual(f.client.runtimeEpoch, epoch, location) } + if let seq = expected["seq"] as? UInt64 { XCTAssertEqual(f.client.sequence, seq, location) } + if expected["sent_list"] as? Bool == true { XCTAssertGreaterThan(f.sent.filter { $0.op == "list" }.count, 1, location) } + if let states = expected["registry_state"] as? [String: String] { + for (id, state) in states { XCTAssertEqual(f.client.registry[id]?.state, state, location) } + } + } else { XCTFail("Unhandled fixture step: " + location) } + } + } + XCTAssertEqual(scenarios, 3, "Every r6.2 client scenario must execute") + } + +} + +final class AtemSessionClientTests: XCTestCase { + func testViewOnlyAndTerminalOnlyCapabilitiesDoNotOfferUnsupportedSubmissions() throws { + let viewer = SessionFixture() + try viewer.handshake(permissions: ["view"]); try viewer.snapshot() + viewer.client.acquire(viewer.target) + XCTAssertFalse(viewer.sent.contains { $0.op == "acquire" }) + XCTAssertNil(viewer.client.submit(viewer.target, kind: "prompt", text: "never send")) + let terminal = SessionFixture() + try terminal.ready(); try terminal.takeControl() + var descriptor = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(SessionFixture.descriptor())) as? [String: Any]) + descriptor["capabilities"] = ["terminal-v1"] + let codex = try JSONDecoder().decode(AtemSessionDescriptor.self, from: JSONSerialization.data(withJSONObject: descriptor)) + try terminal.snapshot(sequence: 2, descriptors: [codex]) + XCTAssertNil(terminal.client.submit(terminal.target, kind: "prompt", text: "use terminal")) + XCTAssertTrue(terminal.client.controls(terminal.target)) + XCTAssertFalse(terminal.sent.contains { $0.op == "submit" }) + XCTAssertTrue(terminal.client.status.contains("terminal input only")) + } + + func testAdmissionRefusalsFenceControlWithoutReplay() throws { + for code in ["forbidden", "session_closed", "stale_lease", "stale_target", "unknown_session"] { + let f = SessionFixture() + try f.ready(); try f.takeControl(epoch: 1025) + let request = try XCTUnwrap(f.client.submit(f.target, kind: "prompt", text: "one prompt")) + var error = AtemSessionMessage(op: "error") + error.re = try XCTUnwrap(f.sent.last(where: { $0.op == "submit" })).id + error.code = code + try f.receive(error) + XCTAssertFalse(f.client.controls(f.target), code) + XCTAssertNil(f.client.submit(f.target, kind: "stop"), code) + XCTAssertEqual(f.sent.filter { $0.op == "submit" }.count, 1, code) + XCTAssertEqual(f.disk.state.requests.first?.requestId, request) + } + } + + func testBusySubmitQueriesOriginalReceiptWithoutAssumingNoEffectOrResending() throws { + let f = SessionFixture() + try f.ready(); try f.takeControl() + let request = try XCTUnwrap(f.client.submit(f.target, kind: "prompt", text: "one prompt")) + var error = AtemSessionMessage(op: "error") + error.re = try XCTUnwrap(f.sent.last(where: { $0.op == "submit" })).id; error.code = "busy" + try f.receive(error) + f.time = 5; f.client.tick() + XCTAssertEqual(f.sent.filter { $0.op == "submit" }.count, 1) + XCTAssertEqual(f.sent.last(where: { $0.op == "receipt_query" })?.request_ids, [request]) + XCTAssertNil(f.disk.state.requests.first?.receipt) + try f.receipt(request, state: .accepted) + XCTAssertEqual(f.disk.state.requests.first?.receipt?.state, .accepted) + } + + func testFreshWelcomeClearsSequenceAndTombstonesButKeepsRuntimeFloor() throws { + let f = SessionFixture() + try f.ready(); try f.takeControl() + try f.snapshot(sequence: 100, descriptors: [SessionFixture.descriptor(state: "removed")]) + XCTAssertTrue(f.client.registry.isEmpty) + f.source = AtemSessionContext(route: RemoteTerminalConnection(clientId: "host", connectionId: "new-socket"), + account: "account-a", device: "host-a", pairingSession: "pairing-1") + f.client.connectionChanged() + try f.handshake(attachment: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + XCTAssertTrue(f.client.registry.isEmpty) + XCTAssertEqual(f.client.runtimeEpoch, 7) + XCTAssertNil(f.client.sequence) + try f.snapshot(sequence: 1) + XCTAssertNotNil(f.client.registry[sessionID]) + XCTAssertEqual(f.client.sequence, 1) + XCTAssertFalse(f.client.controls(f.target)) + try f.snapshot(epoch: 6, sequence: 999) + XCTAssertEqual(f.client.sequence, 1) + } + + func testRevocationBeforeGrantCannotRestoreControlAndExplicitAcquireCanRecover() throws { + let f = SessionFixture() + try f.ready() + f.client.acquire(f.target) + let acquire = try XCTUnwrap(f.sent.last) + var revoked = AtemSessionMessage(op: "lease_changed") + revoked.session_id = sessionID; revoked.process_gen = processID + revoked.lease_epoch = 2; revoked.reason = "local_input" + try f.receive(revoked) + try f.lease(replyTo: acquire.id, epoch: 2) + XCTAssertFalse(f.client.controls(f.target)) + f.client.acquire(f.target) + try f.lease(replyTo: XCTUnwrap(f.sent.last).id, epoch: 3) + XCTAssertTrue(f.client.controls(f.target)) + } + + func testRenewalCannotChangeEpochOrRestoreControlAtExactDeadline() throws { + for (time, epoch) in [(6.0, UInt64(2)), (15.0, UInt64(1))] { + let f = SessionFixture() + try f.ready(); try f.takeControl() + f.time = 5; f.client.tick() + let renewal = try XCTUnwrap(f.sent.last(where: { $0.op == "renew" })) + f.time = time + try f.lease(replyTo: renewal.id, epoch: epoch) + XCTAssertEqual(f.client.controlDeadline(for: f.target), time == 15 ? nil : 15) + if time == 15 { XCTAssertFalse(f.client.controls(f.target)) } + } + } + + func testMismatchedReceiptReplyDoesNotConsumeControlRequest() throws { + let f = SessionFixture() + try f.ready(); try f.takeControl() + let request = try XCTUnwrap(f.client.submit(f.target, kind: "prompt", text: "inspect logs")) + f.time = 5; f.client.tick() + let renewal = try XCTUnwrap(f.sent.last(where: { $0.op == "renew" })) + var unrelated = AtemSessionMessage(op: "receipt") + unrelated.re = renewal.id; unrelated.request_id = request; unrelated.target = f.target + unrelated.kind = "prompt"; unrelated.state = .accepted; unrelated.updated_at = 100 + try f.receive(unrelated) + XCTAssertNil(f.client.latestReceipt) + try f.lease(replyTo: renewal.id) + XCTAssertEqual(f.client.controlDeadline(for: f.target), 20) + } + + func testFreshAttachmentNeverRestoresControlOrReplaysPrompt() throws { + let f = SessionFixture() + try f.ready(); try f.takeControl() + let request = try XCTUnwrap(f.client.submit(f.target, kind: "prompt", text: "inspect logs")) + try f.receipt(request, state: .accepted) + let oldRoute = f.route + f.source = nil; f.client.connectionChanged() + XCTAssertFalse(f.client.controls(f.target)) + f.source = AtemSessionContext(route: RemoteTerminalConnection(clientId: "host", connectionId: "socket-2"), account: "account-a", device: "host-a", pairingSession: "pairing-1") + f.client.connectionChanged() + try f.handshake(attachment: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + try f.snapshot() + XCTAssertFalse(f.client.controls(f.target)) + XCTAssertEqual(f.sent.filter { $0.op == "submit" }.count, 1) + XCTAssertEqual(f.sent.last(where: { $0.op == "receipt_query" })?.request_ids, [request]) + var late = AtemSessionMessage(op: "lease") + late.re = f.sent.first(where: { $0.op == "acquire" })!.id + late.binding = AtemSessionBinding(target: f.target, attachment_gen: attachmentID, lease_epoch: 1) + late.ms_left = 15_000 + try f.receive(late, route: oldRoute) + XCTAssertFalse(f.client.controls(f.target)) + } + + func testRevocationOrPairingChangeImmediatelyFencesInputBeforeTimer() throws { + let f = SessionFixture() + try f.ready(); try f.takeControl() + f.source = AtemSessionContext(route: f.route, account: "account-a", device: "host-a", pairingSession: "pairing-2") + XCTAssertFalse(f.client.controls(f.target)) + XCTAssertNil(f.client.submit(f.target, kind: "prompt", text: "must not send")) + f.source = nil + XCTAssertFalse(f.client.controls(f.target)) + } + + func testPersistenceFailurePreventsAnySubmit() throws { + let f = SessionFixture() + try f.ready(); try f.takeControl() + f.disk.fail = true + XCTAssertNil(f.client.submit(f.target, kind: "prompt", text: "never written")) + XCTAssertFalse(f.sent.contains { $0.op == "submit" }) + } + + func testRecoveryMetadataContainsNoPromptAndSurvivesClientRestart() throws { + let disk = SessionDisk() + let f = SessionFixture(disk: disk) + try f.ready(); try f.takeControl() + let request = try XCTUnwrap(f.client.submit(f.target, kind: "prompt", text: "secret prompt contents")) + let raw = String(decoding: try JSONEncoder().encode(disk.state), as: UTF8.self) + XCTAssertFalse(raw.contains("secret prompt contents")) + XCTAssertFalse(raw.contains("payload")) + let recovered = SessionFixture(disk: disk) + try recovered.ready() + XCTAssertEqual(recovered.sent.last(where: { $0.op == "receipt_query" })?.request_ids, [request]) + XCTAssertFalse(recovered.sent.contains { $0.op == "submit" }) + } + + func testTerminalReceiptIsImmutableAcrossDuplicatesAndLateCompletion() throws { + for final in [AtemReceiptState.rejected, .outcome_unknown] { + let f = SessionFixture() + try f.ready(); try f.takeControl() + let request = try XCTUnwrap(f.client.submit(f.target, kind: "prompt", text: "run tests")) + try f.receipt(request, state: .accepted, time: 1) + try f.receipt(request, state: .executing, time: 2) + try f.receipt(request, state: final, time: 3) + try f.receipt(request, state: final, time: 3) + XCTAssertEqual(f.client.latestReceipt?.state, final) + try f.receipt(request, state: .completed, time: 4) + XCTAssertEqual(f.client.phase, .failed) + XCTAssertEqual(f.disk.state.requests.first?.receipt?.state, final) + } + } + + func testTrustGenerationCannotRollbackAndNewTrustDoesNotQueryOldReceipts() throws { + let disk = SessionDisk() + let f = SessionFixture(disk: disk) + try f.ready(); try f.takeControl() + _ = f.client.submit(f.target, kind: "prompt", text: "old trust") + let next = SessionFixture(disk: disk) + try next.handshake(trust: 2) + XCTAssertFalse(next.sent.contains { $0.op == "receipt_query" }) + let stale = SessionFixture(disk: disk) + try stale.handshake(trust: 1) + XCTAssertEqual(stale.client.phase, .failed) + } + + func testLocalTakeoverFencesOutstandingRenewalAndQueuedControlReply() throws { + let f = SessionFixture() + try f.ready(); try f.takeControl() + f.time = 5; f.client.tick() + let renew = try XCTUnwrap(f.sent.last(where: { $0.op == "renew" })) + var changed = AtemSessionMessage(op: "lease_changed") + changed.session_id = sessionID; changed.process_gen = processID + changed.lease_epoch = 2; changed.reason = "local_input" + try f.receive(changed) + try f.lease(replyTo: renew.id) + XCTAssertFalse(f.client.controls(f.target)) + XCTAssertNil(f.client.submit(f.target, kind: "stop")) + } + + func testRuntimeRestartRequiresFreshWelcomeAndNeverRestoresLease() throws { + let f = SessionFixture() + try f.ready(); try f.takeControl() + try f.snapshot(sequence: 100) + let newRuntime = "99999999-9999-4999-8999-999999999999" + try f.snapshot(epoch: 8, runtime: newRuntime) + XCTAssertEqual(f.client.phase, .failed) + XCTAssertFalse(f.client.controls(f.target)) + let helloCount = f.sent.filter { $0.op == "hello" }.count + f.client.refresh() + XCTAssertEqual(f.sent.filter { $0.op == "hello" }.count, helloCount, "No second hello on a welcomed attachment") + XCTAssertEqual(f.disk.state.hosts.first?.runtimeEpoch, 8) + f.source = AtemSessionContext(route: RemoteTerminalConnection(clientId: "host", connectionId: "restarted"), + account: "account-a", device: "host-a", pairingSession: "pairing-1") + f.client.connectionChanged() + try f.handshake(runtime: newRuntime, attachment: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + try f.snapshot(epoch: 8, runtime: newRuntime) + XCTAssertEqual(f.client.phase, .ready) + XCTAssertNotNil(f.client.target(sessionId: sessionID, process: processID), "The new runtime starts a fresh sequence domain") + XCTAssertFalse(f.client.controls(f.target)) + } + + func testMultipleRemovedGenerationsCannotResurrectAnEarlierProcess() throws { + let f = SessionFixture() + try f.ready() + let second = "77777777-7777-4777-8777-777777777777" + for (sequence, state, process) in [(UInt64(2), "removed", processID), (3, "running", second), (4, "removed", second), (5, "running", processID)] { + var change = AtemSessionMessage(op: "session_changed") + change.runtime_gen = runtimeID; change.runtime_epoch = 7; change.seq = sequence + change.descriptor = try SessionFixture.descriptor(state: state, process: process) + try f.receive(change) + } + XCTAssertTrue(f.client.registry.isEmpty) + XCTAssertNil(f.client.target(sessionId: sessionID, process: processID)) + } + + func testMissingReceiptsAreQueriedAgainWithoutResubmission() throws { + let f = SessionFixture() + try f.ready(); try f.takeControl() + let request = try XCTUnwrap(f.client.submit(f.target, kind: "prompt", text: "run tests")) + f.time = 5; f.client.tick() + let query = try XCTUnwrap(f.sent.last(where: { $0.op == "receipt_query" })) + var reply = AtemSessionMessage(op: "receipts") + reply.re = query.id; reply.receipts = [] + try f.receive(reply) + f.time = 10; f.client.tick() + XCTAssertEqual(f.sent.filter { $0.op == "receipt_query" }.count, 2) + XCTAssertEqual(f.sent.last(where: { $0.op == "receipt_query" })?.request_ids, [request]) + XCTAssertEqual(f.sent.filter { $0.op == "submit" }.count, 1) + } +} + +final class AtemSessionJournalTests: XCTestCase { + func testRealFileReloadRestrictsPermissionsAndPreservesOnlyRecoveryMetadata() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("receipts.json") + let journal = AtemSessionJournal.persistent(at: file) + let f = SessionFixture() + let record = AtemSessionRequestRecord(requestId: UUID().uuidString.lowercased(), target: f.target, + kind: "prompt", digest: try AtemSessionJSON.object(["text": .string("private prompt")]).digest()) + try journal.update { + $0.requests.append(record) + $0.hosts.append(AtemSessionHostFence(account: "account-a", device: "host-a", trust: 2, + runtimeEpoch: 7, runtimeGeneration: runtimeID)) + } + let reloaded = AtemSessionJournal.persistent(at: file) + XCTAssertFalse(reloaded.loadFailed) + XCTAssertEqual(reloaded.snapshot, journal.snapshot) + let permissions = try XCTUnwrap(FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber) + XCTAssertEqual(permissions.intValue & 0o777, 0o600) + let bytes = try String(contentsOf: file, encoding: .utf8) + XCTAssertFalse(bytes.contains("private prompt")) + XCTAssertFalse(bytes.contains("payload")) + XCTAssertFalse(bytes.contains("token")) + } + + func testCorruptAndCrossTargetJournalsFailClosed() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("receipts.json") + try Data(#"{"version":1,"version":1,"requests":[],"hosts":[]}"#.utf8).write(to: file) + let corrupted = AtemSessionJournal.persistent(at: file) + XCTAssertTrue(corrupted.loadFailed) + XCTAssertThrowsError(try corrupted.update { $0.hosts = [] }) + let target = SessionFixture().target + var invalid = AtemSessionJournal.Snapshot() + let request = UUID().uuidString.lowercased() + invalid.requests = [AtemSessionRequestRecord(requestId: request, target: target, kind: "prompt", digest: String(repeating: "a", count: 64), + receipt: AtemSessionReceipt(request_id: UUID().uuidString.lowercased(), target: target, kind: "prompt", state: .accepted, detail: nil, updated_at: 1))] + XCTAssertTrue(AtemSessionJournal(load: { invalid }).loadFailed) + } + + func testUncertainPersistenceFailurePreventsLaterFenceOverwrite() throws { + let disk = SessionDisk() + let journal = disk.journal() + disk.fail = true + XCTAssertThrowsError(try journal.update { $0.hosts.append(AtemSessionHostFence(account: "a", device: "d", trust: 2)) }) + disk.fail = false + XCTAssertThrowsError(try journal.update { $0.hosts.append(AtemSessionHostFence(account: "a", device: "d", trust: 1)) }) + XCTAssertTrue(disk.state.hosts.isEmpty) + } +} + +private final class SessionTerminalFixture { + let protocolFixture = SessionFixture() + var sent: [RemoteTerminalMessage] = [] + var rendered: [(RemoteTerminalFrame, String)] = [] + lazy var terminal = RemoteTerminalController(currentConnection: { self.protocolFixture.source?.route }, + send: { message, _ in self.sent.append(message); return true }, now: { self.protocolFixture.time }) + var session: RemoteTerminalSession { + RemoteTerminalSession(terminalId: sessionID, generation: processID, provider: "claude", rows: 40, cols: 120, + exitStatus: nil, rendering: "vt100-screen-diff", widthProfile: "atem-unicode-width-0.1.14", capabilities: ["terminal-v1"], agentId: sessionID) + } + var stream: RemoteTerminalStream { RemoteTerminalStream(terminalId: sessionID, generation: processID, streamId: "stream") } + init() { + terminal.sessionClient = protocolFixture.client + protocolFixture.client.onChange = { [weak self] in self?.terminal.sessionStateChanged() } + terminal.render = { self.rendered.append(($0, $1)) } + } + func receive(_ message: RemoteTerminalMessage, bound: Bool = false) throws { + let envelope: RemoteTerminalMessage + if bound { + envelope = .bound(message, try XCTUnwrap(protocolFixture.client.binding(for: protocolFixture.target, mutation: false))) + } else { envelope = message } + let bytes = try JSONEncoder().encode(AstationMessage.terminal(envelope)) + guard case .terminal(let decoded) = try AstationMessage.decodePacket(bytes) else { return XCTFail("terminal") } + terminal.receive(decoded, from: protocolFixture.route) + } + func viewing() throws { + try protocolFixture.ready() + terminal.open(preferredTerminalId: sessionID) + try receive(.attached(session, streamId: "stream")) + try receive(.frame(RemoteTerminalFrame(stream: stream, offset: 0, bytes: Data("screen".utf8).base64EncodedString(), + reset: true, complete: true, rows: 40, cols: 120))) + terminal.consumed(try XCTUnwrap(rendered.last).1) + XCTAssertEqual(terminal.phase, .viewing) + } + func control() throws { + terminal.takeControl() + try protocolFixture.lease(replyTo: XCTUnwrap(protocolFixture.sent.last(where: { $0.op == "acquire" })).id) + XCTAssertTrue(terminal.canInput) + } +} + +final class AtemSessionTerminalTests: XCTestCase { + func testOldStreamErrorCannotCloseNewViewAndCurrentRefusalDropsControl() throws { + let f = SessionTerminalFixture() + try f.viewing(); try f.control() + try f.receive(.error(terminalId: sessionID, streamId: "old-stream", code: "stale_target", message: "Old stream")) + XCTAssertTrue(f.terminal.canInput) + XCTAssertEqual(f.terminal.stream, f.stream) + f.protocolFixture.client.refresh() + try f.receive(.error(terminalId: sessionID, streamId: "stream", code: "session_closed", message: "Session closed")) + XCTAssertFalse(f.terminal.canInput) + XCTAssertFalse(f.protocolFixture.client.controls(f.protocolFixture.target)) + XCTAssertNil(f.terminal.stream) + try f.protocolFixture.snapshot(sequence: 2) + XCTAssertFalse(f.protocolFixture.client.controls(f.protocolFixture.target), "A snapshot cannot restore a lease refused during resync") + } + + func testActualTerminalUsesBindingForInputResizeAndReceiptSubmissions() throws { + let f = SessionTerminalFixture() + try f.viewing(); try f.control() + f.terminal.input(Data("hi".utf8)) + f.terminal.resize(rows: 50, cols: 100) + for message in f.sent.dropFirst() { + guard case .bound(_, let binding) = message else { return XCTFail("Unbound terminal request") } + XCTAssertEqual(binding.target, f.protocolFixture.target) + } + XCTAssertTrue(f.terminal.sendScopedInput(kind: "text", value: "run tests")) + XCTAssertTrue(f.terminal.sendScopedInput(kind: "key", value: "ctrl-c")) + f.terminal.stop() + XCTAssertEqual(f.protocolFixture.sent.filter { $0.op == "submit" }.map(\.kind), ["prompt", "interrupt", "stop"]) + XCTAssertEqual(f.protocolFixture.disk.state.requests.count, 3) + XCTAssertFalse(f.sent.contains { if case .control = $0 { return true }; if case .stop = $0 { return true }; return false }) + } + + func testRegistryDrivesDiscoveryAndOnlyAnOutstandingSubscriptionCanAttach() throws { + let f = SessionTerminalFixture() + try f.protocolFixture.handshake() + f.terminal.open(preferredTerminalId: sessionID) + try f.receive(.sessions([f.session])) + try f.receive(.attached(f.session, streamId: "unsolicited")) + XCTAssertEqual(f.terminal.phase, .listing) + XCTAssertTrue(f.sent.isEmpty, "No removed terminal list op") + XCTAssertNil(f.terminal.stream) + try f.protocolFixture.snapshot() + XCTAssertEqual(f.terminal.phase, .attaching) + XCTAssertEqual(f.sent.count, 1) + try f.receive(.attached(f.session, streamId: "stream")) + XCTAssertEqual(f.terminal.stream, f.stream) + } + + func testReleaseRevocationAndExactExpiryFenceTerminalAndRendererCallbacks() throws { + for action in ["release", "revoke", "expiry"] { + let f = SessionTerminalFixture() + try f.viewing(); try f.control() + if action == "release" { f.terminal.release() } + else if action == "revoke" { + var revoked = AtemSessionMessage(op: "lease_changed") + revoked.session_id = sessionID; revoked.process_gen = processID + revoked.lease_epoch = 2; revoked.reason = "local_input" + try f.protocolFixture.receive(revoked) + } else { f.protocolFixture.time = 15 } + let count = f.sent.count + f.terminal.input(Data("late queued key".utf8)) + XCTAssertFalse(f.terminal.canInput) + XCTAssertFalse(f.terminal.sendScopedInput(kind: "text", value: "late voice")) + XCTAssertEqual(f.sent.count, count) + } + } +} diff --git a/Tests/AstationTests/AtemVerificationUITests.swift b/Tests/AstationTests/AtemVerificationUITests.swift index d52e718..90ca2a9 100644 --- a/Tests/AstationTests/AtemVerificationUITests.swift +++ b/Tests/AstationTests/AtemVerificationUITests.swift @@ -146,7 +146,7 @@ final class AtemVerificationUITests: XCTestCase { XCTAssertEqual(context.localizedFallbackTitle, "") XCTAssertEqual(context.touchIDAuthenticationAllowableReuseDuration, 0) let content = try XCTUnwrap(prompt.alert.window.contentView) - for view in [prompt.safetyCode, prompt.authenticationView, prompt.status] + prompt.alert.buttons { + for view in [prompt.safetyCode, prompt.authenticationView, prompt.status, prompt.passwordButton] + prompt.alert.buttons { let frame = content.convert(view.bounds, from: view) XCTAssertGreaterThan(frame.height, 0) XCTAssertGreaterThan(frame.width, 0) @@ -273,6 +273,11 @@ final class AtemVerificationUITests: XCTestCase { drainMainQueue() context.replies[0](false, NSError(domain: LAError.errorDomain, code: code.rawValue)) drainMainQueue() + XCTAssertTrue(results.isEmpty) + XCTAssertTrue(prompt.isActive) + XCTAssertTrue(prompt.passwordButton.isEnabled) + XCTAssertTrue(prompt.status.stringValue.contains("Use Mac Password")) + prompt.alert.buttons[1].performClick(nil) XCTAssertEqual(results, [false]) XCTAssertEqual(context.invalidations, 1) XCTAssertFalse(prompt.alert.window.isVisible) @@ -404,7 +409,7 @@ final class AtemVerificationUITests: XCTestCase { XCTAssertTrue(decisions.isEmpty) XCTAssertTrue(prompt.alert.window.isVisible) let content = try XCTUnwrap(prompt.alert.window.contentView) - for view in [prompt.authenticationView, prompt.status] + prompt.alert.buttons { + for view in [prompt.authenticationView, prompt.status, prompt.passwordButton] + prompt.alert.buttons { let frame = content.convert(view.bounds, from: view) XCTAssertGreaterThan(frame.height, 0) XCTAssertGreaterThan(frame.width, 0) @@ -531,12 +536,192 @@ final class AtemVerificationUITests: XCTestCase { drainMainQueue() context.replies[0](false, NSError(domain: LAError.errorDomain, code: code.rawValue)) drainMainQueue() + XCTAssertTrue(decisions.isEmpty) + XCTAssertTrue(prompt.isActive) + XCTAssertTrue(prompt.passwordButton.isEnabled) + XCTAssertTrue(prompt.status.stringValue.contains("Use Mac Password")) + prompt.alert.buttons[0].performClick(nil) XCTAssertEqual(decisions, [.deny]) XCTAssertEqual(context.invalidations, 1) XCTAssertFalse(prompt.alert.window.isVisible) } } + func testPasswordVerificationRequiresCodesMatchAndSkipsQueuedBiometrics() throws { + _ = NSApplication.shared + let biometric = AuthenticationContext(), password = AuthenticationContext() + var results: [Bool] = [] + let prompt = AtemSafetyCodePrompt(request: verificationRequest, context: biometric, + makePasswordContext: { password }) { results.append($0) } + prompt.show() + XCTAssertFalse(prompt.passwordButton.isEnabled) + prompt.passwordButton.performClick(nil) + XCTAssertTrue(password.policies.isEmpty) + prompt.alert.buttons[0].performClick(nil) + prompt.passwordButton.performClick(nil) + prompt.passwordButton.performClick(nil) + drainMainQueue() + XCTAssertTrue(biometric.policies.isEmpty) + XCTAssertEqual(biometric.invalidations, 1) + XCTAssertEqual(password.policies, [.deviceOwnerAuthentication]) + XCTAssertEqual(password.localizedFallbackTitle, "Use Password...") + XCTAssertEqual(password.touchIDAuthenticationAllowableReuseDuration, 0) + XCTAssertFalse(prompt.authenticationView.context === password) + XCTAssertTrue(prompt.alert.window.isVisible) + XCTAssertEqual(prompt.safetyCode.stringValue, verificationRequest.safetyCode) + let content = try XCTUnwrap(prompt.alert.window.contentView) + XCTAssertTrue(content.bounds.contains(content.convert(prompt.passwordButton.bounds, from: prompt.passwordButton))) + password.replies[0](true, nil) + drainMainQueue() + XCTAssertEqual(results, [true]) + XCTAssertEqual(password.invalidations, 1) + } + + func testSwitchToPasswordIgnoresAlreadyQueuedBiometricSuccess() { + _ = NSApplication.shared + let biometric = AuthenticationContext(), password = AuthenticationContext() + var results: [Bool] = [] + let prompt = AtemSafetyCodePrompt(request: verificationRequest, context: biometric, + makePasswordContext: { password }) { results.append($0) } + prompt.show() + prompt.alert.buttons[0].performClick(nil) + drainMainQueue() + biometric.replies[0](true, nil) + prompt.passwordButton.performClick(nil) + drainMainQueue() + XCTAssertTrue(results.isEmpty) + XCTAssertTrue(prompt.isActive) + XCTAssertEqual(biometric.invalidations, 1) + password.replies[0](true, nil) + drainMainQueue() + XCTAssertEqual(results, [true]) + } + + func testPasswordVerificationRetryIgnoresPreviousAttemptReplies() { + _ = NSApplication.shared + let biometric = AuthenticationContext(), first = AuthenticationContext(), second = AuthenticationContext() + biometric.available = false + var contexts = [first, second] + var results: [Bool] = [] + let prompt = AtemSafetyCodePrompt(request: verificationRequest, context: biometric, + makePasswordContext: { contexts.removeFirst() }) { results.append($0) } + prompt.show() + prompt.alert.buttons[0].performClick(nil) + drainMainQueue() + prompt.passwordButton.performClick(nil) + first.replies[0](false, NSError(domain: LAError.errorDomain, code: LAError.userCancel.rawValue)) + drainMainQueue() + XCTAssertTrue(results.isEmpty) + XCTAssertTrue(prompt.passwordButton.isEnabled) + prompt.passwordButton.performClick(nil) + first.replies[0](true, nil) + drainMainQueue() + XCTAssertTrue(results.isEmpty) + XCTAssertEqual(first.invalidations, 1) + second.replies[0](true, nil) + drainMainQueue() + XCTAssertEqual(results, [true]) + XCTAssertEqual(second.invalidations, 1) + } + + func testPasswordUnlockWorksWithUnavailableTouchID() { + _ = NSApplication.shared + let biometric = AuthenticationContext(), password = AuthenticationContext() + biometric.available = false + var decisions: [AtemUnlockDecision] = [] + let prompt = AtemUnlockPrompt(request: unlockRequest, context: biometric, + makePasswordContext: { password }) { decisions.append($0) } + prompt.show() + drainMainQueue() + XCTAssertTrue(prompt.status.stringValue.contains("Touch ID is unavailable")) + prompt.passwordButton.performClick(nil) + XCTAssertEqual(password.preflightPolicies, [.deviceOwnerAuthentication]) + XCTAssertEqual(password.policies, [.deviceOwnerAuthentication]) + XCTAssertEqual(password.reasons, ["unlock this Atem's storage key"]) + XCTAssertTrue(decisions.isEmpty) + password.replies[0](true, nil) + drainMainQueue() + XCTAssertEqual(decisions, [.approve]) + XCTAssertEqual(biometric.invalidations, 1) + XCTAssertEqual(password.invalidations, 1) + } + + func testPasswordUnlockDenyRevokeAndPeerCancellationIgnoreQueuedSuccess() { + _ = NSApplication.shared + for action in 0..<3 { + let biometric = AuthenticationContext(), password = AuthenticationContext() + var decisions: [AtemUnlockDecision] = [] + let prompt = AtemUnlockPrompt(request: unlockRequest, context: biometric, + makePasswordContext: { password }) { decisions.append($0) } + prompt.show() + drainMainQueue() + prompt.passwordButton.performClick(nil) + password.replies[0](true, nil) + if action < 2 { prompt.alert.buttons[action].performClick(nil) } + else { prompt.finish(.deny) } + biometric.replies[0](true, nil) + drainMainQueue() + XCTAssertEqual(decisions, [action == 1 ? .revoke : .deny]) + XCTAssertEqual(password.invalidations, 1) + XCTAssertEqual(biometric.invalidations, 1) + XCTAssertFalse(prompt.alert.window.isVisible) + } + } + + func testUnavailablePasswordAuthenticationNeverApproves() { + _ = NSApplication.shared + let biometric = AuthenticationContext(), password = AuthenticationContext() + biometric.available = false + password.available = false + var decisions: [AtemUnlockDecision] = [] + let prompt = AtemUnlockPrompt(request: unlockRequest, context: biometric, + makePasswordContext: { password }) { decisions.append($0) } + prompt.show() + prompt.passwordButton.performClick(nil) + drainMainQueue() + XCTAssertTrue(biometric.policies.isEmpty) + XCTAssertTrue(password.policies.isEmpty) + XCTAssertTrue(decisions.isEmpty) + XCTAssertTrue(prompt.passwordButton.isEnabled) + prompt.alert.window.close() + XCTAssertEqual(decisions, [.deny]) + } + + func testPasswordVerificationCancellationCannotApprove() { + _ = NSApplication.shared + for closeWindow in [false, true] { + let biometric = AuthenticationContext(), password = AuthenticationContext() + var results: [Bool] = [] + let prompt = AtemSafetyCodePrompt(request: verificationRequest, context: biometric, + makePasswordContext: { password }) { results.append($0) } + prompt.show() + prompt.alert.buttons[0].performClick(nil) + prompt.passwordButton.performClick(nil) + password.replies[0](true, nil) + if closeWindow { prompt.alert.window.close() } + else { prompt.finish(false) } + drainMainQueue() + XCTAssertEqual(results, [false]) + XCTAssertEqual(password.invalidations, 1) + XCTAssertTrue(biometric.policies.isEmpty) + } + } + + func testLocalCredentialAuthenticationOffersSystemPassword() { + let context = AuthenticationContext() + var results: [Bool] = [] + let cancel = LocalCredentialActions.deviceOwner(context: context, reason: "save a credential") { results.append($0) } + XCTAssertEqual(context.preflightPolicies, [.deviceOwnerAuthentication]) + XCTAssertEqual(context.policies, [.deviceOwnerAuthentication]) + XCTAssertEqual(context.localizedFallbackTitle, "Use Password...") + XCTAssertEqual(context.touchIDAuthenticationAllowableReuseDuration, 0) + context.replies[0](false, nil) + drainMainQueue() + XCTAssertEqual(results, [false]) + cancel() + XCTAssertEqual(context.invalidations, 1) + } + func testUnlockRevokeButtonAndSinglePromptGate() throws { _ = NSApplication.shared let context = AuthenticationContext() diff --git a/Tests/AstationTests/BrowserAgentChatTests.swift b/Tests/AstationTests/BrowserAgentChatTests.swift new file mode 100644 index 0000000..9b6cc59 --- /dev/null +++ b/Tests/AstationTests/BrowserAgentChatTests.swift @@ -0,0 +1,399 @@ +import CryptoKit +import XCTest +@testable import Menubar + +private final class BrowserChatFixture { + let host = RemoteTerminalConnection(clientId: "relay-host", connectionId: UUID().uuidString) + var current: RemoteTerminalConnection? + var time = Date(timeIntervalSince1970: 1_000) + var hostMessages: [(AgentChatWire, RemoteTerminalConnection)] = [] + var browserFrames: [(BrowserChatFrame, RemoteTerminalConnection)] = [] + var sendSucceeds = true + var sessionId = "" + var key = SymmetricKey(size: .bits256) + var nonce = "browser-nonce" + var sequence: UInt64 = 0 + var link: URL! + var browser: RemoteTerminalConnection { + RemoteTerminalConnection(clientId: "relay-browser-\(sessionId)", connectionId: "browser-connection") + } + lazy var chat = BrowserAgentChat(currentRoute: { [unowned self] id in + current?.clientId == id ? current : nil + }, sendAgent: { [unowned self] event, route in + hostMessages.append((event, route)) + return sendSucceeds + }, sendBrowser: { [unowned self] frame, route in browserFrames.append((frame, route)) }, now: { [unowned self] in time }) + + init(capable: Bool = true) throws { + current = host + let agent = try JSONDecoder().decode(AtemAgentInfo.self, from: JSONSerialization.data(withJSONObject: [ + "id": "agent", "name": "Claude", "kind": "ClaudeCode", "capabilities": capable ? ["chat-v1"] : [] + ])) + link = try chat.createLink(relayURL: "https://station.agora.build", stationId: "station", clientId: host.clientId, + agent: agent, hostname: "Genie") + let parameters = URLComponents(string: "?" + (link.fragment ?? ""))?.queryItems ?? [] + sessionId = try XCTUnwrap(parameters.first { $0.name == "session" }?.value) + var secret = try XCTUnwrap(parameters.first { $0.name == "key" }?.value) + .replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + while secret.count % 4 != 0 { secret += "=" } + key = SymmetricKey(data: try XCTUnwrap(Data(base64Encoded: secret))) + } + + func frame(_ body: [String: Any], nonce: String? = nil, sequence: UInt64? = nil) throws -> BrowserChatFrame { + self.sequence += 1 + let number = sequence ?? self.sequence + let nonce = nonce ?? self.nonce + let body = try JSONSerialization.data(withJSONObject: body).base64EncodedString() + let mac = HMAC.authenticationCode(for: BrowserChatFrame.signingData("request", sessionId: sessionId, + nonce: nonce, sequence: number, body: body), using: key) + return BrowserChatFrame(sessionId: sessionId, nonce: nonce, sequence: number, body: body, mac: Data(mac).base64EncodedString()) + } + + func receive(_ body: [String: Any]) throws { chat.receive(try frame(body), from: browser) } + func open() throws { try receive(["op": "open"]) } + func opened(state: String = "ready") throws { + try open() + let request = try XCTUnwrap(hostMessages.last?.0) + chat.receiveAgent(AgentChatWire(op: "opened", conversationId: request.conversationId, agentId: "agent", + generation: "generation", sequence: 0, + status: AgentChatStatus(state: state, since: .milliseconds(1_000_000))), from: host) + } + func event(_ op: String, sequence: UInt64, requestId: String? = nil, text: String? = nil, + messageId: String? = nil, generation: String = "generation", source: RemoteTerminalConnection? = nil) throws { + let conversationId = try XCTUnwrap(hostMessages.last { $0.0.op == "open" }?.0.conversationId) + var event = AgentChatWire(op: op, conversationId: conversationId, agentId: "agent", generation: generation, + sequence: sequence, requestId: requestId, messageId: messageId, text: op == "error" ? text : nil) + if op == "complete" { event.turnId = "turn"; event.outcome = "done" } + if op == "message" { + event.role = "assistant"; event.turnId = "turn"; event.createdAt = .milliseconds(1_000_000) + event.parts = text.map { [AgentChatPart(id: "p1", kind: "text", markdown: $0)] } + } + if op == "delta" { event.part = text.map { AgentChatPart(id: "p\(sequence)", kind: "text", markdown: $0) } } + if op == "status" { event.state = text ?? "ready"; event.since = .milliseconds(1_000_000) } + chat.receiveAgent(event, from: source ?? host) + } + func bodies() throws -> [[String: Any]] { + try browserFrames.map { frame, _ in + let mac = try XCTUnwrap(Data(base64Encoded: frame.mac)) + XCTAssertTrue(HMAC.isValidAuthenticationCode(mac, authenticating: BrowserChatFrame.signingData("response", + sessionId: frame.sessionId, nonce: frame.nonce, sequence: frame.sequence, body: frame.body), using: key)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(Data(base64Encoded: frame.body))) as? [String: Any]) + } + } + var sentPrompts: Int { hostMessages.filter { $0.0.op == "submit" }.count } +} + +final class BrowserAgentChatTests: XCTestCase { + func testPublishedTypedHostFixtureSurvivesNativeBridgeIncludingLateParts() throws { + struct Fixture: Decodable { let requestId: String; let events: [AgentChatWire] } + let url = try XCTUnwrap(Bundle.module.url(forResource: "agent-chat-v1", withExtension: "json", subdirectory: "Fixtures")) + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + let f = try BrowserChatFixture() + try f.open() + let conversation = try XCTUnwrap(f.hostMessages.last?.0.conversationId) + for var event in fixture.events { + event.conversationId = conversation + event.agentId = "agent" + if event.op == "accepted" { + try f.receive(["op": "submit", "requestId": fixture.requestId, "text": "Reply with a harmless greeting"]) + } + f.chat.receiveAgent(event, from: f.host) + } + let events = try f.bodies().compactMap { $0["event"] as? [String: Any] } + XCTAssertEqual(events.count, fixture.events.count) + XCTAssertEqual(f.sentPrompts, 1) + XCTAssertEqual(events[11]["op"] as? String, "delta", "late content after complete remains valid") + XCTAssertEqual(try f.bodies().last?["enabled"] as? Bool, true) + } + + func testPreSessionErrorsAndHeaderlessCloseOnlyAffectExactAuthenticatedConversation() throws { + let f = try BrowserChatFixture() + try f.open() + let conversation = try XCTUnwrap(f.hostMessages.last?.0.conversationId) + let error = AgentChatWire(op: "error", conversationId: conversation, agentId: "agent", text: "chat is unavailable") + let count = f.browserFrames.count + f.chat.receiveAgent(error, from: RemoteTerminalConnection(clientId: f.host.clientId, connectionId: "wrong")) + XCTAssertEqual(f.browserFrames.count, count) + f.chat.receiveAgent(error, from: f.host) + XCTAssertEqual(try f.bodies().last?["notice"] as? String, "chat is unavailable") + XCTAssertEqual(try f.bodies().last?["enabled"] as? Bool, false) + XCTAssertEqual(try f.bodies().last?["canReopen"] as? Bool, true) + + let open = try BrowserChatFixture() + try open.opened() + let id = try XCTUnwrap(open.hostMessages.last?.0.conversationId) + open.chat.receiveAgent(AgentChatWire(op: "close", conversationId: id, agentId: "agent"), from: open.host) + try open.receive(["op": "submit", "requestId": UUID().uuidString, "text": "Must not send"]) + XCTAssertEqual(open.sentPrompts, 0) + } + + func testHistoryPagingUsesFreshConversationAndPinsOriginalProcessGeneration() throws { + let f = try BrowserChatFixture() + try f.opened() + try f.event("message", sequence: 1, text: "Current", messageId: "current") + let original = try XCTUnwrap(f.hostMessages.first?.0.conversationId) + try f.receive(["op": "history", "before": "current"]) + let request = try XCTUnwrap(f.hostMessages.last?.0) + XCTAssertEqual(request.op, "open") + XCTAssertEqual(request.before, "current") + XCTAssertNotEqual(request.conversationId, original) + let before = f.browserFrames.count + f.chat.receiveAgent(AgentChatWire(op: "close", conversationId: original, agentId: "agent"), from: f.host) + XCTAssertEqual(f.browserFrames.count, before, "old conversation cannot close the new view") + f.chat.receiveAgent(AgentChatWire(op: "opened", conversationId: request.conversationId, agentId: "agent", + generation: "restarted", sequence: 0, + status: AgentChatStatus(state: "ready", since: .milliseconds(1_000_000))), from: f.host) + XCTAssertTrue(try f.bodies().contains { $0["blocked"] as? Bool == true }) + XCTAssertEqual(f.sentPrompts, 0) + } + + func testUnknownRegistryAndCapabilityDefaultsNeverClaimIdle() throws { + for status in [nil, "Unknown", "FutureStatus", "Idle", "Thinking", "WaitingForInput", "Disconnected"] as [String?] { + var object = ["id": "agent", "name": "Codex", "kind": "Codex"] + object["status"] = status + let agent = try JSONDecoder().decode(AtemAgentInfo.self, from: JSONSerialization.data(withJSONObject: object)) + XCTAssertEqual(agent.status, ["Idle", "Thinking", "WaitingForInput", "Disconnected"].contains(status ?? "") ? (status ?? "Unknown") : "Unknown") + XCTAssertTrue(agent.capabilities.isEmpty) + } + } + + func testHostPromptRulesMatchNativeBoundary() { + for text in ["", " ", " /resume", "\n !echo test", "bad\r", "\u{1b}[201~bad", "\u{85}", String(repeating: "a", count: 32_769)] { + XCTAssertFalse(BrowserAgentChat.validPrompt(text)) + } + for text in ["# hello", "line\nnext\tcolumn", "emoji \u{200d} ok", String(repeating: "a", count: 32_768)] { + XCTAssertTrue(BrowserAgentChat.validPrompt(text)) + } + } + + func testStatusGatesPromptsAndUnknownDeliveryCanRecoverOnlyFromHostHistory() throws { + let f = try BrowserChatFixture() + try f.opened(state: "unknown") + try f.receive(["op": "submit", "requestId": UUID().uuidString, "text": "Not ready"]) + XCTAssertEqual(f.sentPrompts, 0) + try f.event("status", sequence: 1) + let id = UUID().uuidString + try f.receive(["op": "submit", "requestId": id, "text": "Maybe delivered"]) + try f.event("error", sequence: 2, requestId: id, text: "delivery unknown; check the terminal") + try f.receive(["op": "submit", "requestId": UUID().uuidString, "text": "Must not retry"]) + XCTAssertEqual(f.sentPrompts, 1) + try f.receive(["op": "reopen"]) + XCTAssertEqual(f.sentPrompts, 1) + XCTAssertEqual(try f.bodies().last?["enabled"] as? Bool, false) + let conversation = try XCTUnwrap(f.hostMessages.last?.0.conversationId) + f.chat.receiveAgent(AgentChatWire(op: "opened", conversationId: conversation, agentId: "agent", + generation: "generation", sequence: 0, + status: AgentChatStatus(state: "ready", since: .milliseconds(1_000_000))), from: f.host) + f.chat.receiveAgent(AgentChatWire(op: "message", conversationId: conversation, agentId: "agent", + generation: "generation", sequence: 1, requestId: id, messageId: "confirmed-user", + role: "user", origin: "remote", turnId: "turn", createdAt: .milliseconds(1_000_000), + parts: [AgentChatPart(id: "text", kind: "text", markdown: "Maybe delivered")], history: true), from: f.host) + XCTAssertEqual(try f.bodies().last?["deliveryUnknown"] as? Bool, false) + try f.receive(["op": "submit", "requestId": UUID().uuidString, "text": "A separate new task"]) + XCTAssertEqual(f.sentPrompts, 2, "only an explicitly submitted new request is sent") + } + + func testMACMatchesIndependentJavaScriptAndPythonVectors() throws { + let key = SymmetricKey(data: Data(repeating: 0, count: 32)) + let frame = BrowserChatFrame(sessionId: "test-session", nonce: "test-nonce", sequence: 1, + body: "eyJvcCI6Im9wZW4ifQ==", mac: "TNBvyVK7GHmiZIA5L3gZe9NzZyELeIyGC3hlntDA56Y=") + XCTAssertEqual(frame.open(key: key)?["op"] as? String, "open") + let signed = try XCTUnwrap(BrowserChatFrame.response(key: key, sessionId: frame.sessionId, nonce: frame.nonce, + sequence: 1, object: ["op": "open"])) + XCTAssertEqual(signed.mac, "xrk7NVzgDUxvcJ45X+FWsW2Rjg7xDY4xFq3v37xTNWY=") + XCTAssertNil(signed.open(key: key), "response cannot authenticate as a request") + let changed = BrowserChatFrame(sessionId: frame.sessionId, nonce: frame.nonce, sequence: 2, body: frame.body, mac: frame.mac) + XCTAssertNil(changed.open(key: key)) + } + + func testLinkKeepsSecretInFragmentAndUnsupportedHostCannotReceivePrompts() throws { + let f = try BrowserChatFixture(capable: false) + XCTAssertEqual(f.link.path, "/agent") + XCTAssertNil(f.link.query) + XCTAssertTrue(f.link.fragment?.contains("key=") == true) + try f.open() + try f.receive(["op": "submit", "requestId": UUID().uuidString, "text": "Hello"]) + XCTAssertTrue(f.hostMessages.isEmpty) + let context = try XCTUnwrap(f.bodies().first) + XCTAssertEqual(context["supported"] as? Bool, false) + XCTAssertEqual(context["enabled"] as? Bool, false) + } + + func testAuthenticatedBrowserIsBoundToExactSourceAndFramesCannotBeReplayed() throws { + let f = try BrowserChatFixture() + let open = try f.frame(["op": "open"]) + f.chat.receive(open, from: RemoteTerminalConnection(clientId: "relay-another", connectionId: "browser-connection")) + XCTAssertTrue(f.hostMessages.isEmpty) + f.chat.receive(open, from: f.browser) + let count = f.browserFrames.count + f.chat.receive(open, from: f.browser) + XCTAssertEqual(f.browserFrames.count, count) + XCTAssertEqual(f.hostMessages.count, 1) + let refresh = try f.frame(["op": "refresh"]) + f.chat.receive(refresh, from: RemoteTerminalConnection(clientId: f.browser.clientId, connectionId: "replaced")) + XCTAssertEqual(f.browserFrames.count, count) + } + + func testReloadBindsFreshNonceReopensHistoryAndNeverReplaysPrompt() throws { + let f = try BrowserChatFixture() + try f.opened() + let id = UUID().uuidString + try f.receive(["op": "submit", "requestId": id, "text": "One prompt"]) + let newBrowser = RemoteTerminalConnection(clientId: f.browser.clientId, connectionId: "reconnected") + let reload = try f.frame(["op": "open"], nonce: "new-nonce", sequence: 1) + f.chat.receive(reload, from: newBrowser) + XCTAssertEqual(f.hostMessages.filter { $0.0.op == "open" }.count, 2) + XCTAssertEqual(f.hostMessages.filter { $0.0.op == "close" }.count, 1) + XCTAssertNotEqual(f.hostMessages.first?.0.conversationId, f.hostMessages.last?.0.conversationId) + XCTAssertEqual(f.sentPrompts, 1) + XCTAssertEqual(f.browserFrames.last?.0.sequence, 1) + XCTAssertEqual(f.browserFrames.last?.1, newBrowser) + XCTAssertEqual(try f.bodies().last?["pending"] as? Bool, true) + let count = f.browserFrames.count + f.chat.receive(try f.frame(["op": "open"], sequence: 1), from: f.browser) + XCTAssertEqual(f.browserFrames.count, count, "old nonce cannot take back the route") + } + + func testPromptDeduplicationAndMatchingCompletion() throws { + let f = try BrowserChatFixture() + try f.opened() + let id = UUID().uuidString + let prompt: [String: Any] = ["op": "submit", "requestId": id, "text": "Only once"] + try f.receive(prompt) + try f.receive(prompt) + XCTAssertEqual(f.sentPrompts, 1) + XCTAssertEqual(f.hostMessages.last?.1, f.host) + try f.event("accepted", sequence: 1, requestId: id) + f.time.addTimeInterval(60) + f.chat.tick() + try f.event("complete", sequence: 2, requestId: id) + try f.receive(prompt) + XCTAssertEqual(f.sentPrompts, 1) + try f.receive(["op": "submit", "requestId": UUID().uuidString, "text": "Stale ready"]) + XCTAssertEqual(f.sentPrompts, 1) + try f.event("status", sequence: 3) + try f.receive(["op": "submit", "requestId": UUID().uuidString, "text": "Next"]) + XCTAssertEqual(f.sentPrompts, 2) + } + + func testImmediateDisconnectAndDelayedHostEventDoNotReachReplacedConnection() throws { + let f = try BrowserChatFixture() + try f.opened() + let id = UUID().uuidString + try f.receive(["op": "submit", "requestId": id, "text": "Prompt"]) + f.current = RemoteTerminalConnection(clientId: f.host.clientId, connectionId: "new-generation") + let count = f.browserFrames.count + try f.event("accepted", sequence: 1, requestId: id) + XCTAssertEqual(f.browserFrames.count, count) + f.chat.tick() + try f.receive(["op": "submit", "requestId": UUID().uuidString, "text": "No fallback"]) + XCTAssertEqual(f.sentPrompts, 1) + XCTAssertTrue(try f.bodies().contains { $0["blocked"] as? Bool == true }) + } + + func testRevocationBetweenOpenAndSubmitPreventsDelivery() throws { + let f = try BrowserChatFixture() + try f.opened() + f.current = nil + try f.receive(["op": "submit", "requestId": UUID().uuidString, "text": "Revoked"]) + XCTAssertEqual(f.sentPrompts, 0) + } + + func testUnacknowledgedPromptBecomesUnknownWithoutRetryAndSessionExpires() throws { + let f = try BrowserChatFixture() + try f.opened() + try f.receive(["op": "submit", "requestId": UUID().uuidString, "text": "Maybe delivered"]) + f.time.addTimeInterval(13) + f.chat.tick() + try f.receive(["op": "open"]) + XCTAssertEqual(f.sentPrompts, 1) + XCTAssertTrue(try f.bodies().contains { $0["delivery"] as? String == "unknown" }) + f.time.addTimeInterval(3600) + f.chat.tick() + let count = f.browserFrames.count + try f.receive(["op": "open"]) + XCTAssertEqual(f.browserFrames.count, count) + } + + func testWrongHostAndDuplicatesIgnoredButSequenceGapStopsSending() throws { + let f = try BrowserChatFixture() + try f.opened() + let count = f.browserFrames.count + try f.event("message", sequence: 1, text: "Bad", messageId: "m", source: RemoteTerminalConnection(clientId: "other", connectionId: f.host.connectionId)) + XCTAssertEqual(f.browserFrames.count, count) + try f.event("message", sequence: 1, text: "Good", messageId: "m") + try f.event("message", sequence: 1, text: "Duplicate", messageId: "m") + XCTAssertEqual(f.browserFrames.count, count + 2) + try f.event("message", sequence: 3, text: "Gap", messageId: "m") + try f.receive(["op": "submit", "requestId": UUID().uuidString, "text": "Blocked"]) + XCTAssertEqual(f.sentPrompts, 0) + XCTAssertTrue(try f.bodies().contains { $0["canReopen"] as? Bool == true && $0["enabled"] as? Bool == false }) + } + + func testChangedProcessGenerationImmediatelyBlocksExistingView() throws { + let f = try BrowserChatFixture() + try f.opened() + try f.event("status", sequence: 1, generation: "changed-process") + try f.receive(["op": "submit", "requestId": UUID().uuidString, "text": "Must not cross processes"]) + XCTAssertEqual(f.sentPrompts, 0) + XCTAssertTrue(try f.bodies().contains { $0["blocked"] as? Bool == true }) + } + + func testLiveTranscriptEchoDoesNotReplaceExplicitAcceptanceAcknowledgment() throws { + let f = try BrowserChatFixture() + try f.opened() + let id = UUID().uuidString + try f.receive(["op": "submit", "requestId": id, "text": "Needs both hook and transcript"]) + let conversation = try XCTUnwrap(f.hostMessages.first?.0.conversationId) + f.chat.receiveAgent(AgentChatWire(op: "message", conversationId: conversation, agentId: "agent", + generation: "generation", sequence: 1, requestId: id, messageId: "user", + role: "user", origin: "remote", turnId: "turn", createdAt: .milliseconds(1_000_000), + parts: [AgentChatPart(id: "text", kind: "text", markdown: "Needs both hook and transcript")]), from: f.host) + f.time.addTimeInterval(13) + f.chat.tick() + XCTAssertEqual(try f.bodies().last?["deliveryUnknown"] as? Bool, true) + XCTAssertEqual(f.sentPrompts, 1) + } + + func testInvalidHostEventsAndAccumulatedRepliesFailClosed() throws { + let malformed = try BrowserChatFixture() + try malformed.opened() + try malformed.event("accepted", sequence: 1) + XCTAssertTrue(try malformed.bodies().contains { $0["blocked"] as? Bool == true }) + let f = try BrowserChatFixture() + try f.opened() + try f.event("message", sequence: 1, text: String(repeating: "a", count: 131_072), messageId: "m") + try f.event("delta", sequence: 2, text: "overflow", messageId: "m") + XCTAssertTrue(try f.bodies().contains { $0["blocked"] as? Bool == true }) + } + + func testEmptyPartMetadataIsBoundedEvenWithoutAnyContentBytes() throws { + let f = try BrowserChatFixture() + try f.opened() + let conversation = try XCTUnwrap(f.hostMessages.first?.0.conversationId) + let parts = (0..<2048).map { + AgentChatPart(id: String(repeating: "p", count: 120) + String($0), kind: "text", markdown: "") + } + for index in 0..<60 { + f.chat.receiveAgent(AgentChatWire(op: "message", conversationId: conversation, agentId: "agent", + generation: "generation", sequence: UInt64(index + 1), messageId: "m\(index)", + role: "assistant", turnId: "turn", createdAt: .milliseconds(1_000_000), parts: parts), from: f.host) + } + XCTAssertTrue(try f.bodies().contains { $0["blocked"] as? Bool == true }) + XCTAssertLessThan(try f.bodies().filter { ($0["event"] as? [String: Any])?["op"] as? String == "message" }.count, 60) + } + + func testChatEnvelopesAndURLCapabilitiesAreRedactedFromLogs() throws { + for text in [ + #"{"type":"agentChat","data":{"op":"message","text":"private reply"}}"#, + #"{"atem_id":"peer","payload":{"type":"browserChat","data":{"body":"private reply","mac":"signature"}}}"#, + #"{"type":"agentChat","data":{"text":"private reply""# + ] { + XCTAssertFalse(NetworkDebugLogger.sanitizedPayload(text).contains("private reply")) + } + let url = URL(string: "https://station.agora.build/agent/#station=id&key=private-key")! + XCTAssertFalse(NetworkDebugLogger.sanitizeURL(url).contains("private-key")) + XCTAssertFalse(NetworkDebugLogger.sanitizedPayload(url.absoluteString).contains("private-key")) + XCTAssertTrue(AstationMessage.agentChat(AgentChatWire(op: "open", conversationId: "c", agentId: "a")).isPrivateAgentTraffic) + } +} diff --git a/Tests/AstationTests/DirectConnectionTests.swift b/Tests/AstationTests/DirectConnectionTests.swift index b29f193..7d717aa 100644 --- a/Tests/AstationTests/DirectConnectionTests.swift +++ b/Tests/AstationTests/DirectConnectionTests.swift @@ -202,6 +202,49 @@ final class DirectConnectionTests: XCTestCase { _ = socket.close() } + func testRevokedDirectPairingRejectsTerminalTrafficInBothDirections() throws { + for outbound in [false, true] { + let fixture = try DirectServerFixture(host: "127.0.0.1") + defer { fixture.shutdown() } + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + var clientId = "" + let authenticate = loopbackAuthMessage(fixture: fixture, atemId: "terminal-test") + let (result, socket) = try connect(host: "127.0.0.1", fixture: fixture, group: group, + authMessage: { data in + clientId = data["clientId"] ?? "" + return authenticate(data) + }).wait() + XCTAssertEqual(result, "authenticated:local_proof") + XCTAssertFalse(clientId.isEmpty) + let session = try XCTUnwrap(fixture.sessions.getAllActive().first) + fixture.sessions.delete(sessionId: session.id) + XCTAssertNil(fixture.sessions.get(sessionId: session.id)) + let closed = expectation(description: "Revoked terminal transport closes") + socket.onClose.whenComplete { _ in closed.fulfill() } + if outbound { + fixture.server.sendMessageToClient(.terminal(.list), clientId: clientId) + } else { + socket.send(String(decoding: try JSONEncoder().encode(AstationMessage.terminal(.sessions([]))), as: UTF8.self)) + } + wait(for: [closed], timeout: 2) + } + } + + func testPairingRemovalDisconnectsAuthenticatedDirectSocket() throws { + let fixture = try DirectServerFixture(host: "127.0.0.1") + defer { fixture.shutdown() } + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + let (_, socket) = try connect(host: "127.0.0.1", fixture: fixture, group: group, + authMessage: loopbackAuthMessage(fixture: fixture, atemId: "terminal-test")).wait() + let session = try XCTUnwrap(fixture.sessions.getAllActive().first) + let closed = expectation(description: "Removed pairing closes its direct socket") + socket.onClose.whenComplete { _ in closed.fulfill() } + fixture.server.disconnectClients(authenticatedWith: [session.id]) + wait(for: [closed], timeout: 2) + } + private func connect( host: String, fixture: DirectServerFixture, diff --git a/Tests/AstationTests/Fixtures/agent-chat-v1.json b/Tests/AstationTests/Fixtures/agent-chat-v1.json new file mode 100644 index 0000000..59749f2 --- /dev/null +++ b/Tests/AstationTests/Fixtures/agent-chat-v1.json @@ -0,0 +1,189 @@ +{ + "source": "Shapes from Agora-Build/Atem PR 65, src/chat/wire.rs at 10d3ea8. Synthetic content, no provider acceptance claim.", + "requestId": "12345678-1234-4234-8234-123456789abc", + "events": [ + { + "v": 1, + "op": "opened", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 0, + "status": { + "state": "ready", + "since": 1700000000000 + } + }, + { + "v": 1, + "op": "message", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 1, + "messageId": "local-user", + "role": "user", + "origin": "local", + "turnId": "local-turn", + "createdAt": 1700000000100, + "parts": [ + { + "id": "text", + "kind": "text", + "markdown": "A terminal prompt" + } + ], + "history": true + }, + { + "v": 1, + "op": "message", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 2, + "messageId": "local-reply", + "role": "assistant", + "turnId": "local-turn", + "createdAt": 1700000000200, + "parts": [ + { + "id": "text", + "kind": "text", + "markdown": "An earlier reply" + } + ], + "history": true + }, + { + "v": 1, + "op": "status", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 3, + "state": "working", + "since": 1700000000300 + }, + { + "v": 1, + "op": "complete", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 4, + "turnId": "local-turn", + "outcome": "interrupted" + }, + { + "v": 1, + "op": "status", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 5, + "state": "ready", + "since": 1700000000400 + }, + { + "v": 1, + "op": "accepted", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 6, + "requestId": "12345678-1234-4234-8234-123456789abc" + }, + { + "v": 1, + "op": "message", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 7, + "messageId": "remote-user", + "role": "user", + "origin": "remote", + "turnId": "remote-turn", + "requestId": "12345678-1234-4234-8234-123456789abc", + "createdAt": 1700000000500, + "parts": [ + { + "id": "text", + "kind": "text", + "markdown": "Reply with a harmless greeting" + } + ] + }, + { + "v": 1, + "op": "message", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 8, + "messageId": "remote-reply", + "role": "assistant", + "turnId": "remote-turn", + "requestId": "12345678-1234-4234-8234-123456789abc", + "createdAt": 1700000000600, + "parts": [ + { + "id": "p1", + "kind": "text", + "markdown": "Hello, **Brent**." + } + ] + }, + { + "v": 1, + "op": "delta", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 9, + "messageId": "remote-reply", + "part": { + "id": "p2", + "kind": "tool", + "name": "Read", + "summary": "README.md" + } + }, + { + "v": 1, + "op": "complete", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 10, + "turnId": "remote-turn", + "requestId": "12345678-1234-4234-8234-123456789abc", + "outcome": "done" + }, + { + "v": 1, + "op": "delta", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 11, + "messageId": "remote-reply", + "part": { + "id": "p3", + "kind": "text", + "markdown": "Late transcript content remains valid." + } + }, + { + "v": 1, + "op": "status", + "conversationId": "fixture-conversation", + "agentId": "fixture-agent", + "generation": "generation", + "sequence": 12, + "state": "ready", + "since": 1700000000700 + } + ] +} diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/closed-session-refuses-work-before-the-lease-check.json b/Tests/AstationTests/Fixtures/atem-session-v1/closed-session-refuses-work-before-the-lease-check.json new file mode 100644 index 0000000..e0c6387 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/closed-session-refuses-work-before-the-lease-check.json @@ -0,0 +1,193 @@ +{ + "name": "closed-session-refuses-work-before-the-lease-check", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "close_session": "ses-aaaaaaaaaaaa" + }, + { + "expect": "c1", + "match": { + "op": "lease_changed", + "reason": "revoked", + "lease_epoch": 2, + "controller": null + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000001", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000001", + "code": "session_closed" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "session_closed", + "terminal_id": "ses-aaaaaaaaaaaa", + "stream_id": "${s}" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000002", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "code": "session_closed", + "requestId": "44444444-4444-4444-8444-000000000002" + } + }, + { + "assert": { + "pty_writes": 0, + "dispatch_attempts": 0, + "stops": 0 + } + } + ], + "note": "Closing a session revokes its lease; later work is session_closed (checked before the lease) in each family, with no effect." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/digest-vectors.json b/Tests/AstationTests/Fixtures/atem-session-v1/digest-vectors.json new file mode 100644 index 0000000..30eec86 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/digest-vectors.json @@ -0,0 +1,140 @@ +{ + "algorithm": "sha256(RFC 8785 JCS(payload)) lowercase hex. Supported domain: objects, arrays, strings (valid Unicode scalar values only), true/false/null, integers within ±(2^53−1). Keys sorted by UTF-16 code units. Parsers must reject the must_reject inputs before canonicalising (duplicate keys at any depth, lone surrogates, non-integers, exponents, unsafe integers, invalid UTF-8).", + "vectors": [ + { + "name": "prompt-ascii", + "payload": { + "text": "run the tests" + }, + "canonical": "{\"text\":\"run the tests\"}", + "sha256": "51b365c654de71c69dac5afdc09dbbf06929ef6137ee7878f32937071d2923a8" + }, + { + "name": "prompt-unicode", + "payload": { + "text": "héllo 世界" + }, + "canonical": "{\"text\":\"héllo 世界\"}", + "sha256": "98945f882da718228730803d0d175141da947f0f1169099d47b558dd44746e8e" + }, + { + "name": "empty", + "payload": {}, + "canonical": "{}", + "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }, + { + "name": "nested-sorted", + "payload": { + "text": "a", + "b": [ + 1, + true, + null, + "x" + ] + }, + "canonical": "{\"b\":[1,true,null,\"x\"],\"text\":\"a\"}", + "sha256": "11132adf10a49624454ea644e1582f0669cf71867b61bd12f20b80ee3cb28e02" + }, + { + "name": "escapes", + "payload": { + "text": "line1\nline2\t\"q\"\\" + }, + "canonical": "{\"text\":\"line1\\nline2\\t\\\"q\\\"\\\\\"}", + "sha256": "40e4b600f8bca9c4c6636065e2274854929101b360f5bc2a4d221ad42e9b450a" + }, + { + "name": "utf16-key-order-supplementary", + "payload": { + "😀": 1, + "fi": 2, + "a": 3, + "é": 4 + }, + "canonical": "{\"a\":3,\"é\":4,\"😀\":1,\"fi\":2}", + "sha256": "db257859f37825cbdbc7e3fa930596e1fa4eac1ce98d86df74169ec143eaf4aa" + }, + { + "name": "supplementary-value", + "payload": { + "text": "emoji 😀 and CJK 世" + }, + "canonical": "{\"text\":\"emoji 😀 and CJK 世\"}", + "sha256": "b9c8f42c512a0164707cae3a06ec873f703bc9e963cf6c6871d96c01eab49d6e" + }, + { + "name": "control-escapes", + "payload": { + "text": "\u0000\u0001\u001f\b\f\n\r\t/" + }, + "canonical": "{\"text\":\"\\u0000\\u0001\\u001f\\b\\f\\n\\r\\t/\"}", + "sha256": "235e007d5d4c4a4288ab4b44483417567dcc3391c301176fbcc39413735f07c9" + }, + { + "name": "safe-integer-max", + "payload": { + "n": 9007199254740991, + "m": -9007199254740991 + }, + "canonical": "{\"m\":-9007199254740991,\"n\":9007199254740991}", + "sha256": "3f54c80abdfbc38f08ed5654622eea6e7d7e58cbac36cf5a0670c8aeb054703d" + }, + { + "name": "nested-key-order", + "payload": { + "z": { + "b": 1, + "a": 2 + }, + "a": [ + { + "y": true, + "x": false + } + ] + }, + "canonical": "{\"a\":[{\"x\":false,\"y\":true}],\"z\":{\"a\":2,\"b\":1}}", + "sha256": "7f886746bde2f1329e79e1fa3909a0f0d44b044c2e301ee84d6ae25eede4d698" + } + ], + "must_reject": [ + { + "name": "float-rejected", + "payload_json": "{\"n\":1.5}" + }, + { + "name": "duplicate-key-rejected", + "payload_json": "{\"text\":\"a\",\"text\":\"b\"}" + }, + { + "name": "unsafe-integer-rejected", + "payload_json": "{\"n\":9007199254740992}" + }, + { + "name": "lone-high-surrogate", + "payload_json": "{\"text\":\"\\ud800\"}" + }, + { + "name": "lone-low-surrogate", + "payload_json": "{\"text\":\"\\udc00x\"}" + }, + { + "name": "negative-unsafe-integer", + "payload_json": "{\"n\":-9007199254740992}" + }, + { + "name": "exponent-number", + "payload_json": "{\"n\":1e3}" + }, + { + "name": "nested-duplicate-key", + "payload_json": "{\"a\":{\"k\":1,\"k\":1}}" + }, + { + "name": "invalid-utf8-bytes", + "payload_hex": "7b2274657874223a22ff227d" + } + ] +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/dispatch-failure-after-partial-write-is-outcome-unknown.json b/Tests/AstationTests/Fixtures/atem-session-v1/dispatch-failure-after-partial-write-is-outcome-unknown.json new file mode 100644 index 0000000..fec5311 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/dispatch-failure-after-partial-write-is-outcome-unknown.json @@ -0,0 +1,177 @@ +{ + "name": "dispatch-failure-after-partial-write-is-outcome-unknown", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "fail_next_pty_write_after_partial": true + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444447", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444447", + "state": "accepted" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444447", + "state": "executing" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444447", + "state": "outcome_unknown", + "detail": "write interrupted" + } + }, + { + "assert": { + "dispatch_attempts": 1, + "pty_bytes_written": "unknown" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444447", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444447", + "state": "outcome_unknown" + } + }, + { + "restart_runtime": { + "runtime_gen": "99999999-9999-4999-8999-999999999999", + "runtime_epoch": 8 + } + }, + { + "reconnect": "c1", + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "request_ids": [ + "44444444-4444-4444-8444-444444444447" + ] + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipts", + "receipts.0.request_id": "44444444-4444-4444-8444-444444444447", + "receipts.0.state": "outcome_unknown" + } + }, + { + "assert": { + "dispatch_attempts": 1 + } + } + ], + "note": "The final failure state is durable, never retried, returned for a duplicate submit and still reported after a runtime restart." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/dispatch-failure-before-any-effect-is-rejected.json b/Tests/AstationTests/Fixtures/atem-session-v1/dispatch-failure-before-any-effect-is-rejected.json new file mode 100644 index 0000000..0e5f7f7 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/dispatch-failure-before-any-effect-is-rejected.json @@ -0,0 +1,177 @@ +{ + "name": "dispatch-failure-before-any-effect-is-rejected", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "fail_next_pty_enqueue": true + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444446", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444446", + "state": "accepted" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444446", + "state": "executing" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444446", + "state": "rejected", + "detail": "could not write to the terminal" + } + }, + { + "assert": { + "dispatch_attempts": 1, + "pty_bytes_written": 0 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444446", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444446", + "state": "rejected" + } + }, + { + "restart_runtime": { + "runtime_gen": "99999999-9999-4999-8999-999999999999", + "runtime_epoch": 8 + } + }, + { + "reconnect": "c1", + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "request_ids": [ + "44444444-4444-4444-8444-444444444446" + ] + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipts", + "receipts.0.request_id": "44444444-4444-4444-8444-444444444446", + "receipts.0.state": "rejected" + } + }, + { + "assert": { + "dispatch_attempts": 1 + } + } + ], + "note": "The final failure state is durable, never retried, returned for a duplicate submit and still reported after a runtime restart." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/duplicate-submit-returns-receipt-and-reuse-is-rejected.json b/Tests/AstationTests/Fixtures/atem-session-v1/duplicate-submit-returns-receipt-and-reuse-is-rejected.json new file mode 100644 index 0000000..e5c5e59 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/duplicate-submit-returns-receipt-and-reuse-is-rejected.json @@ -0,0 +1,151 @@ +{ + "name": "duplicate-submit-returns-receipt-and-reuse-is-rejected", + "setup": { + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "holds_lease_epoch": 1, + "client_kind": "native" + } + ], + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + } + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444444", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444444", + "state": "accepted" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444444", + "state": "executing" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000004", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444444", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "re": "11111111-1111-4111-8111-000000000004", + "request_id": "44444444-4444-4444-8444-444444444444", + "state": "executing" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000005", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444444", + "kind": "prompt", + "payload": { + "text": "something else" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "error", + "code": "request_id_reused" + } + }, + { + "assert": { + "dispatches": 1 + } + } + ] +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/exited-session-refuses-work-before-the-lease-check.json b/Tests/AstationTests/Fixtures/atem-session-v1/exited-session-refuses-work-before-the-lease-check.json new file mode 100644 index 0000000..bef5be3 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/exited-session-refuses-work-before-the-lease-check.json @@ -0,0 +1,193 @@ +{ + "name": "exited-session-refuses-work-before-the-lease-check", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "crash_worker": "ses-aaaaaaaaaaaa" + }, + { + "expect": "c1", + "match": { + "op": "lease_changed", + "reason": "process_ended", + "lease_epoch": 2, + "controller": null + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000001", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000001", + "code": "session_closed" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "session_closed", + "terminal_id": "ses-aaaaaaaaaaaa", + "stream_id": "${s}" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000002", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "code": "session_closed", + "requestId": "44444444-4444-4444-8444-000000000002" + } + }, + { + "assert": { + "pty_writes": 0, + "dispatch_attempts": 0, + "stops": 0 + } + } + ], + "note": "Admission order is control -> session accepting -> lease: after the agent exits, a submit, terminal input or chat submit carrying the old lease epoch is session_closed (not stale_lease), each in its own family, with no effect." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/family-errors-and-chat-results.json b/Tests/AstationTests/Fixtures/atem-session-v1/family-errors-and-chat-results.json new file mode 100644 index 0000000..e21f763 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/family-errors-and-chat-results.json @@ -0,0 +1,414 @@ +{ + "name": "family-errors-and-chat-results", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 2 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "stale_lease", + "terminal_id": "ses-aaaaaaaaaaaa", + "stream_id": "${s}" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "not-the-stream", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "stale_target", + "terminal_id": "ses-aaaaaaaaaaaa", + "stream_id": "not-the-stream" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000001", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 2 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "code": "stale_lease", + "requestId": "44444444-4444-4444-8444-000000000001", + "conversationId": "conv-1" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000002", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "accepted", + "requestId": "44444444-4444-4444-8444-000000000002", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa" + } + }, + { + "turn_complete": "ses-aaaaaaaaaaaa", + "turn_id": "turn-1" + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "complete", + "requestId": "44444444-4444-4444-8444-000000000002", + "turnId": "turn-1", + "outcome": "done" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000002", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "complete", + "requestId": "44444444-4444-4444-8444-000000000002", + "outcome": "done" + } + }, + { + "fail_next_pty_enqueue": true + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000003", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "accepted", + "requestId": "44444444-4444-4444-8444-000000000003" + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "requestId": "44444444-4444-4444-8444-000000000003", + "code": "rejected", + "text": "could not write to the terminal" + } + }, + { + "fail_next_pty_write_after_partial": true + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000004", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "accepted", + "requestId": "44444444-4444-4444-8444-000000000004" + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "requestId": "44444444-4444-4444-8444-000000000004", + "code": "outcome_unknown", + "text": "write interrupted" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000005", + "text": "hi" + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "claude", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "assert": { + "dispatch_attempts": 3, + "dispatches": 2, + "pty_writes": 0 + } + } + ], + "note": "Refused terminal-v1 requests get terminal-v1 error{terminal_id, stream_id, code, message}; a chat-v1 submit hears only chat-v1 ops mapped from its receipt (accepted/executing -> accepted, completed -> complete{outcome:done, turnId}, rejected/outcome_unknown -> error{code, text}), never a session receipt; removed and undecodable family requests get a session error." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/late-renew-reply-does-not-resurrect-control.json b/Tests/AstationTests/Fixtures/atem-session-v1/late-renew-reply-does-not-resurrect-control.json new file mode 100644 index 0000000..8e71da2 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/late-renew-reply-does-not-resurrect-control.json @@ -0,0 +1,367 @@ +{ + "name": "late-renew-reply-does-not-resurrect-control", + "side": "client", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000090", + "re": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 15000 + } + }, + { + "advance_ms": 2000 + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000014", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000090", + "re": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 15000 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000094", + "re": "11111111-1111-4111-8111-000000000014", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 17000 + } + }, + { + "advance_ms": 3000 + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000011", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "advance_ms": 12000 + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000091", + "re": "11111111-1111-4111-8111-000000000011", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": false + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000012", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000092", + "re": "11111111-1111-4111-8111-000000000012", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 3 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 32000 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000090", + "re": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 32000 + } + }, + { + "advance_ms": 14000 + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000013", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "advance_ms": 500 + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000093", + "re": "11111111-1111-4111-8111-000000000013", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 3 + }, + "ms_left": 500 + } + } + }, + { + "assert_client": { + "controls": false, + "input_enabled": false + } + } + ], + "note": "Client-side fixture (t = fake monotonic clock, ms). While renew B (sent at 2000) is pending, a duplicate of the already consumed acquire reply is dropped and the deadline stays 15000; B's reply then gives 2000+15000 = 17000. The reply to the renew sent at 5000 arrives at t=17000 with no timer tick: the acknowledged deadline (17000) is reached (now >= deadline), so it is dropped. A replayed reply to an already consumed re is dropped. A repeat acquire sent at 31000 whose reply (ms_left 500) is applied at 31500 gives deadline = min(32000, 31000+500, auth) = 31500 = now, so control and input are off immediately (expiry is now >= deadline)." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/lease-expires-without-renewal.json b/Tests/AstationTests/Fixtures/atem-session-v1/lease-expires-without-renewal.json new file mode 100644 index 0000000..4472211 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/lease-expires-without-renewal.json @@ -0,0 +1,107 @@ +{ + "name": "lease-expires-without-renewal", + "setup": { + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ], + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + } + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000001", + "binding.lease_epoch": 1 + } + }, + { + "advance_ms": 15001 + }, + { + "expect": "c1", + "match": { + "op": "lease_changed", + "reason": "expired", + "lease_epoch": 2 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "error", + "code": "stale_lease" + } + }, + { + "assert": { + "lease_holder": null, + "pty_writes": 0 + } + } + ] +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/lease-expiry-at-the-deadline-needs-no-timer.json b/Tests/AstationTests/Fixtures/atem-session-v1/lease-expiry-at-the-deadline-needs-no-timer.json new file mode 100644 index 0000000..6ee9686 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/lease-expiry-at-the-deadline-needs-no-timer.json @@ -0,0 +1,223 @@ +{ + "name": "lease-expiry-at-the-deadline-needs-no-timer", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000001", + "binding.lease_epoch": 1, + "ms_left": 15000 + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "hold_pty_writes": true + }, + { + "advance_ms": 14999, + "timers": false + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "advance_ms": 1, + "timers": false + }, + { + "flush_pty_writes": true + }, + { + "assert": { + "pty_writes_remote": 0, + "pty_writes_dropped": 1 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000002", + "code": "stale_lease" + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "expired", + "lease_epoch": 2 + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 1, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "stale_lease" + } + }, + { + "assert": { + "lease_holder": null, + "pty_writes": 0 + } + } + ], + "note": "Expiry is now >= deadline whether or not a timer fired: with the clock moved to exactly 15000 ms and no tick, bytes queued at 14999 are dropped at the write boundary, and renew/input are refused. The host may announce the expiry (lease_changed) when it next sees the time." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/local-input-revokes-the-lease-and-drops-queued-bytes.json b/Tests/AstationTests/Fixtures/atem-session-v1/local-input-revokes-the-lease-and-drops-queued-bytes.json new file mode 100644 index 0000000..272ff08 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/local-input-revokes-the-lease-and-drops-queued-bytes.json @@ -0,0 +1,154 @@ +{ + "name": "local-input-revokes-the-lease-and-drops-queued-bytes", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "hold_pty_writes": true + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "local_input": "ses-aaaaaaaaaaaa", + "bytes": "eA==" + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "local_input", + "lease_epoch": 2, + "controller": null + } + }, + { + "flush_pty_writes": true + }, + { + "assert": { + "pty_writes_local": 1, + "pty_writes_remote": 0, + "pty_writes_dropped": 1, + "lease_holder": null + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 1, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "stale_lease" + } + } + ], + "note": "Local input wins: the remote lease is revoked (new epoch) before the local bytes are written, and remote bytes queued under the old epoch are dropped at the write boundary." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/receipt-query-is-scoped-to-the-exact-target.json b/Tests/AstationTests/Fixtures/atem-session-v1/receipt-query-is-scoped-to-the-exact-target.json new file mode 100644 index 0000000..6c03a0d --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/receipt-query-is-scoped-to-the-exact-target.json @@ -0,0 +1,368 @@ +{ + "name": "receipt-query-is-scoped-to-the-exact-target", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + { + "session_id": "ses-bbbbbbbbbbbb", + "process_gen": "22222222-2222-4222-8222-bbbbbbbbbbbb" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + }, + { + "name": "v", + "attachment_gen": "77777777-7777-4777-8777-777777777777", + "permissions": [ + "view" + ], + "client_kind": "native" + }, + { + "name": "b", + "attachment_gen": "88888888-8888-4888-8888-888888888888", + "permissions": [ + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000001" + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "granted", + "session_id": "ses-aaaaaaaaaaaa" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-bbbbbbbbbbbb", + "process_gen": "22222222-2222-4222-8222-bbbbbbbbbbbb" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000002" + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "granted", + "session_id": "ses-bbbbbbbbbbbb" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000001", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000001", + "state": "accepted" + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000001", + "state": "executing" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000004", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-bbbbbbbbbbbb", + "process_gen": "22222222-2222-4222-8222-bbbbbbbbbbbb" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000002", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000002", + "state": "accepted" + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000002", + "state": "executing" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000005", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "request_ids": [ + "44444444-4444-4444-8444-000000000001", + "44444444-4444-4444-8444-000000000002", + "44444444-4444-4444-8444-000000000099", + "44444444-4444-4444-8444-000000000001" + ] + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipts", + "re": "11111111-1111-4111-8111-000000000005", + "receipts.#": 1, + "receipts.0.request_id": "44444444-4444-4444-8444-000000000001", + "receipts.0.target.session_id": "ses-aaaaaaaaaaaa" + } + }, + { + "send": "v", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000006", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-bbbbbbbbbbbb", + "process_gen": "22222222-2222-4222-8222-bbbbbbbbbbbb" + }, + "attachment_gen": "77777777-7777-4777-8777-777777777777" + }, + "request_ids": [ + "44444444-4444-4444-8444-000000000001", + "44444444-4444-4444-8444-000000000002" + ] + } + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "receipts", + "re": "11111111-1111-4111-8111-000000000006", + "receipts.#": 1, + "receipts.0.request_id": "44444444-4444-4444-8444-000000000002" + } + }, + { + "send": "b", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000007", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "88888888-8888-4888-8888-888888888888" + }, + "request_ids": [ + "44444444-4444-4444-8444-000000000001" + ] + } + } + }, + { + "expect": "b", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000007", + "code": "unknown_session" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000008", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 5, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "request_ids": [ + "44444444-4444-4444-8444-000000000001" + ] + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000008", + "code": "stale_target" + } + }, + { + "assert": { + "receipts_visible": { + "c1": 2, + "v": 2, + "b": 0 + } + } + } + ], + "note": "receipt_query returns only receipts bound to exactly the binding's target (other targets' and unknown ids are absent, duplicates once); without view it is unknown_session; a wrong trust generation is stale_target." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/reconnect-restarts-the-registry-sequence.json b/Tests/AstationTests/Fixtures/atem-session-v1/reconnect-restarts-the-registry-sequence.json new file mode 100644 index 0000000..a1532ae --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/reconnect-restarts-the-registry-sequence.json @@ -0,0 +1,141 @@ +{ + "name": "reconnect-restarts-the-registry-sequence", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "list", + "id": "11111111-1111-4111-8111-000000000001" + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "sessions", + "re": "11111111-1111-4111-8111-000000000001", + "seq": 1, + "runtime_epoch": 7, + "sessions.#": 1 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000002" + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "session_changed", + "seq": 2, + "descriptor.controller.lease_epoch": 1 + } + }, + { + "reconnect": "c1", + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "list", + "id": "11111111-1111-4111-8111-000000000003" + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "sessions", + "re": "11111111-1111-4111-8111-000000000003", + "seq": 1, + "runtime_epoch": 7, + "sessions.0.controller.lease_epoch": 1 + } + }, + { + "local_input": "ses-aaaaaaaaaaaa", + "bytes": "eA==" + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "local_input", + "lease_epoch": 2 + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "session_changed", + "seq": 2, + "descriptor.controller": null + } + } + ], + "note": "seq counts the messages one attachment received: a reconnected client (new attachment) gets a snapshot at seq 1 again and changes continue from there; the old attachment's lease is not inherited." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/registry-restarts-on-a-new-attachment.json b/Tests/AstationTests/Fixtures/atem-session-v1/registry-restarts-on-a-new-attachment.json new file mode 100644 index 0000000..4726886 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/registry-restarts-on-a-new-attachment.json @@ -0,0 +1,177 @@ +{ + "name": "registry-restarts-on-a-new-attachment", + "side": "client", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000101", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7, + "seq": 5, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + ] + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "seq": 5 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "welcome", + "id": "11111111-1111-4111-8111-000000000102", + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "controller_session": "12121212-1212-4212-8212-121212121212", + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "client_kind": "native", + "permissions": [ + "view" + ], + "authorization_ms_left": 86400000, + "server_time": 1760500000000 + } + } + }, + { + "assert_client": { + "registry": [], + "runtime_epoch": 7 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000103", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7, + "seq": 1, + "sessions": [] + } + } + }, + { + "assert_client": { + "registry": [], + "seq": 1 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "session_changed", + "id": "11111111-1111-4111-8111-000000000104", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7, + "seq": 2, + "descriptor": { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "seq": 2 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000105", + "runtime_gen": "77777777-7777-4777-8777-777777777777", + "runtime_epoch": 6, + "seq": 9, + "sessions": [] + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "runtime_epoch": 7, + "seq": 2 + } + } + ], + "note": "Client side: a welcome starts a new attachment, so the registry clears and the next snapshot applies at any seq (here 1 after 5), while the highest runtime epoch stays the floor." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/removed-ops-have-no-effect.json b/Tests/AstationTests/Fixtures/atem-session-v1/removed-ops-have-no-effect.json new file mode 100644 index 0000000..76d1369 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/removed-ops-have-no-effect.json @@ -0,0 +1,437 @@ +{ + "name": "removed-ops-have-no-effect", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "up", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + }, + { + "name": "other", + "attachment_gen": "66666666-6666-4666-8666-666666666666", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "up", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "up", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000001" + } + }, + { + "expect": "other", + "match": { + "op": "lease_changed", + "reason": "granted", + "lease_epoch": 1 + } + }, + { + "send": "other", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + } + } + } + }, + { + "expect": "other", + "match": { + "op": "attached" + }, + "capture": { + "s2": "stream_id" + } + }, + { + "send": "other", + "json": { + "type": "agentInput", + "data": { + "agent_id": null, + "kind": "text", + "text": "rm -rf x" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentInput", + "data": { + "agent_id": "ses-aaaaaaaaaaaa", + "kind": "key", + "key": "enter" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentPrompt", + "data": { + "agent_id": "ses-aaaaaaaaaaaa", + "session_id": null, + "text": "hi" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "voiceCommand", + "data": { + "text": "run the tests", + "is_final": true + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "userCommand", + "data": { + "command": "ls", + "action": "cli_input" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "c", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-444444444445", + "text": "hi" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "control", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s2}", + "take": true + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "claude", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + } + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "assert": { + "pty_writes": 0, + "resizes": 0, + "stops": 0, + "dispatches": 0, + "lease_holder": "up" + } + }, + { + "send": "up", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "release", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "up", + "match": { + "op": "lease_released" + } + }, + { + "expect": "other", + "match": { + "op": "lease_changed", + "reason": "released", + "lease_epoch": 2, + "controller": null + } + }, + { + "send": "other", + "json": { + "type": "agentInput", + "data": { + "agent_id": null, + "kind": "text", + "text": "rm -rf x" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentInput", + "data": { + "agent_id": "ses-aaaaaaaaaaaa", + "kind": "key", + "key": "enter" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentPrompt", + "data": { + "agent_id": "ses-aaaaaaaaaaaa", + "session_id": null, + "text": "hi" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "voiceCommand", + "data": { + "text": "run the tests", + "is_final": true + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "userCommand", + "data": { + "command": "ls", + "action": "cli_input" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "c", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-444444444445", + "text": "hi" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "local_input": "ses-aaaaaaaaaaaa", + "bytes": "eA==" + }, + { + "assert": { + "pty_writes_local": 1, + "pty_writes_remote": 0, + "dispatches": 0 + } + } + ], + "note": "r6: no legacy paths. Old ops (agentInput, agentPrompt, voice, userCommand, unbound chat submit, terminal-v1 control) and provider-name terminal ids are refused with unsupported_version and no effect, whether or not a lease is held; only session-v1 acquire grants control; local input still works." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/runtime-epoch-and-sequence-ordering.json b/Tests/AstationTests/Fixtures/atem-session-v1/runtime-epoch-and-sequence-ordering.json new file mode 100644 index 0000000..af943c6 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/runtime-epoch-and-sequence-ordering.json @@ -0,0 +1,258 @@ +{ + "name": "runtime-epoch-and-sequence-ordering", + "side": "client", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000100", + "runtime_gen": "77777777-7777-4777-8777-777777777777", + "runtime_epoch": 7, + "seq": 100, + "sessions": [ + { + "session_id": "ses-oldoldoldold", + "process_gen": "88888888-8888-4888-8888-888888888888", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "ready", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + ] + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000101", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 1, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "ready", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + ] + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "runtime_epoch": 8, + "seq": 1 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000102", + "runtime_gen": "77777777-7777-4777-8777-777777777777", + "runtime_epoch": 7, + "seq": 101, + "sessions": [ + { + "session_id": "ses-oldoldoldold", + "process_gen": "88888888-8888-4888-8888-888888888888", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "ready", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + ] + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "runtime_epoch": 8 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "session_changed", + "id": "11111111-1111-4111-8111-000000000103", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 3, + "descriptor": { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "exited", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + } + } + }, + { + "assert_client": { + "sent_list": true, + "registry_state": { + "ses-aaaaaaaaaaaa": "running" + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "session_changed", + "id": "11111111-1111-4111-8111-000000000104", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 2, + "descriptor": { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "removed", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000105", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 3, + "sessions": [] + } + } + }, + { + "assert_client": { + "registry": [], + "seq": 3 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "session_changed", + "id": "11111111-1111-4111-8111-000000000106", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 3, + "descriptor": { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "exited", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + } + } + }, + { + "assert_client": { + "registry": [] + } + } + ], + "note": "Old-runtime snapshot after a newer epoch is discarded; a gap (seq 3 after 1) triggers list and the change is not applied; during resync changes with seq <= the snapshot's are discarded; a duplicate/late change for the removed session does not resurrect it." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/schema.md b/Tests/AstationTests/Fixtures/atem-session-v1/schema.md new file mode 100644 index 0000000..64cc194 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/schema.md @@ -0,0 +1,58 @@ +# Fixture format (atem-session v1, r6) + +One scenario per `*.json` file; `digest-vectors.json` holds the canonical-digest +known-answer vectors (`vectors`: payload → canonical string → sha256; +`must_reject`: raw JSON (`payload_json`) or bytes (`payload_hex`) a parser must +refuse before canonicalising). + +Scenario: `{name, side?, setup, steps, note?}`. `side` is `host` (default: run +against a host implementation with fake clients) or `client` (run a client +implementation; the fixture plays the host with `deliver_to_client`). + +`setup`: + +- `host`: `{account_id, atem_device_id, host_trust_gen, runtime_gen, runtime_epoch}` — the host's own context. +- `sessions[]`: `{session_id, process_gen, chat?}`. +- `clients[]`: `{name, attachment_gen, permissions, client_kind, holds_lease_epoch?}` — the client's authenticated identity, permissions and connection mode, set by the harness (never derived from message fields). + +Host scenarios start with every client already welcomed: the harness connects +each client, sends `hello` itself and consumes (and checks) the `welcome` +before step 0; `reconnect` does the same on the new attachment. With +`holds_lease_epoch: n` the harness then sends a real `acquire` for the +scenario's only session and requires a `lease` at epoch `n`. None of these +setup messages is visible to the steps. + +`steps[]`, in order, on a deterministic monotonic clock starting at 0 ms: + +| step | meaning | +|---|---| +| `{"send": client, "json": msg}` | client sends `msg` to the host (send time = current clock) | +| `{"expect": client, "type"?: t, "match": {path: value}, "capture"?: {name: path}}` | takes that client's next message of envelope type `t` (when given) and op `match.op` (when given); messages of other types/ops stay queued for later `expect`s. The taken message must match every path (dotted paths into `data`, array indexes as numbers, `#` for an array's length: `receipts.#`; a path that doesn't exist never matches, not even `null`), else the step fails. With neither `type` nor `op` it takes the very next message. `capture` stores values for later steps, referenced in any string as `${name}` | +| `{"expect_all": {…}}` | `expect` with that `match` for every client | +| `{"expect_none": client, "for_ms": n}` | nothing of any type is queued for that client after the clock advances n | +| `{"advance_ms": n, "timers"?: bool}` | advance the clock; timers may fire. With `"timers": false` only the clock moves: the host gets no tick and sees the new time only with the next message, write check or event (expiry must still hold: `now >= deadline`) | +| `{"hold": {"to": client, "op": op}}` / `{"deliver": "held"}` | delay the next matching host→client message, deliver it later (reordering) | +| `{"deliver_to_client": client, "json": msg}` | client-side fixtures: the host's message arrives now | +| `{"disconnect": client}`, `{"resume": client, "attachment_gen": uuid}` | transport events | +| `{"local_input": session_id, "bytes": base64}` | local terminal input on the host | +| `{"crash_worker": session_id}`, `{"restart_runtime": {"runtime_gen": uuid, "runtime_epoch": n}}` | failures | +| `{"fail_next_fsync": true}`, `{"change_trust_gen": n}` | durability and trust events | +| `{"fail_next_pty_enqueue": true}` | the next dispatch fails before any byte reaches the PTY | +| `{"fail_next_pty_write_after_partial": true}` | the next dispatch's writer fails after possibly writing some bytes | +| `{"reconnect": client, "attachment_gen": uuid}` | a new attachment for the same client (no resume) | +| `{"close_session": session_id}` | the runtime closes the session (it no longer accepts work; the process may still run) | +| `{"turn_complete": session_id, "turn_id": t}` | the provider reports that the session's executing prompt's turn `t` ended | +| `{"hold_pty_writes": true}` / `{"flush_pty_writes": true}` | remote terminal writes queue at the PTY writer / the writer writes them now, each re-checked at the write boundary at the current clock (dropped ones count as `pty_writes_dropped`) | +| `{"assert": {…}}` | host counters: `dispatch_attempts` (every dispatch the host started, including ones refused before any effect), `dispatches` (dispatches that reached the PTY/agent: attempts minus those that failed with no effect), `pty_bytes_written` (a number or `"unknown"`), `pty_writes` (terminal input written, split as `pty_writes_local`/`pty_writes_remote`; dispatches are counted separately), `pty_writes_dropped` (remote chunks refused at the write boundary), `resizes`, `stops`, `lease_holder`, `receipts_visible: {client: n}` | +| `{"assert_client": {…}}` | client state: `controls`, `deadline_ms`, `registry` (ids), `registry_state`, `runtime_epoch`, `seq`, `sent_list`, `input_enabled` | + +A host scenario fails if, after its last step, any client still has a queued +(unexpected) message or a held message was never delivered: every message the +host sends must be expected. + +Negative scenarios assert the error and the absence of side effects +(writes, resizes, stops, dispatch, disclosure, resurrection). + +Wire messages in fixtures are complete: every documented required field is present (e.g. full session descriptors). Decoders must parse them with the real wire types. + +Expiry is `now >= deadline` everywhere. diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/source.json b/Tests/AstationTests/Fixtures/atem-session-v1/source.json new file mode 100644 index 0000000..9cb60c7 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/source.json @@ -0,0 +1,26 @@ +{ + "repository": "https://github.com/Agora-Build/Atem", + "tag": "atem-session-v1-r6.2", + "commit": "22de096b1980ee3ae342c0352b4c1d5fa54e3b51", + "revision": "r6.2", + "sha256": { + "closed-session-refuses-work-before-the-lease-check.json": "eb3e53a4ee68d54128290035532e0d866c06045d7955fa5ede3413a6a71915e7", + "digest-vectors.json": "321ba7d68808772b2735732bb60b727f6122f8846cd7b138b82f951c0b4ce6a3", + "dispatch-failure-after-partial-write-is-outcome-unknown.json": "a2c79c07dcb8f3e7c268027a02b0646b85bb64e8c3f52c7cf7cc10480a604da2", + "dispatch-failure-before-any-effect-is-rejected.json": "513ddd521697485ce2b2c19af2380af62fcebc76a2c0c924671db4d21f8a5c2c", + "duplicate-submit-returns-receipt-and-reuse-is-rejected.json": "a7af3d5f15a45904dfdc7c9a0b99430ea0c9dff49644853371930cb8e1848456", + "exited-session-refuses-work-before-the-lease-check.json": "68fd85d6b5f823a2696a69379bfe618847ea93c26007930a3548c31f34300f02", + "family-errors-and-chat-results.json": "a01a7b9f4adc79e61a38024ab103a3736f25526cc46fb1ee18c7b5fe3835fe65", + "late-renew-reply-does-not-resurrect-control.json": "144e867e4807559f8e059112822377d258e3cfccbb738b6337545d0e55cd8b9b", + "lease-expires-without-renewal.json": "eff3581a31d7e7c4fbc98e89901c6c138c8d382c248d8290fbb77ed1d63b53d0", + "lease-expiry-at-the-deadline-needs-no-timer.json": "e2f9debe3cf80ba7a7f48f08a2a5dbf9f69d8531b885652afd497d640458aa31", + "local-input-revokes-the-lease-and-drops-queued-bytes.json": "c5a4d191be6e1c5d93f69c8ad6be8faef731f06796084ae5504071185c27dc0a", + "receipt-query-is-scoped-to-the-exact-target.json": "309adc61b6344e76b795d5d7e236174a8bb36228594846ab4e856da678029410", + "reconnect-restarts-the-registry-sequence.json": "77314620f98187ca93e109acb05777451253db7b9cd3df24b6ab6d6236feb70c", + "registry-restarts-on-a-new-attachment.json": "744d19335f10e22e0fb62a7c94e47dd2222f5d68a67e4c77d2ea2b4bf150d9da", + "removed-ops-have-no-effect.json": "547d383d8fbf3d1064a21ac2cfe330e900772769c45bbe90b93ea029dc44230d", + "runtime-epoch-and-sequence-ordering.json": "75dd2bd6bcd42726cbc74c0c4e9e78eabfc805f3c5341c6bc6df8c91342280aa", + "schema.md": "fef712b3b58ab94a3e23cc4300b241ba7e83124247f841ceb836cb86e6db01c2", + "takeover-moves-control-and-the-old-holder-becomes-a-viewer.json": "9ae061339270b8a5fe731b3813865d990c99ac8a553d4290c5da5b4861c02334" + } +} diff --git a/Tests/AstationTests/Fixtures/atem-session-v1/takeover-moves-control-and-the-old-holder-becomes-a-viewer.json b/Tests/AstationTests/Fixtures/atem-session-v1/takeover-moves-control-and-the-old-holder-becomes-a-viewer.json new file mode 100644 index 0000000..49bce79 --- /dev/null +++ b/Tests/AstationTests/Fixtures/atem-session-v1/takeover-moves-control-and-the-old-holder-becomes-a-viewer.json @@ -0,0 +1,199 @@ +{ + "name": "takeover-moves-control-and-the-old-holder-becomes-a-viewer", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + }, + { + "name": "c2", + "attachment_gen": "66666666-6666-4666-8666-666666666666", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + }, + { + "name": "v", + "attachment_gen": "77777777-7777-4777-8777-777777777777", + "permissions": [ + "view" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + }, + "takeover": false + } + } + }, + { + "expect": "c2", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000001", + "code": "lease_held" + } + }, + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + }, + "takeover": true + } + } + }, + { + "expect": "c2", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000002", + "binding.lease_epoch": 2, + "ms_left": 15000 + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "takeover", + "lease_epoch": 2, + "controller.lease_epoch": 2 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000003", + "code": "stale_lease" + } + }, + { + "send": "v", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000004", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "77777777-7777-4777-8777-777777777777" + }, + "takeover": true + } + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000004", + "code": "forbidden" + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "takeover", + "lease_epoch": 2 + } + }, + { + "assert": { + "lease_holder": "c2" + } + } + ], + "note": "Without takeover a held lease is lease_held; takeover (needs control) moves it with a new epoch, the previous holder and other viewers get lease_changed{takeover}, the requester gets only its lease reply, and the old epoch is stale." +} \ No newline at end of file diff --git a/Tests/AstationTests/Fixtures/session-multiplexing-v1.json b/Tests/AstationTests/Fixtures/session-multiplexing-v1.json new file mode 100644 index 0000000..6fa4f93 --- /dev/null +++ b/Tests/AstationTests/Fixtures/session-multiplexing-v1.json @@ -0,0 +1,44 @@ +{ + "contract": "astation-atem-session-v1", + "defaults": { + "control_lease_seconds": 15, + "control_renew_seconds": 5, + "qr_request_seconds": 120, + "browser_grant_seconds": 3600, + "offline_authorization_seconds": 86400, + "closed_receipt_seconds": 604800, + "closed_history_seconds": 2592000 + }, + "status_labels": { + "credentials": "Credentials" + }, + "session": { + "terminal_id": "claude-process-a", + "generation": "process-generation-a", + "provider": "claude", + "rows": 24, + "cols": 80, + "exit_status": null, + "rendering": "vt100-screen-diff", + "width_profile": "atem-unicode-width-0.1.14", + "capabilities": ["terminal-lease-v1"], + "agent_id": "agent-a", + "label": "Claude / workspace-a" + }, + "renew": { + "v": 1, + "op": "renew", + "terminal_id": "claude-process-a", + "generation": "process-generation-a", + "stream_id": "stream-a", + "lease_id": "lease-a" + }, + "controller": { + "v": 1, + "op": "controller", + "terminal_id": "claude-process-a", + "generation": "process-generation-a", + "stream_id": "stream-a", + "lease_id": "lease-a" + } +} diff --git a/Tests/AstationTests/RemoteTerminalHubTests.swift b/Tests/AstationTests/RemoteTerminalHubTests.swift new file mode 100644 index 0000000..35d9dbd --- /dev/null +++ b/Tests/AstationTests/RemoteTerminalHubTests.swift @@ -0,0 +1,441 @@ +import CryptoKit +import XCTest +@testable import Menubar + +private final class TerminalHubSocket: IdentityRelaySocket, @unchecked Sendable { + var sent: [[String: Any]] = [] + var closeCode = URLSessionWebSocketTask.CloseCode.invalid + var closeReason: Data? + private var receiver: ((Result) -> Void)? + func resume() {} + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {} + func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping @Sendable (Error?) -> Void) { + if case .string(let text) = message, + let object = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any] { sent.append(object) } + completionHandler(nil) + } + func receive(completionHandler: @escaping @Sendable (Result) -> Void) { receiver = completionHandler } + func emit(_ object: [String: Any]) throws { + let text = String(decoding: try JSONSerialization.data(withJSONObject: object), as: UTF8.self) + let callback = receiver + receiver = nil + callback?(.success(.string(text))) + } + func fail() { + let callback = receiver + receiver = nil + callback?(.failure(NSError(domain: NSPOSIXErrorDomain, code: 54))) + } +} + +@MainActor +private final class TerminalHubFixture { + let socket = TerminalHubSocket() + let directory: URL + let defaults: UserDefaults + let suite = "Astation.terminal-hub.\(UUID().uuidString)" + let hub: AstationHubManager + let identity: AtemE2EIdentity + let identityStorage = TestAtemStorage() + let deviceSigning = Curve25519.Signing.PrivateKey() + let unlockSigning = Curve25519.Signing.PrivateKey() + var session: SessionInfo + let deviceId = "selected-host" + var generation = UUID().uuidString + var clientId: String { "relay-" + deviceId } + var terminalFrames: [[String: Any]] { + socket.sent.filter { ($0["payload"] as? [String: Any])?["type"] as? String == "terminal" } + } + var challenges: [String] { + socket.sent.compactMap { object in + guard let payload = object["payload"] as? [String: Any], + let data = payload["data"] as? [String: Any], data["status"] as? String == "auth_required" + else { return nil } + return (data["data"] as? [String: String])?["challenge"] + } + } + + init(verified: Bool = true) throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + let sessions = SessionStore(storageURL: directory.appendingPathComponent("sessions.json")) + session = sessions.create(hostname: "Host", atemId: deviceId) + let key = RelayIdentityKey(softwareKey: P256.Signing.PrivateKey()) + let manager = RelayIdentityKeyManager(loadKey: { _ in key }, perform: { $0() }, deliver: { $0() }, schedule: { _, _ in }) + let socket = self.socket + identity = AtemE2EIdentity(storage: identityStorage, crypto: TestAtemCryptography()) + if verified { + let keys = try identity.verificationKeys(nonce: Data(repeating: 1, count: 32)) + try identity.markRecoverySaved(signingPublicKey: keys.signPub) + _ = try identity.completeVerification(account: AstationIdentity.shared.id, deviceId: deviceId, + reveal: AtemVerifyReveal(devicePub: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation, + deviceSignPub: deviceSigning.publicKey.rawRepresentation, + unlockAuthPub: unlockSigning.publicKey.rawRepresentation, + nonce: Data(repeating: 2, count: 32)), + transcript: Data(repeating: 3, count: 32), expectedSigningKey: keys.signPub, key: nil) + } + hub = AstationHubManager(skipProjectLoad: true, deviceSessionStore: sessions, + sessionStore: SsoSessionStore(storageURL: directory.appendingPathComponent("sso.enc")), + relayIdentityKeyManager: manager, relayIdentityRepairDefaults: defaults, + atemE2EIdentity: identity, sessionJournal: AtemSessionJournal(), makeIdentityRelayTask: { _ in socket }) + } + + func drain() async { + for _ in 0..<4 { await withCheckedContinuation { c in DispatchQueue.main.async { c.resume() } } } + } + + func start() async throws { + hub.startIdentityRelay() + try socket.emit(["type": "relayAuthResult", "status": "verified"]) + await drain() + } + + func authenticate() async throws { + try emit(.statusUpdate(status: "hello", data: ["hostname": "Host"])) + await drain() + try await prove(challenge: XCTUnwrap(challenges.last)) + } + + func prove(challenge: String) async throws { + let proof = DeviceAuthentication.proof(token: session.token, challenge: challenge, + astationId: AstationIdentity.shared.id, atemId: deviceId, sessionId: session.id) + try emit(.statusUpdate(status: "auth", data: ["atem_id": deviceId, "session_id": session.id, "proof": proof])) + await drain() + } + + func emit(_ message: AstationMessage) throws { + try socket.emit(["atem_id": deviceId, "connection_id": generation, + "payload": JSONSerialization.jsonObject(with: JSONEncoder().encode(message))]) + } + + var sessionFrames: [[String: Any]] { + socket.sent.compactMap { $0["payload"] as? [String: Any] }.filter { $0["type"] as? String == "session" } + } + + func readySession() async throws -> (AtemSessionClient, AtemSessionTarget) { + let client = try XCTUnwrap(hub.sessionClient(for: clientId)) + let context = try XCTUnwrap(hub.sessionContext(for: clientId)) + let hello = try XCTUnwrap(sessionFrames.last { ($0["data"] as? [String: Any])?["op"] as? String == "hello" }) + let data = try XCTUnwrap(hello["data"] as? [String: Any]) + var welcome = AtemSessionMessage(op: "welcome") + welcome.re = try XCTUnwrap(data["id"] as? String) + welcome.account_id = context.account; welcome.atem_device_id = context.device + welcome.host_trust_gen = 1; welcome.runtime_gen = "55555555-5555-4555-8555-555555555555" + welcome.controller_session = "66666666-6666-4666-8666-666666666666" + welcome.attachment_gen = "33333333-3333-4333-8333-333333333333" + welcome.client_kind = "native"; welcome.permissions = ["view", "control"] + welcome.authorization_ms_left = 60_000; welcome.server_time = 1_760_500_000_000 + try emit(.session(welcome)); await drain() + let descriptor = try JSONDecoder().decode(AtemSessionDescriptor.self, from: Data( + #"{"session_id":"ses-aaaaaaaaaaaa","process_gen":"22222222-2222-4222-8222-222222222222","provider":"claude","cwd_label":"Project","state":"running","activity":"ready","activity_since":1,"controller":null,"capabilities":["terminal-v1","chat-v1"],"created_at":1}"#.utf8)) + var snapshot = AtemSessionMessage(op: "sessions") + snapshot.runtime_gen = welcome.runtime_gen; snapshot.runtime_epoch = 7; snapshot.seq = 1; snapshot.sessions = [descriptor] + try emit(.session(snapshot)); await drain() + return (client, try XCTUnwrap(client.target(sessionId: descriptor.session_id, process: descriptor.process_gen))) + } + + func revokeVerification() throws { + let account = AstationIdentity.shared.id + let keys = try identity.verificationKeys(nonce: Data(repeating: 4, count: 32)) + let sealed = try AtemE2ECrypto.sealKey(Data(repeating: 5, count: 32), to: keys.encPub, + info: AtemStatements.storageKeyInfo(account: account, deviceId: deviceId, kid: "01020304")) + let rotate = try AtemStatements.storageRotate(account: account, deviceId: deviceId, oldKid: "", newKid: "01020304", + sealedHash: AtemE2ECrypto.sealedKeyDigest(sealed)) + _ = try identity.rotateStorageKey(AtemStorageRotateWire( + rotate: AtemSignedWire(statement: rotate, signature: try deviceSigning.signature(for: rotate)), + encappedKey: sealed.encapsulatedKey, ciphertext: sealed.ciphertext), account: account, deviceId: deviceId) + let request = try AtemStatements.unlockRequest(account: account, deviceId: deviceId, bootId: "test-boot", ticket: "", + ephemeralPublicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation, + nonce: Data(repeating: 6, count: 32), time: UInt64(Date().timeIntervalSince1970), storageKid: "01020304") + let authorization = try identity.prepareUnlock(AtemUnlockRequestWire(request: request, + signature: try unlockSigning.signature(for: request)), account: account, deviceId: deviceId, now: Date()) + try identity.revokeDevice(authorization) + } + + deinit { + defaults.removePersistentDomain(forName: suite) + try? FileManager.default.removeItem(at: directory) + } +} + +@MainActor +final class RemoteTerminalHubTests: XCTestCase { + func testDelayedSessionMessagesRecheckPairingAndVerificationBeforeDispatch() async throws { + for revokeIdentity in [false, true] { + let f = try TerminalHubFixture() + try await f.start(); try await f.authenticate() + var delivered = 0 + f.hub.sessionMessageHandler = { _, _ in delivered += 1 } + _ = f.hub.handleMessage(.session(AtemSessionMessage(op: "list")), from: f.clientId, relayConnectionId: f.generation) + if revokeIdentity { try f.revokeVerification() } + else { f.hub.deviceSessionStore.delete(sessionId: f.session.id) } + await f.drain() + XCTAssertEqual(delivered, 0) + } + } + + func testSessionDispatchPinsAuthenticatedGenerationAndRejectsReconnectRace() async throws { + let f = try TerminalHubFixture() + try await f.start(); try await f.authenticate() + var routes: [RemoteTerminalConnection] = [] + f.hub.sessionMessageHandler = { _, route in routes.append(route) } + let old = try XCTUnwrap(f.hub.sessionContext(for: f.clientId)) + _ = f.hub.handleMessage(.session(AtemSessionMessage(op: "list")), from: f.clientId, relayConnectionId: "spoofed") + await f.drain() + XCTAssertTrue(routes.isEmpty) + _ = f.hub.handleMessage(.session(AtemSessionMessage(op: "list")), from: f.clientId, relayConnectionId: f.generation) + f.socket.fail() + await f.drain() + // A message delivered before disconnect is harmless; no later work can + // inherit the replacement connection's identity or authorization. + XCTAssertTrue(routes.allSatisfy { $0 == old.route }) + f.generation = UUID().uuidString + try await f.start(); try await f.authenticate() + let current = try XCTUnwrap(f.hub.sessionContext(for: f.clientId)) + XCTAssertNotEqual(old.route, current.route) + XCTAssertFalse(f.hub.sendSessionMessage(AtemSessionMessage(op: "hello"), to: old.route)) + _ = f.hub.handleMessage(.session(AtemSessionMessage(op: "list")), from: f.clientId, relayConnectionId: old.route.connectionId) + await f.drain() + XCTAssertFalse(routes.contains(current.route)) + } + + func testBrowserAccessIsBlockedBeforeHelloAndAfterReconnect() async throws { + let f = try TerminalHubFixture() + try await f.start(); try await f.authenticate() + let agent = try JSONDecoder().decode(AtemAgentInfo.self, from: Data( + #"{"id":"agent","name":"Claude","kind":"ClaudeCode","capabilities":["chat-v1"]}"#.utf8)) + for _ in 0..<2 { + XCTAssertThrowsError(try f.hub.openAgentInBrowser(clientId: f.clientId, agent: agent)) { + XCTAssertTrue($0.localizedDescription.contains("secure companion")) + } + f.generation = UUID().uuidString + try await f.authenticate() + } + XCTAssertFalse(f.socket.sent.contains { ($0["payload"] as? [String: Any])?["type"] as? String == "agentChat" }) + } + + func testVerificationRevocationFencesDelayedFramesAndReconnectedSessionTokens() async throws { + let f = try TerminalHubFixture() + try await f.start() + try await f.authenticate() + let route = try XCTUnwrap(f.hub.terminalConnection(for: f.clientId)) + var delivered = false + f.hub.terminalMessageHandler = { _, _ in delivered = true } + try f.emit(.terminal(.sessions([]))) + try f.revokeVerification() + await f.drain() + XCTAssertFalse(delivered) + XCTAssertFalse(f.hub.sendTerminalMessage(.list, to: route)) + XCTAssertEqual(f.hub.remoteAccessBlockReason(for: f.clientId), .verificationRequired) + f.generation = UUID().uuidString + try await f.authenticate() + XCTAssertNil(f.hub.terminalConnection(for: f.clientId), "a valid session token does not restore revoked safety-code verification") + } + + func testAuthenticatedButUnverifiedHostCannotReadOrControlRemotely() async throws { + let f = try TerminalHubFixture(verified: false) + try await f.start() + try await f.authenticate() + XCTAssertNil(f.hub.terminalConnection(for: f.clientId)) + XCTAssertEqual(f.hub.remoteAccessBlockReason(for: f.clientId), .verificationRequired) + var delivered = false + f.hub.terminalMessageHandler = { _, _ in delivered = true } + try f.emit(.terminal(.sessions([]))) + let before = f.socket.sent.count + f.hub.sendAgentText("private prompt", clientId: f.clientId) + f.hub.sendAgentKey("ctrl-c", clientId: f.clientId) + f.hub.requestAgentList(from: f.clientId) + await f.drain() + XCTAssertFalse(delivered) + XCTAssertEqual(f.socket.sent.count, before) + let agent = try JSONDecoder().decode(AtemAgentInfo.self, from: Data( + #"{"id":"agent","name":"Claude","kind":"ClaudeCode","capabilities":["chat-v1"]}"#.utf8)) + XCTAssertThrowsError(try f.hub.openAgentInBrowser(clientId: f.clientId, agent: agent)) { + XCTAssertEqual($0 as? RemoteAccessBlockReason, .verificationRequired) + } + } + + func testUnreadableVerifiedIdentityImmediatelyBlocksExistingRemoteRoute() async throws { + let f = try TerminalHubFixture() + try await f.start() + try await f.authenticate() + let route = try XCTUnwrap(f.hub.terminalConnection(for: f.clientId)) + var delivered = false + f.hub.terminalMessageHandler = { _, _ in delivered = true } + try f.emit(.terminal(.sessions([]))) + f.identityStorage.failRead = true + await f.drain() + XCTAssertFalse(delivered) + XCTAssertEqual(f.hub.remoteAccessBlockReason(for: f.clientId), .identityUnavailable) + XCTAssertFalse(f.hub.sendTerminalMessage(.list, to: route)) + } + + func testDelayedAgentListCannotRestoreCapabilitiesAfterPairingRevocation() async throws { + let f = try TerminalHubFixture() + try await f.start() + try await f.authenticate() + let agent = try JSONDecoder().decode(AtemAgentInfo.self, from: Data( + #"{"id":"agent","name":"Claude","kind":"ClaudeCode","status":"Unknown","capabilities":["chat-v1"]}"#.utf8)) + _ = f.hub.handleMessage(.agentListResponse(agents: [agent]), from: f.clientId, relayConnectionId: f.generation) + f.hub.deviceSessionStore.delete(sessionId: f.session.id) + await f.drain() + XCTAssertTrue(f.hub.agentsByClientId[f.clientId]?.isEmpty != false) + } + + func testOneSessionClientServesConnectionsAndTerminalWithoutSecondHello() async throws { + let f = try TerminalHubFixture() + try await f.start(); try await f.authenticate() + let (client, target) = try await f.readySession() + let initialHellos = f.sessionFrames.filter { ($0["data"] as? [String: Any])?["op"] as? String == "hello" }.count + XCTAssertEqual(initialHellos, 1) + for _ in 0..<3 { + XCTAssertTrue(f.hub.sessionClient(for: f.clientId) === client) + client.start() + f.hub.requestAgentList(from: f.clientId) + } + XCTAssertEqual(f.sessionFrames.filter { ($0["data"] as? [String: Any])?["op"] as? String == "hello" }.count, 1) + XCTAssertEqual(f.hub.agentsByClientId[f.clientId]?.map(\.id), [target.session_id]) + XCTAssertEqual(f.hub.agentsByClientId[f.clientId]?.first?.status, "Idle") + XCTAssertTrue(f.terminalFrames.isEmpty) + XCTAssertFalse(f.socket.sent.contains { ($0["payload"] as? [String: Any])?["type"] as? String == "agentListRequest" }) + } + + func testRemovedCommandsReturnUnsupportedVersionAndConsoleNeverSends() async throws { + let f = try TerminalHubFixture() + try await f.start(); try await f.authenticate() + let before = f.socket.sent.count + XCTAssertFalse(f.hub.sendCommandToClient("private command", action: "shell", clientId: f.clientId)) + XCTAssertEqual(f.socket.sent.count, before) + for removed in [AstationMessage.agentListRequest, .voiceCommand(text: "never dispatch", isFinal: true), + .userCommand(command: "never execute", context: ["action": "shell"])] { + guard case .session(let response) = f.hub.handleMessage(removed, from: f.clientId, relayConnectionId: f.generation) else { + return XCTFail("Removed op needs unsupported_version") + } + XCTAssertEqual(response.code, "unsupported_version") + XCTAssertNil(response.re) + } + } + + func testRestoredRelayConnectionRequestsProofWithoutHelloAndRacingHelloKeepsProofValid() async throws { + let f = try TerminalHubFixture() + try await f.start() + try f.socket.emit(["atem_id": f.deviceId, "connection_id": f.generation, "relay_event": "connected"]) + await f.drain() + let challenge = try XCTUnwrap(f.challenges.last) + XCTAssertTrue(f.hub.connectedClients.isEmpty) + XCTAssertNil(f.hub.terminalConnection(for: f.clientId)) + try f.emit(.statusUpdate(status: "hello", data: ["hostname": "Host"])) + await f.drain() + XCTAssertEqual(f.challenges, [challenge, challenge]) + try await f.prove(challenge: challenge) + XCTAssertNotNil(f.hub.terminalConnection(for: f.clientId)) + try f.socket.emit(["atem_id": f.deviceId, "connection_id": f.generation, "relay_event": "connected"]) + await f.drain() + XCTAssertEqual(f.challenges.count, 2) + XCTAssertNotNil(f.hub.terminalConnection(for: f.clientId)) + } + + func testAstationOnlyReconnectRequiresFreshProofForUnchangedAtemSocket() async throws { + let f = try TerminalHubFixture() + try await f.start() + try await f.authenticate() + let oldRoute = try XCTUnwrap(f.hub.terminalConnection(for: f.clientId)) + let oldChallenge = try XCTUnwrap(f.challenges.last) + f.socket.fail() + await f.drain() + XCTAssertNil(f.hub.terminalConnection(for: f.clientId)) + try await f.start() + try f.socket.emit(["atem_id": f.deviceId, "connection_id": f.generation, "relay_event": "connected"]) + await f.drain() + let newChallenge = try XCTUnwrap(f.challenges.last) + XCTAssertNotEqual(newChallenge, oldChallenge) + XCTAssertFalse(f.hub.sendTerminalMessage(.list, to: oldRoute)) + try await f.prove(challenge: oldChallenge) + XCTAssertNil(f.hub.terminalConnection(for: f.clientId)) + try await f.prove(challenge: newChallenge) + XCTAssertNotNil(f.hub.terminalConnection(for: f.clientId)) + } + + func testRelayTerminalRequiresEndpointAuthenticationAndTargetsCurrentGeneration() async throws { + let f = try TerminalHubFixture() + var received: [RemoteTerminalConnection] = [] + f.hub.terminalMessageHandler = { _, route in received.append(route) } + try await f.start() + try f.emit(.terminal(.sessions([]))) + await f.drain() + XCTAssertTrue(received.isEmpty) + XCTAssertNil(f.hub.terminalConnection(for: f.clientId)) + try await f.authenticate() + let route = try XCTUnwrap(f.hub.terminalConnection(for: f.clientId)) + XCTAssertEqual(route.connectionId, f.generation) + let (client, target) = try await f.readySession() + let subscribe = RemoteTerminalMessage.bound(.subscribe(terminalId: target.session_id, generation: target.process_gen), + try XCTUnwrap(client.binding(for: target, mutation: false))) + XCTAssertFalse(f.hub.sendTerminalMessage(.list, to: route)) + XCTAssertTrue(f.hub.sendTerminalMessage(subscribe, to: route)) + XCTAssertEqual(f.terminalFrames.count, 1) + let sent = try XCTUnwrap(f.terminalFrames.first) + XCTAssertEqual(sent["atem_id"] as? String, f.deviceId) + XCTAssertEqual(sent["connection_id"] as? String, f.generation) + try f.emit(.terminal(.sessions([]))) + await f.drain() + XCTAssertEqual(received, [route]) + f.hub.broadcastToAuthenticatedIdentityRelayClients(.terminal(.list)) + XCTAssertEqual(f.terminalFrames.count, 1) + } + + func testDisconnectBeforeDelayedDispatchDropsAcceptedTerminalFrame() async throws { + let f = try TerminalHubFixture() + try await f.start() + try await f.authenticate() + var delivered = 0 + f.hub.terminalMessageHandler = { _, _ in delivered += 1 } + let old = try XCTUnwrap(f.hub.terminalConnection(for: f.clientId)) + try f.emit(.terminal(.sessions([]))) + try f.socket.emit(["atem_id": f.deviceId, "connection_id": f.generation, "relay_event": "disconnected"]) + await f.drain() + XCTAssertEqual(delivered, 0) + XCTAssertFalse(f.hub.sendTerminalMessage(.list, to: old)) + f.generation = UUID().uuidString + try await f.authenticate() + let current = try XCTUnwrap(f.hub.terminalConnection(for: f.clientId)) + XCTAssertNotEqual(current, old) + XCTAssertFalse(f.hub.sendTerminalMessage(.list, to: old)) + let (client, target) = try await f.readySession() + let subscribe = RemoteTerminalMessage.bound(.subscribe(terminalId: target.session_id, generation: target.process_gen), + try XCTUnwrap(client.binding(for: target, mutation: false))) + XCTAssertTrue(f.hub.sendTerminalMessage(subscribe, to: current)) + XCTAssertEqual(f.terminalFrames.last?["connection_id"] as? String, f.generation) + } + + func testRevokedPairingCannotKeepSendingOrReceivingThroughLiveRelay() async throws { + let f = try TerminalHubFixture() + try await f.start() + try await f.authenticate() + let route = try XCTUnwrap(f.hub.terminalConnection(for: f.clientId)) + var delivered = 0 + f.hub.terminalMessageHandler = { _, _ in delivered += 1 } + try f.emit(.terminal(.sessions([]))) + f.hub.deviceSessionStore.delete(sessionId: f.session.id) + await f.drain() + XCTAssertEqual(delivered, 0) + XCTAssertNil(f.hub.terminalConnection(for: f.clientId)) + XCTAssertFalse(f.hub.sendTerminalMessage(.list, to: route)) + XCTAssertTrue(f.terminalFrames.isEmpty) + } + + func testLANReconnectStaysBoundToSameDeviceAndTransport() throws { + let f = try TerminalHubFixture() + f.hub.connectedClients = [ConnectedClient(id: "direct-old", clientType: "Atem", connectedAt: Date(), + hostname: "Host", atemId: f.deviceId, authenticationSessionId: f.session.id)] + let old = try XCTUnwrap(f.hub.terminalConnection(for: "direct-old", atemId: f.deviceId)) + f.hub.connectedClients = [ConnectedClient(id: "direct-new", clientType: "Atem", connectedAt: Date(), + hostname: "Host", atemId: f.deviceId, authenticationSessionId: f.session.id)] + let new = try XCTUnwrap(f.hub.terminalConnection(for: "direct-old", atemId: f.deviceId)) + XCTAssertNotEqual(old, new) + XCTAssertEqual(new.clientId, "direct-new") + XCTAssertNil(f.hub.terminalConnection(for: "direct-old", atemId: "different-device")) + XCTAssertNil(f.hub.terminalConnection(for: "relay-old", atemId: f.deviceId)) + } +} diff --git a/Tests/AstationTests/RemoteTerminalRendererTests.swift b/Tests/AstationTests/RemoteTerminalRendererTests.swift new file mode 100644 index 0000000..bb8e0df --- /dev/null +++ b/Tests/AstationTests/RemoteTerminalRendererTests.swift @@ -0,0 +1,157 @@ +import AppKit +import WebKit +import XCTest +@testable import Menubar + +@MainActor +final class RemoteTerminalRendererTests: XCTestCase { + private func host(_ renderer: RemoteTerminalRenderer) -> NSWindow { + _ = NSApplication.shared + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 900, height: 550), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = renderer.webView + window.contentView?.layoutSubtreeIfNeeded() + return window + } + + private func load(_ renderer: RemoteTerminalRenderer) async { + let ready = expectation(description: "Bundled xterm and Unicode provider loaded") + renderer.onFailure = { XCTFail("Native terminal renderer failed to load") } + renderer.onAction = { action in if case .ready = action { ready.fulfill() } } + renderer.load() + await fulfillment(of: [ready], timeout: 15) + } + + private func evaluate(_ renderer: RemoteTerminalRenderer, _ script: String) async throws -> Any? { + try await withCheckedThrowingContinuation { continuation in + renderer.webView.evaluateJavaScript(script) { value, error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume(returning: value) } + } + } + } + + func testActualWebKitRendersByteSplitUnicodeAndCanonicalColors() async throws { + let renderer = RemoteTerminalRenderer() + let window = host(renderer) + defer { window.close() } + await load(renderer) + let consumed = expectation(description: "Both frame writes consumed") + consumed.expectedFulfillmentCount = 2 + renderer.onAction = { action in if case .consumed = action { consumed.fulfill() } } + let prefix = Data([0x1b, 0x5b, 0x33, 0x31, 0x6d]) + Data("RED ".utf8) + Data([0xf0, 0x9f]) + let suffix = Data([0x8c, 0x9f]) + Data("\u{1b}[0m\r\nsecond row".utf8) + let stream = RemoteTerminalStream(terminalId: "claude", generation: "test", streamId: "stream") + renderer.render(RemoteTerminalFrame(stream: stream, offset: 0, bytes: prefix.base64EncodedString(), + reset: true, complete: false, rows: 10, cols: 60), token: "first") + renderer.render(RemoteTerminalFrame(stream: stream, offset: UInt64(prefix.count), bytes: suffix.base64EncodedString(), + reset: false, complete: true, rows: 10, cols: 60), token: "second") + await fulfillment(of: [consumed], timeout: 10) + let result = try await evaluate(renderer, "window.astationTerminal.inspect()") + let state = try XCTUnwrap(result as? [String: Any]) + XCTAssertTrue((state["text"] as? String ?? "").contains("RED \u{1f31f}")) + XCTAssertTrue((state["text"] as? String ?? "").contains("second row")) + XCTAssertEqual(state["unicode"] as? String, "atem-unicode-width-0.1.14") + XCTAssertEqual(state["scrollback"] as? Int, 0) + XCTAssertEqual(state["cols"] as? Int, 60) + XCTAssertEqual(state["enabled"] as? Bool, false) + XCTAssertEqual(state["firstForeground"] as? Int, 1) + } + + func testPastePreservesBracketedModeAndRejectsOldOwnershipEpoch() async throws { + let renderer = RemoteTerminalRenderer() + let window = host(renderer) + defer { window.close() } + await load(renderer) + let restore = expectation(description: "Bracketed paste mode restored") + renderer.onAction = { action in if case .consumed = action { restore.fulfill() } } + renderer.render(RemoteTerminalFrame(stream: RemoteTerminalStream(terminalId: "claude", generation: "test", streamId: "stream"), + offset: 0, bytes: Data("\u{1b}[?2004h".utf8).base64EncodedString(), reset: true, complete: true, rows: 10, cols: 60), token: "restore") + await fulfillment(of: [restore], timeout: 10) + let input = expectation(description: "One atomic bracketed paste") + var received: [Data] = [] + renderer.onAction = { action in + if case .input(let bytes, let epoch) = action { + received.append(bytes) + XCTAssertEqual(epoch, "lease-2") + input.fulfill() + } + } + renderer.setControl(true, epoch: "lease-2") + renderer.paste("must not be sent", epoch: "lease-1") + renderer.paste("first\nsecond", epoch: "lease-2") + await fulfillment(of: [input], timeout: 10) + XCTAssertEqual(received, [Data("\u{1b}[200~first\rsecond\u{1b}[201~".utf8)]) + renderer.setControl(false, epoch: "") + renderer.paste("disabled", epoch: "lease-2") + _ = try await evaluate(renderer, "window.astationTerminal.inspect()") + XCTAssertEqual(received.count, 1) + } + + func testNewStreamDiscardsOldScreenAndResetsControl() async throws { + let renderer = RemoteTerminalRenderer() + let window = host(renderer) + defer { window.close() } + await load(renderer) + let written = expectation(description: "New stream restored") + renderer.onAction = { action in if case .consumed(let token) = action, token == "new" { written.fulfill() } } + let oldStream = RemoteTerminalStream(terminalId: "claude", generation: "old", streamId: "old-stream") + let newStream = RemoteTerminalStream(terminalId: "codex", generation: "new", streamId: "new-stream") + renderer.render(RemoteTerminalFrame(stream: oldStream, offset: 0, bytes: Data("OLD SCREEN".utf8).base64EncodedString(), + reset: true, complete: true, rows: 10, cols: 60), token: "old") + renderer.setControl(true, epoch: "old-lease") + renderer.render(RemoteTerminalFrame(stream: newStream, offset: 0, bytes: Data("NEW SCREEN".utf8).base64EncodedString(), + reset: true, complete: true, rows: 10, cols: 60), token: "new") + await fulfillment(of: [written], timeout: 10) + let result = try await evaluate(renderer, "window.astationTerminal.inspect()") + let state = try XCTUnwrap(result as? [String: Any]) + XCTAssertTrue((state["text"] as? String ?? "").contains("NEW SCREEN")) + XCTAssertFalse((state["text"] as? String ?? "").contains("OLD SCREEN")) + XCTAssertEqual(state["enabled"] as? Bool, false) + } + + func testTmuxCopyModeShortcutForwardsControlBThenBracketOnlyWhileControlling() async throws { + let renderer = RemoteTerminalRenderer() + let window = host(renderer) + defer { window.close() } + await load(renderer) + let shortcut = """ + (() => { + const target = document.querySelector('.xterm-helper-textarea'); + target.focus(); + const key = (type, value, code, keyCode, ctrlKey = false, charCode = 0) => + target.dispatchEvent(new KeyboardEvent(type, { + key: value, code, keyCode, which: charCode || keyCode, charCode, + ctrlKey, bubbles: true, cancelable: true + })); + key('keydown', 'Control', 'ControlLeft', 17, true); + key('keydown', 'b', 'KeyB', 66, true); + key('keyup', 'b', 'KeyB', 66, true); + key('keyup', 'Control', 'ControlLeft', 17); + key('keydown', '[', 'BracketLeft', 219); + key('keypress', '[', 'BracketLeft', 91, false, 91); + key('keyup', '[', 'BracketLeft', 219); + })() + """ + var received: [Data] = [] + let input = expectation(description: "Control-B followed by a literal bracket") + input.expectedFulfillmentCount = 2 + renderer.onAction = { action in + if case .input(let bytes, let epoch) = action { + received.append(bytes) + XCTAssertEqual(epoch, "tmux-lease") + input.fulfill() + } + } + _ = try await evaluate(renderer, shortcut) + XCTAssertTrue(received.isEmpty) + renderer.setControl(true, epoch: "tmux-lease") + _ = try await evaluate(renderer, shortcut) + await fulfillment(of: [input], timeout: 10) + XCTAssertEqual(received, [Data([0x02]), Data("[".utf8)]) + renderer.setControl(false, epoch: "") + _ = try await evaluate(renderer, shortcut) + XCTAssertEqual(received, [Data([0x02]), Data("[".utf8)]) + } +} diff --git a/Tests/AstationTests/RemoteTerminalTests.swift b/Tests/AstationTests/RemoteTerminalTests.swift new file mode 100644 index 0000000..f838963 --- /dev/null +++ b/Tests/AstationTests/RemoteTerminalTests.swift @@ -0,0 +1,471 @@ +import XCTest +@testable import Menubar + +final class RemoteTerminalWireTests: XCTestCase { + private let stream = RemoteTerminalStream(terminalId: "claude", generation: "process", streamId: "stream") + + func testSharedMultiplexingFixturePreservesExactProcessAndLeaseTargets() throws { + let url = try XCTUnwrap(Bundle.module.url(forResource: "session-multiplexing-v1", withExtension: "json", subdirectory: "Fixtures")) + let fixture = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + let session = try JSONDecoder().decode(RemoteTerminalSession.self, + from: JSONSerialization.data(withJSONObject: try XCTUnwrap(fixture["session"]))) + XCTAssertTrue(session.isSupported) + XCTAssertTrue(session.supportsExpiringControl) + XCTAssertEqual(session.agentId, "agent-a") + XCTAssertEqual(session.label, "Claude / workspace-a") + let target = RemoteTerminalStream(terminalId: session.terminalId, generation: session.generation, streamId: "stream-a") + for (key, expected) in [("renew", RemoteTerminalMessage.renew(target, leaseId: "lease-a")), + ("controller", RemoteTerminalMessage.controller(target, leaseId: "lease-a"))] { + let object = try XCTUnwrap(fixture[key]) + let wire = try JSONSerialization.data(withJSONObject: object) + XCTAssertEqual(try JSONDecoder().decode(RemoteTerminalMessage.self, from: wire), expected) + let encoded = try JSONEncoder().encode(expected) + XCTAssertEqual(try JSONSerialization.jsonObject(with: encoded) as? NSDictionary, object as? NSDictionary) + } + } + + func testSerdeWireMessagesRoundTripWithoutRenamingFields() throws { + let session = terminalSession() + let messages: [RemoteTerminalMessage] = [ + .list, .sessions([session]), .subscribe(terminalId: "claude", generation: "process"), + .unsubscribe(stream), .ack(stream, offset: 4096), .control(stream, take: true), + .renew(stream, leaseId: "lease"), + .input(stream, leaseId: "lease", sequence: 0, bytes: "aGVsbG8N"), + .resize(stream, leaseId: "lease", rows: 40, cols: 120), .stop(stream, leaseId: "lease"), + .attached(session, streamId: "stream"), + .frame(RemoteTerminalFrame(stream: stream, offset: 0, bytes: "aGVsbG8N", reset: true, + complete: true, rows: 40, cols: 120)), + .controller(stream, leaseId: "lease"), .controller(stream, leaseId: nil), + .written(stream, sequence: 0, error: nil), .written(stream, sequence: 1, error: "revoked"), + .exited(terminalId: "claude", generation: "process", status: "0"), .error(terminalId: nil, message: "failure") + ] + for message in messages { + let encoded = try JSONEncoder().encode(AstationMessage.terminal(message)) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + XCTAssertEqual(object["type"] as? String, "terminal") + let data = try XCTUnwrap(object["data"] as? [String: Any]) + XCTAssertEqual(data["v"] as? Int, 1) + XCTAssertNil(data["terminalId"]) + XCTAssertNil(data["streamId"]) + guard case .terminal(let decoded) = try JSONDecoder().decode(AstationMessage.self, from: encoded) else { + return XCTFail("Not a terminal envelope") + } + XCTAssertEqual(decoded, message) + } + } + + func testHostFixturesDecodeAndUnsupportedVersionsFailClosed() throws { + let attached = Data(#"{"type":"terminal","data":{"v":1,"op":"attached","session":{"terminal_id":"claude","generation":"process","provider":"claude","rows":40,"cols":120,"exit_status":null,"rendering":"vt100-screen-diff","width_profile":"atem-unicode-width-0.1.14"},"stream_id":"stream"}}"#.utf8) + guard case .terminal(.attached(let session, let id)) = try JSONDecoder().decode(AstationMessage.self, from: attached) else { + return XCTFail("Host fixture did not decode") + } + XCTAssertEqual(session, terminalSession(capabilities: [])) + XCTAssertFalse(session.supportsExpiringControl) + XCTAssertEqual(id, "stream") + let unsupported = try JSONDecoder().decode(RemoteTerminalMessage.self, from: Data(#"{"v":2,"op":"frame"}"#.utf8)) + XCTAssertEqual(unsupported, .unsupported(version: 2, operation: "frame")) + XCTAssertThrowsError(try JSONEncoder().encode(unsupported)) + XCTAssertThrowsError(try JSONDecoder().decode(RemoteTerminalMessage.self, from: Data(#"{"v":1,"op":"input","bytes":"eA=="}"#.utf8))) + } + + func testTerminalContentIsRedactedIncludingRelayEnvelope() { + for payload in [ + #"{"type":"terminal","data":{"v":1,"op":"frame","bytes":"private-base64-output","message":"private-text","lease_id":"private-lease"}}"#, + #"{"atem_id":"host","connection_id":"connection","payload":{"type":"terminal","data":{"v":1,"op":"input","bytes":"private-input"}}}"#, + #"{"type":"terminal","data":{"op":"frame","bytes":"private-truncated"#, + #"{"payload":{"type": "terminal","data":{"bytes":"private-truncated"# + ] { + let result = NetworkDebugLogger.sanitizedPayload(payload) + XCTAssertFalse(result.contains("private")) + XCTAssertTrue(result.contains("terminal")) + XCTAssertTrue(result.contains("redacted")) + } + } +} + +final class RemoteTerminalControllerTests: XCTestCase { + func testVoiceAndShortcutInputReuseHeldLeaseAndSequence() { + let f = TerminalFixture() + f.viewing() + XCTAssertFalse(f.controller.sendScopedInput(kind: "text", value: "do not send")) + f.controller.takeControl() + f.receive(.controller(terminalStream(), leaseId: "lease")) + XCTAssertTrue(f.controller.sendScopedInput(kind: "text", value: "inspect logs")) + XCTAssertEqual(f.sent.last?.0, .input(terminalStream(), leaseId: "lease", sequence: 0, + bytes: Data("inspect logs\r".utf8).base64EncodedString())) + XCTAssertTrue(f.controller.sendScopedInput(kind: "key", value: "ctrl-c")) + XCTAssertEqual(f.sent.last?.0, .input(terminalStream(), leaseId: "lease", sequence: 1, + bytes: Data([3]).base64EncodedString())) + XCTAssertFalse(f.controller.sendScopedInput(kind: "key", value: "unknown")) + f.time = 15 + XCTAssertFalse(f.controller.sendScopedInput(kind: "text", value: "expired")) + } + + func testLegacySessionAllowsViewingButDoesNotOfferNonExpiringControl() { + let f = TerminalFixture() + f.open() + let legacy = terminalSession(capabilities: []) + f.receive(.sessions([legacy])) + f.receive(.attached(legacy, streamId: "stream")) + f.frame() + f.consume() + XCTAssertEqual(f.controller.phase, .viewing) + XCTAssertFalse(f.controller.canTakeControl) + XCTAssertTrue(f.controller.status.contains("Update Atem")) + let before = f.sent.count + f.controller.takeControl() + XCTAssertEqual(f.sent.count, before) + } + + func testLeaseRenewalRequiresMatchingAcknowledgmentAndExpiresWithoutIt() { + let f = TerminalFixture() + f.controlling() + f.time = 5 + f.controller.checkTimeouts() + XCTAssertEqual(f.sent.last?.0, .renew(terminalStream(), leaseId: "lease")) + f.time = 6 + f.receive(.controller(terminalStream(), leaseId: "lease")) + f.time = 10 + f.controller.checkTimeouts() + XCTAssertEqual(f.sent.filter { if case .renew = $0.0 { return true }; return false }.count, 2) + f.time = 20 + XCTAssertFalse(f.controller.canInput, "input stops at the deadline even before the timer fires") + f.controller.checkTimeouts() + XCTAssertEqual(f.controller.phase, .viewing) + XCTAssertFalse(f.controller.canInput) + XCTAssertTrue(f.controller.status.contains("expired")) + f.receive(.controller(terminalStream(), leaseId: "lease")) + XCTAssertFalse(f.controller.canInput, "a late acknowledgment never reacquires control") + } + + func testUnsolicitedLeaseAcknowledgmentsCannotExtendDeadline() { + let f = TerminalFixture() + f.controlling() + f.time = 14 + f.receive(.controller(terminalStream(), leaseId: "lease")) + f.time = 15 + XCTAssertFalse(f.controller.canInput) + f.controller.checkTimeouts() + XCTAssertEqual(f.controller.phase, .viewing) + } + + func testRenewalReplyAfterPreviousDeadlineCannotRestoreControlWithoutTimeoutTick() { + let f = TerminalFixture() + f.controlling() + f.time = 5 + f.controller.checkTimeouts() + XCTAssertEqual(f.sent.last?.0, .renew(terminalStream(), leaseId: "lease")) + f.time = 17 + f.receive(.controller(terminalStream(), leaseId: "lease")) + XCTAssertEqual(f.controller.phase, .viewing) + XCTAssertFalse(f.controller.canInput) + XCTAssertNil(f.controller.leaseId) + XCTAssertTrue(f.controller.status.contains("expired")) + XCTAssertEqual(f.sent.last?.0, .control(terminalStream(), take: false)) + f.receive(.controller(terminalStream(), leaseId: "lease")) + XCTAssertFalse(f.controller.canInput) + XCTAssertEqual(f.controller.phase, .viewing) + } + + func testAgentSelectionDoesNotAttachAnotherSameProviderSession() { + let f = TerminalFixture() + var first = terminalSession() + first.agentId = "agent-one" + var second = terminalSession(generation: "process-two") + second.agentId = "agent-two" + f.controller.open(preferredTerminalId: nil, preferredAgentId: "agent-two") + f.receive(.sessions([first, second])) + XCTAssertEqual(f.controller.session, second) + XCTAssertEqual(f.sent.last?.0, .subscribe(terminalId: second.terminalId, generation: second.generation)) + f.controller.open(preferredTerminalId: nil, preferredAgentId: "missing-agent") + f.receive(.sessions([first, second])) + XCTAssertNil(f.controller.session) + XCTAssertEqual(f.controller.phase, .ready) + XCTAssertTrue(f.controller.status.contains("not reported")) + } + + func testOpenTargetsOnlySelectedConnectionAndStartsViewOnly() { + let f = TerminalFixture() + f.open() + XCTAssertEqual(f.sent.map(\.1), [f.route]) + XCTAssertEqual(f.sent.first?.0, .list) + f.list() + XCTAssertEqual(f.sent.last?.0, .subscribe(terminalId: "claude", generation: "process")) + f.attach() + f.controller.input(Data("should not send".utf8)) + f.controller.takeControl() + XCTAssertFalse(f.sent.contains { if case .input = $0.0 { return true }; return false }) + XCTAssertFalse(f.sent.contains { if case .control = $0.0 { return true }; return false }) + f.frame() + XCTAssertEqual(f.controller.phase, .restoring) + XCTAssertEqual(f.acks.count, 0) + f.consume() + XCTAssertEqual(f.controller.phase, .viewing) + XCTAssertTrue(f.controller.canTakeControl) + XCTAssertFalse(f.controller.canInput) + XCTAssertEqual(f.acks, [1]) + } + + func testFrameCreditWaitsForRendererAndPreservesSplitUTF8() { + let f = TerminalFixture() + f.start() + let first = Data([0xf0, 0x9f]) + let second = Data([0x8c, 0x9f]) + f.frame(bytes: first, offset: 0, complete: false) + f.frame(bytes: second, offset: 2, reset: false) + XCTAssertEqual(f.rendered.count, 1) + XCTAssertEqual(f.controller.queuedBytes, 4) + f.consume() + XCTAssertEqual(f.acks, [2]) + XCTAssertEqual(f.rendered.count, 2) + XCTAssertEqual(Data(base64Encoded: f.rendered[0].0.bytes), first) + XCTAssertEqual(Data(base64Encoded: f.rendered[1].0.bytes), second) + XCTAssertEqual(f.controller.phase, .restoring) + f.consume() + XCTAssertEqual(f.acks, [2, 4]) + XCTAssertEqual(f.controller.phase, .viewing) + XCTAssertEqual(f.controller.queuedBytes, 0) + } + + func testGapsMalformedFramesAndMissingSnapshotRequireFreshView() { + for invalid in [ + RemoteTerminalFrame(stream: terminalStream(), offset: 1, bytes: "eA==", reset: true, complete: true, rows: 40, cols: 120), + RemoteTerminalFrame(stream: terminalStream(), offset: 0, bytes: "eA==", reset: false, complete: true, rows: 40, cols: 120), + RemoteTerminalFrame(stream: terminalStream(), offset: 0, bytes: "not-base64", reset: true, complete: true, rows: 40, cols: 120), + RemoteTerminalFrame(stream: terminalStream(), offset: 0, bytes: "", reset: true, complete: true, rows: 40, cols: 120), + RemoteTerminalFrame(stream: terminalStream(), offset: 0, bytes: "eA==", reset: true, complete: true, rows: 1, cols: 120) + ] { + let f = TerminalFixture() + f.start() + f.receive(.frame(invalid)) + XCTAssertEqual(f.controller.phase, .failed) + XCTAssertTrue(f.rendered.isEmpty) + XCTAssertEqual(f.sent.last?.0, .unsubscribe(terminalStream())) + } + } + + func testSlowRendererCannotExceedHostCreditWindow() { + let f = TerminalFixture() + f.start() + let bytes = Data(repeating: 65, count: 16 * 1024) + for index in 0..<8 { f.frame(bytes: bytes, offset: UInt64(index * bytes.count), reset: index == 0, complete: false) } + XCTAssertEqual(f.controller.queuedBytes, 128 * 1024) + XCTAssertEqual(f.rendered.count, 1) + f.frame(bytes: bytes, offset: 128 * 1024, reset: false) + XCTAssertEqual(f.controller.phase, .failed) + XCTAssertEqual(f.controller.queuedBytes, 0) + XCTAssertTrue(f.acks.isEmpty) + } + + func testControlLeaseAndWrittenAcknowledgmentHaveSeparateMeaning() { + let f = TerminalFixture() + f.viewing() + f.controller.takeControl() + XCTAssertFalse(f.controller.canInput) + f.receive(.controller(terminalStream(), leaseId: "lease")) + let bytes = Data(" hello\r\n\u{1b}[A\u{3}".utf8) + f.controller.input(bytes) + XCTAssertEqual(f.sent.last?.0, .input(terminalStream(), leaseId: "lease", sequence: 0, bytes: bytes.base64EncodedString())) + XCTAssertEqual(f.controller.pendingWrites.count, 1) + f.receive(.written(terminalStream(), sequence: 0, error: nil)) + XCTAssertTrue(f.controller.pendingWrites.isEmpty) + XCTAssertTrue(f.controller.status.contains("Written to terminal")) + XCTAssertFalse(f.controller.status.contains("completed")) + f.receive(.written(terminalStream(), sequence: 0, error: nil)) + XCTAssertTrue(f.controller.canInput) + } + + func testReleaseAndLocalReclaimDisableInputAndDoNotResetSequence() { + let f = TerminalFixture() + f.controlling() + f.controller.input(Data("first".utf8)) + f.receive(.written(terminalStream(), sequence: 0, error: nil)) + f.controller.release() + XCTAssertFalse(f.controller.canInput) + let before = f.sent.count + f.controller.input(Data("discard".utf8)) + XCTAssertEqual(f.sent.count, before) + f.receive(.controller(terminalStream(), leaseId: "late-lease")) + XCTAssertFalse(f.controller.canInput) + XCTAssertEqual(f.sent.last?.0, .control(terminalStream(), take: false)) + f.controller.takeControl() + f.receive(.controller(terminalStream(), leaseId: "new-lease")) + f.controller.input(Data("second".utf8)) + XCTAssertEqual(f.controller.nextSequence, 2) + f.receive(.controller(terminalStream(), leaseId: nil)) + XCTAssertFalse(f.controller.canInput) + XCTAssertEqual(f.controller.phase, .viewing) + } + + func testDisconnectAndReconnectNeverRestoreControlOrReplayInput() { + let f = TerminalFixture() + f.controlling() + f.controller.input(Data("uncertain".utf8)) + let oldRenderToken = f.rendered.last!.1 + f.current = nil + f.controller.connectionChanged() + XCTAssertEqual(f.controller.phase, .offline) + XCTAssertTrue(f.controller.status.contains("unknown")) + f.current = RemoteTerminalConnection(clientId: "relay-selected", connectionId: "connection-2") + f.controller.connectionChanged() + let newRoute = f.current! + f.controller.receive(.controller(terminalStream(), leaseId: "old-lease"), from: f.route) + f.controller.consumed(oldRenderToken) + f.controller.receive(.sessions([terminalSession()]), from: newRoute) + f.controller.receive(.attached(terminalSession(), streamId: "stream-2"), from: newRoute) + let newStream = terminalStream(id: "stream-2") + f.controller.receive(.frame(RemoteTerminalFrame(stream: newStream, offset: 0, bytes: "eA==", reset: true, + complete: true, rows: 40, cols: 120)), from: newRoute) + f.consume() + XCTAssertEqual(f.controller.phase, .viewing) + XCTAssertFalse(f.controller.canInput) + XCTAssertEqual(f.sent.filter { if case .input = $0.0 { return true }; return false }.count, 1) + XCTAssertEqual(f.sent.filter { if case .control(_, true) = $0.0 { return true }; return false }.count, 1) + } + + func testStaleHostProcessStreamAndRendererCallbacksAreIgnored() { + let f = TerminalFixture() + f.start() + let wrong = RemoteTerminalConnection(clientId: "another-host", connectionId: "connection") + f.controller.receive(.error(terminalId: nil, message: "wrong host"), from: wrong) + f.receive(.frame(RemoteTerminalFrame(stream: terminalStream(id: "old-stream"), offset: 0, bytes: "eA==", + reset: true, complete: true, rows: 40, cols: 120))) + f.receive(.controller(terminalStream(id: "old-stream"), leaseId: "old")) + f.receive(.exited(terminalId: "claude", generation: "old-process", status: "0")) + f.frame() + let token = f.rendered.last!.1 + f.controller.view() + f.controller.consumed(token) + XCTAssertTrue(f.acks.isEmpty) + XCTAssertEqual(f.controller.phase, .attaching) + } + + func testProcessReplacementAfterReconnectRequiresDeliberateView() { + let f = TerminalFixture() + f.viewing() + f.current = nil + f.controller.connectionChanged() + f.current = f.route + f.controller.connectionChanged() + f.receive(.sessions([terminalSession(generation: "replacement")])) + XCTAssertEqual(f.controller.phase, .ready) + XCTAssertTrue(f.controller.status.contains("restarted")) + XCTAssertEqual(f.sent.filter { if case .subscribe = $0.0 { return true }; return false }.count, 1) + } + + func testLateAttachAfterWindowCloseIsUnsubscribed() { + let f = TerminalFixture() + f.open() + f.list() + f.controller.close() + f.attach() + XCTAssertEqual(f.controller.phase, .closed) + XCTAssertEqual(f.sent.last?.0, .unsubscribe(terminalStream())) + } + + func testCloseAndReopenBeforeAttachDoesNotUnsubscribeSuccessor() { + let f = TerminalFixture() + f.open() + f.list() + f.controller.close() + f.open() + f.list() + f.attach() + XCTAssertEqual(f.controller.phase, .attaching) + f.receive(.attached(terminalSession(), streamId: "stream-2")) + XCTAssertEqual(f.controller.stream, terminalStream(id: "stream-2")) + XCTAssertFalse(f.sent.contains { if case .unsubscribe = $0.0 { return true }; return false }) + } + + func testPasteLimitAndTimeoutNeverRetryAnUnknownWrite() { + let f = TerminalFixture() + f.controlling() + let before = f.sent.count + f.controller.input(Data(repeating: 65, count: 16 * 1024 + 1)) + XCTAssertEqual(f.sent.count, before) + XCTAssertTrue(f.controller.status.contains("nothing was sent")) + f.controller.input(Data("single send".utf8)) + f.time += 11 + f.controller.checkTimeouts() + XCTAssertEqual(f.controller.phase, .failed) + XCTAssertTrue(f.controller.status.contains("unknown")) + f.controller.checkTimeouts() + XCTAssertEqual(f.sent.filter { if case .input = $0.0 { return true }; return false }.count, 1) + } + + func testUnsupportedHostAndRestoreTimeoutAreVisible() { + let f = TerminalFixture() + f.open() + f.time += 9 + f.controller.checkTimeouts() + XCTAssertEqual(f.controller.phase, .failed) + XCTAssertTrue(f.controller.status.contains("update Atem")) + f.controller.refresh(autoView: true) + f.list() + f.attach() + f.time += 16 + f.controller.checkTimeouts() + XCTAssertEqual(f.controller.phase, .failed) + XCTAssertEqual(f.sent.last?.0, .unsubscribe(terminalStream())) + } + + func testResizeAndStopRequireControlAndExitKeepsFinalScreen() { + let f = TerminalFixture() + f.viewing() + f.controller.resize(rows: 30, cols: 100) + f.controller.stop() + XCTAssertFalse(f.sent.contains { if case .resize = $0.0 { return true }; return false }) + f.controller.takeControl() + f.receive(.controller(terminalStream(), leaseId: "lease")) + XCTAssertEqual(f.sent.last?.0, .resize(terminalStream(), leaseId: "lease", rows: 30, cols: 100)) + f.controller.stop() + XCTAssertEqual(f.controller.phase, .stopping) + XCTAssertFalse(f.controller.canInput) + f.frame(offset: 1, reset: false) + f.consume() + f.receive(.exited(terminalId: "claude", generation: "process", status: "exit 0")) + XCTAssertEqual(f.controller.phase, .exited) + XCTAssertNotNil(f.controller.stream) + XCTAssertFalse(f.controller.canTakeControl) + XCTAssertEqual(f.acks, [1, 2]) + } +} + +private func terminalSession(generation: String = "process", capabilities: [String] = ["terminal-lease-v1"]) -> RemoteTerminalSession { + RemoteTerminalSession(terminalId: "claude", generation: generation, provider: "claude", rows: 40, cols: 120, + exitStatus: nil, rendering: "vt100-screen-diff", widthProfile: "atem-unicode-width-0.1.14", + capabilities: capabilities) +} + +private func terminalStream(id: String = "stream") -> RemoteTerminalStream { + RemoteTerminalStream(terminalId: "claude", generation: "process", streamId: id) +} + +private final class TerminalFixture { + let route = RemoteTerminalConnection(clientId: "relay-selected", connectionId: "connection") + var current: RemoteTerminalConnection? + var sent: [(RemoteTerminalMessage, RemoteTerminalConnection)] = [] + var rendered: [(RemoteTerminalFrame, String)] = [] + var time: TimeInterval = 0 + lazy var controller = RemoteTerminalController(currentConnection: { self.current }, send: { message, connection in + self.sent.append((message, connection)); return true + }, now: { self.time }) + + init() { current = route } + var acks: [UInt64] { sent.compactMap { if case .ack(_, let offset) = $0.0 { return offset }; return nil } } + func open() { + controller.render = { self.rendered.append(($0, $1)) } + controller.open(preferredTerminalId: "claude") + } + func list() { receive(.sessions([terminalSession()])) } + func attach() { receive(.attached(terminalSession(), streamId: "stream")) } + func start() { open(); list(); attach() } + func viewing() { start(); frame(); consume() } + func controlling() { viewing(); controller.takeControl(); receive(.controller(terminalStream(), leaseId: "lease")) } + func receive(_ message: RemoteTerminalMessage) { controller.receive(message, from: route) } + func frame(bytes: Data = Data("x".utf8), offset: UInt64 = 0, reset: Bool = true, complete: Bool = true) { + receive(.frame(RemoteTerminalFrame(stream: terminalStream(), offset: offset, bytes: bytes.base64EncodedString(), + reset: reset, complete: complete, rows: 40, cols: 120))) + } + func consume() { if let token = rendered.last?.1 { controller.consumed(token) } } +} diff --git a/designs/agent-chat.md b/designs/agent-chat.md index b846822..9a78fb3 100644 --- a/designs/agent-chat.md +++ b/designs/agent-chat.md @@ -2,9 +2,9 @@ ## Product -The webapp hosting change serves this UI and its assets in production. The -companion macOS bridge remains in the `feat/remote-terminal` worktree; Atem's -semantic host implementation is coordinated through shared project memory. +The webapp serves this UI and its assets in production. This implementation +integrates the native bridge with Atem's published `chat-v1` host contract: +`Agora-Build/Atem`, commit `10d3ea8`, `designs/agent-chat-host.md` (PR 65). Connections keeps Open Terminal and adds Open in Browser for each agent. The new page is served by the webapp at `/agent/`, with a responsive layout for desktop @@ -15,9 +15,10 @@ the conversation. The terminal remains available for CLI-specific interactions. This is a separate protocol from terminal-v1. ANSI redraws cannot establish message boundaries, task completion or an approval request. A host must advertise `chat-v1` in an agent's optional `capabilities` array before prompts are enabled. -Existing Atem hosts do not advertise this capability. Their browser page opens -with an explicit unsupported state. Preview mode is labeled and never contacts -an agent. Atem implementation is coordinated through shared project memory. +Claude launched by an updated `atem agent launch claude` advertises this only +after its hooks become active. Codex and agents without an adapter remain +Unknown, with terminal access and no chat capability. Preview mode is labeled +and never contacts an agent. Atem releases are owned by the Atem agent. ## Lifecycle and security invariants @@ -45,11 +46,14 @@ an agent. Atem implementation is coordinated through shared project memory. request ID. Accepted means delivered, not completed. Working/Waiting/Ready require semantic host events; missing or stale activity becomes Unknown. - Content is rendered using DOM text nodes, never remote HTML. Only explicit - HTTPS/HTTP links are clickable. No third-party scripts, fonts, analytics or + HTTPS/HTTP links are clickable. No externally hosted scripts, fonts, analytics or service worker. Content and capabilities are omitted from network logs. - Session state is bounded to eight sessions, 32 browser nonces per session, - 256 submitted request IDs, 512 messages and 2 MiB of UTF-8 conversation content. - One prompt is at most 32 KiB and one accumulated reply at most 128 KiB. + 256 submitted request IDs, 512 messages, 8 MiB of UTF-8 conversation content, + and 16 MiB of serialized message data including part IDs and metadata. + One prompt is at most 32 KiB and one accumulated message at most 128 KiB + plus Atem's reserved truncation marker. Typed parts and wire frames also have + explicit bounds. History replaces whole messages; repeated part IDs add no data. Exceeding a host event bound disables submission. No relay transcript is stored. - HMAC authenticates the browser-to-Astation bridge; it does not encrypt content. The relay can read the payload; the webapp serves the browser code. Existing TLS and @@ -58,12 +62,7 @@ an agent. Atem implementation is coordinated through shared project memory. ## Host contract: chat-v1 -This section records the transport proposal implemented by this draft client. -The Atem owner's approved design adds typed message parts, turn IDs and history, -as recorded in shared memory `mem_ccbe16ed2a6545688fac459186fef712` (2026-10-15). -Client/native alignment with that contract and live provider validation remain -required before live Agent support is enabled. Hosting and Stoa can ship -independently of that integration. The agreed additions are: +The approved host contract uses these event shapes: - Messages carry `role`, `turnId`, `createdAt`, typed text/tool `parts`, optional `history` and `requestId`, and local/remote origin for user messages. @@ -80,18 +79,46 @@ All host traffic uses the existing authenticated connection and `{"type":"agentChat","data":{...}}`. `data` always includes `v:1`, `op`, `conversationId`, and `agentId`. No broadcast or focused-agent routing is allowed. -1. Astation sends `open` with a new opaque conversation ID. -2. Host sends `opened` with its opaque process `generation` and `sequence:0`. +1. Astation sends `open` with a new opaque conversation ID and optional `before` + message ID. A page contains at most 50 whole messages / 256 KiB of content. +2. Host sends `opened` with its opaque process `generation`, `sequence:0` and + `status:{state,since,detail?}`. The implemented host uses Unix milliseconds + for `since` and `createdAt`; clients also accept RFC 3339 timestamps. 3. Astation sends `submit` with that generation, a unique `requestId` and `text`. - The host must deduplicate request IDs for the lifetime of this conversation. + The host deduplicates request IDs for the lifetime of the agent session. 4. Host events carry the same generation and strictly increasing `sequence`: - `accepted` includes `requestId`; `message` includes `messageId`, `requestId`, - full Markdown `text`; `delta` appends `text` to that message; `status` includes - `state` (`ready`, `working`, `waiting`, `unknown`); `complete` includes - `requestId`; `error` includes a safe `text` and optional `requestId`. -5. A sequence gap disables submissions and requires a fresh view. Duplicate - sequences are ignored. Unexpected conversations, agents and generations are - rejected. Reconnect does not restore permission or replay prompts. + `accepted` includes `requestId`. `message` includes `messageId`, `role`, + `turnId`, `createdAt`, `parts`, optional `requestId` and `history:true`. + User messages additionally carry `origin:local|remote`. Text parts are + `{id,kind:"text",markdown}`; tool parts are `{id,kind:"tool",name,summary}`. + `delta:{messageId,part}` appends a part once. `status` carries + `state:ready|working|waiting|unknown`, `since` and optional `detail`. + `complete` carries `turnId`, optional `requestId`, and `outcome:done|interrupted`. + `error` carries safe `text` and optional `requestId`. +5. Two explicit exceptions have no generation/sequence: pre-session `error`, + and host `close`. They are accepted only from the exact authenticated route, + agent and currently opened conversation. They never grant readiness. +6. A sequence gap disables submissions and permits reopening history without + replaying prompts. Duplicate sequences are ignored. Unexpected conversations, + agents and process generations are rejected. A changed generation requires + a new native-authorized link; a relay route never supplies device identity. + +History and reload merge messages by stable message ID, reconcile optimistic +remote prompts by request ID, and retain locally typed terminal prompts as +distinct user messages. Completion ends an execution turn; late parts for that +turn are still accepted. A refusal is shown as refused, an already accepted +request's error as a task error, and "delivery unknown" remains uncertain. +Unknown delivery never triggers resend. Status freshness uses receipt time; +the original `since` remains unchanged on a heartbeat. Only fresh Ready permits +submission. Permission requests stay in the terminal. + +Pagination and browser reload close the previous host conversation and open a +new conversation ID on the same authenticated route, pinned to the original +generation. This prevents delayed events from an earlier open corrupting the +new sequence. The host also supports reopening the same ID with `before`, but +the bridge uses fresh IDs. A host `close` (restart, capability withdrawal, +session switch or eviction) stops sending and offers an explicit history reopen. +The reopen action sends only `open`, never a prior `submit`. The host owns original-session attachment and semantic event collection. It must not start an unrelated conversation, scrape ANSI repaint output as replies, @@ -116,7 +143,7 @@ The browser uses `/ws?role=atem&code=&atem_id=browser-` solely as an untrusted transport to Astation. It sends `browserChat` frames with `data:{sessionId,nonce,sequence,body,mac}`. `body` is base64 UTF-8 JSON; `mac` is base64 HMAC. The fragment key uses unpadded base64url. Requests are `open`, -`submit` (requestId/text), and `refresh`. +`submit` (requestId/text), `history` (before), `reopen`, and `refresh`. Signed response bodies are `context`, `event`, or `notice`. The exact payloads are defined by the coordinator and covered by cross-language MAC vectors. @@ -124,9 +151,10 @@ Transcripts stay in tab memory; no localStorage, sessionStorage or transcript database is used. The capability stays in the URL fragment, which browser history may retain, until its local authorization expires. Reloading can reuse its fragment until the local session expires, with a new browser nonce and -response sequence. It receives subsequent events without restoring previous tab -messages. A pending request at reload disables new submissions; inspect the -terminal and open a new native-authorized session. A separate `?preview=1` +response sequence. It reopens host history without sending earlier prompts. +A pending request with unresolved delivery keeps sending disabled; inspect the +terminal and open a new native-authorized session if history cannot resolve it. +A separate `?preview=1` mode uses sample data, is visibly labeled and has no live network connection. ## Validation @@ -138,6 +166,6 @@ scroll retention, narrow/mobile layout and explicit preview/live separation. Production deployment and live Atem integration require separate acceptance; no production deployment or Atem source change is part of the UI implementation. -See [user setup and validation](../docs/agent-chat.md). Atem project memory -contains the proposed host contract (`mem_b3ccce1f5afc4c558655e0f5db13eb60`) -and provider/activity requirements (`mem_0cf8ab492a6a4465b2dfb9434fb3a43f`). +See [user setup and validation](../docs/agent-chat.md) and Atem's published +`designs/agent-chat-host.md`. Shared memory coordinates ownership and records +validation; the published wire contract supersedes the earlier flat-text draft. diff --git a/designs/atem-unlock-ui.md b/designs/atem-unlock-ui.md index f9b7ebc..7d604c4 100644 --- a/designs/atem-unlock-ui.md +++ b/designs/atem-unlock-ui.md @@ -16,15 +16,19 @@ that a Touch ID match approves release of this device's sealed keys. The window keeps **Deny** and **Deny and Revoke**. Deny closes the request without changing verification. Deny and Revoke removes the local E2E pin through the existing storage-key controller and requires fresh verification. -Closing the window, cancelling or failing authentication denies the request. -When Touch ID is unavailable, the same window explains how to enable it; no -alternative authentication method or approval action is offered. +Closing the window or choosing Deny cancels the request. **Use Mac Password...** +opens Apple's system authentication dialog using a separate context. This works +when Touch ID is inaccessible, unavailable or locked out. If macOS still offers +Touch ID, its Use Password option switches to password entry. Unsuccessful +authentication grants nothing and leaves explicit password retry/deny controls. ## Lifecycle and Security - The embedded view and policy evaluation use the same fresh `LAContext`. - Only `deviceOwnerAuthenticationWithBiometrics` is evaluated. Password - fallback and reuse of a recent Mac unlock are disabled. + It evaluates `deviceOwnerAuthenticationWithBiometrics` without reuse of a + recent Mac unlock. Password selection invalidates that attempt and uses a + fresh nonembedded context with `deviceOwnerAuthentication`. No login password + passes through Astation, its logs, memory or relay. - The controller validates the signed request and current local device pin before presentation. Displayed device names and boot IDs have control and Unicode formatting characters removed and their lengths bounded. They @@ -34,7 +38,8 @@ alternative authentication method or approval action is offered. invalidates authentication and closes the window exactly once. - Cancelling before authentication starts prevents policy evaluation. Disconnect, timeout, account change and replaced connection cancellation - invalidate the context; late or duplicate biometric replies have no effect. + invalidate all contexts; late or duplicate replies and replies from a replaced + authentication method have no effect. Password retry uses another fresh context. - The existing controller rechecks the captured connection, account, expiry and local pin before releasing a key. A successful Touch ID result cannot bypass those checks. Revocation remains an explicit button action. @@ -48,3 +53,6 @@ denial, explicit revocation, window close, cancellation before evaluation, duplicate and delayed replies, and the shared prompt gate. Existing controller tests continue covering disconnect, expiry, account and revocation races. The real Touch ID sensor and rendered system control need local verification. +Password tests cover unavailable Touch ID, system policy selection, cancelled +and stale replies, denial/revocation and visible fallback controls. Actual +closed-lid and remote-display password entry remain manual acceptance checks. diff --git a/designs/atem-verification-ui.md b/designs/atem-verification-ui.md index 546d646..0c45fd1 100644 --- a/designs/atem-verification-ui.md +++ b/designs/atem-verification-ui.md @@ -6,6 +6,8 @@ selectable safety code, comparison instructions and Apple's embedded **Codes Match**. That action starts Touch ID inside the same window, keeping the code visible throughout authentication. It does not complete verification by itself. **Deny** remains available before and during authentication. +After Codes Match, **Use Mac Password...** can instead open macOS's system +authentication dialog, including when the lid is closed or Touch ID is unavailable. Both biometric prompts share a 420-point content column, rounded detail cards and a consistently aligned Touch ID panel. Device details use labeled rows; @@ -27,14 +29,18 @@ comparison must never approve a device. - The embedded view and evaluation share a fresh `LAContext`. Clicking Codes Match enables one biometric evaluation after the window is visible. Further clicks cannot start another evaluation or complete approval. -- Only `deviceOwnerAuthenticationWithBiometrics` is evaluated, with password - fallback and reuse of a recent Mac unlock disabled. Unavailable Touch ID - displays guidance in the same window without hiding the code or offering - another approval method. +- The embedded view evaluates `deviceOwnerAuthenticationWithBiometrics` without + reuse of a recent Mac unlock. The password button invalidates that context + and uses a fresh, nonembedded context with `deviceOwnerAuthentication`. + macOS owns password entry; Astation never reads or stores the login password. + Unavailable or unsuccessful Touch ID leaves the explicit password/deny controls + available, without approving the request or automatically opening a new dialog. - Approval requires both explicit code-match confirmation and successful - biometrics. Denial, window close, peer abort, disconnect or timeout invalidates - the context. Completion runs on main exactly once; queued, duplicate and late - biometric replies cannot change a completed or replacement request. + device-owner authentication. Denial, window close, peer abort, disconnect or + timeout invalidates all contexts. Switching to password invalidates biometric + callbacks before evaluating the new policy. Password failure/cancellation + grants nothing and permits an explicit retry. Completion runs on main exactly + once; queued, duplicate and late replies cannot change a successor request. - The shared verification/recovery/unlock prompt gate remains in effect. The existing controller rechecks account, connection generation, expiry and signing identity before committing a device pin or sending a grant. @@ -50,4 +56,7 @@ window close, queued/late/duplicate replies, background callbacks and shared prompt gating. Existing protocol/controller tests cover commit binding, disconnects, replacement accounts/connections, expiry and durable verification. Physical Touch ID and the comparison against a live remote Atem need manual -verification. +verification. Password regressions cover the code-comparison gate, unavailable +biometrics, separate contexts, policy switching, retries, queued/late successes +and cancellation. Physical closed-lid and remote-display password entry require +manual acceptance. diff --git a/designs/credentials-ui.md b/designs/credentials-ui.md index 1ad970d..d4e4df3 100644 --- a/designs/credentials-ui.md +++ b/designs/credentials-ui.md @@ -19,7 +19,10 @@ UserDefaults, files, logs, memory, skills, the shared-notes vault, SSO broadcast or the relay. Keychain failures and unsupported/corrupt records retain the existing item and fail closed. The existing SSO and E2EE identities are separate. -Add, update, delete and copy require Touch ID. The controller captures the +Add, update, delete and copy require fresh macOS device-owner authentication +(Touch ID or the Mac login password in Apple's system dialog). Astation never +collects or stores the login password. Reuse of a recent biometric unlock is +disabled. The controller captures the Astation's identity and record revision before asking for approval, and checks that context again before acting. Cancelled or stale callbacks cannot change data or copy a value. Leaving the tab or closing Settings cancels input sheets, diff --git a/designs/e2e-encryption.md b/designs/e2e-encryption.md index c1c58d8..20de005 100644 --- a/designs/e2e-encryption.md +++ b/designs/e2e-encryption.md @@ -5,6 +5,24 @@ This documents Astation's authority and durable processing of grants, escrow and recovery, is maintained in Atem's `designs/e2e-encryption.md`. +## Owner Approval + +Device verification and storage-key unlock accept a fresh macOS device-owner +authentication: Touch ID or the current Mac account's login password. The +embedded biometric view keeps its biometric-only context. An explicit **Use Mac +Password...** button invalidates that attempt and creates a separate, nonembedded +`LAContext` using `.deviceOwnerAuthentication`, allowing macOS to present password +entry when the lid is closed, biometrics are unavailable or the user selects the +system password option. Astation never collects, stores or relays the password. + +Verification enables either method only after **Codes Match**. Switching methods +invalidates the previous attempt before presenting the next; late or queued +callbacks cannot approve a successor. Denial, revocation, window close, peer +cancellation and expiry invalidate all active contexts and complete at most once. +Password cancellation/failure grants nothing and leaves the explicit retry/deny +controls available. Existing device pins, signed requests, route generations, +recovery requirements and durable cryptographic checks remain required. + ## Lifecycle 1. Owner approval persists `enabling` or `disabling`, its account, target, diff --git a/designs/plans/2026-10-16-session-r6.2.md b/designs/plans/2026-10-16-session-r6.2.md new file mode 100644 index 0000000..0a25a75 --- /dev/null +++ b/designs/plans/2026-10-16-session-r6.2.md @@ -0,0 +1,53 @@ +# Astation session r6.2 integration + +Contract: Atem PR #68, tag `atem-session-v1-r6.2`, commit +`22de096b1980ee3ae342c0352b4c1d5fa54e3b51`. Host: Atem PR #69, +`a09be62c4db344b9a0540cf68f2ae4b28852844a`, with run instructions in +`designs/protocol/atem-host-handoff.md`. Update Astation draft PR #49 without +merging, deploying, restarting the installed app, or changing any Atem checkout. + +## Lifecycle and security invariants + +- One native session client owns hello/welcome and registry state per verified, + authenticated host connection. Connections and terminal windows share that + client; opening a window never sends another hello on a welcomed attachment. +- A replacement authenticated connection clears registry values, sequence, + tombstones, pending replies, terminal streams and leases. Keep only durable + host trust/runtime fences and command recovery metadata. A new attachment's + first snapshot may start at any sequence. Never infer identity from the relay. +- Removed agent-list, terminal-list/control and unbound chat/voice mutation + paths have no fallback. Browser access remains disabled pending section 9. + Native terminal requests use exact session/process/attachment bindings. +- Session errors may omit correlation for removed/undecodable requests. Valid + terminal errors retain terminal id, optional stream id, code and message; + chat errors retain code. A stale stream error cannot close its successor. +- Host admission is permission, accepting session, then lease. Client controls + require permission and a running session; refusals invalidate stale control + or refresh state, without replaying a prompt, input or stop. A busy response + keeps the durable request identity available for receipt recovery. +- Preserve monotonic expiry and late-reply fencing. Epochs may skip. Read-only + viewers and snapshots never grant control; reconnect never reacquires it. +- Import all r6.2 fixtures byte-for-byte, pin their provenance and hashes, run + every client scenario through real Swift/JavaScript decoders, and exercise + host-family messages as wire inputs. Host execution remains the Atem owner's + responsibility; do not claim client tests execute a Rust host. +- r7 approval decisions remain out of scope until the contract is supplied. + +## Work and validation + +1. Pin the contract, handoff and fixtures; replace r5-only fixture assumptions. +2. Update wire errors, attachment reset, shared registry ownership and native UI. +3. Test new-attachment sequence reset, no duplicate hello, removed outgoing ops, + family errors, stale streams, permission/session/lease refusals and recovery. +4. Run focused native tests, the webapp suite and terminal asset verification; + publish the revised owner handoff and push PR #49 for joint live testing. + +## Completed validation + +- 102 focused native tests pass, including all three shared client scenarios. +- 849 broader native tests complete with eight skips and zero failures, after + excluding the five pre-existing local failures documented in the implementation + evidence. The final refusal-during-resync change passes the focused suite. +- Webapp: 49 pass, one skip; eight terminal assets verify. +- Joint live testing with the owner's #69 host and r7 approvals remain gated as + recorded in the owner handoff; no local Atem implementation or app restart. diff --git a/designs/protocol/astation-session-v1-implementation.md b/designs/protocol/astation-session-v1-implementation.md new file mode 100644 index 0000000..dd5e8f9 --- /dev/null +++ b/designs/protocol/astation-session-v1-implementation.md @@ -0,0 +1,118 @@ +# Astation atem-session v1 implementation + +Date: 2026-10-16 + +Branch: `feat/session-multiplexing`, [draft PR #49](https://github.com/Agora-Build/Astation/pull/49), +targeting `main` and retaining both commits from superseded #48. +Contract: [atem-session r6.2](atem-session-v1.md), Atem PR #68, history tag +`atem-session-v1-r6.2` at `22de096b1980ee3ae342c0352b4c1d5fa54e3b51`. +Host: [Atem PR #69](https://github.com/Agora-Build/Atem/pull/69), run instructions +and coverage in the [pinned host handoff](atem-host-handoff.md), read at +`a09be62c4db344b9a0540cf68f2ae4b28852844a`. + +## Lifecycle and invariants + +1. The hub captures account, verified host device, exact authenticated socket + and pairing session before asynchronous dispatch, then rechecks that context. + Message fields and relay presence cannot supply identity or authority. +2. One client per authenticated host connection owns hello/welcome, the registry + and leases. Connections and terminal windows share it. Opening or reopening a + window never sends another hello on a welcomed attachment. Failed attachments + wait for a replacement authenticated connection. No resume or auto-control. +3. Every welcome clears sessions, sequence, removals, pending operations, + terminal subscriptions and leases; retain durable host trust/runtime floors + and request recovery metadata only. The first snapshot can use any sequence. + Reject runtime rollback, gaps, incomplete descriptors and removed-process + resurrection within the current attachment. Runtime floors commit durably. +4. Session-v1 list is the only registry. Removed agent lists, terminal lists, + control/lease_id, unbound voice/prompts and console commands have no production + fallback. Browser host access is disabled even before hello. The old browser + UI and its isolated tests remain for future companion work, without transport + authority. Native UI disables Open in Browser. +5. Terminal requests carry the exact account/host/trust/session/process target + and attachment; mutations additionally carry the current lease epoch. Actual + r6.2 host events carry no binding. Accept them only from the captured verified + connection and correlate subscribe/process/stream locally. An error for a + replaced stream cannot close its successor. Per-attachment resets drop old + terminal queues and subscriptions. +6. Session errors accept absent `re` for removed/undecodable requests. Terminal + errors preserve terminal id, optional stream id, code and message; chat errors + preserve code and request id. There is no cross-family receipt interpretation. + Permission, closed-session and stale-lease refusals remove local control; + stale targets refresh the registry, without replaying a submission. +7. Acquire control explicitly; renew every five seconds and expire at + `now >= deadline`. Correlate each grant once, estimate deadlines from request + send time and fence late grants. Repeated acquire never extends control; + lease epochs can skip. Read-only or terminal-only capabilities never grant + control or imply structured prompt support. Codex uses terminal typing/paste. +8. Persist request id, full target, kind and JCS digest before submitting prompt, + interrupt or stop. A failed local save sends nothing. Disconnect never replays + submissions or PTY input; reconnect queries original receipts, including old + process targets. Host admission is permission, accepting session, then lease. + Busy leaves the recovery identity unresolved for receipt queries: the host + branch documents that a double I/O failure can make delivery uncertain. +9. The shared journal contains recovery metadata, not prompts, terminal output, + keys or control authority. Use one journal for every host client to avoid + overwriting another host's records. Restricted temporary files, fsync, rename + and directory fsync commit writes; corrupt load/uncertain save fails closed. + Retain at most 4096 requests and 128 host fences; only final receipts can be + evicted to admit work. Final outcomes are immutable. +10. Parse original direct/relay bytes before Foundation loses duplicate keys or + number spellings. Enforce safe counters, Unicode identity and payload bounds. + Debug logging redacts session and terminal contents, including bad packets. + Section 9 trust, secure grants, freshness and traffic E2EE remain disabled; + r7 prompt-bound approvals await their separate contract. + +## Validation + +All 18 upstream fixture blobs are verified against their Git object hashes on +import, copied byte-for-byte to design/test resources, and pinned by SHA-256 in +`Tests/AstationTests/Fixtures/atem-session-v1/source.json`. This includes the +schema, digest vectors and 16 scenarios. The obsolete r5 legacy-bypass fixture +is replaced by removed-ops-have-no-effect. + +Swift and offline JavaScript run all three client scenarios through their real +wire decoders: late/duplicate renewals, runtime/sequence ordering and new +attachment reset. Harnesses recognize the documented steps and assert the +reported state rather than skipping unsupported assertions. A fixture welcome +represents a new authenticated test transport and is correlated with its hello. +Native tests also cover actual terminal binding, unbound host events, family +errors, admission refusals, busy receipt recovery, no second hello, disabled +browser fallback, revocation, persistence failure and journal reload. +Host-side fixtures are data for Atem's runners; these client results do not +claim to execute a Rust host or the real runtime/worker harness. + +```sh +swift test --filter 'AtemSession|RemoteTerminal|AgentInputRouting|BrowserAgentChat' +node --test webapp/tests/* +python3 scripts/setup-terminal-assets.py --verify +``` + +Final focused run: 102 native tests pass; webapp: 49 pass, one skip; +eight pinned terminal assets verify, including the final permission/capability +regression and the refusal-during-resync check. + +The broader local run passed: 849 tests, eight skipped, zero failures, explicitly +excluding the five previously documented local failures: + +- `AtemE2EIdentityTests.testRealProtectedIdentityReloadsRecoveryPinsAndEpochs` +- `AtemE2EIdentityTests.testRealProtectedMigrationJournalRecoversAndCommitsOnceAfterIdentityReload` +- `AtemE2EIdentityTests.testRealProtectedStorageKeyEscrowRestartUnlockAndReplayRejection` +- `FloatingSpeechControlsTests.testManualCaptionScrollHoldsSnapshotFor15SecondsAndResumesWithoutNewSpeech` +- `TimeSyncTests.testMultipleCallsReturnConsistentTimestamps` + +The previous unfiltered run failed protected-key creation (-25308), the caption +scroll assertion, and a subsequent broader run failed TimeSync after network +timeouts. The production files for those tests are unchanged; do not report an unfiltered full local +suite as green. Previous CI passed r5 implementation `328c7d1`; r6.2 needs its +own PR checks after this push. + +## Joint integration and release gates + +See the [Atem owner handoff](atem-implementation-handoff.md) for the concrete +live checks. The host currently has one Astation link/attachment at a time. +Multiple independent agent sessions can share that link; multiple simultaneous +native controllers and QR companions still require section 9. + +No local Atem checkout was changed, built or tested. No active agent process or +installed app was replaced/restarted, no PR was merged, and nothing was deployed. diff --git a/designs/protocol/atem-host-handoff.md b/designs/protocol/atem-host-handoff.md new file mode 100644 index 0000000..b975f3e --- /dev/null +++ b/designs/protocol/atem-host-handoff.md @@ -0,0 +1,236 @@ +# Atem host handoff: atem-session r6 on `feat/runtime` + +For the Astation team, 2026-10-16. The Atem side of +[atem-session-v1.md](atem-session-v1.md) (r6, compatibility mode) now runs in +Atem's real runtime on the `feat/runtime` branch (not merged, not released). +This note says what is live, how to run it, every change Astation can see on +the wire, how the shared fixtures fare against the real processes, and what +stays gated by §9. + +## What is live on feat/runtime + +- **One runtime per profile.** `atem runtime` (hidden; started on demand by + any `atem agent …`, bare `atem`, `atem pair`, `atem pair verify`, `atem cred + unlock|projects`) is the broker. It holds the machine's one Astation link + (local `:8080` or the relay, as before) and the session host. Nothing else + in Atem opens an Astation socket, so pairing and unlock no longer displace a + running session's connection. +- **Workers.** Each session runs in its own `atem worker` process that owns + the agent's PTY, canonical screen and (Claude) chat adapter. Workers outlive + the broker; a restarted broker adopts them after checking session id, + process generation and the OS start time of the worker's pid. +- **Session host.** `SessionHost` with a durable `FileJournal` + (`~/.config/atem/runtime/journal/`): attachments, registry, 15 s control + leases with takeover, input epochs, receipts per request, terminal-v1 and + chat-v1 bound to session-v1. The broker is the only writer authority; the + worker drops writes of an older input epoch at the PTY write boundary. +- **Local frontends** (`atem agent launch|attach|list|stop`, bare `atem`) + talk to the broker over a same-user Unix socket. Local input revokes any + remote lease before it is written. +- **Astation operations** (`atem pair`, `atem pair verify`, `atem cred + unlock|projects`, paired-token refresh) run inside the broker on its + existing link; the CLI is only the UI. + +## How to run the host + +1. Build the branch: `git checkout feat/runtime && cargo build` (the binary is + `target/debug/atem`; use the same binary for every command below). +2. Stop every other atem on the machine first (older binaries open their own + Astation connection with the same `atem_id` and evict the runtime's; an + older runtime is replaced only when it has no live sessions). +3. Pair and verify: `atem pair` (first pairing; it starts the runtime and + pairs and verifies on its link), or on an already paired profile `atem + pair verify`. `atem status` shows Pairing/Verification offline. Only a + paired **and verified** profile gets a session attachment. +4. Launch agents: `atem agent launch claude` / `atem agent launch codex` (in + the project directory). `atem agent list` prints `Runtime pid … · protocol + 1 · build … · Astation ✓|✗` and the sessions. +5. In Astation: after the link authenticates, send session-v1 `hello`, then + `list` (the registry replaces the agent list), `acquire` for control, + terminal-v1 `subscribe` / chat-v1 `open` with a binding. +6. Logs: `~/.config/atem/runtime.log` (broker), + `~/.config/atem/runtime/workers/.log` (worker). The runtime exits + after 10 minutes with no live sessions and no frontends, or on SIGTERM to + the pid in `~/.config/atem/runtime.lock`. + +`ASTATION_WS` (or `astation_ws` in `config.toml`) points the runtime at a +specific local Astation, as before. + +## Wire-visible changes + +Removed, no compatibility (atem-session r6): + +- `agentListRequest`/`agentListResponse` are gone: the registry is session-v1 + `list` → `sessions`, then `session_changed` (per-attachment `seq`). +- `agentInput`, `agentPrompt`, `voiceCommand`, `voiceToggle`/`voice_toggle`, + `voiceRequest`, `userCommand`, `codexTaskRequest` and `agentListRequest` + no longer parse. Each is answered with a session + `error{code:"unsupported_version", detail:"removed in atem-session r6"}` + (no `re`: they have no session id) and has no effect (r6 §0). +- terminal-v1 `list`/`sessions`, `control`/`controller`, `lease_id`, the + `terminal_ids` marker and provider-name ids (`claude`, `codex`) are gone; + chat-v1 `submit` without `binding` is refused. These family messages are + answered with a session `error{code:"unsupported_version"}` and no effect. + +Attachments and identity: + +- Bare `atem` (the menu TUI) opens no Astation connection at all; only + `atem runtime` does, and it serves the sessions. If the runtime's session + host can't start (e.g. a damaged `runtime/journal/runtime_epoch`, which is + never reset silently), session-family messages are answered + `error{code:"unsupported_version"}` and `atem agent list` shows "remote + sessions disabled: is damaged; move it aside to reset the runtime + epoch". +- Only the paired Astation the link authenticated with, verified in this + profile's `cred_state.json`, gets an attachment (`view`, `control`, + `approve`; `client_kind: native`). Any other link (unpaired, unverified, + another Astation) gets none: its session-family messages are answered + `error{code:"unauthorized"}`. +- Every connect and every re-authentication (`auth_required` over an open + socket) is a **new attachment**: send `hello` again; a message bound to an + old `attachment_gen` is `stale_target`. Leases are not inherited. +- `welcome`: `account_id` = the verified `data_account` from + `cred_state.json`; `atem_device_id` = this device's verified `device_id`; + `host_trust_gen` = `pairing.json`'s generation (moves on every + `atem pair`/`atem unpair`); `runtime_gen` and `runtime_epoch` change when + the broker restarts (the epoch is a durable counter) and when the host + rebinds to a new identity (a pairing verified after the broker started). +- Authorization: an attachment is authorized for 24 h (no freshness proof in + compatibility mode), counted on the monotonic clock and in wall time, + whichever ends first (the monotonic clock stands still while the machine + is suspended). At the end the broker fences it and drops the link; the + reconnect authenticates again and gets a new attachment. +- Duplicates: a session message with the same `id` and identical content on + the same attachment within 60 s is handled once (e.g. arriving over both + transports); other content under that id, or a retry on a new attachment, + is handled. +- `atem unpair` / `atem logout` make a running runtime reload: a link whose + pairing is gone is dropped with its attachments. The broker also re-checks + the link's pairing every 5 s, so an unpair takes effect even if the + reload never reached it. +- Duplicate detection remembers at most 4096 messages (oldest forgotten + first). + +terminal-v1: + +- `terminal_id` = `session_id`; `generation` = `process_gen`. Every message + carries a session-v1 `binding`; `input`, `resize` and `stop` also need the + current lease. +- Each `input` is answered with `written` (no `error`, or an error text; + `"control changed; not written"` when the bytes were dropped at the write + boundary because control moved or the lease's deadline passed). +- Lease deadline at the write boundary: every remote write and paste the + broker hands a worker carries `not_after`, the lease's deadline on + `CLOCK_MONOTONIC`; the worker's PTY writer drops a chunk whose deadline has + passed when its turn comes, so nothing remote is written at or after the + deadline even if the host's 100 ms `advance` hasn't run yet. (A chunk queued + before a `renew` keeps the deadline it was sent with.) +- A slow transport: frames are dropped and every stream ends with + `error{code:"busy"}` ("Terminal transport congested; subscribe again"); + `written` acks and errors keep their order. +- Inbound `terminal` messages larger than 32 KiB (`session`/`agentChat`: + 1 MiB) are dropped unread and answered with a session + `error{code:"unsupported_version"}`. A full host inbox (1024 messages per + pass) answers `busy`. + +chat-v1: + +- `open`, `close` and `submit` carry a session-v1 binding; `submit` also the + lease. A submit's `accepted` / `complete{outcome, turnId}` / + `error{code, text}` follow its receipt (accepted and executing → `accepted`; + completed → `complete`; rejected / outcome_unknown → `error`), never a + session receipt. +- At most 8 open conversations per session: a ninth `open` closes the oldest + (`close`). +- Codex has no chat (`capabilities: ["terminal-v1"]`; Claude: + `["terminal-v1","chat-v1"]`). + +Receipts (session-v1 `submit`), per kind: + +- `prompt`: pasted into Claude once its chat accepts it, completed when the + chat reports the turn's end (`turn_id`). On a session without chat (Codex) + it is `rejected` with no effect. +- `interrupt`: writes Esc; completes when a turn ends, or at once when none + runs. +- `stop`: stops the agent; completes at its exit. +- `approval`: `rejected` (approvals stay in the terminal). +- A dispatch refused before anything was queued is `rejected` ("could not + write to the terminal"); a writer failure after queueing is + `outcome_unknown` ("write interrupted"). Work executing when the broker + restarts becomes `outcome_unknown`; nothing is retried. Duplicate submits + return the recorded receipt; the same `request_id` with other content is + `request_id_reused`. Receipts survive broker restarts (`receipt_query`). + +Pairing, verification and unlock: + +- `atem pair`, `atem pair verify`, `atem cred unlock`, `atem cred projects` + and the paired-token refresh run on the runtime's existing link: the + handshake messages (`pairSavePreference`, `credentialSync`, `verifyCommit` → + `verifyKeys` → `verifyReveal` → `deviceVerified`/`verifyAbort`, + `unlockRequest` → `unlockGrant`/`unlockDenied`, storage-key rotation, + `credentialProjectListResponse`) arrive on the same socket that carries the + sessions, not on a new one. One operation at a time; replies are routed to + the running operation only and dropped after it ends; a CLI that goes away + ends it (`verifyAbort` for verification); a lost link ends it, never + replayed. + +## Shared fixtures against the real runtime + +`tests/runtime_fixtures_binary.rs` runs every host scenario in `fixtures/` +against the `atem runtime` binary on a temp profile (paired and verified as +the fixture's account, device and trust generation), real `atem worker` +processes with a fake agent in their PTYs (a fake Claude that fires Atem's +chat hooks and writes a transcript; turn ids `turn-`), a local frontend +for local input, and a fake Astation that authenticates the runtime's link +and plays the fixture's client. Runtime-chosen ids (session ids, process, +runtime and attachment generations) are translated; the clock and PTY write +faults use debug-only hooks (`src/runtime/test_hooks.rs`). Terminal `frame`s +and `written` acks are not queued for `expect` (the acks count toward +`pty_writes_dropped`). + +| Fixture | Result | +|---|---| +| dispatch-failure-after-partial-write-is-outcome-unknown | pass (incl. `restart_runtime` + `reconnect`) | +| lease-expiry-at-the-deadline-needs-no-timer | pass (the remote chunk held at the PTY writer past the deadline is dropped by its `not_after`) | +| dispatch-failure-before-any-effect-is-rejected | pass (incl. `restart_runtime` + `reconnect`) | +| duplicate-submit-returns-receipt-and-reuse-is-rejected | pass | +| exited-session-refuses-work-before-the-lease-check | pass (`crash_worker` = SIGKILL to the worker) | +| family-errors-and-chat-results | pass | +| lease-expires-without-renewal | pass | +| local-input-revokes-the-lease-and-drops-queued-bytes | pass | +| reconnect-restarts-the-registry-sequence | pass | +| removed-ops-have-no-effect | pass folded onto one attachment (the lease holder itself sends every removed op, with and without the lease; `lease_changed` for the second client left out); the two-attachment original runs in the core harness | +| receipt-query-is-scoped-to-the-exact-target | core harness only (3 attachments) | +| takeover-moves-control-and-the-old-holder-becomes-a-viewer | core harness only (3 attachments) | +| closed-session-refuses-work-before-the-lease-check | core harness only: the runtime has no trigger that closes a running session yet | + +The multi-attachment fixtures (receipt-query, removed-ops, takeover) run +only in the core harness (`SessionHost` in `src/session_protocol/fixtures.rs`) +because the runtime holds one Astation link, so one attachment, at a time +today; closed-session runs only there because nothing in the runtime closes +a running session yet. + +The same fixtures all pass against the in-process `SessionHost` +(`src/session_protocol/fixtures.rs`, with the memory and the file journal). + +Not honoured against real processes: `timers: false` (the broker's loop runs +`advance` whenever the clock moved 100 ms), `close_session`, +`disconnect`/`resume` (no resume in compatibility mode), `fail_next_fsync`, +`change_trust_gen`, and more than one client per scenario. + +## Differences from session-multiplexing.md + +- §1 lists `atem cred status` as a compatibility alias of `atem status`; the + user removed it. `atem status` is the only status command. (Astation + maintains session-multiplexing.md.) + +## Still gated by §9 + +Refused until §9 is specified jointly, implemented and tested: resume (and +`resume_proof`), browser clients and QR grants, account-wide native access +(only the one verified Astation attaches), relay confidentiality (no +end-to-end channel; the relay sees the messages), authenticated freshness +proofs (the 24 h window starts at authentication, not at a validated +authority proof), signed membership and revocation ordering. Several +simultaneous Astation clients per host also wait for it (today: one link, one +attachment). diff --git a/designs/protocol/atem-implementation-handoff.md b/designs/protocol/atem-implementation-handoff.md new file mode 100644 index 0000000..b6af9bd --- /dev/null +++ b/designs/protocol/atem-implementation-handoff.md @@ -0,0 +1,117 @@ +# Atem and Astation session integration handoff + +Date: 2026-10-16 + +Astations and Atems update together to **atem-session r6.2**, with no backward +compatibility. Review [draft Astation #49](https://github.com/Agora-Build/Astation/pull/49), +which targets `main` and contains superseded #48. All Atem implementation belongs +to the Atem owner; Astation has not changed, built or tested an Atem checkout. + +Contract: [Atem #68](https://github.com/Agora-Build/Atem/pull/68), history tag +`atem-session-v1-r6.2`, pinned `22de096b1980ee3ae342c0352b4c1d5fa54e3b51`; +[exact snapshot](atem-session-v1.md), [fixtures](fixtures/schema.md). +Host: [Atem #69](https://github.com/Agora-Build/Atem/pull/69), `feat/runtime`, +read at `a09be62c4db344b9a0540cf68f2ae4b28852844a`; +[build/run instructions and real-runtime coverage](atem-host-handoff.md). + +## Astation changes ready for joint testing + +- One authenticated session client per host connection, shared by Connections + and terminal windows. No second hello when opening or reopening a window. + The session registry replaces both removed lists and supplies exact process + ids, workspace labels, capabilities and activity to the UI. +- Every new attachment clears sequence, registry, removals, terminal queues, + pending replies and leases, while retaining durable trust/runtime fences and + submission recovery metadata. A first snapshot at sequence 1 after 100 works. +- Requests bind full target/attachment and mutation lease. Actual r6.2 terminal + host events are unbound; Astation checks verified connection, exact process, + outstanding subscription and stream instead. Old-stream errors are ignored. +- Removed operations have no outgoing fallback. Session unsupported_version + errors can omit re. Terminal and chat preserve family error codes. Permission, + session_closed and stale_lease refusals remove control without replay. +- Prompt/interrupt/stop requests persist their identities before sending. + Reconnect queries the original receipts. Busy also keeps a request unresolved + and never causes an automatic resubmit. Codex uses terminal input/paste; + structured prompts require chat-v1. Leases retain exact-deadline fencing. +- Browser access is disabled on all host sockets, including before hello; no + unbound chat is sent. Open in Browser is disabled pending secure companions. +- All 18 r6.2 fixture files are copied and pinned; Swift and offline JavaScript + run all three client scenarios, plus digest/rejection vectors. Host scenarios + remain the owner's responsibility. See [validation evidence](astation-session-v1-implementation.md). + +## Joint live test sequence + +1. Use the same #69 binary for pair/verify/launch on an isolated test profile; + preserve existing sessions. Run instructions are in the host handoff. Build + Astation #49, authenticate/verify the host, and check exactly one hello/welcome + even when Connections refreshes and terminal windows reopen. +2. Launch Claude and Codex and another session of one provider. Confirm each + session/workspace/process appears, can be selected and viewed independently, + and updates activity/removal without agentListRequest or terminal list. +3. Take Control, type/resize/paste and release. Local host input must revoke + remote input at the worker write boundary. Expiry and late renewal must not + restore input; large durable epoch jumps must work. No legacy control op or + lease_id is sent. Provider approvals still use the controlled terminal. +4. On Claude, submit a prompt and observe accepted/executing/completed; interrupt + and stop use their own receipts. Codex's structured prompt field is disabled. + Disconnect immediately after acceptance and restart/reconnect the runtime; + query the original receipt without a second dispatch or restored lease. +5. Reauthenticate/reconnect to the same runtime. The registry sequence can + restart at 1, removed-process caches clear, runtime rollback stays refused, + and old terminal frames/errors cannot enter the new subscription. Verify + session_closed is handled before stale_lease when both conditions apply. +6. Exercise terminal busy/stale_lease/stale_target errors and chat rejected or + outcome_unknown in their own families. Removed forms return session + unsupported_version, including without re; malformed messages never write. + Browser open and unbound submit stay disabled. +7. Unpair/revoke with a pending request, then pair/verify again. Old authority + never returns, pending work is not replayed under new trust, and local agents, + memory, vaults and saved credentials remain intact. + +Atem's real runtime currently supports **one Astation link/attachment**; do not +advertise simultaneous controllers or execute a multiple-link live test yet. +Its core harness covers the multi-attachment scenarios. New fixture steps +include reconnect, close_session, turn_complete, held/flushed PTY writes, +family-filtered expect, captures and end-of-scenario queue exhaustion. The host +handoff distinguishes actual binary coverage from core-only or unsupported +fault steps; client tests do not expand that claim. + +## Contract clarifications for the Atem owner + +The pinned host handoff says every terminal message carries a binding. Its wire +implementation and actual host events instead require bindings only on requests +and prohibit them on host events. Astation follows the implementation and +fixtures, with local connection/process/stream fencing for responses. Clarify +that sentence in the owner document for joint testing. + +The #69 contract adds a double-I/O-failure exception to busy admission: a request +answered busy might still dispatch once under its original identity. The r6.2 +tag/#68 text says busy records nothing. Astation conservatively retains the +request for queries and never auto-resends it; reconcile the wording upstream. +Neither clarification blocks the client changes or initial live tests. + +## Security and approval gates + +Section 9 remains open: resume, browser/QR grants, multiple native attachments, +account-wide trust/enrollment, signed membership/revocation ordering, +authenticated freshness proofs and live traffic E2EE stay disabled. The relay +is not identity, trust, encryption-state or control authority. Compatibility +mode does not claim relay confidentiality. r7 prompt-bound allow once / always +allow / deny is being built by Atem; Astation awaits the contract text before +implementing approval decisions. + +## Coordination and provenance + +Use this hosted handoff for cross-machine coordination. The r6.2 update is saved +locally as Atem memory `mem_ad576dc855114182ae1ef6d30ced6d3d` for project Astation, +but the relay rejected sync: `memory does not match the account encryption mode`. +The earlier r5 memory id was not present in this CLI store. Shared memory is not +confirmed; read the hosted handoff instead. Do not rely on a local Atem checkout. + +Earlier worktree wording referred to an abandoned, uncommitted experiment at +`/Users/brent/Dev/Agora.Build/Atem-session-multiplexing`, based on `10d3ea8`. +It is preserved and is not an implementation source. The owner's published +pair/verify/status and trust-generation work (#67) is authoritative. + +Nothing was merged or deployed, and no installed app or running agent was +replaced or restarted for this update. #49 remains draft for joint live testing. diff --git a/designs/protocol/atem-session-v1-r1.md b/designs/protocol/atem-session-v1-r1.md new file mode 100644 index 0000000..e85b535 --- /dev/null +++ b/designs/protocol/atem-session-v1-r1.md @@ -0,0 +1,204 @@ +# atem-session v1: sessions, control leases and receipts (DRAFT for joint review) + +Status: Atem-side draft, 2026-10-15, for review by the Astation agent. Implements +the Atem-owned parts of [session-multiplexing.md](../session-multiplexing.md) +§2–§5 on the wire. Nothing here is final until both sides accept it and the +fixtures in `fixtures/` pass on both. Sections marked **JOINT — OPEN** need the +joint trust/transport specification and are placeholders only. + +## 0. Scope and layering + +``` ++--------------------------------------------------------------+ +| atem-session v1 (this doc): registry, leases, receipts | +| terminal-v1 (existing) / chat-v1 (existing) streams | ++--------------------------------------------------------------+ +| secure channel (JOINT — OPEN): E2EE, endpoint keys, freshness | ++--------------------------------------------------------------+ +| transport: direct ws :8080 | relay (atem_id + connection_id)| ++--------------------------------------------------------------+ +``` + +- Direct and relay are thin transports. Every message below is identical on + both; the host deduplicates across them. +- Until the secure channel exists, these messages run inside today's + authenticated Astation connection (no relay confidentiality is claimed). +- terminal-v1 and chat-v1 keep their own ops. This contract replaces their + addressing (`terminal_id`/`agentId` = `session_id`) and their control rule + (one lease per session, defined here) — see §6. + +## 1. Envelope + +```json +{"type":"session","data":{"v":1,"op":"…","id":"", …}} +``` + +- `v` = 1. A receiver ignores unknown fields and refuses unknown `v` with + `error{code:"unsupported_version"}`. +- `id`: sender-unique message id (UUID); replies carry `re` = the request's + `id`. Replies are not deduplicated by `id`; submissions are deduplicated by + `request_id` (§5). +- Host = Atem runtime. Client = one authenticated Astation connection + (`controller_session` below). + +## 2. Identities and generations + +| Field | Format | Meaning | +|---|---|---| +| `account_id` | opaque string | account owning the data | +| `atem_device_id` | opaque string | Atem device/profile identity (existing `device_id`) | +| `host_trust_gen` | integer ≥ 1 | increments durably on unpair/rebind; older values rejected | +| `runtime_gen` | UUID | new each time the broker (runtime) starts | +| `session_id` | `ses-` + 12 `[a-z2-7]` | one agent session; stable for its life | +| `process_gen` | UUID | one agent process of a session; a provider resume is a new one | +| `controller_session` | UUID | one authenticated client connection's controller identity (assigned by the host on `hello`; survives a transport switch only through `resume`, §3) | +| `lease_epoch` | integer | increments on every grant/transfer/expiry/revoke of a session's lease | +| `request_id` | UUID | one high-level submission (§5) | + +Every mutating request carries the full target: +`{account_id, atem_device_id, host_trust_gen, session_id, process_gen}`. A +mismatch on any field is `error{code:"stale_target"}` with the current values +the client is allowed to see. + +## 3. Hello and connection identity + +| op | dir | fields | +|---|---|---| +| `hello` | C→H | `client_device_id`, `client_kind` (`native`, `browser`), `resume?{controller_session}` | +| `welcome` | H→C | `account_id`, `atem_device_id`, `host_trust_gen`, `runtime_gen`, `controller_session`, `permissions` (list), `authorization_expires_at` (ms), `server_time` (ms) | + +- `resume` keeps the same `controller_session` on a new transport only if the + host still knows it and its authorization is valid; leases are NOT resumed + (an expired lease stays expired; a live one continues only if the same + `controller_session` renews before expiry). +- Authentication of `hello` is the secure channel's job (**JOINT — OPEN**); + today it is the existing Astation authentication of the socket. + +## 4. Session registry + +| op | dir | fields | +|---|---|---| +| `list` | C→H | — | +| `sessions` | H→C | `runtime_gen`, `sessions: [session]` | +| `session_changed` | H→C | `session` (pushed on launch, status change, exit, removal) | + +`session`: + +```json +{"session_id":"ses-abcdefghijkl","process_gen":"…","provider":"claude", + "cwd_label":"Atem","state":"running|exited|interrupted|recovery_required|closed", + "activity":"ready|working|waiting|unknown","activity_since":1760500000000, + "controller":{"controller_session":"…","lease_epoch":7,"expires_at":1760500015000}|null, + "capabilities":["terminal-v1","chat-v1"],"created_at":1760490000000} +``` + +- `cwd_label` is the last path component only (no full paths over the wire). +- `interrupted`: the worker/agent ended unexpectedly; `recovery_required`: the + host can't confirm the worker (e.g. after reboot); a provider resume, if + offered, creates a new `process_gen`. + +## 5. Control leases + +| op | dir | fields | result | +|---|---|---|---| +| `acquire` | C→H | target, `takeover: bool` | `lease{session_id, lease_epoch, expires_at}` or `error` | +| `renew` | C→H | target, `lease_epoch` | `lease{…}` (new `expires_at`) or `error` | +| `release` | C→H | target, `lease_epoch` | `lease_released{session_id, lease_epoch}` | +| `lease_changed` | H→C (all viewers) | `session_id`, `controller` (as in `session`), `reason` (`granted`, `takeover`, `local_input`, `expired`, `released`, `revoked`, `process_ended`) | + +Rules (host-enforced, serialized per session): + +- One lease per session. `expires_at` = grant/renew time + 15 000 ms, or the + controller's `authorization_expires_at` if sooner. No renewal by then → the + lease expires (`reason: expired`). +- `acquire` with a lease held by another controller: `takeover:false` → + `error{code:"lease_held"}`; `takeover:true` → transfer (needs the `control` + permission), previous holder gets `lease_changed{reason:"takeover"}` and stays + a viewer. +- Local terminal input on the host takes the lease locally: the remote lease is + revoked atomically (`reason:"local_input"`) before that input is written. +- Every mutation (terminal input/resize/stop, chat submit, interrupt, future + APIs) carries `lease_epoch` and is rejected with `error{code:"stale_lease"}` + unless it equals the session's current epoch held by this + `controller_session`. Delayed input from an old epoch or `process_gen` is + never written. +- Disconnect does not release immediately; the lease simply stops being renewed. + Reconnect never reacquires automatically. + +## 6. Submissions and receipts + +A submission is a high-level request (chat prompt, interrupt, provider +approval answer, stop). Raw terminal keystrokes are NOT submissions; they are +a live stream (terminal-v1 `input`) checked only against the lease. + +| op | dir | fields | +|---|---|---| +| `submit` | C→H | target, `lease_epoch`, `request_id`, `kind` (`prompt`, `interrupt`, `stop`), `payload`, `payload_digest` (SHA-256 hex of the canonical payload JSON) | +| `receipt` | H→C | `request_id`, `session_id`, `process_gen`, `state`, `detail?`, `updated_at` | +| `receipt_query` | C→H | `session_id`, `request_ids: [..]` (≤ 64) | +| `receipts` | H→C | `receipts: [receipt]` | + +`state`: + +| state | meaning | +|---|---| +| `accepted` | durably recorded by the worker; not yet dispatched | +| `executing` | dispatched to the agent | +| `completed` | the agent's turn for it ended (chat-v1 `complete`) | +| `rejected` | refused; `detail` says why; nothing was dispatched | +| `outcome_unknown` | dispatched before a crash and the outcome can't be recovered | + +- Acceptance means the worker wrote the receipt to its journal and synced it; + the host replies `receipt{state:"accepted"}` only after that. +- Same `request_id` + same digest → the recorded receipt, never re-executed. + Same `request_id` + other digest → `error{code:"request_id_reused"}`. +- Receipts are kept for the session's life and 7 days after it closes. A + session that reaches its receipt limit refuses new submissions with + `error{code:"receipt_limit"}`; receipts of an accepting session are never + evicted. Closed sessions reject submissions with `error{code:"session_closed"}`. +- chat-v1's `submit`/`accepted`/`complete` map onto this: its `requestId` is + the `request_id`; its acceptance requires the receipt to be `accepted` first. + +## 7. Errors + +`error{re, code, message, target?}`; `message` is human-readable, never +contains secrets, paths or raw OS errors. + +| code | when | +|---|---| +| `unsupported_version` | unknown `v` | +| `unauthorized` | not authenticated / authorization expired / not fresh | +| `forbidden` | missing permission (`view`, `control`, `approve`, `manage`) | +| `stale_target` | trust generation, session or process generation mismatch | +| `unknown_session` | no such session | +| `lease_held` | another controller holds the lease (no takeover) | +| `stale_lease` | wrong or expired `lease_epoch` | +| `request_id_reused` | same request id, other payload | +| `receipt_limit` | the session's receipt journal is full | +| `session_closed` | the session no longer accepts submissions | +| `busy` | the agent can't take a submission now (e.g. chat-v1 not ready) | +| `paused` | authorization freshness deadline passed; viewing/control paused | + +## 8. Freshness, trust and encryption — JOINT — OPEN + +Placeholders only; to be specified jointly before implementation: + +- Trust anchor, enrollment, membership certificates, management authority, + host acceptance of account-wide enrollment, unpair/rebind (`host_trust_gen`). +- Authenticated freshness proof for the 24-hour window, clock and restart + handling, persisted high-water marks; how `authorization_expires_at` is + derived and refreshed. +- End-to-end encrypted channel binding endpoint keys, account, roles, + permissions, generations; replay/downgrade protection; how direct :8080 and + relay share it. +- Browser QR request/grant formats and validation without an online native + bridge. + +## 9. Fixtures + +`fixtures/*.json` hold one JSON object per file: +`{"name": "...", "messages": [{"dir":"C→H"|"H→C","json":{…}}...], "expect": "..."}` +covering: hello/welcome; list/sessions; acquire/renew/expiry; takeover; +local-input revocation; stale epoch input; submit accepted → completed; +duplicate submit; reused request id; crash → outcome_unknown; closed session. +Both implementations replay them in tests. diff --git a/designs/protocol/atem-session-v1-r2-review.md b/designs/protocol/atem-session-v1-r2-review.md new file mode 100644 index 0000000..ac5da00 --- /dev/null +++ b/designs/protocol/atem-session-v1-r2-review.md @@ -0,0 +1,131 @@ +# Astation Review of atem-session v1 r2 + +Date: 2026-10-15 + +Reviewed upstream commit: `9383193eb80d5f1cf57d8e0d19ff749f9870fa7a`. +The [r2 snapshot](atem-session-v1-r2.md) is unchanged. Atem has since published +r5; see the [current review](atem-session-v1-r5-review.md) and +[current snapshot](atem-session-v1.md). The [r1 review](atem-session-v1-review.md) +is also retained for context. The findings below describe r2. + +R2 addresses the main r1 concerns: authenticated principals, attachment fencing, +one target binding, host-computed canonical digests, target-scoped receipts, +durable dispatch boundaries, monotonic leases, registry revisions, and bounded +representations. Accept these choices as the shared design direction. The +following details still need agreement and fixtures before the wire contract +is frozen. Account-wide trust and browser activation remain gated by section 9. + +## 1. Apply one session authority across legacy and upgraded connections + +Section 0 rejects legacy mutations only on an upgraded connection while leaving +non-upgraded connections unchanged. That allows a second authenticated legacy +connection to evade a lease held through the upgraded connection. Upgrade state +is not the session's control authority. + +Once a session uses session-v1 control, every remote mutation of that session +must pass the same host authority, regardless of socket, transport, or message +family. Reject incompatible legacy operations against that session, including +unscoped requests that cannot identify a safe target. Alternatively, switch the +whole host into the new control mode. Preserve legacy compatibility only where +it cannot compete with or bypass the new authority. + +Add a two-connection fixture: an upgraded controller acquires a session; a +non-upgraded connection attempts agentInput, voice, terminal control, resize, +stop, and chat submission. Assert no writes, dispatches, resize, or stop. Local +terminal input continues to revoke remote authority first. + +## 2. Specify renewal reply correlation and the client deadline + +Section 5 matches replies by attachment, target, and epoch. These values remain +the same across renewals, so they do not distinguish an old or duplicate reply +from the current renewal. Matching the binding alone must not extend control. + +Require `re` to match one outstanding acquire/renew request and consume that +response once. Derive a conservative deadline from the request's monotonic send +time, bounded by the returned remaining lifetime and authorization deadline. +Do not set a fresh 15-second deadline from response arrival. Repeated acquire +must not extend the existing deadline. + +Before applying a renewal reply, check the previously acknowledged local +deadline. If it already expired, discard the reply, become a viewer, and require +explicit acquisition. This check must work even if the UI timer never fired. + +Fixtures: acquire at t=0, renew at t=5, reply at t=17 without any timeout tick; +duplicate an earlier reply while a later renewal is pending; return a repeated +acquire response near expiry. None may resurrect or silently extend control. +The initial native compatibility client has a regression for the first case. + +## 3. Complete registry snapshot, restart, and visibility rules + +Section 4 scopes revisions to runtime generation but also requires replacement +snapshots to have a revision at least as high as the last applied revision. +Apply that comparison only within the same runtime generation. A new accepted +runtime starts a fresh revision domain; a snapshot at revision 1 must replace +the old runtime's revision 100. Delayed snapshots from an older runtime must +never switch the client back. + +Define when subscription begins and serialize snapshot delivery with subsequent +changes. Buffer/reconcile changes during resynchronization so a late snapshot +cannot overwrite a newer applied state. Define duplicate changes as harmless +or explicitly requiring a resync, with no state resurrection in either case. + +Filtering also needs a revision rule. If the global revision advances for a +session the attachment cannot see, a later visible change creates a gap. Use +an attachment-visible sequence/checkpoint or explicitly define safe relisting +for filtered gaps. A ten-minute tombstone is not a substitute for ordering and +runtime fencing. + +Fixtures: old runtime revision 100 followed by a new runtime snapshot at 1; +an old-runtime snapshot delivered afterward; snapshot/change reordering; hidden +session changes between visible updates; a late update after a tombstone. + +## 4. Freeze complete terminal/chat wire examples and receipt states + +Section 2 says terminal/chat carry a top-level binding but lacks an exact full +envelope showing its location. Include fixtures for terminal subscribe, input, +resize, and stop, plus chat open and submit. Specify which legacy generation +field is being compared when chat operations nest their payload. All decoders +must agree whether binding is inside `data`, inside a chat payload, or beside +the outer envelope. Use one unambiguous encoding for each family. + +Section 6 should explicitly enumerate the five receipt states (`accepted`, +`executing`, `completed`, `rejected`, `outcome_unknown`) and legal transitions. +Make the handling of nonterminal accepted work after a crash distinct from +replaying an operation whose side effect may have started. Receipts, response +IDs, and original target/kind remain immutable through recovery. + +## 5. Finish cross-language and failure fixtures + +The five provided digest vectors have consistent canonical strings and SHA-256 +values. They are useful starting points, not proof of full RFC 8785 compliance. +Add UTF-16 property-order cases (including supplementary Unicode), required +escaping and invalid Unicode, safe-integer boundaries, and a raw duplicate-key +case that each parser must reject before information is lost. + +Complete the listed negative scenarios, plus the cases above. Add receipt-query +authorization, changed-target request-ID reuse, interrupted durable writes, +crash after executing, trust change before first dispatch, local reclaim at +the PTY boundary, and old attachment/runtime input. Fixtures must assert the +absence of side effects and disclosure, not just an error reply. + +The fixture schema also needs a defined initial host account/device/trust and +runtime context, a deterministic clock, and a way to deliver/reorder delayed +responses. Setup must not derive identity or permissions from untrusted message +fields. Run the same fixtures against Rust, Swift, and browser implementations; +the two sample scenarios are not yet a passing integration suite. + +## Security and implementation status + +Section 9 remains a release gate for account-wide access, resume, independent +browser clients, and relay confidentiality. Legacy session authentication and +retained pins do not supply live possession of verified endpoint keys or an +encrypted channel. Relay account identifiers, presence, and timestamps cannot +establish native membership or refresh trust. + +Correction: the original implementation paragraph referred to an unpublished, +uncommitted Atem experiment based on `10d3ea874ef25347dd4447fa27e7c64654785a13`. +It did not describe an Atem GitHub commit or release. That work has stopped; +all Atem implementation belongs to the Atem agent, whose lifecycle work is +reported on `feat/status-pair-verify`. The experiment's blocked chat-submit +path is not claimed as behavior of the owner's build. See the +[exact provenance and optional diff](atem-implementation-handoff.md). diff --git a/designs/protocol/atem-session-v1-r2.md b/designs/protocol/atem-session-v1-r2.md new file mode 100644 index 0000000..cd4df5e --- /dev/null +++ b/designs/protocol/atem-session-v1-r2.md @@ -0,0 +1,278 @@ +# atem-session v1: sessions, control leases and receipts (DRAFT r2 for joint review) + +Status: Atem-side draft r2, 2026-10-15. r1 (`7a0c691`) was reviewed by Astation +([review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-review.md), +summarised in [session-multiplexing.md](../session-multiplexing.md) §12); r2 +answers review items 1–7 and 9. Item 8 (trust, freshness, end-to-end channel, +QR grants) stays an implementation gate (§9). Not final until both sides +accept it and the fixtures in `fixtures/` pass in Rust, Swift and the browser. + +## 0. Layering and activation + +``` ++-----------------------------------------------------------------+ +| atem-session v1 (this doc): registry, leases, receipts | +| terminal-v1 / chat-v1 streams, bound to this doc's targets | ++-----------------------------------------------------------------+ +| secure channel (§9 gate): endpoint keys, E2EE, freshness | ++-----------------------------------------------------------------+ +| transport: direct ws :8080 | relay (atem_id + connection_id) | ++-----------------------------------------------------------------+ +``` + +- Direct and relay are thin transports; messages are identical on both and the + host deduplicates across them. +- **Compatibility mode** (until §9 is implemented): the messages run inside + today's authenticated Astation socket for isolated tests and for the one + Astation already verified with this host. In this mode: no resume (§3), no + browser clients, no account-wide native access, and no claim of relay + confidentiality. Admission fails closed; no placeholder trust decision is + exposed as production authorization. +- **Upgrade**: a connection is "upgraded" once `welcome` is received. On an + upgraded connection this contract's lease is the only control lifecycle: + terminal-v1 `control`, legacy `agentInput`, `agentPrompt`, voice commands, + `userCommand` and chat-v1 `submit` without a session binding are rejected with + `error{code:"forbidden"}` (legacy messages stay as today on non-upgraded + connections). + +## 1. Representation + +| Item | Rule | +|---|---| +| Envelope | `{"type":"session","data":{"v":1,"op":"…","id":"", …}}`; replies carry `re` | +| UUID | lowercase canonical v4 (`8-4-4-4-12`), validated | +| `session_id` | `ses-` + exactly 12 chars `[a-z2-7]` | +| Opaque ids (`account_id`, `atem_device_id`, `client_device_id`) | 1–128 bytes, `[A-Za-z0-9._:-]` | +| Counters (`host_trust_gen`, `lease_epoch`, `revision`) | JSON integers 0 ≤ n ≤ 2^53−1; a value that would exceed it is refused (`error{code:"unsupported_version"}` for peers, fatal for the host's own counters: it stops admitting and asks for re-pairing) — never wraps | +| Times | integer milliseconds since the Unix epoch; **display/estimation only** — authority uses monotonic clocks (§5) | +| Digest | SHA-256, lowercase hex, 64 chars | +| Bounds | message ≤ 1 MiB after decoding; `payload` ≤ 64 KiB canonical; prompt text ≤ 32 768 bytes; `receipt_query.request_ids` ≤ 64; `detail` ≤ 512 bytes; ≤ 64 in-flight requests per attachment (excess → `busy`) | +| Unknown fields | ignored; unknown `v` → `unsupported_version` | +| Parsing | bounds checked before allocation; duplicate object keys, non-integer numbers where integers are required, and out-of-range values are rejected | + +## 2. Identities, target and binding + +| Field | Meaning | +|---|---| +| `account_id` | account owning the data | +| `atem_device_id` | this Atem device/profile | +| `host_trust_gen` | increments durably on unpair/rebind; older values rejected | +| `runtime_gen` | UUID, new each broker (runtime) start | +| `session_id` | one agent session, stable for its life | +| `process_gen` | UUID, one agent process; replacement/resume = new value | +| `controller_session` | UUID, logical controller identity assigned by the host | +| `attachment_gen` | UUID, one live attachment of a controller to the host; new on every connect/resume; fences the previous one | +| `lease_epoch` | per session, increments on every grant, transfer, expiry, release, revoke | + +Every request that targets a session carries one **binding** object: + +```json +{"target":{"account_id":"account-a","atem_device_id":"host-a","host_trust_gen":1, + "session_id":"ses-abcdefghijkl","process_gen":"22222222-2222-4222-8222-222222222222"}, + "attachment_gen":"33333333-3333-4333-8333-333333333333", + "lease_epoch":7} +``` + +- Session ops (§4–§6) carry it as `binding`. terminal-v1 and chat-v1 messages on + an upgraded connection carry it as a top-level `binding` field next to their + existing fields (`terminal_id`/`agentId` must equal `target.session_id`, + `generation` must equal `target.process_gen`). +- `lease_epoch` is required for mutations, absent for reads. +- Any mismatch → `error{code:"stale_target"}` with **no** current values; the + client re-lists (§4). Errors never reveal another target's ids, paths or + receipts; an unauthorized or unknown session is `unknown_session` either way. + +## 3. Hello, attachment and resume + +| op | dir | fields | +|---|---|---| +| `hello` | C→H | `resume?: {controller_session}` | +| `challenge` | H→C | `nonce` (32 bytes, base64url) — only when resuming | +| `resume_proof` | C→H | `proof` (§9: signature by the client's authenticated key over the transcript) | +| `welcome` | H→C | `account_id`, `atem_device_id`, `host_trust_gen`, `runtime_gen`, `controller_session`, `attachment_gen`, `client_kind` (`native`/`browser`, as authenticated), `permissions` (`view`, `control`, `approve`), `authorization_ms_left`, `server_time` | + +- The client's device and kind come from the authenticated channel, never from + `hello` fields or relay labels. A `controller_session` UUID is not a credential. +- Resume needs key possession over a fresh `challenge` and yields a **new** + `attachment_gen`; the host fences the old attachment (closes it and rejects + anything still arriving on it) before accepting mutations on the new one. + Resume never re-acquires a lease and never revives an expired one. +- Compatibility mode: no `challenge`/`resume_proof`; every `hello` gets a new + `controller_session` and `attachment_gen` and holds no lease. +- A broker restart (new `runtime_gen`) invalidates all attachments and leases; + workers and receipts stay addressable by `session_id`/`process_gen`. + +## 4. Session registry + +| op | dir | fields | +|---|---|---| +| `list` | C→H | — | +| `sessions` | H→C | `runtime_gen`, `revision`, `sessions: [descriptor]` | +| `session_changed` | H→C | `runtime_gen`, `revision`, `descriptor` | + +Descriptor: + +```json +{"session_id":"ses-abcdefghijkl","process_gen":"…","provider":"claude", + "cwd_label":"Atem","state":"running","activity":"ready","activity_since":1760500000000, + "controller":{"controller_session":"…","lease_epoch":7}|null, + "capabilities":["terminal-v1","chat-v1"],"created_at":1760490000000} +``` + +- `state`: `running`, `exited`, `interrupted`, `recovery_required`, `closed`, + `removed` (tombstone). `activity`: `ready`, `working`, `waiting`, `unknown`. +- `revision` is scoped to `runtime_gen`, starts at 1, and increases by exactly 1 + per `session_changed`. Clients: apply a `session_changed` only if + `revision == last + 1` (same `runtime_gen`); on a gap, a new `runtime_gen` or + out-of-order delivery, send `list` and replace state with a snapshot whose + `revision` ≥ the last applied one; discard older snapshots. A removed session + is sent as `state:"removed"` and kept as a tombstone for 10 minutes so a late + update can't resurrect it. +- The registry and every response are filtered by the attachment's view + permission. `cwd_label` is the last path component only; clients show + `session_id` when labels repeat. Presence is never liveness evidence. + +## 5. Control lease + +| op | dir | fields | result | +|---|---|---|---| +| `acquire` | C→H | `binding` (no `lease_epoch`), `takeover` | `lease` or `error` | +| `renew` | C→H | `binding` | `lease` or `error` | +| `release` | C→H | `binding` | `lease_released` | +| `lease` | H→C | `binding` (with the current `lease_epoch`), `ms_left` | — | +| `lease_changed` | H→viewers | `session_id`, `process_gen`, `controller` (or null), `lease_epoch`, `reason` (`granted`, `takeover`, `local_input`, `expired`, `released`, `revoked`, `process_ended`) | — | + +Normative rules: + +- **Duration**: a lease lasts 15 s from its grant/renewal on the host's + monotonic clock, capped by the attachment's remaining authorization. Clients + renew every 5 s and never after their own monotonic estimate of expiry. + `ms_left` is for estimation only. +- **Fencing**: a lease is held by one (`controller_session`, `attachment_gen`). + Only a `lease` reply whose binding matches the client's current attachment, + target and epoch advances its local deadline; any other reply is ignored. +- **Renew** after expiry → `stale_lease` (it never re-acquires). +- **Repeat acquire** by the current holder on the same attachment returns the + current lease unchanged (same epoch, no extension); on another attachment of + the same controller → `stale_lease` (resume first). +- **Takeover** (`takeover: true`, needs `control`) moves the lease to the caller + with a new epoch; the previous holder gets `lease_changed{reason:"takeover"}` + and stays a viewer. Without takeover a held lease → `lease_held`. +- **Local input wins**: local terminal input on the host revokes any remote lease + (new epoch, `reason:"local_input"`) before that input is written. +- **Input epoch**: the worker's PTY writer checks the session's input epoch at + write time for every queued chunk. Transfer, expiry, release, local input and + revoke bump that epoch first, so already-queued remote bytes are dropped, not + written. Raw keystrokes are never replayed after reconnect. +- **Restart**: leases are not persisted. A worker restart (new `process_gen`) or + broker restart (new `runtime_gen`) invalidates them; the epoch counter itself + is persisted per session so it never repeats. +- **Scope**: the lease governs terminal input/resize/stop, chat submission, + interrupt, approval answers and future mutations. Disconnect stops renewal; + the lease then expires. + +## 6. Submissions and receipts + +| op | dir | fields | +|---|---|---| +| `submit` | C→H | `binding` (with `lease_epoch`), `request_id`, `kind`, `payload` | +| `receipt` | H→C | `request_id`, `target`, `kind`, `state`, `detail?`, `updated_at` | +| `receipt_query` | C→H | `binding` (no `lease_epoch`), `request_ids` (≤ 64) | +| `receipts` | H→C | `receipts: [receipt]` (only those bound to that exact target) | + +Kinds and payloads (v1): + +| kind | payload | completes when | +|---|---|---| +| `prompt` | `{"text": string}` | the provider confirms the turn ended (chat-v1 `complete`) | +| `interrupt` | `{}` | the provider reports the turn interrupted, or the turn was already over (`detail`) | +| `stop` | `{}` | the agent process exits | +| `approval` | reserved for v1.1 (needs an approval identity); rejected with `forbidden` + `detail:"approval answers are not supported in v1"` | + +- **Digest**: the host computes `payload_digest = sha256(JCS(payload))`, JCS = + RFC 8785 over the supported domain (objects, arrays, strings, booleans, null, + integers in the safe range; no floats). A client-supplied digest is not used. + Known-answer vectors: `fixtures/digest-vectors.json`. +- **Immutable request**: (`request_id`, `account_id`, `atem_device_id`, + `host_trust_gen`, `session_id`, `process_gen`, `kind`, `payload_digest`). + Same `request_id` with every bound value equal → the recorded receipt (needs + current view permission for the target; no lease, no re-execution). Same + `request_id` with any value different → `request_id_reused`. +- **Admission** of a new request needs the current lease (`stale_lease` + otherwise), `control` permission, and a session that accepts submissions. +- **Crash boundaries**: `accepted` is persisted (fsync) before the reply; + `executing` is persisted before the first external side effect (e.g. the + paste). After a crash: `accepted` work may still run under its original target + and only if the host trust generation is unchanged; otherwise it becomes + `rejected` with `detail:"host trust changed"`. `executing` work is reconciled + with the worker/provider; if that can't establish the outcome it becomes + `outcome_unknown`. Nothing is resubmitted automatically. +- Controller disconnect and lease expiry don't cancel accepted work. +- **Retention**: receipts live for the session's life and 7 days after it closes. + An accepting session's receipts are never evicted; at its limit new + submissions get `receipt_limit`. Closed sessions answer `session_closed`. +- chat-v1 on an upgraded connection: its `submit.requestId` is this + `request_id`, it carries `binding`, and its `accepted`/`complete` follow this + receipt's `accepted`/`completed`. + +## 7. Errors + +`error{re, code, detail?}`; `detail` is human-readable, bounded, and never +contains secrets, paths, raw OS errors or other targets' identifiers. + +| code | when | +|---|---| +| `unsupported_version` | unknown `v` or out-of-range representation | +| `unauthorized` | not authenticated, attachment fenced, or authorization expired | +| `forbidden` | missing permission, or a legacy op on an upgraded connection | +| `stale_target` | trust generation, session or process generation mismatch | +| `unknown_session` | unknown or not visible to this attachment | +| `lease_held` | another controller holds the lease (no takeover) | +| `stale_lease` | wrong, expired or other-attachment lease epoch | +| `request_id_reused` | same request id, different bound values | +| `receipt_limit` | the session's receipt journal is full | +| `session_closed` | the session no longer accepts submissions | +| `busy` | the agent or attachment can't take it now | +| `paused` | authorization freshness deadline passed; viewing/control paused | + +## 8. Fixtures + +One JSON file per scenario in `fixtures/`, schema `fixtures/schema.md`: + +```json +{"name":"lease-expires-without-renewal", + "setup":{"sessions":[{"session_id":"ses-aaaaaaaaaaaa","process_gen":"…"}],"clients":["c1"]}, + "steps":[ + {"send":"c1","json":{"type":"session","data":{"v":1,"op":"acquire",…}}}, + {"expect":"c1","match":{"op":"lease","binding.lease_epoch":1}}, + {"advance_ms":15001}, + {"expect_all":{"op":"lease_changed","reason":"expired"}}, + {"send":"c1","json":{…"op":"renew"…}}, + {"expect":"c1","match":{"op":"error","code":"stale_lease"}}, + {"assert":{"pty_writes":0,"dispatches":0}}]} +``` + +Actions: `send`, `expect` (structured partial match), `expect_none` (for a +duration), `advance_ms` (fake monotonic clock), `disconnect`, `resume`, +`local_input`, `crash_worker`, `restart_runtime`, `assert` (host-side counters: +PTY writes, dispatches, receipts visible). Negative scenarios assert both the +error and the absence of writes, dispatch, disclosure or resurrection. The +first set: hello/welcome, list/snapshot+revision, gap recovery, tombstone, +acquire/renew/expiry, repeat acquire, takeover, local input revocation, queued +bytes dropped on transfer, stale epoch, submit accepted→executing→completed, +duplicate submit, reused id (other target), crash after executing → +outcome_unknown, trust change before dispatch → rejected, closed session, +receipt query scoped to target. + +## 9. Gate: trust, freshness, channel and browser grants (joint) + +Must be specified jointly, implemented and tested before account-wide native +access, resume, browser clients or relay confidentiality are enabled: trust +anchor installation, signed native membership and bounded management +delegation, host acceptance of account-wide authority, unpair generation +commits, signed revocation ordering, authenticated freshness proofs (the 24 h +window starts at a validated authority proof, never at relay presence, +reconnection, an old signature or a relay timestamp; rollback protection and +conservative restart/clock handling), channel key establishment with +replay/downgrade protection binding endpoint keys, account, roles, permissions +and generations, the `resume_proof` format, and QR grant binding. diff --git a/designs/protocol/atem-session-v1-r3-review.md b/designs/protocol/atem-session-v1-r3-review.md new file mode 100644 index 0000000..e49cf65 --- /dev/null +++ b/designs/protocol/atem-session-v1-r3-review.md @@ -0,0 +1,85 @@ +# Astation Review of atem-session v1 r3 + +Date: 2026-10-15 + +Reviewed upstream commit: `1bf805c021acc156600d43e9ac368ecd2e9a0717`. +The [r3 snapshot](atem-session-v1-r3.md) is unchanged. R4 addresses all three +findings below; see the [current review](atem-session-v1-r5-review.md) and +[current snapshot](atem-session-v1.md). The [r2 review](atem-session-v1-r2-review.md) +is retained for context. The findings below describe r3. + +R3 addresses the main binding, reply-correlation, runtime-fencing, receipt-state, +and canonical-encoding concerns. Accept those choices as the basis for the +compatibility implementation. Three specific corrections remain before the +contract and fixtures are frozen; section 9 remains the security release gate. + +## 1. Keep legacy unscoped input disabled for a managed session + +Section 0 now shares one authority across connections, but permits legacy +unscoped mutations whenever no remote holder exists. After local input revokes +a lease, an old socket can immediately send agentInput or voice without an +explicit control acquisition. The same bypass becomes available after release +or expiry. This does not satisfy the approved rule that remote mutations need +the session's current control authority and reconnect cannot regain control. + +Once a session uses the new authority, reject unscoped legacy mutations against +it whether or not a remote holder currently exists. Legacy terminal `control` +can map to the same explicit acquire/release authority, provided permission, +attachment, exact target, stream, generation, and deadline checks still apply. +Legacy behavior may remain for sessions that have never entered the managed +mode, without creating an alternate mutation path to a managed session. + +Extend the legacy fixture to send unscoped mutations after local reclaim, +explicit release, and expiry. Assert no remote bytes or dispatches until a new +explicit acquisition succeeds. Local input remains available throughout. + +## 2. Make the legacy takeover fixture executable without elided setup + +`legacy-connection-cannot-bypass-lease.json` uses stream `s` for resize, stop, +and control without establishing that subscription. Its note says a missing +subscription is forbidden, but the later control step expects a successful +takeover. A real host cannot satisfy both expectations. + +Add an explicit subscription and capture its attached stream ID for later +messages, or define a deterministic subscription in fixture setup. The schema +must define any captured-value substitution. Do not omit state that is needed +to authorize the successful part of a machine-checkable scenario. Keep the +invalid lease/epoch attempts and assert their exact errors as well as no side +effects. + +## 3. Align the repeated-acquire fixture with the deadline formula + +The final step of `late-renew-reply-does-not-resurrect-control.json` sends at +31000 ms and receives `ms_left: 500`. Section 5's formula gives a conservative +deadline of 31500 ms, but the fixture expects the old deadline of 32000 ms. +Its note explicitly keeps that old value. The normative rule and the test +currently disagree. + +Use `min(previous_deadline, request_send_time + ms_left, +authorization_deadline)` for a repeated acquisition while already holding +control. This never extends the prior deadline and honors a shorter remaining +lifetime. Update that fixture to 31500 ms, and assert that input is disabled +when it expires. Explicit acquisition after expiry starts a new operation; +it must not be confused with a delayed reply restoring expired control. + +## Validation and remaining work + +All ten provided digest vectors have matching canonical strings and SHA-256 +hashes, including UTF-16 key ordering. This is an independent vector-content +check; shared Rust/Swift/browser parser and scenario execution remains pending. +The raw rejection cases must still be consumed by those implementations. + +Add `runtime_epoch` and `seq` to the representation table's bounded counters, +keep a fixed runtime UUID for each accepted epoch, and request a snapshot before +using changes from a newly accepted runtime. These are implementation details +to cover alongside the existing registry fixture, not a change to the approved +registry model. + +Astation's optional `terminal-lease-v1` client work remains separate from full +session-v1 activation. All Atem implementation belongs to the Atem agent; the +[handoff](atem-implementation-handoff.md) corrects the earlier unpublished +experiment's provenance. Browser/native prompt integration must preserve the +owner's existing functionality while enforcing the shared session authority. +Account-wide native authority, resume, independent browser grants, and relay +confidentiality remain gated until section 9 is jointly specified, implemented, +and tested. diff --git a/designs/protocol/atem-session-v1-r3.md b/designs/protocol/atem-session-v1-r3.md new file mode 100644 index 0000000..4352691 --- /dev/null +++ b/designs/protocol/atem-session-v1-r3.md @@ -0,0 +1,368 @@ +# atem-session v1: sessions, control leases and receipts (DRAFT r3 for joint review) + +Status: Atem-side draft r3, 2026-10-15. r1 (`7a0c691`) and r2 (`9383193`) were +reviewed by Astation ([r1 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-review.md), +[r2 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r2-review.md), +summarised in [session-multiplexing.md](../session-multiplexing.md) §12). r2 +answered r1 items 1–7 and 9; r3 answers r2 review items 1–5. Item 8 (trust, freshness, end-to-end channel, +QR grants) stays an implementation gate (§9). Not final until both sides +accept it and the fixtures in `fixtures/` pass in Rust, Swift and the browser. + +## 0. Layering and activation + +``` ++-----------------------------------------------------------------+ +| atem-session v1 (this doc): registry, leases, receipts | +| terminal-v1 / chat-v1 streams, bound to this doc's targets | ++-----------------------------------------------------------------+ +| secure channel (§9 gate): endpoint keys, E2EE, freshness | ++-----------------------------------------------------------------+ +| transport: direct ws :8080 | relay (atem_id + connection_id) | ++-----------------------------------------------------------------+ +``` + +- Direct and relay are thin transports; messages are identical on both and the + host deduplicates across them. +- **Compatibility mode** (until §9 is implemented): the messages run inside + today's authenticated Astation socket for isolated tests and for the one + Astation already verified with this host. In this mode: no resume (§3), no + browser clients, no account-wide native access, and no claim of relay + confidentiality. Admission fails closed; no placeholder trust decision is + exposed as production authorization. +- **One session authority, every connection.** Each session has exactly one + lease authority on the host (§5), whatever socket, transport or message family + a request uses. A connection is "upgraded" once it received `welcome`; upgrade + changes how a client addresses things, not who may mutate a session: + - terminal-v1 `control{take:true}` from a non-upgraded connection is an + `acquire{takeover:true}` by that connection's attachment on the same + authority; `control{take:false}` is `release`. Its `lease_id` maps to that + lease. + - Legacy unscoped mutations (`agentInput`, `agentPrompt`, voice commands, + `userCommand`, chat-v1 `submit` without `binding`) are rejected + (`forbidden`, nothing written or dispatched) whenever the session they would + route to has a remote lease holder, and they never acquire one. With no + holder they behave as today (routed per Atem's default-session rule). + - On an upgraded connection every mutation must carry `binding` (§2) and the + current lease; legacy unscoped mutations there are always `forbidden`. + - Local terminal input on the host revokes any remote lease first (§5). + Fixture `legacy-connection-cannot-bypass-lease`. + +## 1. Representation + +| Item | Rule | +|---|---| +| Envelope | `{"type":"session","data":{"v":1,"op":"…","id":"", …}}`; replies carry `re` | +| UUID | lowercase canonical v4 (`8-4-4-4-12`), validated | +| `session_id` | `ses-` + exactly 12 chars `[a-z2-7]` | +| Opaque ids (`account_id`, `atem_device_id`, `client_device_id`) | 1–128 bytes, `[A-Za-z0-9._:-]` | +| Counters (`host_trust_gen`, `lease_epoch`, `revision`) | JSON integers 0 ≤ n ≤ 2^53−1; a value that would exceed it is refused (`error{code:"unsupported_version"}` for peers, fatal for the host's own counters: it stops admitting and asks for re-pairing) — never wraps | +| Times | integer milliseconds since the Unix epoch; **display/estimation only** — authority uses monotonic clocks (§5) | +| Digest | SHA-256, lowercase hex, 64 chars | +| Bounds | message ≤ 1 MiB after decoding; `payload` ≤ 64 KiB canonical; prompt text ≤ 32 768 bytes; `receipt_query.request_ids` ≤ 64; `detail` ≤ 512 bytes; ≤ 64 in-flight requests per attachment (excess → `busy`) | +| Unknown fields | ignored; unknown `v` → `unsupported_version` | +| Parsing | bounds checked before allocation; duplicate object keys, non-integer numbers where integers are required, and out-of-range values are rejected | + +## 2. Identities, target and binding + +| Field | Meaning | +|---|---| +| `account_id` | account owning the data | +| `atem_device_id` | this Atem device/profile | +| `host_trust_gen` | increments durably on unpair/rebind; older values rejected | +| `runtime_gen` | UUID, new each broker (runtime) start | +| `session_id` | one agent session, stable for its life | +| `process_gen` | UUID, one agent process; replacement/resume = new value | +| `controller_session` | UUID, logical controller identity assigned by the host | +| `attachment_gen` | UUID, one live attachment of a controller to the host; new on every connect/resume; fences the previous one | +| `lease_epoch` | per session, increments on every grant, transfer, expiry, release, revoke | + +Every request that targets a session carries one **binding** object: + +```json +{"target":{"account_id":"account-a","atem_device_id":"host-a","host_trust_gen":1, + "session_id":"ses-abcdefghijkl","process_gen":"22222222-2222-4222-8222-222222222222"}, + "attachment_gen":"33333333-3333-4333-8333-333333333333", + "lease_epoch":7} +``` + +- Session ops (§4–§6) carry it as `data.binding`. terminal-v1 and chat-v1 + carry it as `data.binding` too — inside `data`, next to the family's own + fields, never inside a nested payload and never beside the outer envelope. + Their own ids must agree with it: terminal-v1 `terminal_id` = chat-v1 + `agentId` = `binding.target.session_id`; terminal-v1 `generation` = chat-v1 + `generation` (both are the agent process's generation) = + `binding.target.process_gen`. A disagreement is `stale_target`. Exact + envelopes: + +```json +{"type":"terminal","data":{"v":1,"op":"subscribe","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-2222-4222-8222-222222222222", + "binding":{"target":{…},"attachment_gen":"33333333-…"}}} +{"type":"terminal","data":{"v":1,"op":"input","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…","sequence":0,"bytes":"aGkN", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"terminal","data":{"v":1,"op":"resize","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…","rows":40,"cols":120, + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"terminal","data":{"v":1,"op":"stop","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"agentChat","data":{"v":1,"op":"open","conversationId":"…","agentId":"ses-aaaaaaaaaaaa", + "binding":{"target":{…},"attachment_gen":"33333333-…"}}} +{"type":"agentChat","data":{"v":1,"op":"submit","conversationId":"…","agentId":"ses-aaaaaaaaaaaa","generation":"22222222-…", + "requestId":"44444444-…","text":"run the tests", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +``` + + On upgraded connections the terminal-v1 `lease_id` field is not used (the + binding's epoch replaces it); a chat `submit` is the §6 `submit` with + `kind:"prompt"`, `request_id = requestId`, `payload = {"text": text}`. +- `lease_epoch` is required for mutations, absent for reads. +- Any mismatch → `error{code:"stale_target"}` with **no** current values; the + client re-lists (§4). Errors never reveal another target's ids, paths or + receipts; an unauthorized or unknown session is `unknown_session` either way. + +## 3. Hello, attachment and resume + +| op | dir | fields | +|---|---|---| +| `hello` | C→H | `resume?: {controller_session}` | +| `challenge` | H→C | `nonce` (32 bytes, base64url) — only when resuming | +| `resume_proof` | C→H | `proof` (§9: signature by the client's authenticated key over the transcript) | +| `welcome` | H→C | `account_id`, `atem_device_id`, `host_trust_gen`, `runtime_gen`, `controller_session`, `attachment_gen`, `client_kind` (`native`/`browser`, as authenticated), `permissions` (`view`, `control`, `approve`), `authorization_ms_left`, `server_time` | + +- The client's device and kind come from the authenticated channel, never from + `hello` fields or relay labels. A `controller_session` UUID is not a credential. +- Resume needs key possession over a fresh `challenge` and yields a **new** + `attachment_gen`; the host fences the old attachment (closes it and rejects + anything still arriving on it) before accepting mutations on the new one. + Resume never re-acquires a lease and never revives an expired one. +- Compatibility mode: no `challenge`/`resume_proof`; every `hello` gets a new + `controller_session` and `attachment_gen` and holds no lease. +- A broker restart (new `runtime_gen`) invalidates all attachments and leases; + workers and receipts stay addressable by `session_id`/`process_gen`. + +## 4. Session registry + +| op | dir | fields | +|---|---|---| +| `list` | C→H | — | +| `sessions` | H→C | `runtime_gen`, `runtime_epoch`, `seq`, `sessions: [descriptor]` | +| `session_changed` | H→C | `runtime_gen`, `runtime_epoch`, `seq`, `descriptor` | + +Descriptor: + +```json +{"session_id":"ses-abcdefghijkl","process_gen":"…","provider":"claude", + "cwd_label":"Atem","state":"running","activity":"ready","activity_since":1760500000000, + "controller":{"controller_session":"…","lease_epoch":7}|null, + "capabilities":["terminal-v1","chat-v1"],"created_at":1760490000000} +``` + +- `state`: `running`, `exited`, `interrupted`, `recovery_required`, `closed`, + `removed`. `activity`: `ready`, `working`, `waiting`, `unknown`. +- **Runtime fencing.** `runtime_epoch` is a host-persisted counter incremented + at every broker start (with `runtime_gen` its UUID). A client accepts a + snapshot or change only if its `runtime_epoch` ≥ the highest it has accepted + from this host; a higher epoch replaces all state (a snapshot at `seq` 1 of + epoch 8 replaces `seq` 100 of epoch 7); anything from a lower epoch is + discarded, so a delayed old-runtime snapshot can never switch the client back. +- **Per-attachment sequence.** `list` subscribes the attachment. The host then + sends, on that attachment's single ordered stream, one `sessions` snapshot + followed only by `session_changed` events. `seq` counts the messages *this + attachment* received in this epoch (snapshot = n, next change = n+1, …), so + changes to sessions it can't see never create gaps. Within an epoch the client + applies a change only if `seq == last + 1`; `seq ≤ last` is a duplicate and is + ignored; `seq > last + 1` is a gap: it sends `list` and, until the new + snapshot arrives, discards every change with `seq` ≤ that snapshot's `seq`. + A snapshot whose `seq` is lower than the last applied one (same epoch) is + discarded. +- **Removal.** A removed session is sent as `state:"removed"`; applying it + deletes the session, and a later change for that `session_id` is only accepted + with a higher `seq` in the same epoch and a different `process_gen` — never + as a resurrection of the removed one. (Tombstones are kept 10 minutes host-side + only to answer late requests with `unknown_session`.) +- The registry and every response are filtered by the attachment's view + permission. `cwd_label` is the last path component only; clients show + `session_id` when labels repeat. Presence is never liveness evidence. + +## 5. Control lease + +| op | dir | fields | result | +|---|---|---|---| +| `acquire` | C→H | `binding` (no `lease_epoch`), `takeover` | `lease` or `error` | +| `renew` | C→H | `binding` | `lease` or `error` | +| `release` | C→H | `binding` | `lease_released` | +| `lease` | H→C | `binding` (with the current `lease_epoch`), `ms_left` | — | +| `lease_changed` | H→viewers | `session_id`, `process_gen`, `controller` (or null), `lease_epoch`, `reason` (`granted`, `takeover`, `local_input`, `expired`, `released`, `revoked`, `process_ended`) | — | + +Normative rules: + +- **Duration**: a lease lasts 15 s from its grant/renewal on the host's + monotonic clock, capped by the attachment's remaining authorization. Clients + renew every 5 s and never after their own monotonic estimate of expiry. + `ms_left` is for estimation only. +- **Fencing**: a lease is held by one (`controller_session`, `attachment_gen`). +- **Reply correlation and the client deadline** (normative for clients): + 1. A `lease` reply is applied only if its `re` equals one outstanding + `acquire`/`renew` request id, and each outstanding request consumes at most + one reply; replies with an unknown, already consumed or duplicate `re` are + dropped, even when the binding matches. + 2. Before applying it, the client checks its previously acknowledged local + deadline on the monotonic clock; if that deadline has passed (whether or not + a UI timer fired), the reply is dropped, the client becomes a viewer, and only + an explicit new `acquire` can regain control. + 3. Otherwise the new deadline = the request's monotonic **send** time + + `ms_left` from the reply (never the reply's arrival time + 15 s), and never + later than the authorization deadline. + 4. A repeat `acquire` by the current holder returns `ms_left` of the existing + lease; it never extends the deadline. + Fixture `late-renew-reply-does-not-resurrect-control` (acquire at t=0, renew + at t=5000, reply delivered at t=17000 with no timer tick; a duplicate earlier + reply while a later renew is pending; a repeat-acquire reply near expiry). +- **Renew** after expiry → `stale_lease` (it never re-acquires). +- **Repeat acquire** by the current holder on the same attachment returns the + current lease unchanged (same epoch, no extension); on another attachment of + the same controller → `stale_lease` (resume first). +- **Takeover** (`takeover: true`, needs `control`) moves the lease to the caller + with a new epoch; the previous holder gets `lease_changed{reason:"takeover"}` + and stays a viewer. Without takeover a held lease → `lease_held`. +- **Local input wins**: local terminal input on the host revokes any remote lease + (new epoch, `reason:"local_input"`) before that input is written. +- **Input epoch**: the worker's PTY writer checks the session's input epoch at + write time for every queued chunk. Transfer, expiry, release, local input and + revoke bump that epoch first, so already-queued remote bytes are dropped, not + written. Raw keystrokes are never replayed after reconnect. +- **Restart**: leases are not persisted. A worker restart (new `process_gen`) or + broker restart (new `runtime_gen`) invalidates them; the epoch counter itself + is persisted per session so it never repeats. +- **Scope**: the lease governs terminal input/resize/stop, chat submission, + interrupt, approval answers and future mutations. Disconnect stops renewal; + the lease then expires. + +## 6. Submissions and receipts + +| op | dir | fields | +|---|---|---| +| `submit` | C→H | `binding` (with `lease_epoch`), `request_id`, `kind`, `payload` | +| `receipt` | H→C | `request_id`, `target`, `kind`, `state`, `detail?`, `updated_at` | +| `receipt_query` | C→H | `binding` (no `lease_epoch`), `request_ids` (≤ 64) | +| `receipts` | H→C | `receipts: [receipt]` (only those bound to that exact target) | + +Kinds and payloads (v1): + +| kind | payload | completes when | +|---|---|---| +| `prompt` | `{"text": string}` | the provider confirms the turn ended (chat-v1 `complete`) | +| `interrupt` | `{}` | the provider reports the turn interrupted, or the turn was already over (`detail`) | +| `stop` | `{}` | the agent process exits | +| `approval` | reserved for v1.1 (needs an approval identity); rejected with `forbidden` + `detail:"approval answers are not supported in v1"` | + +- **Digest**: the host computes `payload_digest = sha256(JCS(payload))`, JCS = + RFC 8785 over the supported domain (objects, arrays, strings, booleans, null, + integers in the safe range; no floats). A client-supplied digest is not used. + Known-answer vectors: `fixtures/digest-vectors.json`. +- **Immutable request**: (`request_id`, `account_id`, `atem_device_id`, + `host_trust_gen`, `session_id`, `process_gen`, `kind`, `payload_digest`). + Same `request_id` with every bound value equal → the recorded receipt (needs + current view permission for the target; no lease, no re-execution). Same + `request_id` with any value different → `request_id_reused`. +- **Admission** of a new request needs the current lease (`stale_lease` + otherwise), `control` permission, and a session that accepts submissions. +- **States and transitions** (all others are invalid and never happen): + +| from | to | when | +|---|---|---| +| — | `accepted` | admitted and durably recorded (fsync) — then the reply | +| — | `rejected` | refused at admission (nothing recorded as accepted) | +| `accepted` | `executing` | durably recorded before the first external side effect | +| `accepted` | `rejected` | before any side effect: session closed, trust generation changed, or the durable write of `executing` failed | +| `executing` | `completed` | the kind's completion (table above) observed | +| `executing` | `outcome_unknown` | crash/restart and reconciliation can't establish the outcome | + + `completed`, `rejected` and `outcome_unknown` are final. A receipt's + `request_id`, target, kind and digest never change; replies carry the same + values through recovery. +- **Crash boundaries**: `accepted` is persisted (fsync) before the reply; + `executing` is persisted before the first external side effect (e.g. the + paste). After a crash, two cases stay distinct: + `accepted` work has provably not started any side effect, so it may still be + dispatched — once, under its original target, and only if the host trust + generation is unchanged (else `rejected`, `detail:"host trust changed"`). + `executing` work may have started a side effect: it is never dispatched again; + it is reconciled with the worker/provider, and if that can't establish the + outcome it becomes `outcome_unknown`. +- Controller disconnect and lease expiry don't cancel accepted work. +- **Retention**: receipts live for the session's life and 7 days after it closes. + An accepting session's receipts are never evicted; at its limit new + submissions get `receipt_limit`. Closed sessions answer `session_closed`. +- chat-v1 on an upgraded connection: its `submit.requestId` is this + `request_id`, it carries `binding`, and its `accepted`/`complete` follow this + receipt's `accepted`/`completed`. + +## 7. Errors + +`error{re, code, detail?}`; `detail` is human-readable, bounded, and never +contains secrets, paths, raw OS errors or other targets' identifiers. + +| code | when | +|---|---| +| `unsupported_version` | unknown `v` or out-of-range representation | +| `unauthorized` | not authenticated, attachment fenced, or authorization expired | +| `forbidden` | missing permission, or a legacy op on an upgraded connection | +| `stale_target` | trust generation, session or process generation mismatch | +| `unknown_session` | unknown or not visible to this attachment | +| `lease_held` | another controller holds the lease (no takeover) | +| `stale_lease` | wrong, expired or other-attachment lease epoch | +| `request_id_reused` | same request id, different bound values | +| `receipt_limit` | the session's receipt journal is full | +| `session_closed` | the session no longer accepts submissions | +| `busy` | the agent or attachment can't take it now | +| `paused` | authorization freshness deadline passed; viewing/control paused | + +## 8. Fixtures + +One JSON file per scenario in `fixtures/`, schema `fixtures/schema.md`: + +```json +{"name":"lease-expires-without-renewal", + "setup":{"sessions":[{"session_id":"ses-aaaaaaaaaaaa","process_gen":"…"}],"clients":["c1"]}, + "steps":[ + {"send":"c1","json":{"type":"session","data":{"v":1,"op":"acquire",…}}}, + {"expect":"c1","match":{"op":"lease","binding.lease_epoch":1}}, + {"advance_ms":15001}, + {"expect_all":{"op":"lease_changed","reason":"expired"}}, + {"send":"c1","json":{…"op":"renew"…}}, + {"expect":"c1","match":{"op":"error","code":"stale_lease"}}, + {"assert":{"pty_writes":0,"dispatches":0}}]} +``` + +The setup fixes the host context (`account_id`, `atem_device_id`, +`host_trust_gen`, `runtime_gen`, `runtime_epoch`) and each client's +authenticated identity and permissions; nothing is derived from message fields. +Actions (deterministic clock, see `fixtures/schema.md`): `send`, `expect`, +`expect_none`, `advance_ms`, `hold`/`deliver` (delay and reorder a specific +message), `disconnect`, `resume`, `local_input`, `crash_worker`, +`restart_runtime`, `fail_next_fsync`, `change_trust_gen`, `assert` (host +counters: PTY writes, resizes, stops, dispatches, receipts visible per client; +client state for client-side fixtures: `controls`, `deadline_ms`, registry). Negative scenarios assert both the +error and the absence of writes, dispatch, disclosure or resurrection. The +first set (r3 adds the r2-review cases: two-connection lease bypass, late/duplicate +lease replies, runtime epoch snapshots, snapshot/change reordering, hidden-session +changes, change after removal, receipt-query authorization, changed-target id +reuse, failed durable write, local reclaim at the PTY boundary, old attachment +and old runtime input): hello/welcome, list/snapshot+revision, gap recovery, tombstone, +acquire/renew/expiry, repeat acquire, takeover, local input revocation, queued +bytes dropped on transfer, stale epoch, submit accepted→executing→completed, +duplicate submit, reused id (other target), crash after executing → +outcome_unknown, trust change before dispatch → rejected, closed session, +receipt query scoped to target. + +## 9. Gate: trust, freshness, channel and browser grants (joint) + +Must be specified jointly, implemented and tested before account-wide native +access, resume, browser clients or relay confidentiality are enabled: trust +anchor installation, signed native membership and bounded management +delegation, host acceptance of account-wide authority, unpair generation +commits, signed revocation ordering, authenticated freshness proofs (the 24 h +window starts at a validated authority proof, never at relay presence, +reconnection, an old signature or a relay timestamp; rollback protection and +conservative restart/clock handling), channel key establishment with +replay/downgrade protection binding endpoint keys, account, roles, permissions +and generations, the `resume_proof` format, and QR grant binding. diff --git a/designs/protocol/atem-session-v1-r4-review.md b/designs/protocol/atem-session-v1-r4-review.md new file mode 100644 index 0000000..6ac882a --- /dev/null +++ b/designs/protocol/atem-session-v1-r4-review.md @@ -0,0 +1,102 @@ +# Astation Review of atem-session v1 r4 + +Date: 2026-10-15 + +Reviewed upstream commit: `fd907ccf0492a02fd5fce08453eefda661597df5` on +`docs/atem-session-protocol`. +The [r4 snapshot](atem-session-v1-r4.md) is unchanged. R5 resolves all three +findings below; see the [current review](atem-session-v1-r5-review.md) and +[current snapshot](atem-session-v1.md). The findings below describe r4 protocol +and fixture contents. All Atem implementation belongs to the Atem agent. + +## Findings + +### 1. P2: Define a terminal receipt outcome for ordinary dispatch failures + +[Section 6, transition table](https://github.com/Agora-Build/Atem/blob/fd907ccf0492a02fd5fce08453eefda661597df5/designs/protocol/atem-session-v1.md#L284-L306) +requires durable `executing` before the first external side effect and forbids +every transition not listed. Its only exits from `executing` are successful +kind-specific completion and unresolved outcome after crash/restart. + +An ordinary dispatch error has neither exit. For example, after persisting +`executing`, a PTY enqueue/write can fail before writing any bytes, or a stop +operation can fail while the process stays alive. No provider turn ends and no +process exits; no crash/restart occurs. The receipt remains `executing` forever, +and a duplicate request only returns that state. A transport `error` alone does +not resolve the durable receipt. + +Specify and durably record the outcome for known failures as well as uncertain +partial dispatch. The contract can use an explicitly defined failure result or +allow an existing final state under precise conditions; it need not add a new +state. A rejection after `executing` is safe only with proof that no side effect +occurred. Uncertain dispatch must not be retried automatically. Add fixtures +for failure before any effect and failure after a possible partial effect, +including duplicate submission and recovery of the recorded outcome. + +### 2. P2: Make descriptor fixtures agree with the wire schema + +[The registry client fixture](https://github.com/Agora-Build/Atem/blob/fd907ccf0492a02fd5fce08453eefda661597df5/designs/protocol/fixtures/runtime-epoch-and-sequence-ordering.json#L37-L42) +delivers descriptors with only `session_id`, `process_gen`, and `state`, including +in its first two snapshots. The same abbreviation appears in changes. +[Section 4](https://github.com/Agora-Build/Atem/blob/fd907ccf0492a02fd5fce08453eefda661597df5/designs/protocol/atem-session-v1.md#L157-L167) +also lists `provider`, `cwd_label`, `activity`, `activity_since`, `controller`, +`capabilities`, and `created_at`, with no optional/default rules. The harness +schema defines `deliver_to_client` as delivering the supplied message; it does +not expand descriptor fields. + +A decoder requiring the documented descriptor cannot reach the ordering +assertions; one that silently fills fields uses defaults the other languages +need not share. Supply complete descriptors in executable wire fixtures, or +explicitly specify optional fields and their defaults in the protocol. If +`session_changed` is intended as a patch, define that separately from a full +snapshot descriptor. Run this fixture through actual wire decoders, not only +against an in-memory registry model. + +### 3. P3: Disable control at the newly computed expiry boundary + +[The late-renew fixture](https://github.com/Agora-Build/Atem/blob/fd907ccf0492a02fd5fce08453eefda661597df5/designs/protocol/fixtures/late-renew-reply-does-not-resurrect-control.json#L275-L288) +now correctly computes the repeated-acquire deadline as 31500 ms. However, +its reply arrives at exactly 31500 ms and the next assertion still requires +`controls: true`. Only after another millisecond does it assert disabled input. +There is already zero remaining lifetime when the reply is applied. + +Define the equality boundary explicitly (`now >= deadline` expires control), +and have the client disable input immediately when applying an already-expired +conservative deadline. Either expect disabled control/input at this step or +deliver the reply earlier and add a separate assertion at exactly 31500 ms. +This is a fixture and boundary-contract issue, not evidence of a shipped Atem +lease bug. + +## Resolved from r3 + +R4 addresses all three findings in the [r3 review](atem-session-v1-r3-review.md): + +- A session stays managed until it ends, so legacy unscoped input remains + forbidden after local reclaim, release, or expiry. +- The legacy fixture explicitly subscribes, captures its stream ID, and uses + schema-defined substitution. +- Repeated acquire uses the conservative minimum formula and the fixture's + expected deadline is now 31500 ms. Finding 3 concerns the remaining equality + assertion, not the previous arithmetic error. + +R4 also bounds `runtime_epoch` and `seq`, fixes one runtime UUID per accepted +epoch, and requires a snapshot before applying changes from a new runtime. + +## Validation and acceptance + +All six fixture JSON files parse with duplicate-key detection. All ten digest +vectors have matching canonical strings and SHA-256 hashes, including UTF-16 +key ordering. An independent fake-clock replay identifies the step at 31500 ms +where the lease assertion disagrees with expiry at zero remaining time. + +These are content checks, not Rust, Swift, or browser implementation test +results. The raw rejection vectors and lifecycle scenarios still need execution +against those implementations. No Atem code was changed, built, or tested for +this review. + +No new critical issue was found in the compatibility foundation. Resolve the +findings before freezing the contract and fixtures. Section 9 remains a release +gate: account-wide native access, resume, browser clients, and relay +confidentiality cannot be enabled until trust, freshness, authenticated channels, +and QR grants are jointly specified, implemented, and tested. The relay never +becomes authoritative for identity, trust, control, or encryption state. diff --git a/designs/protocol/atem-session-v1-r4.md b/designs/protocol/atem-session-v1-r4.md new file mode 100644 index 0000000..2cce416 --- /dev/null +++ b/designs/protocol/atem-session-v1-r4.md @@ -0,0 +1,384 @@ +# atem-session v1: sessions, control leases and receipts (DRAFT r4 for joint review) + +Status: Atem-side draft r4, 2026-10-15. r1 (`7a0c691`), r2 (`9383193`) and r3 +(`1bf805c`, [r3 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r3-review.md)) +were reviewed by Astation ([r1 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-review.md), +[r2 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r2-review.md), +summarised in [session-multiplexing.md](../session-multiplexing.md) §12). r2 +answered r1 items 1–7 and 9; r3 answered r2 review items 1–5; r4 answers the three r3 corrections. Item 8 (trust, freshness, end-to-end channel, +QR grants) stays an implementation gate (§9). Not final until both sides +accept it and the fixtures in `fixtures/` pass in Rust, Swift and the browser. + +## 0. Layering and activation + +``` ++-----------------------------------------------------------------+ +| atem-session v1 (this doc): registry, leases, receipts | +| terminal-v1 / chat-v1 streams, bound to this doc's targets | ++-----------------------------------------------------------------+ +| secure channel (§9 gate): endpoint keys, E2EE, freshness | ++-----------------------------------------------------------------+ +| transport: direct ws :8080 | relay (atem_id + connection_id) | ++-----------------------------------------------------------------+ +``` + +- Direct and relay are thin transports; messages are identical on both and the + host deduplicates across them. +- **Compatibility mode** (until §9 is implemented): the messages run inside + today's authenticated Astation socket for isolated tests and for the one + Astation already verified with this host. In this mode: no resume (§3), no + browser clients, no account-wide native access, and no claim of relay + confidentiality. Admission fails closed; no placeholder trust decision is + exposed as production authorization. +- **One session authority, every connection.** Each session has exactly one + lease authority on the host (§5), whatever socket, transport or message family + a request uses. A connection is "upgraded" once it received `welcome`; upgrade + changes how a client addresses things, not who may mutate a session: + - terminal-v1 `control{take:true}` from a non-upgraded connection is an + `acquire{takeover:true}` by that connection's attachment on the same + authority; `control{take:false}` is `release`. Its `lease_id` maps to that + lease. + - A session is **managed** from the moment any attachment first acquires its + lease (session `acquire` or legacy terminal-v1 `control`) until the session + ends; the flag never clears (not on release, expiry or local reclaim). + Legacy unscoped mutations (`agentInput`, `agentPrompt`, voice commands, + `userCommand`, chat-v1 `submit` without `binding`) routed to a managed + session are always rejected (`forbidden`, nothing written or dispatched), + whether or not a remote holder exists, and they never acquire. Sessions that + have never been managed keep today's legacy behaviour (routed per Atem's + default-session rule); legacy can't create a path into a managed session. + - Legacy terminal-v1 `control` keeps all its checks (permission, attachment, + exact `terminal_id`/`generation`, subscribed `stream_id`, deadline) before it + maps onto `acquire`/`release`. + - On an upgraded connection every mutation must carry `binding` (§2) and the + current lease; legacy unscoped mutations there are always `forbidden`. + - Local terminal input on the host revokes any remote lease first (§5). + Fixture `legacy-connection-cannot-bypass-lease`. + +## 1. Representation + +| Item | Rule | +|---|---| +| Envelope | `{"type":"session","data":{"v":1,"op":"…","id":"", …}}`; replies carry `re` | +| UUID | lowercase canonical v4 (`8-4-4-4-12`), validated | +| `session_id` | `ses-` + exactly 12 chars `[a-z2-7]` | +| Opaque ids (`account_id`, `atem_device_id`, `client_device_id`) | 1–128 bytes, `[A-Za-z0-9._:-]` | +| Counters (`host_trust_gen`, `lease_epoch`, `runtime_epoch`, `seq`) | JSON integers 0 ≤ n ≤ 2^53−1; a value that would exceed it is refused (`error{code:"unsupported_version"}` for peers, fatal for the host's own counters: it stops admitting and asks for re-pairing) — never wraps | +| Times | integer milliseconds since the Unix epoch; **display/estimation only** — authority uses monotonic clocks (§5) | +| Digest | SHA-256, lowercase hex, 64 chars | +| Bounds | message ≤ 1 MiB after decoding; `payload` ≤ 64 KiB canonical; prompt text ≤ 32 768 bytes; `receipt_query.request_ids` ≤ 64; `detail` ≤ 512 bytes; ≤ 64 in-flight requests per attachment (excess → `busy`) | +| Unknown fields | ignored; unknown `v` → `unsupported_version` | +| Parsing | bounds checked before allocation; duplicate object keys, non-integer numbers where integers are required, and out-of-range values are rejected | + +## 2. Identities, target and binding + +| Field | Meaning | +|---|---| +| `account_id` | account owning the data | +| `atem_device_id` | this Atem device/profile | +| `host_trust_gen` | increments durably on unpair/rebind; older values rejected | +| `runtime_gen` | UUID, new each broker (runtime) start | +| `session_id` | one agent session, stable for its life | +| `process_gen` | UUID, one agent process; replacement/resume = new value | +| `controller_session` | UUID, logical controller identity assigned by the host | +| `attachment_gen` | UUID, one live attachment of a controller to the host; new on every connect/resume; fences the previous one | +| `lease_epoch` | per session, increments on every grant, transfer, expiry, release, revoke | + +Every request that targets a session carries one **binding** object: + +```json +{"target":{"account_id":"account-a","atem_device_id":"host-a","host_trust_gen":1, + "session_id":"ses-abcdefghijkl","process_gen":"22222222-2222-4222-8222-222222222222"}, + "attachment_gen":"33333333-3333-4333-8333-333333333333", + "lease_epoch":7} +``` + +- Session ops (§4–§6) carry it as `data.binding`. terminal-v1 and chat-v1 + carry it as `data.binding` too — inside `data`, next to the family's own + fields, never inside a nested payload and never beside the outer envelope. + Their own ids must agree with it: terminal-v1 `terminal_id` = chat-v1 + `agentId` = `binding.target.session_id`; terminal-v1 `generation` = chat-v1 + `generation` (both are the agent process's generation) = + `binding.target.process_gen`. A disagreement is `stale_target`. Exact + envelopes: + +```json +{"type":"terminal","data":{"v":1,"op":"subscribe","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-2222-4222-8222-222222222222", + "binding":{"target":{…},"attachment_gen":"33333333-…"}}} +{"type":"terminal","data":{"v":1,"op":"input","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…","sequence":0,"bytes":"aGkN", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"terminal","data":{"v":1,"op":"resize","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…","rows":40,"cols":120, + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"terminal","data":{"v":1,"op":"stop","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"agentChat","data":{"v":1,"op":"open","conversationId":"…","agentId":"ses-aaaaaaaaaaaa", + "binding":{"target":{…},"attachment_gen":"33333333-…"}}} +{"type":"agentChat","data":{"v":1,"op":"submit","conversationId":"…","agentId":"ses-aaaaaaaaaaaa","generation":"22222222-…", + "requestId":"44444444-…","text":"run the tests", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +``` + + On upgraded connections the terminal-v1 `lease_id` field is not used (the + binding's epoch replaces it); a chat `submit` is the §6 `submit` with + `kind:"prompt"`, `request_id = requestId`, `payload = {"text": text}`. +- `lease_epoch` is required for mutations, absent for reads. +- Any mismatch → `error{code:"stale_target"}` with **no** current values; the + client re-lists (§4). Errors never reveal another target's ids, paths or + receipts; an unauthorized or unknown session is `unknown_session` either way. + +## 3. Hello, attachment and resume + +| op | dir | fields | +|---|---|---| +| `hello` | C→H | `resume?: {controller_session}` | +| `challenge` | H→C | `nonce` (32 bytes, base64url) — only when resuming | +| `resume_proof` | C→H | `proof` (§9: signature by the client's authenticated key over the transcript) | +| `welcome` | H→C | `account_id`, `atem_device_id`, `host_trust_gen`, `runtime_gen`, `controller_session`, `attachment_gen`, `client_kind` (`native`/`browser`, as authenticated), `permissions` (`view`, `control`, `approve`), `authorization_ms_left`, `server_time` | + +- The client's device and kind come from the authenticated channel, never from + `hello` fields or relay labels. A `controller_session` UUID is not a credential. +- Resume needs key possession over a fresh `challenge` and yields a **new** + `attachment_gen`; the host fences the old attachment (closes it and rejects + anything still arriving on it) before accepting mutations on the new one. + Resume never re-acquires a lease and never revives an expired one. +- Compatibility mode: no `challenge`/`resume_proof`; every `hello` gets a new + `controller_session` and `attachment_gen` and holds no lease. +- A broker restart (new `runtime_gen`) invalidates all attachments and leases; + workers and receipts stay addressable by `session_id`/`process_gen`. + +## 4. Session registry + +| op | dir | fields | +|---|---|---| +| `list` | C→H | — | +| `sessions` | H→C | `runtime_gen`, `runtime_epoch`, `seq`, `sessions: [descriptor]` | +| `session_changed` | H→C | `runtime_gen`, `runtime_epoch`, `seq`, `descriptor` | + +Descriptor: + +```json +{"session_id":"ses-abcdefghijkl","process_gen":"…","provider":"claude", + "cwd_label":"Atem","state":"running","activity":"ready","activity_since":1760500000000, + "controller":{"controller_session":"…","lease_epoch":7}|null, + "capabilities":["terminal-v1","chat-v1"],"created_at":1760490000000} +``` + +- `state`: `running`, `exited`, `interrupted`, `recovery_required`, `closed`, + `removed`. `activity`: `ready`, `working`, `waiting`, `unknown`. +- **Runtime fencing.** `runtime_epoch` is a host-persisted counter incremented + at every broker start (with `runtime_gen` its UUID). A client accepts a + snapshot or change only if its `runtime_epoch` ≥ the highest it has accepted + from this host; a higher epoch replaces all state (a snapshot at `seq` 1 of + epoch 8 replaces `seq` 100 of epoch 7); anything from a lower epoch is + discarded, so a delayed old-runtime snapshot can never switch the client back. + Each accepted epoch has one fixed `runtime_gen`; the same epoch with a + different `runtime_gen` is rejected. After accepting a new epoch from a + change (not a snapshot), the client sends `list` and applies nothing from + that epoch until its snapshot arrives. +- **Per-attachment sequence.** `list` subscribes the attachment. The host then + sends, on that attachment's single ordered stream, one `sessions` snapshot + followed only by `session_changed` events. `seq` counts the messages *this + attachment* received in this epoch (snapshot = n, next change = n+1, …), so + changes to sessions it can't see never create gaps. Within an epoch the client + applies a change only if `seq == last + 1`; `seq ≤ last` is a duplicate and is + ignored; `seq > last + 1` is a gap: it sends `list` and, until the new + snapshot arrives, discards every change with `seq` ≤ that snapshot's `seq`. + A snapshot whose `seq` is lower than the last applied one (same epoch) is + discarded. +- **Removal.** A removed session is sent as `state:"removed"`; applying it + deletes the session, and a later change for that `session_id` is only accepted + with a higher `seq` in the same epoch and a different `process_gen` — never + as a resurrection of the removed one. (Tombstones are kept 10 minutes host-side + only to answer late requests with `unknown_session`.) +- The registry and every response are filtered by the attachment's view + permission. `cwd_label` is the last path component only; clients show + `session_id` when labels repeat. Presence is never liveness evidence. + +## 5. Control lease + +| op | dir | fields | result | +|---|---|---|---| +| `acquire` | C→H | `binding` (no `lease_epoch`), `takeover` | `lease` or `error` | +| `renew` | C→H | `binding` | `lease` or `error` | +| `release` | C→H | `binding` | `lease_released` | +| `lease` | H→C | `binding` (with the current `lease_epoch`), `ms_left` | — | +| `lease_changed` | H→viewers | `session_id`, `process_gen`, `controller` (or null), `lease_epoch`, `reason` (`granted`, `takeover`, `local_input`, `expired`, `released`, `revoked`, `process_ended`) | — | + +Normative rules: + +- **Duration**: a lease lasts 15 s from its grant/renewal on the host's + monotonic clock, capped by the attachment's remaining authorization. Clients + renew every 5 s and never after their own monotonic estimate of expiry. + `ms_left` is for estimation only. +- **Fencing**: a lease is held by one (`controller_session`, `attachment_gen`). +- **Reply correlation and the client deadline** (normative for clients): + 1. A `lease` reply is applied only if its `re` equals one outstanding + `acquire`/`renew` request id, and each outstanding request consumes at most + one reply; replies with an unknown, already consumed or duplicate `re` are + dropped, even when the binding matches. + 2. Before applying it, the client checks its previously acknowledged local + deadline on the monotonic clock; if that deadline has passed (whether or not + a UI timer fired), the reply is dropped, the client becomes a viewer, and only + an explicit new `acquire` can regain control. + 3. Otherwise the new deadline = the request's monotonic **send** time + + `ms_left` from the reply (never the reply's arrival time + 15 s), and never + later than the authorization deadline. + 4. For a repeat `acquire` while already holding control, the new deadline = + `min(previous deadline, request send time + ms_left, authorization + deadline)`: it never extends and honours a shorter remaining lifetime. When + the deadline passes, input is disabled. An explicit `acquire` after expiry is + a new operation (rule 2 still discards replies to requests sent before it). + Fixture `late-renew-reply-does-not-resurrect-control` (acquire at t=0, renew + at t=5000, reply delivered at t=17000 with no timer tick; a duplicate earlier + reply while a later renew is pending; a repeat-acquire reply near expiry). +- **Renew** after expiry → `stale_lease` (it never re-acquires). +- **Repeat acquire** by the current holder on the same attachment returns the + current lease unchanged (same epoch, host deadline unchanged, `ms_left` = what + remains); on another attachment of the same controller → `stale_lease` + (resume first). +- **Takeover** (`takeover: true`, needs `control`) moves the lease to the caller + with a new epoch; the previous holder gets `lease_changed{reason:"takeover"}` + and stays a viewer. Without takeover a held lease → `lease_held`. +- **Local input wins**: local terminal input on the host revokes any remote lease + (new epoch, `reason:"local_input"`) before that input is written. +- **Input epoch**: the worker's PTY writer checks the session's input epoch at + write time for every queued chunk. Transfer, expiry, release, local input and + revoke bump that epoch first, so already-queued remote bytes are dropped, not + written. Raw keystrokes are never replayed after reconnect. +- **Restart**: leases are not persisted. A worker restart (new `process_gen`) or + broker restart (new `runtime_gen`) invalidates them; the epoch counter itself + is persisted per session so it never repeats. +- **Scope**: the lease governs terminal input/resize/stop, chat submission, + interrupt, approval answers and future mutations. Disconnect stops renewal; + the lease then expires. + +## 6. Submissions and receipts + +| op | dir | fields | +|---|---|---| +| `submit` | C→H | `binding` (with `lease_epoch`), `request_id`, `kind`, `payload` | +| `receipt` | H→C | `request_id`, `target`, `kind`, `state`, `detail?`, `updated_at` | +| `receipt_query` | C→H | `binding` (no `lease_epoch`), `request_ids` (≤ 64) | +| `receipts` | H→C | `receipts: [receipt]` (only those bound to that exact target) | + +Kinds and payloads (v1): + +| kind | payload | completes when | +|---|---|---| +| `prompt` | `{"text": string}` | the provider confirms the turn ended (chat-v1 `complete`) | +| `interrupt` | `{}` | the provider reports the turn interrupted, or the turn was already over (`detail`) | +| `stop` | `{}` | the agent process exits | +| `approval` | reserved for v1.1 (needs an approval identity); rejected with `forbidden` + `detail:"approval answers are not supported in v1"` | + +- **Digest**: the host computes `payload_digest = sha256(JCS(payload))`, JCS = + RFC 8785 over the supported domain (objects, arrays, strings, booleans, null, + integers in the safe range; no floats). A client-supplied digest is not used. + Known-answer vectors: `fixtures/digest-vectors.json`. +- **Immutable request**: (`request_id`, `account_id`, `atem_device_id`, + `host_trust_gen`, `session_id`, `process_gen`, `kind`, `payload_digest`). + Same `request_id` with every bound value equal → the recorded receipt (needs + current view permission for the target; no lease, no re-execution). Same + `request_id` with any value different → `request_id_reused`. +- **Admission** of a new request needs the current lease (`stale_lease` + otherwise), `control` permission, and a session that accepts submissions. +- **States and transitions** (all others are invalid and never happen): + +| from | to | when | +|---|---|---| +| — | `accepted` | admitted and durably recorded (fsync) — then the reply | +| — | `rejected` | refused at admission (nothing recorded as accepted) | +| `accepted` | `executing` | durably recorded before the first external side effect | +| `accepted` | `rejected` | before any side effect: session closed, trust generation changed, or the durable write of `executing` failed | +| `executing` | `completed` | the kind's completion (table above) observed | +| `executing` | `outcome_unknown` | crash/restart and reconciliation can't establish the outcome | + + `completed`, `rejected` and `outcome_unknown` are final. A receipt's + `request_id`, target, kind and digest never change; replies carry the same + values through recovery. +- **Crash boundaries**: `accepted` is persisted (fsync) before the reply; + `executing` is persisted before the first external side effect (e.g. the + paste). After a crash, two cases stay distinct: + `accepted` work has provably not started any side effect, so it may still be + dispatched — once, under its original target, and only if the host trust + generation is unchanged (else `rejected`, `detail:"host trust changed"`). + `executing` work may have started a side effect: it is never dispatched again; + it is reconciled with the worker/provider, and if that can't establish the + outcome it becomes `outcome_unknown`. +- Controller disconnect and lease expiry don't cancel accepted work. +- **Retention**: receipts live for the session's life and 7 days after it closes. + An accepting session's receipts are never evicted; at its limit new + submissions get `receipt_limit`. Closed sessions answer `session_closed`. +- chat-v1 on an upgraded connection: its `submit.requestId` is this + `request_id`, it carries `binding`, and its `accepted`/`complete` follow this + receipt's `accepted`/`completed`. + +## 7. Errors + +`error{re, code, detail?}`; `detail` is human-readable, bounded, and never +contains secrets, paths, raw OS errors or other targets' identifiers. + +| code | when | +|---|---| +| `unsupported_version` | unknown `v` or out-of-range representation | +| `unauthorized` | not authenticated, attachment fenced, or authorization expired | +| `forbidden` | missing permission, or a legacy op on an upgraded connection | +| `stale_target` | trust generation, session or process generation mismatch | +| `unknown_session` | unknown or not visible to this attachment | +| `lease_held` | another controller holds the lease (no takeover) | +| `stale_lease` | wrong, expired or other-attachment lease epoch | +| `request_id_reused` | same request id, different bound values | +| `receipt_limit` | the session's receipt journal is full | +| `session_closed` | the session no longer accepts submissions | +| `busy` | the agent or attachment can't take it now | +| `paused` | authorization freshness deadline passed; viewing/control paused | + +## 8. Fixtures + +One JSON file per scenario in `fixtures/`, schema `fixtures/schema.md`: + +```json +{"name":"lease-expires-without-renewal", + "setup":{"sessions":[{"session_id":"ses-aaaaaaaaaaaa","process_gen":"…"}],"clients":["c1"]}, + "steps":[ + {"send":"c1","json":{"type":"session","data":{"v":1,"op":"acquire",…}}}, + {"expect":"c1","match":{"op":"lease","binding.lease_epoch":1}}, + {"advance_ms":15001}, + {"expect_all":{"op":"lease_changed","reason":"expired"}}, + {"send":"c1","json":{…"op":"renew"…}}, + {"expect":"c1","match":{"op":"error","code":"stale_lease"}}, + {"assert":{"pty_writes":0,"dispatches":0}}]} +``` + +The setup fixes the host context (`account_id`, `atem_device_id`, +`host_trust_gen`, `runtime_gen`, `runtime_epoch`) and each client's +authenticated identity and permissions; nothing is derived from message fields. +Actions (deterministic clock, see `fixtures/schema.md`): `send`, `expect`, +`expect_none`, `advance_ms`, `hold`/`deliver` (delay and reorder a specific +message), `disconnect`, `resume`, `local_input`, `crash_worker`, +`restart_runtime`, `fail_next_fsync`, `change_trust_gen`, `assert` (host +counters: PTY writes, resizes, stops, dispatches, receipts visible per client; +client state for client-side fixtures: `controls`, `deadline_ms`, registry). Negative scenarios assert both the +error and the absence of writes, dispatch, disclosure or resurrection. The +first set (r3 adds the r2-review cases: two-connection lease bypass, late/duplicate +lease replies, runtime epoch snapshots, snapshot/change reordering, hidden-session +changes, change after removal, receipt-query authorization, changed-target id +reuse, failed durable write, local reclaim at the PTY boundary, old attachment +and old runtime input): hello/welcome, list/snapshot+revision, gap recovery, tombstone, +acquire/renew/expiry, repeat acquire, takeover, local input revocation, queued +bytes dropped on transfer, stale epoch, submit accepted→executing→completed, +duplicate submit, reused id (other target), crash after executing → +outcome_unknown, trust change before dispatch → rejected, closed session, +receipt query scoped to target. + +## 9. Gate: trust, freshness, channel and browser grants (joint) + +Must be specified jointly, implemented and tested before account-wide native +access, resume, browser clients or relay confidentiality are enabled: trust +anchor installation, signed native membership and bounded management +delegation, host acceptance of account-wide authority, unpair generation +commits, signed revocation ordering, authenticated freshness proofs (the 24 h +window starts at a validated authority proof, never at relay presence, +reconnection, an old signature or a relay timestamp; rollback protection and +conservative restart/clock handling), channel key establishment with +replay/downgrade protection binding endpoint keys, account, roles, permissions +and generations, the `resume_proof` format, and QR grant binding. diff --git a/designs/protocol/atem-session-v1-r5-review.md b/designs/protocol/atem-session-v1-r5-review.md new file mode 100644 index 0000000..ff6ac78 --- /dev/null +++ b/designs/protocol/atem-session-v1-r5-review.md @@ -0,0 +1,73 @@ +# Astation Review of atem-session v1 r5 + +Date: 2026-10-15 + +Reviewed upstream commit: `44987915fb24267960fd9d75221df4ed8c7838ec` on +`docs/atem-session-protocol`. +The [current snapshot](atem-session-v1.md) and `fixtures/` are unchanged copies +from that commit. All ten downloaded files were verified against their GitHub +git blob hashes. All Atem implementation belongs to the Atem agent. + +## Review result + +No new findings in the r5 changes. All three [r4 findings](atem-session-v1-r4-review.md) +are resolved. Astation accepts r5 as the compatibility-mode implementation +contract. Final conformance still requires the shared fixtures to pass in Rust, +Swift, and the browser, as required by the draft; section 9 remains the security +release gate. + +Subsequent native client implementation and offline JavaScript fixture execution +are tracked in [the implementation evidence](astation-session-v1-implementation.md). +The validation section below records the scope of the original document review. + +## Resolved findings + +1. **Dispatch failures have durable terminal outcomes.** Section 6 permits + `executing -> rejected` only with proof of no side effect, and uses + `outcome_unknown` for possible partial effects. Every exit from `executing` + must be fsynced before reporting it. Prompt, interrupt, and stop failures have + defined handling, including bounded interrupt/stop observation. Final failure + states cannot be automatically retried or later rewritten by an observed + completion. The two new prompt-failure fixtures check duplicate submission + and retrieval of the same final state after a runtime restart and fresh + attachment, with one dispatch attempt. +2. **Descriptors are complete wire values.** Every documented descriptor field + is required in snapshots and changes, including removals. Missing fields are + rejected; changes are never patches. All six descriptors in the registry + ordering fixture now contain the required fields. +3. **Expiry includes equality.** Host and client use `now >= deadline`. Applying + a reply whose conservative deadline has already arrived immediately disables + control and input. The late-renew fixture now expects both to be disabled at + 31500 ms, without an extra timer tick or millisecond. + +## Validation scope + +- All eight JSON fixture files parse with duplicate-key detection. +- The ten canonical digest/hash vectors and raw rejection cases are byte-for-byte + unchanged from the independently verified r4 set. +- All six delivered registry descriptors include every required field. +- An independent lease-arithmetic replay matches all five client assertion + steps, including immediate expiry at 31500 ms. +- The two new dispatch-failure fixtures have consistent request identity, + transition sequences, duplicate final states, fresh reconnect attachments, + target-scoped recovery queries, and single-dispatch assertions. + +These checks validate fixture contents and deadline arithmetic. No Rust, Swift, +or browser implementation fixture runner was executed for this review. No Atem +code was changed, built, or tested. Implementation conformance must still cover +durable-write failures, partial effects, restart recovery, trust changes, local +input fencing, attachment/runtime changes, and the other scenarios listed in +section 8. The fixtures must use each implementation's actual wire decoders. + +## Security gate + +Keep account-wide native access, resume, browser clients, and relay +confidentiality disabled until section 9 is jointly specified, implemented, and +tested: trust anchor and enrollment authority, revocation ordering, bounded +authenticated freshness, authenticated end-to-end channels, and native-approved +QR grants. Compatibility acceptance does not satisfy those prerequisites. + +Relay labels, account assertions, timestamps, and presence never establish +device identity, trust, lease authority, encryption state, or authorization +freshness. Existing socket authentication and retained pins remain limited to +the compatibility mode described in the protocol. diff --git a/designs/protocol/atem-session-v1-r5.md b/designs/protocol/atem-session-v1-r5.md new file mode 100644 index 0000000..6ba6334 --- /dev/null +++ b/designs/protocol/atem-session-v1-r5.md @@ -0,0 +1,416 @@ +# atem-session v1: sessions, control leases and receipts (DRAFT r5 for joint review) + +Status: Atem-side draft r5, 2026-10-15. r1 (`7a0c691`), r2 (`9383193`), r3 +(`1bf805c`, [r3 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r3-review.md)) +and r4 (`fd907cc`, [r4 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r4-review.md)) +were reviewed by Astation ([r1 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-review.md), +[r2 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r2-review.md), +summarised in [session-multiplexing.md](../session-multiplexing.md) §12). r2 +answered r1 items 1–7 and 9; r3 answered r2 review items 1–5; r4 answered the three r3 corrections; r5 answers the three r4 findings. Item 8 (trust, freshness, end-to-end channel, +QR grants) stays an implementation gate (§9). Not final until both sides +accept it and the fixtures in `fixtures/` pass in Rust, Swift and the browser. + +## 0. Layering and activation + +``` ++-----------------------------------------------------------------+ +| atem-session v1 (this doc): registry, leases, receipts | +| terminal-v1 / chat-v1 streams, bound to this doc's targets | ++-----------------------------------------------------------------+ +| secure channel (§9 gate): endpoint keys, E2EE, freshness | ++-----------------------------------------------------------------+ +| transport: direct ws :8080 | relay (atem_id + connection_id) | ++-----------------------------------------------------------------+ +``` + +- Direct and relay are thin transports; messages are identical on both and the + host deduplicates across them. +- **Compatibility mode** (until §9 is implemented): the messages run inside + today's authenticated Astation socket for isolated tests and for the one + Astation already verified with this host. In this mode: no resume (§3), no + browser clients, no account-wide native access, and no claim of relay + confidentiality. Admission fails closed; no placeholder trust decision is + exposed as production authorization. +- **One session authority, every connection.** Each session has exactly one + lease authority on the host (§5), whatever socket, transport or message family + a request uses. A connection is "upgraded" once it received `welcome`; upgrade + changes how a client addresses things, not who may mutate a session: + - terminal-v1 `control{take:true}` from a non-upgraded connection is an + `acquire{takeover:true}` by that connection's attachment on the same + authority; `control{take:false}` is `release`. Its `lease_id` maps to that + lease. + - A session is **managed** from the moment any attachment first acquires its + lease (session `acquire` or legacy terminal-v1 `control`) until the session + ends; the flag never clears (not on release, expiry or local reclaim). + Legacy unscoped mutations (`agentInput`, `agentPrompt`, voice commands, + `userCommand`, chat-v1 `submit` without `binding`) routed to a managed + session are always rejected (`forbidden`, nothing written or dispatched), + whether or not a remote holder exists, and they never acquire. Sessions that + have never been managed keep today's legacy behaviour (routed per Atem's + default-session rule); legacy can't create a path into a managed session. + - Legacy terminal-v1 `control` keeps all its checks (permission, attachment, + exact `terminal_id`/`generation`, subscribed `stream_id`, deadline) before it + maps onto `acquire`/`release`. + - On an upgraded connection every mutation must carry `binding` (§2) and the + current lease; legacy unscoped mutations there are always `forbidden`. + - Local terminal input on the host revokes any remote lease first (§5). + Fixture `legacy-connection-cannot-bypass-lease`. + +## 1. Representation + +| Item | Rule | +|---|---| +| Envelope | `{"type":"session","data":{"v":1,"op":"…","id":"", …}}`; replies carry `re` | +| UUID | lowercase canonical v4 (`8-4-4-4-12`), validated | +| `session_id` | `ses-` + exactly 12 chars `[a-z2-7]` | +| Opaque ids (`account_id`, `atem_device_id`, `client_device_id`) | 1–128 bytes, `[A-Za-z0-9._:-]` | +| Counters (`host_trust_gen`, `lease_epoch`, `runtime_epoch`, `seq`) | JSON integers 0 ≤ n ≤ 2^53−1; a value that would exceed it is refused (`error{code:"unsupported_version"}` for peers, fatal for the host's own counters: it stops admitting and asks for re-pairing) — never wraps | +| Times | integer milliseconds since the Unix epoch; **display/estimation only** — authority uses monotonic clocks (§5) | +| Digest | SHA-256, lowercase hex, 64 chars | +| Bounds | message ≤ 1 MiB after decoding; `payload` ≤ 64 KiB canonical; prompt text ≤ 32 768 bytes; `receipt_query.request_ids` ≤ 64; `detail` ≤ 512 bytes; ≤ 64 in-flight requests per attachment (excess → `busy`) | +| Unknown fields | ignored; unknown `v` → `unsupported_version` | +| Parsing | bounds checked before allocation; duplicate object keys, non-integer numbers where integers are required, and out-of-range values are rejected | + +## 2. Identities, target and binding + +| Field | Meaning | +|---|---| +| `account_id` | account owning the data | +| `atem_device_id` | this Atem device/profile | +| `host_trust_gen` | increments durably on unpair/rebind; older values rejected | +| `runtime_gen` | UUID, new each broker (runtime) start | +| `session_id` | one agent session, stable for its life | +| `process_gen` | UUID, one agent process; replacement/resume = new value | +| `controller_session` | UUID, logical controller identity assigned by the host | +| `attachment_gen` | UUID, one live attachment of a controller to the host; new on every connect/resume; fences the previous one | +| `lease_epoch` | per session, increments on every grant, transfer, expiry, release, revoke | + +Every request that targets a session carries one **binding** object: + +```json +{"target":{"account_id":"account-a","atem_device_id":"host-a","host_trust_gen":1, + "session_id":"ses-abcdefghijkl","process_gen":"22222222-2222-4222-8222-222222222222"}, + "attachment_gen":"33333333-3333-4333-8333-333333333333", + "lease_epoch":7} +``` + +- Session ops (§4–§6) carry it as `data.binding`. terminal-v1 and chat-v1 + carry it as `data.binding` too — inside `data`, next to the family's own + fields, never inside a nested payload and never beside the outer envelope. + Their own ids must agree with it: terminal-v1 `terminal_id` = chat-v1 + `agentId` = `binding.target.session_id`; terminal-v1 `generation` = chat-v1 + `generation` (both are the agent process's generation) = + `binding.target.process_gen`. A disagreement is `stale_target`. Exact + envelopes: + +```json +{"type":"terminal","data":{"v":1,"op":"subscribe","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-2222-4222-8222-222222222222", + "binding":{"target":{…},"attachment_gen":"33333333-…"}}} +{"type":"terminal","data":{"v":1,"op":"input","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…","sequence":0,"bytes":"aGkN", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"terminal","data":{"v":1,"op":"resize","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…","rows":40,"cols":120, + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"terminal","data":{"v":1,"op":"stop","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"agentChat","data":{"v":1,"op":"open","conversationId":"…","agentId":"ses-aaaaaaaaaaaa", + "binding":{"target":{…},"attachment_gen":"33333333-…"}}} +{"type":"agentChat","data":{"v":1,"op":"submit","conversationId":"…","agentId":"ses-aaaaaaaaaaaa","generation":"22222222-…", + "requestId":"44444444-…","text":"run the tests", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +``` + + On upgraded connections the terminal-v1 `lease_id` field is not used (the + binding's epoch replaces it); a chat `submit` is the §6 `submit` with + `kind:"prompt"`, `request_id = requestId`, `payload = {"text": text}`. +- `lease_epoch` is required for mutations, absent for reads. +- Any mismatch → `error{code:"stale_target"}` with **no** current values; the + client re-lists (§4). Errors never reveal another target's ids, paths or + receipts; an unauthorized or unknown session is `unknown_session` either way. + +## 3. Hello, attachment and resume + +| op | dir | fields | +|---|---|---| +| `hello` | C→H | `resume?: {controller_session}` | +| `challenge` | H→C | `nonce` (32 bytes, base64url) — only when resuming | +| `resume_proof` | C→H | `proof` (§9: signature by the client's authenticated key over the transcript) | +| `welcome` | H→C | `account_id`, `atem_device_id`, `host_trust_gen`, `runtime_gen`, `controller_session`, `attachment_gen`, `client_kind` (`native`/`browser`, as authenticated), `permissions` (`view`, `control`, `approve`), `authorization_ms_left`, `server_time` | + +- The client's device and kind come from the authenticated channel, never from + `hello` fields or relay labels. A `controller_session` UUID is not a credential. +- Resume needs key possession over a fresh `challenge` and yields a **new** + `attachment_gen`; the host fences the old attachment (closes it and rejects + anything still arriving on it) before accepting mutations on the new one. + Resume never re-acquires a lease and never revives an expired one. +- Compatibility mode: no `challenge`/`resume_proof`; every `hello` gets a new + `controller_session` and `attachment_gen` and holds no lease. +- A broker restart (new `runtime_gen`) invalidates all attachments and leases; + workers and receipts stay addressable by `session_id`/`process_gen`. + +## 4. Session registry + +| op | dir | fields | +|---|---|---| +| `list` | C→H | — | +| `sessions` | H→C | `runtime_gen`, `runtime_epoch`, `seq`, `sessions: [descriptor]` | +| `session_changed` | H→C | `runtime_gen`, `runtime_epoch`, `seq`, `descriptor` | + +Descriptor: + +```json +{"session_id":"ses-abcdefghijkl","process_gen":"…","provider":"claude", + "cwd_label":"Atem","state":"running","activity":"ready","activity_since":1760500000000, + "controller":{"controller_session":"…","lease_epoch":7}|null, + "capabilities":["terminal-v1","chat-v1"],"created_at":1760490000000} +``` + +- Every descriptor field is **required** in both `sessions` and + `session_changed` (a change carries the complete new descriptor, never a + patch); `controller` is `null` when no lease is held and `capabilities` may be + `[]`. Decoders reject a descriptor with a missing field. A removal is a + complete descriptor with `state:"removed"` (its last values). +- `state`: `running`, `exited`, `interrupted`, `recovery_required`, `closed`, + `removed`. `activity`: `ready`, `working`, `waiting`, `unknown`. +- **Runtime fencing.** `runtime_epoch` is a host-persisted counter incremented + at every broker start (with `runtime_gen` its UUID). A client accepts a + snapshot or change only if its `runtime_epoch` ≥ the highest it has accepted + from this host; a higher epoch replaces all state (a snapshot at `seq` 1 of + epoch 8 replaces `seq` 100 of epoch 7); anything from a lower epoch is + discarded, so a delayed old-runtime snapshot can never switch the client back. + Each accepted epoch has one fixed `runtime_gen`; the same epoch with a + different `runtime_gen` is rejected. After accepting a new epoch from a + change (not a snapshot), the client sends `list` and applies nothing from + that epoch until its snapshot arrives. +- **Per-attachment sequence.** `list` subscribes the attachment. The host then + sends, on that attachment's single ordered stream, one `sessions` snapshot + followed only by `session_changed` events. `seq` counts the messages *this + attachment* received in this epoch (snapshot = n, next change = n+1, …), so + changes to sessions it can't see never create gaps. Within an epoch the client + applies a change only if `seq == last + 1`; `seq ≤ last` is a duplicate and is + ignored; `seq > last + 1` is a gap: it sends `list` and, until the new + snapshot arrives, discards every change with `seq` ≤ that snapshot's `seq`. + A snapshot whose `seq` is lower than the last applied one (same epoch) is + discarded. +- **Removal.** A removed session is sent as `state:"removed"`; applying it + deletes the session, and a later change for that `session_id` is only accepted + with a higher `seq` in the same epoch and a different `process_gen` — never + as a resurrection of the removed one. (Tombstones are kept 10 minutes host-side + only to answer late requests with `unknown_session`.) +- The registry and every response are filtered by the attachment's view + permission. `cwd_label` is the last path component only; clients show + `session_id` when labels repeat. Presence is never liveness evidence. + +## 5. Control lease + +| op | dir | fields | result | +|---|---|---|---| +| `acquire` | C→H | `binding` (no `lease_epoch`), `takeover` | `lease` or `error` | +| `renew` | C→H | `binding` | `lease` or `error` | +| `release` | C→H | `binding` | `lease_released` | +| `lease` | H→C | `binding` (with the current `lease_epoch`), `ms_left` | — | +| `lease_changed` | H→viewers | `session_id`, `process_gen`, `controller` (or null), `lease_epoch`, `reason` (`granted`, `takeover`, `local_input`, `expired`, `released`, `revoked`, `process_ended`) | — | + +Normative rules: + +- **Duration**: a lease lasts 15 s from its grant/renewal on the host's + monotonic clock (expired when `now >= grant + 15 000 ms`), capped by the attachment's remaining authorization. Clients + renew every 5 s and never after their own monotonic estimate of expiry. + `ms_left` is for estimation only. +- **Fencing**: a lease is held by one (`controller_session`, `attachment_gen`). +- **Reply correlation and the client deadline** (normative for clients): + 1. A `lease` reply is applied only if its `re` equals one outstanding + `acquire`/`renew` request id, and each outstanding request consumes at most + one reply; replies with an unknown, already consumed or duplicate `re` are + dropped, even when the binding matches. + 2. Before applying it, the client checks its previously acknowledged local + deadline on the monotonic clock; if that deadline has passed (`now >= + deadline`, whether or not a UI timer fired), the reply is dropped, the + client becomes a viewer, and only an explicit new `acquire` can regain + control. + 3. Otherwise the new deadline = the request's monotonic **send** time + + `ms_left` from the reply (never the reply's arrival time + 15 s), and never + later than the authorization deadline. + 4. For a repeat `acquire` while already holding control, the new deadline = + `min(previous deadline, request send time + ms_left, authorization + deadline)`: it never extends and honours a shorter remaining lifetime. + 5. Expiry is `now >= deadline`, on both host and client. A client that computes + a deadline already reached when it applies a reply disables control and input + immediately (it never holds control with zero remaining lifetime). An explicit `acquire` after expiry is + a new operation (rule 2 still discards replies to requests sent before it). + Fixture `late-renew-reply-does-not-resurrect-control` (acquire at t=0, renew + at t=5000, reply delivered at t=17000 with no timer tick; a duplicate earlier + reply while a later renew is pending; a repeat-acquire reply near expiry). +- **Renew** after expiry → `stale_lease` (it never re-acquires). +- **Repeat acquire** by the current holder on the same attachment returns the + current lease unchanged (same epoch, host deadline unchanged, `ms_left` = what + remains); on another attachment of the same controller → `stale_lease` + (resume first). +- **Takeover** (`takeover: true`, needs `control`) moves the lease to the caller + with a new epoch; the previous holder gets `lease_changed{reason:"takeover"}` + and stays a viewer. Without takeover a held lease → `lease_held`. +- **Local input wins**: local terminal input on the host revokes any remote lease + (new epoch, `reason:"local_input"`) before that input is written. +- **Input epoch**: the worker's PTY writer checks the session's input epoch at + write time for every queued chunk. Transfer, expiry, release, local input and + revoke bump that epoch first, so already-queued remote bytes are dropped, not + written. Raw keystrokes are never replayed after reconnect. +- **Restart**: leases are not persisted. A worker restart (new `process_gen`) or + broker restart (new `runtime_gen`) invalidates them; the epoch counter itself + is persisted per session so it never repeats. +- **Scope**: the lease governs terminal input/resize/stop, chat submission, + interrupt, approval answers and future mutations. Disconnect stops renewal; + the lease then expires. + +## 6. Submissions and receipts + +| op | dir | fields | +|---|---|---| +| `submit` | C→H | `binding` (with `lease_epoch`), `request_id`, `kind`, `payload` | +| `receipt` | H→C | `request_id`, `target`, `kind`, `state`, `detail?`, `updated_at` | +| `receipt_query` | C→H | `binding` (no `lease_epoch`), `request_ids` (≤ 64) | +| `receipts` | H→C | `receipts: [receipt]` (only those bound to that exact target) | + +Kinds and payloads (v1): + +| kind | payload | completes when | +|---|---|---| +| `prompt` | `{"text": string}` | the provider confirms the turn ended (chat-v1 `complete`) | +| `interrupt` | `{}` | the provider reports the turn interrupted, or the turn was already over (`detail`) | +| `stop` | `{}` | the agent process exits | +| `approval` | reserved for v1.1 (needs an approval identity); rejected with `forbidden` + `detail:"approval answers are not supported in v1"` | + +- **Digest**: the host computes `payload_digest = sha256(JCS(payload))`, JCS = + RFC 8785 over the supported domain (objects, arrays, strings, booleans, null, + integers in the safe range; no floats). A client-supplied digest is not used. + Known-answer vectors: `fixtures/digest-vectors.json`. +- **Immutable request**: (`request_id`, `account_id`, `atem_device_id`, + `host_trust_gen`, `session_id`, `process_gen`, `kind`, `payload_digest`). + Same `request_id` with every bound value equal → the recorded receipt (needs + current view permission for the target; no lease, no re-execution). Same + `request_id` with any value different → `request_id_reused`. +- **Admission** of a new request needs the current lease (`stale_lease` + otherwise), `control` permission, and a session that accepts submissions. +- **States and transitions** (all others are invalid and never happen): + +| from | to | when | +|---|---|---| +| — | `accepted` | admitted and durably recorded (fsync) — then the reply | +| — | `rejected` | refused at admission (nothing recorded as accepted) | +| `accepted` | `executing` | durably recorded before the first external side effect | +| `accepted` | `rejected` | before any side effect: session closed, trust generation changed, or the durable write of `executing` failed | +| `executing` | `completed` | the kind's completion (table above) observed | +| `executing` | `rejected` | dispatch failed **with proof that no side effect happened** (see below) | +| `executing` | `outcome_unknown` | dispatch failed after a possible partial effect, the agent process ended before the kind's completion, or crash/restart and reconciliation can't establish the outcome | + + `completed`, `rejected` and `outcome_unknown` are final. Every exit from + `executing` is persisted (fsync) before it is reported; a receipt never stays + `executing` because of an ordinary failure. +- **Dispatch failures** (normative, per kind): + - `prompt`: the paste is one PTY write of all chunks reserved together. If the + reservation/enqueue fails, no byte reached the PTY → `rejected`, + `detail:"could not write to the terminal"`. If the writer reports an error + after some bytes may have been written → `outcome_unknown`, + `detail:"write interrupted"`. If the agent process ends before the turn + completes → `outcome_unknown`, `detail:"agent exited"`. + - `interrupt`: the interrupt key could not be enqueued → `rejected`; written + but no interrupted/ended turn observed within 10 s → `outcome_unknown`, + `detail:"no interrupt observed"`. + - `stop`: the signal could not be delivered (e.g. `ESRCH`/`EPERM`) and the + process is still alive → `rejected`, `detail:"stop failed"`; delivered but + the process hasn't exited within 10 s → `outcome_unknown`, + `detail:"process still running"` (a later observed exit doesn't change it). + - A failure that cannot be classified as "no side effect" is + `outcome_unknown`. Neither final failure state is ever retried + automatically; a duplicate `submit` returns the recorded final receipt. A receipt's + `request_id`, target, kind and digest never change; replies carry the same + values through recovery. +- **Crash boundaries**: `accepted` is persisted (fsync) before the reply; + `executing` is persisted before the first external side effect (e.g. the + paste). After a crash, two cases stay distinct: + `accepted` work has provably not started any side effect, so it may still be + dispatched — once, under its original target, and only if the host trust + generation is unchanged (else `rejected`, `detail:"host trust changed"`). + `executing` work may have started a side effect: it is never dispatched again; + it is reconciled with the worker/provider, and if that can't establish the + outcome it becomes `outcome_unknown`. +- Controller disconnect and lease expiry don't cancel accepted work. +- **Retention**: receipts live for the session's life and 7 days after it closes. + An accepting session's receipts are never evicted; at its limit new + submissions get `receipt_limit`. Closed sessions answer `session_closed`. +- chat-v1 on an upgraded connection: its `submit.requestId` is this + `request_id`, it carries `binding`, and its `accepted`/`complete` follow this + receipt's `accepted`/`completed`. + +## 7. Errors + +`error{re, code, detail?}`; `detail` is human-readable, bounded, and never +contains secrets, paths, raw OS errors or other targets' identifiers. + +| code | when | +|---|---| +| `unsupported_version` | unknown `v` or out-of-range representation | +| `unauthorized` | not authenticated, attachment fenced, or authorization expired | +| `forbidden` | missing permission, or a legacy op on an upgraded connection | +| `stale_target` | trust generation, session or process generation mismatch | +| `unknown_session` | unknown or not visible to this attachment | +| `lease_held` | another controller holds the lease (no takeover) | +| `stale_lease` | wrong, expired or other-attachment lease epoch | +| `request_id_reused` | same request id, different bound values | +| `receipt_limit` | the session's receipt journal is full | +| `session_closed` | the session no longer accepts submissions | +| `busy` | the agent or attachment can't take it now | +| `paused` | authorization freshness deadline passed; viewing/control paused | + +## 8. Fixtures + +One JSON file per scenario in `fixtures/`, schema `fixtures/schema.md`: + +```json +{"name":"lease-expires-without-renewal", + "setup":{"sessions":[{"session_id":"ses-aaaaaaaaaaaa","process_gen":"…"}],"clients":["c1"]}, + "steps":[ + {"send":"c1","json":{"type":"session","data":{"v":1,"op":"acquire",…}}}, + {"expect":"c1","match":{"op":"lease","binding.lease_epoch":1}}, + {"advance_ms":15001}, + {"expect_all":{"op":"lease_changed","reason":"expired"}}, + {"send":"c1","json":{…"op":"renew"…}}, + {"expect":"c1","match":{"op":"error","code":"stale_lease"}}, + {"assert":{"pty_writes":0,"dispatches":0}}]} +``` + +The setup fixes the host context (`account_id`, `atem_device_id`, +`host_trust_gen`, `runtime_gen`, `runtime_epoch`) and each client's +authenticated identity and permissions; nothing is derived from message fields. +Actions (deterministic clock, see `fixtures/schema.md`): `send`, `expect`, +`expect_none`, `advance_ms`, `hold`/`deliver` (delay and reorder a specific +message), `disconnect`, `resume`, `local_input`, `crash_worker`, +`restart_runtime`, `fail_next_fsync`, `change_trust_gen`, `assert` (host +counters: PTY writes, resizes, stops, dispatches, receipts visible per client; +client state for client-side fixtures: `controls`, `deadline_ms`, registry). Negative scenarios assert both the +error and the absence of writes, dispatch, disclosure or resurrection. The +first set (r3 adds the r2-review cases: two-connection lease bypass, late/duplicate +lease replies, runtime epoch snapshots, snapshot/change reordering, hidden-session +changes, change after removal, receipt-query authorization, changed-target id +reuse, failed durable write, local reclaim at the PTY boundary, old attachment +and old runtime input): hello/welcome, list/snapshot+revision, gap recovery, tombstone, +acquire/renew/expiry, repeat acquire, takeover, local input revocation, queued +bytes dropped on transfer, stale epoch, submit accepted→executing→completed, +dispatch failure before any effect (→ rejected) and after a possible partial +effect (→ outcome_unknown), each followed by a duplicate submit and recovery +after restart returning the same final receipt, +duplicate submit, reused id (other target), crash after executing → +outcome_unknown, trust change before dispatch → rejected, closed session, +receipt query scoped to target. + +## 9. Gate: trust, freshness, channel and browser grants (joint) + +Must be specified jointly, implemented and tested before account-wide native +access, resume, browser clients or relay confidentiality are enabled: trust +anchor installation, signed native membership and bounded management +delegation, host acceptance of account-wide authority, unpair generation +commits, signed revocation ordering, authenticated freshness proofs (the 24 h +window starts at a validated authority proof, never at relay presence, +reconnection, an old signature or a relay timestamp; rollback protection and +conservative restart/clock handling), channel key establishment with +replay/downgrade protection binding endpoint keys, account, roles, permissions +and generations, the `resume_proof` format, and QR grant binding. diff --git a/designs/protocol/atem-session-v1-review.md b/designs/protocol/atem-session-v1-review.md new file mode 100644 index 0000000..d97dfaf --- /dev/null +++ b/designs/protocol/atem-session-v1-review.md @@ -0,0 +1,175 @@ +# Astation Review of atem-session v1 r1 + +Date: 2026-10-15 + +Reviewed upstream commit: `7a0c691f72e402e5e9488090c113c3974056ed92`. +Source: . +The [r1 snapshot](atem-session-v1-r1.md) is unchanged. Atem has since published +r2 at `9383193`, r3 at `1bf805c`, r4 at `fd907cc`, and r5 at `4498791`; see the [current r5 review](atem-session-v1-r5-review.md) and +[current snapshot](atem-session-v1.md). The findings below describe r1 and are +retained as review history; r2 addresses most of them. + +The session registry, host-owned leases, independent process generations, and +durable receipt states match the approved product design. Use this as the +shared higher-level protocol; Astation does not propose a competing multiplexing +protocol. The following changes are needed before freezing fixtures. + +## 1. Bind hello/resume to an authenticated principal and attachment + +Sections 0 and 3 must not let `client_device_id`, `client_kind`, or a known +`controller_session` establish authority. Bind them to the cryptographically +authenticated native key or the approved browser key/grant. A relay may observe +all unencrypted fields; a controller UUID is not a resume credential. + +Specify proof of possession for resume, a fresh host challenge, and a new +attachment generation. Fence the former attachment before accepting mutations +on its replacement. A transport switch may preserve logical controller identity, +but must not let old and new sockets both renew or operate it. A broker restart +invalidates old controller attachments/leases; surviving workers and receipts +remain independently addressable. Resume must never automatically acquire control. + +Existing socket authentication can be used for isolated compatibility tests, +but is not the missing account-wide native authentication or encrypted channel. +Do not enable native enrollment, browser grants, or advertise relay-confidential +operation while section 8 is open. + +## 2. Define exact terminal/chat binding and remove lease bypasses + +Sections 0, 5, and 6 need the exact JSON shape for a full target and its use in +terminal/chat requests. Suggested common object: + +```json +{ + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-abcdefghijkl", + "process_gen": "11111111-1111-4111-8111-111111111111" + }, + "lease_epoch": 7 +} +``` + +Resolve section 9's integer representation rule before freezing this example. +Specify whether terminal/chat messages carry these fields directly or are nested +inside a session request. For upgraded connections, session `acquire/renew/release` +must be the sole control lifecycle. Legacy terminal `control`, chat `submit`, +agentInput, voice commands, stop, resize, and approval answers cannot bypass it. +Raw terminal input remains non-replayable even when it carries this binding. + +Bind every stream to its authenticated controller, attachment, exact session, +and process generation. Echo enough target/controller context in lease replies +and errors to reject delayed responses from an old attachment or process. + +## 3. Bind receipt deduplication to the complete operation + +Section 6 currently says same request ID and payload digest returns a receipt, +which could alias a different target or operation with the same payload. +The stored immutable request identity must bind account, host identity/trust +generation, session, process generation, operation kind, and payload digest. +Reusing an ID with any differing bound value returns `request_id_reused`. +The host recomputes the digest; it cannot trust the client-supplied digest. + +Define canonical JSON precisely, such as RFC 8785 with a documented supported +data domain and explicit rejection of duplicate object keys, invalid numbers, +and out-of-range values. Alternative: hash an explicitly length-prefixed +encoding of typed fields. Cross-language known-answer fixtures are required. + +Authenticate and authorize receipt access before returning a duplicate outcome. +Admission of a new operation requires the current control lease; reading an +already accepted receipt must not require taking control again or re-executing it. + +## 4. Scope receipt queries and errors to authorized targets + +`receipt_query` currently carries only session ID and request IDs. Require the +full target or an equivalent already authenticated session binding, plus current +read permission for that exact target. Do not return another process/account's +receipts or reveal its current target in `stale_target` errors. + +Unknown/forbidden errors should not enumerate unauthorized sessions. Define +message, payload, ID, receipt-query, and per-connection limits before parsing +unbounded arrays or storing attacker-selected request IDs. + +## 5. Define crash boundaries and kind-specific completion + +Persist `accepted` before acknowledging it. Persist the transition to +`executing` before the first external side effect. A crash after that transition +requires reconciliation with the original worker/provider; if it cannot establish +the outcome, retain `outcome_unknown` without automatic resubmission. A crash +before the transition can recover durable accepted work only under its original +target and applicable host authorization. + +Controller disconnection or ordinary lease expiry does not cancel accepted +work. Host unpair/rebind must prevent an old queued operation from first +dispatching under the new trust generation. Work already executing can continue +locally, as the product design requires. + +Define completion per kind: a provider-confirmed prompt turn can complete a +prompt, process exit can complete stop, and interrupt requires its own observed +result. A PTY write is neither prompt acceptance nor turn completion. The text +includes provider approval answers, but the `kind` enum omits them; add an +explicit approval kind with exact approval identity or explicitly defer it. + +## 6. Make lease deadlines, epochs, and response fencing normative + +Use monotonic time for host enforcement and client renewal deadlines; wall-clock +`expires_at`/`server_time` are display/estimation fields, not authority. Renew at +five-second intervals, never after local expiry. Expired renewal must reject, +not reacquire. Only a response matching the current authenticated attachment, +target, controller, and epoch advances the local deadline. + +Lease transfer/expiry/local input must revoke the PTY input epoch before queued +remote bytes can be written. Checking only when a frame is admitted is +insufficient. Specify epoch persistence or invalidation on broker/worker restart, +and test delayed writes, delayed lease replies, and concurrent transports. +Also define repeat acquire by the same current controller so lost replies cannot +silently rotate its active epoch or extend control through unintended retries. + +## 7. Order registry updates and snapshots + +Add a registry revision/event sequence scoped to `runtime_gen`. A client needs +to detect gaps, discard stale snapshots, and distinguish removal from an old +state update. Define snapshot subscription ordering and closed-session tombstones. +Otherwise list replies racing with `session_changed` can resurrect exited or +removed sessions in the UI. Relay presence is never session liveness evidence. + +## 8. Keep section 8 as an implementation gate + +Agree on account trust anchor installation, signed native memberships and bounded +management delegation, host enrollment, unpair generation commits, signed +revocation ordering, authenticated freshness proofs, channel key establishment, +replay/downgrade protection, and QR grant binding before production activation. + +The 24-hour freshness bound starts at a validated authority proof, not relay +presence, reconnection, an old signature, or a relay timestamp. Persist rollback +protection and conservative restart/clock handling. Grants expire earlier when +their own lifetime or parent authorization does. A browser grant is limited to +its exact key/target/scope and never becomes native membership. + +No objection to keeping these cryptographic formats out of Atem's independently +owned registry/runtime implementation, provided admission fails closed and no +placeholder trust decision is exposed as production authorization. + +## 9. Freeze representation and fixture semantics + +Specify byte/string bounds, UUID validation, time units, digest case, and the +integer domain for generations/epochs. Either encode potentially unbounded +64-bit counters as decimal strings or require and enforce JavaScript-safe +integers on all peers. Overflow must reject rather than wrap. + +Fixture `expect` should be structured and machine-checkable, not only prose. +Include a fake monotonic clock and explicit actions such as advance time, +disconnect, resume, transfer, local input, crash, and restart. Negative fixtures +must assert both the error and absence of writes, dispatch, unauthorized receipt +disclosure, or state resurrection. Include canonical-digest vectors shared by +Rust, Swift, and browser implementations. + +## Integration already underway + +The parallel build is implementing explicit CLI verification/status and local +pairing cutoffs, native verified-access gates, exact session selection, and +tests. Astation's optional `terminal-lease-v1` compatibility extension is a +transitional terminal-only mechanism; it does not replace this higher-level +session protocol or satisfy multi-controller/account-wide trust by itself. +Once session-v1 is negotiated, its common lease must govern all mutation paths. diff --git a/designs/protocol/atem-session-v1.md b/designs/protocol/atem-session-v1.md new file mode 100644 index 0000000..02d69b8 --- /dev/null +++ b/designs/protocol/atem-session-v1.md @@ -0,0 +1,454 @@ +# atem-session v1: sessions, control leases and receipts (r6: r5 accepted for compatibility mode, minus legacy paths — for review) + +Status: r6 (2026-10-16) removes every legacy path from r5 at the user's +direction (no backward compatibility; Atem and Astation update together) — +needs Astation's review. r5 was accepted by Astation on 2026-10-15 as the +compatibility-mode implementation contract ([r5 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r5-review.md)). +Final conformance needs the shared fixtures to pass in the Rust, Swift and +browser implementations; §9 remains the security gate. History: r1 (`7a0c691`), r2 (`9383193`), r3 +(`1bf805c`, [r3 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r3-review.md)) +and r4 (`fd907cc`, [r4 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r4-review.md)) +were reviewed by Astation ([r1 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-review.md), +[r2 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r2-review.md), +summarised in [session-multiplexing.md](../session-multiplexing.md) §12). r2 +answered r1 items 1–7 and 9; r3 answered r2 review items 1–5; r4 answered the three r3 corrections; r5 answers the three r4 findings. Item 8 (trust, freshness, end-to-end channel, +QR grants) stays an implementation gate (§9). Not final until both sides +accept it and the fixtures in `fixtures/` pass in Rust, Swift and the browser. + +## 0. Layering and activation + +``` ++-----------------------------------------------------------------+ +| atem-session v1 (this doc): registry, leases, receipts | +| terminal-v1 / chat-v1 streams, bound to this doc's targets | ++-----------------------------------------------------------------+ +| secure channel (§9 gate): endpoint keys, E2EE, freshness | ++-----------------------------------------------------------------+ +| transport: direct ws :8080 | relay (atem_id + connection_id) | ++-----------------------------------------------------------------+ +``` + +- Direct and relay are thin transports; messages are identical on both and the + host deduplicates across them. +- **Compatibility mode** (until §9 is implemented): the messages run inside + today's authenticated Astation socket for isolated tests and for the one + Astation already verified with this host. In this mode: no resume (§3), no + browser clients, no account-wide native access, and no claim of relay + confidentiality. Admission fails closed; no placeholder trust decision is + exposed as production authorization. +- **One session authority, no legacy paths** (r6). Atem and Astation are + updated together; there is no backward compatibility. Every remote mutation + of a session — terminal input/resize/stop, chat submit, interrupt — is a + session-v1 message carrying `binding` and the current lease, after `welcome`. + The old ops are removed from the host and answered with + `error{code:"unsupported_version"}` and no effect: `agentInput`, + `agentPrompt`, `voiceCommand`/`voiceToggle` prompts, `userCommand`, + terminal-v1 `control` and its `lease_id` field, terminal-v1 `list`/`sessions` + (session-v1 `list` is the only registry), chat-v1 `submit` without + `binding`, and any session op before `welcome`. Terminal ids are session ids + only (no `claude`/`codex` aliases). + - Local terminal input on the host revokes any remote lease first (§5). + Fixture `removed-ops-have-no-effect`. + +## 1. Representation + +| Item | Rule | +|---|---| +| Envelope | `{"type":"session","data":{"v":1,"op":"…","id":"", …}}`; replies carry `re` | +| UUID | lowercase canonical v4 (`8-4-4-4-12`), validated | +| `session_id` | `ses-` + exactly 12 chars `[a-z2-7]` | +| Opaque ids (`account_id`, `atem_device_id`, `client_device_id`) | 1–128 bytes, `[A-Za-z0-9._:-]` | +| Counters (`host_trust_gen`, `lease_epoch`, `runtime_epoch`, `seq`) | JSON integers 0 ≤ n ≤ 2^53−1; a value that would exceed it is refused (`error{code:"unsupported_version"}` for peers, fatal for the host's own counters: it stops admitting and asks for re-pairing) — never wraps | +| Times | integer milliseconds since the Unix epoch; **display/estimation only** — authority uses monotonic clocks (§5) | +| Digest | SHA-256, lowercase hex, 64 chars | +| Bounds | message ≤ 1 MiB after decoding; `payload` ≤ 64 KiB canonical; prompt text ≤ 32 768 bytes; `receipt_query.request_ids` ≤ 64; `detail` ≤ 512 bytes; ≤ 64 in-flight requests per attachment (excess → `busy`) | +| Unknown fields | ignored; unknown `v` → `unsupported_version` | +| Parsing | bounds checked before allocation; duplicate object keys, non-integer numbers where integers are required, and out-of-range values are rejected | + +## 2. Identities, target and binding + +| Field | Meaning | +|---|---| +| `account_id` | account owning the data | +| `atem_device_id` | this Atem device/profile | +| `host_trust_gen` | increments durably on unpair/rebind; older values rejected | +| `runtime_gen` | UUID, new each broker (runtime) start | +| `session_id` | one agent session, stable for its life | +| `process_gen` | UUID, one agent process; replacement/resume = new value | +| `controller_session` | UUID, logical controller identity assigned by the host | +| `attachment_gen` | UUID, one live attachment of a controller to the host; new on every connect/resume; fences the previous one | +| `lease_epoch` | per session, increments on every grant, transfer, expiry, release, revoke | + +Every request that targets a session carries one **binding** object: + +```json +{"target":{"account_id":"account-a","atem_device_id":"host-a","host_trust_gen":1, + "session_id":"ses-abcdefghijkl","process_gen":"22222222-2222-4222-8222-222222222222"}, + "attachment_gen":"33333333-3333-4333-8333-333333333333", + "lease_epoch":7} +``` + +- Session ops (§4–§6) carry it as `data.binding`. terminal-v1 and chat-v1 + carry it as `data.binding` too — inside `data`, next to the family's own + fields, never inside a nested payload and never beside the outer envelope. + Their own ids must agree with it: terminal-v1 `terminal_id` = chat-v1 + `agentId` = `binding.target.session_id`; terminal-v1 `generation` = chat-v1 + `generation` (both are the agent process's generation) = + `binding.target.process_gen`. A disagreement is `stale_target`. Exact + envelopes: + +```json +{"type":"terminal","data":{"v":1,"op":"subscribe","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-2222-4222-8222-222222222222", + "binding":{"target":{…},"attachment_gen":"33333333-…"}}} +{"type":"terminal","data":{"v":1,"op":"input","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…","sequence":0,"bytes":"aGkN", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"terminal","data":{"v":1,"op":"resize","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…","rows":40,"cols":120, + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"terminal","data":{"v":1,"op":"stop","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"agentChat","data":{"v":1,"op":"open","conversationId":"…","agentId":"ses-aaaaaaaaaaaa", + "binding":{"target":{…},"attachment_gen":"33333333-…"}}} +{"type":"agentChat","data":{"v":1,"op":"submit","conversationId":"…","agentId":"ses-aaaaaaaaaaaa","generation":"22222222-…", + "requestId":"44444444-…","text":"run the tests", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +``` + + terminal-v1 has no `lease_id` field (the binding's epoch is the lease); a + chat `submit` is the §6 `submit` with + `kind:"prompt"`, `request_id = requestId`, `payload = {"text": text}`. +- `lease_epoch` is required for mutations, absent for reads. +- Any mismatch → `error{code:"stale_target"}` with **no** current values; the + client re-lists (§4). Errors never reveal another target's ids, paths or + receipts; an unauthorized or unknown session is `unknown_session` either way. + +## 3. Hello, attachment and resume + +| op | dir | fields | +|---|---|---| +| `hello` | C→H | `resume?: {controller_session}` | +| `challenge` | H→C | `nonce` (32 bytes, base64url) — only when resuming | +| `resume_proof` | C→H | `proof` (§9: signature by the client's authenticated key over the transcript) | +| `welcome` | H→C | `account_id`, `atem_device_id`, `host_trust_gen`, `runtime_gen`, `controller_session`, `attachment_gen`, `client_kind` (`native`/`browser`, as authenticated), `permissions` (`view`, `control`, `approve`), `authorization_ms_left`, `server_time` | + +- The client's device and kind come from the authenticated channel, never from + `hello` fields or relay labels. A `controller_session` UUID is not a credential. +- Resume needs key possession over a fresh `challenge` and yields a **new** + `attachment_gen`; the host fences the old attachment (closes it and rejects + anything still arriving on it) before accepting mutations on the new one. + Resume never re-acquires a lease and never revives an expired one. +- Compatibility mode: no `challenge`/`resume_proof`; every `hello` gets a new + `controller_session` and `attachment_gen` and holds no lease. A `hello` + with `resume`, and any `resume_proof`, is refused `unauthorized`. A second + `hello` on an attachment that was already welcomed is refused + `unauthorized` (a new welcome needs a new attachment). +- A broker restart (new `runtime_gen`) invalidates all attachments and leases; + workers and receipts stay addressable by `session_id`/`process_gen`. + +## 4. Session registry + +| op | dir | fields | +|---|---|---| +| `list` | C→H | — | +| `sessions` | H→C | `runtime_gen`, `runtime_epoch`, `seq`, `sessions: [descriptor]` | +| `session_changed` | H→C | `runtime_gen`, `runtime_epoch`, `seq`, `descriptor` | + +Descriptor: + +```json +{"session_id":"ses-abcdefghijkl","process_gen":"…","provider":"claude", + "cwd_label":"Atem","state":"running","activity":"ready","activity_since":1760500000000, + "controller":{"controller_session":"…","lease_epoch":7}|null, + "capabilities":["terminal-v1","chat-v1"],"created_at":1760490000000} +``` + +- Every descriptor field is **required** in both `sessions` and + `session_changed` (a change carries the complete new descriptor, never a + patch); `controller` is `null` when no lease is held and `capabilities` may be + `[]`. Decoders reject a descriptor with a missing field. A removal is a + complete descriptor with `state:"removed"` (its last values). +- `state`: `running`, `exited`, `interrupted`, `recovery_required`, `closed`, + `removed`. `activity`: `ready`, `working`, `waiting`, `unknown`. +- **Runtime fencing.** `runtime_epoch` is a host-persisted counter incremented + at every broker start (with `runtime_gen` its UUID). A client accepts a + snapshot or change only if its `runtime_epoch` ≥ the highest it has accepted + from this host; a higher epoch replaces all state (a snapshot at `seq` 1 of + epoch 8 replaces `seq` 100 of epoch 7); anything from a lower epoch is + discarded, so a delayed old-runtime snapshot can never switch the client back. + Each accepted epoch has one fixed `runtime_gen`; the same epoch with a + different `runtime_gen` is rejected. After accepting a new epoch from a + change (not a snapshot), the client sends `list` and applies nothing from + that epoch until its snapshot arrives. +- **Per-attachment sequence.** `list` subscribes the attachment. The host then + sends, on that attachment's single ordered stream, one `sessions` snapshot + followed only by `session_changed` events. `seq` counts the messages *this + attachment* received in this epoch (snapshot = n, next change = n+1, …), so + changes to sessions it can't see never create gaps. Within an epoch the client + applies a change only if `seq == last + 1`; `seq ≤ last` is a duplicate and is + ignored; `seq > last + 1` is a gap: it sends `list` and, until the new + snapshot arrives, discards every change with `seq` ≤ that snapshot's `seq`. + A snapshot whose `seq` is lower than the last applied one (same epoch) is + discarded. +- **New attachment.** `seq` belongs to one attachment. On every `welcome` + (reconnect) the client clears its registry state — sessions, last `seq`, + removals and held changes — keeping only its highest accepted + (`runtime_epoch`, `runtime_gen`) as the fencing floor; the new attachment's + first snapshot applies at any `seq`. +- **Removal.** A removed session is sent as `state:"removed"`; applying it + deletes the session, and a later change for that `session_id` is only accepted + with a higher `seq` in the same epoch and a different `process_gen` — never + as a resurrection of the removed one. (Tombstones are kept 10 minutes host-side + only to answer late requests with `unknown_session`.) +- The registry and every response are filtered by the attachment's view + permission: `list` without `view` is `forbidden`; an op with a `binding` + from an attachment without `view` is `unknown_session`. `cwd_label` is the last path component only; clients show + `session_id` when labels repeat. Presence is never liveness evidence. + +## 5. Control lease + +| op | dir | fields | result | +|---|---|---|---| +| `acquire` | C→H | `binding` (no `lease_epoch`), `takeover` | `lease` or `error` | +| `renew` | C→H | `binding` | `lease` or `error` | +| `release` | C→H | `binding` | `lease_released` | +| `lease` | H→C | `binding` (with the current `lease_epoch`), `ms_left` | — | +| `lease_changed` | H→viewers | `session_id`, `process_gen`, `controller` (or null), `lease_epoch`, `reason` (`granted`, `takeover`, `local_input`, `expired`, `released`, `revoked`, `process_ended`) | — | + +Normative rules: + +- **Duration**: a lease lasts 15 s from its grant/renewal on the host's + monotonic clock (expired when `now >= grant + 15 000 ms`), capped by the attachment's remaining authorization. Clients + renew every 5 s and never after their own monotonic estimate of expiry. + `ms_left` is for estimation only. +- **Fencing**: a lease is held by one (`controller_session`, `attachment_gen`). +- **Epochs**: `lease_epoch` strictly increases per session and may skip values + (the host reserves epochs durably in blocks, so after a restart the next + grant can be well above the last one seen); clients compare, never count. +- **Recipients**: `lease_changed` goes to every welcomed attachment with `view` + except the one whose own request caused the change (it gets the direct + reply: `lease` or `lease_released`). Expiry, local input, revocation and + process end have no requester, so the holder gets `lease_changed` too. +- **Reply correlation and the client deadline** (normative for clients): + 1. A `lease` reply is applied only if its `re` equals one outstanding + `acquire`/`renew` request id, and each outstanding request consumes at most + one reply; replies with an unknown, already consumed or duplicate `re` are + dropped, even when the binding matches. + 2. Before applying it, the client checks its previously acknowledged local + deadline on the monotonic clock; if that deadline has passed (`now >= + deadline`, whether or not a UI timer fired), the reply is dropped, the + client becomes a viewer, and only an explicit new `acquire` can regain + control. + 3. Otherwise the new deadline = the request's monotonic **send** time + + `ms_left` from the reply (never the reply's arrival time + 15 s), and never + later than the authorization deadline. + 4. For a repeat `acquire` while already holding control, the new deadline = + `min(previous deadline, request send time + ms_left, authorization + deadline)`: it never extends and honours a shorter remaining lifetime. + 5. Expiry is `now >= deadline`, on both host and client. A client that computes + a deadline already reached when it applies a reply disables control and input + immediately (it never holds control with zero remaining lifetime). An explicit `acquire` after expiry is + a new operation (rule 2 still discards replies to requests sent before it). + Fixture `late-renew-reply-does-not-resurrect-control` (acquire at t=0, renew + at t=5000, reply delivered at t=17000 with no timer tick; a duplicate earlier + reply while a later renew is pending; a repeat-acquire reply near expiry). +- **Renew** after expiry → `stale_lease` (it never re-acquires). +- **Repeat acquire** by the current holder on the same attachment returns the + current lease unchanged (same epoch, host deadline unchanged, `ms_left` = what + remains); on another attachment of the same controller → `stale_lease` + (resume first). +- **Takeover** (`takeover: true`, needs `control`) moves the lease to the caller + with a new epoch; the previous holder gets `lease_changed{reason:"takeover"}` + and stays a viewer. Without takeover a held lease → `lease_held`. +- **Local input wins**: local terminal input on the host revokes any remote lease + (new epoch, `reason:"local_input"`) before that input is written. +- **Input epoch**: the worker's PTY writer checks the session's input epoch at + write time for every queued chunk. Transfer, expiry, release, local input and + revoke bump that epoch first, so already-queued remote bytes are dropped, not + written. Raw keystrokes are never replayed after reconnect. +- **Restart**: leases are not persisted. A worker restart (new `process_gen`) or + broker restart (new `runtime_gen`) invalidates them; the epoch counter itself + is persisted per session so it never repeats. +- **Scope**: the lease governs terminal input/resize/stop, chat submission, + interrupt, approval answers and future mutations. Disconnect stops renewal; + the lease then expires. + +## 6. Submissions and receipts + +| op | dir | fields | +|---|---|---| +| `submit` | C→H | `binding` (with `lease_epoch`), `request_id`, `kind`, `payload` | +| `receipt` | H→C | `request_id`, `target`, `kind`, `state`, `detail?`, `updated_at` | +| `receipt_query` | C→H | `binding` (no `lease_epoch`), `request_ids` (≤ 64) | +| `receipts` | H→C | `receipts: [receipt]` (only those bound to that exact target) | + +Kinds and payloads (v1): + +| kind | payload | completes when | +|---|---|---| +| `prompt` | `{"text": string}` | the provider confirms the turn ended (chat-v1 `complete`) | +| `interrupt` | `{}` | the provider reports the turn interrupted, or the turn was already over (`detail`) | +| `stop` | `{}` | the agent process exits | +| `approval` | reserved for v1.1 (needs an approval identity); rejected with `forbidden` + `detail:"approval answers are not supported in v1"` | + +- **Digest**: the host computes `payload_digest = sha256(JCS(payload))`, JCS = + RFC 8785 over the supported domain (objects, arrays, strings, booleans, null, + integers in the safe range; no floats). A client-supplied digest is not used. + Known-answer vectors: `fixtures/digest-vectors.json`. +- **Immutable request**: (`request_id`, `account_id`, `atem_device_id`, + `host_trust_gen`, `session_id`, `process_gen`, `kind`, `payload_digest`). + Same `request_id` with every bound value equal → the recorded receipt (needs + current view permission for the target; no lease, no re-execution). Same + `request_id` with any value different → `request_id_reused`. +- **Admission** of a new request — and every lease-scoped mutation + (terminal input/resize/stop) — is checked in this order: `control` + permission (`forbidden`), then a session that accepts work + (`session_closed`, even when the lease is also stale), then the current + lease (`stale_lease`). If the durable write of + `accepted` fails, the reply is `error{code:"busy"}`, nothing is recorded and + the client may retry with the same `request_id`. +- A duplicate `submit` names the current target: one naming an earlier + `process_gen` of the session is `stale_target`, while `receipt_query` for + that earlier target still returns its receipts. +- **Scheduling**: `prompt`s are serialized per session (one paste at a time; + later ones stay `accepted` until the previous one leaves `executing`); + `interrupt` and `stop` are dispatched immediately. +- **States and transitions** (all others are invalid and never happen): + +| from | to | when | +|---|---|---| +| — | `accepted` | admitted and durably recorded (fsync) — then the reply | +| — | `rejected` | refused at admission (nothing recorded as accepted) | +| `accepted` | `executing` | durably recorded before the first external side effect | +| `accepted` | `rejected` | before any side effect: session closed, trust generation changed, or the durable write of `executing` failed | +| `executing` | `completed` | the kind's completion (table above) observed | +| `executing` | `rejected` | dispatch failed **with proof that no side effect happened** (see below) | +| `executing` | `outcome_unknown` | dispatch failed after a possible partial effect, the agent process ended before the kind's completion, or crash/restart and reconciliation can't establish the outcome | + + `completed`, `rejected` and `outcome_unknown` are final. Every exit from + `executing` is persisted (fsync) before it is reported; a receipt never stays + `executing` because of an ordinary failure. +- **Dispatch failures** (normative, per kind): + - `prompt`: the paste is one PTY write of all chunks reserved together. If the + reservation/enqueue fails, no byte reached the PTY → `rejected`, + `detail:"could not write to the terminal"`. If the writer reports an error + after some bytes may have been written → `outcome_unknown`, + `detail:"write interrupted"`. If the agent process ends before the turn + completes → `outcome_unknown`, `detail:"agent exited"`. + - `interrupt`: the interrupt key could not be enqueued → `rejected`; written + but no interrupted/ended turn observed within 10 s → `outcome_unknown`, + `detail:"no interrupt observed"`. + - `stop`: the signal could not be delivered (e.g. `ESRCH`/`EPERM`) and the + process is still alive → `rejected`, `detail:"stop failed"`; delivered but + the process hasn't exited within 10 s → `outcome_unknown`, + `detail:"process still running"` (a later observed exit doesn't change it). + - A failure that cannot be classified as "no side effect" is + `outcome_unknown`. Neither final failure state is ever retried + automatically; a duplicate `submit` returns the recorded final receipt. A receipt's + `request_id`, target, kind and digest never change; replies carry the same + values through recovery. +- **Crash boundaries**: `accepted` is persisted (fsync) before the reply; + `executing` is persisted before the first external side effect (e.g. the + paste). After a crash, two cases stay distinct: + `accepted` work has provably not started any side effect, so it may still be + dispatched — once, under its original target, and only if the host trust + generation is unchanged (else `rejected`, `detail:"host trust changed"`). + `executing` work may have started a side effect: it is never dispatched again; + it is reconciled with the worker/provider, and if that can't establish the + outcome it becomes `outcome_unknown`. +- Controller disconnect and lease expiry don't cancel accepted work. +- **Retention**: receipts live for the session's life and 7 days after it closes. + An accepting session's receipts are never evicted; at its limit new + submissions get `receipt_limit`. Closed sessions answer `session_closed`. +- chat-v1: its `submit.requestId` is this + `request_id`, it carries `binding`, and its `accepted`/`complete` follow this + receipt's `accepted`/`completed`. + +## 7. Errors + +`error{re, code, detail?}`; `detail` is human-readable, bounded, and never +contains secrets, paths, raw OS errors or other targets' identifiers. + +Errors are answered in the family of the request (ruling i): + +- session-v1 requests: session `error{re, code, detail?}`. +- A valid terminal-v1 request that is refused: terminal-v1 + `error{terminal_id, stream_id?, code, message}` (`stream_id` when the + request carried one; `message` is fixed text per code). +- chat-v1: `error{requestId?, code, text}` (chat-v1's `error` gains `code`). + A chat `submit` hears about its request only through chat-v1 ops mapped + from its receipt, never a session `receipt`: `accepted`/`executing` → + `accepted{requestId}`; `completed` → `complete{requestId, turnId?, + outcome:"done"}`; `rejected` → `error{requestId, code:"rejected", text: + detail}`; `outcome_unknown` → `error{requestId, code:"outcome_unknown", + text: detail}`. Refusals at admission use the §7 code. +- Removed forms (§0) and requests that don't decode — of any family — get a + session `error{code:"unsupported_version"}` without `re` when the request + had no readable `id`, and have no effect. + +| code | when | +|---|---| +| `unsupported_version` | unknown `v`, out-of-range representation, or a removed op (§0) | +| `unauthorized` | not authenticated, attachment fenced, or authorization expired | +| `forbidden` | missing permission | +| `stale_target` | trust generation, session or process generation mismatch | +| `unknown_session` | unknown or not visible to this attachment | +| `lease_held` | another controller holds the lease (no takeover) | +| `stale_lease` | wrong, expired or other-attachment lease epoch | +| `request_id_reused` | same request id, different bound values | +| `receipt_limit` | the session's receipt journal is full | +| `session_closed` | the session no longer accepts submissions | +| `busy` | the agent or attachment can't take it now | +| `paused` | authorization freshness deadline passed; viewing/control paused | + +## 8. Fixtures + +One JSON file per scenario in `fixtures/`, schema `fixtures/schema.md`: + +```json +{"name":"lease-expires-without-renewal", + "setup":{"sessions":[{"session_id":"ses-aaaaaaaaaaaa","process_gen":"…"}],"clients":["c1"]}, + "steps":[ + {"send":"c1","json":{"type":"session","data":{"v":1,"op":"acquire",…}}}, + {"expect":"c1","match":{"op":"lease","binding.lease_epoch":1}}, + {"advance_ms":15001}, + {"expect_all":{"op":"lease_changed","reason":"expired"}}, + {"send":"c1","json":{…"op":"renew"…}}, + {"expect":"c1","match":{"op":"error","code":"stale_lease"}}, + {"assert":{"pty_writes":0,"dispatches":0}}]} +``` + +The setup fixes the host context (`account_id`, `atem_device_id`, +`host_trust_gen`, `runtime_gen`, `runtime_epoch`) and each client's +authenticated identity and permissions; nothing is derived from message fields. +Actions (deterministic clock, see `fixtures/schema.md`): `send`, `expect`, +`expect_none`, `advance_ms`, `hold`/`deliver` (delay and reorder a specific +message), `disconnect`, `resume`, `local_input`, `crash_worker`, +`restart_runtime`, `fail_next_fsync`, `change_trust_gen`, `assert` (host +counters: PTY writes, resizes, stops, dispatches, receipts visible per client; +client state for client-side fixtures: `controls`, `deadline_ms`, registry). Negative scenarios assert both the +error and the absence of writes, dispatch, disclosure or resurrection. The +first set (r3 adds the r2-review cases: removed ops have no effect, late/duplicate +lease replies, runtime epoch snapshots, snapshot/change reordering, hidden-session +changes, change after removal, receipt-query authorization, changed-target id +reuse, failed durable write, local reclaim at the PTY boundary, old attachment +and old runtime input): hello/welcome, list/snapshot+revision, gap recovery, tombstone, +acquire/renew/expiry, repeat acquire, takeover, local input revocation, queued +bytes dropped on transfer, stale epoch, submit accepted→executing→completed, +dispatch failure before any effect (→ rejected) and after a possible partial +effect (→ outcome_unknown), each followed by a duplicate submit and recovery +after restart returning the same final receipt, +duplicate submit, reused id (other target), crash after executing → +outcome_unknown, trust change before dispatch → rejected, closed session, +receipt query scoped to target. + +## 9. Gate: trust, freshness, channel and browser grants (joint) + +Must be specified jointly, implemented and tested before account-wide native +access, resume, browser clients or relay confidentiality are enabled: trust +anchor installation, signed native membership and bounded management +delegation, host acceptance of account-wide authority, unpair generation +commits, signed revocation ordering, authenticated freshness proofs (the 24 h +window starts at a validated authority proof, never at relay presence, +reconnection, an old signature or a relay timestamp; rollback protection and +conservative restart/clock handling), channel key establishment with +replay/downgrade protection binding endpoint keys, account, roles, permissions +and generations, the `resume_proof` format, and QR grant binding. diff --git a/designs/protocol/fixtures/closed-session-refuses-work-before-the-lease-check.json b/designs/protocol/fixtures/closed-session-refuses-work-before-the-lease-check.json new file mode 100644 index 0000000..e0c6387 --- /dev/null +++ b/designs/protocol/fixtures/closed-session-refuses-work-before-the-lease-check.json @@ -0,0 +1,193 @@ +{ + "name": "closed-session-refuses-work-before-the-lease-check", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "close_session": "ses-aaaaaaaaaaaa" + }, + { + "expect": "c1", + "match": { + "op": "lease_changed", + "reason": "revoked", + "lease_epoch": 2, + "controller": null + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000001", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000001", + "code": "session_closed" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "session_closed", + "terminal_id": "ses-aaaaaaaaaaaa", + "stream_id": "${s}" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000002", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "code": "session_closed", + "requestId": "44444444-4444-4444-8444-000000000002" + } + }, + { + "assert": { + "pty_writes": 0, + "dispatch_attempts": 0, + "stops": 0 + } + } + ], + "note": "Closing a session revokes its lease; later work is session_closed (checked before the lease) in each family, with no effect." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/digest-vectors.json b/designs/protocol/fixtures/digest-vectors.json new file mode 100644 index 0000000..30eec86 --- /dev/null +++ b/designs/protocol/fixtures/digest-vectors.json @@ -0,0 +1,140 @@ +{ + "algorithm": "sha256(RFC 8785 JCS(payload)) lowercase hex. Supported domain: objects, arrays, strings (valid Unicode scalar values only), true/false/null, integers within ±(2^53−1). Keys sorted by UTF-16 code units. Parsers must reject the must_reject inputs before canonicalising (duplicate keys at any depth, lone surrogates, non-integers, exponents, unsafe integers, invalid UTF-8).", + "vectors": [ + { + "name": "prompt-ascii", + "payload": { + "text": "run the tests" + }, + "canonical": "{\"text\":\"run the tests\"}", + "sha256": "51b365c654de71c69dac5afdc09dbbf06929ef6137ee7878f32937071d2923a8" + }, + { + "name": "prompt-unicode", + "payload": { + "text": "héllo 世界" + }, + "canonical": "{\"text\":\"héllo 世界\"}", + "sha256": "98945f882da718228730803d0d175141da947f0f1169099d47b558dd44746e8e" + }, + { + "name": "empty", + "payload": {}, + "canonical": "{}", + "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }, + { + "name": "nested-sorted", + "payload": { + "text": "a", + "b": [ + 1, + true, + null, + "x" + ] + }, + "canonical": "{\"b\":[1,true,null,\"x\"],\"text\":\"a\"}", + "sha256": "11132adf10a49624454ea644e1582f0669cf71867b61bd12f20b80ee3cb28e02" + }, + { + "name": "escapes", + "payload": { + "text": "line1\nline2\t\"q\"\\" + }, + "canonical": "{\"text\":\"line1\\nline2\\t\\\"q\\\"\\\\\"}", + "sha256": "40e4b600f8bca9c4c6636065e2274854929101b360f5bc2a4d221ad42e9b450a" + }, + { + "name": "utf16-key-order-supplementary", + "payload": { + "😀": 1, + "fi": 2, + "a": 3, + "é": 4 + }, + "canonical": "{\"a\":3,\"é\":4,\"😀\":1,\"fi\":2}", + "sha256": "db257859f37825cbdbc7e3fa930596e1fa4eac1ce98d86df74169ec143eaf4aa" + }, + { + "name": "supplementary-value", + "payload": { + "text": "emoji 😀 and CJK 世" + }, + "canonical": "{\"text\":\"emoji 😀 and CJK 世\"}", + "sha256": "b9c8f42c512a0164707cae3a06ec873f703bc9e963cf6c6871d96c01eab49d6e" + }, + { + "name": "control-escapes", + "payload": { + "text": "\u0000\u0001\u001f\b\f\n\r\t/" + }, + "canonical": "{\"text\":\"\\u0000\\u0001\\u001f\\b\\f\\n\\r\\t/\"}", + "sha256": "235e007d5d4c4a4288ab4b44483417567dcc3391c301176fbcc39413735f07c9" + }, + { + "name": "safe-integer-max", + "payload": { + "n": 9007199254740991, + "m": -9007199254740991 + }, + "canonical": "{\"m\":-9007199254740991,\"n\":9007199254740991}", + "sha256": "3f54c80abdfbc38f08ed5654622eea6e7d7e58cbac36cf5a0670c8aeb054703d" + }, + { + "name": "nested-key-order", + "payload": { + "z": { + "b": 1, + "a": 2 + }, + "a": [ + { + "y": true, + "x": false + } + ] + }, + "canonical": "{\"a\":[{\"x\":false,\"y\":true}],\"z\":{\"a\":2,\"b\":1}}", + "sha256": "7f886746bde2f1329e79e1fa3909a0f0d44b044c2e301ee84d6ae25eede4d698" + } + ], + "must_reject": [ + { + "name": "float-rejected", + "payload_json": "{\"n\":1.5}" + }, + { + "name": "duplicate-key-rejected", + "payload_json": "{\"text\":\"a\",\"text\":\"b\"}" + }, + { + "name": "unsafe-integer-rejected", + "payload_json": "{\"n\":9007199254740992}" + }, + { + "name": "lone-high-surrogate", + "payload_json": "{\"text\":\"\\ud800\"}" + }, + { + "name": "lone-low-surrogate", + "payload_json": "{\"text\":\"\\udc00x\"}" + }, + { + "name": "negative-unsafe-integer", + "payload_json": "{\"n\":-9007199254740992}" + }, + { + "name": "exponent-number", + "payload_json": "{\"n\":1e3}" + }, + { + "name": "nested-duplicate-key", + "payload_json": "{\"a\":{\"k\":1,\"k\":1}}" + }, + { + "name": "invalid-utf8-bytes", + "payload_hex": "7b2274657874223a22ff227d" + } + ] +} \ No newline at end of file diff --git a/designs/protocol/fixtures/dispatch-failure-after-partial-write-is-outcome-unknown.json b/designs/protocol/fixtures/dispatch-failure-after-partial-write-is-outcome-unknown.json new file mode 100644 index 0000000..fec5311 --- /dev/null +++ b/designs/protocol/fixtures/dispatch-failure-after-partial-write-is-outcome-unknown.json @@ -0,0 +1,177 @@ +{ + "name": "dispatch-failure-after-partial-write-is-outcome-unknown", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "fail_next_pty_write_after_partial": true + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444447", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444447", + "state": "accepted" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444447", + "state": "executing" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444447", + "state": "outcome_unknown", + "detail": "write interrupted" + } + }, + { + "assert": { + "dispatch_attempts": 1, + "pty_bytes_written": "unknown" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444447", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444447", + "state": "outcome_unknown" + } + }, + { + "restart_runtime": { + "runtime_gen": "99999999-9999-4999-8999-999999999999", + "runtime_epoch": 8 + } + }, + { + "reconnect": "c1", + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "request_ids": [ + "44444444-4444-4444-8444-444444444447" + ] + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipts", + "receipts.0.request_id": "44444444-4444-4444-8444-444444444447", + "receipts.0.state": "outcome_unknown" + } + }, + { + "assert": { + "dispatch_attempts": 1 + } + } + ], + "note": "The final failure state is durable, never retried, returned for a duplicate submit and still reported after a runtime restart." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/dispatch-failure-before-any-effect-is-rejected.json b/designs/protocol/fixtures/dispatch-failure-before-any-effect-is-rejected.json new file mode 100644 index 0000000..0e5f7f7 --- /dev/null +++ b/designs/protocol/fixtures/dispatch-failure-before-any-effect-is-rejected.json @@ -0,0 +1,177 @@ +{ + "name": "dispatch-failure-before-any-effect-is-rejected", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "fail_next_pty_enqueue": true + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444446", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444446", + "state": "accepted" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444446", + "state": "executing" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444446", + "state": "rejected", + "detail": "could not write to the terminal" + } + }, + { + "assert": { + "dispatch_attempts": 1, + "pty_bytes_written": 0 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444446", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444446", + "state": "rejected" + } + }, + { + "restart_runtime": { + "runtime_gen": "99999999-9999-4999-8999-999999999999", + "runtime_epoch": 8 + } + }, + { + "reconnect": "c1", + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "request_ids": [ + "44444444-4444-4444-8444-444444444446" + ] + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipts", + "receipts.0.request_id": "44444444-4444-4444-8444-444444444446", + "receipts.0.state": "rejected" + } + }, + { + "assert": { + "dispatch_attempts": 1 + } + } + ], + "note": "The final failure state is durable, never retried, returned for a duplicate submit and still reported after a runtime restart." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/duplicate-submit-returns-receipt-and-reuse-is-rejected.json b/designs/protocol/fixtures/duplicate-submit-returns-receipt-and-reuse-is-rejected.json new file mode 100644 index 0000000..e5c5e59 --- /dev/null +++ b/designs/protocol/fixtures/duplicate-submit-returns-receipt-and-reuse-is-rejected.json @@ -0,0 +1,151 @@ +{ + "name": "duplicate-submit-returns-receipt-and-reuse-is-rejected", + "setup": { + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "holds_lease_epoch": 1, + "client_kind": "native" + } + ], + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + } + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444444", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444444", + "state": "accepted" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444444", + "state": "executing" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000004", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444444", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "re": "11111111-1111-4111-8111-000000000004", + "request_id": "44444444-4444-4444-8444-444444444444", + "state": "executing" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000005", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444444", + "kind": "prompt", + "payload": { + "text": "something else" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "error", + "code": "request_id_reused" + } + }, + { + "assert": { + "dispatches": 1 + } + } + ] +} \ No newline at end of file diff --git a/designs/protocol/fixtures/exited-session-refuses-work-before-the-lease-check.json b/designs/protocol/fixtures/exited-session-refuses-work-before-the-lease-check.json new file mode 100644 index 0000000..bef5be3 --- /dev/null +++ b/designs/protocol/fixtures/exited-session-refuses-work-before-the-lease-check.json @@ -0,0 +1,193 @@ +{ + "name": "exited-session-refuses-work-before-the-lease-check", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "crash_worker": "ses-aaaaaaaaaaaa" + }, + { + "expect": "c1", + "match": { + "op": "lease_changed", + "reason": "process_ended", + "lease_epoch": 2, + "controller": null + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000001", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000001", + "code": "session_closed" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "session_closed", + "terminal_id": "ses-aaaaaaaaaaaa", + "stream_id": "${s}" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000002", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "code": "session_closed", + "requestId": "44444444-4444-4444-8444-000000000002" + } + }, + { + "assert": { + "pty_writes": 0, + "dispatch_attempts": 0, + "stops": 0 + } + } + ], + "note": "Admission order is control -> session accepting -> lease: after the agent exits, a submit, terminal input or chat submit carrying the old lease epoch is session_closed (not stale_lease), each in its own family, with no effect." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/family-errors-and-chat-results.json b/designs/protocol/fixtures/family-errors-and-chat-results.json new file mode 100644 index 0000000..e21f763 --- /dev/null +++ b/designs/protocol/fixtures/family-errors-and-chat-results.json @@ -0,0 +1,414 @@ +{ + "name": "family-errors-and-chat-results", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 2 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "stale_lease", + "terminal_id": "ses-aaaaaaaaaaaa", + "stream_id": "${s}" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "not-the-stream", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "stale_target", + "terminal_id": "ses-aaaaaaaaaaaa", + "stream_id": "not-the-stream" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000001", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 2 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "code": "stale_lease", + "requestId": "44444444-4444-4444-8444-000000000001", + "conversationId": "conv-1" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000002", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "accepted", + "requestId": "44444444-4444-4444-8444-000000000002", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa" + } + }, + { + "turn_complete": "ses-aaaaaaaaaaaa", + "turn_id": "turn-1" + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "complete", + "requestId": "44444444-4444-4444-8444-000000000002", + "turnId": "turn-1", + "outcome": "done" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000002", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "complete", + "requestId": "44444444-4444-4444-8444-000000000002", + "outcome": "done" + } + }, + { + "fail_next_pty_enqueue": true + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000003", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "accepted", + "requestId": "44444444-4444-4444-8444-000000000003" + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "requestId": "44444444-4444-4444-8444-000000000003", + "code": "rejected", + "text": "could not write to the terminal" + } + }, + { + "fail_next_pty_write_after_partial": true + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000004", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "accepted", + "requestId": "44444444-4444-4444-8444-000000000004" + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "requestId": "44444444-4444-4444-8444-000000000004", + "code": "outcome_unknown", + "text": "write interrupted" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000005", + "text": "hi" + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "claude", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "assert": { + "dispatch_attempts": 3, + "dispatches": 2, + "pty_writes": 0 + } + } + ], + "note": "Refused terminal-v1 requests get terminal-v1 error{terminal_id, stream_id, code, message}; a chat-v1 submit hears only chat-v1 ops mapped from its receipt (accepted/executing -> accepted, completed -> complete{outcome:done, turnId}, rejected/outcome_unknown -> error{code, text}), never a session receipt; removed and undecodable family requests get a session error." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/late-renew-reply-does-not-resurrect-control.json b/designs/protocol/fixtures/late-renew-reply-does-not-resurrect-control.json new file mode 100644 index 0000000..8e71da2 --- /dev/null +++ b/designs/protocol/fixtures/late-renew-reply-does-not-resurrect-control.json @@ -0,0 +1,367 @@ +{ + "name": "late-renew-reply-does-not-resurrect-control", + "side": "client", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000090", + "re": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 15000 + } + }, + { + "advance_ms": 2000 + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000014", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000090", + "re": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 15000 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000094", + "re": "11111111-1111-4111-8111-000000000014", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 17000 + } + }, + { + "advance_ms": 3000 + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000011", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "advance_ms": 12000 + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000091", + "re": "11111111-1111-4111-8111-000000000011", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": false + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000012", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000092", + "re": "11111111-1111-4111-8111-000000000012", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 3 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 32000 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000090", + "re": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 32000 + } + }, + { + "advance_ms": 14000 + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000013", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "advance_ms": 500 + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000093", + "re": "11111111-1111-4111-8111-000000000013", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 3 + }, + "ms_left": 500 + } + } + }, + { + "assert_client": { + "controls": false, + "input_enabled": false + } + } + ], + "note": "Client-side fixture (t = fake monotonic clock, ms). While renew B (sent at 2000) is pending, a duplicate of the already consumed acquire reply is dropped and the deadline stays 15000; B's reply then gives 2000+15000 = 17000. The reply to the renew sent at 5000 arrives at t=17000 with no timer tick: the acknowledged deadline (17000) is reached (now >= deadline), so it is dropped. A replayed reply to an already consumed re is dropped. A repeat acquire sent at 31000 whose reply (ms_left 500) is applied at 31500 gives deadline = min(32000, 31000+500, auth) = 31500 = now, so control and input are off immediately (expiry is now >= deadline)." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/lease-expires-without-renewal.json b/designs/protocol/fixtures/lease-expires-without-renewal.json new file mode 100644 index 0000000..4472211 --- /dev/null +++ b/designs/protocol/fixtures/lease-expires-without-renewal.json @@ -0,0 +1,107 @@ +{ + "name": "lease-expires-without-renewal", + "setup": { + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ], + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + } + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000001", + "binding.lease_epoch": 1 + } + }, + { + "advance_ms": 15001 + }, + { + "expect": "c1", + "match": { + "op": "lease_changed", + "reason": "expired", + "lease_epoch": 2 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "error", + "code": "stale_lease" + } + }, + { + "assert": { + "lease_holder": null, + "pty_writes": 0 + } + } + ] +} \ No newline at end of file diff --git a/designs/protocol/fixtures/lease-expiry-at-the-deadline-needs-no-timer.json b/designs/protocol/fixtures/lease-expiry-at-the-deadline-needs-no-timer.json new file mode 100644 index 0000000..6ee9686 --- /dev/null +++ b/designs/protocol/fixtures/lease-expiry-at-the-deadline-needs-no-timer.json @@ -0,0 +1,223 @@ +{ + "name": "lease-expiry-at-the-deadline-needs-no-timer", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000001", + "binding.lease_epoch": 1, + "ms_left": 15000 + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "hold_pty_writes": true + }, + { + "advance_ms": 14999, + "timers": false + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "advance_ms": 1, + "timers": false + }, + { + "flush_pty_writes": true + }, + { + "assert": { + "pty_writes_remote": 0, + "pty_writes_dropped": 1 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000002", + "code": "stale_lease" + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "expired", + "lease_epoch": 2 + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 1, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "stale_lease" + } + }, + { + "assert": { + "lease_holder": null, + "pty_writes": 0 + } + } + ], + "note": "Expiry is now >= deadline whether or not a timer fired: with the clock moved to exactly 15000 ms and no tick, bytes queued at 14999 are dropped at the write boundary, and renew/input are refused. The host may announce the expiry (lease_changed) when it next sees the time." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/local-input-revokes-the-lease-and-drops-queued-bytes.json b/designs/protocol/fixtures/local-input-revokes-the-lease-and-drops-queued-bytes.json new file mode 100644 index 0000000..272ff08 --- /dev/null +++ b/designs/protocol/fixtures/local-input-revokes-the-lease-and-drops-queued-bytes.json @@ -0,0 +1,154 @@ +{ + "name": "local-input-revokes-the-lease-and-drops-queued-bytes", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "hold_pty_writes": true + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "local_input": "ses-aaaaaaaaaaaa", + "bytes": "eA==" + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "local_input", + "lease_epoch": 2, + "controller": null + } + }, + { + "flush_pty_writes": true + }, + { + "assert": { + "pty_writes_local": 1, + "pty_writes_remote": 0, + "pty_writes_dropped": 1, + "lease_holder": null + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 1, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "stale_lease" + } + } + ], + "note": "Local input wins: the remote lease is revoked (new epoch) before the local bytes are written, and remote bytes queued under the old epoch are dropped at the write boundary." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/receipt-query-is-scoped-to-the-exact-target.json b/designs/protocol/fixtures/receipt-query-is-scoped-to-the-exact-target.json new file mode 100644 index 0000000..6c03a0d --- /dev/null +++ b/designs/protocol/fixtures/receipt-query-is-scoped-to-the-exact-target.json @@ -0,0 +1,368 @@ +{ + "name": "receipt-query-is-scoped-to-the-exact-target", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + { + "session_id": "ses-bbbbbbbbbbbb", + "process_gen": "22222222-2222-4222-8222-bbbbbbbbbbbb" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + }, + { + "name": "v", + "attachment_gen": "77777777-7777-4777-8777-777777777777", + "permissions": [ + "view" + ], + "client_kind": "native" + }, + { + "name": "b", + "attachment_gen": "88888888-8888-4888-8888-888888888888", + "permissions": [ + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000001" + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "granted", + "session_id": "ses-aaaaaaaaaaaa" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-bbbbbbbbbbbb", + "process_gen": "22222222-2222-4222-8222-bbbbbbbbbbbb" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000002" + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "granted", + "session_id": "ses-bbbbbbbbbbbb" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000001", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000001", + "state": "accepted" + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000001", + "state": "executing" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000004", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-bbbbbbbbbbbb", + "process_gen": "22222222-2222-4222-8222-bbbbbbbbbbbb" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000002", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000002", + "state": "accepted" + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000002", + "state": "executing" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000005", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "request_ids": [ + "44444444-4444-4444-8444-000000000001", + "44444444-4444-4444-8444-000000000002", + "44444444-4444-4444-8444-000000000099", + "44444444-4444-4444-8444-000000000001" + ] + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipts", + "re": "11111111-1111-4111-8111-000000000005", + "receipts.#": 1, + "receipts.0.request_id": "44444444-4444-4444-8444-000000000001", + "receipts.0.target.session_id": "ses-aaaaaaaaaaaa" + } + }, + { + "send": "v", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000006", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-bbbbbbbbbbbb", + "process_gen": "22222222-2222-4222-8222-bbbbbbbbbbbb" + }, + "attachment_gen": "77777777-7777-4777-8777-777777777777" + }, + "request_ids": [ + "44444444-4444-4444-8444-000000000001", + "44444444-4444-4444-8444-000000000002" + ] + } + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "receipts", + "re": "11111111-1111-4111-8111-000000000006", + "receipts.#": 1, + "receipts.0.request_id": "44444444-4444-4444-8444-000000000002" + } + }, + { + "send": "b", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000007", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "88888888-8888-4888-8888-888888888888" + }, + "request_ids": [ + "44444444-4444-4444-8444-000000000001" + ] + } + } + }, + { + "expect": "b", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000007", + "code": "unknown_session" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000008", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 5, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "request_ids": [ + "44444444-4444-4444-8444-000000000001" + ] + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000008", + "code": "stale_target" + } + }, + { + "assert": { + "receipts_visible": { + "c1": 2, + "v": 2, + "b": 0 + } + } + } + ], + "note": "receipt_query returns only receipts bound to exactly the binding's target (other targets' and unknown ids are absent, duplicates once); without view it is unknown_session; a wrong trust generation is stale_target." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/reconnect-restarts-the-registry-sequence.json b/designs/protocol/fixtures/reconnect-restarts-the-registry-sequence.json new file mode 100644 index 0000000..a1532ae --- /dev/null +++ b/designs/protocol/fixtures/reconnect-restarts-the-registry-sequence.json @@ -0,0 +1,141 @@ +{ + "name": "reconnect-restarts-the-registry-sequence", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "list", + "id": "11111111-1111-4111-8111-000000000001" + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "sessions", + "re": "11111111-1111-4111-8111-000000000001", + "seq": 1, + "runtime_epoch": 7, + "sessions.#": 1 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000002" + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "session_changed", + "seq": 2, + "descriptor.controller.lease_epoch": 1 + } + }, + { + "reconnect": "c1", + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "list", + "id": "11111111-1111-4111-8111-000000000003" + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "sessions", + "re": "11111111-1111-4111-8111-000000000003", + "seq": 1, + "runtime_epoch": 7, + "sessions.0.controller.lease_epoch": 1 + } + }, + { + "local_input": "ses-aaaaaaaaaaaa", + "bytes": "eA==" + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "local_input", + "lease_epoch": 2 + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "session_changed", + "seq": 2, + "descriptor.controller": null + } + } + ], + "note": "seq counts the messages one attachment received: a reconnected client (new attachment) gets a snapshot at seq 1 again and changes continue from there; the old attachment's lease is not inherited." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/registry-restarts-on-a-new-attachment.json b/designs/protocol/fixtures/registry-restarts-on-a-new-attachment.json new file mode 100644 index 0000000..4726886 --- /dev/null +++ b/designs/protocol/fixtures/registry-restarts-on-a-new-attachment.json @@ -0,0 +1,177 @@ +{ + "name": "registry-restarts-on-a-new-attachment", + "side": "client", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000101", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7, + "seq": 5, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + ] + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "seq": 5 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "welcome", + "id": "11111111-1111-4111-8111-000000000102", + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "controller_session": "12121212-1212-4212-8212-121212121212", + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "client_kind": "native", + "permissions": [ + "view" + ], + "authorization_ms_left": 86400000, + "server_time": 1760500000000 + } + } + }, + { + "assert_client": { + "registry": [], + "runtime_epoch": 7 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000103", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7, + "seq": 1, + "sessions": [] + } + } + }, + { + "assert_client": { + "registry": [], + "seq": 1 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "session_changed", + "id": "11111111-1111-4111-8111-000000000104", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7, + "seq": 2, + "descriptor": { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "seq": 2 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000105", + "runtime_gen": "77777777-7777-4777-8777-777777777777", + "runtime_epoch": 6, + "seq": 9, + "sessions": [] + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "runtime_epoch": 7, + "seq": 2 + } + } + ], + "note": "Client side: a welcome starts a new attachment, so the registry clears and the next snapshot applies at any seq (here 1 after 5), while the highest runtime epoch stays the floor." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/removed-ops-have-no-effect.json b/designs/protocol/fixtures/removed-ops-have-no-effect.json new file mode 100644 index 0000000..76d1369 --- /dev/null +++ b/designs/protocol/fixtures/removed-ops-have-no-effect.json @@ -0,0 +1,437 @@ +{ + "name": "removed-ops-have-no-effect", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "up", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + }, + { + "name": "other", + "attachment_gen": "66666666-6666-4666-8666-666666666666", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "up", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "up", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000001" + } + }, + { + "expect": "other", + "match": { + "op": "lease_changed", + "reason": "granted", + "lease_epoch": 1 + } + }, + { + "send": "other", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + } + } + } + }, + { + "expect": "other", + "match": { + "op": "attached" + }, + "capture": { + "s2": "stream_id" + } + }, + { + "send": "other", + "json": { + "type": "agentInput", + "data": { + "agent_id": null, + "kind": "text", + "text": "rm -rf x" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentInput", + "data": { + "agent_id": "ses-aaaaaaaaaaaa", + "kind": "key", + "key": "enter" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentPrompt", + "data": { + "agent_id": "ses-aaaaaaaaaaaa", + "session_id": null, + "text": "hi" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "voiceCommand", + "data": { + "text": "run the tests", + "is_final": true + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "userCommand", + "data": { + "command": "ls", + "action": "cli_input" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "c", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-444444444445", + "text": "hi" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "control", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s2}", + "take": true + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "claude", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + } + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "assert": { + "pty_writes": 0, + "resizes": 0, + "stops": 0, + "dispatches": 0, + "lease_holder": "up" + } + }, + { + "send": "up", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "release", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "up", + "match": { + "op": "lease_released" + } + }, + { + "expect": "other", + "match": { + "op": "lease_changed", + "reason": "released", + "lease_epoch": 2, + "controller": null + } + }, + { + "send": "other", + "json": { + "type": "agentInput", + "data": { + "agent_id": null, + "kind": "text", + "text": "rm -rf x" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentInput", + "data": { + "agent_id": "ses-aaaaaaaaaaaa", + "kind": "key", + "key": "enter" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentPrompt", + "data": { + "agent_id": "ses-aaaaaaaaaaaa", + "session_id": null, + "text": "hi" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "voiceCommand", + "data": { + "text": "run the tests", + "is_final": true + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "userCommand", + "data": { + "command": "ls", + "action": "cli_input" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "c", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-444444444445", + "text": "hi" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "local_input": "ses-aaaaaaaaaaaa", + "bytes": "eA==" + }, + { + "assert": { + "pty_writes_local": 1, + "pty_writes_remote": 0, + "dispatches": 0 + } + } + ], + "note": "r6: no legacy paths. Old ops (agentInput, agentPrompt, voice, userCommand, unbound chat submit, terminal-v1 control) and provider-name terminal ids are refused with unsupported_version and no effect, whether or not a lease is held; only session-v1 acquire grants control; local input still works." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/runtime-epoch-and-sequence-ordering.json b/designs/protocol/fixtures/runtime-epoch-and-sequence-ordering.json new file mode 100644 index 0000000..af943c6 --- /dev/null +++ b/designs/protocol/fixtures/runtime-epoch-and-sequence-ordering.json @@ -0,0 +1,258 @@ +{ + "name": "runtime-epoch-and-sequence-ordering", + "side": "client", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000100", + "runtime_gen": "77777777-7777-4777-8777-777777777777", + "runtime_epoch": 7, + "seq": 100, + "sessions": [ + { + "session_id": "ses-oldoldoldold", + "process_gen": "88888888-8888-4888-8888-888888888888", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "ready", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + ] + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000101", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 1, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "ready", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + ] + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "runtime_epoch": 8, + "seq": 1 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000102", + "runtime_gen": "77777777-7777-4777-8777-777777777777", + "runtime_epoch": 7, + "seq": 101, + "sessions": [ + { + "session_id": "ses-oldoldoldold", + "process_gen": "88888888-8888-4888-8888-888888888888", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "ready", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + ] + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "runtime_epoch": 8 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "session_changed", + "id": "11111111-1111-4111-8111-000000000103", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 3, + "descriptor": { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "exited", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + } + } + }, + { + "assert_client": { + "sent_list": true, + "registry_state": { + "ses-aaaaaaaaaaaa": "running" + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "session_changed", + "id": "11111111-1111-4111-8111-000000000104", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 2, + "descriptor": { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "removed", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000105", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 3, + "sessions": [] + } + } + }, + { + "assert_client": { + "registry": [], + "seq": 3 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "session_changed", + "id": "11111111-1111-4111-8111-000000000106", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 3, + "descriptor": { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "exited", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + } + } + }, + { + "assert_client": { + "registry": [] + } + } + ], + "note": "Old-runtime snapshot after a newer epoch is discarded; a gap (seq 3 after 1) triggers list and the change is not applied; during resync changes with seq <= the snapshot's are discarded; a duplicate/late change for the removed session does not resurrect it." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/schema.md b/designs/protocol/fixtures/schema.md new file mode 100644 index 0000000..64cc194 --- /dev/null +++ b/designs/protocol/fixtures/schema.md @@ -0,0 +1,58 @@ +# Fixture format (atem-session v1, r6) + +One scenario per `*.json` file; `digest-vectors.json` holds the canonical-digest +known-answer vectors (`vectors`: payload → canonical string → sha256; +`must_reject`: raw JSON (`payload_json`) or bytes (`payload_hex`) a parser must +refuse before canonicalising). + +Scenario: `{name, side?, setup, steps, note?}`. `side` is `host` (default: run +against a host implementation with fake clients) or `client` (run a client +implementation; the fixture plays the host with `deliver_to_client`). + +`setup`: + +- `host`: `{account_id, atem_device_id, host_trust_gen, runtime_gen, runtime_epoch}` — the host's own context. +- `sessions[]`: `{session_id, process_gen, chat?}`. +- `clients[]`: `{name, attachment_gen, permissions, client_kind, holds_lease_epoch?}` — the client's authenticated identity, permissions and connection mode, set by the harness (never derived from message fields). + +Host scenarios start with every client already welcomed: the harness connects +each client, sends `hello` itself and consumes (and checks) the `welcome` +before step 0; `reconnect` does the same on the new attachment. With +`holds_lease_epoch: n` the harness then sends a real `acquire` for the +scenario's only session and requires a `lease` at epoch `n`. None of these +setup messages is visible to the steps. + +`steps[]`, in order, on a deterministic monotonic clock starting at 0 ms: + +| step | meaning | +|---|---| +| `{"send": client, "json": msg}` | client sends `msg` to the host (send time = current clock) | +| `{"expect": client, "type"?: t, "match": {path: value}, "capture"?: {name: path}}` | takes that client's next message of envelope type `t` (when given) and op `match.op` (when given); messages of other types/ops stay queued for later `expect`s. The taken message must match every path (dotted paths into `data`, array indexes as numbers, `#` for an array's length: `receipts.#`; a path that doesn't exist never matches, not even `null`), else the step fails. With neither `type` nor `op` it takes the very next message. `capture` stores values for later steps, referenced in any string as `${name}` | +| `{"expect_all": {…}}` | `expect` with that `match` for every client | +| `{"expect_none": client, "for_ms": n}` | nothing of any type is queued for that client after the clock advances n | +| `{"advance_ms": n, "timers"?: bool}` | advance the clock; timers may fire. With `"timers": false` only the clock moves: the host gets no tick and sees the new time only with the next message, write check or event (expiry must still hold: `now >= deadline`) | +| `{"hold": {"to": client, "op": op}}` / `{"deliver": "held"}` | delay the next matching host→client message, deliver it later (reordering) | +| `{"deliver_to_client": client, "json": msg}` | client-side fixtures: the host's message arrives now | +| `{"disconnect": client}`, `{"resume": client, "attachment_gen": uuid}` | transport events | +| `{"local_input": session_id, "bytes": base64}` | local terminal input on the host | +| `{"crash_worker": session_id}`, `{"restart_runtime": {"runtime_gen": uuid, "runtime_epoch": n}}` | failures | +| `{"fail_next_fsync": true}`, `{"change_trust_gen": n}` | durability and trust events | +| `{"fail_next_pty_enqueue": true}` | the next dispatch fails before any byte reaches the PTY | +| `{"fail_next_pty_write_after_partial": true}` | the next dispatch's writer fails after possibly writing some bytes | +| `{"reconnect": client, "attachment_gen": uuid}` | a new attachment for the same client (no resume) | +| `{"close_session": session_id}` | the runtime closes the session (it no longer accepts work; the process may still run) | +| `{"turn_complete": session_id, "turn_id": t}` | the provider reports that the session's executing prompt's turn `t` ended | +| `{"hold_pty_writes": true}` / `{"flush_pty_writes": true}` | remote terminal writes queue at the PTY writer / the writer writes them now, each re-checked at the write boundary at the current clock (dropped ones count as `pty_writes_dropped`) | +| `{"assert": {…}}` | host counters: `dispatch_attempts` (every dispatch the host started, including ones refused before any effect), `dispatches` (dispatches that reached the PTY/agent: attempts minus those that failed with no effect), `pty_bytes_written` (a number or `"unknown"`), `pty_writes` (terminal input written, split as `pty_writes_local`/`pty_writes_remote`; dispatches are counted separately), `pty_writes_dropped` (remote chunks refused at the write boundary), `resizes`, `stops`, `lease_holder`, `receipts_visible: {client: n}` | +| `{"assert_client": {…}}` | client state: `controls`, `deadline_ms`, `registry` (ids), `registry_state`, `runtime_epoch`, `seq`, `sent_list`, `input_enabled` | + +A host scenario fails if, after its last step, any client still has a queued +(unexpected) message or a held message was never delivered: every message the +host sends must be expected. + +Negative scenarios assert the error and the absence of side effects +(writes, resizes, stops, dispatch, disclosure, resurrection). + +Wire messages in fixtures are complete: every documented required field is present (e.g. full session descriptors). Decoders must parse them with the real wire types. + +Expiry is `now >= deadline` everywhere. diff --git a/designs/protocol/fixtures/takeover-moves-control-and-the-old-holder-becomes-a-viewer.json b/designs/protocol/fixtures/takeover-moves-control-and-the-old-holder-becomes-a-viewer.json new file mode 100644 index 0000000..49bce79 --- /dev/null +++ b/designs/protocol/fixtures/takeover-moves-control-and-the-old-holder-becomes-a-viewer.json @@ -0,0 +1,199 @@ +{ + "name": "takeover-moves-control-and-the-old-holder-becomes-a-viewer", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + }, + { + "name": "c2", + "attachment_gen": "66666666-6666-4666-8666-666666666666", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + }, + { + "name": "v", + "attachment_gen": "77777777-7777-4777-8777-777777777777", + "permissions": [ + "view" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + }, + "takeover": false + } + } + }, + { + "expect": "c2", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000001", + "code": "lease_held" + } + }, + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + }, + "takeover": true + } + } + }, + { + "expect": "c2", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000002", + "binding.lease_epoch": 2, + "ms_left": 15000 + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "takeover", + "lease_epoch": 2, + "controller.lease_epoch": 2 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000003", + "code": "stale_lease" + } + }, + { + "send": "v", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000004", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "77777777-7777-4777-8777-777777777777" + }, + "takeover": true + } + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000004", + "code": "forbidden" + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "takeover", + "lease_epoch": 2 + } + }, + { + "assert": { + "lease_holder": "c2" + } + } + ], + "note": "Without takeover a held lease is lease_held; takeover (needs control) moves it with a new epoch, the previous holder and other viewers get lease_changed{takeover}, the requester gets only its lease reply, and the old epoch is stale." +} \ No newline at end of file diff --git a/designs/remote-terminal.md b/designs/remote-terminal.md new file mode 100644 index 0000000..a8a86bf --- /dev/null +++ b/designs/remote-terminal.md @@ -0,0 +1,81 @@ +# Remote terminal control + +This implementation consumes Atem's `terminal-v1` contract from merged Atem +PR #57. It attaches to the original Claude/Codex PTY; opening a window does +not start an agent or create another conversation. The authoritative host +contract is `Atem/designs/terminal-v1-implementation.md` at commit `396a0f3`. + +## Lifecycle and security invariants + +- A window addresses one authenticated Atem connection and one process + generation. Relay envelopes bind both `atem_id` and `connection_id`. + Terminal traffic is never broadcast or routed by terminal focus. +- Subscribe always starts view-only. Typing, resizing and Stop require an + explicitly acquired lease. Release, local reclaim, disconnect, window close + and process exit disable input. Reconnect never restores a lease or retries + uncertain input. +- A stream has its own byte offset and input sequence. Stale connections, + processes, streams, renderer callbacks and leases cannot affect its successor. + Input sequence numbers continue across control changes within a stream. +- `written` means bytes reached the PTY, not prompt acceptance or turn + completion. A missing acknowledgment is an unknown outcome. Errors and + timeouts are visible and require a fresh view before further control. +- Frames are canonical ANSI screen updates, not original PTY output or a chat + transcript. The first restore begins with reset at offset zero. Gaps and + malformed frames require a new subscription. Credit is returned only after + xterm's write-completion callback consumes each frame. +- Frame, input and restore bounds match the host. Slow rendering retains at + most one host credit window. Terminal content stays out of network debug + logs. The viewer has no transcript recorder or remote scrollback. +- The renderer loads pinned local assets with a restrictive content security + policy. Remote content is passed only as base64 bytes to xterm, never HTML + or executable JavaScript. Navigation and unsolicited clipboard access are + blocked. Copy and paste are explicit user actions. +- Existing endpoint authentication remains authoritative. Relay routing + metadata does not establish a new device identity, release encryption keys + or alter encryption state. Terminal transport uses the existing authenticated + connection; storage E2EE does not imply terminal-content E2EE. +- A relay `connected` event starts a fresh endpoint challenge when that + connection is unauthenticated, including after Astation alone reconnects. + It never marks the device online. A racing `hello` reuses the pending + challenge so an in-flight proof stays valid. Atem must handle a challenge + received on an already open socket and revoke old terminal ownership while + reauthenticating; socket liveness alone does not prove an endpoint session. + +## Desktop interaction + +Connections opens a resizable Remote Terminal window for the selected host. +The session chooser lists Claude/Codex process generations and final screens. +View attaches without taking control. Take Control enables normal terminal +typing, IME, paste and control keys. Release restores local ownership. Stop +asks for confirmation before stopping that selected original process. + +The viewport follows the host's canonical dimensions. Only the active remote +controller can resize the PTY to fit its window. View-only windows clip/scroll +an oversized screen without changing the host. Native Copy/Paste actions and +Command-C/Command-V operate on the visible terminal selection/input. + +Keyboard combinations are forwarded as terminal bytes only while controlling. +In particular, tmux's default Control-B then `[` sequence remains two inputs; +the viewer must not synthesize it in response to a wheel event or assume a +particular multiplexer prefix. A tmux wrapper outside Atem is not in the +shared agent PTY's input route. Its copy mode and history require host support +and cannot be reached by sending its shortcut directly to the agent. + +The bundled xterm version is 6.0.0, with zero incidental scrollback and Atem's +`atem-unicode-width-0.1.14` provider. `Config/terminal-assets.json` records +upstream versions, archive integrity and individual asset hashes. Run +`python3 scripts/setup-terminal-assets.py --verify` to verify checked-in assets. + +## Acceptance + +Automated checks cover wire compatibility, selected-host routing, split UTF-8, +renderer credit, bounded queues, stale generations, ownership changes, input +acknowledgments, disconnect/reconnect, process exit and asset integrity. +Native WebKit checks exercise actual xterm rendering, selection and paste. +Synthesized keyboard events verify Control-B followed by `[` and the view-only +input gate; they do not verify physical keyboard delivery or a live tmux pane. +Live tests must use a separately owned test session, never inject prompts or +approvals into the user's existing agent. Cross-machine relay/LAN, physical +IME/clipboard, real provider approvals and latency validation are recorded +separately from synthetic protocol tests. diff --git a/designs/session-multiplexing-protocol-v1.md b/designs/session-multiplexing-protocol-v1.md new file mode 100644 index 0000000..f14e512 --- /dev/null +++ b/designs/session-multiplexing-protocol-v1.md @@ -0,0 +1,208 @@ +# Session Multiplexing: Initial Build Contract + +Date: 2026-10-15 + +Status: historical initial proposal, superseded for managed sessions by +[atem-session v1 r6.2](protocol/atem-session-v1.md). Use the +[current implementation handoff](protocol/atem-implementation-handoff.md). +The optional terminal lease below is retained only as a legacy reference. + +This contract proposed the first compatible layer of +[the approved design](session-multiplexing.md). The Astation and Atem agents +must use the same fixtures and field names. Product approval is not evidence +that every feature described in the design has shipped. + +## Ownership and delivery + +| Layer | Atem | Astation, relay, and webapp | +| --- | --- | --- | +| 1: explicit trust lifecycle and bounded control | Separate pair/verify; unified status; renewable host control leases | Verified-access UI/gates; exact session selection; lease renewal | +| 2: persistent host/session ownership | Shared per-profile broker; independent workers; durable receipts/history | Distinct hosts, sessions, generations, and multiple attachments | +| 3: account-wide native trust | Validate explicit account enrollment authority, native membership, freshness, and encrypted channels | Native enrollment/management, signed revocation, encrypted channels; opaque relay routing | +| 4: independent browser companion | Validate limited browser keys/grants and host control permissions | Native QR approval, pending requests, browser key handshake and scoped routing | + +Each layer needs its own implementation and integration evidence. Do not +advertise account-wide native access, crash-surviving workers, independent +browser access, or live traffic confidentiality before the responsible layers +are implemented. Preserve existing processes and pairwise pins during upgrades. + +## Current implementation limits + +The Astation task worktree now uses the r6.2 client and common session lease in +the native remote terminal UI. Legacy controller code remains isolated for +unmanaged callers and regression coverage; it is not an authority fallback. +See [current evidence](protocol/astation-session-v1-implementation.md). + +All Atem implementation belongs to the Atem agent. Lifecycle commands and trust +generation are published on `feat/status-pair-verify` through `207b0e9`; do not +duplicate that work. +A separate uncommitted local Atem experiment has been stopped and preserved +only as an optional reference diff. Its blanket refusal of remote chat submits +was an unfinished experimental policy, not behavior of a published Atem build. +See the [provenance correction and handoff](protocol/atem-implementation-handoff.md). + +Host lease support and shared session integration must be verified with the +Atem owner's actual implementation. This Astation build does not establish +shared brokers/workers, durable receipts/history, account-wide enrollment, +independent QR companions, traffic E2EE, or complete key recovery. + +## CLI lifecycle + +`atem pair` only establishes a binding. If any persisted binding already exists +for this profile, including expired or unverified pairing, it reports that state +without network pairing, key rotation, or another verification exchange. An +expired login or locked key agent does not authorize replacing the binding. + +`atem pair verify` operates only on the existing pairing, through an authenticated +connection to its exact Astation identity. Both endpoints compare the safety +code and explicitly approve. Valid verified bindings are idempotent. A timeout, +cancel, stale callback, identity mismatch, or persistence failure is not success. +Keep pairing available for an explicit retry; never silently replace revoked or +changed keys. Credential persistence preference remains separate from verification. + +`atem cred unlock` and `atem cred lock` never initiate pairing or verification. +Unverified unlock fails with the exact next action `atem pair verify`. Unlock +can require fresh native Touch ID/password approval using existing trust. Lock +can clear local keys offline, including when no valid pairing exists. Lock and +unpair preserve stored account data, service credential records, and agent work. + +`atem status` is one read-only, offline-capable status implementation. `atem cred status` was removed; use `atem status`. Its user-facing label is `Credentials`. +Distinguish account login, persisted pairing, cryptographic verification, +credential availability, authorization freshness, relay connectivity, and agent +liveness. Cached state is labelled stale or unknown; a saved PID or past relay +connection is not proof of a current connection. Status must not autostart a +key agent, perform verification, prompt for authentication, or mutate trust. + +Local agent launch does not require pairing or verification. Remote operations +require the trust and authorization appropriate to their protocol layer. + +## Terminal lease extension + +Keep the outer `type: terminal` message and the inner terminal `v: 1` encoding. +This is an additive, explicitly negotiated extension, not an account-trust +protocol or a claim of live end-to-end encryption. + +Each session descriptor advertises: + +```json +{ + "terminal_id": "claude-process-a", + "generation": "process-generation-a", + "provider": "claude", + "rows": 24, + "cols": 80, + "exit_status": null, + "rendering": "vt100-screen-diff", + "width_profile": "atem-unicode-width-0.1.14", + "capabilities": ["terminal-lease-v1"], + "agent_id": "agent-a", + "label": "Claude / workspace-a" +} +``` + +`capabilities` defaults to an empty list for an older descriptor. `agent_id` and +`label` are optional. Display labels and provider names never determine a target. +When selecting an agent, match its exact agent/session binding; ambiguous +same-provider sessions require explicit selection rather than selecting the +first session. Session/process IDs cannot be reused for replacement processes. + +Existing `control` obtains/releases ownership. Grant a random `lease_id`, bound +to the authenticated attachment, stream, terminal ID, and process generation. +Expire it 15 seconds after grant or its last accepted renewal using a monotonic +clock. Client input itself does not renew a lease. + +The new request is: + +```json +{ + "v": 1, + "op": "renew", + "terminal_id": "claude-process-a", + "generation": "process-generation-a", + "stream_id": "stream-a", + "lease_id": "lease-a" +} +``` + +An accepted renewal uses the existing response: + +```json +{ + "v": 1, + "op": "controller", + "terminal_id": "claude-process-a", + "generation": "process-generation-a", + "stream_id": "stream-a", + "lease_id": "lease-a" +} +``` + +Renew every five seconds only while holding the currently acknowledged lease. +Only a matching response advances the client's local control deadline. At 15 +seconds without acknowledgment, disable client mutations, clear control, and +require explicit Take Control. A delayed response cannot resurrect expired +control. Never automatically retry terminal input or reacquire control. + +The host checks expiry before input, resize, stop, and renewal, independently +of UI timers. Expired or mismatched renewal is rejected and cannot acquire a +lease. Expiry, disconnect, and local reclaim invalidate the PTY input epoch so +already queued remote bytes cannot cross a later ownership transition. Local +terminal input takes priority. Reattachment never revives an old lease. + +Advertise the capability only after enforcing these rules. A new native client +may view a verified older host, but disables Take Control when the session lacks +`terminal-lease-v1`, with an update instruction. The relay forwards the terminal +envelope opaquely and never issues leases or interprets lease IDs as authority. + +## Security and generation boundary for subsequent layers + +Existing pairwise verification remains authoritative only for its current +binding. Relay account grouping, service login, presence, and ordinary socket +authentication cannot upgrade it into account-wide native trust. + +Before enabling layer 3, define and implement the exact signed native membership +format, management delegation limits, enrollment bootstrap, durable host trust +generation, revocation ordering, and authenticated freshness proof. A fresh proof +must bind an unpredictable endpoint challenge to the accepted trust authority; +replaying a signed snapshot or asking the relay for its current time cannot +refresh the 24-hour window. Persist rollback protection across restarts and +fail closed when a reliable remaining lifetime cannot be established. + +Protected operations must bind account, host/profile identity, host trust +generation, agent/session ID, process generation, authenticated controller, +attachment generation, permissions, request ID, and lease epoch. Authenticate +and encrypt payloads end to end with reviewed cryptographic primitives before +claiming relay confidentiality. Reject protocol downgrade, stale generations, +cross-target reuse, and unknown authority. No transport switch changes trust. + +Before enabling layer 4, implement native-approved browser-key grants with a +two-minute QR request and at most a one-hour grant, fresh proof of browser key +possession, exact target/scope, replay prevention, and host validation independent +of the approving native app remaining online. A legacy URL capability forwarded +through Astation is not this protocol and must not be described as such. + +## Shared fixtures and integration checks + +The canonical fixture is +`Tests/AstationTests/Fixtures/session-multiplexing-v1.json`; Atem checks in the +same bytes at `tests/fixtures/session-multiplexing-v1.json`. Both decoders must +consume the renewable-lease requests, responses, and session metadata. Policy +durations are seconds; wire field names use the existing snake_case convention. + +Required evidence for the first layer: + +- Repeated pair on paired/unverified/expired profiles makes no binding changes. +- Explicit verification handles cancellation, timeout, retry, and revoked keys. +- Status and lock work offline without prompting or starting a key agent. +- Authenticated but unverified remote terminal/chat/mutation paths fail closed. +- Renewal before expiry keeps exactly the same lease; renewal after expiry, + old generations, and old lease IDs cannot regain control. +- At lease expiry or local reclaim, queued input from the former owner is + rejected at the PTY boundary; input is never replayed after reconnect. +- A verified legacy host can be viewed with a clear update requirement for + renewable control. Both direct port 8080 and relay transport remain supported. + +Remaining layers additionally require the complete acceptance matrix in the +product design, including multi-controller fanout, broker/worker crashes, +durable acceptance and receipt recovery, cross-device enrollment, signed +revocation races, and independent QR companion access. diff --git a/designs/session-multiplexing.md b/designs/session-multiplexing.md new file mode 100644 index 0000000..8e1b360 --- /dev/null +++ b/designs/session-multiplexing.md @@ -0,0 +1,674 @@ +# Atem Sessions, Multiplexing, and Device Trust + +Date: 2026-10-16 + +Status: product decisions and all six initial defaults approved by the user on +2026-10-15. The consolidated CLI workflow below is also approved. Implementation +of the native r6.2 client is in draft PR #49, targeting main; joint live host +integration is next. All Atem +implementation belongs to the Atem agent; see the handoff in section 10. The +shared wire protocol in section 12 is a joint draft; required engineering +changes and security gates remain explicit. Product approval is not protocol +security signoff or evidence that all features have shipped. + +## 1. Product decisions + +Enroll Atem once per machine, OS user, and account profile. Further launches and +reconnects reuse that approved identity through automatic authentication. +Account login, device verification, and key unlock are separate states; a locked +key agent or expired login must not automatically mean "pair again." + +`atem pair` is a no-op for an already paired profile: report its current +pairing/verification status without changing identity, trust bindings, keys, or +sessions, and without starting another pairing or verification exchange. +Changing or replacing a pairing requires explicit `atem unpair` first. +`atem pair verify` completes or retries verification of the existing binding +without re-pairing. Unpairing preserves account data and running agents while +invalidating the old host authorization as specified in the recovery lifecycle +below. + +Approved CLI workflow to implement: + +| Command | Purpose | +| --- | --- | +| `atem status` | Unified account, pairing, verification, credential availability, authorization freshness, connectivity, and local-agent status. | +| `atem pair` | Establish a pairing; report status without changes when already paired. | +| `atem pair verify` | Explicit safety-code verification of the existing binding with native approval. | +| `atem cred unlock` | Unlock protected keys using existing verified trust; native Touch ID or password approval may be required. | +| `atem cred lock` | Clear unlocked key material locally, including offline, without requiring verification or native approval. | +| `atem unpair` | Explicitly remove the binding and invalidate its authorization, preserving account data and running agents. | + +`atem status` is read-only, works offline, and marks cached remote information +as stale. Use the user-facing label `Credentials`, not `Keys`: + +```text +Account: signed in +Pairing: paired +Verification: verified +Credentials: locked +Authorization: fresh +Relay: connected +Local agents: 2 running +``` + +Credential availability is separate from pairing and verification. Locking clears +unlocked key material; it does not delete saved credential records. `atem status` +is the only status command; `atem cred status` was removed by user decision. +Do not add a separate `atem pair status` command. + +`atem pair verify` requires an existing pairing and is a no-op for a currently +valid verified binding. Cancellation or interruption preserves pairing and +allows an explicit safe retry. Revocation or changed identities require the +applicable recovery flow; verification cannot silently replace trusted keys. +Neither `atem cred unlock` nor `atem cred lock` starts pairing or safety-code +verification. Unlock on an unverified profile stops with an instruction to run +`atem pair verify`. + +Local `atem agent launch` remains usable without Astation pairing/verification. +Remote viewing/control requires verified, fresh authorization and its applicable +permissions/lease. Memory/vault access additionally requires its grants and, +for encrypted content, available unlocked keys. Verification does not itself +turn account encryption on; the signed migration lifecycle remains separate. + +The design supports: + +- Multiple independent Codex/Claude sessions, including several of one provider, + with their original processes, projects, conversations, and unfinished input. +- Multiple trusted native Astation clients viewing the same Atem session and + switching between sessions without re-pairing. Native iOS/Android clients can + participate alongside Macs. +- Any authorized, verified native Astation can access enrolled Atem hosts under + the same account's trust, subject to host/session permissions. Enroll each + native device and host once; do not pair every Astation-Atem combination or + each independent agent process. +- A web companion authorized by a trusted native Astation scanning its QR code. + Web is not a standalone Astation or a permanent trusted device. +- Account recovery followed by explicit local Atem unpairing, pairing, and fresh + verification when no trusted native device remains. +- Manual device removal in Settings. Recovery never automatically removes an + Astation ID. Account data and surviving encryption material remain intact. +- Keep Astation's authenticated direct WebSocket connection on port 8080, + alongside relay connections, including for Atem and Astation on the same host. + Multiplexing does not require relay-only connectivity. + +Approved initial control policy: multiple viewers, one active controller per +agent session. The accepted defaults cover enrollment authority, control transfer, +browser independence and lifetimes, retention, and offline authorization. + +`atem pair` currently establishes a relationship with an Astation through the +relay. The relay provides discovery and routing; it does not approve device +keys. Account login alone must not authorize an existing host or release its +keys. + +Direct and relay connections are transports for the same identity, session +registry, permissions, control leases, and request receipts. Keep their adapters +thin; do not introduce separate pairing, session, or control implementations for +each path. A direct connection does not grant trust or bypass authorization, +revocation, or confidentiality requirements. + +Same-host direct connections can remain usable during relay/internet outages +while locally validated authorization remains valid under the agreed freshness +policy. A connection switch authenticates the new transport and resynchronizes +the original session without re-pairing, reclaiming expired control, or replaying +input. Simultaneous direct/relay delivery uses the same submission deduplication. +Runtime-to-worker local IPC is separate from Astation's port-8080 listener. + +## 2. Identity and authorization + +| Identity or generation | Purpose | +| --- | --- | +| Account ID | Stable data ownership, independent of email, device removal, or relay routing. | +| Atem device/profile ID and keys | Persist across launches for one OS user/account profile. | +| Astation device ID and keys | Separate protected identity for each native installation. | +| Host trust generation | Changes durably on authorized unpair/rebind; invalidates old remote grants. | +| Agent session ID and process generation | Address one original session and distinguish a replacement process. | +| Runtime and transport generations | Distinguish broker restarts and socket replacements from device trust. | +| Controller session and lease epoch | Identify the attachment currently allowed to operate a session. | +| Browser grant and ephemeral key | Bind one limited companion authorization to one browser session. | + +Commands target the exact account, Atem device, host trust generation, agent +session, and process generation. Conversation and request IDs stay explicit. +Never route a delayed command to whichever agent happens to be selected. + +Only trusted native Astations explicitly authorized to manage devices may +approve new native controllers. Enrollment requires fresh Touch ID or password +approval. Browser companions and Atem sessions cannot enroll controllers. +Recovering an existing Atem host still requires local unpair, pair, and verify. + +Recommended enrollment mechanism: an authorized native device approves another +native device's actual keys and issues account-bound, scoped authorization. +Atem validates that authorization against retained trust and authenticates each +connection. This avoids pairing every controller with every agent session. +Each native device keeps its own private keys; devices do not clone one identity. +Safety codes are derived locally from the verification transcript and actual +endpoint keys. A relay-supplied code or approval cannot establish trust. + +The cryptographic trust anchor, membership/certificate format, and mechanism for +assigning management authority remain engineering specifications. View/control +permission does not imply enrollment authority. Existing pairwise pins cannot +silently become account-wide authority. A host must explicitly accept the +account's enrollment authority before account-wide native access applies; +matching relay account IDs alone is insufficient. Approved native clients should +reach Atem without the original approving Mac acting as a permanent forwarding +process. + +## 3. Runtime and process recovery + +Recommended architecture: one shared runtime per OS user/account profile owns +connection management, device/key-agent access, and the session registry. +Individual `atem` launches attach through owner-restricted local IPC. +Concurrent launches must elect one owner without displacing a live runtime. +Pairing, unlock, sync, and short-lived commands use that shared ownership. + +Each independent session worker owns its original agent process, PTY, output +collection, and submission journal. An existing Atem host can become a worker +without replacing its agent. A broker crash or frontend exit must not terminate +those workers or close their PTYs. OS users and account profiles remain isolated. + +| Failure | Recovery behavior | +| --- | --- | +| Connection loss | Authenticate a new connection and resynchronize the existing session. | +| Shared runtime crash | Surviving workers re-register through authenticated local IPC; preserve their original processes. | +| Worker/agent crash or machine reboot | Show Interrupted or Recovery Required. Offer an explicit provider-supported resume when available. | + +Worker recovery validates session identity, process generation, and process-start +metadata; a PID alone is insufficient. A registry file or saved history cannot +prove that an agent is alive. Provider resume creates a new process generation +and invalidates old leases/actions. Never replay a transcript into a new process +and present it as the original live session. + +Platform supervision and worker adoption mechanisms remain implementation choices. + +## 4. Viewing and control + +The Atem host enforces a renewable control lease per agent session, expiring +after 15 seconds without renewal or earlier when its authorization expires. +Multiple authorized clients can read output, history, and activity, but only the +current controller may submit prompts or terminal input. Interrupts, restarts, +and provider approvals also require their applicable permissions. Holding a +lease never automatically approves a provider permission request. + +Different clients may control different sessions on the same machine. A Mac +could operate Codex while a phone operates Claude, with both viewing both. + +Lease acquisition, transfer, expiry, and revocation serialize at the host. Every +mutation path, including chat, terminal, and future APIs, checks the same target, +controller identity, lease epoch, and permissions. Terminal resize follows the +shared terminal policy. Delayed input from an old owner or process is rejected. + +Take Control explicitly transfers ownership and leaves the previous controller +as a viewer. Local terminal input takes priority and transfers control locally, +invalidating the previous remote lease before accepting conflicting input. +Disconnects expire control within the 15-second renewal window while agents +continue running. Reconnect does not replay input or reclaim an expired lease +automatically. Lease transfer never restarts an agent or resubmits accepted work. + +Renewal scheduling and the UI presentation of ownership changes remain engineering +details within these approved rules. + +## 5. Command receipts and retained history + +A session worker durably records a high-level submission before acknowledging +acceptance. Its request ID binds the exact target and payload digest; duplicates +return the recorded outcome or are rejected. The same ID with different content +is rejected. Broker/socket delivery alone is not durable acceptance. + +Accepted, executing, completed, rejected, and outcome-unknown are distinct. +If dispatch occurs before a crash and the outcome cannot be recovered from the +original worker/provider, report uncertainty without resubmitting. Exactly-once +external effects cannot be guaranteed across arbitrary process crashes. +An admitted task may continue after controller disconnection or lease expiry; +future mutations and cancellation require current authorization. + +Raw terminal keystrokes are a live stream, not a durable task queue. Never replay +uncertain input after reconnect. Reattachment queries session state, history, +and request outcomes rather than starting another conversation. + +Approved retention defaults: + +| Data | Retention | +| --- | --- | +| Request IDs, payload digests, and command outcomes | Entire active session lifetime, then seven days after closure. | +| Atem-owned structured conversation history | Thirty days after closure, with configurable disk limits and export/delete controls. | +| Raw PTY output | Bounded scrollback with visible truncation. | + +Do not evict deduplication receipts while a session accepts submissions. Refuse +new admission explicitly if its receipt limit is reached. Closed/expired +sessions reject later submissions, including after old receipts are deleted. + +Protect persisted records with owner-only access and encrypt sensitive content +under the approved local key policy. Unavailable keys do not permit plaintext +fallback. Retention controls apply to Atem-owned records, not silent deletion of +provider transcripts. Keep structured user/agent/tool messages separate from +terminal repaint output. Memory, skills, vaults, and saved credentials retain +their separate preservation rules. Storage engine and disk budgets remain +engineering specifications; the retention durations above are approved. + +## 6. Web companion: native-scanned QR authorization + +1. The browser tab generates a fresh ephemeral key and a short-lived pending + request, then displays its QR code. +2. A trusted native Astation scans and validates the request. Scanning does not + automatically open arbitrary URLs or grant access. +3. Native Astation shows the request and lets the user select an authenticated + Atem host, exact agent session, permissions, and duration. Browser labels and + requested targets are untrusted hints. The user explicitly approves, using + local device authentication where required. +4. The native client authorizes the exact browser key, request/challenge, + account, host trust generation, agent/process, scope, and expiry. The browser + proves possession of its key through an authenticated bootstrap. The + receiving trusted endpoint validates the issuer and grant; relay approval + alone is insufficient. + +Approved default: view access. Operation additionally requires explicit +control permission and the current session lease. Grants do not enroll devices, +release vault keys, administer encryption, or expand to other agents. Selecting +another target requires new native authorization, not new Atem enrollment. +Remote control still permits actions within the controlled agent's authority; +a scoped grant is not a sandbox around arbitrary shell commands. + +QR requests expire two minutes after creation; approved browser grants expire +one hour after issuance, or earlier on revocation or loss of underlying +authorization. Requests and grants have separate lifetimes. Requests are consumed +atomically. Retries or repeated scans cannot mint extra grants. Changed keys/targets/accounts, +cancellation, expiry, or revocation invalidate pending approval, including late +local-authentication callbacks. Native Astation lists active companion grants +and offers revoke/disconnect. + +The reviewed bootstrap must authenticate both directions. Public request IDs +and keys alone do not authenticate the return approval. QR payloads must not +contain account credentials, long-term keys, recovery material, or usable access +grants. Any one-time out-of-band bootstrap secret belongs only in the reviewed +handshake and must stay out of relay traffic, URLs, logs, and stored history. + +Approved initial storage is tab memory only: reload/close requires a new QR +authorization. A short reconnect within the same tab may resume a still-valid +grant with fresh connection authentication, without replaying submissions. + +After approval, the browser connects through the relay using its scoped grant; +the approving native Astation need not remain online or forward traffic. Closing +that native app does not itself revoke the grant. Atem validates the grant and +its issuer, permissions, expiry, revocation state, and any current control lease. + +QR authorization limits browser authority; it does not prove that delivered +JavaScript is trustworthy. A compromised page could substitute another browser's +QR or misuse its grant. Passkeys do not fix that code-delivery boundary. + +## 7. Account recovery and data preservation + +When no trusted native device remains: + +1. Recover login to the same account and set up a new native Astation. Login + recovery does not approve that device on existing Atem hosts. +2. Use independent local OS access to each host to unpair the Atem profile. + Commit a new host trust generation and invalidate old controller sessions, + browser grants, leases, pending approvals, and input/output routes. +3. Pair the host with the new native Astation, compare safety codes, and complete + explicit approval and cryptographic verification. Remote viewing/control stay + disabled until verification is durably committed. +4. Reauthorize other native clients and companions under the new trust binding. + +This is once per host/profile, not per agent process. Interrupted recovery stays +unpaired/unverified across restart. Without independent host access, the recovered +account has no takeover path through relay administration or a browser QR. + +| Preserve | Invalidate | +| --- | --- | +| Memory, skills, vault entries, and their histories | Old host pairing/session authentication tokens | +| Saved provider/API/SSO service credentials, subject to provider expiry/revocation | Old controller memberships and leases on that host | +| Surviving account/storage keys, encrypted key blobs, and recovery metadata | Browser grants and pending remote approvals | +| Existing account ID, local agents, history, and command receipts | Old trust-bound routes and stale queued remote input | + +Unpair/re-pair must not create an empty data account, move records to a new +namespace, overwrite data, or change signed encryption state. Retain historical +signature metadata needed to validate old records without authorizing new actions. +Local agent work can continue independently of remote trust reset. + +Retained encrypted data requires its original available/unlocked keys or +cryptographic recovery material. Login recovery and fresh verification do not +reconstruct lost keys. Preserve ciphertext and report missing keys explicitly. + +## 8. Manual device management + +Recommended location: Settings > Security > Devices, linked by Manage Devices +from Accounts. Show device name, exact Astation ID, platform, current-device +badge, and last-seen/connection status. Presence is informational, not proof of +cryptographic trust. + +Provide Remove from Account for an individually selected device. Confirmation +names the device/ID and explains that service access is revoked while memory, +vault data, and saved credentials remain. Cancellation changes nothing. Require +authorized account-owner access and fresh authentication where policy requires; +no approval from a lost device is needed. + +Removal is optional and manual, during ordinary account management or after +recovery. Account recovery, login, re-pairing, and time offline never remove an +Astation ID automatically. Device removal is not a prerequisite for local host +trust repair. + +Revoke the selected registration, service routes, and associated companion +sessions. Keep a durable revocation record so deleting a registration cannot +reopen automatic first-use trust. Preserve the stable account and all data; +re-adding a device requires explicit authorization. + +This service-side operation does not replace local host unpairing or new-device +verification, including for direct connections outside relay control. + +## 9. Security boundaries + +The relay transports messages and enforces service access; endpoints authorize +device trust, control, encryption state, and key use. Relay-supplied identities, +labels, presence, counters, and acknowledgments cannot create that authority. + +Live terminal output, prompts, replies, and control messages require reviewed, +authenticated end-to-end encryption before claiming relay confidentiality. +Storage E2EE, HMAC message authentication, and relay-terminated HTTPS/WSS do not +provide that guarantee. Bind handshakes and messages to endpoint keys, account, +roles, permissions, fresh context, and exact target/generations; reject tampering, +replay, substitution, and downgrade. + +Approved offline authorization window: at most 24 hours after the last validated +trust/revocation update. During this window, direct access still requires valid +endpoint authentication, membership, permissions, and any applicable grant or +lease. Shorter authorization expiries and known revocations take effect sooner. +At the freshness deadline, pause further Astation viewing and control until a +valid refresh; keep agents and the local terminal running. A reconnect, relay +presence report, or replayed trust snapshot cannot extend the window. + +Signed membership needs bounded freshness and revocation rules. A valid old +signature does not prove no newer revocation exists. Persist version high-water +marks and freshness deadlines across restarts, and reject stale/unknown +authority for both viewing and mutation. The protocol must define an authenticated +freshness proof and conservative handling of clock changes; the relay cannot +renew authority on its own. Account recovery cannot +instantly change trust on an offline host or through a relay withholding updates; +local unpair is the definite host-side cutoff. Revocation also cannot erase +plaintext, credentials, or keys already copied to a removed device. Protecting +future data after key exposure requires a separate rotation/rekey policy. + +Multiple controllers do not automatically become enrollment or encryption-state +authorities. Initially preserve the existing approved encryption authority, +signed-state checks, and durable/idempotent migration completion lifecycle. +Authority transfer and encrypted-data recovery require separate specifications. +An accepted migration report must not depend on its sender staying connected. + +Native mobile clients protect their own keys using platform-secured storage and +hardware protection where supported. Local biometrics/device authentication +approve local key use; the enrollment protocol establishes remote endpoint trust. + +## 10. Ownership and remaining engineering specifications + +| Area | Owner | +| --- | --- | +| Shared runtime, session workers, receipts, and host control enforcement | Atem | +| Native trust approval, session/control UI, and manual device management | Astation | +| Distinct controller routing, fanout, and replica consistency | Relay | +| QR requests, scanning, and scoped companion authorization | Astation and webapp | +| Trust, revocation, encryption, and recovery wire contracts | Joint protocol design | + +All six product defaults are accepted. No product confirmation remains for the +choices recorded in this document. Before activating the dependent layers, specify: + +- Cryptographic trust anchor, initial enrollment/bootstrap, management-authority + assignment, membership format, and durable host unpair/rebind protocol. +- Authenticated freshness proofs, clock/restart handling for the approved + 24-hour window, encrypted transport, and separate key recovery/rotation protocols. +- QR bootstrap and validation of scoped grants without an online native bridge, + using the approved two-minute request and one-hour grant limits. +- Platform supervision, worker recovery, journal encryption, and disk budgets + under the approved retention policy. +- Lease renewal and atomic local/remote arbitration under the approved + 15-second expiry, explicit takeover, and local-input priority rules. + +Define versioned contracts and cross-client fixtures before implementing either +side. Preserve existing identities and keys during migration; expanding old +pairwise trust requires explicit validation and approval. Unsupported clients +must fail clearly rather than downgrade security. + +### Current build status + +Astations and Atems update together to atem-session r6.2, with no backward +compatibility. Draft [Astation #49](https://github.com/Agora-Build/Astation/pull/49) +uses one authenticated hello/welcome and registry client per host connection, +shared by Connections and terminal windows. Every new attachment resets the +sequence, removals, streams and leases, retaining durable trust/runtime fences. +Terminal requests use exact target/attachment bindings; input/resize also need +the 15-second lease. Prompts, interrupts and stops use durable submissions; +reconnect queries their receipts without replay or control restoration. + +The host is [Atem #69](https://github.com/Agora-Build/Atem/pull/69), on +`feat/runtime`; the contract is [Atem #68](https://github.com/Agora-Build/Atem/pull/68), +with history tagged `atem-session-v1-r6.2`. See the +[pinned host run instructions and wire changes](protocol/atem-host-handoff.md), +[client validation](protocol/astation-session-v1-implementation.md) and +[joint test handoff](protocol/atem-implementation-handoff.md). +All Atem implementation belongs to its owner; no local Atem checkout is used. + +Removed agent lists, terminal lists/control/lease_id, unbound prompts/voice and +console commands are not fallbacks. Browser access is disabled, including on a +socket that has not started hello. The r6.2 runtime currently has one Astation +link and attachment; multiple simultaneous native controllers, QR companions, +account-wide trust, traffic E2EE, freshness proofs and resume still require +section 9. r7 prompt-bound approvals await their contract and are not enabled. +No installed app was replaced or restarted and nothing was merged or deployed. + +## 11. Acceptance criteria + +These are product acceptance requirements. Completed client checks and remaining +host/security work are distinguished in the implementation evidence linked above. + +| Scenario | Required result | +| --- | --- | +| Concurrent launches and pair/unlock/sync with existing agents | One profile identity and runtime owner; preserve processes, drafts, and separately addressed sessions. | +| Repeat `atem pair` on an already paired profile, including incomplete verification | Report current status without starting pairing/verification or changing identity, keys, trust, or sessions; require explicit unpair before replacing the binding. | +| Explicit `atem pair verify`, cancellation, retry, and already-valid verification | Verify only the existing binding; preserve pairing on interruption; do nothing when already validly verified; never silently replace trusted keys. | +| `atem status` and offline execution | Use one read-only implementation with a `Credentials` label, distinct verification/lock state, and visibly stale cached remote information. | +| Credential unlock/lock on an unverified profile | Unlock instructs the user to run `atem pair verify`; lock can clear local keys offline; neither command initiates verification. | +| Same-host direct access during a relay outage, connection switching, and simultaneous direct/relay delivery | Honor valid local authorization; preserve the same sessions, leases, and receipts; authenticate replacement connections and reject duplicate submissions or stale input. | +| Runtime crash, worker failure, PID reuse, and agent restart | Reattach only to authenticated surviving workers; show interrupted sessions honestly; reject old process generations. | +| Several viewers and competing remote/local controllers | One lease holder per session; independent sessions remain independently controllable. | +| Explicit takeover, local terminal input, and missed renewals | Previous controller becomes a viewer; local input wins atomically; remote control expires after 15 seconds without renewal while agents continue. | +| Delayed input after lease transfer, revocation, or account change | Reject stale epochs and cross-account/target commands. | +| Immediate disconnect, delayed dispatch, duplicates, and crash after dispatch | Preserve accepted work and deduplicate; show unresolved outcomes without automatic replay. | +| Receipt/history limits and closed-session expiry | Preserve active deduplication; reject late submissions to closed sessions; identify truncation. | +| Closed-session retention deadlines | Retain receipts for seven days and conversation history for thirty days after closure; preserve memory, skills, vaults, and saved credentials. | +| QR approval, repeated/expired scans, target/key substitution, and late callbacks | One scoped grant for the captured request; no stale approval or secret disclosure. | +| Two-minute QR expiry, one-hour browser expiry, and approving native disconnect | Reject expired requests/grants; an approved browser remains usable within its valid authority when the native app closes. | +| Companion reload, expiry, revocation, or view-only control attempt | Require valid native authorization and control permission; never gain permanent trust or replay input. | +| Enrollment by a viewer, browser, Atem session, or native without fresh authentication | Reject enrollment; require an explicitly authorized native management device and fresh Touch ID or password approval. | +| Account recovery and interrupted host re-enrollment | No automatic host access or device removal; restart stays unverified until new approval commits. | +| Cancel/confirm manual removal of one Astation ID | Cancellation changes nothing; confirmation revokes only that device's service authority, preserving data and blocking silent re-registration. | +| Unpair/re-pair with existing memory, vaults, credentials, and missing keys | Preserve records, identities, and surviving keys; invalidate old access; report unavailable keys without resetting encryption. | +| Malicious relay alters traffic or withholds newer revocation state | Cryptographic validation and the defined freshness policy prevent forged or stale authority. | +| Offline freshness deadline, restart, clock change, or replayed trust update | Enforce the 24-hour maximum without extending stale authority; pause Astation viewing/control on expiry while agents and local terminal continue. | + +## 12. Shared wire protocol: atem-session v1 + +Status: r6.2 is the current implementation contract, with no legacy paths. +Source: [Atem #68](https://github.com/Agora-Build/Atem/pull/68), history tag +`atem-session-v1-r6.2`, commit `22de096b1980ee3ae342c0352b4c1d5fa54e3b51`. +The [exact snapshot](protocol/atem-session-v1.md), +[fixture schema](protocol/fixtures/schema.md) and +[Astation's implementation evidence](protocol/astation-session-v1-implementation.md) +are available. Historical r1-r5 reviews remain as review history, not a fallback +implementation contract. + +Removed or undecodable operations receive session `unsupported_version`, +possibly without `re`; valid refused terminal/chat requests receive their own +family's error with `code`. Chat receipt outcomes map to chat ops, never session +receipts. Host admission checks control permission, accepting session, then +lease; closed sessions report `session_closed` even with a stale lease. +A new welcome resets per-attachment registry state while preserving the highest +accepted runtime floor. Lease epochs may skip and are compared, never counted. + +All 18 shared fixture files are imported byte-for-byte with pinned hashes. +Swift and offline JavaScript execute all three client scenarios and digest/raw +rejection vectors. Atem owns host execution, including the new reconnect, +closed/exited admission, family-error, PTY boundary and turn-completion steps; +see its pinned handoff for real-runtime coverage and limits. Joint live testing +remains outstanding. Upstream section 9 remains the security release gate. + +### Envelope and exact target + +```json +{"type":"session","data":{"v":1,"op":"list","id":"11111111-1111-4111-8111-111111111111"}} +``` + +Requests use sender-unique message IDs; replies reference the request with `re`. +Submissions are deduplicated by their durable `request_id`, independently of +message delivery IDs. Unknown versions fail with `unsupported_version`. + +Define one full target object consistently across session, terminal, and chat +operations: + +```json +{ + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-abcdefghijkl", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 7 +} +``` + +Host trust generation increments durably on unpair/rebind. Session ID persists +for the original session; process generation changes on replacement/resume. +Runtime generation changes on broker restart. Do not route by provider name, +cwd label, selected row, or PID. R2 uses a `binding` object containing this +target, attachment, and mutation lease epoch; reads omit the epoch. Counters +are integers within the JavaScript-safe range and never wrap. R3 places the +binding in `data.binding` consistently for session, terminal, and chat envelopes. + +### Authenticated controller and session registry + +| Operation | Direction | Purpose | +| --- | --- | --- | +| `hello` | Client to host | Identify a native/browser client and optionally request authenticated resume. | +| `welcome` | Host to client | Account/host/trust/runtime context, controller session, scoped permissions, and authorization deadline. | +| `list` | Client to host | Request the authorized session registry snapshot. | +| `sessions` | Host to client | Runtime generation/epoch, attachment sequence, and permitted session descriptors. | +| `session_changed` | Host to clients | Ordered session update or removal/tombstone. | + +Session descriptors contain exact session/process identity, provider, a limited +cwd label, lifecycle state, observed activity, controller/lease context, +capabilities, and creation time. Lifecycle states are running, exited, +interrupted, recovery_required, and closed; activity is ready, working, waiting, +or unknown. Filter registry/history by the client's actual view permissions. +Show distinguishing session IDs when provider/workspace labels repeat. + +The authenticated channel, not `hello` fields or relay labels, establishes the +client device/key and whether it is native or a scoped browser. A known +controller-session UUID is not a resume credential. Resume requires key +possession, a fresh challenge, and a new attachment generation that fences the +old socket. A transport switch can preserve logical controller identity, but +cannot authorize both old and replacement attachments or reacquire control. +Broker restart invalidates old controller attachments/leases while surviving +workers remain addressable independently. + +R3 uses a durable runtime epoch and an attachment-visible sequence. A newer +runtime resets the sequence domain; old epochs cannot roll state back. `list` +starts an ordered snapshot/change stream. Duplicates are ignored and gaps +trigger a fresh snapshot. Apply removal without resurrecting its old process. +Presence is informational, not proof of liveness. + +### One common control lease + +| Operation | Direction | Purpose | +| --- | --- | --- | +| `acquire` | Client to host | Acquire a lease; explicit `takeover` transfers an existing holder. | +| `renew` | Client to host | Renew only the exact current target/controller/epoch. | +| `release` | Client to host | Release the exact held epoch. | +| `lease_changed` | Host to viewers | Announce grant, takeover, local input, expiry, release, revocation, or process exit. | + +Use the approved 15-second lease and five-second renewal schedule. Bound the +deadline by the underlying authorization's earlier expiry. Enforce deadlines +with monotonic clocks; wall-clock expiry/server-time fields are display and +estimation data. Only matching authenticated responses to an outstanding request +advance client control deadlines. Expired renewals cannot reacquire; delayed +replies cannot restore expired control, including when no timeout timer ran. +Repeat acquisition by the current attachment returns the same lease without an +extension; the client uses the minimum of the previous deadline, request send +time plus the reply's remaining lifetime, and the authorization deadline. + +Once this protocol is negotiated, its lease governs terminal input/resize/stop, +chat submission, interruption, approval answers, and future mutations. Legacy +terminal `control`, agentInput, voice, and chat requests cannot bypass it. Bind +streams to the controller attachment, exact session, and process generation. +Revoke the PTY input epoch before writing local input or after remote ownership +expires/transfers, including already queued bytes. Reconnect never replays keys. + +### Durable submissions and receipt queries + +| Operation | Direction | Purpose | +| --- | --- | --- | +| `submit` | Client to host | Binding, request ID, operation kind, and typed payload; the host computes its digest. | +| `receipt` | Host to client | Request/target identity, durable state, bounded detail, and update time. | +| `receipt_query` | Client to host | Authorized exact target and at most 64 request IDs. | +| `receipts` | Host to client | Only receipts the authenticated client may read. | + +Receipt states are accepted, executing, completed, rejected, and outcome_unknown. +Bind an immutable request to the full account/host/trust/session/process target, +kind, and payload digest. Reusing an ID with any changed bound field is +`request_id_reused`. The host recomputes the digest from an agreed canonical +encoding; a client-supplied hash is not sufficient. Use RFC 8785 JCS within the +supported integer-only domain. The ten known-answer vectors include Unicode +ordering; the raw parser rejection cases pass in Swift and offline JavaScript. +Rust execution remains the Atem owner's responsibility. + +Persist acceptance before acknowledging it and executing before the first +external side effect. On an unreconciled crash after dispatch could have begun, +retain outcome_unknown without automatic resubmission. Accepted work can survive +controller disconnection/lease expiry; an old queued operation must not first +dispatch under a new host trust generation after unpair/rebind. + +Define completion by operation kind: provider-confirmed turn completion for a +prompt, observed process exit for stop, and explicit interrupt results. +A PTY write is not semantic completion. Approval answers are deferred to v1.1. +For dispatch failures, reject only with proof of no side effect; otherwise use +outcome_unknown. Fsync final transitions before reporting them, preserve the +same final receipt across duplicates/restarts, and never automatically retry +either final failure state. Interrupt/stop observation is bounded to ten seconds. +Receipt reads need current target-scoped view permission, +without requiring a new control lease. Preserve active receipts and enforce the +approved seven-day post-closure retention and explicit admission limits. + +### Errors, bounds, and fixtures + +Use stable codes: unsupported_version, unauthorized, forbidden, stale_target, +unknown_session, lease_held, stale_lease, request_id_reused, receipt_limit, +session_closed, busy, and paused. Do not reveal another target's identifiers, +paths, or receipts through errors. Define ID/payload/query limits before parsing +or allocation. Enforce the JavaScript-safe integer range for counters +everywhere; overflow must reject. + +Fixtures follow Atem's shared scenario format: name, ordered directed messages, +and structured machine-checkable expectations. Add fake-clock and lifecycle +actions for expiry, disconnect/resume, takeover, local input, crash/restart, +and stale registry updates. Negative scenarios assert the absence of writes, +dispatch, disclosure, and state resurrection, alongside the error response. +Run the same canonical-digest and message fixtures in Rust, Swift, and browser +tests where applicable. + +### Security gate before account-wide or independent browser activation + +Upstream section 9 remains joint work: trust anchor installation, signed native +membership and bounded management delegation, host acceptance of account-wide +authority, revocation ordering, authenticated freshness, the end-to-end channel, +and native-approved browser-key grants. These must be implemented and tested +before the dependent features are enabled. + +Current authenticated socket/pin checks are compatibility protections. They do +not themselves prove live possession of pinned endpoint keys, establish +account-wide membership, or provide live end-to-end encryption. Relay account +identity, timestamps, presence, and welcome fields cannot create that authority. +Do not renew the 24-hour freshness window from any of those signals. + +## References + +- [Atem device verification](https://github.com/Agora-Build/Astation/blob/main/docs/atem-device-verification.md) +- [Encryption migration lifecycle](https://github.com/Agora-Build/Astation/blob/main/designs/e2e-encryption.md) +- [Unified agora.build identity](https://github.com/Agora-Build/Astation/blob/main/docs/specs/2026-10-10-unified-agora-build-identity.md) diff --git a/docs/README.md b/docs/README.md index ad91725..c1e0586 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ work. Completed implementation checklists are available in Git history. | [Android device sharing](android-device-sharing.md) | Connection model, setup, lifecycle, and pending phone/IDE validation | | [Local audio recording](audio-recording.md) | Capture sources, original tracks, permissions, and practical Mac validation | | [Live transcription](live-transcription.md) | Local/cloud captions, draggable floating window, model downloads, privacy, and validation | +| [Remote terminal control](remote-terminal.md) | View and control original Atem sessions, ownership, clipboard, and recovery | | [Agent](agent-chat.md) | Webapp chat app, companion Astation/Atem support, delivery states and recovery | | [Stoa](stoa.md) | Browser meeting and screen-sharing app, existing links, and future agora.build destination | | [Screen sharing](screen-sharing.md) | ScreenCaptureKit capture, Agora publication, optional system audio, browser controls, and verification | diff --git a/docs/agent-chat.md b/docs/agent-chat.md index 193a8bf..afba874 100644 --- a/docs/agent-chat.md +++ b/docs/agent-chat.md @@ -1,83 +1,123 @@ # Agent -This change configures the Agent app at `https://station.agora.build/agent/` -after deployment. The macOS -**Open in Browser** bridge is being implemented separately on -`feat/remote-terminal`, and live prompt/reply handling requires Atem's `chat-v1` -host support. These companion pieces are required for the live workflow below. - -In Astation, open **Tools > Connections > Clients & Agents**, choose an online -Atem, then click **Open in Browser** on its agent. **Open Terminal** remains -beside it for the full CLI and provider permission prompts. - -The responsive page shows your prompts on the right and agent replies on the -left. Replies support code blocks, syntax colors, safe HTTP/HTTPS links and -copy buttons. Scrolling up keeps your reading position during a reply; -**Latest messages** returns to the end. On a phone, use the send button. +Browser host access is disabled in draft Astation PR #49. Atem session r6.2 +removes unbound chat, and the secure browser companion requires section 9 and +shared host leases. **Open in Browser** is disabled before and after native +hello; reconnect does not restore an older bridge. Use **Open Terminal** for +current access to the original agent. + +The `/agent/` app remains hosted by the webapp. Its preview and offline protocol +tests are available; the following UI description is historical behavior for +the future companion implementation, not supported live access to r6.2 hosts. + +Prompts appear on the right and replies on the left. Prompts typed directly in +the original terminal are labeled **You / Terminal**. Replies support CommonMark, +code blocks, syntax colors, safe HTTP/HTTPS links, copy actions, and compact tool +activity. Raw HTML and remote images never execute. Scrolling up keeps your +reading position; **Latest messages** returns to the end. On a phone, use Send. ## Atem support -Live chat requires an Atem host that advertises `chat-v1` for the selected agent. -Without that capability, the page explains that an Atem update is needed and -keeps sending disabled. The Astation UI and webapp page are implemented locally; -the companion host implementation is handed off through shared Atem memory. -Live provider acceptance remains pending that work. - -Terminal screen updates alone cannot reliably identify prompt/reply boundaries, -completion or agent readiness. The browser uses structured events from the -original agent session. **Delivered** means Atem acknowledged a prompt; -completion requires a separate event. **Ready**, **Working** and **Needs your -input** follow semantic host reports. Missing reports become **Unknown** after -30 seconds. The older registry's `Idle` value does not establish readiness. +Atem's host implementation is merged in +[PR 65](https://github.com/Agora-Build/Atem/pull/65), commit `10d3ea8`. +It supports **Claude Code launched by `atem agent launch claude`**, once its +per-session hooks are active. Hooks can activate after the workspace-trust +prompt is answered. Existing agent processes do not gain hooks from a binary +update. Preserve an active session and arrange a separate test session with +the Atem owner. At implementation time the latest release, `v0.6.19`, predates +this host change; use a release/build that includes PR 65. + +The selected agent must advertise `capabilities:["chat-v1"]`. Codex and other +agents without a chat adapter keep their terminal access, report Unknown, and +cannot receive browser prompts. An unsupported page explains this and disables +sending. Preview mode (`?preview=1`) uses labeled sample data and opens no agent +connection. + +The browser uses structured content from the original agent's transcript, +never terminal repaint text. **Delivered** means Atem confirmed acceptance. +**Turn finished** or **Interrupted** comes from an explicit completion event; +late transcript parts can still arrive for that turn. A refusal is displayed +separately from a task error after delivery. Provider approvals stay in the +terminal; chat cannot approve a tool request. + +**Ready**, **Working**, **Needs your input**, and **Unknown** follow semantic +host reports. Atem refreshes status every 15 seconds with its original start +time. Astation uses receipt time to detect staleness: reports older than 30 +seconds become Unknown. Only fresh Ready permits Send. A draft can be edited +while the agent works. The host also refuses submission while the terminal has +an unsent draft or a viewer holds control. Release terminal control before +sending in chat. Commands beginning with `/`, shell mode beginning with `!`, +control characters, empty prompts, and prompts over 32 KiB are unavailable. ## Sessions and recovery -The browser link authorizes one selected agent through Astation for one hour. -Keep it private. Astation must remain running and authenticated with that host. -Replacing a connection or revoking its pairing disables submission. Open a new -browser session from Astation after reconnecting. - -Messages stay in tab memory. Reloading can reopen the same view while its link -is valid, but does not restore earlier messages or replay prompts. If a request -was pending at reload, open a new session after inspecting the original terminal. -When delivery is unknown, inspect the terminal before deciding to send again. -The application never retries a prompt automatically. - -The webapp serves the page and its assets directly at `/agent/`. The existing -relay supplies `/ws` transport. RTC functionality lives in reusable modules -in Stoa under `webapp/stoa/rtc/`; see [webapp structure](../webapp/README.md). No extra -deployment environment variables are needed. Browser links must use `/agent/`; -the earlier `/chat/` route is removed. `?preview=1` opens a labeled, -interactive sample conversation and never connects to an agent. - -The session key stays in the URL fragment, and requests and responses are -authenticated. Chat traffic is not end-to-end encrypted: the relay can read it -and the webapp serves the browser code. Storage encryption does not encrypt this -stream. +A browser link authorizes one selected agent through Astation for one hour. +Keep it private. Astation must stay running and authenticated with that host. +Pairing revocation or a replaced authenticated connection disables the link; +open a new link from Astation after reconnecting. A changed process generation +also requires a new native-authorized link. + +Opening or reloading reads the host's latest 50 messages / 256 KiB of history. +**Load earlier messages** asks for an older page and preserves the reading +position. Atem retains at most 500 messages / 8 MiB per process. Whole messages +replace prior copies by stable ID, and repeated part IDs never duplicate content. +Remote prompt history reconciles the Sending bubble by request ID. Astation +also limits serialized message data, including IDs and part metadata, to 16 MiB. + +**Reopen conversation** restores the view after a closed or interrupted stream. +It sends only an `open` request, never an earlier prompt. No prompt is retried +on timeout, reload, history paging, reconnect, or reauthentication. If delivery +is unknown, inspect the terminal. Sending remains disabled until authenticated +history resolves that request or a new session is authorized. Ready after a +reopen may permit more work after known delivery; it does not invent a completion +outcome for an earlier turn. + +The link key stays in the URL fragment; requests and responses carry +direction-bound HMACs and fresh browser nonces. No transcript is stored in +localStorage or on the relay by this feature. Chat traffic is **not end-to-end +encrypted**: the relay can read it and the webapp serves the browser code. +Storage encryption does not encrypt this stream. The relay supplies transport; +Astation's authenticated endpoint session establishes device identity. + +The webapp owns `/agent/` and its assets; the relay owns `/ws` and APIs. RTC +functionality lives in Stoa under `webapp/stoa/rtc/`. No new deployment variables +are required. The removed `/chat/` and `/session/` routes remain 404. ## Verification ```sh -node --test webapp/tests/agent-chat.test.mjs +python3 scripts/setup-terminal-assets.py --verify +swift test --filter 'BrowserAgentChatTests|RemoteTerminal' +node --test webapp/tests/agent-chat.test.mjs webapp/tests/agent-markdown.test.mjs node webapp/tests/nginx.test.js -node --test webapp/tests/screen-share.test.mjs webapp/tests/rtc-channel.test.mjs # Against a local webapp nginx (also runs in CI against the Docker image): WEBAPP_TEST_ORIGIN=http://localhost:8080 node --test webapp/tests/hosting.test.mjs ``` -Local validation on 2026-10-14: all 446 runnable relay tests pass (109 -database/Valkey integration tests ignored locally). All 24 browser crypto/state, -RTC and Stoa session-link tests pass. An actual nginx 1.28 instance serves both apps and shared -modules with the relay unavailable; CI repeats this against the webapp image. -RTC browser checks with a mocked SDK verify join, microphone off/on/mute, -screen audio selection, native dimensions/60 fps configuration and cleanup. -These checks do not measure live media quality. Desktop, -390px and 320px Chrome checks pass. Browser checks exercise streaming, code copy, -safe Markdown, scroll retention, mobile navigation and preview isolation. A -signed mock bridge verifies delivery, tamper rejection, unsupported hosts and -reload without replay. Live Atem/provider integration is a separate check. - -The companion macOS worktree separately passes 798 Swift tests (11 skipped) -and its Release build; those sources are not included in this hosting change. +The shared synthetic wire fixture in +`Tests/AstationTests/Fixtures/agent-chat-v1.json` uses the published host shapes +and runs through both Swift and browser state tests. It covers local/remote +prompts, history, text/tool parts, explicit outcomes, and late content. Security +checks cover MAC vectors, tampering/replay, exact routes, revocation races, +connection replacement, close without sequence headers, pre-session errors, +size limits, status freshness, generation changes, and unknown delivery. + +Local validation (2026-10-14): Debug and Release builds, bundled-resource and +packaged-app checks, pinned terminal assets, 97 browser/automation tests, and +Chrome checks at 1440, 390 and 320 pixels pass. The signed mock bridge covers +reload/history without resending, duplicate parts, late content and tampering. +The available Swift run executes 802 tests with seven expected skips and no +failures after excluding six checks that cannot pass in this local environment: +five protected Keychain/Secure Enclave tests return macOS error `-25308`, and +one synthetic caption-scroll test fails identically on unchanged main. No CI +exclusions or product changes were added for those checks. Provisioned signing +alone did not resolve the local protected-Keychain restriction. + +Live acceptance requires a harmless prompt through the exact selected, +authenticated original Claude process, an actual reply and completion, and a +reopen without prompt replay. Synthetic fixtures, signed mock bridges, previews, +and deployment checks do not establish this. Preserve existing user processes. +Current validation results and any remaining acceptance step are recorded in +shared Astation/Atem memory. See the [protocol and security design](../designs/agent-chat.md). diff --git a/docs/atem-device-verification.md b/docs/atem-device-verification.md index dfc1ab0..4ce4a42 100644 --- a/docs/atem-device-verification.md +++ b/docs/atem-device-verification.md @@ -15,7 +15,7 @@ its running key agent; an old executable or agent cannot complete migration. For the current pairing, project-picker and migration steps, use the [credentials guide](credentials.md). Its Atem commands also work from another -machine; Touch ID approval remains on the home Astation Mac. +machine; Touch ID or Mac-password approval remains on the home Astation Mac. ## Protocol And Trust @@ -28,13 +28,16 @@ Astations bind attempts to the authenticated Atem ID, the connection generation, the data account, and a fresh 32-byte nonce. Attempts expire after three minutes. The revealed keys must match the original commitment before the Mac displays a safety code. Approval requires matching codes -and Touch ID. One window displays the safety code and the embedded system -Touch ID control. After comparing both codes, click **Codes Match**, then use +and fresh device-owner authentication. One window displays the safety code and +the embedded system Touch ID control. After comparing both codes, click **Codes Match**, then use Touch ID in that window. The code remains visible; Codes Match alone cannot approve the device, and biometrics do not start before confirmation. There is -no separate authentication alert or password fallback. The first verification -also requires confirmation that the -complete recovery kit was saved. Denial, abort, expiry, and connection +an explicit **Use Mac Password...** button after Codes Match. It opens macOS's +authentication dialog using a separate context. macOS asks for the Mac login +password when Touch ID is unavailable; if it offers Touch ID first, choose its +Use Password option. Astation never receives or stores that password. The first +verification also requires confirmation that the complete recovery kit was +saved. Denial, abort, expiry, and connection replacement invalidate pending approvals. See [the verification UI design](../designs/atem-verification-ui.md) for the @@ -42,7 +45,7 @@ code-comparison gate and biometric lifecycle/security invariants. Safety-code and recovery dialogs run without blocking the main queue. A peer abort, disconnect, or timeout dismisses the active dialog and -invalidates any pending Touch ID request. A retry can start immediately; +invalidates any pending Touch ID or password request. A retry can start immediately; callbacks from an earlier prompt cannot approve the new attempt. The Atem client must use its authenticated Atem ID in `verifyCommit`, @@ -50,7 +53,7 @@ rather than its instance UUID. It must receive an explicit safety-code answer from an interactive terminal; redirected stdin or unavailable input must not be interpreted as a code mismatch. SSO pairing and an authenticated connection can remain available after E2E verification -fails. Check `atem cred status` for device trust. +fails. Check `atem status` for device trust. Certificates include the ceremony transcript. The certificate is signed at epoch E and the accompanying account state at E+1. Device pins and the @@ -129,16 +132,28 @@ key ID, the ephemeral X25519 key, and a five-minute arrival-time window before opening a prompt. One window shows device name, boot ID, request time, last unlock and Apple's embedded Touch ID control. Touch ID starts when the window opens; there is no extra Approve click or separate authentication alert. -Approval requires a fresh Touch ID match with no password fallback or reuse -of a recent Mac unlock. Deny and +Alternatively, **Use Mac Password...** opens macOS's authentication dialog. +Approval requires fresh Touch ID or Mac-password authentication, without reuse +of a recent biometric unlock. Deny and revoke removes the local E2E verification pin; the device must verify again before receiving further grants. Relay-wide signed device revocation is a later protocol step. -The same window explains unavailable Touch ID without offering another way to -approve. See [the unlock UI design](../designs/atem-unlock-ui.md) for embedded +The same window explains unavailable Touch ID and keeps the password button +available. This supports a closed lid with an external display or a remote Mac +desktop session, subject to macOS's authentication rules. Switching methods +cancels the previous attempt; stale replies cannot approve. A cancelled or +unsuccessful password attempt grants nothing and can be retried explicitly. +See [the unlock UI design](../designs/atem-unlock-ui.md) for embedded authentication, cancellation and lifecycle invariants. +Password-fallback lifecycle tests use injected authentication contexts and do +not enter a real password. They cover separate policies, retries, stale replies, +cancellation and comparison gating. See [Agent validation](agent-chat.md#verification) +for combined-build results and local environment limits. With an external display and closed lid, or while +viewing the Mac remotely, use **Use Mac Password...** on a real request and +authenticate in Apple's dialog to complete hardware/session acceptance. + `unlockDenied` includes `revoked: true` after Deny and revoke has persisted, and when Astation no longer has a verification pin for the authenticated device in this account. Atem records this signal and verifies with fresh @@ -299,7 +314,7 @@ Before release, complete a real `atem pair` against an isolated account, confirm both codes, test Touch ID cancellation and success, relaunch the Mac app to check pins/epochs, and confirm a changed key cannot obtain K. -For live step 2a verification, run `atem pair` and check `atem cred status` +For live step 2a verification, run `atem pair` and check `atem status` reports the storage key held by Astation. Then run `atem cred lock` and `atem cred unlock`, approve Touch ID on the Mac, and confirm storage-key rotation. Test Deny and a disconnected unlock before approving a fresh @@ -318,7 +333,7 @@ have already passed with Genie. The user has authorized squash merge after final review and passing checks. The following live OFF and OFF-then-ON checks remain unrecorded; run them with the updated app and client: -1. Confirm Astation's relay/account status is connected and `atem cred status` +1. Confirm Astation's relay/account status is connected and `atem status` on the other machine reports a verified device and an unlocked key agent. 2. In Astation, turn encryption off. Run `atem cred unlock` on the other machine. Astation should reach Off after verified migration, and existing diff --git a/docs/credentials.md b/docs/credentials.md index fddb797..d3ca113 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -15,8 +15,8 @@ The Keychain item requires an unlocked Mac and is not synced through iCloud. 3. Paste the value. **Show multiline value** opens a text editor for PEM, JSON or multiline values. Values retain their exact whitespace and may contain up to 64 KiB of UTF-8 text. -4. Click **Save with Touch ID** and approve. The credential appears in the list - only after the Keychain write succeeds. +4. Click **Save Securely** and approve with Touch ID or your Mac login password. + The credential appears in the list only after the Keychain write succeeds. Project spelling is retained for display and saving: `github.com/Agora-Build/Atem` stays capitalized. Project comparisons and credential names are case-insensitive, @@ -43,10 +43,11 @@ to add a credential for a project that is not listed; no connected Atem is neede if shared sign-in credentials should remain usable after Astation disconnects; without saving, they have a five-minute grace period after disconnection. 3. Approve pairing in Astation and compare the complete safety codes. Type `y` - in Atem; on the Mac click **Codes Match**, then use Touch ID in that window. + in Atem; on the Mac click **Codes Match**, then use Touch ID in that window + or click **Use Mac Password...** for macOS authentication. Either side can finish first. Save and confirm the complete recovery kit if requested, and finish any key-escrow/unlock approval. -4. Check `atem cred status`: the device must be verified, and its key agent +4. Check `atem status`: the device must be verified, and its key agent must be unlocked before reporting projects. **Connected** or **Paired with Astation** alone does not establish either state. 5. In the Git repository on the Atem machine, run: @@ -57,7 +58,7 @@ to add a credential for a project that is not listed; no connected Atem is neede ``` Unlock approval happens on the home Astation Mac, even when Atem runs on Linux. -Keep the command open while approving Touch ID; the wait is bounded to five +Keep the command open while approving on the Mac; the wait is bounded to five minutes. Saving shared sign-in does not unlock device keys indefinitely. Open Settings > Credentials on the Mac, click **Add Credential**, enable **Use project scope**, and select the repository. Use **Refresh** if needed. @@ -86,7 +87,7 @@ its published version. If a command is missing, install a release containing it or build current `main` using the [Atem source instructions](https://github.com/Agora-Build/Atem#build-from-source). Run the source binary explicitly, such as `./target/release/atem`, for every upgrade command; building alone does not replace a global npm installation. -`atem cred status` marks an older running agent as **older build**. +`atem status` marks an older running agent as **older build**. Project names are encrypted to the verified Astation even when memory encryption is off. They are suggestions, not device permissions. See the @@ -119,11 +120,12 @@ remains plaintext with mode 0600. ## Copy and delete -Select a row and click **Copy Value**, then approve Touch ID. The clipboard -clears after 30 seconds or app termination, provided it still contains that -copy. A later clipboard change is retained. +Select a row and click **Copy Value**, then approve with Touch ID or your Mac +login password. The clipboard clears after 30 seconds or app termination, +provided it still contains that copy. A later clipboard change is retained. -Select **Delete**, confirm and approve Touch ID to remove the saved credential. +Select **Delete**, confirm and authenticate with Touch ID or your Mac login +password to remove the saved credential. Cancelling or failing authentication changes nothing. If another operation changes the store while a prompt is pending, the stale operation is refused; refresh and reopen the editor. Storage errors retain existing credentials. diff --git a/docs/remote-terminal.md b/docs/remote-terminal.md new file mode 100644 index 0000000..d47b902 --- /dev/null +++ b/docs/remote-terminal.md @@ -0,0 +1,154 @@ +# Remote terminal control + +Control the original Claude Code or Codex process running on an authenticated +Atem, and see its responses in Astation. Opening a view preserves the existing +conversation and unfinished input. + +## Start a session + +This development branch requires an Atem host implementing +[atem-session v1 r6.2](../designs/protocol/atem-session-v1.md), in addition to +shared-terminal rendering. Earlier terminal-only hosts cannot provide control +through this build. Use the runtime host in [Atem PR #69](https://github.com/Agora-Build/Atem/pull/69); +the published pairing fixes alone do not imply r6.2 support. See the +[integration status](../designs/protocol/astation-session-v1-implementation.md). + +Use the same host binary for pairing and launch; see the +[pinned host handoff](../designs/protocol/atem-host-handoff.md) for build and +run instructions. On the serving machine: + +```sh +atem agent launch claude +atem agent launch codex +``` + +Agent options can follow the provider, for example +`atem agent launch codex --profile oss`. Pair/authenticate Atem with Astation +as usual, then run `atem pair verify` if it is not already verified. Local agent +launch does not require pairing; remote viewing and control do. The chooser +addresses each original session separately. + +## Open and control + +1. Open **Tools > Connections > Clients & Agents** and select the online host. +2. Click **Open Terminal** on its agent, or **Terminals** in the panel header. +3. Select a session. **View** opens its current screen without taking control. +4. Click **Take Control** to type, paste, send Enter/Escape/Ctrl-C or operate + the original CLI's approval prompts. +5. **Release** restores local input ownership. Closing the window detaches + the viewer and leaves the agent running. **Stop Agent** requires confirmation + and ends that selected original process. + +Control expires after 15 seconds without renewal; Astation renews every five +seconds. Local input on the host takes priority and revokes remote control. +The Astation view then becomes read-only. Reconnecting always opens a fresh view +and requires another deliberate Take Control. A replacement process generation +requires choosing View again. + +**Copy** copies the visible selection. Command-C copies a selection and +Command-A selects the visible screen. **Paste** and Command-V use the native +clipboard, preserve multiline text and the CLI's bracketed-paste mode, and +reject oversized pastes before sending. Ctrl-C continues to mean interrupt. +The maximum single input is 16 KiB, including bracketed-paste framing. + +Only the active controller resizes the remote PTY. A viewing window preserves +the host dimensions and scrolls/clips a screen larger than its viewport. + +### Scrolling and tmux + +Scrolling a terminal application's own UI requires Take Control and the +application's terminal mouse support. With tmux's default bindings, hold Ctrl +and press B, release both, then press `[` to enter copy mode. This sequence is +forwarded as Control-B followed by a literal bracket; it is not three separate +key presses. Astation does not automatically insert tmux shortcuts when scrolling. + +The shared terminal is the original agent PTY, not Atem's outer frontend or +the tmux pane containing Atem. If tmux wraps Atem, input to the shared agent +bypasses that outer tmux, so its copy-mode shortcut cannot access the pane's +history. A tmux instance inside the shared PTY can receive the shortcut. +Access to outer tmux or Atem frontend history needs host support; forwarding +more key sequences cannot expose that history in the current stream. + +## Status and recovery + +The prompt field sends a scoped submission to the selected session. Voice text, +the scoped interrupt shortcut and Stop Agent use the same authority and durable +receipt lifecycle. The receipt label distinguishes accepted, executing, +completed, rejected and outcome unknown. Disconnect never replays a prompt; +reconnect queries its original request ID. Inspect the agent before manually +trying again when the outcome is unknown. Closing the window releases control +while Astation continues receipt recovery. + +**Written to terminal** confirms a PTY write; it does not mean the agent has +accepted a prompt or finished a response. Watch its live output for those states. +If an acknowledgment is lost or times out, the outcome is unknown. Astation +does not retry typing. Open a fresh View, inspect the screen, then decide whether +to take control and submit something again. + +Refresh requests the shared session-v1 registry. Connections and terminal +windows reuse one client, so opening a window never sends a second hello. A missing response can mean an old +Atem build or a connection problem. No shared terminals means the host must +launch an agent through Atem. Unsupported versions/width profiles, malformed +frames and restore failures disable input and show an actionable status. +Removing the pairing disables terminal access; direct sockets authenticated +by that pairing close as well. + +**Paired offline** means the pairing is saved but Astation has no authenticated +connection for that device. The Atem process and its local agent may still be +running. A live relay socket alone does not make the device online. + +When Astation alone reconnects, it requests a fresh endpoint proof from the +relay's restored connections. Atem must handle that challenge on its existing +socket and discard previous remote streams and control leases before resuming. +Older Atem builds that ignore late authentication challenges can leave a running +Atem paired offline. That companion fix is coordinated with the Atem owner +through shared project memory. On 2026-10-14 at 17:17 PDT, the running Genie host +answered a fresh challenge and authenticated after Astation restarted. The new runtime owns independent workers, which survive its restart; preserve +active conversations while arranging host updates with the Atem owner. + +## Rendering and privacy + +The first protocol shares canonical visible-screen ANSI updates, preserving +colors, cursor and supported terminal modes. It provides no remote scrollback +or complete conversation history. The zero-scrollback renderer avoids presenting +incidental repaint scrolls as a transcript. Claude advertises `chat-v1`, which +allows durable prompt submissions; Codex currently accepts terminal input only. +Astation disables the prompt field for terminal-only sessions. Browser access +is disabled pending secure section 9 companion grants, regardless of whether +hello has started. There is no older browser bridge fallback. + +xterm 6.0.0, addon-fit 0.11.0 and Atem's matching Unicode-width provider are +bundled locally. The viewer loads no CDN, permits no external navigation and +passes agent output as bytes rather than HTML. Copy/paste require user actions; +agent terminal output cannot request the native clipboard. Terminal input/output +is redacted from network debug logs and is not saved by this feature. + +Transport uses the existing authenticated LAN/relay connection. Storage E2EE +does not make the terminal stream end-to-end encrypted. The relay routes +selected-host envelopes; endpoint pairing remains authoritative for device +identity and access. + +## Verification + +```sh +python3 scripts/setup-terminal-assets.py --verify +swift test --filter 'AtemSession|RemoteTerminal|AgentInputRouting|BrowserAgentChat' +``` + +Tests cover the serde-compatible wire format, real WebKit rendering and +bracketed paste, renderer credit and queue bounds, connection/process/stream +isolation, control revocation, unknown write outcomes, reconnects and pairing +revocation races. The full resource check also verifies that packaged apps +contain the viewer assets. Live provider tests must use independently owned +test sessions, preserving the user's active conversation. + +The current session-v1 client builds in Debug and passes its 102 focused native +checks, including WebKit rendering, plus the webapp conformance suite and pinned +assets. The [current validation evidence](../designs/protocol/astation-session-v1-implementation.md) +records the broader local test failures and pending actual-host integration. +Live cross-machine r6.2 control, physical IME/clipboard and real provider approvals +remain acceptance checks. Earlier relay endpoint reauthentication after an +Astation restart passed separately on Genie at 17:17 PDT on 2026-10-14. + +See [lifecycle and security invariants](../designs/remote-terminal.md) and +[Atem's current host handoff](../designs/protocol/atem-host-handoff.md). diff --git a/scripts/setup-terminal-assets.py b/scripts/setup-terminal-assets.py new file mode 100644 index 0000000..e2efeca --- /dev/null +++ b/scripts/setup-terminal-assets.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Fetch pinned terminal assets, or verify them without network access.""" +import argparse +import base64 +import hashlib +import io +import json +from pathlib import Path +import tarfile +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +LOCK = ROOT / "Config/terminal-assets.json" +DEST = ROOT / "Sources/Menubar/Resources/RemoteTerminal" + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def unicode_browser(provider, table): + # Keep the host provider unchanged inside a browser-safe CommonJS wrapper. + return ("(() => {\nconst widths = " + table.decode().strip() + ";\n" + "const require = name => {\n" + " if (name !== './unicode-width-0.1.14.json') throw new Error('Unknown module');\n" + " return widths;\n};\nconst module = { exports: {} };\n" + + provider.decode() + "\nwindow.AtemUnicode = module.exports.AtemUnicode;\n})();\n").encode() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--verify", action="store_true") + args = parser.parse_args() + lock = json.loads(LOCK.read_text()) + if args.verify: + expected = lock.get("asset_sha256", {}) + if not expected: + raise SystemExit("Missing terminal asset hashes") + for name, checksum in expected.items(): + if digest((DEST / name).read_bytes()) != checksum: + raise SystemExit("Terminal asset mismatch: " + name) + browser = unicode_browser((DEST / "atem-unicode.cjs").read_bytes(), + (DEST / "unicode-width-0.1.14.json").read_bytes()) + if browser != (DEST / "atem-unicode.js").read_bytes(): + raise SystemExit("Unicode browser wrapper differs from the host provider") + print("Verified " + str(len(expected)) + " pinned terminal assets") + return + + assets = {} + for package in lock["packages"]: + archive = urllib.request.urlopen(package["url"], timeout=30).read() + actual = "sha512-" + base64.b64encode(hashlib.sha512(archive).digest()).decode() + if actual != package["integrity"]: + raise SystemExit("Archive integrity mismatch: " + package["name"]) + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar: + for source, target in package["files"].items(): + member = tar.extractfile(source) + if member is None: + raise SystemExit("Missing asset: " + source) + assets[target] = member.read() + base = "https://raw.githubusercontent.com/Agora-Build/Atem/" + lock["atem_commit"] + "/" + for name, checksum in lock["atem_assets"].items(): + data = urllib.request.urlopen(base + "assets/terminal/" + name, timeout=30).read() + if digest(data) != checksum: + raise SystemExit("Host asset mismatch: " + name) + assets[name] = data + assets["atem-unicode.js"] = unicode_browser(assets["atem-unicode.cjs"], + assets["unicode-width-0.1.14.json"]) + hashes = {name: digest(data) for name, data in sorted(assets.items())} + if lock.get("asset_sha256") and lock["asset_sha256"] != hashes: + raise SystemExit("Downloaded assets differ from checked-in hashes") + DEST.mkdir(parents=True, exist_ok=True) + for name, data in assets.items(): + (DEST / name).write_bytes(data) + lock["asset_sha256"] = hashes + LOCK.write_text(json.dumps(lock, indent=2) + "\n") + print("Installed " + str(len(assets)) + " pinned terminal assets") + + +if __name__ == "__main__": + main() diff --git a/webapp/TESTING.md b/webapp/TESTING.md index cdc9ec4..2fb0f56 100644 --- a/webapp/TESTING.md +++ b/webapp/TESTING.md @@ -20,7 +20,7 @@ Run the browser-independent checks and the actual nginx image checks with: ```sh node tests/codec.test.mjs node tests/nginx.test.js -node --test tests/agent-chat.test.mjs tests/screen-share.test.mjs tests/rtc-channel.test.mjs tests/stoa-session.test.mjs +node --test tests/agent-chat.test.mjs tests/agent-markdown.test.mjs tests/screen-share.test.mjs tests/rtc-channel.test.mjs tests/stoa-session.test.mjs WEBAPP_TEST_ORIGIN=http://localhost:8080 node --test tests/hosting.test.mjs ``` diff --git a/webapp/agent/chat-core.mjs b/webapp/agent/chat-core.mjs index f04d2b4..07a9811 100644 --- a/webapp/agent/chat-core.mjs +++ b/webapp/agent/chat-core.mjs @@ -1,7 +1,9 @@ export const MAX_PROMPT_BYTES = 32768; export const MAX_MESSAGE_BYTES = 131072; export const ACTIVITY_TTL_MS = 30000; -export const MAX_TRANSCRIPT_BYTES = 2097152; +export const MAX_TRANSCRIPT_BYTES = 8388608; +export const MAX_SERIALIZED_BYTES = 16777216; +export const MAX_FRAME_BYTES = 1500000; export const MAX_MESSAGES = 512; const encoder = new TextEncoder(); const decoder = new TextDecoder("utf-8", { fatal: true }); @@ -12,7 +14,7 @@ export function base64(bytes) { return btoa(binary); } -export function unbase64(value, limit = MAX_MESSAGE_BYTES * 2) { +export function unbase64(value, limit = MAX_FRAME_BYTES) { if ( typeof value !== "string" || value.length > limit || @@ -97,7 +99,7 @@ export async function verifiedBody( !Number.isSafeInteger(frame.sequence) || frame.sequence <= lastSequence || typeof frame.body !== "string" || - frame.body.length > MAX_MESSAGE_BYTES * 2 + frame.body.length > MAX_FRAME_BYTES ) return null; try { @@ -129,36 +131,6 @@ export function safeLink(value) { } } -export function markdownBlocks(text) { - const blocks = []; - let lines = []; - let language = null; - const flush = () => { - if (lines.length || language !== null) { - blocks.push({ - type: language === null ? "text" : "code", - language, - text: lines.join("\n"), - }); - } - lines = []; - }; - for (const line of text.split("\n")) { - if (language === null) { - const opening = /^```([^`]*)$/.exec(line); - if (opening) { - flush(); - language = opening[1].trim().slice(0, 32) || "code"; - } else lines.push(line); - } else if (/^```\s*$/.test(line)) { - flush(); - language = null; - } else lines.push(line); - } - flush(); - return blocks; -} - export function activity(state, lastUpdate, connected, now = Date.now()) { if (!connected) return { state: "offline", label: "Offline" }; if (!lastUpdate || now - lastUpdate > ACTIVITY_TTL_MS) @@ -172,6 +144,64 @@ export function activity(state, lastUpdate, connected, now = Date.now()) { ); } +export function timestamp(value) { + const number = + typeof value === "number" + ? value + : typeof value === "string" && /^\d{4}-\d\d-\d\dT/.test(value) + ? Date.parse(value) + : NaN; + return Number.isFinite(number) && number >= 0 && number <= 8640000000000000 + ? number + : null; +} + +export function validPrompt(text) { + return ( + typeof text === "string" && + Boolean(text.trim()) && + encoder.encode(text).length <= MAX_PROMPT_BYTES && + !/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/.test(text) && + !/^[\s]*[!/]/u.test(text) + ); +} + +const identifier = (value) => + typeof value === "string" && + value.length > 0 && + encoder.encode(value).length <= 128; +const states = ["ready", "working", "waiting", "unknown"]; +const validStatus = (status) => + status && + states.includes(status.state) && + timestamp(status.since) !== null && + (status.detail === undefined || + (typeof status.detail === "string" && + encoder.encode(status.detail).length <= 4096)); +const validPart = (part) => + part && + identifier(part.id) && + ((part.kind === "text" && typeof part.markdown === "string") || + (part.kind === "tool" && + typeof part.name === "string" && + typeof part.summary === "string")); +const partBytes = (part) => + part.id === "atem:truncated" && + part.kind === "text" && + part.markdown === "\u2026 (truncated)" + ? 0 + : encoder.encode( + part.kind === "text" ? part.markdown : part.name + part.summary, + ).length; +export const messageText = (message) => + message.parts + .map((part) => + part.kind === "text" + ? part.markdown + : `${part.name}${part.summary ? `: ${part.summary}` : ""}`, + ) + .join("\n\n"); + export class Conversation { constructor(now = () => Date.now()) { this.now = now; @@ -183,12 +213,19 @@ export class Conversation { enabled: false, }; this.state = "unknown"; + this.since = null; + this.detail = ""; this.activityAt = 0; this.activeRequest = null; this.lastSequence = 0; this.generation = null; this.notice = ""; this.uncertain = false; + this.closed = true; + this.opening = false; + this.pageBefore = null; + this.turns = new Map(); + this.requestIds = new Set(); } get canSend() { @@ -196,32 +233,41 @@ export class Conversation { this.context.connected && this.context.enabled && Boolean(this.generation) && + !this.closed && + !this.opening && !this.activeRequest && - !this.uncertain + !this.uncertain && + activity(this.state, this.activityAt, true, this.now()).state === "ready" ); } + get oldestMessageId() { + return this.messages.find((message) => !message.optimistic)?.id; + } + addPrompt(id, text) { - if ( - !this.canSend || - typeof id !== "string" || - !id || - this.messages.some((item) => item.id === id && item.role === "user") || - typeof text !== "string" || - !text.trim() || - encoder.encode(text).length > MAX_PROMPT_BYTES - ) + if (!this.canSend || !identifier(id) || this.requestIds.has(id)) + return false; + if (!validPrompt(text)) { + this.notice = + "Use a text prompt up to 32 KiB. Commands (/), shell mode (!) and control characters are unavailable in chat."; return false; - if (!this.withinLimits(text)) { + } + if (!this.withinLimits([{ id: "prompt", kind: "text", markdown: text }])) { this.disconnect( "This conversation reached its size limit. Open a new session from Astation.", ); return false; } + this.requestIds.add(id); + this.notice = ""; this.messages.push({ id, + requestId: id, role: "user", - text, + origin: "remote", + optimistic: true, + parts: [{ id: "prompt", kind: "text", markdown: text }], delivery: "sending", at: this.now(), }); @@ -232,20 +278,24 @@ export class Conversation { receive(body) { if (!body || typeof body !== "object") return; if (body.op === "context") { - this.context = { ...this.context, ...body }; if ( - !this.generation && - typeof body.generation === "string" && - body.generation + body.conversationId && + body.conversationId !== this.context.conversationId ) { - this.generation = body.generation; - this.lastSequence = Number.isSafeInteger(body.lastSequence) - ? body.lastSequence - : 0; + this.opening = true; + this.closed = true; + this.lastSequence = 0; + this.pageBefore = body.before || null; } - if (body.pending && !this.activeRequest) this.uncertain = true; + this.context = { ...this.context, ...body }; + this.opening = Boolean(body.opening); + if (typeof body.closed === "boolean") this.closed = body.closed; + if (body.pendingRequestId) this.activeRequest = body.pendingRequestId; + else if (body.pending === false) this.activeRequest = null; + if (typeof body.deliveryUnknown === "boolean") + this.uncertain = body.deliveryUnknown; this.notice = body.notice || ""; - if (!body.connected) this.disconnect(this.notice); + if (!body.connected) this.disconnect(this.notice || undefined); return; } if (body.op === "notice") { @@ -261,125 +311,290 @@ export class Conversation { } if (body.op !== "event") return; const event = body.event; - if (!event || event.v !== 1) return; + if ( + !event || + event.v !== 1 || + (this.context.conversationId && + event.conversationId !== this.context.conversationId) || + (this.context.agentId && event.agentId !== this.context.agentId) + ) + return; + if ( + event.op === "close" && + event.generation === undefined && + event.sequence === undefined + ) { + this.closed = true; + this.opening = false; + this.state = "unknown"; + this.notice ||= + "The host closed this conversation. Reopen the view to load history."; + if (this.activeRequest) { + this.updateDelivery(this.activeRequest, "unknown"); + this.uncertain = true; + } + return; + } + if ( + event.op === "error" && + event.generation === undefined && + event.sequence === undefined && + this.opening + ) { + this.notice = event.text || "Chat is unavailable."; + this.closed = true; + this.opening = false; + return; + } if (event.op === "opened") { if ( - this.generation || + !this.opening || event.sequence !== 0 || - typeof event.generation !== "string" || - !event.generation + !identifier(event.generation) || + !validStatus(event.status) ) return; + if (this.generation && this.generation !== event.generation) { + this.disconnect( + "The agent process changed. Open a new browser session from Astation.", + ); + return; + } this.generation = event.generation; this.lastSequence = 0; + this.opening = false; + this.closed = false; + this.setStatus(event.status); return; } if ( + this.closed || + this.opening || !this.generation || - event.generation !== this.generation || - !Number.isSafeInteger(event.sequence) || - event.sequence <= this.lastSequence + !Number.isSafeInteger(event.sequence) ) return; - if (event.sequence !== this.lastSequence + 1) { + if (event.generation !== this.generation) { this.disconnect( - "Some conversation events are missing. Reopen this agent from Astation.", + "The agent process changed. Open a new browser session from Astation.", ); return; } + if (event.sequence <= this.lastSequence) return; + if (event.sequence !== this.lastSequence + 1) { + this.closed = true; + this.notice = + "Some conversation events are missing. Reopen the view; prompts are never resent."; + if (this.activeRequest) this.uncertain = true; + return; + } this.lastSequence = event.sequence; switch (event.op) { case "accepted": this.updateDelivery(event.requestId, "delivered"); + if (event.requestId === this.activeRequest) this.uncertain = false; break; case "message": + this.mergeMessage(event); + break; case "delta": { - if ( - typeof event.messageId !== "string" || - !event.messageId || - typeof event.text !== "string" - ) - return; - let message = this.messages.find( - (item) => item.id === event.messageId && item.role === "assistant", + const message = this.messages.find( + (item) => item.id === event.messageId && !item.optimistic, ); - const text = - event.op === "delta" - ? (message?.text || "") + event.text - : event.text; - if (encoder.encode(text).length > MAX_MESSAGE_BYTES) { - this.disconnect("The reply exceeded the conversation limit."); - return; + if (!message || !validPart(event.part)) { + this.invalid(); + break; } - if (!this.withinLimits(text, message)) { - this.disconnect( - "This conversation reached its size limit. Open a new session from Astation.", - ); - return; - } - if (!message) { - message = { - id: event.messageId, - role: "assistant", - text: "", - at: this.now(), - }; - this.messages.push(message); + if (message.parts.some((part) => part.id === event.part.id)) break; + const parts = [...message.parts, event.part]; + if (!this.withinLimits(parts, message)) { + this.invalid(); + break; } - message.text = text; + message.parts = parts; break; } case "status": - this.state = ["ready", "working", "waiting"].includes(event.state) - ? event.state - : "unknown"; - this.activityAt = this.now(); + if (validStatus(event)) this.setStatus(event); + else this.invalid(); break; case "complete": + if ( + !identifier(event.turnId) || + !["done", "interrupted"].includes(event.outcome) + ) { + this.invalid(); + break; + } + this.turns.set(event.turnId, event.outcome); + if (this.turns.size > 1024) + this.turns.delete(this.turns.keys().next().value); + for (const message of this.messages) + if (message.turnId === event.turnId) message.outcome = event.outcome; if (event.requestId === this.activeRequest) { this.updateDelivery(event.requestId, "delivered"); this.activeRequest = null; + this.uncertain = false; } break; - case "error": + case "error": { this.notice = event.text || "The agent could not complete the request."; if (event.requestId === this.activeRequest) { - this.updateDelivery(event.requestId, "delivered"); + const message = this.messages.find( + (item) => + item.requestId === event.requestId && item.role === "user", + ); + const unknown = /delivery unknown/i.test(this.notice); + this.updateDelivery( + event.requestId, + unknown + ? "unknown" + : message?.delivery === "delivered" + ? "delivered" + : "refused", + ); + this.uncertain = unknown; this.activeRequest = null; } break; + } + } + } + + setStatus(status) { + this.state = status.state; + this.since = timestamp(status.since); + this.detail = status.detail || ""; + this.activityAt = this.now(); + } + + mergeMessage(event) { + if ( + !identifier(event.messageId) || + !["user", "assistant"].includes(event.role) || + !identifier(event.turnId) || + timestamp(event.createdAt) === null || + (event.role === "user" && !["local", "remote"].includes(event.origin)) || + !Array.isArray(event.parts) || + !event.parts.every(validPart) || + new Set(event.parts.map((part) => part.id)).size !== event.parts.length + ) { + this.invalid(); + return; + } + let message = this.messages.find( + (item) => item.id === event.messageId && !item.optimistic, + ); + if ( + message && + (message.role !== event.role || message.turnId !== event.turnId) + ) { + this.invalid(); + return; + } + const optimistic = + event.role === "user" && event.origin === "remote" && event.requestId + ? this.messages.find( + (item) => item.optimistic && item.requestId === event.requestId, + ) + : null; + const replacement = message || optimistic; + if (!this.withinLimits(event.parts, replacement)) { + this.invalid(); + return; + } + const updated = { + id: event.messageId, + role: event.role, + origin: event.origin, + turnId: event.turnId, + requestId: event.requestId, + parts: event.parts, + at: timestamp(event.createdAt), + outcome: this.turns.get(event.turnId), + history: event.history === true, + delivery: + event.role === "user" && event.origin === "remote" + ? event.history + ? "delivered" + : replacement?.delivery || "remote" + : undefined, + }; + if (replacement) Object.assign(replacement, updated, { optimistic: false }); + else { + const anchor = + event.history && this.pageBefore + ? this.messages.findIndex((item) => item.id === this.pageBefore) + : -1; + if (anchor >= 0) this.messages.splice(anchor, 0, updated); + else this.messages.push(updated); + } + if (optimistic && message && optimistic !== message) + this.messages.splice(this.messages.indexOf(optimistic), 1); + if ( + event.history && + event.role === "user" && + event.origin === "remote" && + event.requestId === this.activeRequest + ) { + this.uncertain = false; } } updateDelivery(id, delivery) { const message = this.messages.find( - (item) => item.id === id && item.role === "user", + (item) => item.requestId === id && item.role === "user", ); - if (message) message.delivery = delivery; + if ( + message && + !(delivery === "unknown" && message.delivery === "delivered") + ) + message.delivery = delivery; } - withinLimits(text, replacement) { + withinLimits(parts, replacement) { + // Empty parts still consume memory through IDs, metadata and JSON escaping. + const serializedBytes = + this.messages.reduce( + (total, item) => + total + + (item === replacement + ? 0 + : encoder.encode(JSON.stringify(item)).length), + 0, + ) + encoder.encode(JSON.stringify({ ...replacement, parts })).length; const bytes = this.messages.reduce( (total, item) => - total + (item === replacement ? 0 : encoder.encode(item.text).length), + total + + (item === replacement + ? 0 + : item.parts.reduce((sum, part) => sum + partBytes(part), 0)), 0, ); + const size = parts.reduce((sum, part) => sum + partBytes(part), 0); return ( - bytes + encoder.encode(text).length <= MAX_TRANSCRIPT_BYTES && + parts.length <= 4096 && + size <= MAX_MESSAGE_BYTES && + bytes + size <= MAX_TRANSCRIPT_BYTES && + serializedBytes <= MAX_SERIALIZED_BYTES && (replacement || this.messages.length < MAX_MESSAGES) ); } + invalid() { + this.disconnect( + "The agent sent invalid or oversized conversation data. Reopen from Astation or use Open Terminal.", + ); + } + disconnect( reason = "Connection lost. Reopen this agent from Astation to reconnect.", ) { this.context.connected = false; this.uncertain = true; + this.closed = true; this.state = "unknown"; this.notice = reason; - if (this.activeRequest) { - this.updateDelivery(this.activeRequest, "unknown"); - this.uncertain = true; - } + if (this.activeRequest) this.updateDelivery(this.activeRequest, "unknown"); } } diff --git a/webapp/agent/chat.css b/webapp/agent/chat.css index 81635b5..439e00e 100644 --- a/webapp/agent/chat.css +++ b/webapp/agent/chat.css @@ -994,3 +994,41 @@ dialog .detail-fine { scroll-behavior: auto !important; } } +/* Typed tool activity shares the message rhythm without resembling a reply. */ +.tool-part { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + margin: 12px 0; + padding: 10px 14px; + border-left: 2px solid var(--accent, #34796b); + background: rgba(52, 121, 107, 0.06); + border-radius: 0 8px 8px 0; +} +.tool-summary { + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.turn-outcome { + color: #767a76; + font-size: 11px; +} +.history-button { + display: block; + margin: 12px auto; + padding: 8px 16px; + border: 1px solid #d9dfd8; + border-radius: 999px; + background: #f9faf7; + color: #426153; + font: inherit; + font-size: 12px; + cursor: pointer; +} +.history-button:hover { + background: #edf2eb; +} +.history-button:disabled { + opacity: 0.5; + cursor: wait; +} diff --git a/webapp/agent/chat.mjs b/webapp/agent/chat.mjs index a9bedb3..0d9cae4 100644 --- a/webapp/agent/chat.mjs +++ b/webapp/agent/chat.mjs @@ -1,12 +1,15 @@ import { Conversation, activity, - safeLink, - markdownBlocks, + messageText, + MAX_FRAME_BYTES, importSessionKey, signedFrame, verifiedBody, } from "./chat-core.mjs"; +import { renderMarkdown } from "./markdown.mjs"; + +const parser = new globalThis.commonmark.Parser(); const $ = (id) => document.getElementById(id); const conversation = new Conversation(); @@ -43,33 +46,6 @@ async function copy(text) { } } -function inline(parent, text) { - const pattern = - /(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^\]\n]+\]\([^\s)]+\)|https?:\/\/[^\s<>]+)/g; - let start = 0; - for (const match of text.matchAll(pattern)) { - parent.append(document.createTextNode(text.slice(start, match.index))); - const value = match[0]; - if (value.startsWith("`")) - parent.append(node("code", "", value.slice(1, -1))); - else if (value.startsWith("**")) - parent.append(node("strong", "", value.slice(2, -2))); - else { - const markdown = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(value); - const href = safeLink(markdown ? markdown[2] : value); - if (href) { - const link = node("a", "", markdown ? markdown[1] : value); - link.href = href; - link.target = "_blank"; - link.rel = "noopener noreferrer"; - parent.append(link); - } else parent.append(document.createTextNode(value)); - } - start = match.index + value.length; - } - parent.append(document.createTextNode(text.slice(start))); -} - function highlighted(code, text) { const pattern = /(\/\/[^\n]*|#[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b(?:const|let|var|func|fn|function|return|if|else|true|false|null|nil|async|await|pub|use|import|from|export|struct|class|guard|try|throw)\b|\b\d+(?:\.\d+)?\b)/g; @@ -91,12 +67,10 @@ function highlighted(code, text) { } function markdown(text) { - const fragment = document.createDocumentFragment(); - const sections = markdownBlocks(text); - for (const section of sections) { - if (section.type === "code") { - const language = section.language; - const source = section.text; + return renderMarkdown(text, { + document, + parser, + codeBlock(source, language) { const block = node("div", "code-block"); const header = node("div", "code-header"); header.append(node("span", "", language)); @@ -109,59 +83,9 @@ function markdown(text) { highlighted(code, source); pre.append(code); block.append(header, pre); - fragment.append(block); - continue; - } - const lines = section.text.split("\n"); - let paragraph = []; - let list; - const flush = () => { - if (paragraph.length) { - const p = node("p"); - inline(p, paragraph.join("\n")); - fragment.append(p); - paragraph = []; - } - list = null; - }; - for (const line of lines) { - if (!line.trim()) { - flush(); - continue; - } - const item = /^\s*[-*]\s+(.+)/.exec(line); - if (item) { - if (paragraph.length) flush(); - if (!list) { - list = node("ul"); - fragment.append(list); - } - const li = node("li"); - inline(li, item[1]); - list.append(li); - continue; - } - const heading = /^(#{1,3})\s+(.+)/.exec(line); - if (heading) { - flush(); - const h = node(heading[1].length > 1 ? "h3" : "h2"); - inline(h, heading[2]); - fragment.append(h); - continue; - } - if (line.startsWith("> ")) { - flush(); - const quote = node("blockquote"); - inline(quote, line.slice(2)); - fragment.append(quote); - continue; - } - list = null; - paragraph.push(line); - } - flush(); - } - return fragment; + return block; + }, + }); } function messageElement(message) { @@ -176,7 +100,11 @@ function messageElement(message) { node( "strong", "", - message.role === "user" ? "You" : conversation.context.agentName, + message.role === "user" + ? message.origin === "local" + ? "You / Terminal" + : "You" + : conversation.context.agentName, ), ); const time = node( @@ -190,8 +118,16 @@ function messageElement(message) { time.dateTime = new Date(message.at).toISOString(); meta.append(time); const content = node("div", "message-content"); - if (message.role === "user") content.append(node("p", "", message.text)); - else content.append(markdown(message.text)); + for (const part of message.parts) { + if (part.kind === "tool") { + const tool = node("div", "tool-part"); + tool.append(node("strong", "tool-name", part.name)); + if (part.summary) tool.append(node("code", "tool-summary", part.summary)); + content.append(tool); + } else if (message.role === "user") + content.append(node("p", "", part.markdown)); + else content.append(markdown(part.markdown)); + } const footer = node("div", "message-footer"); if (message.role === "user") { const labels = { @@ -199,6 +135,8 @@ function messageElement(message) { delivered: "Delivered", unknown: "Delivery unknown", failed: "Not delivered", + refused: "Refused / check terminal", + remote: "From chat", }; const status = node( "span", @@ -210,9 +148,17 @@ function messageElement(message) { } else { const button = node("button", "", "Copy message"); button.type = "button"; - button.addEventListener("click", () => copy(message.text)); + button.addEventListener("click", () => copy(messageText(message))); footer.append(button); } + if (message.outcome) + footer.append( + node( + "span", + "turn-outcome", + message.outcome === "interrupted" ? "Interrupted" : "Turn finished", + ), + ); element.append(meta, content, footer); return element; } @@ -222,6 +168,13 @@ function render() { const context = conversation.context; const timeline = $("timeline"); const previousScroll = timeline.scrollTop; + const anchor = [...$("messages").children].find( + (child) => + child.getBoundingClientRect().bottom > + timeline.getBoundingClientRect().top, + ); + const anchorTop = anchor?.getBoundingClientRect().top; + const anchorId = anchor?.dataset.messageId; $("agent-name").textContent = context.agentName; $("agent-location").textContent = `${context.hostname} / Remote coding agent`; $("composer-agent").textContent = context.agentName; @@ -237,7 +190,8 @@ function render() { (message) => message.role === "user", ); $("conversation-title").textContent = - firstPrompt?.text.slice(0, 70) || "A new conversation"; + (firstPrompt ? messageText(firstPrompt).slice(0, 70) : "") || + "A new conversation"; $("empty-state").hidden = conversation.messages.length > 0; const existing = new Map( Array.from($("messages").children, (child) => [ @@ -245,16 +199,36 @@ function render() { child, ]), ); + let position = 0; for (const message of conversation.messages) { - const version = message.text + "\0" + (message.delivery || ""); + const version = JSON.stringify([ + message.parts, + message.delivery, + message.outcome, + message.origin, + message.at, + ]); const old = existing.get(`${message.role}:${message.id}`); - if (old?.renderVersion === version) continue; - const element = messageElement(message); + existing.delete(`${message.role}:${message.id}`); + const element = + old?.renderVersion === version ? old : messageElement(message); element.renderVersion = version; - if (old) old.replaceWith(element); - else $("messages").append(element); + if (old && old !== element) old.replaceWith(element); + if ($("messages").children[position] !== element) + $("messages").insertBefore( + element, + $("messages").children[position] || null, + ); + position++; } - $("prompt").disabled = !conversation.canSend; + for (const old of existing.values()) old.remove(); + $("earlier-messages").hidden = + !conversation.oldestMessageId || !context.canReopen; + $("earlier-messages").disabled = conversation.opening; + $("reopen-chat").hidden = + !context.canReopen || (!conversation.closed && context.enabled); + // Keep a draft editable while the agent works; only submission requires Ready. + $("prompt").disabled = !context.supported || !context.connected; $("send").disabled = !conversation.canSend || !$("prompt").value.trim(); $("connection-notice").textContent = conversation.notice; $("connection-notice").hidden = !conversation.notice; @@ -267,7 +241,15 @@ function render() { : "Messages stay in this tab. Your terminal is always available in Astation."; renderActivity(); if (followLatest) timeline.scrollTop = timeline.scrollHeight; - else timeline.scrollTop = previousScroll; + else { + const restored = [...$("messages").children].find( + (child) => child.dataset.messageId === anchorId, + ); + timeline.scrollTop = + restored && anchorTop !== undefined + ? previousScroll + restored.getBoundingClientRect().top - anchorTop + : previousScroll; + } $("jump-latest").hidden = followLatest; } @@ -294,7 +276,15 @@ function renderActivity() { $("activity").title = value.state === "unknown" ? "No recent semantic activity report is available." - : value.label; + : [ + value.label, + conversation.detail, + conversation.since === null + ? "" + : `Since ${new Date(conversation.since).toLocaleString()}`, + ] + .filter(Boolean) + .join(" / "); $("agent-progress").hidden = !["working", "waiting"].includes(value.state); $("agent-progress-label").textContent = value.state === "waiting" @@ -331,48 +321,76 @@ function startPreview() { connected: true, supported: true, enabled: true, - generation: "preview", + opening: true, + }); + receive({ + op: "event", + event: { + v: 1, + op: "opened", + generation: "preview", + sequence: 0, + status: { state: "ready", since: Date.now() }, + }, }); const id = "sample-prompt"; conversation.addPrompt( id, "Can you make the agent workspace feel more like a conversation? Keep the terminal one click away.", ); - previewEvent("accepted", { requestId: id }); - previewEvent("message", { - messageId: "sample-reply", - requestId: id, - text: 'Absolutely. Here\u2019s a first pass at a quieter workspace, with the conversation at its center.\n\n### A familiar place to build\n- Your prompts sit on the right; agent replies arrive on the left.\n- **Code stays readable**, with syntax colors and a copy button.\n- The activity indicator follows explicit agent events.\n\n```typescript\nconst conversation = {\n agent: "Claude Code",\n workspace: "Genie",\n status: "ready"\n};\n```\n\nThe same layout adapts to a phone. And when you need the full CLI, **Open Terminal** is still right there in Astation.\n\nReference: [Astation on GitHub](https://github.com/Agora-Build/Astation).', - }); - previewEvent("complete", { requestId: id }); - previewEvent("status", { state: "ready" }); + const message = (requestId, text, reply) => { + const turnId = `turn-${requestId}`; + previewEvent("accepted", { requestId }); + previewEvent("message", { + messageId: `user-${requestId}`, + role: "user", + origin: "remote", + turnId, + requestId, + createdAt: Date.now(), + parts: [{ id: "prompt", kind: "text", markdown: text }], + }); + previewEvent("message", { + messageId: `reply-${requestId}`, + role: "assistant", + turnId, + requestId, + createdAt: Date.now(), + parts: [{ id: "text", kind: "text", markdown: reply }], + }); + previewEvent("delta", { + messageId: `reply-${requestId}`, + part: { + id: "tool", + kind: "tool", + name: "Read", + summary: "webapp/agent/chat.mjs", + }, + }); + previewEvent("complete", { turnId, requestId, outcome: "done" }); + previewEvent("status", { state: "ready", since: Date.now() }); + }; + message( + id, + messageText(conversation.messages[0]), + 'Here is a first pass at a quieter workspace.\n\n### A familiar place to build\n- Prompts sit on the right; agent replies arrive on the left.\n- **Code stays readable**, with syntax colors and a copy button.\n- Activity follows explicit agent events.\n\n```typescript\nconst conversation = {\n agent: "Claude Code",\n status: "ready"\n};\n```\n\n[View Astation on GitHub](https://github.com/Agora-Build/Astation).', + ); transport = async (body) => { if (body.op !== "submit") return; - previewEvent("accepted", { requestId: body.requestId }); - previewEvent("status", { state: "working" }); - const text = - "This is a **preview reply**, so no prompt was sent to a real code agent.\n\nIn a live conversation, your selected Atem will send the agent\u2019s structured reply here. Messages can include `inline code`, code blocks, and safe links.\n\nYour message was:\n> " + - body.text.replace(/\n/g, "\n> "); - const chunks = text.match(/[\s\S]{1,35}/g) || []; - const messageId = crypto.randomUUID(); - const step = () => { - const chunk = chunks.shift(); - if (chunk) { - previewEvent("delta", { - messageId, - requestId: body.requestId, - text: chunk, - }); - previewTimer = setTimeout(step, 45); - } else { - previewEvent("complete", { requestId: body.requestId }); - previewEvent("status", { state: "ready" }); - } - }; - previewTimer = setTimeout(step, 650); + previewEvent("status", { state: "working", since: Date.now() }); + previewTimer = setTimeout( + () => + message( + body.requestId, + body.text, + "This is a **preview reply**. Nothing was sent to a real agent.\n\nYour message was:\n> " + + body.text.replace(/\n/g, "\n> "), + ), + 650, + ); }; $("session-description").textContent = - "This is an interactive preview using sample data. You can send messages to explore the layout; nothing is delivered to a real agent."; + "This interactive preview uses sample data. Nothing is delivered to a real agent."; } async function connect() { @@ -432,7 +450,10 @@ async function connect() { socket.addEventListener("message", (event) => { receiveQueue = receiveQueue .then(async () => { - if (typeof event.data !== "string" || event.data.length > 300000) + if ( + typeof event.data !== "string" || + event.data.length > MAX_FRAME_BYTES + 2048 + ) return; let frame; try { @@ -508,7 +529,10 @@ $("composer").addEventListener("submit", async (event) => { event.preventDefault(); const text = $("prompt").value; const requestId = crypto.randomUUID(); - if (!transport || !conversation.addPrompt(requestId, text)) return; + if (!transport || !conversation.addPrompt(requestId, text)) { + scheduleRender(); + return; + } $("prompt").value = ""; $("prompt").style.height = ""; followLatest = true; @@ -571,7 +595,7 @@ $("copy-conversation").addEventListener("click", () => conversation.messages .map( (message) => - `${message.role === "user" ? "You" : conversation.context.agentName}:\n${message.text}`, + `${message.role === "user" ? "You" : conversation.context.agentName}:\n${messageText(message)}`, ) .join("\n\n"), ), @@ -580,7 +604,19 @@ document.addEventListener("keydown", (event) => { if (event.key === "Escape") sidebar(false); }); window.addEventListener("pagehide", () => clearTimeout(previewTimer)); -setInterval(renderActivity, 1000); +$("earlier-messages").addEventListener("click", () => { + const before = conversation.oldestMessageId; + if (!before || !transport) return; + followLatest = false; + transport({ op: "history", before }).catch(fail); +}); +$("reopen-chat").addEventListener("click", () => + transport?.({ op: "reopen" }).catch(fail), +); +setInterval(() => { + renderActivity(); + $("send").disabled = !conversation.canSend || !$("prompt").value.trim(); +}, 1000); if (preview) startPreview(); else connect().catch(fail); render(); diff --git a/webapp/agent/index.html b/webapp/agent/index.html index a0e6352..edca0ec 100644 --- a/webapp/agent/index.html +++ b/webapp/agent/index.html @@ -11,6 +11,7 @@ Agent · Astation + @@ -107,6 +108,7 @@

Your code agent

+
Your code agent >A SPACE TO BUILD SOMETHING
+

YOUR IDEAS. YOUR AGENT.

diff --git a/webapp/agent/markdown.mjs b/webapp/agent/markdown.mjs new file mode 100644 index 0000000..c04056d --- /dev/null +++ b/webapp/agent/markdown.mjs @@ -0,0 +1,82 @@ +import { safeLink } from "./chat-core.mjs"; + +// Render parsed CommonMark with DOM APIs so HTML and image URLs never execute. +export function renderMarkdown(text, { document, parser, codeBlock }) { + const root = document.createDocumentFragment(); + const stack = [root]; + const containers = new Set(); + const walker = parser.parse(text).walker(); + let event; + while ((event = walker.next())) { + const item = event.node; + if (!event.entering) { + if (containers.has(item)) stack.pop(); + continue; + } + const parent = stack[stack.length - 1]; + let element; + switch (item.type) { + case "document": + continue; + case "text": + case "html_inline": + case "html_block": + parent.append(document.createTextNode(item.literal || "")); + continue; + case "softbreak": + parent.append(document.createTextNode("\n")); + continue; + case "linebreak": + parent.append(document.createElement("br")); + continue; + case "thematic_break": + parent.append(document.createElement("hr")); + continue; + case "code": + element = document.createElement("code"); + element.textContent = item.literal; + parent.append(element); + continue; + case "code_block": + parent.append( + codeBlock(item.literal, (item.info || "code").split(/\s/)[0]), + ); + continue; + case "link": { + const href = safeLink(item.destination); + element = document.createElement(href ? "a" : "span"); + if (href) { + element.href = href; + element.target = "_blank"; + element.rel = "noopener noreferrer"; + if (item.title) element.title = item.title; + } + break; + } + case "list": + element = document.createElement( + item.listType === "ordered" ? "ol" : "ul", + ); + if (item.listType === "ordered") element.start = item.listStart; + break; + default: { + const tags = { + paragraph: "p", + heading: `h${item.level}`, + item: "li", + block_quote: "blockquote", + emph: "em", + strong: "strong", + image: "span", + }; + element = document.createElement(tags[item.type] || "span"); + } + } + parent.append(element); + if (item.isContainer) { + containers.add(item); + stack.push(element); + } + } + return root; +} diff --git a/webapp/agent/session-protocol.mjs b/webapp/agent/session-protocol.mjs new file mode 100644 index 0000000..e8c76ba --- /dev/null +++ b/webapp/agent/session-protocol.mjs @@ -0,0 +1,282 @@ +// Pure r6.2 protocol state for conformance tests. Live browser enrollment and +// transport remain gated on the joint section 9 security specification. +export const BROWSER_SESSION_ACCESS_ENABLED = false; +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true }); +const rawNumber = Symbol("raw JSON number"); +const uuid = (v) => typeof v === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(v); +const sessionId = (v) => typeof v === "string" && /^ses-[a-z2-7]{12}$/.test(v); +const integer = (v) => Number.isSafeInteger(v) && v >= 0; +const check = (condition) => { if (!condition) throw new Error("Invalid session protocol message"); }; +const bytes = (text, maximum) => typeof text === "string" && encoder.encode(text).length <= maximum; +const opaque = (v) => bytes(v, 128) && /^[A-Za-z0-9._:-]+$/.test(v); +const plain = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && !v[rawNumber]; + +function validUnicode(text) { + for (let i = 0; i < text.length; i++) { + const unit = text.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = text.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + } else if (unit >= 0xdc00 && unit <= 0xdfff) return false; + } + return true; +} + +export function canonicalPayload(value) { + if (value === null) return "null"; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + check(Number.isSafeInteger(value)); + return JSON.stringify(value); + } + if (typeof value === "string") { check(validUnicode(value)); return JSON.stringify(value); } + if (Array.isArray(value)) return `[${value.map(canonicalPayload).join(",")}]`; + check(plain(value)); + return `{${Object.keys(value).sort().map((key) => `${canonicalPayload(key)}:${canonicalPayload(value[key])}`).join(",")}}`; +} + +export async function payloadDigest(value, cryptoAPI = globalThis.crypto) { + const hash = new Uint8Array(await cryptoAPI.subtle.digest("SHA-256", encoder.encode(canonicalPayload(value)))); + return Array.from(hash, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +// JSON.parse alone loses duplicate keys and the spelling of 1e0/1.0 before +// integer validation. Scan original wire bytes, including relay envelopes. +export function parseSessionJSON(input, { integersOnly = true, maximum = 1048576 } = {}) { + const text = typeof input === "string" ? input : decoder.decode(input); + check(bytes(text, maximum)); + let offset = 0; + const whitespace = () => { while (/[\t\n\r ]/.test(text[offset] ?? "x")) offset++; }; + const consume = (character) => { whitespace(); if (text[offset] !== character) return false; offset++; return true; }; + const string = () => { + whitespace(); check(text[offset] === '"'); + const start = offset++; + while (offset < text.length) { + const character = text[offset++]; + if (character === '"') { + const result = JSON.parse(text.slice(start, offset)); + check(validUnicode(result)); + return result; + } + if (character === "\\") offset++; + } + throw new Error("Unterminated JSON string"); + }; + const value = (depth) => { + whitespace(); check(depth <= 64 && offset < text.length); + if (consume("{")) { + const object = Object.create(null); + if (consume("}")) return object; + do { + const key = string(); + check(!Object.hasOwn(object, key) && consume(":")); + object[key] = value(depth + 1); + if (consume("}")) return object; + } while (consume(",")); + throw new Error("Invalid JSON object"); + } + if (consume("[")) { + const array = []; + if (consume("]")) return array; + do { array.push(value(depth + 1)); if (consume("]")) return array; } while (consume(",")); + throw new Error("Invalid JSON array"); + } + if (text[offset] === '"') return string(); + for (const [literal, result] of [["true", true], ["false", false], ["null", null]]) { + if (text.startsWith(literal, offset)) { offset += literal.length; return result; } + } + const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(text.slice(offset)); + check(Boolean(match)); + offset += match[0].length; + const number = Number(match[0]); + if (!/[.eE]/.test(match[0]) && Number.isSafeInteger(number)) return number; + check(!integersOnly); + return { [rawNumber]: match[0] }; + }; + const result = value(0); + whitespace(); check(offset === text.length); + return result; +} + +export function validTarget(target) { + return plain(target) && opaque(target.account_id) && opaque(target.atem_device_id) + && integer(target.host_trust_gen) && sessionId(target.session_id) && uuid(target.process_gen); +} +function validBinding(binding) { + return plain(binding) && validTarget(binding.target) && uuid(binding.attachment_gen) + && (binding.lease_epoch === undefined || integer(binding.lease_epoch)); +} +function controller(value) { + return value === null || (plain(value) && uuid(value.controller_session) && integer(value.lease_epoch)); +} +function descriptor(value) { + check(plain(value) && sessionId(value.session_id) && uuid(value.process_gen) && bytes(value.provider, 128) && value.provider.length > 0 + && bytes(value.cwd_label, 256) && ["running", "exited", "interrupted", "recovery_required", "closed", "removed"].includes(value.state) + && ["ready", "working", "waiting", "unknown"].includes(value.activity) && integer(value.activity_since) + && Object.hasOwn(value, "controller") && controller(value.controller) && integer(value.created_at) + && Array.isArray(value.capabilities) && value.capabilities.length <= 32 + && value.capabilities.every((v) => bytes(v, 128) && v.length > 0)); +} +function receipt(value) { + check(plain(value) && uuid(value.request_id) && validTarget(value.target) && ["prompt", "interrupt", "stop"].includes(value.kind) + && ["accepted", "executing", "completed", "rejected", "outcome_unknown"].includes(value.state) + && integer(value.updated_at) && (value.detail === undefined || bytes(value.detail, 512))); +} + +export function decodeSessionPacket(input) { + const envelope = parseSessionJSON(input, { integersOnly: false, maximum: 2097152 }); + const packet = envelope.payload ?? envelope; + check(packet.type === "session" && plain(packet.data)); + const message = packet.data; + check(bytes(JSON.stringify(message, (_, value) => value?.[rawNumber] ?? value), 1048576)); + check(message.v === 1 && uuid(message.id) && (message.re === undefined || uuid(message.re))); + for (const key of ["host_trust_gen", "authorization_ms_left", "server_time", "runtime_epoch", "seq", "ms_left", "lease_epoch", "updated_at"]) { + if (message[key] !== undefined) check(integer(message[key])); + } + if (message.binding !== undefined) check(validBinding(message.binding)); + if (message.detail !== undefined) check(bytes(message.detail, 512)); + switch (message.op) { + case "hello": case "list": break; + case "welcome": + check(opaque(message.account_id) && opaque(message.atem_device_id) && integer(message.host_trust_gen) + && uuid(message.runtime_gen) && uuid(message.controller_session) && uuid(message.attachment_gen) + && ["native", "browser"].includes(message.client_kind) && Array.isArray(message.permissions) + && message.permissions.every((v) => ["view", "control", "approve"].includes(v)) + && integer(message.authorization_ms_left) && integer(message.server_time)); + break; + case "sessions": case "session_changed": + check(uuid(message.runtime_gen) && integer(message.runtime_epoch) && integer(message.seq)); + if (message.op === "sessions") { + check(Array.isArray(message.sessions) && message.sessions.length <= 1024); + message.sessions.forEach(descriptor); + } else descriptor(message.descriptor); + break; + case "acquire": check(message.binding && message.binding.lease_epoch === undefined && typeof message.takeover === "boolean"); break; + case "renew": case "release": check(message.binding && integer(message.binding.lease_epoch)); break; + case "lease": check(uuid(message.re) && message.binding && integer(message.binding.lease_epoch) && integer(message.ms_left) && message.ms_left <= 15000); break; + case "lease_released": check(uuid(message.re)); break; + case "lease_changed": + check(sessionId(message.session_id) && uuid(message.process_gen) && integer(message.lease_epoch) && controller(message.controller) + && ["granted", "takeover", "local_input", "expired", "released", "revoked", "process_ended"].includes(message.reason)); + break; + case "submit": + check(message.binding && integer(message.binding.lease_epoch) && uuid(message.request_id) && ["prompt", "interrupt", "stop"].includes(message.kind)); + check(encoder.encode(canonicalPayload(message.payload)).length <= 65536); + if (message.kind === "prompt") check(plain(message.payload) && Object.keys(message.payload).length === 1 && bytes(message.payload.text, 32768)); + else check(plain(message.payload) && Object.keys(message.payload).length === 0); + break; + case "receipt": receipt(message); break; + case "receipt_query": check(message.binding && message.binding.lease_epoch === undefined && Array.isArray(message.request_ids) + && message.request_ids.length <= 64 && message.request_ids.every(uuid)); break; + case "receipts": check(uuid(message.re) && Array.isArray(message.receipts) && message.receipts.length <= 64); message.receipts.forEach(receipt); break; + case "error": check(bytes(message.code, 128) && message.code.length > 0); break; + default: throw new Error("Unsupported session operation"); + } + return message; +} + +export function decodeFamilyError(input) { + const envelope = parseSessionJSON(input, { integersOnly: false, maximum: 2097152 }); + const packet = envelope.payload ?? envelope; + const message = packet.data; + check(plain(message) && message.v === 1 && message.op === "error" + && bytes(message.code, 128) && message.code.length > 0 && message.binding === undefined); + if (packet.type === "terminal") { + check(sessionId(message.terminal_id) && bytes(message.message, 512) + && (message.stream_id === undefined || (bytes(message.stream_id, 128) && message.stream_id.length > 0))); + } else { + check(packet.type === "agentChat" && sessionId(message.agentId) && bytes(message.conversationId, 128) + && message.conversationId.length > 0 && bytes(message.text, 512) + && (message.requestId === undefined || uuid(message.requestId))); + } + return message; +} + +export class SessionRegistry { + constructor() { + this.sessions = new Map(); this.tombstones = new Map(); this.epoch = null; + this.runtime = null; this.seq = null; this.needsSnapshot = true; this.requestedList = false; + this.attachment = null; + } + apply(message) { + check(["welcome", "sessions", "session_changed"].includes(message.op)); + if (message.op === "welcome") { + check(uuid(message.attachment_gen)); + if (message.attachment_gen === this.attachment) return; + this.attachment = message.attachment_gen; + this.sessions.clear(); this.tombstones.clear(); this.seq = null; + this.needsSnapshot = true; this.requestedList = false; + return; + } + const { runtime_epoch: epoch, runtime_gen: runtime, seq } = message; + if (this.epoch !== null && epoch < this.epoch) return; + if (epoch === this.epoch) check(runtime === this.runtime); + if (epoch !== this.epoch) { + this.epoch = epoch; this.runtime = runtime; this.seq = null; + this.sessions.clear(); this.tombstones.clear(); this.needsSnapshot = true; + } + if (message.op === "sessions") { + if (this.seq !== null && seq < this.seq) return; + check(new Set(message.sessions.map((v) => v.session_id)).size === message.sessions.length); + const next = new Map(); + for (const value of message.sessions) { + if (value.state === "removed") this.removed(value); + else if (!this.tombstones.get(value.session_id)?.has(value.process_gen)) next.set(value.session_id, value); + } + this.sessions = next; this.seq = seq; this.needsSnapshot = false; + } else { + if (this.needsSnapshot || this.seq === null) { this.requestedList = true; return; } + if (seq <= this.seq) return; + if (seq !== this.seq + 1) { this.needsSnapshot = true; this.requestedList = true; return; } + this.seq = seq; + const value = message.descriptor; + if (value.state === "removed") { this.sessions.delete(value.session_id); this.removed(value); } + else if (!this.tombstones.get(value.session_id)?.has(value.process_gen)) this.sessions.set(value.session_id, value); + } + check([...this.tombstones.values()].reduce((count, values) => count + values.size, 0) <= 4096); + } + removed(value) { + const generations = this.tombstones.get(value.session_id) ?? new Set(); + generations.add(value.process_gen); + this.tombstones.set(value.session_id, generations); + } +} + +export class SessionLease { + constructor(binding, { now = () => performance.now(), authorizationDeadline = Infinity } = {}) { + check(validBinding(binding)); + this.binding = binding; this.now = now; this.authorizationDeadline = authorizationDeadline; + this.deadline = null; this.epoch = null; this.highestEpoch = 0; this.revokedThrough = null; this.pending = new Map(); + } + get controls() { return this.epoch !== null && this.now() < this.deadline && this.now() < this.authorizationDeadline; } + sent(message) { + check(["acquire", "renew"].includes(message.op)); + check(canonicalPayload(message.binding.target) === canonicalPayload(this.binding.target) && message.binding.attachment_gen === this.binding.attachment_gen); + if (message.op === "renew" && !this.controls) return false; + if (message.op === "acquire" && !this.controls) { this.pending.clear(); this.epoch = null; } + this.pending.set(message.id, { sent: this.now(), op: message.op, epoch: message.binding.lease_epoch, previous: this.controls ? this.deadline : null }); + return true; + } + receive(message) { + check(message.op === "lease" && validBinding(message.binding) && integer(message.binding.lease_epoch) + && integer(message.ms_left) && message.ms_left <= 15000); + const request = this.pending.get(message.re); + if (!request) return; + this.pending.delete(message.re); + const binding = message.binding; + if (!binding || binding.attachment_gen !== this.binding.attachment_gen || canonicalPayload(binding.target) !== canonicalPayload(this.binding.target) + || binding.lease_epoch < this.highestEpoch || (this.revokedThrough !== null && binding.lease_epoch <= this.revokedThrough) + || (request.op === "renew" && binding.lease_epoch !== request.epoch)) return; + if (request.previous !== null && this.now() >= request.previous) { this.epoch = null; return; } + let deadline = Math.min(request.sent + message.ms_left, this.authorizationDeadline); + if (request.op === "acquire" && request.previous !== null) deadline = Math.min(deadline, request.previous); + this.deadline = deadline; + if (this.now() >= deadline) { this.epoch = null; return; } + this.epoch = binding.lease_epoch; this.highestEpoch = this.epoch; + } + revoke(epoch) { + if (!integer(epoch) || epoch < this.highestEpoch) return; + this.highestEpoch = epoch; this.revokedThrough = epoch; this.epoch = null; this.pending.clear(); + } +} diff --git a/webapp/agent/vendor/LICENSE b/webapp/agent/vendor/LICENSE new file mode 100644 index 0000000..e946ca8 --- /dev/null +++ b/webapp/agent/vendor/LICENSE @@ -0,0 +1,89 @@ +Copyright (c) 2014, John MacFarlane + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +lib/from-code-point.js is derived from a polyfill +Copyright Mathias Bynens + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +bench/samples/*.md: + +With the exception of `bench/samples/README.md`, the samples in +`bench/samples` are taken from https://github.com/markdown-it/markdown-it. + +Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +--- + +The CommonMark spec (spec.txt) in test/ is + +Copyright (C) 2014-15 John MacFarlane + +Released under the Creative Commons CC-BY-SA 4.0 license: +. diff --git a/webapp/agent/vendor/README.md b/webapp/agent/vendor/README.md new file mode 100644 index 0000000..4b84e56 --- /dev/null +++ b/webapp/agent/vendor/README.md @@ -0,0 +1,11 @@ +# CommonMark parser + +commonmark 0.31.2, BSD-2-Clause, from the npm package. +The browser uses its parser with a DOM renderer; raw HTML never enters the DOM. + +Pinned archive integrity: `sha512-2fRLTyb9r/2835k5cwcAwOj0DEc44FARnMp5veGsJ+mEAZdi52sNopLu07ZyElQUz058H43whzlERDIaaSw4rg==` + +SHA-256 `LICENSE`: `6cc4b9b28cf68e5bd20f9e94859a85cde35095ef4f9d0f7385b0b6166642f50b` + +SHA-256 `commonmark.min.js`: `2de0f8ecbca0a6470da57c8b2ad043777ae999c5132f9abf12e8c332d4e46164` + diff --git a/webapp/agent/vendor/commonmark.min.js b/webapp/agent/vendor/commonmark.min.js new file mode 100644 index 0000000..42bff6b --- /dev/null +++ b/webapp/agent/vendor/commonmark.min.js @@ -0,0 +1 @@ +!function(e,r){"object"==typeof exports&&"undefined"!=typeof module?r(exports):"function"==typeof define&&define.amd?define(["exports"],r):r((e=e||self).commonmark={})}(this,function(e){"use strict";function i(e){switch(e._type){case"document":case"block_quote":case"list":case"item":case"paragraph":case"heading":case"emph":case"strong":case"link":case"image":case"custom_inline":case"custom_block":return!0;default:return!1}}function B(e,r){this.current=e,this.entering=!0===r}function H(){var e,r=this.current,t=this.entering;return null===r?null:(e=i(r),t&&e?r._firstChild?(this.current=r._firstChild,this.entering=!0):this.entering=!1:r===this.root?this.current=null:null===r._next?(this.current=r._parent,this.entering=!1):(this.current=r._next,this.entering=!0),{entering:t,node:r})}function O(e){return{current:e,root:e,entering:!0,next:H,resumeAt:B}}var b=function(e,r){this._type=e,this._parent=null,this._firstChild=null,this._lastChild=null,this._prev=null,this._next=null,this._sourcepos=r,this._open=!0,this._string_content=null,this._literal=null,this._listData={},this._info=null,this._destination=null,this._title=null,this._isFenced=!1,this._fenceChar=null,this._fenceLength=0,this._fenceOffset=null,this._level=null,this._onEnter=null,this._onExit=null},r=b.prototype,l=(Object.defineProperty(r,"isContainer",{get:function(){return i(this)}}),Object.defineProperty(r,"type",{get:function(){return this._type}}),Object.defineProperty(r,"firstChild",{get:function(){return this._firstChild}}),Object.defineProperty(r,"lastChild",{get:function(){return this._lastChild}}),Object.defineProperty(r,"next",{get:function(){return this._next}}),Object.defineProperty(r,"prev",{get:function(){return this._prev}}),Object.defineProperty(r,"parent",{get:function(){return this._parent}}),Object.defineProperty(r,"sourcepos",{get:function(){return this._sourcepos}}),Object.defineProperty(r,"literal",{get:function(){return this._literal},set:function(e){this._literal=e}}),Object.defineProperty(r,"destination",{get:function(){return this._destination},set:function(e){this._destination=e}}),Object.defineProperty(r,"title",{get:function(){return this._title},set:function(e){this._title=e}}),Object.defineProperty(r,"info",{get:function(){return this._info},set:function(e){this._info=e}}),Object.defineProperty(r,"level",{get:function(){return this._level},set:function(e){this._level=e}}),Object.defineProperty(r,"listType",{get:function(){return this._listData.type},set:function(e){this._listData.type=e}}),Object.defineProperty(r,"listTight",{get:function(){return this._listData.tight},set:function(e){this._listData.tight=e}}),Object.defineProperty(r,"listStart",{get:function(){return this._listData.start},set:function(e){this._listData.start=e}}),Object.defineProperty(r,"listDelimiter",{get:function(){return this._listData.delimiter},set:function(e){this._listData.delimiter=e}}),Object.defineProperty(r,"onEnter",{get:function(){return this._onEnter},set:function(e){this._onEnter=e}}),Object.defineProperty(r,"onExit",{get:function(){return this._onExit},set:function(e){this._onExit=e}}),b.prototype.appendChild=function(e){e.unlink(),(e._parent=this)._lastChild?(this._lastChild._next=e)._prev=this._lastChild:this._firstChild=e,this._lastChild=e},b.prototype.prependChild=function(e){e.unlink(),(e._parent=this)._firstChild?((this._firstChild._prev=e)._next=this._firstChild,this._firstChild=e):(this._firstChild=e,this._lastChild=e)},b.prototype.unlink=function(){this._prev?this._prev._next=this._next:this._parent&&(this._parent._firstChild=this._next),this._next?this._next._prev=this._prev:this._parent&&(this._parent._lastChild=this._prev),this._parent=null,this._next=null,this._prev=null},b.prototype.insertAfter=function(e){e.unlink(),e._next=this._next,e._next&&(e._next._prev=e),((e._prev=this)._next=e)._parent=this._parent,e._next||(e._parent._lastChild=e)},b.prototype.insertBefore=function(e){e.unlink(),e._prev=this._prev,e._prev&&(e._prev._next=e),((e._next=this)._prev=e)._parent=this._parent,e._prev||(e._parent._firstChild=e)},b.prototype.walker=function(){return new O(this)},{});function c(e,r,t){var i,n,s,a,o="";for("string"!=typeof r&&(t=r,r=c.defaultChars),void 0===t&&(t=!0),a=function(e){var r,t,i=l[e];if(!i){for(i=l[e]=[],r=0;r<128;r++)t=String.fromCharCode(r),/^[0-9a-z]$/i.test(t)?i.push(t):i.push("%"+("0"+r.toString(16).toUpperCase()).slice(-2));for(r=0;r>>10&1023|55296),e=56320|1023&e),r+=String.fromCharCode(e)};r.default=function(e){var r;return 55296<=e&&e<=57343||1114111>8;if(0!=n){if(1==n)return i===e[t]?t+1:-1;var r=r&h.JUMP_TABLE;if(r)return(r=i-c.JUMP_OFFSET_BASE-r)<0||n>>1,l=e[o];if(l",lt:"<",quot:'"',default:{amp:"&",apos:"'",gt:">",lt:"<",quot:'"'}})),g=s(Object.freeze({__proto__:null,Aacute:"Á",aacute:"á",Abreve:"Ă",abreve:"ă",ac:"∾",acd:"∿",acE:"∾̳",Acirc:"Â",acirc:"â",acute:"´",Acy:"А",acy:"а",AElig:"Æ",aelig:"æ",af:"⁡",Afr:"𝔄",afr:"𝔞",Agrave:"À",agrave:"à",alefsym:"ℵ",aleph:"ℵ",Alpha:"Α",alpha:"α",Amacr:"Ā",amacr:"ā",amalg:"⨿",amp:"&",AMP:"&",andand:"⩕",And:"⩓",and:"∧",andd:"⩜",andslope:"⩘",andv:"⩚",ang:"∠",ange:"⦤",angle:"∠",angmsdaa:"⦨",angmsdab:"⦩",angmsdac:"⦪",angmsdad:"⦫",angmsdae:"⦬",angmsdaf:"⦭",angmsdag:"⦮",angmsdah:"⦯",angmsd:"∡",angrt:"∟",angrtvb:"⊾",angrtvbd:"⦝",angsph:"∢",angst:"Å",angzarr:"⍼",Aogon:"Ą",aogon:"ą",Aopf:"𝔸",aopf:"𝕒",apacir:"⩯",ap:"≈",apE:"⩰",ape:"≊",apid:"≋",apos:"'",ApplyFunction:"⁡",approx:"≈",approxeq:"≊",Aring:"Å",aring:"å",Ascr:"𝒜",ascr:"𝒶",Assign:"≔",ast:"*",asymp:"≈",asympeq:"≍",Atilde:"Ã",atilde:"ã",Auml:"Ä",auml:"ä",awconint:"∳",awint:"⨑",backcong:"≌",backepsilon:"϶",backprime:"‵",backsim:"∽",backsimeq:"⋍",Backslash:"∖",Barv:"⫧",barvee:"⊽",barwed:"⌅",Barwed:"⌆",barwedge:"⌅",bbrk:"⎵",bbrktbrk:"⎶",bcong:"≌",Bcy:"Б",bcy:"б",bdquo:"„",becaus:"∵",because:"∵",Because:"∵",bemptyv:"⦰",bepsi:"϶",bernou:"ℬ",Bernoullis:"ℬ",Beta:"Β",beta:"β",beth:"ℶ",between:"≬",Bfr:"𝔅",bfr:"𝔟",bigcap:"⋂",bigcirc:"◯",bigcup:"⋃",bigodot:"⨀",bigoplus:"⨁",bigotimes:"⨂",bigsqcup:"⨆",bigstar:"★",bigtriangledown:"▽",bigtriangleup:"△",biguplus:"⨄",bigvee:"⋁",bigwedge:"⋀",bkarow:"⤍",blacklozenge:"⧫",blacksquare:"▪",blacktriangle:"▴",blacktriangledown:"▾",blacktriangleleft:"◂",blacktriangleright:"▸",blank:"␣",blk12:"▒",blk14:"░",blk34:"▓",block:"█",bne:"=⃥",bnequiv:"≡⃥",bNot:"⫭",bnot:"⌐",Bopf:"𝔹",bopf:"𝕓",bot:"⊥",bottom:"⊥",bowtie:"⋈",boxbox:"⧉",boxdl:"┐",boxdL:"╕",boxDl:"╖",boxDL:"╗",boxdr:"┌",boxdR:"╒",boxDr:"╓",boxDR:"╔",boxh:"─",boxH:"═",boxhd:"┬",boxHd:"╤",boxhD:"╥",boxHD:"╦",boxhu:"┴",boxHu:"╧",boxhU:"╨",boxHU:"╩",boxminus:"⊟",boxplus:"⊞",boxtimes:"⊠",boxul:"┘",boxuL:"╛",boxUl:"╜",boxUL:"╝",boxur:"└",boxuR:"╘",boxUr:"╙",boxUR:"╚",boxv:"│",boxV:"║",boxvh:"┼",boxvH:"╪",boxVh:"╫",boxVH:"╬",boxvl:"┤",boxvL:"╡",boxVl:"╢",boxVL:"╣",boxvr:"├",boxvR:"╞",boxVr:"╟",boxVR:"╠",bprime:"‵",breve:"˘",Breve:"˘",brvbar:"¦",bscr:"𝒷",Bscr:"ℬ",bsemi:"⁏",bsim:"∽",bsime:"⋍",bsolb:"⧅",bsol:"\\",bsolhsub:"⟈",bull:"•",bullet:"•",bump:"≎",bumpE:"⪮",bumpe:"≏",Bumpeq:"≎",bumpeq:"≏",Cacute:"Ć",cacute:"ć",capand:"⩄",capbrcup:"⩉",capcap:"⩋",cap:"∩",Cap:"⋒",capcup:"⩇",capdot:"⩀",CapitalDifferentialD:"ⅅ",caps:"∩︀",caret:"⁁",caron:"ˇ",Cayleys:"ℭ",ccaps:"⩍",Ccaron:"Č",ccaron:"č",Ccedil:"Ç",ccedil:"ç",Ccirc:"Ĉ",ccirc:"ĉ",Cconint:"∰",ccups:"⩌",ccupssm:"⩐",Cdot:"Ċ",cdot:"ċ",cedil:"¸",Cedilla:"¸",cemptyv:"⦲",cent:"¢",centerdot:"·",CenterDot:"·",cfr:"𝔠",Cfr:"ℭ",CHcy:"Ч",chcy:"ч",check:"✓",checkmark:"✓",Chi:"Χ",chi:"χ",circ:"ˆ",circeq:"≗",circlearrowleft:"↺",circlearrowright:"↻",circledast:"⊛",circledcirc:"⊚",circleddash:"⊝",CircleDot:"⊙",circledR:"®",circledS:"Ⓢ",CircleMinus:"⊖",CirclePlus:"⊕",CircleTimes:"⊗",cir:"○",cirE:"⧃",cire:"≗",cirfnint:"⨐",cirmid:"⫯",cirscir:"⧂",ClockwiseContourIntegral:"∲",CloseCurlyDoubleQuote:"”",CloseCurlyQuote:"’",clubs:"♣",clubsuit:"♣",colon:":",Colon:"∷",Colone:"⩴",colone:"≔",coloneq:"≔",comma:",",commat:"@",comp:"∁",compfn:"∘",complement:"∁",complexes:"ℂ",cong:"≅",congdot:"⩭",Congruent:"≡",conint:"∮",Conint:"∯",ContourIntegral:"∮",copf:"𝕔",Copf:"ℂ",coprod:"∐",Coproduct:"∐",copy:"©",COPY:"©",copysr:"℗",CounterClockwiseContourIntegral:"∳",crarr:"↵",cross:"✗",Cross:"⨯",Cscr:"𝒞",cscr:"𝒸",csub:"⫏",csube:"⫑",csup:"⫐",csupe:"⫒",ctdot:"⋯",cudarrl:"⤸",cudarrr:"⤵",cuepr:"⋞",cuesc:"⋟",cularr:"↶",cularrp:"⤽",cupbrcap:"⩈",cupcap:"⩆",CupCap:"≍",cup:"∪",Cup:"⋓",cupcup:"⩊",cupdot:"⊍",cupor:"⩅",cups:"∪︀",curarr:"↷",curarrm:"⤼",curlyeqprec:"⋞",curlyeqsucc:"⋟",curlyvee:"⋎",curlywedge:"⋏",curren:"¤",curvearrowleft:"↶",curvearrowright:"↷",cuvee:"⋎",cuwed:"⋏",cwconint:"∲",cwint:"∱",cylcty:"⌭",dagger:"†",Dagger:"‡",daleth:"ℸ",darr:"↓",Darr:"↡",dArr:"⇓",dash:"‐",Dashv:"⫤",dashv:"⊣",dbkarow:"⤏",dblac:"˝",Dcaron:"Ď",dcaron:"ď",Dcy:"Д",dcy:"д",ddagger:"‡",ddarr:"⇊",DD:"ⅅ",dd:"ⅆ",DDotrahd:"⤑",ddotseq:"⩷",deg:"°",Del:"∇",Delta:"Δ",delta:"δ",demptyv:"⦱",dfisht:"⥿",Dfr:"𝔇",dfr:"𝔡",dHar:"⥥",dharl:"⇃",dharr:"⇂",DiacriticalAcute:"´",DiacriticalDot:"˙",DiacriticalDoubleAcute:"˝",DiacriticalGrave:"`",DiacriticalTilde:"˜",diam:"⋄",diamond:"⋄",Diamond:"⋄",diamondsuit:"♦",diams:"♦",die:"¨",DifferentialD:"ⅆ",digamma:"ϝ",disin:"⋲",div:"÷",divide:"÷",divideontimes:"⋇",divonx:"⋇",DJcy:"Ђ",djcy:"ђ",dlcorn:"⌞",dlcrop:"⌍",dollar:"$",Dopf:"𝔻",dopf:"𝕕",Dot:"¨",dot:"˙",DotDot:"⃜",doteq:"≐",doteqdot:"≑",DotEqual:"≐",dotminus:"∸",dotplus:"∔",dotsquare:"⊡",doublebarwedge:"⌆",DoubleContourIntegral:"∯",DoubleDot:"¨",DoubleDownArrow:"⇓",DoubleLeftArrow:"⇐",DoubleLeftRightArrow:"⇔",DoubleLeftTee:"⫤",DoubleLongLeftArrow:"⟸",DoubleLongLeftRightArrow:"⟺",DoubleLongRightArrow:"⟹",DoubleRightArrow:"⇒",DoubleRightTee:"⊨",DoubleUpArrow:"⇑",DoubleUpDownArrow:"⇕",DoubleVerticalBar:"∥",DownArrowBar:"⤓",downarrow:"↓",DownArrow:"↓",Downarrow:"⇓",DownArrowUpArrow:"⇵",DownBreve:"̑",downdownarrows:"⇊",downharpoonleft:"⇃",downharpoonright:"⇂",DownLeftRightVector:"⥐",DownLeftTeeVector:"⥞",DownLeftVectorBar:"⥖",DownLeftVector:"↽",DownRightTeeVector:"⥟",DownRightVectorBar:"⥗",DownRightVector:"⇁",DownTeeArrow:"↧",DownTee:"⊤",drbkarow:"⤐",drcorn:"⌟",drcrop:"⌌",Dscr:"𝒟",dscr:"𝒹",DScy:"Ѕ",dscy:"ѕ",dsol:"⧶",Dstrok:"Đ",dstrok:"đ",dtdot:"⋱",dtri:"▿",dtrif:"▾",duarr:"⇵",duhar:"⥯",dwangle:"⦦",DZcy:"Џ",dzcy:"џ",dzigrarr:"⟿",Eacute:"É",eacute:"é",easter:"⩮",Ecaron:"Ě",ecaron:"ě",Ecirc:"Ê",ecirc:"ê",ecir:"≖",ecolon:"≕",Ecy:"Э",ecy:"э",eDDot:"⩷",Edot:"Ė",edot:"ė",eDot:"≑",ee:"ⅇ",efDot:"≒",Efr:"𝔈",efr:"𝔢",eg:"⪚",Egrave:"È",egrave:"è",egs:"⪖",egsdot:"⪘",el:"⪙",Element:"∈",elinters:"⏧",ell:"ℓ",els:"⪕",elsdot:"⪗",Emacr:"Ē",emacr:"ē",empty:"∅",emptyset:"∅",EmptySmallSquare:"◻",emptyv:"∅",EmptyVerySmallSquare:"▫",emsp13:" ",emsp14:" ",emsp:" ",ENG:"Ŋ",eng:"ŋ",ensp:" ",Eogon:"Ę",eogon:"ę",Eopf:"𝔼",eopf:"𝕖",epar:"⋕",eparsl:"⧣",eplus:"⩱",epsi:"ε",Epsilon:"Ε",epsilon:"ε",epsiv:"ϵ",eqcirc:"≖",eqcolon:"≕",eqsim:"≂",eqslantgtr:"⪖",eqslantless:"⪕",Equal:"⩵",equals:"=",EqualTilde:"≂",equest:"≟",Equilibrium:"⇌",equiv:"≡",equivDD:"⩸",eqvparsl:"⧥",erarr:"⥱",erDot:"≓",escr:"ℯ",Escr:"ℰ",esdot:"≐",Esim:"⩳",esim:"≂",Eta:"Η",eta:"η",ETH:"Ð",eth:"ð",Euml:"Ë",euml:"ë",euro:"€",excl:"!",exist:"∃",Exists:"∃",expectation:"ℰ",exponentiale:"ⅇ",ExponentialE:"ⅇ",fallingdotseq:"≒",Fcy:"Ф",fcy:"ф",female:"♀",ffilig:"ffi",fflig:"ff",ffllig:"ffl",Ffr:"𝔉",ffr:"𝔣",filig:"fi",FilledSmallSquare:"◼",FilledVerySmallSquare:"▪",fjlig:"fj",flat:"♭",fllig:"fl",fltns:"▱",fnof:"ƒ",Fopf:"𝔽",fopf:"𝕗",forall:"∀",ForAll:"∀",fork:"⋔",forkv:"⫙",Fouriertrf:"ℱ",fpartint:"⨍",frac12:"½",frac13:"⅓",frac14:"¼",frac15:"⅕",frac16:"⅙",frac18:"⅛",frac23:"⅔",frac25:"⅖",frac34:"¾",frac35:"⅗",frac38:"⅜",frac45:"⅘",frac56:"⅚",frac58:"⅝",frac78:"⅞",frasl:"⁄",frown:"⌢",fscr:"𝒻",Fscr:"ℱ",gacute:"ǵ",Gamma:"Γ",gamma:"γ",Gammad:"Ϝ",gammad:"ϝ",gap:"⪆",Gbreve:"Ğ",gbreve:"ğ",Gcedil:"Ģ",Gcirc:"Ĝ",gcirc:"ĝ",Gcy:"Г",gcy:"г",Gdot:"Ġ",gdot:"ġ",ge:"≥",gE:"≧",gEl:"⪌",gel:"⋛",geq:"≥",geqq:"≧",geqslant:"⩾",gescc:"⪩",ges:"⩾",gesdot:"⪀",gesdoto:"⪂",gesdotol:"⪄",gesl:"⋛︀",gesles:"⪔",Gfr:"𝔊",gfr:"𝔤",gg:"≫",Gg:"⋙",ggg:"⋙",gimel:"ℷ",GJcy:"Ѓ",gjcy:"ѓ",gla:"⪥",gl:"≷",glE:"⪒",glj:"⪤",gnap:"⪊",gnapprox:"⪊",gne:"⪈",gnE:"≩",gneq:"⪈",gneqq:"≩",gnsim:"⋧",Gopf:"𝔾",gopf:"𝕘",grave:"`",GreaterEqual:"≥",GreaterEqualLess:"⋛",GreaterFullEqual:"≧",GreaterGreater:"⪢",GreaterLess:"≷",GreaterSlantEqual:"⩾",GreaterTilde:"≳",Gscr:"𝒢",gscr:"ℊ",gsim:"≳",gsime:"⪎",gsiml:"⪐",gtcc:"⪧",gtcir:"⩺",gt:">",GT:">",Gt:"≫",gtdot:"⋗",gtlPar:"⦕",gtquest:"⩼",gtrapprox:"⪆",gtrarr:"⥸",gtrdot:"⋗",gtreqless:"⋛",gtreqqless:"⪌",gtrless:"≷",gtrsim:"≳",gvertneqq:"≩︀",gvnE:"≩︀",Hacek:"ˇ",hairsp:" ",half:"½",hamilt:"ℋ",HARDcy:"Ъ",hardcy:"ъ",harrcir:"⥈",harr:"↔",hArr:"⇔",harrw:"↭",Hat:"^",hbar:"ℏ",Hcirc:"Ĥ",hcirc:"ĥ",hearts:"♥",heartsuit:"♥",hellip:"…",hercon:"⊹",hfr:"𝔥",Hfr:"ℌ",HilbertSpace:"ℋ",hksearow:"⤥",hkswarow:"⤦",hoarr:"⇿",homtht:"∻",hookleftarrow:"↩",hookrightarrow:"↪",hopf:"𝕙",Hopf:"ℍ",horbar:"―",HorizontalLine:"─",hscr:"𝒽",Hscr:"ℋ",hslash:"ℏ",Hstrok:"Ħ",hstrok:"ħ",HumpDownHump:"≎",HumpEqual:"≏",hybull:"⁃",hyphen:"‐",Iacute:"Í",iacute:"í",ic:"⁣",Icirc:"Î",icirc:"î",Icy:"И",icy:"и",Idot:"İ",IEcy:"Е",iecy:"е",iexcl:"¡",iff:"⇔",ifr:"𝔦",Ifr:"ℑ",Igrave:"Ì",igrave:"ì",ii:"ⅈ",iiiint:"⨌",iiint:"∭",iinfin:"⧜",iiota:"℩",IJlig:"IJ",ijlig:"ij",Imacr:"Ī",imacr:"ī",image:"ℑ",ImaginaryI:"ⅈ",imagline:"ℐ",imagpart:"ℑ",imath:"ı",Im:"ℑ",imof:"⊷",imped:"Ƶ",Implies:"⇒",incare:"℅",infin:"∞",infintie:"⧝",inodot:"ı",intcal:"⊺",int:"∫",Int:"∬",integers:"ℤ",Integral:"∫",intercal:"⊺",Intersection:"⋂",intlarhk:"⨗",intprod:"⨼",InvisibleComma:"⁣",InvisibleTimes:"⁢",IOcy:"Ё",iocy:"ё",Iogon:"Į",iogon:"į",Iopf:"𝕀",iopf:"𝕚",Iota:"Ι",iota:"ι",iprod:"⨼",iquest:"¿",iscr:"𝒾",Iscr:"ℐ",isin:"∈",isindot:"⋵",isinE:"⋹",isins:"⋴",isinsv:"⋳",isinv:"∈",it:"⁢",Itilde:"Ĩ",itilde:"ĩ",Iukcy:"І",iukcy:"і",Iuml:"Ï",iuml:"ï",Jcirc:"Ĵ",jcirc:"ĵ",Jcy:"Й",jcy:"й",Jfr:"𝔍",jfr:"𝔧",jmath:"ȷ",Jopf:"𝕁",jopf:"𝕛",Jscr:"𝒥",jscr:"𝒿",Jsercy:"Ј",jsercy:"ј",Jukcy:"Є",jukcy:"є",Kappa:"Κ",kappa:"κ",kappav:"ϰ",Kcedil:"Ķ",kcedil:"ķ",Kcy:"К",kcy:"к",Kfr:"𝔎",kfr:"𝔨",kgreen:"ĸ",KHcy:"Х",khcy:"х",KJcy:"Ќ",kjcy:"ќ",Kopf:"𝕂",kopf:"𝕜",Kscr:"𝒦",kscr:"𝓀",lAarr:"⇚",Lacute:"Ĺ",lacute:"ĺ",laemptyv:"⦴",lagran:"ℒ",Lambda:"Λ",lambda:"λ",lang:"⟨",Lang:"⟪",langd:"⦑",langle:"⟨",lap:"⪅",Laplacetrf:"ℒ",laquo:"«",larrb:"⇤",larrbfs:"⤟",larr:"←",Larr:"↞",lArr:"⇐",larrfs:"⤝",larrhk:"↩",larrlp:"↫",larrpl:"⤹",larrsim:"⥳",larrtl:"↢",latail:"⤙",lAtail:"⤛",lat:"⪫",late:"⪭",lates:"⪭︀",lbarr:"⤌",lBarr:"⤎",lbbrk:"❲",lbrace:"{",lbrack:"[",lbrke:"⦋",lbrksld:"⦏",lbrkslu:"⦍",Lcaron:"Ľ",lcaron:"ľ",Lcedil:"Ļ",lcedil:"ļ",lceil:"⌈",lcub:"{",Lcy:"Л",lcy:"л",ldca:"⤶",ldquo:"“",ldquor:"„",ldrdhar:"⥧",ldrushar:"⥋",ldsh:"↲",le:"≤",lE:"≦",LeftAngleBracket:"⟨",LeftArrowBar:"⇤",leftarrow:"←",LeftArrow:"←",Leftarrow:"⇐",LeftArrowRightArrow:"⇆",leftarrowtail:"↢",LeftCeiling:"⌈",LeftDoubleBracket:"⟦",LeftDownTeeVector:"⥡",LeftDownVectorBar:"⥙",LeftDownVector:"⇃",LeftFloor:"⌊",leftharpoondown:"↽",leftharpoonup:"↼",leftleftarrows:"⇇",leftrightarrow:"↔",LeftRightArrow:"↔",Leftrightarrow:"⇔",leftrightarrows:"⇆",leftrightharpoons:"⇋",leftrightsquigarrow:"↭",LeftRightVector:"⥎",LeftTeeArrow:"↤",LeftTee:"⊣",LeftTeeVector:"⥚",leftthreetimes:"⋋",LeftTriangleBar:"⧏",LeftTriangle:"⊲",LeftTriangleEqual:"⊴",LeftUpDownVector:"⥑",LeftUpTeeVector:"⥠",LeftUpVectorBar:"⥘",LeftUpVector:"↿",LeftVectorBar:"⥒",LeftVector:"↼",lEg:"⪋",leg:"⋚",leq:"≤",leqq:"≦",leqslant:"⩽",lescc:"⪨",les:"⩽",lesdot:"⩿",lesdoto:"⪁",lesdotor:"⪃",lesg:"⋚︀",lesges:"⪓",lessapprox:"⪅",lessdot:"⋖",lesseqgtr:"⋚",lesseqqgtr:"⪋",LessEqualGreater:"⋚",LessFullEqual:"≦",LessGreater:"≶",lessgtr:"≶",LessLess:"⪡",lesssim:"≲",LessSlantEqual:"⩽",LessTilde:"≲",lfisht:"⥼",lfloor:"⌊",Lfr:"𝔏",lfr:"𝔩",lg:"≶",lgE:"⪑",lHar:"⥢",lhard:"↽",lharu:"↼",lharul:"⥪",lhblk:"▄",LJcy:"Љ",ljcy:"љ",llarr:"⇇",ll:"≪",Ll:"⋘",llcorner:"⌞",Lleftarrow:"⇚",llhard:"⥫",lltri:"◺",Lmidot:"Ŀ",lmidot:"ŀ",lmoustache:"⎰",lmoust:"⎰",lnap:"⪉",lnapprox:"⪉",lne:"⪇",lnE:"≨",lneq:"⪇",lneqq:"≨",lnsim:"⋦",loang:"⟬",loarr:"⇽",lobrk:"⟦",longleftarrow:"⟵",LongLeftArrow:"⟵",Longleftarrow:"⟸",longleftrightarrow:"⟷",LongLeftRightArrow:"⟷",Longleftrightarrow:"⟺",longmapsto:"⟼",longrightarrow:"⟶",LongRightArrow:"⟶",Longrightarrow:"⟹",looparrowleft:"↫",looparrowright:"↬",lopar:"⦅",Lopf:"𝕃",lopf:"𝕝",loplus:"⨭",lotimes:"⨴",lowast:"∗",lowbar:"_",LowerLeftArrow:"↙",LowerRightArrow:"↘",loz:"◊",lozenge:"◊",lozf:"⧫",lpar:"(",lparlt:"⦓",lrarr:"⇆",lrcorner:"⌟",lrhar:"⇋",lrhard:"⥭",lrm:"‎",lrtri:"⊿",lsaquo:"‹",lscr:"𝓁",Lscr:"ℒ",lsh:"↰",Lsh:"↰",lsim:"≲",lsime:"⪍",lsimg:"⪏",lsqb:"[",lsquo:"‘",lsquor:"‚",Lstrok:"Ł",lstrok:"ł",ltcc:"⪦",ltcir:"⩹",lt:"<",LT:"<",Lt:"≪",ltdot:"⋖",lthree:"⋋",ltimes:"⋉",ltlarr:"⥶",ltquest:"⩻",ltri:"◃",ltrie:"⊴",ltrif:"◂",ltrPar:"⦖",lurdshar:"⥊",luruhar:"⥦",lvertneqq:"≨︀",lvnE:"≨︀",macr:"¯",male:"♂",malt:"✠",maltese:"✠",map:"↦",mapsto:"↦",mapstodown:"↧",mapstoleft:"↤",mapstoup:"↥",marker:"▮",mcomma:"⨩",Mcy:"М",mcy:"м",mdash:"—",mDDot:"∺",measuredangle:"∡",MediumSpace:" ",Mellintrf:"ℳ",Mfr:"𝔐",mfr:"𝔪",mho:"℧",micro:"µ",midast:"*",midcir:"⫰",mid:"∣",middot:"·",minusb:"⊟",minus:"−",minusd:"∸",minusdu:"⨪",MinusPlus:"∓",mlcp:"⫛",mldr:"…",mnplus:"∓",models:"⊧",Mopf:"𝕄",mopf:"𝕞",mp:"∓",mscr:"𝓂",Mscr:"ℳ",mstpos:"∾",Mu:"Μ",mu:"μ",multimap:"⊸",mumap:"⊸",nabla:"∇",Nacute:"Ń",nacute:"ń",nang:"∠⃒",nap:"≉",napE:"⩰̸",napid:"≋̸",napos:"ʼn",napprox:"≉",natural:"♮",naturals:"ℕ",natur:"♮",nbsp:" ",nbump:"≎̸",nbumpe:"≏̸",ncap:"⩃",Ncaron:"Ň",ncaron:"ň",Ncedil:"Ņ",ncedil:"ņ",ncong:"≇",ncongdot:"⩭̸",ncup:"⩂",Ncy:"Н",ncy:"н",ndash:"–",nearhk:"⤤",nearr:"↗",neArr:"⇗",nearrow:"↗",ne:"≠",nedot:"≐̸",NegativeMediumSpace:"​",NegativeThickSpace:"​",NegativeThinSpace:"​",NegativeVeryThinSpace:"​",nequiv:"≢",nesear:"⤨",nesim:"≂̸",NestedGreaterGreater:"≫",NestedLessLess:"≪",NewLine:"\n",nexist:"∄",nexists:"∄",Nfr:"𝔑",nfr:"𝔫",ngE:"≧̸",nge:"≱",ngeq:"≱",ngeqq:"≧̸",ngeqslant:"⩾̸",nges:"⩾̸",nGg:"⋙̸",ngsim:"≵",nGt:"≫⃒",ngt:"≯",ngtr:"≯",nGtv:"≫̸",nharr:"↮",nhArr:"⇎",nhpar:"⫲",ni:"∋",nis:"⋼",nisd:"⋺",niv:"∋",NJcy:"Њ",njcy:"њ",nlarr:"↚",nlArr:"⇍",nldr:"‥",nlE:"≦̸",nle:"≰",nleftarrow:"↚",nLeftarrow:"⇍",nleftrightarrow:"↮",nLeftrightarrow:"⇎",nleq:"≰",nleqq:"≦̸",nleqslant:"⩽̸",nles:"⩽̸",nless:"≮",nLl:"⋘̸",nlsim:"≴",nLt:"≪⃒",nlt:"≮",nltri:"⋪",nltrie:"⋬",nLtv:"≪̸",nmid:"∤",NoBreak:"⁠",NonBreakingSpace:" ",nopf:"𝕟",Nopf:"ℕ",Not:"⫬",not:"¬",NotCongruent:"≢",NotCupCap:"≭",NotDoubleVerticalBar:"∦",NotElement:"∉",NotEqual:"≠",NotEqualTilde:"≂̸",NotExists:"∄",NotGreater:"≯",NotGreaterEqual:"≱",NotGreaterFullEqual:"≧̸",NotGreaterGreater:"≫̸",NotGreaterLess:"≹",NotGreaterSlantEqual:"⩾̸",NotGreaterTilde:"≵",NotHumpDownHump:"≎̸",NotHumpEqual:"≏̸",notin:"∉",notindot:"⋵̸",notinE:"⋹̸",notinva:"∉",notinvb:"⋷",notinvc:"⋶",NotLeftTriangleBar:"⧏̸",NotLeftTriangle:"⋪",NotLeftTriangleEqual:"⋬",NotLess:"≮",NotLessEqual:"≰",NotLessGreater:"≸",NotLessLess:"≪̸",NotLessSlantEqual:"⩽̸",NotLessTilde:"≴",NotNestedGreaterGreater:"⪢̸",NotNestedLessLess:"⪡̸",notni:"∌",notniva:"∌",notnivb:"⋾",notnivc:"⋽",NotPrecedes:"⊀",NotPrecedesEqual:"⪯̸",NotPrecedesSlantEqual:"⋠",NotReverseElement:"∌",NotRightTriangleBar:"⧐̸",NotRightTriangle:"⋫",NotRightTriangleEqual:"⋭",NotSquareSubset:"⊏̸",NotSquareSubsetEqual:"⋢",NotSquareSuperset:"⊐̸",NotSquareSupersetEqual:"⋣",NotSubset:"⊂⃒",NotSubsetEqual:"⊈",NotSucceeds:"⊁",NotSucceedsEqual:"⪰̸",NotSucceedsSlantEqual:"⋡",NotSucceedsTilde:"≿̸",NotSuperset:"⊃⃒",NotSupersetEqual:"⊉",NotTilde:"≁",NotTildeEqual:"≄",NotTildeFullEqual:"≇",NotTildeTilde:"≉",NotVerticalBar:"∤",nparallel:"∦",npar:"∦",nparsl:"⫽⃥",npart:"∂̸",npolint:"⨔",npr:"⊀",nprcue:"⋠",nprec:"⊀",npreceq:"⪯̸",npre:"⪯̸",nrarrc:"⤳̸",nrarr:"↛",nrArr:"⇏",nrarrw:"↝̸",nrightarrow:"↛",nRightarrow:"⇏",nrtri:"⋫",nrtrie:"⋭",nsc:"⊁",nsccue:"⋡",nsce:"⪰̸",Nscr:"𝒩",nscr:"𝓃",nshortmid:"∤",nshortparallel:"∦",nsim:"≁",nsime:"≄",nsimeq:"≄",nsmid:"∤",nspar:"∦",nsqsube:"⋢",nsqsupe:"⋣",nsub:"⊄",nsubE:"⫅̸",nsube:"⊈",nsubset:"⊂⃒",nsubseteq:"⊈",nsubseteqq:"⫅̸",nsucc:"⊁",nsucceq:"⪰̸",nsup:"⊅",nsupE:"⫆̸",nsupe:"⊉",nsupset:"⊃⃒",nsupseteq:"⊉",nsupseteqq:"⫆̸",ntgl:"≹",Ntilde:"Ñ",ntilde:"ñ",ntlg:"≸",ntriangleleft:"⋪",ntrianglelefteq:"⋬",ntriangleright:"⋫",ntrianglerighteq:"⋭",Nu:"Ν",nu:"ν",num:"#",numero:"№",numsp:" ",nvap:"≍⃒",nvdash:"⊬",nvDash:"⊭",nVdash:"⊮",nVDash:"⊯",nvge:"≥⃒",nvgt:">⃒",nvHarr:"⤄",nvinfin:"⧞",nvlArr:"⤂",nvle:"≤⃒",nvlt:"<⃒",nvltrie:"⊴⃒",nvrArr:"⤃",nvrtrie:"⊵⃒",nvsim:"∼⃒",nwarhk:"⤣",nwarr:"↖",nwArr:"⇖",nwarrow:"↖",nwnear:"⤧",Oacute:"Ó",oacute:"ó",oast:"⊛",Ocirc:"Ô",ocirc:"ô",ocir:"⊚",Ocy:"О",ocy:"о",odash:"⊝",Odblac:"Ő",odblac:"ő",odiv:"⨸",odot:"⊙",odsold:"⦼",OElig:"Œ",oelig:"œ",ofcir:"⦿",Ofr:"𝔒",ofr:"𝔬",ogon:"˛",Ograve:"Ò",ograve:"ò",ogt:"⧁",ohbar:"⦵",ohm:"Ω",oint:"∮",olarr:"↺",olcir:"⦾",olcross:"⦻",oline:"‾",olt:"⧀",Omacr:"Ō",omacr:"ō",Omega:"Ω",omega:"ω",Omicron:"Ο",omicron:"ο",omid:"⦶",ominus:"⊖",Oopf:"𝕆",oopf:"𝕠",opar:"⦷",OpenCurlyDoubleQuote:"“",OpenCurlyQuote:"‘",operp:"⦹",oplus:"⊕",orarr:"↻",Or:"⩔",or:"∨",ord:"⩝",order:"ℴ",orderof:"ℴ",ordf:"ª",ordm:"º",origof:"⊶",oror:"⩖",orslope:"⩗",orv:"⩛",oS:"Ⓢ",Oscr:"𝒪",oscr:"ℴ",Oslash:"Ø",oslash:"ø",osol:"⊘",Otilde:"Õ",otilde:"õ",otimesas:"⨶",Otimes:"⨷",otimes:"⊗",Ouml:"Ö",ouml:"ö",ovbar:"⌽",OverBar:"‾",OverBrace:"⏞",OverBracket:"⎴",OverParenthesis:"⏜",para:"¶",parallel:"∥",par:"∥",parsim:"⫳",parsl:"⫽",part:"∂",PartialD:"∂",Pcy:"П",pcy:"п",percnt:"%",period:".",permil:"‰",perp:"⊥",pertenk:"‱",Pfr:"𝔓",pfr:"𝔭",Phi:"Φ",phi:"φ",phiv:"ϕ",phmmat:"ℳ",phone:"☎",Pi:"Π",pi:"π",pitchfork:"⋔",piv:"ϖ",planck:"ℏ",planckh:"ℎ",plankv:"ℏ",plusacir:"⨣",plusb:"⊞",pluscir:"⨢",plus:"+",plusdo:"∔",plusdu:"⨥",pluse:"⩲",PlusMinus:"±",plusmn:"±",plussim:"⨦",plustwo:"⨧",pm:"±",Poincareplane:"ℌ",pointint:"⨕",popf:"𝕡",Popf:"ℙ",pound:"£",prap:"⪷",Pr:"⪻",pr:"≺",prcue:"≼",precapprox:"⪷",prec:"≺",preccurlyeq:"≼",Precedes:"≺",PrecedesEqual:"⪯",PrecedesSlantEqual:"≼",PrecedesTilde:"≾",preceq:"⪯",precnapprox:"⪹",precneqq:"⪵",precnsim:"⋨",pre:"⪯",prE:"⪳",precsim:"≾",prime:"′",Prime:"″",primes:"ℙ",prnap:"⪹",prnE:"⪵",prnsim:"⋨",prod:"∏",Product:"∏",profalar:"⌮",profline:"⌒",profsurf:"⌓",prop:"∝",Proportional:"∝",Proportion:"∷",propto:"∝",prsim:"≾",prurel:"⊰",Pscr:"𝒫",pscr:"𝓅",Psi:"Ψ",psi:"ψ",puncsp:" ",Qfr:"𝔔",qfr:"𝔮",qint:"⨌",qopf:"𝕢",Qopf:"ℚ",qprime:"⁗",Qscr:"𝒬",qscr:"𝓆",quaternions:"ℍ",quatint:"⨖",quest:"?",questeq:"≟",quot:'"',QUOT:'"',rAarr:"⇛",race:"∽̱",Racute:"Ŕ",racute:"ŕ",radic:"√",raemptyv:"⦳",rang:"⟩",Rang:"⟫",rangd:"⦒",range:"⦥",rangle:"⟩",raquo:"»",rarrap:"⥵",rarrb:"⇥",rarrbfs:"⤠",rarrc:"⤳",rarr:"→",Rarr:"↠",rArr:"⇒",rarrfs:"⤞",rarrhk:"↪",rarrlp:"↬",rarrpl:"⥅",rarrsim:"⥴",Rarrtl:"⤖",rarrtl:"↣",rarrw:"↝",ratail:"⤚",rAtail:"⤜",ratio:"∶",rationals:"ℚ",rbarr:"⤍",rBarr:"⤏",RBarr:"⤐",rbbrk:"❳",rbrace:"}",rbrack:"]",rbrke:"⦌",rbrksld:"⦎",rbrkslu:"⦐",Rcaron:"Ř",rcaron:"ř",Rcedil:"Ŗ",rcedil:"ŗ",rceil:"⌉",rcub:"}",Rcy:"Р",rcy:"р",rdca:"⤷",rdldhar:"⥩",rdquo:"”",rdquor:"”",rdsh:"↳",real:"ℜ",realine:"ℛ",realpart:"ℜ",reals:"ℝ",Re:"ℜ",rect:"▭",reg:"®",REG:"®",ReverseElement:"∋",ReverseEquilibrium:"⇋",ReverseUpEquilibrium:"⥯",rfisht:"⥽",rfloor:"⌋",rfr:"𝔯",Rfr:"ℜ",rHar:"⥤",rhard:"⇁",rharu:"⇀",rharul:"⥬",Rho:"Ρ",rho:"ρ",rhov:"ϱ",RightAngleBracket:"⟩",RightArrowBar:"⇥",rightarrow:"→",RightArrow:"→",Rightarrow:"⇒",RightArrowLeftArrow:"⇄",rightarrowtail:"↣",RightCeiling:"⌉",RightDoubleBracket:"⟧",RightDownTeeVector:"⥝",RightDownVectorBar:"⥕",RightDownVector:"⇂",RightFloor:"⌋",rightharpoondown:"⇁",rightharpoonup:"⇀",rightleftarrows:"⇄",rightleftharpoons:"⇌",rightrightarrows:"⇉",rightsquigarrow:"↝",RightTeeArrow:"↦",RightTee:"⊢",RightTeeVector:"⥛",rightthreetimes:"⋌",RightTriangleBar:"⧐",RightTriangle:"⊳",RightTriangleEqual:"⊵",RightUpDownVector:"⥏",RightUpTeeVector:"⥜",RightUpVectorBar:"⥔",RightUpVector:"↾",RightVectorBar:"⥓",RightVector:"⇀",ring:"˚",risingdotseq:"≓",rlarr:"⇄",rlhar:"⇌",rlm:"‏",rmoustache:"⎱",rmoust:"⎱",rnmid:"⫮",roang:"⟭",roarr:"⇾",robrk:"⟧",ropar:"⦆",ropf:"𝕣",Ropf:"ℝ",roplus:"⨮",rotimes:"⨵",RoundImplies:"⥰",rpar:")",rpargt:"⦔",rppolint:"⨒",rrarr:"⇉",Rrightarrow:"⇛",rsaquo:"›",rscr:"𝓇",Rscr:"ℛ",rsh:"↱",Rsh:"↱",rsqb:"]",rsquo:"’",rsquor:"’",rthree:"⋌",rtimes:"⋊",rtri:"▹",rtrie:"⊵",rtrif:"▸",rtriltri:"⧎",RuleDelayed:"⧴",ruluhar:"⥨",rx:"℞",Sacute:"Ś",sacute:"ś",sbquo:"‚",scap:"⪸",Scaron:"Š",scaron:"š",Sc:"⪼",sc:"≻",sccue:"≽",sce:"⪰",scE:"⪴",Scedil:"Ş",scedil:"ş",Scirc:"Ŝ",scirc:"ŝ",scnap:"⪺",scnE:"⪶",scnsim:"⋩",scpolint:"⨓",scsim:"≿",Scy:"С",scy:"с",sdotb:"⊡",sdot:"⋅",sdote:"⩦",searhk:"⤥",searr:"↘",seArr:"⇘",searrow:"↘",sect:"§",semi:";",seswar:"⤩",setminus:"∖",setmn:"∖",sext:"✶",Sfr:"𝔖",sfr:"𝔰",sfrown:"⌢",sharp:"♯",SHCHcy:"Щ",shchcy:"щ",SHcy:"Ш",shcy:"ш",ShortDownArrow:"↓",ShortLeftArrow:"←",shortmid:"∣",shortparallel:"∥",ShortRightArrow:"→",ShortUpArrow:"↑",shy:"­",Sigma:"Σ",sigma:"σ",sigmaf:"ς",sigmav:"ς",sim:"∼",simdot:"⩪",sime:"≃",simeq:"≃",simg:"⪞",simgE:"⪠",siml:"⪝",simlE:"⪟",simne:"≆",simplus:"⨤",simrarr:"⥲",slarr:"←",SmallCircle:"∘",smallsetminus:"∖",smashp:"⨳",smeparsl:"⧤",smid:"∣",smile:"⌣",smt:"⪪",smte:"⪬",smtes:"⪬︀",SOFTcy:"Ь",softcy:"ь",solbar:"⌿",solb:"⧄",sol:"/",Sopf:"𝕊",sopf:"𝕤",spades:"♠",spadesuit:"♠",spar:"∥",sqcap:"⊓",sqcaps:"⊓︀",sqcup:"⊔",sqcups:"⊔︀",Sqrt:"√",sqsub:"⊏",sqsube:"⊑",sqsubset:"⊏",sqsubseteq:"⊑",sqsup:"⊐",sqsupe:"⊒",sqsupset:"⊐",sqsupseteq:"⊒",square:"□",Square:"□",SquareIntersection:"⊓",SquareSubset:"⊏",SquareSubsetEqual:"⊑",SquareSuperset:"⊐",SquareSupersetEqual:"⊒",SquareUnion:"⊔",squarf:"▪",squ:"□",squf:"▪",srarr:"→",Sscr:"𝒮",sscr:"𝓈",ssetmn:"∖",ssmile:"⌣",sstarf:"⋆",Star:"⋆",star:"☆",starf:"★",straightepsilon:"ϵ",straightphi:"ϕ",strns:"¯",sub:"⊂",Sub:"⋐",subdot:"⪽",subE:"⫅",sube:"⊆",subedot:"⫃",submult:"⫁",subnE:"⫋",subne:"⊊",subplus:"⪿",subrarr:"⥹",subset:"⊂",Subset:"⋐",subseteq:"⊆",subseteqq:"⫅",SubsetEqual:"⊆",subsetneq:"⊊",subsetneqq:"⫋",subsim:"⫇",subsub:"⫕",subsup:"⫓",succapprox:"⪸",succ:"≻",succcurlyeq:"≽",Succeeds:"≻",SucceedsEqual:"⪰",SucceedsSlantEqual:"≽",SucceedsTilde:"≿",succeq:"⪰",succnapprox:"⪺",succneqq:"⪶",succnsim:"⋩",succsim:"≿",SuchThat:"∋",sum:"∑",Sum:"∑",sung:"♪",sup1:"¹",sup2:"²",sup3:"³",sup:"⊃",Sup:"⋑",supdot:"⪾",supdsub:"⫘",supE:"⫆",supe:"⊇",supedot:"⫄",Superset:"⊃",SupersetEqual:"⊇",suphsol:"⟉",suphsub:"⫗",suplarr:"⥻",supmult:"⫂",supnE:"⫌",supne:"⊋",supplus:"⫀",supset:"⊃",Supset:"⋑",supseteq:"⊇",supseteqq:"⫆",supsetneq:"⊋",supsetneqq:"⫌",supsim:"⫈",supsub:"⫔",supsup:"⫖",swarhk:"⤦",swarr:"↙",swArr:"⇙",swarrow:"↙",swnwar:"⤪",szlig:"ß",Tab:"\t",target:"⌖",Tau:"Τ",tau:"τ",tbrk:"⎴",Tcaron:"Ť",tcaron:"ť",Tcedil:"Ţ",tcedil:"ţ",Tcy:"Т",tcy:"т",tdot:"⃛",telrec:"⌕",Tfr:"𝔗",tfr:"𝔱",there4:"∴",therefore:"∴",Therefore:"∴",Theta:"Θ",theta:"θ",thetasym:"ϑ",thetav:"ϑ",thickapprox:"≈",thicksim:"∼",ThickSpace:"  ",ThinSpace:" ",thinsp:" ",thkap:"≈",thksim:"∼",THORN:"Þ",thorn:"þ",tilde:"˜",Tilde:"∼",TildeEqual:"≃",TildeFullEqual:"≅",TildeTilde:"≈",timesbar:"⨱",timesb:"⊠",times:"×",timesd:"⨰",tint:"∭",toea:"⤨",topbot:"⌶",topcir:"⫱",top:"⊤",Topf:"𝕋",topf:"𝕥",topfork:"⫚",tosa:"⤩",tprime:"‴",trade:"™",TRADE:"™",triangle:"▵",triangledown:"▿",triangleleft:"◃",trianglelefteq:"⊴",triangleq:"≜",triangleright:"▹",trianglerighteq:"⊵",tridot:"◬",trie:"≜",triminus:"⨺",TripleDot:"⃛",triplus:"⨹",trisb:"⧍",tritime:"⨻",trpezium:"⏢",Tscr:"𝒯",tscr:"𝓉",TScy:"Ц",tscy:"ц",TSHcy:"Ћ",tshcy:"ћ",Tstrok:"Ŧ",tstrok:"ŧ",twixt:"≬",twoheadleftarrow:"↞",twoheadrightarrow:"↠",Uacute:"Ú",uacute:"ú",uarr:"↑",Uarr:"↟",uArr:"⇑",Uarrocir:"⥉",Ubrcy:"Ў",ubrcy:"ў",Ubreve:"Ŭ",ubreve:"ŭ",Ucirc:"Û",ucirc:"û",Ucy:"У",ucy:"у",udarr:"⇅",Udblac:"Ű",udblac:"ű",udhar:"⥮",ufisht:"⥾",Ufr:"𝔘",ufr:"𝔲",Ugrave:"Ù",ugrave:"ù",uHar:"⥣",uharl:"↿",uharr:"↾",uhblk:"▀",ulcorn:"⌜",ulcorner:"⌜",ulcrop:"⌏",ultri:"◸",Umacr:"Ū",umacr:"ū",uml:"¨",UnderBar:"_",UnderBrace:"⏟",UnderBracket:"⎵",UnderParenthesis:"⏝",Union:"⋃",UnionPlus:"⊎",Uogon:"Ų",uogon:"ų",Uopf:"𝕌",uopf:"𝕦",UpArrowBar:"⤒",uparrow:"↑",UpArrow:"↑",Uparrow:"⇑",UpArrowDownArrow:"⇅",updownarrow:"↕",UpDownArrow:"↕",Updownarrow:"⇕",UpEquilibrium:"⥮",upharpoonleft:"↿",upharpoonright:"↾",uplus:"⊎",UpperLeftArrow:"↖",UpperRightArrow:"↗",upsi:"υ",Upsi:"ϒ",upsih:"ϒ",Upsilon:"Υ",upsilon:"υ",UpTeeArrow:"↥",UpTee:"⊥",upuparrows:"⇈",urcorn:"⌝",urcorner:"⌝",urcrop:"⌎",Uring:"Ů",uring:"ů",urtri:"◹",Uscr:"𝒰",uscr:"𝓊",utdot:"⋰",Utilde:"Ũ",utilde:"ũ",utri:"▵",utrif:"▴",uuarr:"⇈",Uuml:"Ü",uuml:"ü",uwangle:"⦧",vangrt:"⦜",varepsilon:"ϵ",varkappa:"ϰ",varnothing:"∅",varphi:"ϕ",varpi:"ϖ",varpropto:"∝",varr:"↕",vArr:"⇕",varrho:"ϱ",varsigma:"ς",varsubsetneq:"⊊︀",varsubsetneqq:"⫋︀",varsupsetneq:"⊋︀",varsupsetneqq:"⫌︀",vartheta:"ϑ",vartriangleleft:"⊲",vartriangleright:"⊳",vBar:"⫨",Vbar:"⫫",vBarv:"⫩",Vcy:"В",vcy:"в",vdash:"⊢",vDash:"⊨",Vdash:"⊩",VDash:"⊫",Vdashl:"⫦",veebar:"⊻",vee:"∨",Vee:"⋁",veeeq:"≚",vellip:"⋮",verbar:"|",Verbar:"‖",vert:"|",Vert:"‖",VerticalBar:"∣",VerticalLine:"|",VerticalSeparator:"❘",VerticalTilde:"≀",VeryThinSpace:" ",Vfr:"𝔙",vfr:"𝔳",vltri:"⊲",vnsub:"⊂⃒",vnsup:"⊃⃒",Vopf:"𝕍",vopf:"𝕧",vprop:"∝",vrtri:"⊳",Vscr:"𝒱",vscr:"𝓋",vsubnE:"⫋︀",vsubne:"⊊︀",vsupnE:"⫌︀",vsupne:"⊋︀",Vvdash:"⊪",vzigzag:"⦚",Wcirc:"Ŵ",wcirc:"ŵ",wedbar:"⩟",wedge:"∧",Wedge:"⋀",wedgeq:"≙",weierp:"℘",Wfr:"𝔚",wfr:"𝔴",Wopf:"𝕎",wopf:"𝕨",wp:"℘",wr:"≀",wreath:"≀",Wscr:"𝒲",wscr:"𝓌",xcap:"⋂",xcirc:"◯",xcup:"⋃",xdtri:"▽",Xfr:"𝔛",xfr:"𝔵",xharr:"⟷",xhArr:"⟺",Xi:"Ξ",xi:"ξ",xlarr:"⟵",xlArr:"⟸",xmap:"⟼",xnis:"⋻",xodot:"⨀",Xopf:"𝕏",xopf:"𝕩",xoplus:"⨁",xotime:"⨂",xrarr:"⟶",xrArr:"⟹",Xscr:"𝒳",xscr:"𝓍",xsqcup:"⨆",xuplus:"⨄",xutri:"△",xvee:"⋁",xwedge:"⋀",Yacute:"Ý",yacute:"ý",YAcy:"Я",yacy:"я",Ycirc:"Ŷ",ycirc:"ŷ",Ycy:"Ы",ycy:"ы",yen:"¥",Yfr:"𝔜",yfr:"𝔶",YIcy:"Ї",yicy:"ї",Yopf:"𝕐",yopf:"𝕪",Yscr:"𝒴",yscr:"𝓎",YUcy:"Ю",yucy:"ю",yuml:"ÿ",Yuml:"Ÿ",Zacute:"Ź",zacute:"ź",Zcaron:"Ž",zcaron:"ž",Zcy:"З",zcy:"з",Zdot:"Ż",zdot:"ż",zeetrf:"ℨ",ZeroWidthSpace:"​",Zeta:"Ζ",zeta:"ζ",zfr:"𝔷",Zfr:"ℨ",ZHcy:"Ж",zhcy:"ж",zigrarr:"⇝",zopf:"𝕫",Zopf:"ℤ",Zscr:"𝒵",zscr:"𝓏",zwj:"‍",zwnj:"‌",default:{Aacute:"Á",aacute:"á",Abreve:"Ă",abreve:"ă",ac:"∾",acd:"∿",acE:"∾̳",Acirc:"Â",acirc:"â",acute:"´",Acy:"А",acy:"а",AElig:"Æ",aelig:"æ",af:"⁡",Afr:"𝔄",afr:"𝔞",Agrave:"À",agrave:"à",alefsym:"ℵ",aleph:"ℵ",Alpha:"Α",alpha:"α",Amacr:"Ā",amacr:"ā",amalg:"⨿",amp:"&",AMP:"&",andand:"⩕",And:"⩓",and:"∧",andd:"⩜",andslope:"⩘",andv:"⩚",ang:"∠",ange:"⦤",angle:"∠",angmsdaa:"⦨",angmsdab:"⦩",angmsdac:"⦪",angmsdad:"⦫",angmsdae:"⦬",angmsdaf:"⦭",angmsdag:"⦮",angmsdah:"⦯",angmsd:"∡",angrt:"∟",angrtvb:"⊾",angrtvbd:"⦝",angsph:"∢",angst:"Å",angzarr:"⍼",Aogon:"Ą",aogon:"ą",Aopf:"𝔸",aopf:"𝕒",apacir:"⩯",ap:"≈",apE:"⩰",ape:"≊",apid:"≋",apos:"'",ApplyFunction:"⁡",approx:"≈",approxeq:"≊",Aring:"Å",aring:"å",Ascr:"𝒜",ascr:"𝒶",Assign:"≔",ast:"*",asymp:"≈",asympeq:"≍",Atilde:"Ã",atilde:"ã",Auml:"Ä",auml:"ä",awconint:"∳",awint:"⨑",backcong:"≌",backepsilon:"϶",backprime:"‵",backsim:"∽",backsimeq:"⋍",Backslash:"∖",Barv:"⫧",barvee:"⊽",barwed:"⌅",Barwed:"⌆",barwedge:"⌅",bbrk:"⎵",bbrktbrk:"⎶",bcong:"≌",Bcy:"Б",bcy:"б",bdquo:"„",becaus:"∵",because:"∵",Because:"∵",bemptyv:"⦰",bepsi:"϶",bernou:"ℬ",Bernoullis:"ℬ",Beta:"Β",beta:"β",beth:"ℶ",between:"≬",Bfr:"𝔅",bfr:"𝔟",bigcap:"⋂",bigcirc:"◯",bigcup:"⋃",bigodot:"⨀",bigoplus:"⨁",bigotimes:"⨂",bigsqcup:"⨆",bigstar:"★",bigtriangledown:"▽",bigtriangleup:"△",biguplus:"⨄",bigvee:"⋁",bigwedge:"⋀",bkarow:"⤍",blacklozenge:"⧫",blacksquare:"▪",blacktriangle:"▴",blacktriangledown:"▾",blacktriangleleft:"◂",blacktriangleright:"▸",blank:"␣",blk12:"▒",blk14:"░",blk34:"▓",block:"█",bne:"=⃥",bnequiv:"≡⃥",bNot:"⫭",bnot:"⌐",Bopf:"𝔹",bopf:"𝕓",bot:"⊥",bottom:"⊥",bowtie:"⋈",boxbox:"⧉",boxdl:"┐",boxdL:"╕",boxDl:"╖",boxDL:"╗",boxdr:"┌",boxdR:"╒",boxDr:"╓",boxDR:"╔",boxh:"─",boxH:"═",boxhd:"┬",boxHd:"╤",boxhD:"╥",boxHD:"╦",boxhu:"┴",boxHu:"╧",boxhU:"╨",boxHU:"╩",boxminus:"⊟",boxplus:"⊞",boxtimes:"⊠",boxul:"┘",boxuL:"╛",boxUl:"╜",boxUL:"╝",boxur:"└",boxuR:"╘",boxUr:"╙",boxUR:"╚",boxv:"│",boxV:"║",boxvh:"┼",boxvH:"╪",boxVh:"╫",boxVH:"╬",boxvl:"┤",boxvL:"╡",boxVl:"╢",boxVL:"╣",boxvr:"├",boxvR:"╞",boxVr:"╟",boxVR:"╠",bprime:"‵",breve:"˘",Breve:"˘",brvbar:"¦",bscr:"𝒷",Bscr:"ℬ",bsemi:"⁏",bsim:"∽",bsime:"⋍",bsolb:"⧅",bsol:"\\",bsolhsub:"⟈",bull:"•",bullet:"•",bump:"≎",bumpE:"⪮",bumpe:"≏",Bumpeq:"≎",bumpeq:"≏",Cacute:"Ć",cacute:"ć",capand:"⩄",capbrcup:"⩉",capcap:"⩋",cap:"∩",Cap:"⋒",capcup:"⩇",capdot:"⩀",CapitalDifferentialD:"ⅅ",caps:"∩︀",caret:"⁁",caron:"ˇ",Cayleys:"ℭ",ccaps:"⩍",Ccaron:"Č",ccaron:"č",Ccedil:"Ç",ccedil:"ç",Ccirc:"Ĉ",ccirc:"ĉ",Cconint:"∰",ccups:"⩌",ccupssm:"⩐",Cdot:"Ċ",cdot:"ċ",cedil:"¸",Cedilla:"¸",cemptyv:"⦲",cent:"¢",centerdot:"·",CenterDot:"·",cfr:"𝔠",Cfr:"ℭ",CHcy:"Ч",chcy:"ч",check:"✓",checkmark:"✓",Chi:"Χ",chi:"χ",circ:"ˆ",circeq:"≗",circlearrowleft:"↺",circlearrowright:"↻",circledast:"⊛",circledcirc:"⊚",circleddash:"⊝",CircleDot:"⊙",circledR:"®",circledS:"Ⓢ",CircleMinus:"⊖",CirclePlus:"⊕",CircleTimes:"⊗",cir:"○",cirE:"⧃",cire:"≗",cirfnint:"⨐",cirmid:"⫯",cirscir:"⧂",ClockwiseContourIntegral:"∲",CloseCurlyDoubleQuote:"”",CloseCurlyQuote:"’",clubs:"♣",clubsuit:"♣",colon:":",Colon:"∷",Colone:"⩴",colone:"≔",coloneq:"≔",comma:",",commat:"@",comp:"∁",compfn:"∘",complement:"∁",complexes:"ℂ",cong:"≅",congdot:"⩭",Congruent:"≡",conint:"∮",Conint:"∯",ContourIntegral:"∮",copf:"𝕔",Copf:"ℂ",coprod:"∐",Coproduct:"∐",copy:"©",COPY:"©",copysr:"℗",CounterClockwiseContourIntegral:"∳",crarr:"↵",cross:"✗",Cross:"⨯",Cscr:"𝒞",cscr:"𝒸",csub:"⫏",csube:"⫑",csup:"⫐",csupe:"⫒",ctdot:"⋯",cudarrl:"⤸",cudarrr:"⤵",cuepr:"⋞",cuesc:"⋟",cularr:"↶",cularrp:"⤽",cupbrcap:"⩈",cupcap:"⩆",CupCap:"≍",cup:"∪",Cup:"⋓",cupcup:"⩊",cupdot:"⊍",cupor:"⩅",cups:"∪︀",curarr:"↷",curarrm:"⤼",curlyeqprec:"⋞",curlyeqsucc:"⋟",curlyvee:"⋎",curlywedge:"⋏",curren:"¤",curvearrowleft:"↶",curvearrowright:"↷",cuvee:"⋎",cuwed:"⋏",cwconint:"∲",cwint:"∱",cylcty:"⌭",dagger:"†",Dagger:"‡",daleth:"ℸ",darr:"↓",Darr:"↡",dArr:"⇓",dash:"‐",Dashv:"⫤",dashv:"⊣",dbkarow:"⤏",dblac:"˝",Dcaron:"Ď",dcaron:"ď",Dcy:"Д",dcy:"д",ddagger:"‡",ddarr:"⇊",DD:"ⅅ",dd:"ⅆ",DDotrahd:"⤑",ddotseq:"⩷",deg:"°",Del:"∇",Delta:"Δ",delta:"δ",demptyv:"⦱",dfisht:"⥿",Dfr:"𝔇",dfr:"𝔡",dHar:"⥥",dharl:"⇃",dharr:"⇂",DiacriticalAcute:"´",DiacriticalDot:"˙",DiacriticalDoubleAcute:"˝",DiacriticalGrave:"`",DiacriticalTilde:"˜",diam:"⋄",diamond:"⋄",Diamond:"⋄",diamondsuit:"♦",diams:"♦",die:"¨",DifferentialD:"ⅆ",digamma:"ϝ",disin:"⋲",div:"÷",divide:"÷",divideontimes:"⋇",divonx:"⋇",DJcy:"Ђ",djcy:"ђ",dlcorn:"⌞",dlcrop:"⌍",dollar:"$",Dopf:"𝔻",dopf:"𝕕",Dot:"¨",dot:"˙",DotDot:"⃜",doteq:"≐",doteqdot:"≑",DotEqual:"≐",dotminus:"∸",dotplus:"∔",dotsquare:"⊡",doublebarwedge:"⌆",DoubleContourIntegral:"∯",DoubleDot:"¨",DoubleDownArrow:"⇓",DoubleLeftArrow:"⇐",DoubleLeftRightArrow:"⇔",DoubleLeftTee:"⫤",DoubleLongLeftArrow:"⟸",DoubleLongLeftRightArrow:"⟺",DoubleLongRightArrow:"⟹",DoubleRightArrow:"⇒",DoubleRightTee:"⊨",DoubleUpArrow:"⇑",DoubleUpDownArrow:"⇕",DoubleVerticalBar:"∥",DownArrowBar:"⤓",downarrow:"↓",DownArrow:"↓",Downarrow:"⇓",DownArrowUpArrow:"⇵",DownBreve:"̑",downdownarrows:"⇊",downharpoonleft:"⇃",downharpoonright:"⇂",DownLeftRightVector:"⥐",DownLeftTeeVector:"⥞",DownLeftVectorBar:"⥖",DownLeftVector:"↽",DownRightTeeVector:"⥟",DownRightVectorBar:"⥗",DownRightVector:"⇁",DownTeeArrow:"↧",DownTee:"⊤",drbkarow:"⤐",drcorn:"⌟",drcrop:"⌌",Dscr:"𝒟",dscr:"𝒹",DScy:"Ѕ",dscy:"ѕ",dsol:"⧶",Dstrok:"Đ",dstrok:"đ",dtdot:"⋱",dtri:"▿",dtrif:"▾",duarr:"⇵",duhar:"⥯",dwangle:"⦦",DZcy:"Џ",dzcy:"џ",dzigrarr:"⟿",Eacute:"É",eacute:"é",easter:"⩮",Ecaron:"Ě",ecaron:"ě",Ecirc:"Ê",ecirc:"ê",ecir:"≖",ecolon:"≕",Ecy:"Э",ecy:"э",eDDot:"⩷",Edot:"Ė",edot:"ė",eDot:"≑",ee:"ⅇ",efDot:"≒",Efr:"𝔈",efr:"𝔢",eg:"⪚",Egrave:"È",egrave:"è",egs:"⪖",egsdot:"⪘",el:"⪙",Element:"∈",elinters:"⏧",ell:"ℓ",els:"⪕",elsdot:"⪗",Emacr:"Ē",emacr:"ē",empty:"∅",emptyset:"∅",EmptySmallSquare:"◻",emptyv:"∅",EmptyVerySmallSquare:"▫",emsp13:" ",emsp14:" ",emsp:" ",ENG:"Ŋ",eng:"ŋ",ensp:" ",Eogon:"Ę",eogon:"ę",Eopf:"𝔼",eopf:"𝕖",epar:"⋕",eparsl:"⧣",eplus:"⩱",epsi:"ε",Epsilon:"Ε",epsilon:"ε",epsiv:"ϵ",eqcirc:"≖",eqcolon:"≕",eqsim:"≂",eqslantgtr:"⪖",eqslantless:"⪕",Equal:"⩵",equals:"=",EqualTilde:"≂",equest:"≟",Equilibrium:"⇌",equiv:"≡",equivDD:"⩸",eqvparsl:"⧥",erarr:"⥱",erDot:"≓",escr:"ℯ",Escr:"ℰ",esdot:"≐",Esim:"⩳",esim:"≂",Eta:"Η",eta:"η",ETH:"Ð",eth:"ð",Euml:"Ë",euml:"ë",euro:"€",excl:"!",exist:"∃",Exists:"∃",expectation:"ℰ",exponentiale:"ⅇ",ExponentialE:"ⅇ",fallingdotseq:"≒",Fcy:"Ф",fcy:"ф",female:"♀",ffilig:"ffi",fflig:"ff",ffllig:"ffl",Ffr:"𝔉",ffr:"𝔣",filig:"fi",FilledSmallSquare:"◼",FilledVerySmallSquare:"▪",fjlig:"fj",flat:"♭",fllig:"fl",fltns:"▱",fnof:"ƒ",Fopf:"𝔽",fopf:"𝕗",forall:"∀",ForAll:"∀",fork:"⋔",forkv:"⫙",Fouriertrf:"ℱ",fpartint:"⨍",frac12:"½",frac13:"⅓",frac14:"¼",frac15:"⅕",frac16:"⅙",frac18:"⅛",frac23:"⅔",frac25:"⅖",frac34:"¾",frac35:"⅗",frac38:"⅜",frac45:"⅘",frac56:"⅚",frac58:"⅝",frac78:"⅞",frasl:"⁄",frown:"⌢",fscr:"𝒻",Fscr:"ℱ",gacute:"ǵ",Gamma:"Γ",gamma:"γ",Gammad:"Ϝ",gammad:"ϝ",gap:"⪆",Gbreve:"Ğ",gbreve:"ğ",Gcedil:"Ģ",Gcirc:"Ĝ",gcirc:"ĝ",Gcy:"Г",gcy:"г",Gdot:"Ġ",gdot:"ġ",ge:"≥",gE:"≧",gEl:"⪌",gel:"⋛",geq:"≥",geqq:"≧",geqslant:"⩾",gescc:"⪩",ges:"⩾",gesdot:"⪀",gesdoto:"⪂",gesdotol:"⪄",gesl:"⋛︀",gesles:"⪔",Gfr:"𝔊",gfr:"𝔤",gg:"≫",Gg:"⋙",ggg:"⋙",gimel:"ℷ",GJcy:"Ѓ",gjcy:"ѓ",gla:"⪥",gl:"≷",glE:"⪒",glj:"⪤",gnap:"⪊",gnapprox:"⪊",gne:"⪈",gnE:"≩",gneq:"⪈",gneqq:"≩",gnsim:"⋧",Gopf:"𝔾",gopf:"𝕘",grave:"`",GreaterEqual:"≥",GreaterEqualLess:"⋛",GreaterFullEqual:"≧",GreaterGreater:"⪢",GreaterLess:"≷",GreaterSlantEqual:"⩾",GreaterTilde:"≳",Gscr:"𝒢",gscr:"ℊ",gsim:"≳",gsime:"⪎",gsiml:"⪐",gtcc:"⪧",gtcir:"⩺",gt:">",GT:">",Gt:"≫",gtdot:"⋗",gtlPar:"⦕",gtquest:"⩼",gtrapprox:"⪆",gtrarr:"⥸",gtrdot:"⋗",gtreqless:"⋛",gtreqqless:"⪌",gtrless:"≷",gtrsim:"≳",gvertneqq:"≩︀",gvnE:"≩︀",Hacek:"ˇ",hairsp:" ",half:"½",hamilt:"ℋ",HARDcy:"Ъ",hardcy:"ъ",harrcir:"⥈",harr:"↔",hArr:"⇔",harrw:"↭",Hat:"^",hbar:"ℏ",Hcirc:"Ĥ",hcirc:"ĥ",hearts:"♥",heartsuit:"♥",hellip:"…",hercon:"⊹",hfr:"𝔥",Hfr:"ℌ",HilbertSpace:"ℋ",hksearow:"⤥",hkswarow:"⤦",hoarr:"⇿",homtht:"∻",hookleftarrow:"↩",hookrightarrow:"↪",hopf:"𝕙",Hopf:"ℍ",horbar:"―",HorizontalLine:"─",hscr:"𝒽",Hscr:"ℋ",hslash:"ℏ",Hstrok:"Ħ",hstrok:"ħ",HumpDownHump:"≎",HumpEqual:"≏",hybull:"⁃",hyphen:"‐",Iacute:"Í",iacute:"í",ic:"⁣",Icirc:"Î",icirc:"î",Icy:"И",icy:"и",Idot:"İ",IEcy:"Е",iecy:"е",iexcl:"¡",iff:"⇔",ifr:"𝔦",Ifr:"ℑ",Igrave:"Ì",igrave:"ì",ii:"ⅈ",iiiint:"⨌",iiint:"∭",iinfin:"⧜",iiota:"℩",IJlig:"IJ",ijlig:"ij",Imacr:"Ī",imacr:"ī",image:"ℑ",ImaginaryI:"ⅈ",imagline:"ℐ",imagpart:"ℑ",imath:"ı",Im:"ℑ",imof:"⊷",imped:"Ƶ",Implies:"⇒",incare:"℅",in:"∈",infin:"∞",infintie:"⧝",inodot:"ı",intcal:"⊺",int:"∫",Int:"∬",integers:"ℤ",Integral:"∫",intercal:"⊺",Intersection:"⋂",intlarhk:"⨗",intprod:"⨼",InvisibleComma:"⁣",InvisibleTimes:"⁢",IOcy:"Ё",iocy:"ё",Iogon:"Į",iogon:"į",Iopf:"𝕀",iopf:"𝕚",Iota:"Ι",iota:"ι",iprod:"⨼",iquest:"¿",iscr:"𝒾",Iscr:"ℐ",isin:"∈",isindot:"⋵",isinE:"⋹",isins:"⋴",isinsv:"⋳",isinv:"∈",it:"⁢",Itilde:"Ĩ",itilde:"ĩ",Iukcy:"І",iukcy:"і",Iuml:"Ï",iuml:"ï",Jcirc:"Ĵ",jcirc:"ĵ",Jcy:"Й",jcy:"й",Jfr:"𝔍",jfr:"𝔧",jmath:"ȷ",Jopf:"𝕁",jopf:"𝕛",Jscr:"𝒥",jscr:"𝒿",Jsercy:"Ј",jsercy:"ј",Jukcy:"Є",jukcy:"є",Kappa:"Κ",kappa:"κ",kappav:"ϰ",Kcedil:"Ķ",kcedil:"ķ",Kcy:"К",kcy:"к",Kfr:"𝔎",kfr:"𝔨",kgreen:"ĸ",KHcy:"Х",khcy:"х",KJcy:"Ќ",kjcy:"ќ",Kopf:"𝕂",kopf:"𝕜",Kscr:"𝒦",kscr:"𝓀",lAarr:"⇚",Lacute:"Ĺ",lacute:"ĺ",laemptyv:"⦴",lagran:"ℒ",Lambda:"Λ",lambda:"λ",lang:"⟨",Lang:"⟪",langd:"⦑",langle:"⟨",lap:"⪅",Laplacetrf:"ℒ",laquo:"«",larrb:"⇤",larrbfs:"⤟",larr:"←",Larr:"↞",lArr:"⇐",larrfs:"⤝",larrhk:"↩",larrlp:"↫",larrpl:"⤹",larrsim:"⥳",larrtl:"↢",latail:"⤙",lAtail:"⤛",lat:"⪫",late:"⪭",lates:"⪭︀",lbarr:"⤌",lBarr:"⤎",lbbrk:"❲",lbrace:"{",lbrack:"[",lbrke:"⦋",lbrksld:"⦏",lbrkslu:"⦍",Lcaron:"Ľ",lcaron:"ľ",Lcedil:"Ļ",lcedil:"ļ",lceil:"⌈",lcub:"{",Lcy:"Л",lcy:"л",ldca:"⤶",ldquo:"“",ldquor:"„",ldrdhar:"⥧",ldrushar:"⥋",ldsh:"↲",le:"≤",lE:"≦",LeftAngleBracket:"⟨",LeftArrowBar:"⇤",leftarrow:"←",LeftArrow:"←",Leftarrow:"⇐",LeftArrowRightArrow:"⇆",leftarrowtail:"↢",LeftCeiling:"⌈",LeftDoubleBracket:"⟦",LeftDownTeeVector:"⥡",LeftDownVectorBar:"⥙",LeftDownVector:"⇃",LeftFloor:"⌊",leftharpoondown:"↽",leftharpoonup:"↼",leftleftarrows:"⇇",leftrightarrow:"↔",LeftRightArrow:"↔",Leftrightarrow:"⇔",leftrightarrows:"⇆",leftrightharpoons:"⇋",leftrightsquigarrow:"↭",LeftRightVector:"⥎",LeftTeeArrow:"↤",LeftTee:"⊣",LeftTeeVector:"⥚",leftthreetimes:"⋋",LeftTriangleBar:"⧏",LeftTriangle:"⊲",LeftTriangleEqual:"⊴",LeftUpDownVector:"⥑",LeftUpTeeVector:"⥠",LeftUpVectorBar:"⥘",LeftUpVector:"↿",LeftVectorBar:"⥒",LeftVector:"↼",lEg:"⪋",leg:"⋚",leq:"≤",leqq:"≦",leqslant:"⩽",lescc:"⪨",les:"⩽",lesdot:"⩿",lesdoto:"⪁",lesdotor:"⪃",lesg:"⋚︀",lesges:"⪓",lessapprox:"⪅",lessdot:"⋖",lesseqgtr:"⋚",lesseqqgtr:"⪋",LessEqualGreater:"⋚",LessFullEqual:"≦",LessGreater:"≶",lessgtr:"≶",LessLess:"⪡",lesssim:"≲",LessSlantEqual:"⩽",LessTilde:"≲",lfisht:"⥼",lfloor:"⌊",Lfr:"𝔏",lfr:"𝔩",lg:"≶",lgE:"⪑",lHar:"⥢",lhard:"↽",lharu:"↼",lharul:"⥪",lhblk:"▄",LJcy:"Љ",ljcy:"љ",llarr:"⇇",ll:"≪",Ll:"⋘",llcorner:"⌞",Lleftarrow:"⇚",llhard:"⥫",lltri:"◺",Lmidot:"Ŀ",lmidot:"ŀ",lmoustache:"⎰",lmoust:"⎰",lnap:"⪉",lnapprox:"⪉",lne:"⪇",lnE:"≨",lneq:"⪇",lneqq:"≨",lnsim:"⋦",loang:"⟬",loarr:"⇽",lobrk:"⟦",longleftarrow:"⟵",LongLeftArrow:"⟵",Longleftarrow:"⟸",longleftrightarrow:"⟷",LongLeftRightArrow:"⟷",Longleftrightarrow:"⟺",longmapsto:"⟼",longrightarrow:"⟶",LongRightArrow:"⟶",Longrightarrow:"⟹",looparrowleft:"↫",looparrowright:"↬",lopar:"⦅",Lopf:"𝕃",lopf:"𝕝",loplus:"⨭",lotimes:"⨴",lowast:"∗",lowbar:"_",LowerLeftArrow:"↙",LowerRightArrow:"↘",loz:"◊",lozenge:"◊",lozf:"⧫",lpar:"(",lparlt:"⦓",lrarr:"⇆",lrcorner:"⌟",lrhar:"⇋",lrhard:"⥭",lrm:"‎",lrtri:"⊿",lsaquo:"‹",lscr:"𝓁",Lscr:"ℒ",lsh:"↰",Lsh:"↰",lsim:"≲",lsime:"⪍",lsimg:"⪏",lsqb:"[",lsquo:"‘",lsquor:"‚",Lstrok:"Ł",lstrok:"ł",ltcc:"⪦",ltcir:"⩹",lt:"<",LT:"<",Lt:"≪",ltdot:"⋖",lthree:"⋋",ltimes:"⋉",ltlarr:"⥶",ltquest:"⩻",ltri:"◃",ltrie:"⊴",ltrif:"◂",ltrPar:"⦖",lurdshar:"⥊",luruhar:"⥦",lvertneqq:"≨︀",lvnE:"≨︀",macr:"¯",male:"♂",malt:"✠",maltese:"✠",Map:"⤅",map:"↦",mapsto:"↦",mapstodown:"↧",mapstoleft:"↤",mapstoup:"↥",marker:"▮",mcomma:"⨩",Mcy:"М",mcy:"м",mdash:"—",mDDot:"∺",measuredangle:"∡",MediumSpace:" ",Mellintrf:"ℳ",Mfr:"𝔐",mfr:"𝔪",mho:"℧",micro:"µ",midast:"*",midcir:"⫰",mid:"∣",middot:"·",minusb:"⊟",minus:"−",minusd:"∸",minusdu:"⨪",MinusPlus:"∓",mlcp:"⫛",mldr:"…",mnplus:"∓",models:"⊧",Mopf:"𝕄",mopf:"𝕞",mp:"∓",mscr:"𝓂",Mscr:"ℳ",mstpos:"∾",Mu:"Μ",mu:"μ",multimap:"⊸",mumap:"⊸",nabla:"∇",Nacute:"Ń",nacute:"ń",nang:"∠⃒",nap:"≉",napE:"⩰̸",napid:"≋̸",napos:"ʼn",napprox:"≉",natural:"♮",naturals:"ℕ",natur:"♮",nbsp:" ",nbump:"≎̸",nbumpe:"≏̸",ncap:"⩃",Ncaron:"Ň",ncaron:"ň",Ncedil:"Ņ",ncedil:"ņ",ncong:"≇",ncongdot:"⩭̸",ncup:"⩂",Ncy:"Н",ncy:"н",ndash:"–",nearhk:"⤤",nearr:"↗",neArr:"⇗",nearrow:"↗",ne:"≠",nedot:"≐̸",NegativeMediumSpace:"​",NegativeThickSpace:"​",NegativeThinSpace:"​",NegativeVeryThinSpace:"​",nequiv:"≢",nesear:"⤨",nesim:"≂̸",NestedGreaterGreater:"≫",NestedLessLess:"≪",NewLine:"\n",nexist:"∄",nexists:"∄",Nfr:"𝔑",nfr:"𝔫",ngE:"≧̸",nge:"≱",ngeq:"≱",ngeqq:"≧̸",ngeqslant:"⩾̸",nges:"⩾̸",nGg:"⋙̸",ngsim:"≵",nGt:"≫⃒",ngt:"≯",ngtr:"≯",nGtv:"≫̸",nharr:"↮",nhArr:"⇎",nhpar:"⫲",ni:"∋",nis:"⋼",nisd:"⋺",niv:"∋",NJcy:"Њ",njcy:"њ",nlarr:"↚",nlArr:"⇍",nldr:"‥",nlE:"≦̸",nle:"≰",nleftarrow:"↚",nLeftarrow:"⇍",nleftrightarrow:"↮",nLeftrightarrow:"⇎",nleq:"≰",nleqq:"≦̸",nleqslant:"⩽̸",nles:"⩽̸",nless:"≮",nLl:"⋘̸",nlsim:"≴",nLt:"≪⃒",nlt:"≮",nltri:"⋪",nltrie:"⋬",nLtv:"≪̸",nmid:"∤",NoBreak:"⁠",NonBreakingSpace:" ",nopf:"𝕟",Nopf:"ℕ",Not:"⫬",not:"¬",NotCongruent:"≢",NotCupCap:"≭",NotDoubleVerticalBar:"∦",NotElement:"∉",NotEqual:"≠",NotEqualTilde:"≂̸",NotExists:"∄",NotGreater:"≯",NotGreaterEqual:"≱",NotGreaterFullEqual:"≧̸",NotGreaterGreater:"≫̸",NotGreaterLess:"≹",NotGreaterSlantEqual:"⩾̸",NotGreaterTilde:"≵",NotHumpDownHump:"≎̸",NotHumpEqual:"≏̸",notin:"∉",notindot:"⋵̸",notinE:"⋹̸",notinva:"∉",notinvb:"⋷",notinvc:"⋶",NotLeftTriangleBar:"⧏̸",NotLeftTriangle:"⋪",NotLeftTriangleEqual:"⋬",NotLess:"≮",NotLessEqual:"≰",NotLessGreater:"≸",NotLessLess:"≪̸",NotLessSlantEqual:"⩽̸",NotLessTilde:"≴",NotNestedGreaterGreater:"⪢̸",NotNestedLessLess:"⪡̸",notni:"∌",notniva:"∌",notnivb:"⋾",notnivc:"⋽",NotPrecedes:"⊀",NotPrecedesEqual:"⪯̸",NotPrecedesSlantEqual:"⋠",NotReverseElement:"∌",NotRightTriangleBar:"⧐̸",NotRightTriangle:"⋫",NotRightTriangleEqual:"⋭",NotSquareSubset:"⊏̸",NotSquareSubsetEqual:"⋢",NotSquareSuperset:"⊐̸",NotSquareSupersetEqual:"⋣",NotSubset:"⊂⃒",NotSubsetEqual:"⊈",NotSucceeds:"⊁",NotSucceedsEqual:"⪰̸",NotSucceedsSlantEqual:"⋡",NotSucceedsTilde:"≿̸",NotSuperset:"⊃⃒",NotSupersetEqual:"⊉",NotTilde:"≁",NotTildeEqual:"≄",NotTildeFullEqual:"≇",NotTildeTilde:"≉",NotVerticalBar:"∤",nparallel:"∦",npar:"∦",nparsl:"⫽⃥",npart:"∂̸",npolint:"⨔",npr:"⊀",nprcue:"⋠",nprec:"⊀",npreceq:"⪯̸",npre:"⪯̸",nrarrc:"⤳̸",nrarr:"↛",nrArr:"⇏",nrarrw:"↝̸",nrightarrow:"↛",nRightarrow:"⇏",nrtri:"⋫",nrtrie:"⋭",nsc:"⊁",nsccue:"⋡",nsce:"⪰̸",Nscr:"𝒩",nscr:"𝓃",nshortmid:"∤",nshortparallel:"∦",nsim:"≁",nsime:"≄",nsimeq:"≄",nsmid:"∤",nspar:"∦",nsqsube:"⋢",nsqsupe:"⋣",nsub:"⊄",nsubE:"⫅̸",nsube:"⊈",nsubset:"⊂⃒",nsubseteq:"⊈",nsubseteqq:"⫅̸",nsucc:"⊁",nsucceq:"⪰̸",nsup:"⊅",nsupE:"⫆̸",nsupe:"⊉",nsupset:"⊃⃒",nsupseteq:"⊉",nsupseteqq:"⫆̸",ntgl:"≹",Ntilde:"Ñ",ntilde:"ñ",ntlg:"≸",ntriangleleft:"⋪",ntrianglelefteq:"⋬",ntriangleright:"⋫",ntrianglerighteq:"⋭",Nu:"Ν",nu:"ν",num:"#",numero:"№",numsp:" ",nvap:"≍⃒",nvdash:"⊬",nvDash:"⊭",nVdash:"⊮",nVDash:"⊯",nvge:"≥⃒",nvgt:">⃒",nvHarr:"⤄",nvinfin:"⧞",nvlArr:"⤂",nvle:"≤⃒",nvlt:"<⃒",nvltrie:"⊴⃒",nvrArr:"⤃",nvrtrie:"⊵⃒",nvsim:"∼⃒",nwarhk:"⤣",nwarr:"↖",nwArr:"⇖",nwarrow:"↖",nwnear:"⤧",Oacute:"Ó",oacute:"ó",oast:"⊛",Ocirc:"Ô",ocirc:"ô",ocir:"⊚",Ocy:"О",ocy:"о",odash:"⊝",Odblac:"Ő",odblac:"ő",odiv:"⨸",odot:"⊙",odsold:"⦼",OElig:"Œ",oelig:"œ",ofcir:"⦿",Ofr:"𝔒",ofr:"𝔬",ogon:"˛",Ograve:"Ò",ograve:"ò",ogt:"⧁",ohbar:"⦵",ohm:"Ω",oint:"∮",olarr:"↺",olcir:"⦾",olcross:"⦻",oline:"‾",olt:"⧀",Omacr:"Ō",omacr:"ō",Omega:"Ω",omega:"ω",Omicron:"Ο",omicron:"ο",omid:"⦶",ominus:"⊖",Oopf:"𝕆",oopf:"𝕠",opar:"⦷",OpenCurlyDoubleQuote:"“",OpenCurlyQuote:"‘",operp:"⦹",oplus:"⊕",orarr:"↻",Or:"⩔",or:"∨",ord:"⩝",order:"ℴ",orderof:"ℴ",ordf:"ª",ordm:"º",origof:"⊶",oror:"⩖",orslope:"⩗",orv:"⩛",oS:"Ⓢ",Oscr:"𝒪",oscr:"ℴ",Oslash:"Ø",oslash:"ø",osol:"⊘",Otilde:"Õ",otilde:"õ",otimesas:"⨶",Otimes:"⨷",otimes:"⊗",Ouml:"Ö",ouml:"ö",ovbar:"⌽",OverBar:"‾",OverBrace:"⏞",OverBracket:"⎴",OverParenthesis:"⏜",para:"¶",parallel:"∥",par:"∥",parsim:"⫳",parsl:"⫽",part:"∂",PartialD:"∂",Pcy:"П",pcy:"п",percnt:"%",period:".",permil:"‰",perp:"⊥",pertenk:"‱",Pfr:"𝔓",pfr:"𝔭",Phi:"Φ",phi:"φ",phiv:"ϕ",phmmat:"ℳ",phone:"☎",Pi:"Π",pi:"π",pitchfork:"⋔",piv:"ϖ",planck:"ℏ",planckh:"ℎ",plankv:"ℏ",plusacir:"⨣",plusb:"⊞",pluscir:"⨢",plus:"+",plusdo:"∔",plusdu:"⨥",pluse:"⩲",PlusMinus:"±",plusmn:"±",plussim:"⨦",plustwo:"⨧",pm:"±",Poincareplane:"ℌ",pointint:"⨕",popf:"𝕡",Popf:"ℙ",pound:"£",prap:"⪷",Pr:"⪻",pr:"≺",prcue:"≼",precapprox:"⪷",prec:"≺",preccurlyeq:"≼",Precedes:"≺",PrecedesEqual:"⪯",PrecedesSlantEqual:"≼",PrecedesTilde:"≾",preceq:"⪯",precnapprox:"⪹",precneqq:"⪵",precnsim:"⋨",pre:"⪯",prE:"⪳",precsim:"≾",prime:"′",Prime:"″",primes:"ℙ",prnap:"⪹",prnE:"⪵",prnsim:"⋨",prod:"∏",Product:"∏",profalar:"⌮",profline:"⌒",profsurf:"⌓",prop:"∝",Proportional:"∝",Proportion:"∷",propto:"∝",prsim:"≾",prurel:"⊰",Pscr:"𝒫",pscr:"𝓅",Psi:"Ψ",psi:"ψ",puncsp:" ",Qfr:"𝔔",qfr:"𝔮",qint:"⨌",qopf:"𝕢",Qopf:"ℚ",qprime:"⁗",Qscr:"𝒬",qscr:"𝓆",quaternions:"ℍ",quatint:"⨖",quest:"?",questeq:"≟",quot:'"',QUOT:'"',rAarr:"⇛",race:"∽̱",Racute:"Ŕ",racute:"ŕ",radic:"√",raemptyv:"⦳",rang:"⟩",Rang:"⟫",rangd:"⦒",range:"⦥",rangle:"⟩",raquo:"»",rarrap:"⥵",rarrb:"⇥",rarrbfs:"⤠",rarrc:"⤳",rarr:"→",Rarr:"↠",rArr:"⇒",rarrfs:"⤞",rarrhk:"↪",rarrlp:"↬",rarrpl:"⥅",rarrsim:"⥴",Rarrtl:"⤖",rarrtl:"↣",rarrw:"↝",ratail:"⤚",rAtail:"⤜",ratio:"∶",rationals:"ℚ",rbarr:"⤍",rBarr:"⤏",RBarr:"⤐",rbbrk:"❳",rbrace:"}",rbrack:"]",rbrke:"⦌",rbrksld:"⦎",rbrkslu:"⦐",Rcaron:"Ř",rcaron:"ř",Rcedil:"Ŗ",rcedil:"ŗ",rceil:"⌉",rcub:"}",Rcy:"Р",rcy:"р",rdca:"⤷",rdldhar:"⥩",rdquo:"”",rdquor:"”",rdsh:"↳",real:"ℜ",realine:"ℛ",realpart:"ℜ",reals:"ℝ",Re:"ℜ",rect:"▭",reg:"®",REG:"®",ReverseElement:"∋",ReverseEquilibrium:"⇋",ReverseUpEquilibrium:"⥯",rfisht:"⥽",rfloor:"⌋",rfr:"𝔯",Rfr:"ℜ",rHar:"⥤",rhard:"⇁",rharu:"⇀",rharul:"⥬",Rho:"Ρ",rho:"ρ",rhov:"ϱ",RightAngleBracket:"⟩",RightArrowBar:"⇥",rightarrow:"→",RightArrow:"→",Rightarrow:"⇒",RightArrowLeftArrow:"⇄",rightarrowtail:"↣",RightCeiling:"⌉",RightDoubleBracket:"⟧",RightDownTeeVector:"⥝",RightDownVectorBar:"⥕",RightDownVector:"⇂",RightFloor:"⌋",rightharpoondown:"⇁",rightharpoonup:"⇀",rightleftarrows:"⇄",rightleftharpoons:"⇌",rightrightarrows:"⇉",rightsquigarrow:"↝",RightTeeArrow:"↦",RightTee:"⊢",RightTeeVector:"⥛",rightthreetimes:"⋌",RightTriangleBar:"⧐",RightTriangle:"⊳",RightTriangleEqual:"⊵",RightUpDownVector:"⥏",RightUpTeeVector:"⥜",RightUpVectorBar:"⥔",RightUpVector:"↾",RightVectorBar:"⥓",RightVector:"⇀",ring:"˚",risingdotseq:"≓",rlarr:"⇄",rlhar:"⇌",rlm:"‏",rmoustache:"⎱",rmoust:"⎱",rnmid:"⫮",roang:"⟭",roarr:"⇾",robrk:"⟧",ropar:"⦆",ropf:"𝕣",Ropf:"ℝ",roplus:"⨮",rotimes:"⨵",RoundImplies:"⥰",rpar:")",rpargt:"⦔",rppolint:"⨒",rrarr:"⇉",Rrightarrow:"⇛",rsaquo:"›",rscr:"𝓇",Rscr:"ℛ",rsh:"↱",Rsh:"↱",rsqb:"]",rsquo:"’",rsquor:"’",rthree:"⋌",rtimes:"⋊",rtri:"▹",rtrie:"⊵",rtrif:"▸",rtriltri:"⧎",RuleDelayed:"⧴",ruluhar:"⥨",rx:"℞",Sacute:"Ś",sacute:"ś",sbquo:"‚",scap:"⪸",Scaron:"Š",scaron:"š",Sc:"⪼",sc:"≻",sccue:"≽",sce:"⪰",scE:"⪴",Scedil:"Ş",scedil:"ş",Scirc:"Ŝ",scirc:"ŝ",scnap:"⪺",scnE:"⪶",scnsim:"⋩",scpolint:"⨓",scsim:"≿",Scy:"С",scy:"с",sdotb:"⊡",sdot:"⋅",sdote:"⩦",searhk:"⤥",searr:"↘",seArr:"⇘",searrow:"↘",sect:"§",semi:";",seswar:"⤩",setminus:"∖",setmn:"∖",sext:"✶",Sfr:"𝔖",sfr:"𝔰",sfrown:"⌢",sharp:"♯",SHCHcy:"Щ",shchcy:"щ",SHcy:"Ш",shcy:"ш",ShortDownArrow:"↓",ShortLeftArrow:"←",shortmid:"∣",shortparallel:"∥",ShortRightArrow:"→",ShortUpArrow:"↑",shy:"­",Sigma:"Σ",sigma:"σ",sigmaf:"ς",sigmav:"ς",sim:"∼",simdot:"⩪",sime:"≃",simeq:"≃",simg:"⪞",simgE:"⪠",siml:"⪝",simlE:"⪟",simne:"≆",simplus:"⨤",simrarr:"⥲",slarr:"←",SmallCircle:"∘",smallsetminus:"∖",smashp:"⨳",smeparsl:"⧤",smid:"∣",smile:"⌣",smt:"⪪",smte:"⪬",smtes:"⪬︀",SOFTcy:"Ь",softcy:"ь",solbar:"⌿",solb:"⧄",sol:"/",Sopf:"𝕊",sopf:"𝕤",spades:"♠",spadesuit:"♠",spar:"∥",sqcap:"⊓",sqcaps:"⊓︀",sqcup:"⊔",sqcups:"⊔︀",Sqrt:"√",sqsub:"⊏",sqsube:"⊑",sqsubset:"⊏",sqsubseteq:"⊑",sqsup:"⊐",sqsupe:"⊒",sqsupset:"⊐",sqsupseteq:"⊒",square:"□",Square:"□",SquareIntersection:"⊓",SquareSubset:"⊏",SquareSubsetEqual:"⊑",SquareSuperset:"⊐",SquareSupersetEqual:"⊒",SquareUnion:"⊔",squarf:"▪",squ:"□",squf:"▪",srarr:"→",Sscr:"𝒮",sscr:"𝓈",ssetmn:"∖",ssmile:"⌣",sstarf:"⋆",Star:"⋆",star:"☆",starf:"★",straightepsilon:"ϵ",straightphi:"ϕ",strns:"¯",sub:"⊂",Sub:"⋐",subdot:"⪽",subE:"⫅",sube:"⊆",subedot:"⫃",submult:"⫁",subnE:"⫋",subne:"⊊",subplus:"⪿",subrarr:"⥹",subset:"⊂",Subset:"⋐",subseteq:"⊆",subseteqq:"⫅",SubsetEqual:"⊆",subsetneq:"⊊",subsetneqq:"⫋",subsim:"⫇",subsub:"⫕",subsup:"⫓",succapprox:"⪸",succ:"≻",succcurlyeq:"≽",Succeeds:"≻",SucceedsEqual:"⪰",SucceedsSlantEqual:"≽",SucceedsTilde:"≿",succeq:"⪰",succnapprox:"⪺",succneqq:"⪶",succnsim:"⋩",succsim:"≿",SuchThat:"∋",sum:"∑",Sum:"∑",sung:"♪",sup1:"¹",sup2:"²",sup3:"³",sup:"⊃",Sup:"⋑",supdot:"⪾",supdsub:"⫘",supE:"⫆",supe:"⊇",supedot:"⫄",Superset:"⊃",SupersetEqual:"⊇",suphsol:"⟉",suphsub:"⫗",suplarr:"⥻",supmult:"⫂",supnE:"⫌",supne:"⊋",supplus:"⫀",supset:"⊃",Supset:"⋑",supseteq:"⊇",supseteqq:"⫆",supsetneq:"⊋",supsetneqq:"⫌",supsim:"⫈",supsub:"⫔",supsup:"⫖",swarhk:"⤦",swarr:"↙",swArr:"⇙",swarrow:"↙",swnwar:"⤪",szlig:"ß",Tab:"\t",target:"⌖",Tau:"Τ",tau:"τ",tbrk:"⎴",Tcaron:"Ť",tcaron:"ť",Tcedil:"Ţ",tcedil:"ţ",Tcy:"Т",tcy:"т",tdot:"⃛",telrec:"⌕",Tfr:"𝔗",tfr:"𝔱",there4:"∴",therefore:"∴",Therefore:"∴",Theta:"Θ",theta:"θ",thetasym:"ϑ",thetav:"ϑ",thickapprox:"≈",thicksim:"∼",ThickSpace:"  ",ThinSpace:" ",thinsp:" ",thkap:"≈",thksim:"∼",THORN:"Þ",thorn:"þ",tilde:"˜",Tilde:"∼",TildeEqual:"≃",TildeFullEqual:"≅",TildeTilde:"≈",timesbar:"⨱",timesb:"⊠",times:"×",timesd:"⨰",tint:"∭",toea:"⤨",topbot:"⌶",topcir:"⫱",top:"⊤",Topf:"𝕋",topf:"𝕥",topfork:"⫚",tosa:"⤩",tprime:"‴",trade:"™",TRADE:"™",triangle:"▵",triangledown:"▿",triangleleft:"◃",trianglelefteq:"⊴",triangleq:"≜",triangleright:"▹",trianglerighteq:"⊵",tridot:"◬",trie:"≜",triminus:"⨺",TripleDot:"⃛",triplus:"⨹",trisb:"⧍",tritime:"⨻",trpezium:"⏢",Tscr:"𝒯",tscr:"𝓉",TScy:"Ц",tscy:"ц",TSHcy:"Ћ",tshcy:"ћ",Tstrok:"Ŧ",tstrok:"ŧ",twixt:"≬",twoheadleftarrow:"↞",twoheadrightarrow:"↠",Uacute:"Ú",uacute:"ú",uarr:"↑",Uarr:"↟",uArr:"⇑",Uarrocir:"⥉",Ubrcy:"Ў",ubrcy:"ў",Ubreve:"Ŭ",ubreve:"ŭ",Ucirc:"Û",ucirc:"û",Ucy:"У",ucy:"у",udarr:"⇅",Udblac:"Ű",udblac:"ű",udhar:"⥮",ufisht:"⥾",Ufr:"𝔘",ufr:"𝔲",Ugrave:"Ù",ugrave:"ù",uHar:"⥣",uharl:"↿",uharr:"↾",uhblk:"▀",ulcorn:"⌜",ulcorner:"⌜",ulcrop:"⌏",ultri:"◸",Umacr:"Ū",umacr:"ū",uml:"¨",UnderBar:"_",UnderBrace:"⏟",UnderBracket:"⎵",UnderParenthesis:"⏝",Union:"⋃",UnionPlus:"⊎",Uogon:"Ų",uogon:"ų",Uopf:"𝕌",uopf:"𝕦",UpArrowBar:"⤒",uparrow:"↑",UpArrow:"↑",Uparrow:"⇑",UpArrowDownArrow:"⇅",updownarrow:"↕",UpDownArrow:"↕",Updownarrow:"⇕",UpEquilibrium:"⥮",upharpoonleft:"↿",upharpoonright:"↾",uplus:"⊎",UpperLeftArrow:"↖",UpperRightArrow:"↗",upsi:"υ",Upsi:"ϒ",upsih:"ϒ",Upsilon:"Υ",upsilon:"υ",UpTeeArrow:"↥",UpTee:"⊥",upuparrows:"⇈",urcorn:"⌝",urcorner:"⌝",urcrop:"⌎",Uring:"Ů",uring:"ů",urtri:"◹",Uscr:"𝒰",uscr:"𝓊",utdot:"⋰",Utilde:"Ũ",utilde:"ũ",utri:"▵",utrif:"▴",uuarr:"⇈",Uuml:"Ü",uuml:"ü",uwangle:"⦧",vangrt:"⦜",varepsilon:"ϵ",varkappa:"ϰ",varnothing:"∅",varphi:"ϕ",varpi:"ϖ",varpropto:"∝",varr:"↕",vArr:"⇕",varrho:"ϱ",varsigma:"ς",varsubsetneq:"⊊︀",varsubsetneqq:"⫋︀",varsupsetneq:"⊋︀",varsupsetneqq:"⫌︀",vartheta:"ϑ",vartriangleleft:"⊲",vartriangleright:"⊳",vBar:"⫨",Vbar:"⫫",vBarv:"⫩",Vcy:"В",vcy:"в",vdash:"⊢",vDash:"⊨",Vdash:"⊩",VDash:"⊫",Vdashl:"⫦",veebar:"⊻",vee:"∨",Vee:"⋁",veeeq:"≚",vellip:"⋮",verbar:"|",Verbar:"‖",vert:"|",Vert:"‖",VerticalBar:"∣",VerticalLine:"|",VerticalSeparator:"❘",VerticalTilde:"≀",VeryThinSpace:" ",Vfr:"𝔙",vfr:"𝔳",vltri:"⊲",vnsub:"⊂⃒",vnsup:"⊃⃒",Vopf:"𝕍",vopf:"𝕧",vprop:"∝",vrtri:"⊳",Vscr:"𝒱",vscr:"𝓋",vsubnE:"⫋︀",vsubne:"⊊︀",vsupnE:"⫌︀",vsupne:"⊋︀",Vvdash:"⊪",vzigzag:"⦚",Wcirc:"Ŵ",wcirc:"ŵ",wedbar:"⩟",wedge:"∧",Wedge:"⋀",wedgeq:"≙",weierp:"℘",Wfr:"𝔚",wfr:"𝔴",Wopf:"𝕎",wopf:"𝕨",wp:"℘",wr:"≀",wreath:"≀",Wscr:"𝒲",wscr:"𝓌",xcap:"⋂",xcirc:"◯",xcup:"⋃",xdtri:"▽",Xfr:"𝔛",xfr:"𝔵",xharr:"⟷",xhArr:"⟺",Xi:"Ξ",xi:"ξ",xlarr:"⟵",xlArr:"⟸",xmap:"⟼",xnis:"⋻",xodot:"⨀",Xopf:"𝕏",xopf:"𝕩",xoplus:"⨁",xotime:"⨂",xrarr:"⟶",xrArr:"⟹",Xscr:"𝒳",xscr:"𝓍",xsqcup:"⨆",xuplus:"⨄",xutri:"△",xvee:"⋁",xwedge:"⋀",Yacute:"Ý",yacute:"ý",YAcy:"Я",yacy:"я",Ycirc:"Ŷ",ycirc:"ŷ",Ycy:"Ы",ycy:"ы",yen:"¥",Yfr:"𝔜",yfr:"𝔶",YIcy:"Ї",yicy:"ї",Yopf:"𝕐",yopf:"𝕪",Yscr:"𝒴",yscr:"𝓎",YUcy:"Ю",yucy:"ю",yuml:"ÿ",Yuml:"Ÿ",Zacute:"Ź",zacute:"ź",Zcaron:"Ž",zcaron:"ž",Zcy:"З",zcy:"з",Zdot:"Ż",zdot:"ż",zeetrf:"ℨ",ZeroWidthSpace:"​",Zeta:"Ζ",zeta:"ζ",zfr:"𝔷",Zfr:"ℨ",ZHcy:"Ж",zhcy:"ж",zigrarr:"⇝",zopf:"𝕫",Zopf:"ℤ",Zscr:"𝒵",zscr:"𝓏",zwj:"‍",zwnj:"‌"}})),w=n(function(e,l){var r=p&&p.__importDefault||function(e){return e&&e.__esModule?e:{default:e}},r=(Object.defineProperty(l,"__esModule",{value:!0}),l.getTrie=l.encodeHTMLTrieRe=l.getCodePoint=void 0,r(g));function c(e){return 55296==(64512&e)}l.getCodePoint=null!=String.prototype.codePointAt?function(e,r){return e.codePointAt(r)}:function(e,r){return c(e.charCodeAt(r))?1024*(e.charCodeAt(r)-55296)+e.charCodeAt(r+1)-56320+65536:e.charCodeAt(r)};var u=t(r.default);function t(e){for(var r=new Map,t=0,i=Object.keys(e);t`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*/?>",k="]",I=new RegExp("^(?:<[A-Za-z][A-Za-z0-9-]*(?:\\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\\s*=\\s*(?:[^\"'=<>`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*/?>|]|\x3c!--\x3e|\x3c!---\x3e|\x3c!--[\\s\\S]*?--\x3e|[<][?][\\s\\S]*?[?][>]|]*>|)"),F=/[\\&]/,x="[!\"#$%&'()*+,./:;<=>?@[\\\\\\]^_`{|}~-]",Z=new RegExp("\\\\"+x+"|"+r,"gi"),_=new RegExp('[&<>"]',"g"),X=function(e){switch(e){case"&":return"&";case"<":return"<";case">":return">";case'"':return""";default:return e}};function T(e){return h(e)}h=String.fromCodePoint?function(e){try{return String.fromCodePoint(e)}catch(e){if(e instanceof RangeError)return String.fromCharCode(65533);throw e}}:(u=String.fromCharCode,d=Math.floor,function(){var e=[],r=-1,t=arguments.length;if(!t)return"";for(var i="";++r>10),n%1024+56320)),(r+1===t||16384?@\[\]\\^_`{|}~\p{P}\p{S}]/u),K=new RegExp('^(?:"('+x+'|\\\\[^\\\\]|[^\\\\"\\x00])*"|\'('+x+"|\\\\[^\\\\]|[^\\\\'\\x00])*'|\\(("+x+"|\\\\[^\\\\]|[^\\\\()\\x00])*\\))"),Q=/^(?:<(?:[^<>\n\\\x00]|\\.)*>)/,W=new RegExp("^"+r),e1=new RegExp("^&(?:#x[a-f0-9]{1,6}|#[0-9]{1,7}|[a-z][a-z0-9]{1,31});","i"),r1=/`+/,t1=/^`+/,i1=/\.\.\./g,n1=/--+/g,s1=/^<([a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)>/,a1=/^<[A-Za-z][A-Za-z0-9.+-]{1,31}:[^<>\x00-\x20]*>/i,o1=/^ *(?:\n *)?/,l1=/^[ \t\n\x0b\x0c\x0d]/,c1=/^\s/,u1=/ *$/,p1=/^ */,d1=/^ *(?:\n|$)/,h1=/^\[(?:[^\\\[\]]|\\.){0,1000}\]/s,f1=/^[^\n`\[\]\\!<&*_'"]+/m,g1=function(e){e=e.exec(this.subject.slice(this.pos));return null===e?null:(this.pos+=e.index+e[0].length,e[0])},m1=function(){return this.pos=N}function Y1(e){var r,t,i=!0,n=this.doc;for(this.oldtip=this.tip,this.offset=0,this.column=0,this.blank=!1,this.partiallyConsumedTab=!1,this.lineNumber+=1,-1!==e.indexOf("\0")&&(e=e.replace(/\0/g,"�")),this.currentLine=e;(t=n._lastChild)&&t._open;){switch(n=t,this.findNextNonspace(),this.blocks[n.type].continue(this,n)){case 0:break;case 1:i=!1;break;case 2:return;default:throw"continue returned illegal value, must be 0, 1, or 2"}if(!i){n=n._parent;break}}this.allClosed=n===this.oldtip;for(var s="paragraph"!==(this.lastMatchedContainer=n).type&&d9[n.type].acceptsLines,a=this.blockStarts,o=a.length;!s;){if(this.findNextNonspace(),!this.indented&&!i9.test(e.slice(this.nextNonspace))){this.advanceNextNonspace();break}for(var l=0;l|$)/i,/^/,/\?>/,/>/,/\]\]>/],t9=/^(?:\*[ \t]*){3,}$|^(?:_[ \t]*){3,}$|^(?:-[ \t]*){3,}$/,i9=/^[#`~*+_=<>0-9-]/,n9=/[^ \t\f\v\r\n]/,s9=/^[*+-]/,a9=/^(\d{1,9})([.)])/,o9=/^#{1,6}(?:[ \t]+|$)/,l9=/^`{3,}(?!.*`)|^~{3,}/,c9=/^(?:`{3,}|~{3,})(?=[ \t]*$)/,u9=/^(?:=+|-+)[ \t]*$/,p9=/\r\n|\n|\r/,d9={document:{continue:function(){return 0},finalize:function(e,r){F1(e,r)},canContain:function(e){return"item"!==e},acceptsLines:!1},list:{continue:function(){return 0},finalize:function(e,r){for(var t=r._firstChild;t;){if(t._next&&j1(t)){r._listData.tight=!1;break}for(var i=t._firstChild;i;){if(i._next&&j1(i)){r._listData.tight=!1;break}i=i._next}t=t._next}r.sourcepos[1]=r._lastChild.sourcepos[1]},canContain:function(e){return"item"===e},acceptsLines:!1},block_quote:{continue:function(e){var r=e.currentLine;return e.indented||62!==S(r,e.nextNonspace)?1:(e.advanceNextNonspace(),e.advanceOffset(1,!1),D(S(r,e.offset))&&e.advanceOffset(1,!0),0)},finalize:function(){},canContain:function(e){return"item"!==e},acceptsLines:!1},item:{continue:function(e,r){if(e.blank){if(null==r._firstChild)return 1;e.advanceNextNonspace()}else{if(!(e.indent>=r._listData.markerOffset+r._listData.padding))return 1;e.advanceOffset(r._listData.markerOffset+r._listData.padding,!0)}return 0},finalize:function(e,r){r._lastChild?r.sourcepos[1]=r._lastChild.sourcepos[1]:(r.sourcepos[1][0]=r.sourcepos[0][0],r.sourcepos[1][1]=r._listData.markerOffset+r._listData.padding)},canContain:function(e){return"item"!==e},acceptsLines:!1},heading:{continue:function(){return 1},finalize:function(){},canContain:function(){return!1},acceptsLines:!1},thematic_break:{continue:function(){return 1},finalize:function(){},canContain:function(){return!1},acceptsLines:!1},code_block:{continue:function(e,r){var t=e.currentLine,i=e.indent;if(r._isFenced){var n=i<=3&&t.charAt(e.nextNonspace)===r._fenceChar&&t.slice(e.nextNonspace).match(c9);if(n&&n[0].length>=r._fenceLength)return e.lastLineLength=e.offset+i+n[0].length,e.finalize(r,e.lineNumber),2;for(var s=r._fenceOffset;0')))},M.prototype.emph=function(e,r){this.tag(r?"em":"/em")},M.prototype.strong=function(e,r){this.tag(r?"strong":"/strong")},M.prototype.paragraph=function(e,r){var t=e.parent.parent,e=this.attrs(e);null!==t&&"list"===t.type&&t.listTight||(r?(this.cr(),this.tag("p",e)):(this.tag("/p"),this.cr()))},M.prototype.heading=function(e,r){var t="h"+e.level,e=this.attrs(e);r?(this.cr(),this.tag(t,e)):(this.tag("/"+t),this.cr())},M.prototype.code=function(e){this.tag("code"),this.out(e.literal),this.tag("/code")},M.prototype.code_block=function(e){var r=e.info?e.info.split(/\s+/):[],t=this.attrs(e);0]*\>/;function U(e){e=e||{},this.disableTags=0,this.lastOut="\n",this.indentLevel=0,this.indent=" ",this.esc=e.esc||o,this.options=e}(U.prototype=Object.create(R.prototype)).render=function(e){this.buffer="";var r,t,i,n,s,a,o,l=e.walker(),c=this.options;for(c.time&&console.time("rendering"),this.buffer+='\n',this.buffer+='\n';o=l.next();)if(a=o.entering,o=(i=o.node).type,n=i.isContainer,s="thematic_break"===o||"linebreak"===o||"softbreak"===o,t=o.replace(/([a-z])([A-Z])/g,"$1_$2").toLowerCase(),a){switch(r=[],o){case"document":r.push(["xmlns","http://commonmark.org/xml/1.0"]);break;case"list":null!==i.listType&&r.push(["type",i.listType.toLowerCase()]),null!==i.listStart&&r.push(["start",String(i.listStart)]),null!==i.listTight&&r.push(["tight",i.listTight?"true":"false"]);var u=i.listDelimiter;null!==u&&r.push(["delimiter","."===u?"period":"paren"]);break;case"code_block":i.info&&r.push(["info",i.info]);break;case"heading":r.push(["level",String(i.level)]);break;case"link":case"image":r.push(["destination",i.destination]),r.push(["title",i.title]);break;case"custom_inline":case"custom_block":r.push(["on_enter",i.onEnter]),r.push(["on_exit",i.onExit])}!c.sourcepos||(a=i.sourcepos)&&r.push(["sourcepos",String(a[0][0])+":"+String(a[0][1])+"-"+String(a[1][0])+":"+String(a[1][1])]),this.cr(),this.out(this.tag(t,r,s)),n?this.indentLevel+=1:s||((o=i.literal)&&this.out(this.esc(o)),this.out(this.tag("/"+t)))}else--this.indentLevel,this.cr(),this.out(this.tag("/"+t));return c.time&&console.timeEnd("rendering"),this.buffer+="\n",this.buffer},U.prototype.out=function(e){0 { ); }); -test("fenced Markdown preserves all code lines, closing fences and incomplete streams", () => { - const text = "Before\n```js\nconst value = {\n ready: true\n};\n```\nAfter"; - assert.deepEqual(markdownBlocks(text), [ - { type: "text", language: null, text: "Before" }, - { - type: "code", - language: "js", - text: "const value = {\n ready: true\n};", - }, - { type: "text", language: null, text: "After" }, - ]); - assert.deepEqual(markdownBlocks("```js\nconst partial"), [ - { type: "code", language: "js", text: "const partial" }, - ]); -}); - -test("a provider error describes the task outcome without claiming the prompt was undelivered", () => { - const { conversation: c, event } = connected(); - c.addPrompt("r", "Try this"); - event("accepted", { requestId: "r" }); - event("error", { requestId: "r", text: "The task failed" }); - assert.equal(c.messages[0].delivery, "delivered"); - assert.equal(c.notice, "The task failed"); - assert(c.canSend); -}); - -function connected() { +const fixture = JSON.parse( + readFileSync( + new URL( + "../../Tests/AstationTests/Fixtures/agent-chat-v1.json", + import.meta.url, + ), + ), +); +const textPart = (id, markdown) => ({ id, kind: "text", markdown }); +function connected(state = "ready") { let time = 1000; const conversation = new Conversation(() => time); - conversation.receive({ - op: "context", - connected: true, - supported: true, - enabled: true, - generation: "g", - }); let sequence = 0; + let conv = "c"; + const context = (extra = {}) => + conversation.receive({ + op: "context", + connected: true, + supported: true, + enabled: true, + agentId: "a", + conversationId: conv, + opening: true, + ...extra, + }); + context(); + const opened = (extra = {}) => + conversation.receive({ + op: "event", + event: { + v: 1, + op: "opened", + agentId: "a", + conversationId: conv, + generation: "g", + sequence: 0, + status: { state, since: time }, + ...extra, + }, + }); + opened(); return { conversation, advance: (ms) => { time += ms; }, + reopen(before, extra = {}) { + conv += "x"; + sequence = 0; + context({ before, ...extra }); + opened(); + }, event(op, data = {}) { + const defaults = + op === "message" + ? { + messageId: "m", + role: "assistant", + turnId: "t", + createdAt: time, + parts: [textPart("p1", "Hello")], + } + : op === "status" + ? { state: "ready", since: time } + : op === "complete" + ? { turnId: "t", outcome: "done" } + : {}; conversation.receive({ op: "event", - event: { v: 1, op, generation: "g", sequence: ++sequence, ...data }, + event: { + v: 1, + op, + agentId: "a", + conversationId: conv, + generation: "g", + sequence: ++sequence, + ...defaults, + ...data, + }, }); }, }; } -test("prompts have delivery boundaries and completion requires an explicit event", () => { - const { conversation: c, event, advance } = connected(); +test("published host shapes cover history, local prompts, optimistic reconciliation, tools and late content", () => { + const c = new Conversation(() => 1700000001000); + c.receive({ + op: "context", + connected: true, + supported: true, + enabled: true, + opening: true, + conversationId: "fixture-conversation", + agentId: "fixture-agent", + }); + for (const event of fixture.events) { + if (event.op === "accepted") + assert(c.addPrompt(fixture.requestId, "Reply with a harmless greeting")); + c.receive({ op: "event", event }); + } + assert.equal(c.messages.length, 4); + assert.equal(c.messages[0].origin, "local"); + assert.equal(c.messages[0].outcome, "interrupted"); + assert.equal(c.messages[2].id, "remote-user"); + assert.equal(c.messages[2].delivery, "delivered"); + assert.equal(c.messages[3].parts.length, 3); + assert.equal(c.messages[3].parts[1].kind, "tool"); + assert.equal(c.messages[3].outcome, "done"); + assert.match(messageText(c.messages[3]), /Late transcript/); assert(c.canSend); - assert(c.addPrompt("r", "Fix this")); - assert(!c.canSend); +}); + +test("parts deduplicate by ID and whole history replaces content without duplicate bubbles", () => { + const { conversation: c, event, reopen } = connected(); + event("message"); + event("delta", { messageId: "m", part: textPart("p2", "Next") }); + event("delta", { messageId: "m", part: textPart("p2", "Ignored") }); + assert.equal(c.messages[0].parts.length, 2); + reopen(); + event("message", { + history: true, + parts: [textPart("p1", "Replaced"), textPart("p2", "Whole")], + }); + assert.equal(c.messages.length, 1); + assert.equal(messageText(c.messages[0]), "Replaced\n\nWhole"); +}); + +test("live transcript echo waits for acceptance and a disconnect preserves confirmed delivery", () => { + const { conversation: c, event } = connected(); + c.addPrompt("r", "Hello"); + event("message", { + messageId: "user", + role: "user", + origin: "remote", + requestId: "r", + parts: [textPart("text", "Hello")], + }); assert.equal(c.messages[0].delivery, "sending"); event("accepted", { requestId: "r" }); + c.disconnect(); assert.equal(c.messages[0].delivery, "delivered"); - assert.equal(c.state, "unknown", "acceptance does not invent working status"); - event("status", { state: "working" }); - event("message", { messageId: "m", text: "First" }); - event("delta", { messageId: "m", text: " reply" }); - assert.equal(c.messages[1].text, "First reply"); - advance(120000); - assert(!c.canSend, "silence cannot imply completion"); - assert.equal(activity(c.state, c.activityAt, true, c.now()).state, "unknown"); - event("complete", { requestId: "r" }); + assert(!c.canSend); + assert.equal(c.messages.length, 1); +}); + +test("older history pages insert before the requested anchor in host order", () => { + const { conversation: c, event, reopen } = connected(); + event("message", { messageId: "latest" }); + reopen("latest"); + event("message", { messageId: "old-1", history: true }); + event("message", { messageId: "old-2", history: true }); + assert.deepEqual( + c.messages.map((m) => m.id), + ["old-1", "old-2", "latest"], + ); + assert.equal(c.oldestMessageId, "old-1"); +}); + +test("only fresh Ready enables submission; heartbeat preserves the original since", () => { + const { conversation: c, event, advance } = connected("unknown"); + assert(!c.canSend); + for (const state of ["working", "waiting", "unknown"]) { + event("status", { state }); + assert(!c.canSend); + } + event("status", { state: "ready", since: 1000 }); + assert(c.canSend); + advance(31000); + assert(!c.canSend); + event("status", { state: "ready", since: 1000 }); + assert.equal(c.since, 1000); assert(c.canSend); - assert(!c.addPrompt("r", "duplicate")); }); -test("duplicates and wrong generations are ignored; gaps permanently disable sending", () => { +test("execution completion accepts local turns without request IDs and late reply parts", () => { const { conversation: c, event } = connected(); - event("message", { messageId: "m", text: "Reply" }); + c.addPrompt("r", "Hello"); + event("accepted", { requestId: "r" }); + event("status", { state: "working" }); + event("message", { requestId: "r" }); + event("complete", { requestId: "r", outcome: "interrupted" }); + event("delta", { messageId: "m", part: textPart("late", "Still valid") }); + assert.equal(c.messages[1].outcome, "interrupted"); + assert.equal(c.messages[1].parts.length, 2); + assert(!c.canSend, "completion does not invent Ready"); + event("status"); + assert(c.canSend); + assert(!c.addPrompt("r", "Duplicate")); +}); + +test("refusals differ from errors after acceptance and unknown delivery never retries", () => { + for (const accepted of [false, true]) { + const { conversation: c, event } = connected(); + c.addPrompt("r", "Try"); + if (accepted) event("accepted", { requestId: "r" }); + event("error", { requestId: "r", text: "The terminal has unsent input" }); + assert.equal(c.messages[0].delivery, accepted ? "delivered" : "refused"); + assert(c.canSend); + } + const { conversation: c, event, reopen } = connected(); + c.addPrompt("r", "Maybe delivered"); + event("error", { + requestId: "r", + text: "delivery unknown; check the terminal", + }); + assert.equal(c.messages[0].delivery, "unknown"); + assert(!c.canSend); + reopen(); + assert(!c.canSend); + assert.equal(c.messages.length, 1, "reopen creates no outgoing prompt"); +}); + +test("pre-session errors and headerless close are bound to the selected conversation", () => { + const c = new Conversation(); + c.receive({ + op: "context", + conversationId: "c", + agentId: "a", + connected: true, + supported: true, + enabled: true, + opening: true, + }); c.receive({ op: "event", event: { v: 1, - op: "delta", - generation: "g", - sequence: 1, - messageId: "m", - text: "duplicate", + op: "error", + conversationId: "wrong", + agentId: "a", + text: "Forged", + }, + }); + assert.notEqual(c.notice, "Forged"); + c.receive({ + op: "event", + event: { + v: 1, + op: "error", + conversationId: "c", + agentId: "a", + text: "Chat unavailable", }, }); - event("status", { generation: "wrong", state: "ready" }); - assert.equal(c.messages[0].text, "Reply"); - assert.equal(c.state, "unknown"); - event("status", { state: "ready" }); + assert.equal(c.notice, "Chat unavailable"); assert(!c.canSend); - c.receive({ op: "context", connected: true, enabled: true }); - assert(!c.canSend, "periodic context does not recover missing events"); + const { conversation: open } = connected(); + open.receive({ + op: "event", + event: { v: 1, op: "close", agentId: "a", conversationId: "c" }, + }); + assert(open.closed); + assert(!open.canSend); +}); + +test("sequence gaps stop sending, while explicit reopen resets only the view sequence", () => { + const { conversation: c, event, reopen } = connected(); + event("message"); + event("delta", { + sequence: 1, + messageId: "m", + part: textPart("duplicate", "No"), + }); + assert.equal(c.messages[0].parts.length, 1); + event("status", { sequence: 3 }); + assert(!c.canSend); + reopen(); + assert(c.canSend); + assert.equal(c.lastSequence, 0); + event("status", { generation: "other" }); + assert(!c.canSend); + assert.match(c.notice, /process changed/); }); -test("unknown delivery, reconnects and page reload never resend prompts", () => { +test("reload and disconnect preserve uncertain delivery without inventing requests", () => { const { conversation: c } = connected(); - c.addPrompt("r", "Possibly delivered"); + c.addPrompt("r", "Maybe delivered"); c.disconnect(); assert.equal(c.messages[0].delivery, "unknown"); - c.receive({ op: "context", connected: true, enabled: true }); - assert(!c.canSend); const reloaded = new Conversation(); reloaded.receive({ op: "context", connected: true, - enabled: true, - generation: "g", - lastSequence: 7, + supported: true, + enabled: false, + opening: true, + conversationId: "new", pending: true, + pendingRequestId: "r", + deliveryUnknown: true, }); - assert.equal(reloaded.lastSequence, 7); assert.equal(reloaded.messages.length, 0); + assert.equal(reloaded.activeRequest, "r"); assert(!reloaded.canSend); }); -test("unsupported hosts, failed submissions and byte limits", () => { +test("prompt validation matches host byte, control and command rules", () => { + for (const text of [ + "", + " ", + "\x1b[201~bad", + "bad\r", + "\u0085", + " /resume", + "\n !echo test", + "\u00e9".repeat(16385), + ]) + assert(!validPrompt(text)); + for (const text of [ + "# hello", + "say /help", + "line\nnext\tcolumn", + "\u00e9".repeat(16384), + "emoji \u200d ok", + ]) + assert(validPrompt(text)); +}); + +test("message and transcript limits include tools but allow the host truncation marker", () => { const { conversation: c, event } = connected(); - c.context.enabled = false; - assert(!c.addPrompt("r", "Unsupported")); - c.context.enabled = true; - assert(!c.addPrompt("huge", "\u00e9".repeat(16385))); - assert(c.addPrompt("r", "Valid")); - c.receive({ - op: "notice", - requestId: "r", - text: "Not delivered", - delivery: "failed", + event("message", { + parts: [ + textPart("p1", "x".repeat(131072)), + textPart("atem:truncated", "\u2026 (truncated)"), + ], }); assert(c.canSend); - assert.equal(c.messages[0].delivery, "failed"); - event("message", { messageId: "m", text: "a".repeat(131072) }); - event("delta", { messageId: "m", text: "b" }); + event("delta", { + messageId: "m", + part: { id: "overflow", kind: "tool", name: "Read", summary: "README" }, + }); assert(!c.canSend); - assert.equal(c.messages[1].text.length, 131072); + const bounded = connected(); + for (let i = 0; i < 513; i++) + bounded.event("message", { messageId: `m${i}` }); + assert.equal(bounded.conversation.messages.length, 512); + assert(!bounded.conversation.canSend); +}); + +test("timestamps accept published Unix milliseconds and RFC3339, rejecting malformed values", () => { + assert.equal(timestamp(1700000000000), 1700000000000); + assert.equal(timestamp("2023-11-14T22:13:20.000Z"), 1700000000000); + for (const value of [null, {}, Infinity, -1, "invalid", "1700000000000"]) + assert.equal(timestamp(value), null); }); -test("conversation size is bounded across many valid messages", () => { +test("part metadata is bounded even when every content field is empty", () => { const { conversation: c, event } = connected(); - for (let i = 0; i < 513; i++) - event("message", { messageId: `m${i}`, text: "hello" }); - assert.equal(c.messages.length, 512); + const parts = Array.from({ length: 2048 }, (_, i) => + textPart("p".repeat(120) + i, ""), + ); + for (let i = 0; i < 60; i++) + event("message", { messageId: `metadata-${i}`, parts }); assert(!c.canSend); + assert(c.messages.length < 60); }); test("safe links reject executable protocols and embedded credentials", () => { diff --git a/webapp/tests/agent-markdown.test.mjs b/webapp/tests/agent-markdown.test.mjs new file mode 100644 index 0000000..c21311d --- /dev/null +++ b/webapp/tests/agent-markdown.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRequire } from "node:module"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { renderMarkdown } from "../agent/markdown.mjs"; + +const { Parser } = createRequire(import.meta.url)( + "../agent/vendor/commonmark.min.js", +); + +class Element { + constructor(tag) { + this.tag = tag; + this.children = []; + } + append(...children) { + this.children.push(...children); + } +} +const document = { + createDocumentFragment: () => new Element("fragment"), + createElement: (tag) => new Element(tag), + createTextNode: (text) => ({ tag: "text", textContent: text }), +}; +const render = (text) => + renderMarkdown(text, { + document, + parser: new Parser(), + codeBlock(text, language) { + const element = new Element("pre"); + element.textContent = text; + element.language = language; + return element; + }, + }); +function descendants(node) { + return [node, ...(node.children || []).flatMap(descendants)]; +} + +test("CommonMark handles nested emphasis, ordered lists, entities and fenced code", () => { + const nodes = descendants( + render( + "### Build & test\n\n3. **Bold and *nested***\n4. `inline`\n\n```js\nconst ready = true;\n```", + ), + ); + assert(nodes.some((n) => n.tag === "h3")); + const heading = nodes.find((n) => n.tag === "h3"); + assert.equal( + descendants(heading) + .map((n) => n.textContent || "") + .join(""), + "Build & test", + ); + assert(nodes.some((n) => n.tag === "ol" && n.start === 3)); + assert( + nodes.some( + (n) => n.tag === "strong" && descendants(n).some((n) => n.tag === "em"), + ), + ); + assert( + nodes.some( + (n) => + n.tag === "pre" && + n.language === "js" && + n.textContent === "const ready = true;\n", + ), + ); +}); + +test("HTML, image URLs, executable links and credential URLs never become active elements", () => { + const nodes = descendants( + render( + "\n\n\n\n[bad](javascript:alert%281%29) [secret](https://user:password@example.com) ![image](https://tracker.example.com/pixel) [good](https://example.com)", + ), + ); + assert(!nodes.some((n) => ["script", "img", "iframe"].includes(n.tag))); + assert(nodes.some((n) => n.textContent === "")); + const links = nodes.filter((n) => n.tag === "a"); + assert.equal(links.length, 1); + assert.equal(links[0].href, "https://example.com/"); + assert.equal(links[0].rel, "noopener noreferrer"); +}); + +test("vendored parser matches the reviewed pinned asset", () => { + const actual = createHash("sha256") + .update( + readFileSync( + new URL("../agent/vendor/commonmark.min.js", import.meta.url), + ), + ) + .digest("hex"); + const readme = readFileSync( + new URL("../agent/vendor/README.md", import.meta.url), + "utf8", + ); + assert.match(readme, new RegExp(`SHA-256 .commonmark.min.js.: .${actual}.`)); +}); diff --git a/webapp/tests/session-protocol.test.mjs b/webapp/tests/session-protocol.test.mjs new file mode 100644 index 0000000..ef8cf7f --- /dev/null +++ b/webapp/tests/session-protocol.test.mjs @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createHash, webcrypto } from "node:crypto"; +import test from "node:test"; +import { + BROWSER_SESSION_ACCESS_ENABLED, canonicalPayload, payloadDigest, parseSessionJSON, + decodeSessionPacket, decodeFamilyError, SessionRegistry, SessionLease, +} from "../agent/session-protocol.mjs"; + +const root = new URL("../../Tests/AstationTests/Fixtures/atem-session-v1/", import.meta.url); +const fixture = async (name) => JSON.parse(await readFile(new URL(`${name}.json`, root), "utf8")); +const decode = (packet) => decodeSessionPacket(JSON.stringify(packet)); + +test("r6.2 shared digest vectors and rejection vectors use the wire parser", async () => { + const vectors = await fixture("digest-vectors"); + for (const vector of vectors.vectors) { + const payload = parseSessionJSON(JSON.stringify(vector.payload)); + assert.equal(canonicalPayload(payload), vector.canonical, vector.name); + assert.equal(await payloadDigest(payload, webcrypto), vector.sha256, vector.name); + } + for (const vector of vectors.must_reject) { + const input = vector.payload_json ?? Buffer.from(vector.payload_hex, "hex"); + assert.throws(() => parseSessionJSON(input), vector.name); + } +}); + +test("shared fixture copies match the pinned protocol snapshot", async () => { + const source = await fixture("source"); + assert.equal(source.commit, "22de096b1980ee3ae342c0352b4c1d5fa54e3b51"); + assert.equal(source.tag, "atem-session-v1-r6.2"); + assert.equal(Object.keys(source.sha256).length, 18); + for (const [name, hash] of Object.entries(source.sha256)) { + const bytes = await readFile(new URL(name, root)); + assert.equal(createHash("sha256").update(bytes).digest("hex"), hash, name); + assert.equal(bytes.toString("utf8"), + await readFile(new URL(`../../designs/protocol/fixtures/${name}`, import.meta.url), "utf8")); + } +}); + +test("Unicode key identity stays exact and escaped duplicate keys are rejected", () => { + assert.equal(canonicalPayload(parseSessionJSON('{"\\u00e9":1,"e\\u0301":2}')), '{"e\u0301":2,"\u00e9":1}'); + assert.throws(() => parseSessionJSON('{"a":1,"\\u0061":2}')); + assert.throws(() => parseSessionJSON('{"__proto__":1,"__proto__":2}')); +}); + +test("duplicate relay payloads and unsafe counter spellings are rejected", () => { + const id = "11111111-1111-4111-8111-000000000001"; + for (const raw of [ + `{"type":"session","data":{"v":1,"v":1,"op":"list","id":"${id}"}}`, + `{"type":"session","data":{"v":1e0,"op":"list","id":"${id}"}}`, + `{"type":"session","data":{"v":1,"op":"list","id":"${id}","seq":9007199254740992}}`, + `{"payload":{"type":"session","data":{"v":1,"op":"list","id":"${id}"}},"payload":{}}`, + ]) assert.throws(() => decodeSessionPacket(raw)); + assert.equal(decodeSessionPacket(`{"type":"session","data":{"v":1,"op":"list","id":"${id}","future":{"fraction":1.5}}}`).op, "list"); +}); + +test("shared client lease fixture includes expiry equality and no late reply resurrection", async () => { + const scenario = await fixture("late-renew-reply-does-not-resurrect-control"); + let now = 0; + const binding = scenario.steps.find((step) => step.send).json.data.binding; + const lease = new SessionLease(binding, { now: () => now }); + for (const step of scenario.steps) { + if (step.advance_ms !== undefined) now += step.advance_ms; + if (step.send) assert.equal(lease.sent(decode(step.json)), true); + if (step.deliver_to_client) lease.receive(decode(step.json)); + const expected = step.assert_client; + if (expected) { + if (expected.controls !== undefined) assert.equal(lease.controls, expected.controls); + if (expected.input_enabled !== undefined) assert.equal(lease.controls, expected.input_enabled); + if (expected.deadline_ms !== undefined) assert.equal(lease.deadline, expected.deadline_ms); + } + } +}); + +test("shared registry fixture uses complete wire messages and rejects resurrection", async () => { + const scenario = await fixture("runtime-epoch-and-sequence-ordering"); + const registry = new SessionRegistry(); + for (const step of scenario.steps) { + if (step.deliver_to_client) registry.apply(decode(step.json)); + const expected = step.assert_client; + if (expected) { + if (expected.registry) assert.deepEqual([...registry.sessions.keys()].sort(), [...expected.registry].sort()); + if (expected.runtime_epoch !== undefined) assert.equal(registry.epoch, expected.runtime_epoch); + if (expected.seq !== undefined) assert.equal(registry.seq, expected.seq); + if (expected.sent_list) assert.equal(registry.requestedList, true); + if (expected.registry_state) { + for (const [id, state] of Object.entries(expected.registry_state)) assert.equal(registry.sessions.get(id)?.state, state); + } + } + } +}); + +test("r6.2 new attachment fixture resets sequence and keeps the runtime floor", async () => { + const scenario = await fixture("registry-restarts-on-a-new-attachment"); + const registry = new SessionRegistry(); + for (const step of scenario.steps) { + if (step.deliver_to_client) registry.apply(decode(step.json)); + else if (step.assert_client) { + const expected = step.assert_client; + for (const key of Object.keys(expected)) assert.ok(["registry", "runtime_epoch", "seq"].includes(key), key); + if (expected.registry) assert.deepEqual([...registry.sessions.keys()].sort(), expected.registry); + if (expected.runtime_epoch !== undefined) assert.equal(registry.epoch, expected.runtime_epoch); + if (expected.seq !== undefined) assert.equal(registry.seq, expected.seq); + } else assert.fail(`Unhandled fixture step: ${JSON.stringify(step)}`); + } +}); + +test("new attachments forget removals and require a snapshot before changes", async () => { + const scenario = await fixture("registry-restarts-on-a-new-attachment"); + const snapshot = decode(scenario.steps[0].json); + const welcome = decode(scenario.steps[2].json); + const registry = new SessionRegistry(); + registry.apply(snapshot); + registry.apply({ ...snapshot, op: "session_changed", seq: 6, descriptor: { ...snapshot.sessions[0], state: "removed" } }); + registry.apply(welcome); + registry.apply({ ...snapshot, op: "session_changed", seq: 1, descriptor: snapshot.sessions[0] }); + assert.equal(registry.sessions.size, 0); + assert.equal(registry.requestedList, true); + registry.apply({ ...snapshot, seq: 1 }); + assert.equal(registry.sessions.size, 1); +}); + +test("r6.2 session errors can omit re, and family errors preserve their codes", () => { + const error = { v: 1, op: "error", id: "11111111-1111-4111-8111-000000000001", code: "unsupported_version" }; + assert.equal(decode({ type: "session", data: error }).re, undefined); + assert.throws(() => decode({ type: "session", data: { ...error, re: "invalid" } })); + for (const packet of [ + { type: "terminal", data: { v: 1, op: "error", terminal_id: "ses-aaaaaaaaaaaa", stream_id: "old-stream", code: "stale_lease", message: "Control lease is stale" } }, + { type: "agentChat", data: { v: 1, op: "error", agentId: "ses-aaaaaaaaaaaa", conversationId: "conv-1", requestId: error.id, code: "outcome_unknown", text: "Write interrupted" } }, + ]) { + assert.equal(decodeFamilyError(JSON.stringify(packet)).code, packet.data.code); + const invalid = structuredClone(packet); delete invalid.data.code; + assert.throws(() => decodeFamilyError(JSON.stringify(invalid))); + } +}); + +test("descriptors require explicit controller and complete removal values", async () => { + const scenario = await fixture("runtime-epoch-and-sequence-ordering"); + const packet = scenario.steps[0].json; + for (const key of Object.keys(packet.data.sessions[0])) { + const invalid = structuredClone(packet); + delete invalid.data.sessions[0][key]; + assert.throws(() => decode(invalid), key); + } +}); + +test("removing a second process cannot forget an earlier tombstone", async () => { + const scenario = await fixture("runtime-epoch-and-sequence-ordering"); + const snapshot = decode(scenario.steps[0].json); + const registry = new SessionRegistry(); + registry.apply(snapshot); + const first = snapshot.sessions[0]; + const second = "77777777-7777-4777-8777-777777777777"; + for (const [offset, state, process_gen] of [[1, "removed", first.process_gen], [2, "running", second], [3, "removed", second], [4, "running", first.process_gen]]) { + registry.apply({ ...snapshot, op: "session_changed", seq: snapshot.seq + offset, descriptor: { ...first, state, process_gen } }); + } + assert.equal(registry.sessions.has(first.session_id), false); +}); + +test("local takeover fences renew replies, and expiry cannot renew itself", async () => { + const scenario = await fixture("late-renew-reply-does-not-resurrect-control"); + const acquire = scenario.steps[0].json.data; + const reply = scenario.steps[1].json.data; + let now = 0; + const lease = new SessionLease(acquire.binding, { now: () => now }); + lease.sent(acquire); lease.receive(reply); + now = 5000; + const renew = scenario.steps[4].json.data; + lease.sent(renew); lease.revoke(2); + lease.receive({ ...reply, re: renew.id }); + assert.equal(lease.controls, false); + now = 15000; + assert.equal(lease.sent(renew), false); + assert.equal(lease.controls, false); + lease.sent({ ...acquire, id: "new-acquire" }); + lease.receive({ ...reply, re: "new-acquire", binding: { ...reply.binding, lease_epoch: 2 } }); + assert.equal(lease.controls, false, "The revoked epoch cannot be granted again"); + lease.sent({ ...acquire, id: "explicit-acquire" }); + lease.receive({ ...reply, re: "explicit-acquire", binding: { ...reply.binding, lease_epoch: 3 } }); + assert.equal(lease.controls, true); +}); + +test("browser host access stays disabled until section 9 is implemented", () => { + assert.equal(BROWSER_SESSION_ACCESS_ENABLED, false); +});