Files
wristcode/watch/WristCode/Demo/DemoGateway.swift
T
gib edc4b88990
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s
Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway: an approval push for a file change says which files it would edit
2026-11-02 14:45:34 -05:00

685 lines
36 KiB
Swift

// Fixture data shaped like the docs/api.md examples, served by an in-memory gateway. Demo mode and
// SwiftUI previews use it, so every screen renders without a live gateway. It reacts to writes:
// answering an approval moves the thread to working, a reply restarts a finished thread, settling
// hides a thread from the feed, snoozing moves it from Recent to Snoozed, Ask before acting and the
// notification switches stick, and so on.
// Subagent threads open like any other but stay out of the feed, as the gateway's do. Search matches
// titles and messages on the online hosts.
import Foundation
actor DemoGateway: GatewayAPI {
static let shared = DemoGateway()
private var items: [FeedItem]
private var messages: [String: [Message]]
/// Pages before each thread's messages, newest page first.
private let earlierPages: [String: [[Message]]]
/// Files each thread's latest run changed, by thread id.
private let changedFiles: [String: [ChangedFile]]
/// Subagent ids by parent id. Subagents are in `items` but not the feed.
private let children: [String: [String]]
private let subagentIds: Set<String>
private let hosts: [HostSummary]
private let projectHosts: [ProjectsResponse.Host]
private var statusResponse: StatusResponse
/// Ids of settled threads, which the feed leaves out.
private var settled: Set<String>
/// Ids of threads in Ask before acting.
private var askingFirst: Set<String> = []
private var nextId = 1
init(now: Date = .now) {
let fixtures = DemoFixtures(now: now)
items = fixtures.items
messages = fixtures.messages
earlierPages = fixtures.history
changedFiles = fixtures.changedFiles
children = fixtures.children
subagentIds = Set(fixtures.children.values.joined())
settled = fixtures.settled
hosts = fixtures.hosts
projectHosts = fixtures.projectHosts
statusResponse = fixtures.status
}
func feed() async throws -> Feed {
DemoFixtures.feed(unsettled, hosts: hosts, now: .now)
}
func summary() async throws -> Summary {
Summary(try await feed())
}
func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
return ThreadDetail(
item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking,
askFirst: askingFirst.contains(item.id),
earlierCursor: earlierPages[item.id] == nil ? nil : "p0",
subagents: (children[item.id] ?? []).compactMap { id in items.first { $0.id == id }.map(row) },
changedFiles: changedFiles[item.id] ?? [])
}
/// Cursors are `p<n>`, the n-th page back from the thread's messages.
func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse {
guard let item = item(hostId, threadId), let pages = earlierPages[item.id],
let index = Int(cursor.dropFirst()), pages.indices.contains(index)
else { throw GatewayError.http(status: 404, message: "Unknown cursor.") }
return HistoryResponse(messages: pages[index], earlierCursor: index + 1 < pages.count ? "p\(index + 1)" : nil)
}
func sendMessage(hostId: String, threadId: String, text: String) async throws {
try update(hostId, threadId) { item in
settled.remove(item.id)
append(Message(id: newId(), role: .user, text: text, createdAt: .now), to: item.id)
if !item.phase.isWorking {
item.phase = .running
item.lastMessage = "Working on it."
}
}
}
/// Answers go to the thread that asked. For a card showing a subagent's request, that is the
/// subagent in `via`, and the parent itself has nothing to answer.
func respond(hostId: String, threadId: String, request: RespondRequest) async throws {
let index = items.firstIndex { $0.requestThread == (hostId, threadId) }
?? items.firstIndex { $0.hostId == hostId && $0.threadId == threadId }
guard let index else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
try update(items[index].hostId, items[index].threadId) { item in
guard item.request != nil, item.requestThread == (hostId, threadId) else {
throw GatewayError.http(status: 409, message: "Already answered.")
}
item.request = nil
item.via = nil
switch request {
case let .decision(_, decision) where decision == .decline || decision == .cancel:
item.phase = .completed
item.lastMessage = "OK, I won't run it. Tell me what to do instead."
default:
item.phase = .running
item.lastMessage = "Thanks. Continuing."
}
if let line = item.lastMessage {
append(Message(id: newId(), role: .assistant, text: line, createdAt: .now), to: item.id)
}
}
// A subagent's own thread stops waiting too.
if let child = items.firstIndex(where: { $0.hostId == hostId && $0.threadId == threadId }), child != index,
items[child].request != nil {
items[child].request = nil
items[child].phase = .running
}
}
func stop(hostId: String, threadId: String) async throws {
try update(hostId, threadId) { item in
item.phase = .completed
item.lastMessage = "Stopped."
}
}
func settle(hostId: String, threadId: String) async throws {
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
settled.insert(item.id)
}
func setAskFirst(hostId: String, threadId: String, askFirst: Bool) async throws {
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
if askFirst { askingFirst.insert(item.id) } else { askingFirst.remove(item.id) }
}
func recent() async throws -> RecentResponse {
RecentResponse(items: DemoFixtures.recent(unsettled, now: .now))
}
func settleRecent() async throws -> SettleRecentResponse {
let completed = DemoFixtures.recent(unsettled, now: .now).filter { $0.phase == .completed }
settled.formUnion(completed.map(\.id))
return SettleRecentResponse(settled: completed.count, failed: 0)
}
func snoozed() async throws -> RecentResponse {
RecentResponse(items: DemoFixtures.snoozed(unsettled, now: .now))
}
func snooze(hostId: String, threadId: String, until: Date) async throws {
try update(hostId, threadId) { item in
guard item.phase == .completed || item.phase == .failed else {
throw GatewayError.http(status: 409, message: "Only a finished thread can be snoozed.")
}
item.snoozedUntil = until
}
}
func unsnooze(hostId: String, threadId: String) async throws {
try update(hostId, threadId) { $0.snoozedUntil = nil }
}
func projects() async throws -> ProjectsResponse {
ProjectsResponse(hosts: projectHosts)
}
func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse {
guard let host = projectHosts.first(where: { $0.id == hostId }) else {
throw GatewayError.http(status: 404, message: "Unknown host.")
}
guard host.status == .online else { throw GatewayError.http(status: 503, message: nil) }
let rows = items
.filter { $0.hostId == hostId && $0.projectId == projectId && !subagentIds.contains($0.id) }
.sorted { $0.updatedAt > $1.updatedAt }
.map(row)
return ThreadListResponse(items: rows)
}
/// Titles first, then messages, on online hosts only, newest first. A message match carries the
/// words around it.
func search(_ query: String) async throws -> SearchResponse {
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard query.count >= 2 else { throw GatewayError.http(status: 400, message: "Search for at least two letters.") }
let online = Set(hosts.filter { $0.status == .online }.map(\.id))
let hits: [(SearchHit, Date)] = items.compactMap { item in
guard online.contains(item.hostId) else { return nil }
let hit = { (snippet: String?) in
SearchHit(thread: self.row(item), projectTitle: item.projectTitle, hostLabel: item.hostLabel, snippet: snippet)
}
if item.title.localizedCaseInsensitiveContains(query) { return (hit(nil), item.updatedAt) }
guard let message = messages[item.id]?.last(where: { $0.text.localizedCaseInsensitiveContains(query) }),
let range = message.text.range(of: query, options: .caseInsensitive)
else { return nil }
let start = message.text.index(range.lowerBound, offsetBy: -30, limitedBy: message.text.startIndex) ?? message.text.startIndex
let end = message.text.index(range.upperBound, offsetBy: 50, limitedBy: message.text.endIndex) ?? message.text.endIndex
let snippet = (start > message.text.startIndex ? "…" : "") + message.text[start ..< end] + (end < message.text.endIndex ? "…" : "")
return (hit(snippet), message.createdAt)
}
return SearchResponse(
hits: hits.sorted { $0.1 > $1.1 }.map(\.0),
unsearched: hosts.filter { !online.contains($0.id) }.map(\.label))
}
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse {
guard let host = projectHosts.first(where: { $0.id == request.hostId }),
let project = host.projects.first(where: { $0.id == request.projectId })
else { throw GatewayError.http(status: 404, message: "Unknown project.") }
if let model = request.model, !host.offers(model) {
throw GatewayError.http(status: 400, message: "\(host.label) does not offer \(model.slug).")
}
let threadId = "demo-\(newId())"
let item = FeedItem(
hostId: host.id, hostLabel: host.label, threadId: threadId, projectId: project.id,
projectTitle: project.title, title: String(request.text.prefix(40)), phase: .starting,
updatedAt: .now, lastMessage: nil, request: nil)
items.append(item)
messages[item.id] = [Message(id: newId(), role: .user, text: request.text, createdAt: .now)]
if request.askFirst == true { askingFirst.insert(item.id) }
return NewThreadResponse(hostId: host.id, threadId: threadId)
}
func registerPush(_ registration: PushRegistration) async throws {}
func status() async throws -> StatusResponse {
statusResponse
}
func reconnect(hostId: String) async throws {}
func testPush() async throws -> PushResult {
let result = PushResult(ok: true, at: .now, status: 200, reason: nil)
statusResponse.gateway.lastPush = result
return result
}
func setNotification(_ key: NotificationPrefs.Key, _ on: Bool) async throws -> NotificationPrefs {
var prefs = statusResponse.device.notifications ?? NotificationPrefs(needsMe: true, finished: true, failed: true, subagents: true)
prefs[key] = on
statusResponse.device.notifications = prefs
return prefs
}
func unpair() async throws {}
// MARK: - Helpers
/// Threads the feed lists: not settled, and not a subagent.
private var unsettled: [FeedItem] {
items.filter { !settled.contains($0.id) && !subagentIds.contains($0.id) }
}
private func row(_ item: FeedItem) -> ThreadRow {
ThreadRow(
hostId: item.hostId, threadId: item.threadId, title: item.title, phase: item.phase,
settled: settled.contains(item.id), updatedAt: item.updatedAt)
}
private func item(_ hostId: String, _ threadId: String) -> FeedItem? {
items.first { $0.hostId == hostId && $0.threadId == threadId }
}
private func update(_ hostId: String, _ threadId: String, _ change: (inout FeedItem) throws -> Void) throws {
guard let index = items.firstIndex(where: { $0.hostId == hostId && $0.threadId == threadId }) else {
throw GatewayError.http(status: 404, message: "Unknown thread.")
}
try change(&items[index])
items[index].updatedAt = .now
}
private func append(_ message: Message, to key: String) {
messages[key, default: []].append(message)
}
private func newId() -> String {
nextId += 1
return "m\(nextId)"
}
}
struct DemoFixtures {
let items: [FeedItem]
let messages: [String: [Message]]
let history: [String: [[Message]]]
/// Files each thread's latest run changed, by thread id. Each thread's `changes` totals them.
let changedFiles: [String: [ChangedFile]]
/// Subagent ids by parent id.
let children: [String: [String]]
/// Threads settled from the start, which only a project's thread list shows.
let settled: Set<String>
let hosts: [HostSummary]
let projectHosts: [ProjectsResponse.Host]
let status: StatusResponse
init(now: Date) {
func ago(_ minutes: Double) -> Date { now.addingTimeInterval(-minutes * 60) }
hosts = [
HostSummary(id: "mac-mini", label: "Mac mini", status: .online, error: nil),
HostSummary(id: "server-gib", label: "server-gib", status: .online, error: nil),
HostSummary(id: "laptop", label: "Laptop", status: .online, error: nil),
HostSummary(id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused"),
]
let approval = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-ocr", projectId: "p-bill",
projectTitle: "bill-tracker", title: "Receipt OCR", phase: .waitingForApproval,
updatedAt: ago(3), lastMessage: "Parser and tests are written. I want to run the suite.",
request: PendingRequest(
id: "r-ocr", kind: "command", prompt: "bun test --filter ocr", reason: nil,
options: [
.init(decision: .accept, label: "Allow"),
.init(decision: .acceptForSession, label: "Allow this session"),
.init(decision: .decline, label: "Deny"),
],
questions: []))
// File approvals: one with the diff the gateway rarely has before approval, one with the name
// only, which is what Claude sends.
func fileApproval(_ id: String, _ files: [PendingFile]) -> PendingRequest {
PendingRequest(
id: id, kind: "file-change", prompt: "Edit: \(files[0].path)", reason: nil,
options: [.init(decision: .accept, label: "Allow"), .init(decision: .decline, label: "Deny")],
questions: [], files: files)
}
let compose = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-compose", projectId: "p-wc",
projectTitle: "wristcode", title: "Gateway restart policy", phase: .waitingForApproval,
updatedAt: ago(2), lastMessage: "Switching the gateway to the Alpine image and restarting it on failure.",
request: fileApproval("r-compose", [
PendingFile(
path: "deploy/compose.yml", name: "compose.yml", additions: 9, deletions: 6,
preview: ["- image: oven/bun:1", "+ image: oven/bun:1-alpine", "+ restart: always"]),
]))
let healthRoute = FeedItem(
hostId: "laptop", hostLabel: "Laptop", threadId: "t-health-route", projectId: "p-wc-laptop",
projectTitle: "wristcode", title: "Health route", phase: .waitingForApproval,
updatedAt: ago(0.8), lastMessage: "Adding a /healthz route the container check can call.",
request: fileApproval("r-health", [
PendingFile(path: "gateway/src/health.ts", name: "health.ts", additions: nil, deletions: nil, preview: []),
]))
let question = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-db", projectId: "p-wc",
projectTitle: "wristcode", title: "Gateway storage", phase: .waitingForInput,
updatedAt: ago(1), lastMessage: "Two ways to store pairing codes.",
request: PendingRequest(
id: "r-db", kind: "user_input", prompt: nil, reason: nil, options: [],
questions: [
.init(
id: "q1", header: "Storage", question: "Hash pairing codes or store them plain?",
options: [.init(label: "Hash them", value: "hash"), .init(label: "Plain", value: "plain")],
allowCustomAnswer: true),
]))
let release = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-release", projectId: "p-wc-mac",
projectTitle: "wristcode", title: "Release checklist", phase: .waitingForInput,
updatedAt: ago(6), lastMessage: "The build is ready. Which checks should run first?",
request: PendingRequest(
id: "r-release", kind: "user_input", prompt: nil, reason: nil, options: [],
questions: [
.init(
id: "checks", header: "Checks", question: "Which checks should run before upload?",
options: [
.init(label: "Unit tests", value: "unit"),
.init(label: "UI tests", value: "ui"),
.init(label: "Lint", value: "lint"),
.init(label: "Archive", value: "archive"),
],
allowCustomAnswer: true, multiSelect: true),
]))
// A subagent's request, shown on the card of the thread that started it.
let homelab = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-homelab", projectId: "p-wristlab",
projectTitle: "WristLab", title: "HomeLab Stats Apple Watch App", phase: .waitingForApproval,
updatedAt: ago(4), lastMessage: "Delegated a PATH check to the Mac mini. Waiting on it before I wire up the build.",
request: PendingRequest(
id: "r-path", kind: "command", prompt: "brew install jq", reason: nil,
options: [.init(decision: .accept, label: "Allow"), .init(decision: .decline, label: "Deny")],
questions: []),
via: .init(hostId: "mac-mini", threadId: "t-path", title: "Check Panama PATH"))
// The subagent that asks on the homelab card. It has no card of its own.
let path = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-path", projectId: "p-wristlab",
projectTitle: "WristLab", title: "Check Panama PATH", phase: .waitingForApproval, updatedAt: ago(4),
lastMessage: "jq is missing from PATH. I want to install it.",
request: homelab.request)
let scaffold = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-scaffold", projectId: "p-wc-mac",
projectTitle: "wristcode", title: "Watch app scaffold", phase: .running, updatedAt: ago(0.5),
lastMessage: "Writing ThreadListView.swift", request: nil,
activity: Activity(kind: .command, text: "xcodebuild test -scheme WristCode", startedAt: ago(0.7)))
let containerfile = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-container", projectId: "p-wc",
projectTitle: "wristcode", title: "Gateway Containerfile", phase: .running, updatedAt: ago(2),
lastMessage: "Split the image build and the healthcheck into two subagents. Waiting on them.", request: nil,
activity: Activity(kind: .edit, text: "Containerfile", startedAt: ago(3)))
// The Containerfile thread's subagents, one delegated to the Mac mini.
let image = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-image", projectId: "p-wc-mac",
projectTitle: "wristcode", title: "Build the image", phase: .running, updatedAt: ago(1),
lastMessage: "Running podman build.", request: nil,
activity: Activity(kind: .command, text: "podman build -t wristcode-gateway .", startedAt: ago(1)))
let healthcheck = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-health", projectId: "p-wc",
projectTitle: "wristcode", title: "Read the healthcheck docs", phase: .completed, updatedAt: ago(2.5),
lastMessage: "Podman runs `HEALTHCHECK` on its own timer. Use `--health-interval`.", request: nil)
let nextjs = FeedItem(
hostId: "littleman", hostLabel: "LittleMan", threadId: "t-next", projectId: "p-site",
projectTitle: "gbrown.org", title: "Bump Next.js", phase: .completed, updatedAt: ago(62),
lastMessage: "Done. Next.js is on 17.1 and the build passes. Want me to commit it?", request: nil)
let cdn = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-cdn", projectId: "p-cdn",
projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140),
lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil)
let statusScreens = FeedItem(
hostId: "laptop", hostLabel: "Laptop", threadId: "t-status", projectId: "p-wc-laptop",
projectTitle: "wristcode", title: "Status screens", phase: .completed, updatedAt: ago(35),
lastMessage: "Done. Status, environment and notification screens are in TestFlight build 202611011516.",
request: nil)
let hyprlockReply = "Fixed. hyprlock no longer crashes when a monitor sleeps. The fix is in `lock.qml`:\n"
+ "- a **bounded** retry when the monitor wakes\n- a *3 s* timeout on the surface"
let hyprlock = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-hyprlock", projectId: "p-panama",
projectTitle: "Panama", title: "Fix hyprlock crash", phase: .completed, updatedAt: ago(95),
lastMessage: hyprlockReply, request: nil)
// Older finished threads, past the feed's first 10 in Recent. The gateway has not read most of
// them, so they have no last message.
func older(_ host: HostSummary, _ id: String, _ project: String, _ title: String, hours: Double, failed: Bool = false) -> FeedItem {
FeedItem(
hostId: host.id, hostLabel: host.label, threadId: id, projectId: "p-\(project)", projectTitle: project,
title: title, phase: failed ? .failed : .completed, updatedAt: ago(hours * 60), lastMessage: nil, request: nil)
}
let (mac, server, laptop) = (hosts[0], hosts[1], hosts[2])
let olderRecent = [
older(laptop, "t-icons", "wristcode", "App icon variants", hours: 4),
older(mac, "t-receipts", "bill-tracker", "Receipt upload form", hours: 6),
older(server, "t-backup", "homelab", "Nightly restic backup", hours: 9, failed: true),
older(mac, "t-tailwind", "gbrown.org", "Tailwind 5 migration", hours: 20),
older(server, "t-caddy", "homelab", "Caddy wildcard certs", hours: 27),
older(laptop, "t-readme", "wristcode", "README pass", hours: 30),
older(mac, "t-csv", "bill-tracker", "CSV export", hours: 50),
older(server, "t-immich", "homelab", "Immich upgrade", hours: 72),
older(mac, "t-lint", "cdn", "Clippy warnings", hours: 96),
older(laptop, "t-pair", "wristcode", "Pairing code expiry", hours: 120),
]
// Finished threads snoozed for later: one back in a few hours, one tomorrow morning.
let calendar = Calendar.current
let later = calendar.nextDate(after: now.addingTimeInterval(2 * 3600), matching: DateComponents(minute: 0), matchingPolicy: .nextTime)
let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))?.addingTimeInterval(9 * 3600)
var dependabot = older(mac, "t-deps", "gbrown.org", "Dependabot bumps", hours: 3)
dependabot.snoozedUntil = later
var plex = older(server, "t-plex", "homelab", "Plex transcoding", hours: 8)
plex.snoozedUntil = tomorrow
// Settled threads, which only the project's thread list shows.
let settledThreads = [
older(mac, "t-switches", "wristcode", "Phase 2 switches", hours: 26),
older(mac, "t-gear", "wristcode", "Gear icon fix", hours: 50),
older(mac, "t-window", "wristcode", "Recent window", hours: 53),
].map { item in
var item = item
item.projectId = "p-wc-mac"
return item
}
settled = Set(settledThreads.map(\.id))
func file(_ path: String, _ additions: Int, _ deletions: Int) -> ChangedFile {
ChangedFile(path: path, name: String(path.split(separator: "/").last ?? ""), additions: additions, deletions: deletions)
}
let changed = [
nextjs.id: [file("package.json", 31, 0), file("next.config.ts", 9, 6), file("app/layout.tsx", 2, 2)],
cdn.id: [file("src/headers.rs", 14, 6)],
]
changedFiles = changed
items = ([approval, compose, healthRoute, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn]
+ olderRecent + [dependabot, plex] + settledThreads + [path, image, healthcheck]).map { item in
guard let files = changed[item.id] else { return item }
var item = item
item.changes = ChangeTotals(
files: files.count, additions: files.map(\.additions).reduce(0, +), deletions: files.map(\.deletions).reduce(0, +))
return item
}
children = [homelab.id: [path.id], containerfile.id: [image.id, healthcheck.id]]
func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
Message(id: id, role: role, text: text, createdAt: ago(minutes))
}
messages = [
approval.id: [
m("a1", .user, 20, "Add OCR for receipts. Use the existing parser module."),
m("a2", .assistant, 12, "I'll add a receipt parser in src/ocr and tests beside it."),
m("a3", .assistant, 3, "Parser and tests are written. I want to run the suite."),
],
question.id: [
m("b1", .user, 10, "Add pairing codes to the gateway."),
m("b2", .assistant, 1, "Two ways to store pairing codes. Hashing is safer, plain is simpler to debug."),
],
release.id: [
m("r1", .user, 25, "Get the watch app ready for TestFlight."),
m("r2", .assistant, 6, "The build is ready. Which checks should run first?"),
],
homelab.id: [
m("h1", .user, 30, "Build a HomeLab stats app for the watch."),
m("h2", .assistant, 4, "Delegated a PATH check to the Mac mini. Waiting on it before I wire up the build."),
],
scaffold.id: [
m("c1", .user, 4, "Scaffold the watch app with XcodeGen."),
m("c2", .assistant, 0.5, "Writing ThreadListView.swift"),
],
containerfile.id: [
m("d1", .user, 15, "Write a Containerfile for the gateway."),
m("d2", .assistant, 2, "Split the image build and the healthcheck into two subagents. Waiting on them."),
],
path.id: [m("p1", .assistant, 4, "jq is missing from PATH. I want to install it.")],
image.id: [m("i1", .assistant, 1, "Running podman build.")],
healthcheck.id: [m("g1", .assistant, 2.5, "Podman runs `HEALTHCHECK` on its own timer. Use `--health-interval`.")],
nextjs.id: [
m("e1", .user, 90, "Bump Next.js to the latest release."),
m("e2", .assistant, 80, "Updating next and eslint-config-next, then running the build."),
m("e3", .assistant, 62, "Done. Next.js is on 17.1 and the build passes. Want me to commit it?"),
],
statusScreens.id: [
m("s1", .user, 120, "Build the status, environment and notification screens."),
m("s2", .assistant, 35, "Done. Status, environment and notification screens are in TestFlight build 202611011516."),
],
hyprlock.id: [
m("k1", .user, 110, "hyprlock crashes when a monitor sleeps. Fix it."),
m("k2", .assistant, 95, hyprlockReply),
],
cdn.id: [
m("f1", .user, 150, "Fix the cache headers the CDN sends for images."),
m("f2", .assistant, 140, "cargo test failed: 2 tests in headers.rs"),
],
]
history = [
hyprlock.id: [
[
m("k-3", .user, 300, "Show the clock in 24 hours."),
m("k-4", .assistant, 290, "Switched the clock to `HH:mm`."),
],
[
m("k-1", .user, 420, "Theme hyprlock like the bar."),
m("k-2", .assistant, 400, "## Done\nhyprlock uses the **bar's** colors and font now."),
],
],
]
let claude = AgentProvider(
instanceId: "claudeAgent", name: "Claude", canAskFirst: true,
models: [
.init(slug: "claude-opus-5-5", name: "Claude Opus 5.5"),
.init(slug: "claude-sonnet-5-5", name: "Claude Sonnet 5.5"),
.init(slug: "claude-haiku-5-5", name: "Claude Haiku 5.5"),
])
let codex = AgentProvider(
instanceId: "codex", name: "Codex", canAskFirst: true,
models: [.init(slug: "gpt-5.5", name: "GPT-5.5"), .init(slug: "gpt-5-codex", name: "GPT-5 Codex")])
projectHosts = [
.init(
id: "mac-mini", label: "Mac mini", status: .online, error: nil,
projects: [
.init(id: "p-wc-mac", title: "wristcode", model: "claude-opus-5-5", instanceId: "claudeAgent"),
.init(id: "p-bill", title: "bill-tracker", model: "claude-sonnet-5-5", instanceId: "claudeAgent"),
.init(id: "p-cdn", title: "cdn", model: "gpt-5-codex", instanceId: "codex"),
.init(id: "p-scratch", title: "scratch", model: nil),
],
providers: [claude, codex]),
.init(
id: "server-gib", label: "server-gib", status: .online, error: nil,
projects: [.init(id: "p-wc", title: "wristcode", model: "claude-opus-5-5", instanceId: "claudeAgent")],
providers: [claude]),
.init(
id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused",
projects: [.init(id: "p-site", title: "gbrown.org", model: "claude-opus-5-5", instanceId: "claudeAgent")]),
]
// Mac mini and server-gib on their first route, Laptop on its VPN fallback, LittleMan offline.
func route(_ url: String, _ kind: RouteKind, _ result: HostRoute.Result, _ error: String? = nil, _ minutes: Double? = nil) -> HostRoute {
HostRoute(url: url, kind: kind, result: result, error: error, checkedAt: minutes.map(ago))
}
func days(_ count: Double) -> Date { now.addingTimeInterval(count * 86_400) }
status = StatusResponse(
hosts: [
HostStatusDetail(
id: "mac-mini", label: "Mac mini", status: .online, error: nil, activeUrl: "http://mac.gib:3773",
routes: [route("http://mac.gib:3773", .direct, .ok, nil, 95), route("https://t3mac.gbrown.org", .public, .untried)],
onlineSince: ago(95), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(29), serverVersion: "0.1.5"),
HostStatusDetail(
id: "server-gib", label: "server-gib", status: .online, error: nil, activeUrl: "http://server.gib:3773",
routes: [route("http://server.gib:3773", .direct, .ok, nil, 300), route("https://t3server.gbrown.org", .public, .untried)],
onlineSince: ago(300), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(5.5), serverVersion: "0.1.5"),
HostStatusDetail(
id: "laptop", label: "Laptop", status: .online, error: nil, activeUrl: "http://laptopvpn.gib:3773",
routes: [
route("http://laptop.gib:3773", .direct, .failed, "no answer in 5 s", 29),
route("http://laptopvpn.gib:3773", .vpn, .ok, nil, 29),
route("https://t3laptop.gbrown.org", .public, .untried),
route("https://t3laptopvpn.gbrown.org", .public, .untried),
],
onlineSince: ago(29), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(29.5), serverVersion: "0.1.5"),
HostStatusDetail(
id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused", activeUrl: nil,
routes: [
route("http://littleman.gib:3773", .direct, .failed, "refused", 0.3),
route("https://t3littleman.gbrown.org", .public, .failed, "502", 0.3),
],
onlineSince: nil, lastOnlineAt: ago(4), nextRetryAt: now.addingTimeInterval(40),
tokenExpiresAt: days(29.5), serverVersion: "0.1.4"),
],
gateway: .init(startedAt: ago(600), pushConfigured: true, lastPush: PushResult(ok: true, at: ago(2), status: 200, reason: nil)),
device: .init(
name: "Apple Watch", pairedAt: days(-3), pushRegistered: true, apnsEnvironment: .production,
notifications: NotificationPrefs(needsMe: true, finished: true, failed: true, subagents: false)))
}
/// The feed the gateway builds from these threads: needs-you oldest first, working and Recent
/// newest first, only the first 10 of Recent, and a count of the snoozed.
static func feed(_ items: [FeedItem], hosts: [HostSummary], now: Date) -> Feed {
let recent = recent(items, now: now)
return Feed(
needsYou: items.filter(\.phase.needsYou).sorted { $0.updatedAt < $1.updatedAt },
working: items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt },
recent: Array(recent.prefix(10)),
recentTotal: recent.count,
snoozedTotal: snoozed(items, now: now).count,
hosts: hosts)
}
/// Every finished or failed thread not snoozed, newest first, as `GET /v1/recent` lists them. A
/// snooze whose time has come no longer counts.
static func recent(_ items: [FeedItem], now: Date) -> [FeedItem] {
finished(items)
.filter { ($0.snoozedUntil ?? .distantPast) <= now }
.map { item in
var item = item
item.snoozedUntil = nil
return item
}
.sorted { $0.updatedAt > $1.updatedAt }
}
/// Finished threads still snoozed, the soonest to come back first, as `GET /v1/snoozed` lists them.
static func snoozed(_ items: [FeedItem], now: Date) -> [FeedItem] {
finished(items)
.filter { ($0.snoozedUntil ?? .distantPast) > now }
.sorted { $0.snoozedUntil! < $1.snoozedUntil! }
}
private static func finished(_ items: [FeedItem]) -> [FeedItem] {
items.filter { $0.phase == .completed || $0.phase == .failed }
}
/// Threads the feed lists: not settled, and not a subagent.
var listed: [FeedItem] {
let subagents = Set(children.values.joined())
return items.filter { !settled.contains($0.id) && !subagents.contains($0.id) }
}
/// A feed for previews, without going through the actor.
static func previewFeed(now: Date = .now) -> Feed {
let fixtures = DemoFixtures(now: now)
return feed(fixtures.listed, hosts: fixtures.hosts, now: now)
}
/// The full Recent list for previews.
static func previewRecent(now: Date = .now) -> [FeedItem] {
recent(DemoFixtures(now: now).listed, now: now)
}
/// The Snoozed list for previews.
static func previewSnoozed(now: Date = .now) -> [FeedItem] {
snoozed(DemoFixtures(now: now).listed, now: now)
}
}