Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ Native macOS app, with a focused iOS companion built on the same core.
bars — GPU (CPU where there is no GPU to read) and memory — mint with headroom, amber busy, coral flat out;
temperature and GPU watts under the name; hover for details, click a remote to open an ssh tile. A tile running
`ssh` is named after its machine and says so in its footer, in place of the local folder.
- **Fleet: shared hardware and agents' jobs.** Point Settings → Fleet at a
[LLooM](https://github.com/Enntity/lloom) head and its leases appear in a band over the board:
one chip per resource, showing who holds it, how long they have left and who is waiting. Jobs
that agents report (`lloom job start`, `progress`, `done`) become tiles, with their latest note,
progress bar and metrics on the card and their notes as the transcript. A finished, failed or
silent job lands in the Needs-you lane and notifies you, and so does a lease that is running out
or whose restore failed. Opening a job goes to the agent's own conversation when it reported one
(`--session`). The Jobs chip filters them. The admin key stays in your Keychain.
- **Shut down & resume.** Quit Tessera (or shut tiles down) and every agent comes back in the same
conversation — including ones you typed into a shell yourself, and through your own launchers
(`codex-work` resumes as `codex-work resume <id>`). A tile that is shut down, exited or failed
Expand Down
29 changes: 25 additions & 4 deletions Sources/TesseraHost/AgentAppWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ public struct AgentAppSession: Identifiable, Equatable, Sendable {
/// The desktop app's own one-line turn summary (Claude writes these).
public var summary: String?
public var lastActivityAt: Date
/// 0...1, for work that reports how far along it is (LLooM jobs).
public var progress: Double? = nil
/// The conversation this one reports for (a LLooM job's `claude:<id>` / `codex:<id>`): opening
/// it goes there.
public var link: String? = nil
}

/// One session's latest state, observed on its own so a busy session re-renders only its tile.
Expand Down Expand Up @@ -46,6 +51,9 @@ public final class AgentAppWatcher {

@ObservationIgnored private let scanner = TranscriptScanner()
@ObservationIgnored private var scanning = false
/// What the last scan found, and what other sources publish (LLooM jobs): `sessions` is both.
@ObservationIgnored private var scanned: [String: AgentAppSession] = [:]
@ObservationIgnored private var external: [String: AgentAppSession] = [:]

public init() {}

Expand All @@ -72,29 +80,42 @@ public final class AgentAppWatcher {
}
}

/// Writes only what changed: rewriting unchanged data would re-render the whole board every scan.
/// Sessions from another source, shown beside the ones found on disk. They join the board
/// with the first scan, so what had finished before Tessera opened doesn't light up.
public func setExternal(_ sessions: [AgentAppSession]) {
external = Dictionary(sessions.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
if hasScanned { publish(rateLimits: nil) }
}

private func apply(_ result: TranscriptScanner.Result) {
scanning = false
hasScanned = true
scanned = result.sessions
publish(rateLimits: result.rateLimits)
}

/// Writes only what changed: rewriting unchanged data would re-render the whole board every scan.
private func publish(rateLimits: CodexRateLimits?) {
let found = scanned.merging(external) { disk, _ in disk }
var changed = false, cameOrWent = false
var tiles = sessions
for (id, session) in result.sessions {
for (id, session) in found {
if let tile = tiles[id] {
if tile.session != session { tile.session = session; changed = true }
} else {
tiles[id] = AgentSessionTile(session)
cameOrWent = true
}
}
for id in tiles.keys where result.sessions[id] == nil {
for id in tiles.keys where found[id] == nil {
tiles[id] = nil
cameOrWent = true
}
if cameOrWent {
sessions = tiles
changed = true
}
if let limits = result.rateLimits, limits != codexRateLimits {
if let limits = rateLimits, limits != codexRateLimits {
codexRateLimits = limits
changed = true
}
Expand Down
141 changes: 141 additions & 0 deletions Sources/TesseraHost/FleetService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import Foundation
import Observation
import TesseraKit

/// Follows a LLooM head's schedule: who holds which shared hardware (the Fleet band) and the jobs
/// agents report (tiles on the board). The head's address is saved with the app's state; its admin
/// key lives in the Keychain and is only ever sent to that address.
@Observable
@MainActor
public final class FleetService {
public struct Config: Codable, Equatable, Sendable {
public var url: String
}

public enum Status: Equatable, Sendable {
case off, loading, ok
case failed(String)
}

public private(set) var config: Config?
public private(set) var lanes: [LLoomSchedule.Lane] = []
public private(set) var status: Status = .off
/// When the head last answered.
public private(set) var updatedAt: Date?
/// Called when the jobs or leases the board shows may have changed.
@ObservationIgnored public var onChange: (() -> Void)?

@ObservationIgnored public private(set) var board: LLoomSchedule.Board?
@ObservationIgnored private let store: URL
@ObservationIgnored private var timer: Timer?
@ObservationIgnored private var inFlight = false
@ObservationIgnored private var retryAt = Date.distantPast
@ObservationIgnored private let session: URLSession = {
let c = URLSessionConfiguration.ephemeral
c.timeoutIntervalForRequest = 15
return URLSession(configuration: c)
}()

static let account = "lloom.head"
public static let refreshInterval: TimeInterval = 10

public init(directory: URL) {
store = directory.appendingPathComponent("lloom.json")
config = StateFile.load(Config.self, from: store).flatMap { LLoomSchedule.baseURL($0.url) == nil ? nil : $0 }
}

public func start() {
poll()
let t = Timer(timeInterval: Self.refreshInterval, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.poll() }
}
t.tolerance = 2
RunLoop.main.add(t, forMode: .common)
timer = t
}

public var hasKey: Bool { Keychain.contains(account: Self.account) }

/// Follows the head at `url`; a non-empty `key` replaces the saved one. False for an address
/// Tessera won't use.
@discardableResult
public func connect(url: String, key: String?) -> Bool {
guard let base = LLoomSchedule.baseURL(url) else { return false }
config = Config(url: base.absoluteString)
StateFile.save(config, to: store)
if let key, !key.isEmpty { Keychain.set(key, account: Self.account) }
retryAt = .distantPast
status = .loading
poll()
return true
}

/// Stops following the head and forgets its key; its jobs leave the board.
public func disconnect() {
config = nil
try? FileManager.default.removeItem(at: store)
Keychain.set(nil, account: Self.account)
board = nil
lanes = []
status = .off
updatedAt = nil
onChange?()
}

/// The jobs and leases to show as tiles: running ones, and those that ended within `lookback`.
public func items(lookback: TimeInterval) -> [LLoomSchedule.Item] {
board.map { LLoomSchedule.items($0, lookback: lookback) } ?? []
}

/// A job or lease as an app-session tile, so it joins the board, the Needs-you lane, alerts and
/// the phone like any conversation. Its notes read as its transcript.
public nonisolated static func session(_ item: LLoomSchedule.Item) -> AgentAppSession {
AgentAppSession(id: item.id, flavor: .lloom, title: item.title, cwd: item.subtitle, openURL: nil, resumeCommand: nil,
snapshot: ConversationSnapshot(items: item.notes, activity: item.activity, detail: item.detail,
lastEventAt: item.changedAt),
summary: nil, lastActivityAt: item.lastActivityAt, progress: item.progress, link: item.session)
}

private func poll() {
guard let config, let base = LLoomSchedule.baseURL(config.url), !inFlight, Date() >= retryAt else { return }
inFlight = true
if board == nil, status == .off { status = .loading }
let account = Self.account
Task { [session] in
defer { self.inFlight = false }
// Reading a secret can raise a Keychain prompt; keep it off the main thread.
let key = await Task.detached(priority: .utility) { Keychain.get(account: account) }.value
do {
let (data, response) = try await session.data(for: LLoomSchedule.request(base: base, token: key))
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
guard self.config == config else { return } // reconfigured while asking
guard (200..<300).contains(code) else {
throw Failure(code == 401 || code == 403 ? "The head refused the key (HTTP \(code))" : "The head answered HTTP \(code)")
}
self.apply(try LLoomSchedule.parse(data))
} catch {
guard self.config == config else { return }
// Keep showing the last schedule, and ask less often until the head is back.
self.retryAt = Date().addingTimeInterval(30)
let message = (error as? Failure)?.message ?? error.localizedDescription
if self.status != .failed(message) { self.status = .failed(message) }
}
}
}

private struct Failure: Error {
let message: String
init(_ message: String) { self.message = message }
}

/// Writes only what changed, so an unchanged poll re-renders nothing (the tiles' own writes are
/// diffed by `AgentAppWatcher`; ages in their text move on with every poll).
private func apply(_ next: LLoomSchedule.Board) {
board = next
updatedAt = Date()
if status != .ok { status = .ok }
let nextLanes = LLoomSchedule.lanes(next)
if lanes != nextLanes { lanes = nextLanes }
onChange?()
}
}
27 changes: 26 additions & 1 deletion Sources/TesseraHost/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public final class Workspace {
public let dsh = DshWebServer()
public let usage: UsageService
public let machines: MachineMonitor
/// A LLooM head's leases and agents' jobs (the Fleet band; jobs are tiles).
public let fleet: FleetService
public private(set) var presets: [LaunchPreset] = LaunchCatalog.known
/// Desktop agent apps on this Mac that can start conversations from Tessera.
public let installedApps: [AgentApp] = AgentApp.allCases.filter {
Expand Down Expand Up @@ -92,6 +94,7 @@ public final class Workspace {
boardHolder = BoardLock.take(in: directory)
usage = UsageService(directory: directory)
machines = MachineMonitor(directory: directory)
fleet = FleetService(directory: directory)
}

public func start() {
Expand All @@ -111,6 +114,8 @@ public final class Workspace {
agents.start()
usage.start()
machines.start()
fleet.onChange = { [weak self] in self?.fleetChanged() }
fleet.start()
DispatchQueue.global(qos: .utility).async {
let found = LaunchCatalog.detectInstalled()
DispatchQueue.main.async { MainActor.assumeIsolated { self.presets = found } }
Expand Down Expand Up @@ -415,6 +420,11 @@ public final class Workspace {
/// keeps such a conversation in Tessera, as its transcript. An id not on the board opens nothing.
public func open(_ id: String, inApp: Bool = true, nativeAt rect: CGRect? = nil) {
guard exists(id) else { return }
// A LLooM job opens the conversation of the agent reporting it, when that is on the board.
if inApp, let conversation = linkedConversation(id) {
agentAcknowledged[id] = Date()
return open(conversation, inApp: true, nativeAt: rect)
}
opened.note(id)
if app(opening: id, inApp: inApp) != nil {
collapse()
Expand Down Expand Up @@ -636,7 +646,22 @@ public final class Workspace {
return TileInfo(id: a.id, kind: .agentSession, flavor: a.flavor, title: agentTitles[a.id] ?? a.title,
subtitle: a.cwd.abbreviatingHome, activity: activity,
attention: unseen,
lastActivityAt: a.lastActivityAt, detail: detail)
lastActivityAt: a.lastActivityAt, detail: detail, progress: a.progress)
}

private func fleetChanged() {
agents.setExternal(fleet.items(lookback: agents.lookback).map(FleetService.session))
}

/// The tile of the conversation a LLooM job or lease names (`claude:<id>`, `codex:<id>`): by its
/// tile id, else by the session id a terminal or a Claude app conversation carries.
public func linkedConversation(_ id: String) -> String? {
guard let link = agents.session(id)?.link, let colon = link.firstIndex(of: ":") else { return nil }
let sessionId = String(link[link.index(after: colon)...])
guard SessionResume.isSafeId(sessionId) else { return nil }
let candidates = [link] + terminals.filter { $0.value.sessionId == sessionId }.map(\.key)
+ agents.sessions.filter { $0.value.session.resumeCommand?.split(separator: " ").contains(Substring(sessionId)) == true }.map(\.key)
return candidates.first { $0 != id && exists($0) && agents.session($0)?.flavor != .lloom }
}

private func syncAgents() {
Expand Down
7 changes: 5 additions & 2 deletions Sources/TesseraKit/Core/BoardQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ public struct BoardQuery: Equatable, Sendable {
/// A filter chip: what a tile is doing (a state), or what it is (a kind).
public enum Chip: String, CaseIterable, Identifiable, Sendable {
case needsYou, working
case terminals, claude, codex, dsh, web
case terminals, claude, codex, dsh, jobs, web

public var id: String { rawValue }

Expand All @@ -14,7 +14,7 @@ public struct BoardQuery: Equatable, Sendable {
switch self {
case .needsYou: .needsInput
case .working: .working
case .terminals, .claude, .codex, .dsh, .web: nil
case .terminals, .claude, .codex, .dsh, .jobs, .web: nil
}
}

Expand All @@ -26,6 +26,7 @@ public struct BoardQuery: Equatable, Sendable {
case .claude: .claudeDesktop
case .codex: .codexDesktop
case .dsh: .dsh
case .jobs: .lloom
case .web: .web
}
}
Expand All @@ -36,6 +37,7 @@ public struct BoardQuery: Equatable, Sendable {
case .needsYou: TileActivity.needsYouLabel
case .terminals: "Terminals"
case .dsh: "dsh"
case .jobs: "Jobs"
default: activity?.label ?? flavor?.displayName ?? rawValue
}
}
Expand All @@ -48,6 +50,7 @@ public struct BoardQuery: Equatable, Sendable {
case .claude: tile.flavor == .claude || tile.flavor == .claudeDesktop
case .codex: tile.flavor == .codex || tile.flavor == .codexDesktop
case .dsh: tile.flavor == .dsh
case .jobs: tile.flavor == .lloom
case .web: tile.kind == .browser
}
}
Expand Down
Loading