import Foundation import Frida import Observation import SwiftyR2 public enum EngineError: Swift.Error, LocalizedError { case spawnedProcessNotFound(pid: UInt) public var errorDescription: String? { switch self { case .spawnedProcessNotFound(let pid): return "Spawned pid \(pid) not found" } } } @Observable @MainActor public final class Engine { public let deviceManager: DeviceManager public let systemParameters = SystemParametersCache() public let store: ProjectStore public let blobs: BlobStore public let eventStore: EventStore? public let compilerWorkspace: CompilerWorkspace public private(set) var processNodes: [ProcessNode] = [] private var drainServices: [String: SystemDrainService] = [:] public private(set) var descriptors: [InstrumentDescriptor] = [] private var descriptorsByID: [String: InstrumentDescriptor] = [:] public let llmRegistry: LLMProviderRegistry public let llmCredentials: LLMCredentialStore public let missionTools: ToolCatalog public var imageProcessor: ImageProcessor? private let missionExecutor: MissionExecutor private var activeNoteReplyTasks: [UUID: Task] = [:] private(set) public var noteReplyProgress: [UUID: NoteReplyProgress] = [:] private var missionLiveTextByID: [UUID: String] = [:] private var externalMissionLiveDeltaSink: (@MainActor (UUID, LLMTurnEvent) -> Void)? #if canImport(Network) || canImport(CSoup) private var activeMCPServersByMissionID: [UUID: MCPServer] = [:] public private(set) var externalMCPServer: MCPServer? public private(set) var externalMCPURL: URL? public private(set) var externalMCPMissionID: UUID? #endif private let _events = AsyncEventSource() public var events: AsyncStream { _events.makeStream() } private let _widgetUpdates = AsyncEventSource() public var widgetUpdates: AsyncStream { _widgetUpdates.makeStream() } private var widgetStates: [UUID: [String: WidgetState]] = [:] public let eventLog = EventLog() public let eventChime = EventChime() public let connectionActivity: ConnectionActivityCenter private var deviceEventTasks: [String: Task] = [:] private var gatingEnabledDevices: Set = [] private var gatingIntendedDevices: Set = [] private var deviceChangeWatcher: Task? private var eventLogTask: Task? private var eventStoreFlushTail: Task = Task {} public let hookPacks: HookPackLibrary public let patterns: PatternLibrary public let patternDecoder = PatternDecoder() public let customInstruments: CustomInstrumentLibrary public let virtualMachines: VirtualMachineManager /// The image the playground, the notebook cells and the mission tools all /// talk to. The frontend says where it lives; see `PharoWorkspace`. @ObservationIgnored public private(set) lazy var pharo = PharoWorkspace(engine: self) @ObservationIgnored private var disassemblers: [UUID: Disassembler] = [:] @ObservationIgnored private var seekRestoredSessions: Set = [] @ObservationIgnored private var memoryReaders: [UUID: CachingMemoryReader] = [:] public let collaboration: CollaborationSession public let gitHubAuth: GitHubAuth public let dataDirectory: URL public private(set) var addressAnnotations: [UUID: [UInt64: AddressAnnotation]] = [:] public private(set) var tracerInstanceIDBySession: [UUID: UUID] = [:] public private(set) var sessions: [ProcessSession] = [] public private(set) var notebookEntries: [NotebookEntry] = [] public private(set) var instrumentsBySession: [UUID: [InstrumentInstance]] = [:] public private(set) var insightsBySession: [UUID: [AddressInsight]] = [:] public private(set) var insightRefreshGenerations: [UUID: Int] = [:] public private(set) var tracesBySession: [UUID: [ITrace]] = [:] public private(set) var moduleAnalysisStatuses: [UUID: [String: ModuleAnalysisStatus]] = [:] public internal(set) var projectUIState: ProjectUIState = ProjectUIState() public internal(set) var sessionUIStates: [UUID: SessionUIState] = [:] public internal(set) var customInstrumentDefUIStates: [UUID: CustomInstrumentDefUIState] = [:] public private(set) var missions: [Mission] = [] public private(set) var installedPackages: [InstalledPackage] = [] @ObservationIgnored private var typeScriptProjects: [TypeScriptProjectKey: Task] = [:] private var addressActionProviders: [AddressActionProvider] = [] @ObservationIgnored private var protectionRegionsBySession: [UUID: [ProtectionRegion]] = [:] @ObservationIgnored private var unmappedPagesBySession: [UUID: Set] = [:] @ObservationIgnored private var mappingProbesBySession: [UUID: [UInt64: Task]] = [:] @ObservationIgnored private var functionStartsBySession: [UUID: [UInt64: Bool]] = [:] @ObservationIgnored private var factsIdentityBySession: [UUID: String] = [:] private var runningCustomInstrumentReloads: Set = [] private var pendingCustomInstrumentReloads: Set = [] private var customInstrumentReloadWaiters: [UUID: [CheckedContinuation]] = [:] private var threadActionProviders: [ThreadActionProvider] = [] @ObservationIgnored public var onSessionListChanged: (@MainActor (SessionListChange) -> Void)? @ObservationIgnored public var onREPLCellAdded: (@MainActor (REPLCell) -> Void)? @ObservationIgnored public var onNotebookChanged: (@MainActor (NotebookChange) -> Void)? @ObservationIgnored public var onAddressNoteChanged: (@MainActor (AddressNoteChange) -> Void)? @ObservationIgnored public var onInstalledPackagesChanged: (@MainActor ([InstalledPackage]) -> Void)? @ObservationIgnored public var onUserNotification: (@MainActor (UserNotification) -> Void)? @ObservationIgnored public var onMissionsChanged: (@MainActor ([Mission]) -> Void)? @ObservationIgnored private var sessionsObservation: StoreObservation? @ObservationIgnored private var notebookObservation: StoreObservation? @ObservationIgnored private var instrumentsObservation: StoreObservation? @ObservationIgnored private var insightsObservation: StoreObservation? @ObservationIgnored private var tracesObservation: StoreObservation? @ObservationIgnored private var missionsObservation: StoreObservation? @ObservationIgnored private var lastUploadedTraceSize: [UUID: Int] = [:] @ObservationIgnored private let _traceCacheInvalidations = AsyncEventSource() public var traceCacheInvalidations: AsyncStream { _traceCacheInvalidations.makeStream() } public struct TraceCacheInvalidation: Sendable { public let traceID: UUID public let knownTotalSize: Int } private static let traceDataPageSize: Int = 1 * 1024 * 1024 @ObservationIgnored private var packagesObservation: StoreObservation? public init( store: ProjectStore, blobs: BlobStore, eventStore: EventStore? = nil, dataDirectory: URL, tokenStore: TokenStore? = nil, gitHubAuth: GitHubAuth? = nil, deviceManager: DeviceManager = .shared ) { self.deviceManager = deviceManager self.store = store self.blobs = blobs self.eventStore = eventStore self.dataDirectory = dataDirectory self.connectionActivity = ConnectionActivityCenter( durations: StageDurationMemory( fileURL: dataDirectory.appendingPathComponent("connection-stage-durations.json") ) ) self.compilerWorkspace = CompilerWorkspace(store: store) let hookPacksDir = dataDirectory.appendingPathComponent("HookPacks", isDirectory: true) try? FileManager.default.createDirectory(at: hookPacksDir, withIntermediateDirectories: true) self.hookPacks = HookPackLibrary(directory: hookPacksDir) let patternsDir = dataDirectory.appendingPathComponent("Patterns", isDirectory: true) try? FileManager.default.createDirectory(at: patternsDir, withIntermediateDirectories: true) self.patterns = PatternLibrary(directory: patternsDir) self.customInstruments = CustomInstrumentLibrary() self.virtualMachines = VirtualMachineManager(deviceManager: deviceManager, store: store, dataDirectory: dataDirectory) self.collaboration = CollaborationSession( deviceManager: deviceManager, store: store, portalAddress: BackendConfig.portalAddress, portalCertificate: BackendConfig.certificate ) let resolvedTokenStore = tokenStore ?? defaultTokenStore(dataDirectory: dataDirectory) if let gitHubAuth { self.gitHubAuth = gitHubAuth } else { self.gitHubAuth = GitHubAuth(tokenStore: resolvedTokenStore) } let registry = LLMProviderRegistry() let credentials = LLMCredentialStore(backing: resolvedTokenStore) let catalog = ToolCatalog() self.llmRegistry = registry self.llmCredentials = credentials self.missionTools = catalog self.missionExecutor = MissionExecutor( store: store, registry: registry, credentials: credentials, catalog: catalog, collaboration: collaboration, systemPromptBuilder: { mission in MissionSystemPrompt.build(for: mission, tools: catalog) } ) #if os(Windows) || os(macOS) || os(Linux) virtualMachines.register(QemuBackend()) #endif #if os(Windows) || os(macOS) || os(Linux) virtualMachines.register(AndroidEmulatorBackend()) #endif #if os(macOS) virtualMachines.register(VirtualizationBackend()) virtualMachines.register(VPhoneBackend()) #endif registerDescriptor(Self.tracerDescriptor) for desc in hookPacks.descriptors() { registerDescriptor(desc) } customInstruments.start(store: store) for desc in customInstruments.descriptors() { registerDescriptor(desc) } customInstruments.observers.append { [weak self] in self?.refreshCustomInstrumentDescriptors() } bindCollaborationCallbacks() missionExecutor.liveDeltaSink = { [weak self] missionID, event in self?.handleMissionLiveEvent(missionID: missionID, event: event) } registerAddressActionProvider { [weak self] sessionID, address, context, facts in self?.tracerAddressActions(sessionID: sessionID, address: address, context: context, facts: facts) ?? [] } registerThreadActionProvider { [weak self] sessionID, thread in self?.threadTraceActions(sessionID: sessionID, thread: thread) ?? [] } Task { @MainActor [auth = self.gitHubAuth] in await auth.loadPersistedToken() } APNsRegistration.shared.observe { [weak self] _ in self?.syncAPNsSubscription() } Task { @MainActor [weak self] in guard let self else { return } for await status in self.collaboration.statusChanges { if case .joined = status { self.syncAPNsSubscription() self.lastUploadedTraceSize.removeAll() } } } eventLogTask = Task { @MainActor [weak self] in guard let self else { return } for await event in self._events.makeStream() { self.eventLog.append(event) } } if let eventStore { eventLog.onEventsToPersist = { [weak self] batch in guard let self else { return } let previous = self.eventStoreFlushTail self.eventStoreFlushTail = Task { await previous.value await eventStore.append(batch) } } } #if os(Windows) || os(macOS) || os(Linux) llmRegistry.register(ClaudeCodeProvider(engine: self)) #endif llmRegistry.register(AnthropicProvider()) llmRegistry.register(OpenAIProvider()) llmRegistry.register(OpenAICompatibleProvider.local()) llmRegistry.register(OpenAICompatibleProvider.remote()) MissionTools.registerStandard(in: missionTools, engine: self) missionsObservation = store.observeMissions { [weak self] missions in Task { @MainActor in guard let self else { return } self.missions = missions self.onMissionsChanged?(missions) } } hookPacks.onError = { [weak self] message in self?.emitEngineError(subsystem: "hookpacks", text: message) } hookPacks.reload() patterns.reload() } private func syncAPNsSubscription() { guard case .joined = collaboration.status else { return } guard let token = APNsRegistration.shared.deviceTokenHex else { return } collaboration.registerPushSubscriptions([[ "platform": "apns", "endpoint": token, "environment": APNsRegistration.environment, ]]) } public func startCollaboration(joiningLab labID: String? = nil) { let existing = labID ?? (try? store.fetchCollaborationState())?.labID Task { @MainActor in guard let token = await gitHubAuth.requestToken() else { return } await collaboration.start(token: token, existingLabID: existing) } } public func signOut() async { await gitHubAuth.signOut() await collaboration.stop() } /// Ask the portal for a one-time enrollment token and build the URL the /// user should open in their default browser to allow Web Push /// notifications. The token is single-use with a five-minute lifetime /// (enforced server-side) so it's safe to stash in the URL fragment. public func webPushEnrollmentURL() async throws -> URL { let ticket = try await collaboration.requestPushEnrollmentToken() guard var components = URLComponents(string: BackendConfig.pushEnrollURL) else { throw URLError(.badURL) } components.fragment = "token=\(ticket.token)&vapid=\(ticket.vapidPublicKey)" guard let url = components.url else { throw URLError(.badURL) } return url } /// Spacing between consecutive `position` values assigned locally. The /// server stamps definitive values on echo; this is just enough slack /// so a drag-between-neighbors computes a non-degenerate midpoint. private static let notebookPositionStep: Double = 1000 public func addNotebookEntry(_ entry: NotebookEntry, after otherEntry: NotebookEntry? = nil) { var e = entry if let otherEntry { e.timestamp = otherEntry.timestamp.addingTimeInterval(0.001) } if e.position == 0 { let maxPos = (try? store.maxNotebookEntryPosition()) ?? 0 e.position = maxPos + Self.notebookPositionStep } try? store.save(e) notebookEntries.append(e) onNotebookChanged?(.added(e)) collaboration.enqueueAdd(e) } public func updateNotebookEntry(_ entry: NotebookEntry) { updateNotebookEntry(entry, changed: .all) } /// The caller names the fields it moved so views apply just those; the model /// does not diff the entry to find out. Only the fields the collaboration /// protocol carries are enqueued, and only when one of them actually moved. public func updateNotebookEntry(_ entry: NotebookEntry, changed: NotebookEntryFields) { try? store.save(entry) if let i = notebookEntries.firstIndex(where: { $0.id == entry.id }) { notebookEntries[i] = entry } onNotebookChanged?(.updated(entry, changed: changed)) if !changed.isDisjoint(with: [.title, .details, .processName, .pharoSnapshot]) { collaboration.enqueueUpdate( entryID: entry.id, title: entry.title, details: entry.details, processName: entry.processName, pharoSnapshot: changed.contains(.pharoSnapshot) ? entry.pharoSnapshot : nil ) } } public func deleteNotebookEntry(_ entry: NotebookEntry) { try? store.deleteNotebookEntry(id: entry.id) notebookEntries.removeAll { $0.id == entry.id } onNotebookChanged?(.removed(entry.id)) collaboration.enqueueRemove(entryID: entry.id) } public func createAddressNote( sessionID: UUID, address: UInt64, title: String? = nil ) -> AddressNote? { guard let node = node(forSessionID: sessionID) else { return nil } let anchor = node.anchor(for: address) let note = AddressNote(sessionID: sessionID, anchor: anchor, title: title) try? store.save(note) rebuildAddressAnnotations(sessionID: sessionID) onAddressNoteChanged?(.noteAdded(note)) collaboration.enqueueAddressNoteUpsert(note) return note } public func updateAddressNote(_ note: AddressNote) { var updated = note updated.updatedAt = Date() try? store.save(updated) onAddressNoteChanged?(.noteUpdated(updated)) collaboration.enqueueAddressNoteUpsert(updated) } public func deleteAddressNote(_ note: AddressNote) { try? store.deleteAddressNote(id: note.id) rebuildAddressAnnotations(sessionID: note.sessionID) onAddressNoteChanged?(.noteRemoved(note)) collaboration.enqueueAddressNoteRemove(noteID: note.id) } public func appendUserMessage(noteID: UUID, body: String) -> AddressNoteMessage? { guard let note = try? store.fetchAddressNote(id: noteID) else { return nil } let existing = (try? store.fetchAddressNoteMessages(noteID: noteID)) ?? [] let nextIndex = (existing.last?.index ?? -1) + 1 let message = AddressNoteMessage( noteID: noteID, index: nextIndex, role: .user, bodyMarkdown: body ) try? store.save(message) bumpNoteUpdatedAt(note) onAddressNoteChanged?(.messageAppended(message)) collaboration.enqueueAddressNoteMessageAppend(message) return message } public func deleteAddressNoteMessage(_ message: AddressNoteMessage) { try? store.deleteAddressNoteMessage(id: message.id) if let note = try? store.fetchAddressNote(id: message.noteID) { bumpNoteUpdatedAt(note) } onAddressNoteChanged?(.messageRemoved(message)) collaboration.enqueueAddressNoteMessageRemove(noteID: message.noteID, messageID: message.id) } public func editUserMessage(noteID: UUID, messageID: UUID, body: String) -> AddressNoteMessage? { guard var message = try? store.fetchAddressNoteMessage(id: messageID), message.noteID == noteID, message.role == .user else { return nil } message.bodyMarkdown = body try? store.save(message) if let note = try? store.fetchAddressNote(id: noteID) { bumpNoteUpdatedAt(note) } onAddressNoteChanged?(.messageEdited(message)) collaboration.enqueueAddressNoteMessageEdit(noteID: noteID, messageID: messageID, bodyMarkdown: body) return message } public func addressNotes(sessionID: UUID, anchor: AddressAnchor? = nil) -> [AddressNote] { let all = (try? store.fetchAddressNotes(sessionID: sessionID)) ?? [] guard let anchor else { return all } return all.filter { $0.anchor == anchor } } public func addressNoteMessages(noteID: UUID) -> [AddressNoteMessage] { (try? store.fetchAddressNoteMessages(noteID: noteID)) ?? [] } private func bumpNoteUpdatedAt(_ note: AddressNote) { var updated = note updated.updatedAt = Date() try? store.save(updated) } public struct NoteReplyProgress: Equatable { public let noteID: UUID public var text: String } public func requestAIReply( noteID: UUID, providerID: String, modelID: String, onDelta: (@MainActor (String) -> Void)? = nil ) async -> AddressNoteMessage? { guard let sessionID = (try? store.fetchAddressNote(id: noteID))?.sessionID else { return nil } activeNoteReplyTasks[sessionID]?.cancel() let task = Task { @MainActor [weak self] in await self?.runNoteReply(noteID: noteID, providerID: providerID, modelID: modelID, onDelta: onDelta) ?? nil } activeNoteReplyTasks[sessionID] = task let reply = await task.value if activeNoteReplyTasks[sessionID] == task { activeNoteReplyTasks.removeValue(forKey: sessionID) } return reply } private func runNoteReply( noteID: UUID, providerID: String, modelID: String, onDelta: (@MainActor (String) -> Void)? ) async -> AddressNoteMessage? { guard let note = try? store.fetchAddressNote(id: noteID), let provider = llmRegistry.provider(id: providerID) else { return nil } let thread = (try? store.fetchAddressNoteMessages(noteID: noteID)) ?? [] guard !thread.isEmpty else { return nil } noteReplyProgress[note.sessionID] = NoteReplyProgress(noteID: noteID, text: "") defer { noteReplyProgress.removeValue(forKey: note.sessionID) } let liveDelta: @MainActor (String) -> Void = { [weak self] delta in self?.noteReplyProgress[note.sessionID]?.text += delta onDelta?(delta) } let mission = getOrCreateAmbientMission( sessionID: note.sessionID, providerID: providerID, modelID: modelID ) guard let mission else { return nil } let system = await buildAddressNoteSystemPrompt(note: note) var messages = thread.map { msg -> LLMMessage in let role: LLMRole = (msg.role == .assistant) ? .assistant : .user return LLMMessage(role: role, blocks: [.text(msg.bodyMarkdown)]) } let toolCallID = "n\(thread.count)" let argsJSON: String = { let payload: [String: Any] = [ "note_id": noteID.uuidString, "address_anchor": note.anchor.displayString, "thread_length": thread.count, ] let data = (try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])) ?? Data("{}".utf8) return String(data: data, encoding: .utf8) ?? "{}" }() var action = MissionAction( missionID: mission.id, turnID: nil, toolName: "address_note_reply", argsJSON: argsJSON, isObserve: true, sessionID: note.sessionID, toolCallID: toolCallID ) action.status = .running action.decidedAt = Date() try? store.save(action) collaboration.enqueueMissionAction(action) let apiKey = (try? await llmCredentials.apiKey(providerID: providerID)) ?? nil if provider.descriptor.capabilities.supports(.apiKey), apiKey == nil { action.status = .failed action.error = "missing API key for provider \(providerID)" action.completedAt = Date() try? store.save(action) collaboration.enqueueMissionAction(action) return nil } let tools = noteTurnToolSpecs() let baseURL = LumaAppState.shared.providerBaseURL(providerID: providerID).flatMap(URL.init(string:)) var responseText = "" var usage = LLMUsage.zero var iterationsRemaining = Self.noteTurnMaxIterations do { iterationLoop: while iterationsRemaining > 0 { iterationsRemaining -= 1 if Task.isCancelled { throw CancellationError() } let request = LLMTurnRequest( modelID: modelID, systemBlocks: [LLMContentBlock(content: .text(system), cacheBoundary: true)], messages: messages, tools: tools, maxOutputTokens: 1024, thinkingBudget: 0, temperature: 0.3, mission: mission ) let outcome = await streamNoteTurn( provider: provider, request: request, apiKey: apiKey, baseURL: baseURL, onDelta: liveDelta ) responseText += outcome.text usage.inputTokens += outcome.usage.inputTokens usage.outputTokens += outcome.usage.outputTokens usage.cacheReadTokens += outcome.usage.cacheReadTokens usage.cacheCreateTokens += outcome.usage.cacheCreateTokens if let error = outcome.error { throw error } let toolUses = outcome.blocks.filter { if case .toolUse = $0.content { return true } return false } if toolUses.isEmpty { break iterationLoop } messages.append(LLMMessage(role: .assistant, blocks: outcome.blocks)) let resultBlocks = await runNoteToolUses( toolUses, sessionID: note.sessionID, mission: mission ) messages.append(LLMMessage(role: .user, blocks: resultBlocks)) } } catch { action.status = .failed action.error = (error is CancellationError) ? "Reply cancelled by user." : error.localizedDescription action.completedAt = Date() try? store.save(action) collaboration.enqueueMissionAction(action) return nil } let trimmed = responseText.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { action.status = .failed action.error = "model returned empty response" action.completedAt = Date() try? store.save(action) collaboration.enqueueMissionAction(action) return nil } action.status = .succeeded action.resultJSON = trimmed action.resultSummary = String(trimmed.prefix(120)) action.completedAt = Date() try? store.save(action) collaboration.enqueueMissionAction(action) var updatedMission = mission updatedMission.tokensUsedInput += usage.inputTokens updatedMission.tokensUsedOutput += usage.outputTokens updatedMission.cacheReadTokens += usage.cacheReadTokens updatedMission.cacheCreateTokens += usage.cacheCreateTokens updatedMission.updatedAt = Date() try? store.save(updatedMission) collaboration.enqueueMissionUpsert(updatedMission) let nextIndex = (thread.last?.index ?? -1) + 1 let message = AddressNoteMessage( noteID: noteID, index: nextIndex, role: .assistant, bodyMarkdown: trimmed, providerID: providerID, modelID: modelID, actionID: action.id ) try? store.save(message) bumpNoteUpdatedAt(note) onAddressNoteChanged?(.messageAppended(message)) collaboration.enqueueAddressNoteMessageAppend(message) return message } private func getOrCreateAmbientMission( sessionID: UUID, providerID: String, modelID: String ) -> Mission? { var state = sessionUIStates[sessionID] ?? SessionUIState(sessionID: sessionID) if let existingID = state.ambientMissionID, let mission = try? store.fetchMission(id: existingID) { return mission } var mission = Mission( goalText: "Address notes for this session.", providerID: providerID, modelID: modelID, tokenBudgetInput: 0, tokenBudgetOutput: 0 ) mission.title = "Ambient — address notes" mission.status = .running try? store.save(mission) collaboration.enqueueMissionUpsert(mission) state.ambientMissionID = mission.id sessionUIStates[sessionID] = state try? store.save(state) return mission } public func ambientMissionID(sessionID: UUID) -> UUID? { sessionUIStates[sessionID]?.ambientMissionID } public func isAmbientMission(_ missionID: UUID) -> Bool { sessionUIStates.values.contains { $0.ambientMissionID == missionID } } private static let noteTurnMaxIterations = 6 private func noteTurnToolSpecs() -> [LLMToolSpec] { missionExecutor.catalog.toolSpecs() } private struct NoteTurnOutcome { var blocks: [LLMContentBlock] var text: String var usage: LLMUsage var error: (any Swift.Error)? } private func streamNoteTurn( provider: any LLMProvider, request: LLMTurnRequest, apiKey: String?, baseURL: URL?, onDelta: (@MainActor (String) -> Void)? ) async -> NoteTurnOutcome { var blocks: [LLMContentBlock] = [] var streamedText = "" var usage = LLMUsage.zero var capturedError: (any Swift.Error)? do { for try await event in provider.streamTurn(request, apiKey: apiKey, baseURL: baseURL) { switch event { case .textDelta(let delta): streamedText += delta onDelta?(delta) case .finalMessage(_, let finalBlocks): blocks = finalBlocks case .usage(let u): usage = u default: break } } } catch { capturedError = error } if blocks.isEmpty, !streamedText.isEmpty { blocks = [LLMContentBlock(content: .text(streamedText))] } let finalText = blocks.compactMap { block -> String? in if case .text(let t) = block.content { return t } return nil }.joined() let textForLoop = finalText.isEmpty ? streamedText : finalText return NoteTurnOutcome(blocks: blocks, text: textForLoop, usage: usage, error: capturedError) } private func runNoteToolUses( _ blocks: [LLMContentBlock], sessionID: UUID, mission: Mission ) async -> [LLMContentBlock] { var results: [LLMContentBlock] = [] for block in blocks { guard case .toolUse(let id, let name, let inputJSON) = block.content else { continue } let (json, isError) = await runNoteToolUse( name: name, inputJSON: inputJSON, toolCallID: id, sessionID: sessionID, mission: mission ) results.append(LLMContentBlock(content: .toolResult( toolUseID: id, contentJSON: json, isError: isError, attachments: [] ))) } return results } private func runNoteToolUse( name: String, inputJSON: String, toolCallID: String, sessionID: UUID, mission: Mission ) async -> (json: String, isError: Bool) { let args = (try? JSONSerialization.jsonObject(with: Data(inputJSON.utf8))) as? [String: Any] ?? [:] let spec = missionExecutor.catalog.spec(named: name) if spec?.isObserve == true { let invocation = ActionInvocation(args: args, mission: mission, sessionID: sessionID, toolCallID: toolCallID) do { let result = try await missionExecutor.catalog.execute(name, invocation: invocation) return (result.resultJSON, result.isError) } catch { return (#"{"error": "\#(error.localizedDescription)"}"#, true) } } let action = MissionAction( missionID: mission.id, turnID: nil, toolName: name, argsJSON: inputJSON, isObserve: false, sessionID: sessionID, toolCallID: toolCallID ) try? store.save(action) collaboration.enqueueMissionAction(action) let completed = await awaitActionTerminalStatus(actionID: action.id, missionID: mission.id) return noteToolResultPayload(for: completed) } private func awaitActionTerminalStatus(actionID: UUID, missionID: UUID) async -> MissionAction { if let cached = try? store.fetchMissionAction(id: actionID), isTerminal(cached.status) { return cached } let (stream, continuation) = AsyncStream.makeStream(of: MissionAction.self) let observation = store.observeMissionActions(missionID: missionID) { rows in if let action = rows.first(where: { $0.id == actionID }) { continuation.yield(action) } } let settled: MissionAction? = await withTaskCancellationHandler { var result: MissionAction? for await action in stream { if isTerminal(action.status) { result = action break } } continuation.finish() _ = observation return result } onCancel: { continuation.finish() } return settled ?? (try? store.fetchMissionAction(id: actionID)) ?? MissionAction( missionID: missionID, turnID: nil, toolName: "", argsJSON: "{}", isObserve: false, sessionID: nil ) } private func isTerminal(_ status: MissionActionStatus) -> Bool { switch status { case .succeeded, .failed, .rejected: return true default: return false } } private func noteToolResultPayload(for action: MissionAction) -> (json: String, isError: Bool) { switch action.status { case .rejected: let reason = action.rejectionReason ?? "User declined to run this tool." return (#"{"error": "\#(reason)"}"#, true) case .failed: return (action.resultJSON ?? #"{"error": "\#(action.error ?? "failed")"}"#, true) case .succeeded: return (action.resultJSON ?? "{}", false) default: return (#"{"error": "no result"}"#, true) } } private func buildAddressNoteSystemPrompt(note: AddressNote) async -> String { var lines = [ "You are an interactive reverse-engineering assistant pinned to one address. Be specific and concise. Lead with the conclusion; cite registers/strings/calls when relevant.", "You may call any tool. Observe (read-only) and auto-run tools execute immediately. Approval-gated tools queue in the global Action Queue popover and the user must approve them; your reply pauses until each one settles, so propose mutations only when they're clearly warranted, and always say in plain text what you're about to do before the call.", "Anchor: \(note.anchor.displayString)", "Session: \(note.sessionID.uuidString) — pass this as `session_id` to tools that require it.", "", MissionSystemPrompt.codeStyle, ] if let dis = disassembler(forSessionID: note.sessionID), let node = node(forSessionID: note.sessionID), let address = try? node.resolveSyncIfReady(note.anchor) { let matchingInsights = (insightsBySession[note.sessionID] ?? []).filter { $0.lastResolvedAddress == address } let named = matchingInsights.first(where: { hasUserTitle($0) }) ?? matchingInsights.first if let named { lines.append("Insight: \"\(displayTitle(for: named))\" — refer to this address by that name.") } let lines64 = await dis.disassemble(DisassemblyRequest(address: address, count: 32, appearance: .light)) let disasm = lines64.map { String(format: "0x%llx", $0.address) + " " + $0.asmText.plainText }.joined(separator: "\n") let pdc = await dis.decompile(at: address).output ?? "" lines.append("") lines.append("Disassembly window:") lines.append(disasm) lines.append("") lines.append("Pseudo-C:") lines.append(pdc) } return lines.joined(separator: "\n") } /// Move `entry` into the slot between `previous` and `next` (either may /// be nil to mean "no neighbor on that side"). Computes a position as /// the midpoint of the two neighbors' positions; if the slot is at an /// edge, extends beyond by a single step. public func reorderNotebookEntry( _ entry: NotebookEntry, between previous: NotebookEntry?, and next: NotebookEntry? ) { let position: Double switch (previous?.position, next?.position) { case let (p?, n?): position = (p + n) / 2 case let (p?, nil): position = p + Self.notebookPositionStep case let (nil, n?): position = n - Self.notebookPositionStep case (nil, nil): position = Self.notebookPositionStep } var updated = entry updated.position = position try? store.save(updated) if let i = notebookEntries.firstIndex(where: { $0.id == entry.id }) { notebookEntries[i] = updated } onNotebookChanged?(.reordered) collaboration.enqueueReorder(entryID: entry.id, position: position) } public func bindCollaborationCallbacks() { collaboration.onAuthRejected = { [weak self] _ in await self?.gitHubAuth.signOut() } collaboration.onCustomInstrumentOpReceived = { [weak self] op in guard let self else { return } switch op { case .upsert(let u): guard var bundle = try? Self.validatedCustomInstrumentBundle(u.bundle) else { return } bundle.def.normalize() try? self.store.save(bundle.def) try? self.store.replaceCustomInstrumentFiles(defID: bundle.def.id, files: bundle.files) self.scheduleCustomInstrumentReload(defID: bundle.def.id) case .remove(let r): try? self.store.deleteCustomInstrumentDef(id: r.defID) } } collaboration.onMissionOpReceived = { [weak self] op in self?.applyRemoteMissionOp(op) } collaboration.onAddressNoteOpReceived = { [weak self] op in self?.applyRemoteAddressNoteOp(op) } collaboration.onMissionSnapshot = { [weak self] snapshot in self?.applyRemoteMissionSnapshot(snapshot) } collaboration.onCustomInstrumentSnapshot = { [weak self] bundles in guard let self else { return } let serverIDs = Set(bundles.map(\.def.id)) let local = (try? self.store.fetchCustomInstrumentDefs()) ?? [] for stale in local where !serverIDs.contains(stale.id) { try? self.store.deleteCustomInstrumentDef(id: stale.id) } let normalized = bundles.map { bundle -> CustomInstrumentBundle in var copy = bundle copy.def.normalize() return copy } for bundle in normalized { try? self.store.save(bundle.def) try? self.store.replaceCustomInstrumentFiles(defID: bundle.def.id, files: bundle.files) } for bundle in normalized { self.scheduleCustomInstrumentReload(defID: bundle.def.id) } } collaboration.onNotebookSnapshot = { [weak self] entries in guard let self else { return } // Snapshot is authoritative: replace local state with it. Any // locally-made mutations that aren't reflected here still live // in the outbox and will replay once status flips to .joined. let existingIDs = self.notebookEntries.map(\.id) for id in existingIDs { try? self.store.deleteNotebookEntry(id: id) } self.notebookEntries.removeAll() for entry in entries { try? self.store.save(entry) self.notebookEntries.append(entry) } self.onNotebookChanged?(.snapshot(entries)) } collaboration.onEntryUpserted = { [weak self] entry in guard let self else { return } let existed = (try? self.store.fetchNotebookEntry(id: entry.id)) != nil try? self.store.save(entry) if let i = self.notebookEntries.firstIndex(where: { $0.id == entry.id }) { self.notebookEntries[i] = entry } else { self.notebookEntries.append(entry) } self.onNotebookChanged?(existed ? .updated(entry, changed: .all) : .added(entry)) } collaboration.onEntryRemoved = { [weak self] id in try? self?.store.deleteNotebookEntry(id: id) self?.notebookEntries.removeAll { $0.id == id } self?.onNotebookChanged?(.removed(id)) } collaboration.onEntryRepositioned = { [weak self] id, position in guard let self else { return } guard var existing = try? self.store.fetchNotebookEntry(id: id) else { return } existing.position = position try? self.store.save(existing) if let i = self.notebookEntries.firstIndex(where: { $0.id == id }) { self.notebookEntries[i] = existing } self.onNotebookChanged?(.reordered) } collaboration.onSessionsSnapshot = { [weak self] sessions in guard let self else { return } self.applySessionsSnapshot(sessions) } collaboration.onSessionAdded = { [weak self] session in self?.adoptRemoteSession(session) } collaboration.onSessionPhaseChanged = { [weak self] sessionID, phase, reason in self?.applyRemoteSessionPhase(sessionID: sessionID, phase: phase, reason: reason) } collaboration.onSessionArmingChanged = { [weak self] sessionID, armingState in self?.applyRemoteSessionArming(sessionID: sessionID, armingState: armingState) } collaboration.onSessionModulesUpdated = { [weak self] sessionID, delta in self?.applyRemoteSessionModules(sessionID: sessionID, delta: delta) } collaboration.onSessionThreadsUpdated = { [weak self] sessionID, delta in self?.applyRemoteSessionThreads(sessionID: sessionID, delta: delta) } collaboration.onSessionHostChanged = { [weak self] sessionID, host, deviceID, deviceName, pid, processName in self?.applyRemoteSessionHostChange( sessionID: sessionID, host: host, deviceID: deviceID, deviceName: deviceName, pid: pid, processName: processName ) } collaboration.onSessionReplCellAdded = { [weak self] sessionID, cell in self?.applyRemoteSessionReplCell(sessionID: sessionID, cell: cell) } collaboration.onSessionReplEvalRequested = { [weak self] sessionID, code, language, cellID in self?.handleRemoteReplEvalRequest(sessionID: sessionID, code: code, language: language, cellID: cellID) } collaboration.onSessionInstrumentAdded = { [weak self] sessionID, instance in self?.applyRemoteSessionInstrumentAdded(sessionID: sessionID, instance: instance) } collaboration.onSessionInstrumentUpdated = { [weak self] sessionID, instance in self?.applyRemoteSessionInstrumentUpdated(sessionID: sessionID, instance: instance) } collaboration.onSessionInstrumentRemoved = { [weak self] sessionID, instanceID in self?.applyRemoteSessionInstrumentRemoved(sessionID: sessionID, instanceID: instanceID) } collaboration.onSessionInstrumentSetStateRequested = { [weak self] sessionID, instanceID, state in self?.handleRemoteInstrumentSetStateRequest(sessionID: sessionID, instanceID: instanceID, state: state) } collaboration.onSessionInstrumentRemoveRequested = { [weak self] sessionID, instanceID in self?.handleRemoteInstrumentRemoveRequest(sessionID: sessionID, instanceID: instanceID) } collaboration.onSessionInstrumentAddRequested = { [weak self] sessionID, kind, sourceIdentifier, configJSON in self?.handleRemoteInstrumentAddRequest( sessionID: sessionID, kind: kind, sourceIdentifier: sourceIdentifier, configJSON: configJSON ) } collaboration.onSessionInstrumentUpdateConfigRequested = { [weak self] sessionID, instanceID, configJSON in self?.handleRemoteInstrumentUpdateConfigRequest(sessionID: sessionID, instanceID: instanceID, configJSON: configJSON) } collaboration.onSessionInsightAdded = { [weak self] sessionID, insight in self?.applyRemoteSessionInsightAdded(sessionID: sessionID, insight: insight) } collaboration.onSessionInsightUpdated = { [weak self] sessionID, insight in self?.applyRemoteSessionInsightUpdated(sessionID: sessionID, insight: insight) } collaboration.onSessionInsightRemoved = { [weak self] sessionID, insightID in self?.applyRemoteSessionInsightRemoved(sessionID: sessionID, insightID: insightID) } collaboration.onSessionProcessInfoUpdated = { [weak self] sessionID, info in self?.applyRemoteSessionProcessInfo(sessionID: sessionID, info: info) } collaboration.onSessionModuleAnalysisUpdated = { [weak self] sessionID, analysis in self?.applyRemoteSessionModuleAnalysis(sessionID: sessionID, analysis: analysis) } collaboration.onSessionModuleSymbolsUpdated = { [weak self] sessionID, entries in self?.applyRemoteSessionModuleSymbols(sessionID: sessionID, entries: entries) } collaboration.onSessionTraceUpserted = { [weak self] sessionID, trace in self?.applyRemoteSessionTraceUpserted(sessionID: sessionID, trace: trace) } collaboration.onSessionTraceRemoved = { [weak self] sessionID, traceID in self?.applyRemoteSessionTraceRemoved(sessionID: sessionID, traceID: traceID) } collaboration.onSessionTraceDataProgressed = { [weak self] sessionID, traceID, totalSize in self?.applyRemoteTraceDataProgressed(sessionID: sessionID, traceID: traceID, totalSize: totalSize) } collaboration.onSessionEventReceived = { [weak self] sessionID, event in self?.applyRemoteSessionEvent(sessionID: sessionID, event: event) } collaboration.onSessionWidgetUpdateReceived = { [weak self] sessionID, update in self?.applyWidgetUpdate(update, sessionID: sessionID, origin: .remote) } collaboration.onSessionWidgetActionRequested = { [weak self] sessionID, instanceID, widget, action, item in self?.handleRemoteWidgetActionRequest( sessionID: sessionID, instanceID: instanceID, widget: widget, action: action, item: item ) } collaboration.onWidgetStatesSnapshot = { [weak self] snapshots in self?.applyWidgetStateSnapshots(snapshots) } collaboration.onReplEvalTimedOut = { [weak self] cellID in self?.markReplCellTimedOut(cellID: cellID) } collaboration.onSessionRemoved = { [weak self] sessionID in self?.applyRemoteSessionRemoval(sessionID: sessionID) } } private func markReplCellTimedOut(cellID: UUID) { guard let cell = try? store.fetchREPLCell(id: cellID), case .text(let text) = cell.result, text == "Running…" else { return } var updated = cell updated.result = .text("Timed out — host did not respond.") try? store.save(updated) onREPLCellAdded?(updated) } private func applyRemoteSessionEvent(sessionID: UUID, event: RuntimeEvent) { var stamped = event stamped.sessionID = sessionID _events.yield(stamped) } private func applyRemoteSessionTraceUpserted(sessionID: UUID, trace: ITrace) { var stored = trace stored.sessionID = sessionID try? store.save(stored) onSessionListChanged?(.traceUpdated(stored)) } private func applyRemoteSessionTraceRemoved(sessionID: UUID, traceID: UUID) { try? store.deleteITrace(id: traceID) blobs.delete(id: traceID, kind: .trace) onSessionListChanged?(.traceRemoved(id: traceID, sessionID: sessionID)) } private func applyRemoteTraceDataProgressed(sessionID: UUID, traceID: UUID, totalSize: Int) { let cachedSize = blobs.size(id: traceID, kind: .trace) ?? 0 guard cachedSize < totalSize else { return } _traceCacheInvalidations.yield(TraceCacheInvalidation(traceID: traceID, knownTotalSize: totalSize)) } private func applyRemoteSessionInsightAdded(sessionID: UUID, insight: AddressInsight) { var stored = insight stored.sessionID = sessionID persistInsight(stored) onSessionListChanged?(.insightAdded(stored)) } private func applyRemoteSessionInsightUpdated(sessionID: UUID, insight: AddressInsight) { var stored = insight stored.sessionID = sessionID persistInsight(stored) applyInsightNameToR2(stored) emitInsightUpdated(stored) } private func applyRemoteSessionInsightRemoved(sessionID: UUID, insightID: UUID) { try? store.deleteInsight(id: insightID) for (sid, list) in insightsBySession { insightsBySession[sid] = list.filter { $0.id != insightID } } onSessionListChanged?(.insightRemoved(id: insightID, sessionID: sessionID)) } private func applyRemoteSessionProcessInfo(sessionID: UUID, info: ProcessSession.ProcessInfo) { try? store.deleteMemoryPagesAnon(sessionID: sessionID, exceptIdentity: info.identity) updateSession(id: sessionID) { $0.processInfo = info } } private func applyRemoteSessionModuleAnalysis(sessionID: UUID, analysis: ModuleAnalysis) { var stored = analysis stored.sessionID = sessionID try? store.save(stored) } private func applyRemoteSessionModuleSymbols(sessionID: UUID, entries: [SessionOp.UpdateModuleSymbols.Entry]) { for entry in entries { try? store.upsertModuleSymbol( sessionID: sessionID, modulePath: entry.modulePath, offset: entry.offset, name: entry.name ) } } private func applyRemoteSessionInstrumentAdded(sessionID: UUID, instance: InstrumentInstance) { var stored = instance stored.sessionID = sessionID try? store.save(stored) onSessionListChanged?(.instrumentAdded(stored)) } private func applyRemoteSessionInstrumentUpdated(sessionID: UUID, instance: InstrumentInstance) { var stored = instance stored.sessionID = sessionID try? store.save(stored) onSessionListChanged?(.instrumentUpdated(stored)) } private func applyRemoteSessionInstrumentRemoved(sessionID: UUID, instanceID: UUID) { try? store.deleteInstrument(id: instanceID) onSessionListChanged?(.instrumentRemoved(id: instanceID, sessionID: sessionID)) } public func localUserHosts(_ sessionID: UUID) -> Bool { guard let session = sessions.first(where: { $0.id == sessionID }) else { return false } return localUserHosts(session) } public func localUserHosts(_ session: ProcessSession) -> Bool { guard let host = session.host, let localID = localUserID else { return true } return host.id == localID } public func isHostingNode(_ sessionID: UUID) -> Bool { node(forSessionID: sessionID) != nil } public func isHostedRemotelyLive(_ sessionID: UUID) -> Bool { guard let session = sessions.first(where: { $0.id == sessionID }) else { return false } guard session.host != nil, !isHostingNode(sessionID) else { return false } return session.phase == .attached || session.phase == .attaching } public var canHostNewSessions: Bool { !collaboration.isCollaborative || collaboration.isOwner } public func canTakeHosting(_ session: ProcessSession) -> Bool { guard collaboration.isCollaborative else { return true } guard case .joined = collaboration.status, collaboration.isOwner else { return false } if localUserHosts(session) { return true } return !isHostedRemotelyLive(session.id) } private func emitEngineError(subsystem: String, text: String) { emitEngineEvent(subsystem: subsystem, level: .error, text: text) } private func emitEngineWarning(subsystem: String, text: String) { emitEngineEvent(subsystem: subsystem, level: .warning, text: text) } private func emitEngineEvent(subsystem: String, level: ConsoleLevel, text: String) { _events.yield(RuntimeEvent( source: .engine(subsystem: subsystem), payload: .consoleMessage(ConsoleMessage(level: level, values: [.string(text)])) )) } private func broadcastInstrumentStatus(instanceID: UUID, sessionID: UUID) { guard let instance = try? store.fetchInstrument(id: instanceID) else { return } onSessionListChanged?(.instrumentUpdated(instance)) if isHostingNode(sessionID) { broadcastInstrumentUpdate(instance) } } private func broadcastInstrumentUpdate(_ instance: InstrumentInstance) { guard isHostingNode(instance.sessionID) else { return } let status = node(forSessionID: instance.sessionID)?.instruments.first(where: { $0.id == instance.id })?.status collaboration.enqueueUpdateInstrument(sessionID: instance.sessionID, instance: instance, runtimeStatus: status) } private func handleRemoteReplEvalRequest(sessionID: UUID, code: String, language: REPLLanguage, cellID: UUID) { guard let node = processNodes.first(where: { $0.sessionID == sessionID }) else { return } Task { @MainActor in await node.evalInREPL(code, language: language, cellID: cellID) } } private func handleRemoteInstrumentSetStateRequest( sessionID: UUID, instanceID: UUID, state: InstrumentState ) { guard isHostingNode(sessionID), let instance = try? store.fetchInstrument(id: instanceID) else { return } Task { @MainActor in await setInstrumentState(instance, state: state) } } private func handleRemoteInstrumentRemoveRequest(sessionID: UUID, instanceID: UUID) { guard isHostingNode(sessionID), let instance = try? store.fetchInstrument(id: instanceID) else { return } Task { @MainActor in await removeInstrument(instance) } } private func handleRemoteInstrumentAddRequest( sessionID: UUID, kind: InstrumentKind, sourceIdentifier: String, configJSON: Data ) { guard isHostingNode(sessionID) else { return } Task { @MainActor in _ = await addInstrument( kind: kind, sourceIdentifier: sourceIdentifier, configJSON: configJSON, sessionID: sessionID ) } } private func handleRemoteInstrumentUpdateConfigRequest( sessionID: UUID, instanceID: UUID, configJSON: Data ) { guard isHostingNode(sessionID), let instance = try? store.fetchInstrument(id: instanceID) else { return } Task { @MainActor in await applyInstrumentConfig(instance, configJSON: configJSON) } } private func applySessionsSnapshot(_ snapshot: [CollaborationSession.Session]) { let serverKnown = Set(snapshot.map(\.id)) for cached in sessions where !localUserHosts(cached) && !serverKnown.contains(cached.id) { applyRemoteSessionRemoval(sessionID: cached.id) } for session in snapshot { adoptRemoteSession(session) } markOrphanedHostsDetached(serverKnown: serverKnown) announceMissingLocalSessions(serverKnown: serverKnown) } private func markOrphanedHostsDetached(serverKnown: Set) { for cached in sessions where localUserHosts(cached) && !isHostingNode(cached.id) && (cached.phase == .attached || cached.phase == .attaching) { let sid = cached.id updateSession(id: sid) { $0.phase = .idle } if serverKnown.contains(sid) { collaboration.enqueueUpdateSessionPhase(sessionID: sid, phase: .detached) } } } private func announceMissingLocalSessions(serverKnown: Set) { guard collaboration.isOwner, let localUser = collaboration.localUser else { return } for session in sessions where session.host == nil && !serverKnown.contains(session.id) { var promoted = session promoted.host = localUser saveSession(promoted) collaboration.enqueueAddSession( sessionID: session.id, deviceID: session.deviceID, deviceName: session.deviceName, pid: session.lastKnownPID, processName: session.processName, createdAt: ISO8601DateFormatter().string(from: session.createdAt) ) announceLocalSessionChildren(sessionID: session.id) } } private func announceLocalSessionChildren(sessionID: UUID) { for cell in (try? store.fetchREPLCells(sessionID: sessionID)) ?? [] { collaboration.enqueueAddReplCell(sessionID: sessionID, cell: cell) } for instance in (try? store.fetchInstruments(sessionID: sessionID)) ?? [] { let status = node(forSessionID: sessionID)?.instruments.first(where: { $0.id == instance.id })?.status collaboration.enqueueAddInstrument(sessionID: sessionID, instance: instance, runtimeStatus: status) } for insight in (try? store.fetchInsights(sessionID: sessionID)) ?? [] { collaboration.enqueueAddInsight(sessionID: sessionID, insight: insight) } for trace in (try? store.fetchITraces(sessionID: sessionID)) ?? [] { collaboration.enqueueUpsertTrace(sessionID: sessionID, trace: trace) Task { @MainActor [weak self] in await self?.uploadTraceDelta(trace, collabSessionID: sessionID) } } } private func adoptRemoteSession(_ session: CollaborationSession.Session) { if let idx = sessions.firstIndex(where: { $0.id == session.id }) { var record = sessions[idx] record.host = session.host record.phase = session.phase.toProcessSessionPhase record.armingState = session.armingState record.processName = session.processName record.deviceID = session.deviceID record.deviceName = session.deviceName record.lastKnownPID = session.pid if !session.modules.isEmpty { record.lastKnownModules = session.modules } if !session.threads.isEmpty { record.lastKnownThreads = session.threads } if let info = session.processInfo { record.processInfo = info try? store.deleteMemoryPagesAnon(sessionID: session.id, exceptIdentity: info.identity) } saveSession(record) for analysis in session.moduleAnalyses { var stored = analysis stored.sessionID = session.id try? store.save(stored) } for entry in session.moduleSymbols { try? store.upsertModuleSymbol( sessionID: session.id, modulePath: entry.modulePath, offset: entry.offset, name: entry.name ) } } else { var record = ProcessSession( id: session.id, kind: .attach, host: session.host, deviceID: session.deviceID, deviceName: session.deviceName, processName: session.processName, lastKnownPID: session.pid, armingState: session.armingState ) record.phase = session.phase.toProcessSessionPhase record.lastKnownModules = session.modules record.lastKnownThreads = session.threads try? store.save(record) sessions.insert(record, at: 0) onSessionListChanged?(.sessionAdded(record)) } for cell in session.replCells { var stored = cell stored.sessionID = session.id try? store.save(stored) onREPLCellAdded?(stored) } for inst in session.instruments { var stored = inst stored.sessionID = session.id try? store.save(stored) onSessionListChanged?(.instrumentAdded(stored)) } for insight in session.insights { var stored = insight stored.sessionID = session.id try? store.save(stored) onSessionListChanged?(.insightAdded(stored)) } for trace in session.traces { var stored = trace stored.sessionID = session.id try? store.save(stored) onSessionListChanged?(.traceUpdated(stored)) } } private func applyRemoteSessionPhase( sessionID: UUID, phase: CollaborationSession.Session.Phase, reason: String? ) { guard let idx = sessions.firstIndex(where: { $0.id == sessionID }) else { return } var updated = sessions[idx] updated.phase = phase.toProcessSessionPhase saveSession(updated) } private func applyRemoteSessionArming( sessionID: UUID, armingState: ProcessSession.ArmingState ) { guard let idx = sessions.firstIndex(where: { $0.id == sessionID }) else { return } var updated = sessions[idx] updated.armingState = armingState saveSession(updated) } private func applyRemoteSessionModules(sessionID: UUID, delta: ModuleDelta) { guard let idx = sessions.firstIndex(where: { $0.id == sessionID }) else { return } var updated = sessions[idx] updated.lastKnownModules = delta.applied(to: updated.lastKnownModules) saveSession(updated) } private func applyRemoteSessionThreads(sessionID: UUID, delta: ThreadDelta) { guard let idx = sessions.firstIndex(where: { $0.id == sessionID }) else { return } var updated = sessions[idx] updated.lastKnownThreads = delta.applied(to: updated.lastKnownThreads) saveSession(updated) } private func applyRemoteSessionHostChange( sessionID: UUID, host: CollaborationSession.UserInfo, deviceID: String, deviceName: String, pid: UInt, processName: String ) { let wasMyHost = sessions.first(where: { $0.id == sessionID })?.host?.id == collaboration.localUser?.id let isMyHost = host.id == collaboration.localUser?.id if wasMyHost && !isMyHost, let node = node(forSessionID: sessionID) { removeNode(node) } guard let idx = sessions.firstIndex(where: { $0.id == sessionID }) else { return } var updated = sessions[idx] updated.host = host updated.deviceID = deviceID updated.deviceName = deviceName updated.processName = processName updated.lastKnownPID = pid updated.lastKnownMainModule = nil updated.lastKnownModules = nil updated.lastKnownThreads = nil saveSession(updated) } private func applyRemoteSessionReplCell(sessionID: UUID, cell: REPLCell) { var stored = cell stored.sessionID = sessionID try? store.save(stored) onREPLCellAdded?(stored) } private func applyRemoteSessionRemoval(sessionID: UUID) { guard sessions.contains(where: { $0.id == sessionID }) else { return } if let node = processNodes.first(where: { $0.sessionID == sessionID }) { removeNode(node) } try? store.deleteSession(id: sessionID) sessions.removeAll { $0.id == sessionID } onSessionListChanged?(.sessionRemoved(sessionID)) } public func start() async { if let eventStore { let restored = await eventStore.loadAll() if !restored.isEmpty { eventLog.restore(restored) } } if let loaded = try? store.fetchSessions() { for var session in loaded where session.phase != .idle { session.phase = .idle try? store.save(session) } } virtualMachines.load() #if canImport(Network) || canImport(CSoup) purgeExternalMCPMissions() #endif notebookEntries = (try? store.fetchNotebookEntries()) ?? [] sessions = (try? store.fetchSessions()) ?? [] for session in sessions { rebuildAddressAnnotations(sessionID: session.id) } projectUIState = (try? store.fetchProjectUIState()) ?? ProjectUIState() sessionUIStates = (try? store.fetchAllSessionUIStates()) ?? [:] customInstrumentDefUIStates = (try? store.fetchAllCustomInstrumentDefUIStates()) ?? [:] sessionsObservation = store.observeSessions { [weak self] sessions in Task { @MainActor in self?.sessions = sessions } } notebookObservation = store.observeNotebookEntries { [weak self] entries in Task { @MainActor in self?.notebookEntries = entries } } instrumentsObservation = store.observeAllInstruments { [weak self] grouped in Task { @MainActor in self?.instrumentsBySession = grouped self?.hydrateWidgetStatesFromStore(grouped) } } insightsObservation = store.observeAllInsights { [weak self] grouped in Task { @MainActor in self?.adoptInsights(grouped) } } tracesObservation = store.observeAllITraces { [weak self] grouped in Task { @MainActor in self?.tracesBySession = grouped } } packagesObservation = store.observeInstalledPackages { [weak self] packages in Task { @MainActor in self?.installedPackages = packages self?.onInstalledPackagesChanged?(packages) } } await loadRemoteDevices() watchDevicesForGating() if let labID = CollaborationJoinQueue.shared.consumeNext() { startCollaboration(joiningLab: labID) } } public func shutdown() async { guard !hasShutDown else { return } hasShutDown = true eventLogTask?.cancel() eventLogTask = nil eventLog.flushNow() await eventStoreFlushTail.value deviceChangeWatcher?.cancel() deviceChangeWatcher = nil for task in deviceEventTasks.values { task.cancel() } deviceEventTasks.removeAll() #if canImport(Network) || canImport(CSoup) for server in activeMCPServersByMissionID.values { server.stop() } activeMCPServersByMissionID.removeAll() externalMCPServer?.stop() externalMCPServer = nil externalMCPURL = nil externalMCPMissionID = nil #endif sessionsObservation = nil notebookObservation = nil instrumentsObservation = nil insightsObservation = nil tracesObservation = nil missionsObservation = nil packagesObservation = nil for node in processNodes { node.stop() } processNodes.removeAll() await virtualMachines.stopAll() for service in drainServices.values { await service.shutdown() } drainServices.removeAll() await collaboration.stop() } @ObservationIgnored private var hasShutDown = false public func clearEventLog() { eventLog.clear() if let eventStore { Task { await eventStore.clear() } } } private func watchDevicesForGating() { deviceChangeWatcher?.cancel() deviceChangeWatcher = Task { @MainActor [weak self] in guard let self else { return } for await snapshot in await self.deviceManager.snapshots() { await self.systemParameters.retain(deviceIDs: Set(snapshot.map(\.id))) for device in snapshot { self.ensureDeviceEventsHooked(for: device) await self.evaluateGating(forDeviceID: device.id) } } } } private func loadRemoteDevices() async { for config in (try? store.fetchRemoteDevices()) ?? [] { do { _ = try await deviceManager.addRemoteDevice( address: config.address, certificate: config.certificate, origin: config.origin, token: config.token, keepaliveInterval: config.keepaliveInterval ) } catch { emitEngineError(subsystem: "devices", text: "Failed to add remote device \(config.address): \(userFacingMessage(error))") } } } public func installPackage( name: String, versionSpec: String? = nil, globalAlias: String? = nil ) async throws -> InstalledPackage { let paths = try compilerWorkspacePaths() let installed = try await compilerWorkspace.installPackage( name: name, versionSpec: versionSpec, globalAlias: globalAlias, paths: paths ) await propagatePackage(installed) return installed } public func typeScriptProject(for profile: EditorProfile) async throws -> TypeScriptProject { let key = TypeScriptProjectKey(languageId: profile.languageId, ambientDeclarations: profile.ambientDeclarations) if let starting = typeScriptProjects[key] { return try await starting.value } let starting = Task { @MainActor in let paths = try compilerWorkspacePaths() let root = try await compilerWorkspace.ensureReady(paths: paths) let project = TypeScriptProject(root: root) try await project.start(ambientDeclarations: profile.ambientDeclarations) return project } typeScriptProjects[key] = starting do { return try await starting.value } catch { typeScriptProjects[key] = nil throw error } } public func upgradePackage(_ package: InstalledPackage) async throws -> InstalledPackage { try await installPackage( name: package.name, versionSpec: nil, globalAlias: package.globalAlias ) } public func removePackage(_ package: InstalledPackage) async throws { let paths = try compilerWorkspacePaths() try await compilerWorkspace.removePackage(package, paths: paths) await recompileAttachedInstruments() } public func loadAllPackages(on node: ProcessNode) async { do { let paths = try compilerWorkspacePaths() let bundles = try await compilerWorkspace.currentPackageBundlesForAgent(paths: paths) guard !bundles.isEmpty else { return } try await node.loadPackages(bundles) for entry in bundles { node.loadedPackageNames.insert(entry["name"] as! String) } } catch { emitEngineError(subsystem: "packages", text: "Failed to load packages: \(userFacingMessage(error))") } } public func loadPackage(_ package: InstalledPackage, on node: ProcessNode) async { if node.loadedPackageNames.contains(package.name) { return } do { let paths = try compilerWorkspacePaths() let bundles = try await compilerWorkspace.currentPackageBundlesForAgent(paths: paths) guard let entry = bundles.first(where: { ($0["name"] as? String) == package.name }) else { return } try await node.loadPackages([entry]) node.loadedPackageNames.insert(package.name) } catch { var message = "Failed to load package \(package.name): \(userFacingMessage(error))" if let compile = error as? CompileFailure, !compile.diagnostics.isEmpty { message += "\n\n" + compile.diagnostics.map(\.description).joined(separator: "\n") } emitEngineError(subsystem: "packages", text: message) } } private func propagatePackage(_ package: InstalledPackage) async { for node in processNodes { await loadPackage(package, on: node) } await recompileAttachedInstruments() } private func recompileAttachedInstruments() async { for node in processNodes { let attached = node.instruments.filter { $0.attachment == .attached } guard !attached.isEmpty else { continue } let instances = (try? store.fetchInstruments(sessionID: node.sessionID)) ?? [] for ref in attached { guard let instance = instances.first(where: { $0.id == ref.id }) else { continue } try? await node.disposeInstrumentOnAgent(instanceID: instance.id) node.markInstrumentDetached(id: instance.id) await loadInstrumentOnNode( instanceID: instance.id, kind: instance.kind, sourceIdentifier: instance.sourceIdentifier, configJSON: instance.configJSON, node: node, sessionID: node.sessionID ) } } } public func descriptor(withID id: String) -> InstrumentDescriptor? { descriptorsByID[id] } public func registerDescriptor(_ descriptor: InstrumentDescriptor) { if let idx = descriptors.firstIndex(where: { $0.id == descriptor.id }) { descriptors[idx] = descriptor } else { descriptors.append(descriptor) } descriptorsByID[descriptor.id] = descriptor } public func reloadHookPacks() { hookPacks.reload() descriptors.removeAll { $0.kind == .hookPack } for key in descriptorsByID.keys where key.hasPrefix("hook-pack:") { descriptorsByID.removeValue(forKey: key) } for desc in hookPacks.descriptors() { registerDescriptor(desc) } } public func descriptor(for instance: InstrumentInstance) -> InstrumentDescriptor { switch instance.kind { case .tracer: return descriptorsByID["tracer"] ?? Self.missingDescriptor(for: instance) case .hookPack: return descriptorsByID["hook-pack:\(instance.sourceIdentifier)"] ?? Self.missingDescriptor(for: instance) case .codeShare: return descriptorsByID["codeshare:\(instance.sourceIdentifier)"] ?? makeCodeShareDescriptor(for: instance) ?? Self.missingDescriptor(for: instance) case .custom: return descriptorsByID["custom:\(instance.sourceIdentifier)"] ?? Self.missingDescriptor(for: instance) } } private static func missingDescriptor(for instance: InstrumentInstance) -> InstrumentDescriptor { InstrumentDescriptor( id: "missing:\(instance.id.uuidString)", kind: instance.kind, sourceIdentifier: instance.sourceIdentifier, displayName: "Missing instrument", icon: .symbolic("puzzle"), makeInitialConfigJSON: { Data("{}".utf8) } ) } private func refreshCustomInstrumentDescriptors() { descriptors.removeAll { $0.kind == .custom } for key in descriptorsByID.keys where key.hasPrefix("custom:") { descriptorsByID.removeValue(forKey: key) } for desc in customInstruments.descriptors() { registerDescriptor(desc) } onSessionListChanged?(.descriptorsChanged) } private func makeCodeShareDescriptor(for instance: InstrumentInstance) -> InstrumentDescriptor? { guard let cfg = try? JSONDecoder().decode(CodeShareConfig.self, from: instance.configJSON) else { return nil } return InstrumentDescriptor( id: "codeshare:\(instance.sourceIdentifier)", kind: .codeShare, sourceIdentifier: instance.sourceIdentifier, displayName: cfg.name, icon: .symbolic("cloud"), makeInitialConfigJSON: { try! JSONEncoder().encode(cfg) } ) } public static let tracerDescriptor = InstrumentDescriptor( id: "tracer", kind: .tracer, sourceIdentifier: "builtin.tracer", displayName: "Tracer", icon: .symbolic("branch"), makeInitialConfigJSON: { try! JSONEncoder().encode(TracerConfig()) }, summarizeEvent: { event in String(describing: event.payload) } ) public func prepareSpawnSession( device: Device, config: SpawnConfig, reusing existing: ProcessSession? = nil ) -> ProcessSession { var session = existing ?? ProcessSession(kind: .spawn(config), deviceID: device.id, deviceName: device.name, processName: config.defaultDisplayName, lastKnownPID: 0) session.kind = .spawn(config) session.deviceID = device.id session.deviceName = device.name session.processName = config.defaultDisplayName session.phase = .attaching if session.iconPNGData == nil, case .application(_, _, let iconPNGData) = config.target { session.iconPNGData = iconPNGData } surfaceSession(session, isNew: existing == nil) return session } public func prepareAttachSession( device: Device, process: ProcessDetails, reusing existing: ProcessSession? = nil ) -> ProcessSession { var session = existing ?? ProcessSession(kind: .attach, deviceID: device.id, deviceName: device.name, processName: process.name, lastKnownPID: process.pid) session.deviceID = device.id session.deviceName = device.name session.processName = process.name session.lastKnownPID = process.pid session.phase = .attaching session.adoptIcon(from: process) surfaceSession(session, isNew: existing == nil) return session } @discardableResult public func spawnAndAttach( device: Device, session: ProcessSession ) async throws -> ProcessSession { guard case .spawn(let config) = session.kind else { fatalError("spawnAndAttach requires a spawn session") } var s = session s.phase = .attaching s.detachReason = .applicationRequested s.lastError = nil saveSession(s) ensureDeviceEventsHooked(for: device) do { let pid = try await device.spawn( program: config.programString, argv: config.argvParam, envp: nil, env: config.envParam, cwd: config.cwdParam, stdio: config.stdio ) let procs = try await device.enumerateProcesses(pids: [pid], scope: .full) guard let process = procs.first else { throw EngineError.spawnedProcessNotFound(pid: pid) } s.deviceName = device.name saveSession(s) try await performAttach( device: device, process: process, session: s ) s = (try? store.fetchSession(id: s.id)) ?? s if config.autoResume { try await device.resume(pid: pid) s.phase = .attached } else { s.phase = .awaitingInitialResume } saveSession(s) return s } catch { s.lastError = error.localizedDescription s.phase = .idle saveSession(s) throw error } } @discardableResult public func attach( device: Device, process: ProcessDetails, session: ProcessSession ) async throws -> ProcessSession { try await performAttach( device: device, process: process, session: session ) return (try? store.fetchSession(id: session.id)) ?? session } private func performAttach( device: Device, process: ProcessDetails, session: ProcessSession ) async throws { var s = session s.lastKnownPID = process.pid s.detachReason = .applicationRequested s.lastError = nil s.phase = .attaching s.adoptIcon(from: process) saveSession(s) do { ensureDeviceEventsHooked(for: device) let fridaSession = try await device.attach(pid: process.pid) updateSession(id: s.id) { $0.lastAttachedAt = Date() } let script = try await fridaSession.createScript( source: LumaAgent.coreSource, name: "luma", runtime: .auto ) let instruments = (try? store.fetchInstruments(sessionID: s.id)) ?? [] let instrumentRefs = instruments.map { ProcessNode.InstrumentRef( id: $0.id, kind: $0.kind, sourceIdentifier: $0.sourceIdentifier, configJSON: $0.configJSON, state: $0.state ) } let node = ProcessNode( sessionID: s.id, device: device, process: process, session: fridaSession, script: script, instruments: instrumentRefs, drainService: drainService(for: device), blobStore: blobs ) node.onResolveBlockNames = { [weak self] addresses in guard let self else { return Array(repeating: "", count: addresses.count) } return await self.symbolDisplay(sessionID: s.id, addresses: addresses).map { $0.primary } } let existingCells = (try? store.fetchREPLCells(sessionID: s.id)) ?? [] if !existingCells.isEmpty { let cell = REPLCell( sessionID: s.id, code: "New process attached", result: .text(""), isSessionBoundary: true ) try? store.save(cell) onREPLCellAdded?(cell) } updateSession(id: s.id) { $0.lastKnownMainModule = nil $0.lastKnownModules = nil $0.lastKnownThreads = nil } subscribeToNodeStreams(node) processNodes.append(node) announceLocalSession(node) await node.waitForScriptEventsSubscription() await Task.yield() try await script.load() if let info = await node.fetchProcessInfo() { let mainModule = node.mainModule invalidateAddressFacts(sessionID: s.id, identity: info.identity) try? store.deleteMemoryPagesAnon(sessionID: s.id, exceptIdentity: info.identity) let storedInfo = ProcessSession.ProcessInfo( platform: info.platform, arch: info.arch, pointerSize: info.pointerSize, identity: info.identity ) updateSession(id: s.id) { $0.processInfo = storedInfo $0.lastKnownMainModule = mainModule } seedReplSeekToMainModule(sessionID: s.id) collaboration.enqueueUpdateProcessInfo(sessionID: s.id, info: storedInfo) } await loadAllPackages(on: node) for ref in node.instruments where ref.state == .enabled { await loadInstrumentOnNode( instanceID: ref.id, kind: ref.kind, sourceIdentifier: ref.sourceIdentifier, configJSON: ref.configJSON, node: node, sessionID: s.id ) } updateSession(id: s.id) { $0.phase = .attached } if let sid = collabSessionID(forNode: node) { collaboration.enqueueUpdateSessionPhase(sessionID: sid, phase: .attached) } } catch { emitEngineError(subsystem: "attach", text: "Failed to attach to \(s.processName): \(userFacingMessage(error))") updateSession(id: s.id) { $0.lastError = error.localizedDescription $0.phase = .idle } throw error } } private func announceLocalSession(_ node: ProcessNode) { guard collaboration.isCollaborative, collaboration.isOwner, let localUser = collaboration.localUser else { return } let sessionID = node.sessionID guard var session = try? store.fetchSession(id: sessionID) else { return } if session.host?.id == localUser.id { return } session.host = localUser saveSession(session) collaboration.enqueueAddSession( sessionID: sessionID, deviceID: node.deviceID, deviceName: node.deviceName, pid: node.pid, processName: node.processName ) } public func resumeSpawnedProcess(node: ProcessNode) async { guard let session = try? store.fetchSession(id: node.sessionID) else { return } do { try await node.resume() updateSession(id: session.id) { $0.phase = .attached } if let sid = collabSessionID(forNode: node) { collaboration.enqueueUpdateSessionPhase(sessionID: sid, phase: .attached) } } catch { updateSession(id: session.id) { $0.lastError = error.localizedDescription } } } public func removeNode(_ node: ProcessNode) { if let idx = processNodes.firstIndex(where: { $0.id == node.id }) { let sid = node.sessionID processNodes.remove(at: idx) node.stop() retireDrainService(deviceID: node.deviceID) updateSession(id: sid) { $0.phase = .idle } rebuildAddressAnnotations(sessionID: sid) } } private func drainService(for device: Device) -> SystemDrainService { if let existing = drainServices[device.id] { return existing } let service = SystemDrainService(device: device, agentSource: LumaAgent.drainSource) drainServices[device.id] = service return service } private func retireDrainService(deviceID: String) { guard !processNodes.contains(where: { $0.deviceID == deviceID }) else { return } guard let service = drainServices.removeValue(forKey: deviceID) else { return } Task { await service.shutdown() } } public func armSession(id: UUID, matchPattern: String) async { guard var session = try? store.fetchSession(id: id) else { return } session.armingState = .armed(matchPattern: matchPattern, armedAt: Date()) session.lastArmPattern = matchPattern clearGatingErrorIfPresent(&session) saveSession(session) broadcastArmingChangeIfHosting(session) gatingIntendedDevices.insert(session.deviceID) await evaluateGating(forDeviceID: session.deviceID) } public func disarmSession(id: UUID) async { guard var session = try? store.fetchSession(id: id) else { return } if case .armed(let pattern, _) = session.armingState { session.lastArmPattern = pattern } session.armingState = .unarmed clearGatingErrorIfPresent(&session) saveSession(session) broadcastArmingChangeIfHosting(session) await evaluateGating(forDeviceID: session.deviceID) } private func clearGatingErrorIfPresent(_ session: inout ProcessSession) { if session.lastError?.hasPrefix("Spawn gating couldn't be enabled") == true { session.lastError = nil } } @discardableResult public func armNewSession( device: Device, config: SpawnConfig, matchPattern: String ) async -> ProcessSession { var session = ProcessSession( kind: .spawn(config), deviceID: device.id, deviceName: device.name, processName: config.defaultDisplayName, lastKnownPID: 0, armingState: .armed(matchPattern: matchPattern, armedAt: Date()) ) session.lastArmPattern = matchPattern createSession(session) announceArmedSessionIfCollaborative(session) gatingIntendedDevices.insert(device.id) await evaluateGating(forDeviceID: device.id) return session } private func broadcastArmingChangeIfHosting(_ session: ProcessSession) { guard collaboration.isCollaborative, isHostingNode(session.id), let collabID = collabSessionID(forSessionID: session.id) else { return } collaboration.enqueueUpdateSessionArming(sessionID: collabID, armingState: session.armingState) } private func announceArmedSessionIfCollaborative(_ session: ProcessSession) { guard collaboration.isCollaborative, collaboration.isOwner, let localUser = collaboration.localUser else { return } var stored = session stored.host = localUser saveSession(stored) collaboration.enqueueAddSession( sessionID: stored.id, deviceID: stored.deviceID, deviceName: stored.deviceName, pid: stored.lastKnownPID, processName: stored.processName ) collaboration.enqueueUpdateSessionPhase( sessionID: stored.id, phase: .detached ) collaboration.enqueueUpdateSessionArming( sessionID: stored.id, armingState: stored.armingState ) } public func resumeGating(forSessionID sessionID: UUID) async { guard let session = sessions.first(where: { $0.id == sessionID }) else { return } gatingIntendedDevices.insert(session.deviceID) await evaluateGating(forDeviceID: session.deviceID) } public func isGatingActive(forDeviceID deviceID: String) -> Bool { gatingEnabledDevices.contains(deviceID) } public func defaultArmPattern(for session: ProcessSession) -> String { if case .armed(let pattern, _) = session.armingState { return pattern } if let last = session.lastArmPattern, !last.isEmpty { return last } let literal: String switch session.kind { case .spawn(let config): literal = config.programString case .attach: literal = session.processName } return "^" + NSRegularExpression.escapedPattern(for: literal) + "$" } private func evaluateGating(forDeviceID deviceID: String) async { let hasArmedSession = sessions.contains { session in guard session.deviceID == deviceID else { return false } if case .armed = session.armingState { return true } return false } let shouldGate = hasArmedSession && gatingIntendedDevices.contains(deviceID) let isGating = gatingEnabledDevices.contains(deviceID) guard shouldGate != isGating else { return } let devices = await deviceManager.currentDevices() guard let device = devices.first(where: { $0.id == deviceID }) else { gatingEnabledDevices.remove(deviceID) return } do { if shouldGate { try await device.enableSpawnGating() gatingEnabledDevices.insert(deviceID) ensureDeviceEventsHooked(for: device) clearGatingErrorOnArmedSessions(forDeviceID: deviceID) } else { try await device.disableSpawnGating() gatingEnabledDevices.remove(deviceID) } notifyGatingChange(forDeviceID: deviceID) } catch { let reason = userFacingMessage(error) if shouldGate { notify(.error, "Couldn't enable spawn gating on \(device.name)", message: reason) recordGatingErrorOnArmedSessions(forDeviceID: deviceID, reason: reason) } else { emitEngineWarning(subsystem: "spawn-gating", text: "Failed to disable spawn gating on \(deviceID): \(reason)") } } } private func notifyGatingChange(forDeviceID deviceID: String) { for session in sessions where session.deviceID == deviceID { guard case .armed = session.armingState else { continue } onSessionListChanged?(.sessionUpdated(session)) } } private func recordGatingErrorOnArmedSessions(forDeviceID deviceID: String, reason: String) { let summary = "Spawn gating couldn't be enabled: \(reason)" for session in sessions where session.deviceID == deviceID { if case .armed = session.armingState { updateSession(id: session.id) { $0.lastError = summary } } } } private func clearGatingErrorOnArmedSessions(forDeviceID deviceID: String) { for session in sessions where session.deviceID == deviceID { guard session.lastError?.hasPrefix("Spawn gating couldn't be enabled") == true else { continue } updateSession(id: session.id) { $0.lastError = nil } } } private func notify( _ severity: UserNotification.Severity, _ title: String, message: String? = nil ) { onUserNotification?(UserNotification(severity: severity, title: title, message: message)) } private func userFacingMessage(_ error: any Swift.Error) -> String { if let compile = error as? CompileFailure { return userFacingMessage(compile.underlying) } if let fridaError = error as? Frida.Error { return fridaError.description } return error.localizedDescription } public func anchor(sessionID: UUID, address: UInt64) -> AddressAnchor { if let node = node(forSessionID: sessionID) { return node.anchor(for: address) } if let modules = session(id: sessionID)?.lastKnownModules, let m = modules.first(where: { address >= $0.base && address < ($0.base + $0.size) }) { return .moduleOffset(name: m.name, offset: address - m.base) } return .absolute(address) } public func node(forSessionID sessionID: UUID) -> ProcessNode? { processNodes.first { $0.id == sessionID || $0.sessionID == sessionID } } public func instrument(id: UUID, sessionID: UUID) -> InstrumentInstance? { try? store.fetchInstrument(id: id) } public func session(id: UUID) -> ProcessSession? { sessions.first { $0.id == id } ?? (try? store.fetchSession(id: id)) } public func session(forNode node: ProcessNode) -> ProcessSession? { session(id: node.sessionID) } public func processNode(forEvent event: RuntimeEvent) -> ProcessNode? { guard let sessionID = event.sessionID else { return nil } return node(forSessionID: sessionID) } public func instrument(forEvent event: RuntimeEvent) -> InstrumentInstance? { guard case .instrument(let id, _) = event.source, let sessionID = event.sessionID else { return nil } return instrument(id: id, sessionID: sessionID) } public var localUserID: String? { collaboration.localUser?.id ?? gitHubAuth.currentUser?.id } public func disassembler(forSessionID sessionID: UUID) -> Disassembler? { if let existing = disassemblers[sessionID] { return existing } guard let info = session(id: sessionID)?.processInfo else { return nil } let env = SessionDisassemblyEnvironment(engine: self, sessionID: sessionID) let reader = memoryReader(forSessionID: sessionID, env: env) let d = Disassembler( sessionID: sessionID, processInfo: info, store: store, modulesProvider: { [weak self] in self?.modulesSnapshot(forSessionID: sessionID) ?? [] }, introspector: env, reader: reader ) d.onAnalysisSaved = { [weak self] analysis in guard let self else { return } self.collaboration.enqueueUpdateModuleAnalysis(sessionID: sessionID, analysis: analysis) Task { @MainActor [weak self] in await self?.linkInsightsForFreshAnalysis(sessionID: sessionID, analysis: analysis) } } d.insightDisplayTitle = { [weak self] insight in self?.displayTitle(for: insight) ?? insight.anchor.displayString } d.onAnalysisStateChanged = { [weak self] path, status in self?.moduleAnalysisStatuses[sessionID, default: [:]][path] = status } disassemblers[sessionID] = d return d } public func moduleAnalysisStatus(sessionID: UUID, modulePath: String) -> ModuleAnalysisStatus { moduleAnalysisStatuses[sessionID]?[modulePath] ?? .notAnalyzed } public func analyzeModule(sessionID: UUID, module: ProcessModule) { guard let disassembler = disassembler(forSessionID: sessionID) else { return } Task { @MainActor in await disassembler.analyze(module: module) } } private func radare2REPLValue(forCommand command: String, sessionID: UUID) async -> REPLResult.Value { guard let disassembler = disassembler(forSessionID: sessionID) else { return .text("r2 is unavailable until the process is attached.") } if let arguments = hexdumpArguments(command) { let value = await hexdumpValue(forCommand: command, arguments: arguments, disassembler: disassembler) await captureREPLSeek(disassembler: disassembler, sessionID: sessionID) return value } let result = await disassembler.runCommand(command) await captureREPLSeek(disassembler: disassembler, sessionID: sessionID) return replValue(forR2Result: result) } private func hexdumpArguments(_ command: String) -> String? { let trimmed = command.trimmingCharacters(in: .whitespaces) let token = trimmed.prefix(while: { !$0.isWhitespace }) guard token == "px" || token == "x" else { return nil } return trimmed.dropFirst(token.count).trimmingCharacters(in: .whitespaces) } private func hexdumpValue(forCommand command: String, arguments: String, disassembler: Disassembler) async -> REPLResult.Value { let bytes = await disassembler.runCommand("pxj \(arguments)") guard let data = decodeR2ByteArray(bytes.output) else { return replValue(forR2Result: await disassembler.runCommand(command)) } let atClause = arguments.range(of: "@").map { String(arguments[$0.lowerBound...]) } ?? "" let address = await disassembler.runCommand("?v $$ \(atClause)".trimmingCharacters(in: .whitespaces)) let base = decodeR2Address(address.output) return .binary(data, meta: REPLCell.Result.BinaryMeta(typedArray: nil, baseAddress: base)) } private func decodeR2ByteArray(_ output: String?) -> Data? { guard let output, let data = output.data(using: .utf8), let values = try? JSONSerialization.jsonObject(with: data) as? [Int] else { return nil } return Data(values.map { UInt8($0 & 0xff) }) } private func decodeR2Address(_ output: String?) -> UInt64? { guard let trimmed = output?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil } if trimmed.hasPrefix("0x") { return UInt64(trimmed.dropFirst(2), radix: 16) } return UInt64(trimmed) } private func captureREPLSeek(disassembler: Disassembler, sessionID: UUID) async { guard let address = await disassembler.currentSeek() else { return } setREPLSeekAnchor(sessionID: sessionID, anchor(sessionID: sessionID, address: address)) } private static let maxRadare2Completions = 64 private func radare2REPLCompletions(forCode code: String, cursor: Int, sessionID: UUID) async -> [REPLCompletion] { guard let disassembler = disassembler(forSessionID: sessionID) else { return [] } let head = String(code.prefix(cursor)) let token = trailingToken(head) guard !token.isEmpty else { return [] } let atCommandPosition = head.dropLast(token.count).allSatisfy(\.isWhitespace) if atCommandPosition { return await commandCompletions(prefix: token, disassembler: disassembler) } return await argumentCompletions(command: leadingCommand(head), prefix: token, disassembler: disassembler) } private func leadingCommand(_ head: String) -> String { String(head.prefix(while: { !$0.isWhitespace })) } private func argumentCompletions(command: String, prefix: String, disassembler: Disassembler) async -> [REPLCompletion] { switch command { case "e": return await evalCompletions(prefix: prefix, disassembler: disassembler) case "fs": return await flagspaceCompletions(prefix: prefix, disassembler: disassembler) default: return await flagCompletions(prefix: prefix, disassembler: disassembler) } } private func evalCompletions(prefix: String, disassembler: Disassembler) async -> [REPLCompletion] { let result = await disassembler.runCommand("e?j") guard let data = result.output?.data(using: .utf8), let variables = try? JSONDecoder().decode([R2EvalVariable].self, from: data) else { return [] } return variables .filter { $0.name.hasPrefix(prefix) } .prefix(Self.maxRadare2Completions) .map { REPLCompletion(insertText: $0.name, displayText: $0.name, detailText: $0.detail) } } private func flagspaceCompletions(prefix: String, disassembler: Disassembler) async -> [REPLCompletion] { let result = await disassembler.runCommand("fsq") return (result.output ?? "") .split(separator: "\n") .map { String($0).trimmingCharacters(in: .whitespaces) } .filter { $0.hasPrefix(prefix) } .prefix(Self.maxRadare2Completions) .map(REPLCompletion.init) } private func commandCompletions(prefix: String, disassembler: Disassembler) async -> [REPLCompletion] { let result = await disassembler.runCommand("\(prefix)?") var seen: Set = [] var entries: [(name: String, signature: String, description: String)] = [] for rawLine in (result.output ?? "").split(separator: "\n") { let line = StyledText.parseAnsi(String(rawLine)).plainText guard line.hasPrefix("|") else { continue } let entry = line.dropFirst().trimmingCharacters(in: .whitespaces) guard let first = entry.split(separator: " ").first else { continue } let name = String(first.prefix(while: isCompletionTokenCharacter)) guard name.hasPrefix(prefix), seen.insert(name).inserted else { continue } let (signature, description) = splitHelp(entry) entries.append((name, signature, description)) } return entries.prefix(Self.maxRadare2Completions).map { entry in REPLCompletion( insertText: entry.name, displayText: entry.signature, detailText: entry.description.isEmpty ? nil : entry.description ) } } private func splitHelp(_ entry: String) -> (signature: String, description: String) { guard let separator = entry.range(of: "\\s{2,}", options: .regularExpression) else { return (entry, "") } return (String(entry[.. [REPLCompletion] { let result = await disassembler.runCommand("fq~\(prefix)") let names = (result.output ?? "").split(separator: "\n").map { String($0).trimmingCharacters(in: .whitespaces) } let prefixMatches = names.filter { $0.hasPrefix(prefix) } let chosen = prefixMatches.isEmpty ? names : prefixMatches return Array(chosen.prefix(Self.maxRadare2Completions)).map(REPLCompletion.init) } private struct R2EvalVariable: Decodable { let name: String let description: String? var detail: String? { description.flatMap { $0.isEmpty ? nil : $0 } } enum CodingKeys: String, CodingKey { case name case description = "desc" } } private func trailingToken(_ text: String) -> String { String(text.reversed().prefix(while: isCompletionTokenCharacter).reversed()) } private func isCompletionTokenCharacter(_ character: Character) -> Bool { character.isLetter || character.isNumber || character == "." || character == "_" || character == "$" } private func replValue(forR2Result result: R2CommandResult) -> REPLResult.Value { let output = result.output ?? "" if !result.hasErrors, let structured = JSInspectValue.fromJSONText(output) { return .js(structured) } let trimmed = String(output.reversed().drop(while: \.isNewline).reversed()) return .styled(StyledText.parseAnsi(combine(trimmed, errors: result.errors))) } private func combine(_ output: String, errors: [R2LogEntry]) -> String { let messages = errors.map(\.message).filter { !$0.isEmpty }.joined(separator: "\n") guard !messages.isEmpty else { return output } return output.isEmpty ? messages : output + "\n" + messages } private func restoreReplSeek(sessionID: UUID) { guard let seekAnchor = replSeekAnchor(forSessionID: sessionID) else { return } guard seekRestoredSessions.insert(sessionID).inserted else { return } guard let disassembler = disassembler(forSessionID: sessionID) else { return } Task(priority: .utility) { @MainActor in if let address = await self.resolve(sessionID: sessionID, anchor: seekAnchor) { await disassembler.seek(to: address) } } } private func seedReplSeekToMainModule(sessionID: UUID) { guard replSeekAnchor(forSessionID: sessionID) == nil else { return } guard seekRestoredSessions.insert(sessionID).inserted else { return } guard let base = session(id: sessionID)?.lastKnownMainModule?.base else { return } guard let disassembler = disassembler(forSessionID: sessionID) else { return } Task(priority: .utility) { @MainActor in await disassembler.seek(to: base) } } private func linkInsightsForFreshAnalysis(sessionID: UUID, analysis: ModuleAnalysis) async { guard let module = modulesSnapshot(forSessionID: sessionID).first(where: { $0.path == analysis.modulePath }) else { return } let functionStarts = Set(analysis.functions.map { module.base &+ $0.offset }) let insights = (try? store.fetchInsights(sessionID: sessionID)) ?? [] let newlyDiscoveredStarts = insights.filter { insight in guard let addr = insight.lastResolvedAddress, functionStarts.contains(addr) else { return false } return !insights.contains { other in other.parentInsightID == insight.id } } for parent in newlyDiscoveredStarts { guard let begin = parent.lastResolvedAddress, let end = await disassemblers[sessionID]?.findFunctionEnd(containing: begin), end > begin else { continue } var changed: [AddressInsight] = [] for sibling in insights where sibling.id != parent.id && sibling.parentInsightID == nil { guard let addr = sibling.lastResolvedAddress, addr > begin, addr < end else { continue } var updated = sibling updated.parentInsightID = parent.id persistInsight(updated) changed.append(updated) } for c in changed { collaboration.enqueueUpdateInsight(sessionID: sessionID, insight: c) emitInsightUpdated(c) } } } public func memoryReader(forSessionID sessionID: UUID) -> MemoryReader { let env = SessionDisassemblyEnvironment(engine: self, sessionID: sessionID) return memoryReader(forSessionID: sessionID, env: env) } private func memoryReader(forSessionID sessionID: UUID, env: SessionDisassemblyEnvironment) -> CachingMemoryReader { if let existing = memoryReaders[sessionID] { existing.live = env return existing } let reader = CachingMemoryReader(sessionID: sessionID, store: store, keying: env, live: env) reader.publish = { [weak self] page in self?.collaboration.publishMemoryPage(sessionID: sessionID, page) } reader.fetchRemote = { [weak self] region in await self?.collaboration.fetchMemoryPage(sessionID: sessionID, region: region) } memoryReaders[sessionID] = reader return reader } public func recordInsightResolution(_ insight: AddressInsight, resolved: UInt64) { var updated = insight updated.lastResolvedAddress = resolved persistInsight(updated) collaboration.enqueueUpdateInsight(sessionID: updated.sessionID, insight: updated) } public func renameInsight(_ insight: AddressInsight, to newTitle: String) { var updated = insight updated.userTitle = newTitle persistInsight(updated) collaboration.enqueueUpdateInsight(sessionID: updated.sessionID, insight: updated) applyInsightNameToR2(updated) emitInsightUpdated(updated) } public func setPlacements(_ placements: [PatternPlacement], forInsight insight: AddressInsight) { var updated = insight updated.placements = placements persistInsight(updated) collaboration.enqueueUpdateInsight(sessionID: updated.sessionID, insight: updated) emitInsightUpdated(updated) } public func setByteCount(_ byteCount: Int, forInsight insight: AddressInsight) { var updated = insight updated.byteCount = byteCount persistInsight(updated) collaboration.enqueueUpdateInsight(sessionID: updated.sessionID, insight: updated) emitInsightUpdated(updated) } public func setPlacements(_ placements: [PatternPlacement], forREPLCell cellID: UUID) { guard var cell = try? store.fetchREPLCell(id: cellID) else { return } cell.placements = placements try? store.save(cell) } private func persistInsight(_ insight: AddressInsight) { try? store.save(insight) upsertInsightCache(insight) } private func upsertInsightCache(_ insight: AddressInsight) { var list = insightsBySession[insight.sessionID] ?? [] if let idx = list.firstIndex(where: { $0.id == insight.id }) { list[idx] = insight } else { list.append(insight) } insightsBySession[insight.sessionID] = list } private func emitInsightUpdated(_ insight: AddressInsight) { onSessionListChanged?(.insightUpdated(insight)) for dependent in computedNameDependents(of: insight) { onSessionListChanged?(.insightUpdated(dependent)) } } private func computedNameDependents(of insight: AddressInsight) -> [AddressInsight] { let siblings = insightsBySession[insight.sessionID] ?? [] var result: [AddressInsight] = [] var frontier: Set = [insight.id] while !frontier.isEmpty { let parents = frontier frontier.removeAll() for candidate in siblings where candidate.userTitle?.isEmpty ?? true { guard let parentID = candidate.parentInsightID, parents.contains(parentID) else { continue } result.append(candidate) frontier.insert(candidate.id) } } return result } public func displayTitle(for insight: AddressInsight) -> String { if let title = insight.userTitle, !title.isEmpty { return title } if let parentID = insight.parentInsightID, let parent = (insightsBySession[insight.sessionID] ?? []).first(where: { $0.id == parentID }), let parentAddr = parent.lastResolvedAddress, let myAddr = insight.lastResolvedAddress { return "\(displayTitle(for: parent))+0x\(String(myAddr &- parentAddr, radix: 16))" } if case .moduleOffset(_, let offset) = insight.anchor, isFunctionStart(insight) { return "sub_\(String(offset, radix: 16))" } return insight.anchor.displayString } private func isFunctionStart(_ insight: AddressInsight) -> Bool { guard let address = insight.lastResolvedAddress else { return false } let modules = modulesSnapshot(forSessionID: insight.sessionID) guard let module = modules.first(where: { address >= $0.base && address < ($0.base &+ $0.size) }) else { return false } let offset = address &- module.base guard let analysis = try? store.fetchModuleAnalysis(sessionID: insight.sessionID, modulePath: module.path) else { return false } return analysis.functions.contains(where: { $0.offset == offset }) } public func symbolDisplay(sessionID: UUID, addresses: [UInt64]) async -> [SymbolDisplay] { var liveByAddress: [UInt64: SymbolicateResult] = [:] if let node = node(forSessionID: sessionID), let live = try? await node.symbolicate(addresses: addresses) { for (idx, result) in live.enumerated() where idx < addresses.count { if let result { liveByAddress[addresses[idx]] = result } } persistSymbolicateResults(sessionID: sessionID, byAddress: liveByAddress) } return addresses.map { address in renderSymbolDisplay(sessionID: sessionID, address: address, live: liveByAddress[address]) } } private func persistSymbolicateResults(sessionID: UUID, byAddress: [UInt64: SymbolicateResult]) { let modules = modulesSnapshot(forSessionID: sessionID) var entries: [SessionOp.UpdateModuleSymbols.Entry] = [] for (address, result) in byAddress { guard let module = modules.first(where: { $0.name == result.module || $0.path == result.module }) else { continue } let entryAddress = address &- (result.offset ?? 0) guard entryAddress >= module.base, entryAddress < (module.base &+ module.size) else { continue } let entryOffset = entryAddress &- module.base try? store.upsertModuleSymbol(sessionID: sessionID, modulePath: module.path, offset: entryOffset, name: result.name) entries.append(.init(modulePath: module.path, offset: entryOffset, name: result.name)) } if !entries.isEmpty { collaboration.enqueueUpdateModuleSymbols(sessionID: sessionID, entries: entries) } } public func symbolicNameOverlay(sessionID: UUID, address: UInt64) -> (title: String, offset: UInt64)? { let insights = insightsBySession[sessionID] ?? [] if let exact = insights.first(where: { $0.lastResolvedAddress == address && hasUserTitle($0) }) { return (exact.userTitle!, 0) } guard let function = enclosingFunction(sessionID: sessionID, address: address) else { return nil } guard let named = insights.first(where: { $0.lastResolvedAddress == function.entryAddress && hasUserTitle($0) }) else { return nil } return (named.userTitle!, address &- function.entryAddress) } public func enclosingFunction(sessionID: UUID, address: UInt64) -> (entryAddress: UInt64, name: String?)? { guard let module = enclosingModule(at: address, sessionID: sessionID) else { return nil } let offset = address &- module.base guard let result = try? store.enclosingFunction(sessionID: sessionID, modulePath: module.path, offset: offset) else { return nil } return (module.base &+ result.functionOffset, result.name) } private func renderSymbolDisplay(sessionID: UUID, address: UInt64, live: SymbolicateResult?) -> SymbolDisplay { if let overlay = symbolicNameOverlay(sessionID: sessionID, address: address) { let suffix = overlay.offset == 0 ? "" : "+0x\(String(overlay.offset, radix: 16))" return SymbolDisplay( primary: overlay.title + suffix, source: live?.source, raw: live, overlay: SymbolDisplay.Overlay(title: overlay.title, offset: overlay.offset) ) } if let live { return SymbolDisplay(primary: live.qualifiedName, source: live.source, raw: live) } if let derived = derivedSymbolDisplay(sessionID: sessionID, address: address) { return derived } return SymbolDisplay(primary: anchor(sessionID: sessionID, address: address).displayString) } private func derivedSymbolDisplay(sessionID: UUID, address: UInt64) -> SymbolDisplay? { guard let module = enclosingModule(at: address, sessionID: sessionID) else { return nil } let offset = address &- module.base if let symbol = try? store.enclosingModuleSymbol(sessionID: sessionID, modulePath: module.path, offset: offset) { let delta = offset &- symbol.offset let suffix = delta == 0 ? "" : "+0x\(String(delta, radix: 16))" return SymbolDisplay(primary: "\(module.name)!\(symbol.name)\(suffix)") } if let function = enclosingFunction(sessionID: sessionID, address: address) { let delta = address &- function.entryAddress let suffix = delta == 0 ? "" : "+0x\(String(delta, radix: 16))" let name = function.name ?? "sub_\(String(function.entryAddress &- module.base, radix: 16))" return SymbolDisplay(primary: "\(module.name)!\(name)\(suffix)") } return nil } private func hasUserTitle(_ insight: AddressInsight) -> Bool { guard let title = insight.userTitle else { return false } return !title.isEmpty } private func applyInsightNameToR2(_ insight: AddressInsight) { guard let address = insight.lastResolvedAddress, let disassembler = disassemblers[insight.sessionID] else { return } let title = displayTitle(for: insight) Task { @MainActor in await disassembler.applyInsightName(at: address, title: title) } } public func invalidateInsightRange(sessionID: UUID, address: UInt64, byteCount: Int) async { let env = SessionDisassemblyEnvironment(engine: self, sessionID: sessionID) let end = address &+ UInt64(byteCount) var cursor = MemoryPage.base(of: address) while cursor < end { switch await env.attribution(pageBase: cursor) { case .module(let module): if let uuid = await env.moduleUUID(forPath: module.path) { try? store.deleteMemoryPage(sessionID: sessionID, moduleUUID: uuid, offset: cursor &- module.base) } case .anonymous: if let identity = env.processIdentity { try? store.deleteMemoryPage(sessionID: sessionID, processIdentity: identity, address: cursor) } case .ephemeral: break } cursor = cursor &+ MemoryPage.size } } public func rereadBytes(of insight: AddressInsight) async { if let address = insight.lastResolvedAddress { await invalidateInsightRange(sessionID: insight.sessionID, address: address, byteCount: insight.byteCount) } requestRefresh(of: insight) } public func reanalyzeModule(of insight: AddressInsight) { guard let module = enclosingModule(of: insight) else { return } invalidateModule(sessionID: insight.sessionID, modulePath: module.path) requestRefresh(of: insight) } public func enclosingModule(of insight: AddressInsight) -> ProcessModule? { if let address = insight.lastResolvedAddress, let module = enclosingModule(at: address, sessionID: insight.sessionID) { return module } if case .moduleOffset(let name, _) = insight.anchor { return modulesSnapshot(forSessionID: insight.sessionID).first { $0.name == name } } return nil } private func requestRefresh(of insight: AddressInsight) { insightRefreshGenerations[insight.id, default: 0] += 1 } private func adoptInsights(_ grouped: [UUID: [AddressInsight]]) { insightsBySession = grouped let live = Set(grouped.values.joined().map(\.id)) insightRefreshGenerations = insightRefreshGenerations.filter { live.contains($0.key) } } public func invalidateModule(sessionID: UUID, modulePath: String) { guard let analysis = try? store.fetchModuleAnalysis(sessionID: sessionID, modulePath: modulePath) else { return } if let uuid = analysis.moduleUUID { try? store.deleteMemoryPagesModule(sessionID: sessionID, moduleUUID: uuid) } try? store.deleteModuleAnalysis(sessionID: sessionID, modulePath: modulePath) disassemblers[sessionID]?.forgetModule(path: modulePath) } public func modulesSnapshot(forSessionID sessionID: UUID) -> [ProcessModule] { if let node = node(forSessionID: sessionID) { return node.modules } return session(id: sessionID)?.lastKnownModules ?? [] } public func enclosingModule(at address: UInt64, sessionID: UUID) -> ProcessModule? { modulesSnapshot(forSessionID: sessionID).first { address >= $0.base && address < ($0.base &+ $0.size) } } public func resolve(sessionID: UUID, anchor: AddressAnchor, hint: UInt64? = nil) async -> UInt64? { if let node = node(forSessionID: sessionID) { do { return try await node.resolve(anchor) } catch { return nil } } switch anchor { case .absolute(let a): return a case .moduleOffset(let name, let offset): if let base = modulesSnapshot(forSessionID: sessionID).first(where: { $0.name == name })?.base { return base &+ offset } return hint case .moduleExport, .objcMethod, .swiftFunc, .debugSymbol, .javaMethod: return hint } } public func updateSession(id: UUID, _ mutate: (inout ProcessSession) -> Void) { guard var s = try? store.fetchSession(id: id) else { return } mutate(&s) saveSession(s) } private func surfaceSession(_ session: ProcessSession, isNew: Bool) { if isNew { createSession(session) } else { saveSession(session) } } func createSession(_ session: ProcessSession) { try? store.save(session) sessions.insert(session, at: 0) onSessionListChanged?(.sessionAdded(session)) } public func deleteSession(id: UUID) { let removedDeviceID = sessions.first(where: { $0.id == id })?.deviceID if let node = node(forSessionID: id) { removeNode(node) } try? store.deleteSession(id: id) sessions.removeAll { $0.id == id } onSessionListChanged?(.sessionRemoved(id)) if let deviceID = removedDeviceID { Task { @MainActor [weak self] in await self?.evaluateGating(forDeviceID: deviceID) } } } public func deleteInsight(id: UUID, sessionID: UUID) { if localUserHosts(sessionID) { try? store.deleteInsight(id: id) onSessionListChanged?(.insightRemoved(id: id, sessionID: sessionID)) } if collaboration.isCollaborative { collaboration.enqueueRemoveInsight(sessionID: sessionID, insightID: id) } } public func deleteITrace(id: UUID, sessionID: UUID) { if localUserHosts(sessionID) { try? store.deleteITrace(id: id) blobs.delete(id: id, kind: .trace) lastUploadedTraceSize.removeValue(forKey: id) onSessionListChanged?(.traceRemoved(id: id, sessionID: sessionID)) } if collaboration.isCollaborative { collaboration.enqueueRemoveTrace(sessionID: sessionID, traceID: id) } } public func loadTraceDataPrefix( traceID: UUID, sessionID: UUID, length: Int ) async throws -> Data { if let node = node(forSessionID: sessionID), let live = node.livePendingTraceData(traceID: traceID) { return live.prefix(length) } if blobs.exists(id: traceID, kind: .trace) { let cached = try blobs.load(id: traceID, kind: .trace) if cached.count >= length { return cached.prefix(length) } } if let sid = collabSessionID(forSessionID: sessionID) { let (data, _) = try await collaboration.fetchTraceData( sessionID: sid, traceID: traceID, offset: 0, length: length ) return data } throw LumaCoreError.invalidOperation("Trace data unavailable") } public func loadTraceData( traceID: UUID, sessionID: UUID, expectedSize: Int? = nil, onProgress: (@MainActor (Int, Int) -> Void)? = nil ) async throws -> Data { try await loadBlob( id: traceID, sessionID: sessionID, kind: .trace, expectedSize: expectedSize, onProgress: onProgress) } /// The three tiers a blob is read through: the live capture still in the /// node, this session's on-disk store, then a paginated fetch from the lab. /// The live tier and the fetch are the trace's to give -- other kinds live /// only in the store until the blob transport is generalized -- so they are /// skipped for everything else. public func loadBlob( id: UUID, sessionID: UUID, kind: BlobKind, expectedSize: Int? = nil, onProgress: (@MainActor (Int, Int) -> Void)? = nil ) async throws -> Data { if kind == .trace, let node = node(forSessionID: sessionID), let live = node.livePendingTraceData(traceID: id) { return live } let collabID = kind == .trace ? collabSessionID(forSessionID: sessionID) : nil var data: Data var cursor: Int if blobs.exists(id: id, kind: kind) { let cached = try blobs.load(id: id, kind: kind) if expectedSize == nil || cached.count == expectedSize { return cached } if let want = expectedSize, want < cached.count { data = Data() cursor = 0 } else { data = cached cursor = cached.count } } else { data = Data() cursor = 0 } guard let sid = collabID else { return try blobs.load(id: id, kind: kind) } while expectedSize.map({ cursor < $0 }) ?? true { let remaining = expectedSize.map { $0 - cursor } let length = remaining.map { min($0, Self.traceDataPageSize) } ?? Self.traceDataPageSize let (chunk, totalSize) = try await collaboration.fetchTraceData( sessionID: sid, traceID: id, offset: cursor, length: length ) if chunk.isEmpty { break } data.append(chunk) cursor += chunk.count onProgress?(cursor, expectedSize ?? totalSize) if expectedSize == nil, cursor >= totalSize { break } } try? blobs.write(data, id: id, kind: kind) return data } private func effectiveLastUploadedSize(traceID: UUID, collabSessionID: UUID) async -> Int { if let cached = lastUploadedTraceSize[traceID] { return cached } let serverSize: Int do { let (_, total) = try await collaboration.fetchTraceData( sessionID: collabSessionID, traceID: traceID, offset: 0, length: 0 ) serverSize = total } catch { serverSize = 0 } lastUploadedTraceSize[traceID] = serverSize return serverSize } private func uploadTraceDelta(_ trace: ITrace, collabSessionID: UUID) async { let traceID = trace.id let knownUploaded = await effectiveLastUploadedSize(traceID: traceID, collabSessionID: collabSessionID) let startOffset = (trace.dataSize < knownUploaded) ? 0 : knownUploaded guard trace.dataSize > startOffset else { return } let data: Data do { data = try await loadTraceData(traceID: traceID, sessionID: trace.sessionID) } catch { return } let endIndex = min(data.count, trace.dataSize) guard endIndex > startOffset else { return } var cursor = startOffset while cursor < endIndex { let chunkEnd = min(cursor + Self.traceDataPageSize, endIndex) let chunk = data.subdata(in: cursor.. UUID { node.sessionID } public enum ReestablishResult { case attached case needsUserInput(reason: String, session: ProcessSession) } public func reHost(sessionID: UUID) async -> ReestablishResult { guard collaboration.isOwner, let localUser = collaboration.localUser else { return .needsUserInput( reason: "Only owners can host a session in this lab.", session: ProcessSession(kind: .attach, deviceID: "", deviceName: "", processName: "", lastKnownPID: 0) ) } guard var session = sessions.first(where: { $0.id == sessionID }) else { return .needsUserInput( reason: "Session not found.", session: ProcessSession(kind: .attach, deviceID: "", deviceName: "", processName: "", lastKnownPID: 0) ) } let devices = await deviceManager.currentDevices() guard let device = devices.first(where: { $0.id == session.deviceID }) else { return .needsUserInput( reason: "Device \"\(session.deviceName)\" isn't available on this machine. Pick a device and target to host \"\(session.processName)\".", session: session ) } do { let processes = try await device.enumerateProcesses(scope: Scope.full) let matches = processes.filter { $0.name == session.processName } guard let chosen = matches.first(where: { $0.pid == session.lastKnownPID }) ?? matches.first else { return .needsUserInput( reason: "No running process named \"\(session.processName)\" on \(session.deviceName). Choose a target.", session: session ) } let claimed = await claimHostingAndAttach(session: &session, device: device, process: chosen, localUser: localUser) guard claimed else { return .needsUserInput( reason: "Another owner is currently hosting this session. Try again once it's free.", session: session ) } return .attached } catch { return .needsUserInput( reason: "Couldn't enumerate processes on \(session.deviceName): \(error.localizedDescription)", session: session ) } } public func claimHosting( sessionID: UUID, device: Device, process: ProcessDetails ) async { guard collaboration.isOwner, let localUser = collaboration.localUser else { return } guard var session = sessions.first(where: { $0.id == sessionID }) else { return } await claimHostingAndAttach(session: &session, device: device, process: process, localUser: localUser) } @discardableResult private func claimHostingAndAttach( session: inout ProcessSession, device: Device, process: ProcessDetails, localUser: CollaborationSession.UserInfo ) async -> Bool { do { try await collaboration.requestClaimHost( sessionID: session.id, deviceID: device.id, deviceName: device.name, pid: process.pid, processName: process.name ) } catch { emitEngineError( subsystem: "collaboration", text: "Couldn't take over hosting \(session.processName): \(error.localizedDescription)" ) return false } if let existingNode = node(forSessionID: session.id) { removeNode(existingNode) } session.host = localUser session.deviceID = device.id session.deviceName = device.name session.processName = process.name session.lastKnownPID = process.pid session.lastKnownMainModule = nil session.lastKnownModules = nil session.lastKnownThreads = nil session.phase = .attaching saveSession(session) _ = try? await attach(device: device, process: process, session: session) return true } public func reestablishSession(id sessionID: UUID) async -> ReestablishResult { guard var s = try? store.fetchSession(id: sessionID) else { return .needsUserInput( reason: "Session not found.", session: ProcessSession(kind: .attach, deviceID: "", deviceName: "", processName: "", lastKnownPID: 0) ) } s.phase = .attaching s.detachReason = .applicationRequested s.lastError = nil saveSession(s) let devices = await deviceManager.currentDevices() guard let device = devices.first(where: { $0.id == s.deviceID }) else { s.phase = .idle saveSession(s) return .needsUserInput( reason: "The saved device \"\(s.deviceName)\" is not available. Choose a device and target to re-establish this session.", session: s ) } if case .spawn(_) = s.kind { _ = try? await spawnAndAttach(device: device, session: s) return .attached } do { let processes = try await device.enumerateProcesses(scope: Scope.full) let matches = processes.filter { $0.name == s.processName } guard !matches.isEmpty else { s.phase = .idle saveSession(s) return .needsUserInput( reason: "No running process named \"\(s.processName)\" was found. Choose a new target to re-establish this session.", session: s ) } let chosen: ProcessDetails if let exact = matches.first(where: { $0.pid == s.lastKnownPID }) { chosen = exact } else if matches.count == 1 { chosen = matches[0] } else { s.phase = .idle saveSession(s) return .needsUserInput( reason: "Multiple processes named \"\(s.processName)\" are running. Choose which one to attach to.", session: s ) } s.deviceName = device.name saveSession(s) try await performAttach(device: device, process: chosen, session: s) return .attached } catch { s.lastError = error.localizedDescription s.phase = .idle saveSession(s) return .needsUserInput( reason: "Quick re-establish failed for \"\(s.processName)\". Choose a new target.", session: s ) } } @discardableResult public func addInstrument( kind: InstrumentKind, sourceIdentifier: String, configJSON: Data, sessionID: UUID ) async -> InstrumentInstance? { if isHostedRemotelyLive(sessionID) { collaboration.sendInstrumentAdd( sessionID: sessionID, kind: kind, sourceIdentifier: sourceIdentifier, configJSON: configJSON ) return nil } let instance = InstrumentInstance( sessionID: sessionID, kind: kind, sourceIdentifier: sourceIdentifier, configJSON: configJSON ) try? store.save(instance) onSessionListChanged?(.instrumentAdded(instance)) if let node = node(forSessionID: sessionID) { node.addInstrument(ProcessNode.InstrumentRef( id: instance.id, kind: instance.kind, sourceIdentifier: instance.sourceIdentifier, configJSON: instance.configJSON, state: instance.state )) await loadInstrumentOnNode( instanceID: instance.id, kind: instance.kind, sourceIdentifier: instance.sourceIdentifier, configJSON: instance.configJSON, node: node, sessionID: sessionID ) if let sid = collabSessionID(forNode: node) { collaboration.enqueueAddInstrument(sessionID: sid, instance: instance) } } return instance } public func removeInstrument(_ instance: InstrumentInstance) async { if isHostedRemotelyLive(instance.sessionID) { collaboration.sendInstrumentRemove(sessionID: instance.sessionID, instanceID: instance.id) return } var sid: UUID? = nil if let node = node(forSessionID: instance.sessionID) { sid = collabSessionID(forNode: node) if node.instruments.first(where: { $0.id == instance.id })?.attachment == .attached { try? await node.disposeInstrumentOnAgent(instanceID: instance.id) } node.removeInstrument(id: instance.id) } try? store.deleteInstrument(id: instance.id) try? store.deleteWidgetStates(instanceID: instance.id) widgetStates.removeValue(forKey: instance.id) onSessionListChanged?(.instrumentRemoved(id: instance.id, sessionID: instance.sessionID)) rebuildAddressAnnotations(sessionID: instance.sessionID) if let sid { collaboration.enqueueRemoveInstrument(sessionID: sid, instanceID: instance.id) } } public func setInstrumentState(_ instance: InstrumentInstance, state: InstrumentState) async { if isHostedRemotelyLive(instance.sessionID) { collaboration.sendInstrumentSetState( sessionID: instance.sessionID, instanceID: instance.id, state: state ) return } var inst = instance inst.state = state try? store.save(inst) onSessionListChanged?(.instrumentUpdated(inst)) broadcastInstrumentUpdate(inst) guard let node = node(forSessionID: inst.sessionID) else { return } switch state { case .enabled: guard node.instruments.first(where: { $0.id == inst.id })?.attachment != .attached else { return } await loadInstrumentOnNode( instanceID: inst.id, kind: inst.kind, sourceIdentifier: inst.sourceIdentifier, configJSON: inst.configJSON, node: node, sessionID: inst.sessionID ) case .disabled: if node.instruments.first(where: { $0.id == inst.id })?.attachment == .attached { try? await node.disposeInstrumentOnAgent(instanceID: inst.id) node.markInstrumentDetached(id: inst.id) } } } public func applyInstrumentConfig(_ instance: InstrumentInstance, configJSON: Data) async { if isHostedRemotelyLive(instance.sessionID) { collaboration.sendInstrumentUpdateConfig( sessionID: instance.sessionID, instanceID: instance.id, configJSON: configJSON ) return } var inst = instance inst.configJSON = configJSON try? store.save(inst) onSessionListChanged?(.instrumentUpdated(inst)) broadcastInstrumentUpdate(inst) guard let node = node(forSessionID: inst.sessionID) else { return } node.updateInstrumentConfig(id: inst.id, configJSON: configJSON) guard node.instruments.first(where: { $0.id == inst.id })?.attachment == .attached else { return } let configObject: JSONObject switch inst.kind { case .tracer: let config = (try? TracerConfig.decode(from: configJSON)) ?? TracerConfig() do { let paths = try compilerWorkspacePaths() configObject = try await compileTracerConfig(config, paths: paths) node.replaceComponentStatuses(instrumentID: inst.id, [:]) broadcastInstrumentStatus(instanceID: inst.id, sessionID: node.sessionID) } catch let failures as TracerHookCompileFailures { applyHookCompileFailures(failures, on: node, instrumentID: inst.id) return } catch { node.setInstrumentStatus(id: inst.id, .from(error: error, kind: .configInvalid)) broadcastInstrumentStatus(instanceID: inst.id, sessionID: node.sessionID) return } case .hookPack: let config = (try? HookPackConfig.decode(from: configJSON)) ?? HookPackConfig(packId: inst.sourceIdentifier, features: [:]) guard let pack = hookPacks.pack(withId: inst.sourceIdentifier) else { return } configObject = config.toAgentJSON(features: pack.manifest.features) case .codeShare: configObject = (try? JSONSerialization.jsonObject(with: configJSON, options: []) as? JSONObject) ?? [:] case .custom: let cfg = (try? CustomInstrumentConfig.decode(from: configJSON)) ?? CustomInstrumentConfig(defID: UUID(uuidString: inst.sourceIdentifier) ?? UUID()) guard let def = customInstrumentDef(for: cfg) else { return } configObject = cfg.toAgentJSON(def: def) } do { try await node.pushInstrumentConfig(instanceID: inst.id, config: configObject) node.clearInstrumentStatus(id: inst.id) broadcastInstrumentStatus(instanceID: inst.id, sessionID: node.sessionID) } catch { node.setInstrumentStatus(id: inst.id, .from(error: error, kind: .configInvalid)) broadcastInstrumentStatus(instanceID: inst.id, sessionID: node.sessionID) } if inst.kind == .tracer { rebuildAddressAnnotations(sessionID: inst.sessionID) } } private func loadInstrumentOnNode( instanceID: UUID, kind: InstrumentKind, sourceIdentifier: String, configJSON: Data, node: ProcessNode, sessionID: UUID ) async { do { switch kind { case .tracer: try await loadTracerInstrument( instanceID: instanceID, config: (try? TracerConfig.decode(from: configJSON)) ?? TracerConfig(), sessionID: sessionID, paths: try compilerWorkspacePaths() ) case .hookPack: guard let pack = hookPacks.pack(withId: sourceIdentifier) else { return } if await skipIfIncompatible(instanceID: instanceID, compatibility: pack.manifest.compatibility, on: node) { return } try await loadHookPackInstrument( instanceID: instanceID, pack: pack, configJSON: configJSON, on: node ) case .codeShare: let cfg = try JSONDecoder().decode(CodeShareConfig.self, from: configJSON) try await loadCodeShareInstrument( instanceID: instanceID, config: cfg, configJSON: configJSON, on: node ) case .custom: let cfg = (try? CustomInstrumentConfig.decode(from: configJSON)) ?? CustomInstrumentConfig(defID: UUID(uuidString: sourceIdentifier) ?? UUID()) guard let bundle = customInstrumentBundle(for: cfg) else { return } if await skipIfIncompatible(instanceID: instanceID, compatibility: bundle.def.compatibility, on: node) { return } try await loadCustomInstrument( instanceID: instanceID, bundle: bundle, config: cfg, on: node ) } node.markInstrumentAttached(id: instanceID) node.replaceComponentStatuses(instrumentID: instanceID, [:]) broadcastInstrumentStatus(instanceID: instanceID, sessionID: node.sessionID) } catch let failures as TracerHookCompileFailures { applyHookCompileFailures(failures, on: node, instrumentID: instanceID) } catch { node.setInstrumentStatus(id: instanceID, .from(error: error, kind: .load)) broadcastInstrumentStatus(instanceID: instanceID, sessionID: node.sessionID) } } private func applyHookCompileFailures( _ failures: TracerHookCompileFailures, on node: ProcessNode, instrumentID: UUID ) { let statuses = failures.hookErrors.mapValues { InstrumentStatus.from(error: $0, kind: .configInvalid) } node.clearInstrumentStatus(id: instrumentID) node.replaceComponentStatuses(instrumentID: instrumentID, statuses) broadcastInstrumentStatus(instanceID: instrumentID, sessionID: node.sessionID) } private func skipIfIncompatible( instanceID: UUID, compatibility: InstrumentCompatibility, on node: ProcessNode ) async -> Bool { guard !compatibility.isUniversal else { node.clearInstrumentStatus(id: instanceID) return false } guard let params = await systemParameters.parameters(for: node.device) else { node.clearInstrumentStatus(id: instanceID) return false } guard let reason = compatibility.incompatibilityReason(for: params) else { node.clearInstrumentStatus(id: instanceID) return false } node.setInstrumentStatus(id: instanceID, .incompatible(reason: reason)) broadcastInstrumentStatus(instanceID: instanceID, sessionID: node.sessionID) return true } public func addTracerHook( sessionID: UUID, address: UInt64, kind: TracerHookKind, code: String? = nil, preferredAnchor: AddressAnchor? = nil, preferredDisplayName: String? = nil ) async -> (instrumentID: UUID, hookID: UUID)? { guard (try? store.fetchSession(id: sessionID)) != nil else { return nil } let anchor: AddressAnchor if let preferredAnchor { anchor = preferredAnchor } else if let node = node(forSessionID: sessionID) { anchor = node.anchor(for: address) } else { anchor = .absolute(address) } let displayName = preferredDisplayName?.isEmpty == false ? preferredDisplayName! : anchor.symbolName let hookCode = code ?? defaultTracerCode(kind: kind, anchor: anchor, displayName: displayName) let newHook = TracerConfig.Hook( id: UUID(), displayName: displayName, addressAnchor: anchor, kind: kind, code: hookCode ) if let existing = tracerInstance(forSessionID: sessionID) { var config = (try? TracerConfig.decode(from: existing.configJSON)) ?? TracerConfig() if let existingID = await existingHookID(in: config, anchor: anchor, sessionID: sessionID) { return (instrumentID: existing.id, hookID: existingID) } config.hooks.append(newHook) await applyInstrumentConfig(existing, configJSON: config.encode()) return (instrumentID: existing.id, hookID: newHook.id) } var initialConfig = TracerConfig() initialConfig.hooks.append(newHook) guard let added = await addInstrument( kind: .tracer, sourceIdentifier: "builtin.tracer", configJSON: initialConfig.encode(), sessionID: sessionID ) else { return nil } return (instrumentID: added.id, hookID: newHook.id) } private func existingHookID(in config: TracerConfig, anchor: AddressAnchor, sessionID: UUID) async -> UUID? { if let exact = config.hooks.first(where: { $0.addressAnchor == anchor }) { return exact.id } guard let node = node(forSessionID: sessionID), let target = try? await node.resolve(anchor) else { return nil } for hook in config.hooks where hook.addressAnchor != anchor { if let resolved = try? await node.resolve(hook.addressAnchor), resolved == target { return hook.id } } return nil } public func registerAddressActionProvider(_ provider: @escaping AddressActionProvider) { addressActionProviders.append(provider) } public func registerThreadActionProvider(_ provider: @escaping ThreadActionProvider) { threadActionProviders.append(provider) } public func threadActions(sessionID: UUID, thread: ProcessThread) -> [ThreadAction] { threadActionProviders.flatMap { $0(sessionID, thread) } } public func addressActions( sessionID: UUID, address: UInt64, context: AddressContext = AddressContext(), facts: AddressFacts ) -> [AddressAction] { addressActionProviders.flatMap { $0(sessionID, address, context, facts) } } private func tracerAddressActions( sessionID: UUID, address: UInt64, context: AddressContext, facts: AddressFacts ) -> [AddressAction] { if let tracerID = tracerInstanceIDBySession[sessionID], let hookID = addressAnnotations[sessionID]?[address]?.tracerHookID { return [ AddressAction( title: "Go to Hook", systemImage: "arrow.turn.down.right", perform: { .instrumentComponent(sessionID: sessionID, instrumentID: tracerID, componentID: hookID) } ) ] } guard facts.mapping == .executable else { return [] } var actions: [AddressAction] = [] if facts.isFunctionStart { actions.append(tracerHookAction(sessionID: sessionID, address: address, kind: .function, title: "Add Function Hook\u{2026}", anchor: context.anchorHint)) } actions.append(tracerHookAction(sessionID: sessionID, address: address, kind: .instruction, title: "Add Instruction Hook\u{2026}", anchor: context.anchorHint)) return actions } private func tracerHookAction( sessionID: UUID, address: UInt64, kind: TracerHookKind, title: String, anchor: AddressAnchor? ) -> AddressAction { AddressAction( title: title, systemImage: kind == .function ? "f.cursive" : "i.square", perform: { [weak self] in guard let self, let result = await self.addTracerHook( sessionID: sessionID, address: address, kind: kind, preferredAnchor: anchor ) else { return nil } return .instrumentComponent( sessionID: sessionID, instrumentID: result.instrumentID, componentID: result.hookID ) } ) } private struct ProtectionRegion { let range: Range let executable: Bool } private static let protectionPageMask: UInt64 = 0xfff public func addressFacts( sessionID: UUID, address: UInt64, context: AddressContext = AddressContext() ) async -> AddressFacts { switch context.kind { case .function: return AddressFacts(mapping: .executable, isFunctionStart: true) case .data: return AddressFacts(mapping: .data) case .code: guard node(forSessionID: sessionID) != nil else { return AddressFacts(mapping: .executable) } return AddressFacts(mapping: .executable, isFunctionStart: await resolveFunctionStart(sessionID: sessionID, address: address)) case .unspecified: break } guard node(forSessionID: sessionID) != nil else { return AddressFacts(mapping: .unmapped) } let mapping = await resolveMapping(sessionID: sessionID, address: address) guard mapping == .executable else { return AddressFacts(mapping: mapping) } let isFunctionStart = await resolveFunctionStart(sessionID: sessionID, address: address) return AddressFacts(mapping: .executable, isFunctionStart: isFunctionStart) } private func resolveMapping(sessionID: UUID, address: UInt64) async -> AddressFacts.Mapping { if let cached = cachedMapping(sessionID: sessionID, address: address) { return cached } let pageBase = address & ~Self.protectionPageMask if let inFlight = mappingProbesBySession[sessionID]?[pageBase] { return await inFlight.value } let probe = Task { [weak self] () -> AddressFacts.Mapping in await self?.fetchMapping(sessionID: sessionID, address: address, pageBase: pageBase) ?? .unmapped } mappingProbesBySession[sessionID, default: [:]][pageBase] = probe let mapping = await probe.value mappingProbesBySession[sessionID]?[pageBase] = nil return mapping } private func cachedMapping(sessionID: UUID, address: UInt64) -> AddressFacts.Mapping? { if let region = protectionRegionsBySession[sessionID]?.first(where: { $0.range.contains(address) }) { return region.executable ? .executable : .data } let pageBase = address & ~Self.protectionPageMask if unmappedPagesBySession[sessionID]?.contains(pageBase) == true { return .unmapped } return nil } private func fetchMapping(sessionID: UUID, address: UInt64, pageBase: UInt64) async -> AddressFacts.Mapping { if let cached = cachedMapping(sessionID: sessionID, address: address) { return cached } guard let node = node(forSessionID: sessionID) else { return .unmapped } guard let range = try? await node.findRangeByAddress(address) else { unmappedPagesBySession[sessionID, default: []].insert(pageBase) return .unmapped } let executable = range.protection.contains("x") protectionRegionsBySession[sessionID, default: []].append( ProtectionRegion(range: range.base..<(range.base &+ range.size), executable: executable)) return executable ? .executable : .data } private func invalidateAddressFacts(sessionID: UUID, identity: String) { guard factsIdentityBySession[sessionID] != identity else { return } factsIdentityBySession[sessionID] = identity protectionRegionsBySession[sessionID] = nil unmappedPagesBySession[sessionID] = nil functionStartsBySession[sessionID] = nil mappingProbesBySession[sessionID] = nil } private func resolveFunctionStart(sessionID: UUID, address: UInt64) async -> Bool { if let cached = functionStartsBySession[sessionID]?[address] { return cached } guard let disassembler = disassembler(forSessionID: sessionID) else { return false } let isFunctionStart = await disassembler.findFunctionStart(containing: address) == address functionStartsBySession[sessionID, default: [:]][address] = isFunctionStart return isFunctionStart } private func threadTraceActions(sessionID: UUID, thread: ProcessThread) -> [ThreadAction] { guard node(forSessionID: sessionID) != nil else { return [] } let threadID = thread.id let threadName = thread.name return [ ThreadAction( title: "Trace Thread\u{2026}", systemImage: "waveform", perform: { [weak self] in guard let self else { return nil } guard let trace = await self.startThreadTrace( sessionID: sessionID, threadID: threadID, threadName: threadName ) else { return nil } return .itrace(sessionID: sessionID, traceID: trace.id) } ) ] } @discardableResult public func startThreadTrace( sessionID: UUID, threadID: UInt, threadName: String? ) async -> ITrace? { guard let node = node(forSessionID: sessionID) else { return nil } let threadTrace = TracerConfig.ThreadTrace(threadID: threadID, threadName: threadName) let trace = ITrace( id: threadTrace.id, sessionID: sessionID, origin: .thread(threadID: threadID, threadName: threadName), displayName: threadName.map { "Thread trace: \($0)" } ?? "Thread trace: tid \(threadID)", startedAt: Date() ) try? store.save(trace) onSessionListChanged?(.traceUpdated(trace)) await addThreadTraceToTracer(sessionID: sessionID, threadTrace: threadTrace) if let sid = collabSessionID(forNode: node) { collaboration.enqueueUpsertTrace(sessionID: sid, trace: trace) } return trace } private func addThreadTraceToTracer(sessionID: UUID, threadTrace: TracerConfig.ThreadTrace) async { if let existing = tracerInstance(forSessionID: sessionID) { var config = (try? TracerConfig.decode(from: existing.configJSON)) ?? TracerConfig() config.threadTraces.append(threadTrace) await applyInstrumentConfig(existing, configJSON: config.encode()) return } var initialConfig = TracerConfig() initialConfig.threadTraces.append(threadTrace) _ = await addInstrument( kind: .tracer, sourceIdentifier: "builtin.tracer", configJSON: initialConfig.encode(), sessionID: sessionID ) } public func stopThreadTrace(traceID: UUID, sessionID: UUID) async { guard let existing = tracerInstance(forSessionID: sessionID) else { return } var config = (try? TracerConfig.decode(from: existing.configJSON)) ?? TracerConfig() guard config.threadTraces.contains(where: { $0.id == traceID }) else { return } config.threadTraces.removeAll { $0.id == traceID } await applyInstrumentConfig(existing, configJSON: config.encode()) } public func decodeTrace(traceID: UUID, sessionID: UUID) async -> DecodedITrace? { let sessionTraces = tracesBySession[sessionID] ?? [] guard let trace = sessionTraces.first(where: { $0.id == traceID }) else { return nil } do { let data = try await loadTraceData(traceID: traceID, sessionID: sessionID, expectedSize: trace.dataSize) return try ITraceDecoder.decode(traceData: data, metadataJSON: trace.metadataJSON) } catch { return nil } } public func rebuildAddressAnnotations(sessionID: UUID) { let node = node(forSessionID: sessionID) let resolve = anchorResolver(sessionID: sessionID, node: node) var map: [UInt64: AddressAnnotation] = [:] if let tracer = tracerInstance(forSessionID: sessionID), let config = try? TracerConfig.decode(from: tracer.configJSON) { tracerInstanceIDBySession[sessionID] = tracer.id for hook in config.hooks where hook.state == .enabled { guard let addr = resolve(hook.addressAnchor) else { continue } var ann = map[addr] ?? AddressAnnotation() ann.decorations.append(InstrumentAddressDecoration(help: "Has instruction hook")) ann.tracerHookID = hook.id map[addr] = ann } } else { tracerInstanceIDBySession[sessionID] = nil } for note in (try? store.fetchAddressNotes(sessionID: sessionID)) ?? [] { guard let addr = resolve(note.anchor) else { continue } var ann = map[addr] ?? AddressAnnotation() ann.noteCount += 1 map[addr] = ann } addressAnnotations[sessionID] = map } public func resolveSync(sessionID: UUID, anchor: AddressAnchor) -> UInt64? { anchorResolver(sessionID: sessionID, node: node(forSessionID: sessionID))(anchor) } private func anchorResolver(sessionID: UUID, node: ProcessNode?) -> (AddressAnchor) -> UInt64? { let modules = modulesSnapshot(forSessionID: sessionID) return { anchor in if let node, let addr = try? node.resolveSyncIfReady(anchor) { return addr } switch anchor { case .absolute(let a): return a case .moduleOffset(let name, let offset): return modules.first(where: { $0.name == name }).map { $0.base &+ offset } case .moduleExport, .objcMethod, .swiftFunc, .debugSymbol, .javaMethod: return nil } } } private func tracerInstance(forSessionID sessionID: UUID) -> InstrumentInstance? { let instruments = (try? store.fetchInstruments(sessionID: sessionID)) ?? [] return instruments.first(where: { $0.kind == .tracer }) } public func tracerHooks(forSessionID sessionID: UUID) -> [TracerConfig.Hook]? { guard let instance = tracerInstance(forSessionID: sessionID), let config = try? TracerConfig.decode(from: instance.configJSON) else { return nil } return config.hooks } public func tracerHook(sessionID: UUID, hookID: UUID) -> TracerConfig.Hook? { tracerHooks(forSessionID: sessionID)?.first(where: { $0.id == hookID }) } @discardableResult public func updateTracerHook( sessionID: UUID, hookID: UUID, _ mutate: (inout TracerConfig.Hook) -> Void ) async -> TracerConfig.Hook? { guard let instance = tracerInstance(forSessionID: sessionID) else { return nil } var config = (try? TracerConfig.decode(from: instance.configJSON)) ?? TracerConfig() guard let idx = config.hooks.firstIndex(where: { $0.id == hookID }) else { return nil } mutate(&config.hooks[idx]) let updated = config.hooks[idx] await applyInstrumentConfig(instance, configJSON: config.encode()) return updated } @discardableResult public func removeTracerHook(sessionID: UUID, hookID: UUID) async -> Bool { guard let instance = tracerInstance(forSessionID: sessionID) else { return false } var config = (try? TracerConfig.decode(from: instance.configJSON)) ?? TracerConfig() let originalCount = config.hooks.count config.hooks.removeAll(where: { $0.id == hookID }) guard config.hooks.count != originalCount else { return false } await applyInstrumentConfig(instance, configJSON: config.encode()) return true } public func compileTracerConfig( _ config: TracerConfig, paths: CompilerWorkspacePaths ) async throws -> JSONObject { _ = try await compilerWorkspace.ensureReady(paths: paths) let outcomes: [(Int, TracerConfig.Hook, Result)] = await withTaskGroup(of: (Int, TracerConfig.Hook, Result).self) { group in for (index, hook) in config.hooks.enumerated() { group.addTask { do { let js = try await self.compileTracerHook( id: hook.id, displayName: hook.displayName, tsSource: hook.code, paths: paths ) return (index, hook, .success(js)) } catch { return (index, hook, .failure(error)) } } } var out: [(Int, TracerConfig.Hook, Result)] = [] out.reserveCapacity(config.hooks.count) for await item in group { out.append(item) } return out } var hookErrors: [UUID: any Swift.Error] = [:] var results: [(Int, String, TracerConfig.Hook)] = [] for (index, hook, outcome) in outcomes { switch outcome { case .success(let js): results.append((index, js, hook)) case .failure(let error): hookErrors[hook.id] = error } } guard hookErrors.isEmpty else { throw TracerHookCompileFailures(hookErrors: hookErrors) } var hooksJSON: [JSONObject] = [] hooksJSON.reserveCapacity(results.count) for (_, js, hook) in results.sorted(by: { $0.0 < $1.0 }) { var dict: JSONObject = [ "id": hook.id.uuidString, "displayName": hook.displayName, "addressAnchor": hook.addressAnchor.toJSON(), "state": hook.state.rawValue, "code": js, ] if let arming = hook.itraceArming { dict["itraceArming"] = [ "maxInvocations": arming.maxInvocations, "maxBytesPerInvocation": arming.maxBytesPerInvocation, ] as [String: Any] } hooksJSON.append(dict) } return ["hooks": hooksJSON, "threadTraces": config.threadTraces.map(threadTraceJSON)] } private func threadTraceJSON(_ trace: TracerConfig.ThreadTrace) -> JSONObject { [ "id": trace.id.uuidString, "threadId": Int(trace.threadID), "threadName": trace.threadName ?? NSNull(), ] } private func compileTracerHook( id: UUID, displayName: String, tsSource: String, paths: CompilerWorkspacePaths ) async throws -> String { let fm = FileManager.default let dirRelPath = "TracerHooks" let dirURL = paths.root.appendingPathComponent(dirRelPath, isDirectory: true) if !fm.fileExists(atPath: dirURL.path) { try fm.createDirectory(at: dirURL, withIntermediateDirectories: true) } let typingsRelPath = "\(dirRelPath)/luma-tracer-typings.ts" let moduleRelPath = "\(dirRelPath)/\(id.uuidString).ts" let entryRelPath = "\(dirRelPath)/\(id.uuidString).entry.ts" let typingsURL = paths.root.appendingPathComponent(typingsRelPath) let moduleURL = paths.root.appendingPathComponent(moduleRelPath) let entryURL = paths.root.appendingPathComponent(entryRelPath) try TracerTypings.handlerDeclarations.write(to: typingsURL, atomically: true, encoding: .utf8) try tsSource.write(to: moduleURL, atomically: true, encoding: .utf8) let entrySource = """ import "./luma-tracer-typings.ts"; import "./\(id.uuidString).ts"; export {}; """ try entrySource.write(to: entryURL, atomically: true, encoding: .utf8) let hookID = id.uuidString let userPath = "TracerHooks/\(hookID).ts" let entryPath = "TracerHooks/\(hookID).entry.ts" let bundle = try await compilerWorkspace.withCompilerDiagnostics( label: "tracer hook \(hookID)", pathDisplay: { path in if path.hasSuffix(userPath) || path.hasSuffix(entryPath) { return displayName } return path } ) { compiler in try await compiler.build( entrypoint: entryRelPath, projectRoot: paths.root.path, sourceMaps: .omitted, compression: .terser) } let modules = try ESMBundleParser.parse(bundle) return modules.modules[modules.order[0]]! } public func loadTracerInstrument( instanceID: UUID, config: TracerConfig, sessionID: UUID, paths: CompilerWorkspacePaths ) async throws { guard let node = node(forSessionID: sessionID) else { return } var compiled = try await compileTracerConfig(config, paths: paths) var counters: [String: Int] = [:] let traces = (try? store.fetchITraces(sessionID: sessionID)) ?? [] for trace in traces { if case .functionCall(let hookID, let callIndex) = trace.origin { let key = hookID.uuidString counters[key] = max(counters[key] ?? 0, callIndex + 1) } } if !counters.isEmpty { compiled["callCounters"] = counters } try await node.loadInstrumentOnAgent( instanceID: instanceID, moduleName: "/builtin/tracer.js", source: LumaAgent.tracerSource, config: compiled ) } public func loadHookPackInstrument( instanceID: UUID, pack: HookPack, configJSON: Data, on node: ProcessNode ) async throws { let config = try InstrumentConfigCodec.decode(HookPackConfig.self, from: configJSON) let paths = try compilerWorkspacePaths() let sourceSlug = "HookPacks/\(pack.id)" let files = try Self.readHookPackFiles(folderURL: pack.folderURL, manifest: pack.manifest) let compiledSource = try await compileTypeScriptInstrument( sourceSlug: sourceSlug, files: files, entrypoint: pack.manifest.entrypoint, paths: paths, diagnosticLabel: "hook pack \(pack.id)" ) let digest = SHA256.hash(data: Data(compiledSource.utf8)) let hashHex = digest.map { String(format: "%02x", $0) }.joined() let moduleName = "/\(sourceSlug)/\(hashHex).js" let restored = hydrateRestoredState(instanceID: instanceID, widgets: pack.manifest.widgets, on: node) try await node.loadInstrumentOnAgent( instanceID: instanceID, moduleName: moduleName, source: compiledSource, config: config.toAgentJSON(features: pack.manifest.features), restored: restored ) } public func loadCustomInstrument( instanceID: UUID, bundle: CustomInstrumentBundle, config: CustomInstrumentConfig, on node: ProcessNode ) async throws { let def = bundle.def let paths = try compilerWorkspacePaths() let sourceSlug = "Custom/\(def.id.uuidString)" let files = bundle.files.map { (path: $0.path, content: Data($0.content.utf8)) } let compiledSource = try await compileTypeScriptInstrument( sourceSlug: sourceSlug, files: files, entrypoint: def.entrypoint, paths: paths, diagnosticLabel: "custom instrument \(def.id.uuidString)" ) let digest = SHA256.hash(data: Data(compiledSource.utf8)) let hashHex = digest.map { String(format: "%02x", $0) }.joined() let moduleName = "/\(sourceSlug)/\(hashHex).js" let restored = hydrateRestoredState(instanceID: instanceID, widgets: def.widgets, on: node) try await node.loadInstrumentOnAgent( instanceID: instanceID, moduleName: moduleName, source: compiledSource, config: config.toAgentJSON(def: def), restored: restored ) } private func hydrateRestoredState( instanceID: UUID, widgets: [InstrumentWidget], on node: ProcessNode ) -> [String: Any] { let persistentWidgets = widgets.filter { $0.persistence == .session } guard !persistentWidgets.isEmpty else { return [:] } let states = (try? store.fetchWidgetStates(instanceID: instanceID)) ?? [:] let persistentIDs = Set(persistentWidgets.map(\.id)) widgetStates[instanceID] = states.filter { persistentIDs.contains($0.key) } for widget in persistentWidgets { let state = states[widget.id] ?? WidgetState() _widgetUpdates.yield(WidgetUpdate( instanceID: instanceID, widget: widget.id, kind: .snapshot(state) )) } var restored: [String: Any] = [:] for widget in persistentWidgets { let state = states[widget.id] ?? WidgetState() switch widget.kind { case .counter: if let counter = state.counter { var obj: [String: Any] = ["value": counter.value] if let unit = counter.unit { obj["unit"] = unit } if let delta = counter.delta { obj["delta"] = delta } restored[widget.id] = ["counter": obj] } else { restored[widget.id] = ["counter": NSNull()] } case .histogram: let buckets = state.histogram.map { ["label": $0.label, "count": $0.count] } restored[widget.id] = ["buckets": buckets] case .graph: let points = state.graphSeries.flatMap { (seriesID, pts) -> [[String: Any]] in pts.map { ["series": seriesID, "x": $0.x, "y": $0.y] } } restored[widget.id] = ["points": points] case .list: let items = state.listItems.map { item -> [String: Any] in var obj: [String: Any] = ["id": item.id, "title": item.title] if let s = item.subtitle { obj["subtitle"] = s } if let a = item.accessory { obj["accessory"] = a } return obj } restored[widget.id] = ["items": items] case .table: let rows = state.tableRows.map { row -> [String: Any] in ["id": row.id, "cells": row.cells] } restored[widget.id] = ["rows": rows] case .hex: if let hex = state.hex { restored[widget.id] = [ "hex": [ "bytes": hex.bytes.base64EncodedString(), "base_address": hex.baseAddress, ], ] } else { restored[widget.id] = ["hex": NSNull()] } case .console: restored[widget.id] = ["entries": state.consoleEntries.map { $0.toWireJSON() }] } } return restored } public func widgetState(instanceID: UUID, widget: String) -> WidgetState { widgetStates[instanceID]?[widget] ?? WidgetState() } private func hydrateWidgetStatesFromStore(_ grouped: [UUID: [InstrumentInstance]]) { for instances in grouped.values { for instance in instances { guard widgetStates[instance.id] == nil else { continue } let widgets = widgets(forInstance: instance) let persistent = widgets.filter { $0.persistence == .session } guard !persistent.isEmpty else { continue } let states = (try? store.fetchWidgetStates(instanceID: instance.id)) ?? [:] let persistentIDs = Set(persistent.map(\.id)) let filtered = states.filter { persistentIDs.contains($0.key) } widgetStates[instance.id] = filtered for widget in persistent { let state = filtered[widget.id] ?? WidgetState() _widgetUpdates.yield(WidgetUpdate( instanceID: instance.id, widget: widget.id, kind: .snapshot(state) )) } } } } private func widgets(forInstance instance: InstrumentInstance) -> [InstrumentWidget] { switch instance.kind { case .custom: guard let defID = UUID(uuidString: instance.sourceIdentifier), let def = customInstruments.def(withId: defID) else { return [] } return def.widgets case .hookPack: return hookPacks.pack(withId: instance.sourceIdentifier)?.manifest.widgets ?? [] default: return [] } } private func compileTypeScriptInstrument( sourceSlug: String, files: [(path: String, content: Data)], entrypoint: String, paths: CompilerWorkspacePaths, diagnosticLabel: String ) async throws -> String { _ = try await compilerWorkspace.ensureReady(paths: paths) let fm = FileManager.default let dirRelPath = "InstrumentSources/\(sourceSlug)" let dirURL = paths.root.appendingPathComponent(dirRelPath, isDirectory: true) if fm.fileExists(atPath: dirURL.path) { try fm.removeItem(at: dirURL) } try fm.createDirectory(at: dirURL, withIntermediateDirectories: true) for file in files { let path = try CustomInstrumentFile.validateRelativePath(file.path) let fileURL = dirURL.appendingPathComponent(path) try fm.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) try file.content.write(to: fileURL) } let entryRelPath = "\(dirRelPath)/\(try CustomInstrumentFile.validateRelativePath(entrypoint))" let sourcePrefix = "\(dirRelPath)/" let bundle = try await compilerWorkspace.withCompilerDiagnostics( label: diagnosticLabel, pathDisplay: { path in guard let range = path.range(of: sourcePrefix) else { return path } return String(path[range.upperBound...]) } ) { compiler in try await compiler.build( entrypoint: entryRelPath, projectRoot: paths.root.path, sourceMaps: .omitted, compression: .terser) } let modules = try ESMBundleParser.parse(bundle) return modules.modules[modules.order[0]]! } private static func readHookPackFiles( folderURL: URL, manifest: HookPackManifest ) throws -> [(path: String, content: Data)] { let fm = FileManager.default let iconPath: String? = if case .file(let p) = manifest.icon { p } else { nil } let basePath = folderURL.standardizedFileURL.path let enumerator = fm.enumerator( at: folderURL, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] ) var result: [(path: String, content: Data)] = [] while let url = enumerator?.nextObject() as? URL { let isDir = (try url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false if isDir { continue } let absolute = url.standardizedFileURL.path let relativePath = String(absolute.dropFirst(basePath.count + 1)) if relativePath == "manifest.json" { continue } if relativePath == iconPath { continue } let data = try Data(contentsOf: url) result.append((path: try CustomInstrumentFile.validateRelativePath(relativePath), content: data)) } return result } private static func validatedCustomInstrumentBundle(_ bundle: CustomInstrumentBundle) throws -> CustomInstrumentBundle { let files = try bundle.files.map { file in CustomInstrumentFile( defID: file.defID, path: try CustomInstrumentFile.validateRelativePath(file.path), content: file.content ) } var def = bundle.def def.entrypoint = try validatedEntrypoint(def.entrypoint, files: files) return CustomInstrumentBundle(def: def, files: files) } private static func validatedEntrypoint(_ entrypoint: String, files: [CustomInstrumentFile]) throws -> String { let path = try CustomInstrumentFile.validateRelativePath(entrypoint) guard files.contains(where: { $0.path == path }) else { throw LumaCoreError.invalidArgument("Entrypoint '\(path)' does not exist in this custom instrument.") } return path } private func customInstrumentDef(for config: CustomInstrumentConfig) -> CustomInstrumentDef? { if let cached = customInstruments.def(withId: config.defID) { return cached } return try? store.fetchCustomInstrumentDef(id: config.defID) } private func customInstrumentBundle(for config: CustomInstrumentConfig) -> CustomInstrumentBundle? { if let cached = customInstruments.bundle(forDefID: config.defID) { return cached } guard let def = try? store.fetchCustomInstrumentDef(id: config.defID), let files = try? store.fetchCustomInstrumentFiles(defID: config.defID) else { return nil } return CustomInstrumentBundle(def: def, files: files) } @discardableResult public func createCustomInstrument( name: String = "Custom Instrument", icon: InstrumentIcon = .symbolic(InstrumentIconCatalog.default.id) ) -> CustomInstrumentDef { let now = Date() let def = CustomInstrumentDef( name: uniquedCustomInstrumentName(name), icon: icon, entrypoint: CustomInstrumentDef.defaultEntrypointFilename, features: [], createdAt: now, updatedAt: now ) let files = CustomInstrumentDef.defaultEntrypointFiles(defID: def.id) try? store.save(def) try? store.replaceCustomInstrumentFiles(defID: def.id, files: files) registerDescriptor(customInstruments.descriptor(for: def)) broadcastCustomInstrumentUpsert(defID: def.id) onSessionListChanged?(.customInstrumentDefsChanged) return def } public func updateCustomInstrument(_ def: CustomInstrumentDef) { var updated = def updated.normalize() updated.updatedAt = Date() try? store.save(updated) registerDescriptor(customInstruments.descriptor(for: updated)) broadcastCustomInstrumentUpsert(defID: updated.id) onSessionListChanged?(.customInstrumentDefsChanged) scheduleCustomInstrumentReload(defID: updated.id) } @discardableResult public func writeCustomInstrumentFile(defID: UUID, path: String, content: String) throws -> String { let validatedPath = try CustomInstrumentFile.validateRelativePath(path) let file = CustomInstrumentFile(defID: defID, path: validatedPath, content: content) _ = try requireCustomInstrumentDef(defID: defID) try store.save(file) try store.save(touchUpdatedAt(defID: defID)) broadcastCustomInstrumentUpsert(defID: defID) scheduleCustomInstrumentReload(defID: defID) return validatedPath } public func deleteCustomInstrumentFile(defID: UUID, path: String) throws { let validatedPath = try CustomInstrumentFile.validateRelativePath(path) let def = try requireCustomInstrumentDef(defID: defID) guard def.entrypoint != validatedPath else { throw LumaCoreError.invalidOperation("Cannot delete the entrypoint file.") } try store.deleteCustomInstrumentFile(defID: defID, path: validatedPath) try store.save(touchUpdatedAt(defID: defID)) broadcastCustomInstrumentUpsert(defID: defID) scheduleCustomInstrumentReload(defID: defID) } @discardableResult public func renameCustomInstrumentFile(defID: UUID, from: String, to: String) throws -> String { let fromPath = try CustomInstrumentFile.validateRelativePath(from) let toPath = try CustomInstrumentFile.validateRelativePath(to) guard try store.fetchCustomInstrumentFile(defID: defID, path: fromPath) != nil else { throw LumaCoreError.invalidArgument("No file '\(fromPath)' in this custom instrument.") } try store.renameCustomInstrumentFile(defID: defID, from: fromPath, to: toPath) var def = try touchUpdatedAt(defID: defID) if def.entrypoint == fromPath { def.entrypoint = toPath } try store.save(def) broadcastCustomInstrumentUpsert(defID: defID) scheduleCustomInstrumentReload(defID: defID) return toPath } @discardableResult public func setCustomInstrumentEntrypoint(defID: UUID, path: String) throws -> String { let validatedPath = try CustomInstrumentFile.validateRelativePath(path) guard try store.fetchCustomInstrumentFile(defID: defID, path: validatedPath) != nil else { throw LumaCoreError.invalidArgument("No file '\(validatedPath)' in this custom instrument.") } var def = try touchUpdatedAt(defID: defID) def.entrypoint = validatedPath try store.save(def) broadcastCustomInstrumentUpsert(defID: defID) scheduleCustomInstrumentReload(defID: defID) return validatedPath } private func scheduleCustomInstrumentReload(defID: UUID) { if runningCustomInstrumentReloads.contains(defID) { pendingCustomInstrumentReloads.insert(defID) return } runningCustomInstrumentReloads.insert(defID) Task { [weak self] in await self?.drainCustomInstrumentReloads(defID: defID) } } private func drainCustomInstrumentReloads(defID: UUID) async { repeat { pendingCustomInstrumentReloads.remove(defID) await reloadCustomInstrumentInstances(defID: defID) } while pendingCustomInstrumentReloads.contains(defID) runningCustomInstrumentReloads.remove(defID) let waiters = customInstrumentReloadWaiters.removeValue(forKey: defID) ?? [] for cont in waiters { cont.resume() } } public func awaitCustomInstrumentReload(defID: UUID) async { guard runningCustomInstrumentReloads.contains(defID) else { return } await withCheckedContinuation { (cont: CheckedContinuation) in customInstrumentReloadWaiters[defID, default: []].append(cont) } } private func requireCustomInstrumentDef(defID: UUID) throws -> CustomInstrumentDef { if let cached = customInstruments.def(withId: defID) { return cached } if let stored = try store.fetchCustomInstrumentDef(id: defID) { return stored } throw LumaCoreError.invalidArgument("No custom instrument with id \(defID).") } private func touchUpdatedAt(defID: UUID) throws -> CustomInstrumentDef { var def = try requireCustomInstrumentDef(defID: defID) def.updatedAt = Date() return def } @discardableResult public func forkHookPackToCustomInstrument(folderURL: URL) throws -> CustomInstrumentDef { let manifestURL = folderURL.appendingPathComponent("manifest.json") let manifestData = try Data(contentsOf: manifestURL) let manifest = try JSONDecoder().decode(HookPackManifest.self, from: manifestData) let icon: InstrumentIcon switch manifest.icon { case nil: icon = .symbolic(InstrumentIconCatalog.default.id) case .symbolic(let id): icon = .symbolic(id) case .file(let path): let iconURL = folderURL.appendingPathComponent(try CustomInstrumentFile.validateRelativePath(path)) icon = .pixels(try Data(contentsOf: iconURL)) } let packFiles = try Self.readHookPackFiles(folderURL: folderURL, manifest: manifest) let now = Date() let defID = UUID() let files = try packFiles.map { pf in CustomInstrumentFile( defID: defID, path: try CustomInstrumentFile.validateRelativePath(pf.path), content: String(decoding: pf.content, as: UTF8.self) ) } var def = CustomInstrumentDef( id: defID, name: uniquedCustomInstrumentName(manifest.name), icon: icon, compatibility: manifest.compatibility, entrypoint: try Self.validatedEntrypoint(manifest.entrypoint, files: files), features: manifest.features, widgets: manifest.widgets, createdAt: now, updatedAt: now ) def.normalize() try store.save(def) try store.replaceCustomInstrumentFiles(defID: defID, files: files) registerDescriptor(customInstruments.descriptor(for: def)) broadcastCustomInstrumentUpsert(defID: defID) onSessionListChanged?(.customInstrumentDefsChanged) return def } public func buildHookPackBundle(for def: CustomInstrumentDef) throws -> HookPackBundle { let iconAttachment: HookPackBundle.IconAttachment? let manifestIcon: HookPackManifest.Icon? switch def.icon { case .symbolic(let id): manifestIcon = .symbolic(id) iconAttachment = nil case .pixels(let data): let iconName = "icon.png" manifestIcon = .file(iconName) iconAttachment = HookPackBundle.IconAttachment(filename: iconName, data: data) } let defFiles = try customInstruments.files(forDefID: def.id).map { file in CustomInstrumentFile( defID: file.defID, path: try CustomInstrumentFile.validateRelativePath(file.path), content: file.content ) } let entrypoint = try Self.validatedEntrypoint(def.entrypoint, files: defFiles) let manifest = HookPackManifest( name: def.name, icon: manifestIcon, compatibility: def.compatibility, entrypoint: entrypoint, features: def.features, widgets: def.widgets ) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let manifestData = try encoder.encode(manifest) let bundleFiles = defFiles.map { HookPackBundle.File(path: $0.path, content: Data($0.content.utf8)) } return HookPackBundle( manifestData: manifestData, files: bundleFiles, icon: iconAttachment ) } public func exportCustomInstrumentAsHookPack(_ def: CustomInstrumentDef, to folderURL: URL) throws { let bundle = try buildHookPackBundle(for: def) let fm = FileManager.default try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) try bundle.manifestData.write(to: folderURL.appendingPathComponent("manifest.json")) for file in bundle.files { let fileURL = folderURL.appendingPathComponent(file.path) try fm.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) try file.content.write(to: fileURL) } if let icon = bundle.icon { try icon.data.write(to: folderURL.appendingPathComponent(icon.filename)) } } public func clearWidget(instance: InstrumentInstance, widget: String) { applyWidgetUpdate( WidgetUpdate(instanceID: instance.id, widget: widget, kind: .clear), sessionID: instance.sessionID, origin: .local ) } private enum WidgetUpdateOrigin { case local case remote } private func handleRemoteWidgetActionRequest( sessionID: UUID, instanceID: UUID, widget: String, action: String, item: String? ) { guard let instance = instrumentsBySession[sessionID]?.first(where: { $0.id == instanceID }) else { return } Task { @MainActor [weak self] in await self?.invokeWidgetAction(instance: instance, widget: widget, action: action, item: item) } } private func applyWidgetStateSnapshots(_ snapshots: [WidgetStateSnapshot]) { for snapshot in snapshots { widgetStates[snapshot.instanceID, default: [:]][snapshot.widget] = snapshot.state _widgetUpdates.yield(WidgetUpdate( instanceID: snapshot.instanceID, widget: snapshot.widget, kind: .snapshot(snapshot.state) )) } } private func applyWidgetUpdate(_ update: WidgetUpdate, sessionID: UUID, origin: WidgetUpdateOrigin) { let widgetDef = widget(forInstanceID: update.instanceID, widgetID: update.widget) var states = widgetStates[update.instanceID, default: [:]] var state = states[update.widget, default: WidgetState()] state.apply(update.kind) if let widgetDef { state.cap(to: widgetDef.kind) } states[update.widget] = state widgetStates[update.instanceID] = states _widgetUpdates.yield(update) if widgetDef?.persistence == .session { try? store.saveWidgetState( instanceID: update.instanceID, widgetID: update.widget, sessionID: sessionID, state: state ) } if origin == .local, let collabSID = collabSessionID(forSessionID: sessionID) { collaboration.sendWidgetUpdate(sessionID: collabSID, update: update) } } public func submitConsoleInput( instance: InstrumentInstance, widget: String, text: String ) async { let entry = WidgetConsoleEntry(kind: .input, text: text) await dispatchConsoleInput(instance: instance, widget: widget, entry: entry) } public struct ConsoleResponse: Sendable { public let inputEntryID: String public let replies: [WidgetConsoleEntry] } /// Submits a console input and awaits replies tagged with `replyTo == /// inputEntry.id`. Returns as soon as the agent signals /// `consoleReplyDone(inputEntryID)`, or when `timeout` elapses. /// Intended for MCP-style request/response flows. public func submitConsoleInputAndAwait( instance: InstrumentInstance, widget: String, text: String, timeout: Duration = .seconds(30) ) async -> ConsoleResponse { let entry = WidgetConsoleEntry(kind: .input, text: text) let inputID = entry.id let instanceID = instance.id let updates = widgetUpdates await dispatchConsoleInput(instance: instance, widget: widget, entry: entry) let buffer = ConsoleReplyBuffer() let collector = Task { @MainActor in for await update in updates { if Task.isCancelled { return } guard update.instanceID == instanceID, update.widget == widget else { continue } switch update.kind { case .consoleAppend(let candidate) where candidate.replyTo == inputID: await buffer.append(candidate) case .consoleReplyDone(let id) where id == inputID: return default: continue } } } let deadline = Task { try? await Task.sleep(for: timeout) collector.cancel() } _ = await collector.value deadline.cancel() return ConsoleResponse(inputEntryID: inputID, replies: await buffer.snapshot()) } private func dispatchConsoleInput( instance: InstrumentInstance, widget: String, entry: WidgetConsoleEntry ) async { let update = WidgetUpdate(instanceID: instance.id, widget: widget, kind: .consoleAppend(entry)) applyWidgetUpdate(update, sessionID: instance.sessionID, origin: .local) guard let node = node(forSessionID: instance.sessionID), node.instruments.first(where: { $0.id == instance.id })?.attachment == .attached else { return } do { try await node.submitConsoleInput(instanceID: instance.id, widget: widget, entryID: entry.id, text: entry.text) } catch { emitEngineError(subsystem: "instruments", text: "Failed to submit console input on \(widget): \(userFacingMessage(error))") } } public func invokeWidgetAction( instance: InstrumentInstance, widget: String, action: String, item: String? = nil ) async { if isHostedRemotelyLive(instance.sessionID) { collaboration.sendWidgetAction( sessionID: instance.sessionID, instanceID: instance.id, widget: widget, action: action, item: item ) return } guard let node = node(forSessionID: instance.sessionID), node.instruments.first(where: { $0.id == instance.id })?.attachment == .attached else { return } do { try await node.invokeWidgetAction(instanceID: instance.id, widget: widget, action: action, item: item) } catch { emitEngineError(subsystem: "instruments", text: "Failed to invoke widget action \(widget).\(action): \(userFacingMessage(error))") } } public func deleteCustomInstrument(_ defID: UUID) async { let key = defID.uuidString let doomedInstances = instrumentsBySession.values .flatMap { $0 } .filter { $0.kind == .custom && $0.sourceIdentifier == key } for instance in doomedInstances { await removeInstrument(instance) } try? store.deleteCustomInstrumentDef(id: defID) broadcastCustomInstrumentRemove(defID: defID) onSessionListChanged?(.customInstrumentDefsChanged) } private func uniquedCustomInstrumentName(_ proposed: String) -> String { let existing = Set(customInstruments.defs.map(\.name)) if !existing.contains(proposed) { return proposed } var n = 2 while existing.contains("\(proposed) \(n)") { n += 1 } return "\(proposed) \(n)" } private func reloadCustomInstrumentInstances(defID: UUID) async { guard let def = try? store.fetchCustomInstrumentDef(id: defID), let storeFiles = try? store.fetchCustomInstrumentFiles(defID: defID) else { return } let bundle = CustomInstrumentBundle(def: def, files: storeFiles) let key = defID.uuidString for (sessionID, instances) in instrumentsBySession { for inst in instances where inst.kind == .custom && inst.sourceIdentifier == key && inst.state == .enabled { guard let node = node(forSessionID: sessionID) else { continue } let wasAttached = node.instruments.first(where: { $0.id == inst.id })?.attachment == .attached let originalCfg = (try? CustomInstrumentConfig.decode(from: inst.configJSON)) ?? CustomInstrumentConfig(defID: defID) let cfg = originalCfg.normalized(against: def) let liveInstance = persistNormalizedConfigIfChanged( instance: inst, originalConfig: originalCfg, normalizedConfig: cfg ) if wasAttached { do { try await node.disposeInstrumentOnAgent(instanceID: liveInstance.id) } catch { emitEngineError( subsystem: "instruments", text: "Failed to dispose custom instance \(liveInstance.id): \(userFacingMessage(error))" ) } node.markInstrumentDetached(id: liveInstance.id) } if await skipIfIncompatible(instanceID: liveInstance.id, compatibility: def.compatibility, on: node) { onSessionListChanged?(.instrumentUpdated(liveInstance)) continue } do { try await loadCustomInstrument( instanceID: liveInstance.id, bundle: bundle, config: cfg, on: node ) node.markInstrumentAttached(id: liveInstance.id) broadcastInstrumentStatus(instanceID: liveInstance.id, sessionID: node.sessionID) } catch { node.setInstrumentStatus(id: liveInstance.id, .from(error: error, kind: .reload)) broadcastInstrumentStatus(instanceID: liveInstance.id, sessionID: node.sessionID) } onSessionListChanged?(.instrumentUpdated(liveInstance)) } } } private func persistNormalizedConfigIfChanged( instance: InstrumentInstance, originalConfig: CustomInstrumentConfig, normalizedConfig: CustomInstrumentConfig ) -> InstrumentInstance { guard normalizedConfig != originalConfig else { return instance } var updated = instance updated.configJSON = normalizedConfig.encode() try? store.save(updated) node(forSessionID: updated.sessionID)?.updateInstrumentConfig(id: updated.id, configJSON: updated.configJSON) onSessionListChanged?(.instrumentUpdated(updated)) broadcastInstrumentUpdate(updated) return updated } private func broadcastCustomInstrumentUpsert(defID: UUID) { guard let def = try? store.fetchCustomInstrumentDef(id: defID), let files = try? store.fetchCustomInstrumentFiles(defID: defID) else { return } let bundle = CustomInstrumentBundle(def: def, files: files) let op = CustomInstrumentOp.upsert(.init(bundle: bundle)) try? store.saveCustomInstrumentOutboxOp(op) collaboration.sendCustomInstrumentOpIfJoined(op) } private func broadcastCustomInstrumentRemove(defID: UUID) { let op = CustomInstrumentOp.remove(.init(defID: defID)) try? store.saveCustomInstrumentOutboxOp(op) collaboration.sendCustomInstrumentOpIfJoined(op) } public func loadCodeShareInstrument( instanceID: UUID, config: CodeShareConfig, configJSON: Data, on node: ProcessNode ) async throws { let configObject: Any if configJSON.isEmpty { configObject = [:] } else { configObject = try JSONSerialization.jsonObject(with: configJSON, options: []) } let data = Data(config.source.utf8) let digest = SHA256.hash(data: data) let hashHex = digest.map { String(format: "%02x", $0) }.joined() let moduleName = "/codeshare/\(config.project?.slug ?? config.name)/\(hashHex).js" try await node.loadInstrumentOnAgent( instanceID: instanceID, moduleName: moduleName, source: LumaAgent.codeShareSource, config: configObject ) } public func getOrCreateInsight( sessionID: UUID, pointer: UInt64, kind: AddressInsight.Kind, preferredAnchor: AddressAnchor? = nil ) throws -> AddressInsight { let anchor = preferredAnchor ?? anchor(sessionID: sessionID, address: pointer) let existing = (try? store.fetchInsights(sessionID: sessionID)) ?? [] if let match = existing.first(where: { $0.kind == kind && $0.anchor == anchor }) { return match } if let match = existing.first(where: { $0.kind == kind && $0.lastResolvedAddress == pointer }) { return match } var insight = AddressInsight( sessionID: sessionID, kind: kind, anchor: anchor ) insight.lastResolvedAddress = pointer try store.save(insight) upsertInsightCache(insight) onSessionListChanged?(.insightAdded(insight)) if let node = node(forSessionID: sessionID), let sid = collabSessionID(forNode: node) { collaboration.enqueueAddInsight(sessionID: sid, insight: insight) } Task { @MainActor [weak self] in await self?.resolveInsightHierarchy(insightID: insight.id, sessionID: sessionID, address: pointer) } return insight } private func resolveInsightHierarchy(insightID: UUID, sessionID: UUID, address: UInt64) async { guard let dis = disassembler(forSessionID: sessionID), let begin = await dis.findFunctionStart(containing: address) else { return } let siblings = (try? store.fetchInsights(sessionID: sessionID)) ?? [] guard let self = (try? store.fetchInsights(sessionID: sessionID))?.first(where: { $0.id == insightID }) else { return } if begin != address { if let parent = siblings.first(where: { $0.lastResolvedAddress == begin }), self.parentInsightID != parent.id { var updated = self updated.parentInsightID = parent.id persistInsight(updated) collaboration.enqueueUpdateInsight(sessionID: sessionID, insight: updated) emitInsightUpdated(updated) } return } guard let end = await dis.findFunctionEnd(containing: address), end > begin else { return } for sibling in siblings where sibling.id != insightID && sibling.parentInsightID == nil { guard let addr = sibling.lastResolvedAddress, addr > begin, addr < end else { continue } var updated = sibling updated.parentInsightID = insightID persistInsight(updated) collaboration.enqueueUpdateInsight(sessionID: sessionID, insight: updated) emitInsightUpdated(updated) } } public static func parseTracerEvent(from value: JSInspectValue) -> ( id: UUID, timestamp: Double, threadId: Int, depth: Int, caller: JSInspectValue, backtrace: [JSInspectValue]?, message: JSInspectValue )? { guard case .array(_, let elements) = value, elements.count == 7 else { return nil } guard case .string(let rawId) = elements[0], let id = UUID(uuidString: rawId) else { return nil } guard case .number(let timestamp) = elements[1] else { return nil } guard case .number(let threadIdNum) = elements[2], threadIdNum.isFinite, threadIdNum.rounded(.towardZero) == threadIdNum else { return nil } guard case .number(let depthNum) = elements[3], depthNum.isFinite, depthNum.rounded(.towardZero) == depthNum else { return nil } let caller = elements[4] guard case .nativePointer = caller else { return nil } guard case .array(_, let btElements) = elements[5] else { return nil } var ptrs: [JSInspectValue] = [] ptrs.reserveCapacity(btElements.count) for e in btElements { guard case .nativePointer = e else { return nil } ptrs.append(e) } guard case .array(_, _) = elements[6] else { return nil } return ( id: id, timestamp: timestamp, threadId: Int(threadIdNum), depth: Int(depthNum), caller: caller, backtrace: ptrs.isEmpty ? nil : ptrs, message: elements[6] ) } public func compilerWorkspacePaths() throws -> CompilerWorkspacePaths { let packagesState = try store.fetchPackagesState() let fm = FileManager.default let root = dataDirectory .appendingPathComponent("Projects", isDirectory: true) .appendingPathComponent(packagesState.id.uuidString, isDirectory: true) .appendingPathComponent("Workspace", isDirectory: true) if !fm.fileExists(atPath: root.path) { try fm.createDirectory(at: root, withIntermediateDirectories: true) } return CompilerWorkspacePaths(root: root) } private func collabSessionID(forNode node: ProcessNode) -> UUID? { guard let session = sessions.first(where: { $0.id == node.sessionID }), let host = session.host, host.id == collaboration.localUser?.id else { return nil } return session.id } private func collabSessionID(forSessionID sessionID: UUID) -> UUID? { guard collaboration.isCollaborative, sessions.contains(where: { $0.id == sessionID }) else { return nil } return sessionID } private func routeTracerError(_ event: RuntimeEvent, node: ProcessNode) -> Bool { guard case .instrument(let instrumentID, _) = event.source, tracerInstanceIDBySession[node.sessionID] == instrumentID, case .jsValue(let value) = event.payload, case .object(_, let properties) = value, jsStringProperty(properties, "type") == "tracer-error", let hookIDString = jsStringProperty(properties, "id"), let hookID = UUID(uuidString: hookIDString) else { return false } let message = jsStringProperty(properties, "message") ?? "Unable to install hook." var statuses = node.instruments.first(where: { $0.id == instrumentID })?.componentStatuses ?? [:] statuses[hookID] = .loadFailed(message: message, stack: nil) node.replaceComponentStatuses(instrumentID: instrumentID, statuses) broadcastInstrumentStatus(instanceID: instrumentID, sessionID: node.sessionID) return true } private func jsStringProperty(_ properties: [JSInspectValue.Property], _ name: String) -> String? { for property in properties { if case .string(let key) = property.key, key == name, case .string(let value) = property.value { return value } } return nil } private func subscribeToNodeStreams(_ node: ProcessNode) { let sessionID = node.sessionID Task { @MainActor [weak self, weak node] in guard let node else { return } for await reason in node.detachEvents { guard let self else { return } self.updateSession(id: sessionID) { $0.detachReason = reason $0.phase = .idle } if let sid = self.collabSessionID(forNode: node) { self.collaboration.enqueueUpdateSessionPhase( sessionID: sid, phase: .detached, reason: String(describing: reason) ) } self.removeNode(node) } } Task { @MainActor [weak self, weak node] in guard let node else { return } for await var event in node.events { event.sessionID = sessionID if self?.routeTracerError(event, node: node) == true { continue } self?._events.yield(event) if let sid = self?.collabSessionID(forNode: node) { switch event.source { case .instrument, .console, .script: self?.collaboration.sendEvent(sessionID: sid, event: event) case .repl, .processOutput, .spawnGating, .engine: break } } } } node.evaluateRadare2 = { [weak self] command in guard let self else { return .text("Session unavailable.") } return await self.radare2REPLValue(forCommand: command, sessionID: sessionID) } node.completeRadare2 = { [weak self] code, cursor in guard let self else { return [] } return await self.radare2REPLCompletions(forCode: code, cursor: cursor, sessionID: sessionID) } Task { @MainActor [weak self, weak node] in guard let node else { return } for await result in node.replResults { let resultValue: REPLCell.Result switch result.value { case .js(let v): resultValue = .js(v) case .text(let t): resultValue = .text(t) case .styled(let s): resultValue = .styled(s) case .binary(let d, let m): resultValue = .binary(d, meta: m) } let cell = REPLCell( id: result.id, sessionID: sessionID, code: result.code, language: result.language, result: resultValue, timestamp: result.timestamp ) try? self?.store.save(cell) self?.onREPLCellAdded?(cell) if let sid = self?.collabSessionID(forNode: node) { self?.collaboration.enqueueAddReplCell(sessionID: sid, cell: cell) } } } Task { @MainActor [weak self, weak node] in guard let node else { return } for await trace in node.traceUpdates { var stored = trace stored.sessionID = sessionID try? self?.store.save(stored) self?.onSessionListChanged?(.traceUpdated(stored)) if let sid = self?.collabSessionID(forNode: node) { self?.collaboration.enqueueUpsertTrace(sessionID: sid, trace: stored) await self?.uploadTraceDelta(stored, collabSessionID: sid) } } } Task { @MainActor [weak self, weak node] in guard let node else { return } for await delta in node.moduleDeltas { self?.updateSession(id: sessionID) { session in session.lastKnownModules = delta.applied(to: session.lastKnownModules) } self?.rebuildAddressAnnotations(sessionID: sessionID) self?.restoreReplSeek(sessionID: sessionID) if let sid = self?.collabSessionID(forNode: node) { self?.collaboration.enqueueUpdateSessionModules(sessionID: sid, delta: delta) } } } Task { @MainActor [weak self, weak node] in guard let node else { return } for await delta in node.threadDeltas { self?.updateSession(id: sessionID) { session in session.lastKnownThreads = delta.applied(to: session.lastKnownThreads) } if let sid = self?.collabSessionID(forNode: node) { self?.collaboration.enqueueUpdateSessionThreads(sessionID: sid, delta: delta) } } } Task { @MainActor [weak self, weak node] in guard let node else { return } for await update in node.widgetUpdates { self?.applyWidgetUpdate(update, sessionID: sessionID, origin: .local) } } } private func widget(forInstanceID instanceID: UUID, widgetID: String) -> InstrumentWidget? { guard let instance = instrumentsBySession.values.flatMap({ $0 }).first(where: { $0.id == instanceID }) else { return nil } switch instance.kind { case .custom: guard let defID = UUID(uuidString: instance.sourceIdentifier), let def = customInstruments.def(withId: defID) else { return nil } return def.widgets.first { $0.id == widgetID } case .hookPack: return hookPacks.pack(withId: instance.sourceIdentifier)?.manifest.widgets.first { $0.id == widgetID } default: return nil } } private func ensureDeviceEventsHooked(for device: Device) { guard deviceEventTasks[device.id] == nil else { return } deviceEventTasks[device.id] = Task { [weak self] in guard let self else { return } for await devEvent in device.events { switch devEvent { case .output(let data, let fd, let pid): self.handleDeviceOutput(device: device, data: data, fd: fd, pid: pid) case .spawnAdded(let details): await self.handleSpawnAdded(device: device, details: details) case .connecting(let status, let progress): self.connectionActivity.report( deviceID: device.id, deviceKind: device.type.description, status: status, fraction: progress ) case .connected: self.connectionActivity.complete(deviceID: device.id) case .lost: self.connectionActivity.complete(deviceID: device.id) self.deviceEventTasks[device.id]?.cancel() self.deviceEventTasks[device.id] = nil self.gatingEnabledDevices.remove(device.id) return default: break } } } } private func handleDeviceOutput(device: Device, data: [UInt8], fd: Int, pid: UInt) { guard let node = processNodes.first(where: { $0.deviceID == device.id && $0.pid == pid }) else { return } let message: String if var text = String(bytes: data, encoding: .utf8) { if text.hasSuffix("\r\n") || text.hasSuffix("\n") { text.removeLast() } message = text } else { message = "(\(data.count) bytes on fd \(fd))" } _events.yield(RuntimeEvent( sessionID: node.sessionID, source: .processOutput(fd: fd), payload: .raw(message: message, data: data), data: data )) } private func handleSpawnAdded(device: Device, details: SpawnDetails) async { let identifier = details.identifier ?? "" let pid = details.pid if let session = matchedArmedSession(forDeviceID: device.id, identifier: identifier) { await attachToGatedSpawn(device: device, pid: pid, session: session) emitSpawnGatingEvent( device: device, identifier: details.identifier, pid: pid, outcome: .captured, sessionID: session.id ) } else { try? await device.resume(pid: pid) emitSpawnGatingEvent( device: device, identifier: details.identifier, pid: pid, outcome: .released, sessionID: nil ) } } private func emitSpawnGatingEvent( device: Device, identifier: String?, pid: UInt, outcome: RuntimeEvent.SpawnGatingOutcome, sessionID: UUID? ) { let displayIdentifier = identifier ?? "(unnamed)" let message: String = { switch outcome { case .captured: return "Captured \(displayIdentifier) (pid \(pid))" case .released: return "Released \(displayIdentifier) (pid \(pid))" } }() let event = RuntimeEvent( sessionID: sessionID, source: .spawnGating( deviceID: device.id, deviceName: device.name, identifier: identifier, pid: pid, outcome: outcome ), payload: .raw(message: message, data: nil) ) _events.yield(event) if outcome == .captured, let sessionID, let collabID = collabSessionID(forSessionID: sessionID) { collaboration.sendEvent(sessionID: collabID, event: event) } } private func matchedArmedSession(forDeviceID deviceID: String, identifier: String) -> ProcessSession? { let candidates = sessions .filter { $0.deviceID == deviceID } .filter { node(forSessionID: $0.id) == nil } .compactMap { session -> (ProcessSession, Date)? in guard case .armed(_, let armedAt) = session.armingState else { return nil } return (session, armedAt) } .sorted { $0.1 < $1.1 } return candidates.first { matches(identifier: identifier, against: $0.0.armingState) }?.0 } private func matches(identifier: String, against state: ProcessSession.ArmingState) -> Bool { guard case .armed(let pattern, _) = state else { return false } guard let regex = try? NSRegularExpression(pattern: pattern) else { return false } let range = NSRange(identifier.startIndex..., in: identifier) return regex.firstMatch(in: identifier, range: range) != nil } private func attachToGatedSpawn(device: Device, pid: UInt, session: ProcessSession) async { do { let processes = try await device.enumerateProcesses(pids: [pid], scope: .full) guard let process = processes.first else { try? await device.resume(pid: pid) return } try await performAttach(device: device, process: process, session: session) } catch { try? await device.resume(pid: pid) updateSession(id: session.id) { $0.lastError = error.localizedDescription $0.phase = .idle } return } guard self.session(id: session.id)?.phase == .attached else { try? await device.resume(pid: pid) return } if shouldAutoResumeOnCapture(session) { try? await device.resume(pid: pid) } else { updateSession(id: session.id) { $0.phase = .awaitingInitialResume } } } private func shouldAutoResumeOnCapture(_ session: ProcessSession) -> Bool { if case .spawn(let config) = session.kind { return config.autoResume } return true } public func setMissionLiveDeltaSink( _ sink: (@MainActor (UUID, LLMTurnEvent) -> Void)? ) { externalMissionLiveDeltaSink = sink } public func missionLiveText(missionID: UUID) -> String { missionLiveTextByID[missionID] ?? "" } private func handleMissionLiveEvent(missionID: UUID, event: LLMTurnEvent) { switch event { case .textDelta(let text): missionLiveTextByID[missionID, default: ""] += text case .liveTextCleared: missionLiveTextByID.removeValue(forKey: missionID) default: break } externalMissionLiveDeltaSink?(missionID, event) } #if canImport(Network) || canImport(CSoup) public func registerActiveMCPServer(_ server: MCPServer, for missionID: UUID) { activeMCPServersByMissionID[missionID] = server } public func unregisterActiveMCPServer(for missionID: UUID) { activeMCPServersByMissionID.removeValue(forKey: missionID) } func purgeExternalMCPMissions() { guard let missions = try? store.fetchMissions() else { return } for mission in missions where mission.providerID == externalMCPProviderID { deleteMission(missionID: mission.id) } } public var isExternalMCPRunning: Bool { externalMCPServer != nil } public var externalMCPTrustsClient: Bool { get { LumaAppState.shared.externalMCPTrustsClient } set { LumaAppState.shared.externalMCPTrustsClient = newValue externalMCPServer?.trustsClientApprovals = newValue } } @discardableResult public func enableExternalMCPServer() async throws -> ExternalMCPInfo { if let server = externalMCPServer, let url = externalMCPURL, let id = externalMCPMissionID { return ExternalMCPInfo(url: url, bearerToken: server.bearerToken, missionID: id) } let token = try await loadOrMintExternalMCPToken() let preferredPort = await loadPreferredExternalMCPPort() purgeExternalMCPMissions() var mission = Mission( goalText: "External tool calls (via MCP)", providerID: externalMCPProviderID, modelID: externalMCPProviderID, tokenBudgetInput: 0, tokenBudgetOutput: 0 ) mission.status = .running try store.save(mission) collaboration.enqueueMissionUpsert(mission) externalMCPMissionID = mission.id let toolNames = missionTools.specs().map(\.name) let server = MCPServer( engine: self, resolveMission: { [weak self] in guard let self, let id = self.externalMCPMissionID else { return nil } return try? self.store.fetchMission(id: id) }, toolNames: toolNames, bearerToken: token ) server.trustsClientApprovals = LumaAppState.shared.externalMCPTrustsClient let url = try await server.start(preferredPort: preferredPort) externalMCPServer = server externalMCPURL = url registerActiveMCPServer(server, for: mission.id) if let port = url.port.flatMap({ UInt16(exactly: $0) }) { await savePreferredExternalMCPPort(port) } return ExternalMCPInfo(url: url, bearerToken: server.bearerToken, missionID: mission.id) } public func disableExternalMCPServer() async { guard let server = externalMCPServer else { return } if let id = externalMCPMissionID { unregisterActiveMCPServer(for: id) if var m = try? store.fetchMission(id: id) { m.status = .completed try? store.save(m) collaboration.enqueueMissionUpsert(m) } } server.stop() externalMCPServer = nil externalMCPURL = nil externalMCPMissionID = nil } @discardableResult public func rotateExternalMCPToken() async throws -> ExternalMCPInfo? { let wasRunning = isExternalMCPRunning if wasRunning { await disableExternalMCPServer() } try? await llmCredentials.backing.delete(service: Self.externalMCPCredentialService, account: Self.externalMCPCredentialAccount) if wasRunning { return try await enableExternalMCPServer() } return nil } #endif private let externalMCPProviderID = "external" private static let externalMCPCredentialService = "luma.mcp.external" private static let externalMCPCredentialAccount = "default" private static let externalMCPPortAccount = "port" private func loadOrMintExternalMCPToken() async throws -> String { if let stored = try? await llmCredentials.backing.get( service: Self.externalMCPCredentialService, account: Self.externalMCPCredentialAccount ), !stored.isEmpty { return stored } let bytes = (0..<32).map { _ in UInt8.random(in: .min...UInt8.max) } let token = Data(bytes).base64EncodedString() try? await llmCredentials.backing.set( service: Self.externalMCPCredentialService, account: Self.externalMCPCredentialAccount, token: token ) return token } private func loadPreferredExternalMCPPort() async -> UInt16? { guard let stored = try? await llmCredentials.backing.get( service: Self.externalMCPCredentialService, account: Self.externalMCPPortAccount ) else { return nil } return UInt16(stored) } private func savePreferredExternalMCPPort(_ port: UInt16) async { try? await llmCredentials.backing.set( service: Self.externalMCPCredentialService, account: Self.externalMCPPortAccount, token: String(port) ) } @discardableResult public func startMission( goal: String, providerID: String, modelID: String, tokenBudgetInput: Int, tokenBudgetOutput: Int, thinkingBudget: Int = 0, reasoningEffort: String? = nil, temperature: Double? = nil ) -> Mission? { var mission = Mission( goalText: goal, providerID: providerID, modelID: modelID, tokenBudgetInput: tokenBudgetInput, tokenBudgetOutput: tokenBudgetOutput, thinkingBudget: thinkingBudget, reasoningEffort: reasoningEffort, temperature: temperature ) do { try store.save(mission) collaboration.enqueueMissionUpsert(mission) } catch { return nil } mission.status = .running try? store.save(mission) collaboration.enqueueMissionUpsert(mission) missionExecutor.start(missionID: mission.id) let fallbackModelID = mission.modelID Task { @MainActor [weak self] in await self?.generateMissionTitle(missionID: mission.id, providerID: providerID, fallbackModelID: fallbackModelID, goal: goal) } return mission } private func generateMissionTitle(missionID: UUID, providerID: String, fallbackModelID: String, goal: String) async { guard let provider = llmRegistry.provider(id: providerID) else { return } let descriptor = provider.descriptor let modelID = descriptor.summarizationModelID ?? descriptor.defaultModelID ?? fallbackModelID guard !modelID.isEmpty else { return } var apiKey: String? if descriptor.capabilities.supports(.apiKey) { apiKey = try? await llmCredentials.apiKey(providerID: providerID) guard let key = apiKey, !key.isEmpty else { return } } let userPrompt = """ Label this investigation in two to four punchy words for a sidebar entry. Prefer informal shorthand — verbs and short nouns over formal phrasing (e.g. "Reverse Foo Protocol" not "Reverse Engineer the Foo Protocol for Interop"). No quotes, no punctuation, no preamble — just the label. Goal: \(goal) """ let request = LLMTurnRequest( modelID: modelID, systemBlocks: [], messages: [LLMMessage(role: .user, blocks: [.text(userPrompt)])], tools: [], maxOutputTokens: 64, thinkingBudget: 0, temperature: 0.2 ) var collected = "" do { let baseURL = LumaAppState.shared.providerBaseURL(providerID: providerID).flatMap(URL.init(string:)) for try await event in provider.streamTurn(request, apiKey: apiKey, baseURL: baseURL) { if case .finalMessage(_, let blocks) = event { for block in blocks { if case .text(let t) = block.content { collected += t } } } } } catch { return } let title = sanitizeMissionTitle(collected) guard !title.isEmpty else { return } if let saved = store.updateMission(id: missionID, { $0.title = title }) { collaboration.enqueueMissionUpsert(saved) } } private func sanitizeMissionTitle(_ raw: String) -> String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) trimmed = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\"'.!?:;")) if let nl = trimmed.firstIndex(of: "\n") { trimmed = String(trimmed[.. String { existing.isEmpty ? addition : "\(existing)\n\n\(addition)" } public func deleteMission(missionID: UUID) { missionExecutor.cancel(missionID: missionID) try? store.deleteMission(id: missionID) collaboration.enqueueMissionRemove(missionID: missionID) } public func approveMissionAction(actionID: UUID) async { guard let action = try? store.fetchMissionAction(id: actionID), action.status == .pending, let mission = try? store.fetchMission(id: action.missionID) else { return } #if canImport(Network) || canImport(CSoup) if let server = activeMCPServersByMissionID[action.missionID] { server.approve(actionID: actionID) return } #endif var approved = action approved.status = .approved approved.decidedAt = Date() try? store.save(approved) collaboration.enqueueMissionAction(approved) await missionExecutor.runActionByID(approved.id, mission: mission) let stillPending = (try? store.hasPendingMissionActions(missionID: approved.missionID)) ?? false if !stillPending { missionExecutor.resume(missionID: approved.missionID) } } public func submitUserInputResponse(actionID: UUID, answer: String) { guard var action = try? store.fetchMissionAction(id: actionID), action.toolName == MissionTools.requestUserInputToolName, action.status == .pending else { return } let payload: [String: Any] = ["answer": answer] let data = (try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])) ?? Data("{}".utf8) action.status = .succeeded action.decidedAt = Date() action.completedAt = Date() action.resultJSON = String(data: data, encoding: .utf8) action.resultSummary = answer try? store.save(action) collaboration.enqueueMissionAction(action) let stillPending = (try? store.hasPendingMissionActions(missionID: action.missionID)) ?? false if !stillPending { missionExecutor.resume(missionID: action.missionID) } } public func cancelAddressNoteReply(sessionID: UUID) async { activeNoteReplyTasks[sessionID]?.cancel() guard let missionID = sessionUIStates[sessionID]?.ambientMissionID else { return } let pending = (try? store.fetchPendingMissionActions(missionID: missionID)) ?? [] for action in pending { await rejectMissionAction(actionID: action.id, reason: "User cancelled the note reply.") } } public func rejectMissionAction(actionID: UUID, reason: String? = nil) async { guard var action = try? store.fetchMissionAction(id: actionID), action.status == .pending else { return } #if canImport(Network) || canImport(CSoup) if let server = activeMCPServersByMissionID[action.missionID] { server.reject(actionID: actionID, reason: reason) return } #endif action.status = .rejected action.decidedAt = Date() action.completedAt = Date() action.rejectionReason = reason try? store.save(action) collaboration.enqueueMissionAction(action) let stillPending = (try? store.hasPendingMissionActions(missionID: action.missionID)) ?? false if !stillPending { missionExecutor.resume(missionID: action.missionID) } } public func acceptFinding(findingID: UUID) { guard var finding = try? findFinding(id: findingID) else { return } finding.status = .accepted try? store.save(finding) collaboration.enqueueMissionFinding(finding) } public func refuteFinding(findingID: UUID) { guard var finding = try? findFinding(id: findingID) else { return } finding.status = .refuted try? store.save(finding) collaboration.enqueueMissionFinding(finding) } private func findFinding(id: UUID) throws -> MissionFinding? { for mission in missions { if let match = (try? store.fetchMissionFindings(missionID: mission.id))?.first(where: { $0.id == id }) { return match } } return nil } private func applyRemoteMissionSnapshot(_ snapshot: MissionSnapshot) { let serverMissionIDs = Set(snapshot.missions.map(\.id)) let localMissions = (try? store.fetchMissions()) ?? [] for stale in localMissions where !serverMissionIDs.contains(stale.id) { try? store.deleteMission(id: stale.id) } for mission in snapshot.missions { try? store.save(mission) } for turn in snapshot.turns { try? store.save(turn) } for action in snapshot.actions { try? store.save(action) } for finding in snapshot.findings { try? store.save(finding) } for evidence in snapshot.evidence { try? store.save(evidence) } } private func applyRemoteAddressNoteOp(_ op: AddressNoteOp) { switch op { case .noteUpsert(let u): let existed = (try? store.fetchAddressNote(id: u.note.id)) != nil try? store.save(u.note) rebuildAddressAnnotations(sessionID: u.note.sessionID) onAddressNoteChanged?(existed ? .noteUpdated(u.note) : .noteAdded(u.note)) case .noteRemove(let r): guard let prior = try? store.fetchAddressNote(id: r.noteID) else { return } try? store.deleteAddressNote(id: r.noteID) rebuildAddressAnnotations(sessionID: prior.sessionID) onAddressNoteChanged?(.noteRemoved(prior)) case .messageAppend(let a): try? store.save(a.message) onAddressNoteChanged?(.messageAppended(a.message)) case .messageEdit(let e): guard var message = try? store.fetchAddressNoteMessage(id: e.messageID) else { return } message.bodyMarkdown = e.bodyMarkdown try? store.save(message) onAddressNoteChanged?(.messageEdited(message)) case .messageRemove(let r): guard let prior = try? store.fetchAddressNoteMessage(id: r.messageID) else { return } try? store.deleteAddressNoteMessage(id: r.messageID) onAddressNoteChanged?(.messageRemoved(prior)) } } private func applyRemoteMissionOp(_ op: MissionOp) { switch op { case .missionUpsert(let u): try? store.save(u.mission) case .missionRemove(let r): try? store.deleteMission(id: r.missionID) case .turnAppend(let a): try? store.save(a.turn) case .actionUpsert(let u): try? store.save(u.action) case .findingUpsert(let u): try? store.save(u.finding) case .findingRemove(let r): try? store.deleteMissionFinding(id: r.findingID) case .evidenceAdd(let a): try? store.save(a.evidence) } } } public struct TracerHookCompileFailures: Swift.Error { public let hookErrors: [UUID: any Swift.Error] } @MainActor final class SessionDisassemblyEnvironment: ModuleIntrospector, MemoryReader, PageKeying { private unowned let engine: Engine private let sessionID: UUID private var uuidCache: [String: String] = [:] private var pageAttribution: [UInt64: CachedAttribution] = [:] private enum CachedAttribution { case module(String) case anonymous case ephemeral } init(engine: Engine, sessionID: UUID) { self.engine = engine self.sessionID = sessionID } var isAvailable: Bool { engine.node(forSessionID: sessionID) != nil } func getModuleIdentity(name: String) async throws -> String? { guard let node = engine.node(forSessionID: sessionID) else { return nil } return try await node.getModuleIdentity(name: name) } func enumerateModuleRanges(name: String) async throws -> [ProcessNode.ModuleRange] { guard let node = engine.node(forSessionID: sessionID) else { return [] } return try await node.enumerateModuleRanges(name: name) } func enumerateModuleFunctions(name: String) async throws -> [ModuleFunction] { guard let node = engine.node(forSessionID: sessionID) else { throw DisassemblyError.detached } return try await node.enumerateModuleFunctions(name: name) } func read(at address: UInt64, count: Int) async throws -> [UInt8] { guard let node = engine.node(forSessionID: sessionID) else { throw DisassemblyError.detached } return try await node.readRemoteMemory(at: address, count: count) } func attribution(pageBase: UInt64) async -> PageAttribution { let modules = engine.modulesSnapshot(forSessionID: sessionID) if let direct = modules.first(where: { pageBase >= $0.base && pageBase < ($0.base &+ $0.size) }) { return .module(direct) } if let cached = pageAttribution[pageBase] { return decode(cached, modules: modules) } if let mapped = moduleByMappedRanges(pageBase: pageBase, modules: modules) { pageAttribution[pageBase] = .module(mapped.path) return .module(mapped) } guard let node = engine.node(forSessionID: sessionID) else { return .ephemeral } guard let range = try? await node.findRangeByAddress(pageBase) else { pageAttribution[pageBase] = .ephemeral return .ephemeral } if let filePath = range.filePath, let owner = modules.first(where: { $0.path == filePath }) { await ensureModuleIdentified(owner) pageAttribution[pageBase] = .module(owner.path) return .module(owner) } pageAttribution[pageBase] = .anonymous return .anonymous } private func decode(_ cached: CachedAttribution, modules: [ProcessModule]) -> PageAttribution { switch cached { case .module(let path): if let m = modules.first(where: { $0.path == path }) { return .module(m) } return .ephemeral case .anonymous: return .anonymous case .ephemeral: return .ephemeral } } private func moduleByMappedRanges(pageBase: UInt64, modules: [ProcessModule]) -> ProcessModule? { for module in modules { guard let analysis = try? engine.store.fetchModuleAnalysis(sessionID: sessionID, modulePath: module.path) else { continue } for range in analysis.mappedRanges { let rlo = module.base &+ range.offset if pageBase >= rlo && pageBase < (rlo &+ range.size) { return module } } } return nil } private func ensureModuleIdentified(_ module: ProcessModule) async { if (try? engine.store.fetchModuleAnalysis(sessionID: sessionID, modulePath: module.path)) != nil { return } guard let node = engine.node(forSessionID: sessionID) else { return } let identity = try? await node.getModuleIdentity(name: module.path) let ranges = (try? await node.enumerateModuleRanges(name: module.path)) ?? [] let stub = ModuleAnalysis( sessionID: sessionID, modulePath: module.path, moduleUUID: identity, mappedRanges: ranges, functions: [] ) try? engine.store.save(stub) } func moduleUUID(forPath path: String) async -> String? { if let cached = uuidCache[path] { return cached } if let stored = (try? engine.store.fetchModuleAnalysis(sessionID: sessionID, modulePath: path))?.moduleUUID { uuidCache[path] = stored return stored } guard let node = engine.node(forSessionID: sessionID), let uuid = try? await node.getModuleIdentity(name: path) else { return nil } uuidCache[path] = uuid return uuid } var processIdentity: String? { engine.session(id: sessionID)?.processInfo?.identity } } private actor ConsoleReplyBuffer { private var entries: [WidgetConsoleEntry] = [] func append(_ entry: WidgetConsoleEntry) { entries.append(entry) } func snapshot() -> [WidgetConsoleEntry] { entries } } #if canImport(CryptoKit) import CryptoKit #else import Crypto #endif