Files
luma/Sources/LumaCore/Engine.swift

6285 lines
248 KiB
Swift

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<AddressNoteMessage?, Never>] = [:]
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<RuntimeEvent>()
public var events: AsyncStream<RuntimeEvent> { _events.makeStream() }
private let _widgetUpdates = AsyncEventSource<WidgetUpdate>()
public var widgetUpdates: AsyncStream<WidgetUpdate> { _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<Void, Never>] = [:]
private var gatingEnabledDevices: Set<String> = []
private var gatingIntendedDevices: Set<String> = []
private var deviceChangeWatcher: Task<Void, Never>?
private var eventLogTask: Task<Void, Never>?
private var eventStoreFlushTail: Task<Void, Never> = 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<UUID> = []
@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<TypeScriptProject, Swift.Error>] = [:]
private var addressActionProviders: [AddressActionProvider] = []
@ObservationIgnored private var protectionRegionsBySession: [UUID: [ProtectionRegion]] = [:]
@ObservationIgnored private var unmappedPagesBySession: [UUID: Set<UInt64>] = [:]
@ObservationIgnored private var mappingProbesBySession: [UUID: [UInt64: Task<AddressFacts.Mapping, Never>]] = [:]
@ObservationIgnored private var functionStartsBySession: [UUID: [UInt64: Bool]] = [:]
@ObservationIgnored private var factsIdentityBySession: [UUID: String] = [:]
private var runningCustomInstrumentReloads: Set<UUID> = []
private var pendingCustomInstrumentReloads: Set<UUID> = []
private var customInstrumentReloadWaiters: [UUID: [CheckedContinuation<Void, Never>]] = [:]
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<TraceCacheInvalidation>()
public var traceCacheInvalidations: AsyncStream<TraceCacheInvalidation> { _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<AddressNoteMessage?, Never> { @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<UUID>) {
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<UUID>) {
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<String> = []
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[..<separator.lowerBound]), String(entry[separator.upperBound...]))
}
private func flagCompletions(prefix: String, disassembler: Disassembler) async -> [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<UUID> = [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..<chunkEnd)
collaboration.uploadTraceData(
sessionID: collabSessionID,
traceID: traceID,
offset: cursor,
chunk: chunk
)
cursor = chunkEnd
}
lastUploadedTraceSize[traceID] = endIndex
}
private func saveSession(_ session: ProcessSession) {
try? store.save(session)
if let i = sessions.firstIndex(where: { $0.id == session.id }) {
sessions[i] = session
}
onSessionListChanged?(.sessionUpdated(session))
}
public func populateSessionList() {
for session in sessions {
onSessionListChanged?(.sessionAdded(session))
for inst in (try? store.fetchInstruments(sessionID: session.id)) ?? [] {
onSessionListChanged?(.instrumentAdded(inst))
}
for insight in ((try? store.fetchInsights(sessionID: session.id)) ?? []).sorted(by: { $0.createdAt < $1.createdAt }) {
onSessionListChanged?(.insightAdded(insight))
}
for trace in ((try? store.fetchITraces(sessionID: session.id)) ?? []).sorted(by: { $0.startedAt < $1.startedAt }) {
onSessionListChanged?(.traceUpdated(trace))
}
}
}
public func sessionID(for node: ProcessNode) -> 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<UInt64>
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<String, Swift.Error>)] =
await withTaskGroup(of: (Int, TracerConfig.Hook, Result<String, Swift.Error>).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<String, Swift.Error>)] = []
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<Void, Never>) 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[..<nl]).trimmingCharacters(in: .whitespacesAndNewlines)
}
return trimmed
}
public func cancelMission(missionID: UUID) {
missionExecutor.cancel(missionID: missionID)
}
public func appendMissionUserMessage(missionID: UUID, text: String) {
enqueueMissionUserText(missionID: missionID, text: text)
guard let mission = try? store.fetchMission(id: missionID) else { return }
switch mission.status {
case .running, .awaitingApproval:
break
case .paused, .completed, .failed, .cancelled, .drafting:
missionExecutor.resume(missionID: missionID)
}
}
public func sendMissionUserMessageNow(missionID: UUID, text: String) {
enqueueMissionUserText(missionID: missionID, text: text)
guard let mission = try? store.fetchMission(id: missionID) else { return }
switch mission.status {
case .running:
missionExecutor.cancel(missionID: missionID)
missionExecutor.resume(missionID: missionID)
case .awaitingApproval:
break
case .paused, .completed, .failed, .cancelled, .drafting:
missionExecutor.resume(missionID: missionID)
}
}
private func enqueueMissionUserText(missionID: UUID, text: String) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard let saved = store.updateMission(id: missionID, { m in
m.pendingUserText = mergedSteer(existing: m.pendingUserText, addition: trimmed)
}) else { return }
collaboration.enqueueMissionUpsert(saved)
}
private func mergedSteer(existing: String, addition: String) -> 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