diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 239448a1c..f62c58a1e 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -1034,6 +1034,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) {
return channel, ok
}
+// HandleFunc registers a custom HTTP handler on the shared gateway mux.
+func (m *Manager) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
+ if m.mux != nil {
+ m.mux.HandleFunc(pattern, handler)
+ }
+}
+
func (m *Manager) GetStatus() map[string]any {
m.mu.RLock()
defer m.mu.RUnlock()
diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go
index 59cf8fa6c..3a887600a 100644
--- a/pkg/channels/whatsapp_native/whatsapp_native.go
+++ b/pkg/channels/whatsapp_native/whatsapp_native.go
@@ -483,6 +483,32 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
return nil, nil
}
+// SendStatus sends a text message to the WhatsApp Status broadcast (status@broadcast).
+func (c *WhatsAppNativeChannel) SendStatus(ctx context.Context, text string) error {
+ c.mu.Lock()
+ client := c.client
+ c.mu.Unlock()
+ if client == nil || !client.IsConnected() {
+ return fmt.Errorf("whatsapp not connected")
+ }
+ if client.Store.ID == nil {
+ return fmt.Errorf("whatsapp not paired")
+ }
+ black := uint32(0xFF000000)
+ white := uint32(0xFFFFFFFF)
+ font := waE2E.ExtendedTextMessage_SYSTEM_BOLD
+ msg := &waE2E.Message{
+ ExtendedTextMessage: &waE2E.ExtendedTextMessage{
+ Text: proto.String(text),
+ BackgroundArgb: &black,
+ TextArgb: &white,
+ Font: &font,
+ },
+ }
+ _, err := client.SendMessage(ctx, types.StatusBroadcastJID, msg)
+ return err
+}
+
// SendMedia implements the channels.MediaSender interface.
func (c *WhatsAppNativeChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go
index 8065a0795..659377a50 100644
--- a/pkg/gateway/gateway.go
+++ b/pkg/gateway/gateway.go
@@ -2,7 +2,9 @@ package gateway
import (
"context"
+ "encoding/json"
"fmt"
+ "net/http"
"os"
"os/signal"
"path/filepath"
@@ -32,7 +34,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
_ "github.com/sipeed/picoclaw/pkg/channels/weixin"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
- _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
+ whatsappnative "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/devices"
@@ -382,6 +384,37 @@ func setupAndStartServices(
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, authToken)
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
+ // API: send text to WhatsApp Status broadcast
+ runningServices.ChannelManager.HandleFunc("/api/send-status", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ Text string `json:"text"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Text == "" {
+ http.Error(w, "bad request: text required", http.StatusBadRequest)
+ return
+ }
+ ch, ok := runningServices.ChannelManager.GetChannel("whatsapp_native")
+ if !ok {
+ http.Error(w, "whatsapp not available", http.StatusServiceUnavailable)
+ return
+ }
+ waCh, ok := ch.(*whatsappnative.WhatsAppNativeChannel)
+ if !ok {
+ http.Error(w, "whatsapp channel type mismatch", http.StatusInternalServerError)
+ return
+ }
+ if err := waCh.SendStatus(r.Context(), body.Text); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+ })
+
if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
return nil, fmt.Errorf("error starting channels: %w", err)
}
diff --git a/tools/PicoWatch/PicoWatch/AppDelegate.swift b/tools/PicoWatch/PicoWatch/AppDelegate.swift
new file mode 100644
index 000000000..2905491d8
--- /dev/null
+++ b/tools/PicoWatch/PicoWatch/AppDelegate.swift
@@ -0,0 +1,89 @@
+import AppKit
+import SwiftUI
+
+@MainActor
+class AppDelegate: NSObject, NSApplicationDelegate {
+ private var statusItem: NSStatusItem!
+ private var popover: NSPopover!
+ private var engine: MonitorEngine!
+ private var eventMonitor: Any?
+ private var pulseTimer: Timer?
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+
+ if let button = statusItem.button {
+ button.image = NSImage(systemSymbolName: "brain.head.profile",
+ accessibilityDescription: "PicoWatch")
+ button.action = #selector(togglePopover)
+ button.target = self
+ }
+
+ engine = MonitorEngine()
+
+ popover = NSPopover()
+ popover.contentSize = NSSize(width: 380, height: 720)
+ popover.behavior = .transient
+ popover.contentViewController = NSHostingController(
+ rootView: PopoverView(engine: engine)
+ )
+
+ NSUserNotificationCenter.default.delegate = self
+
+ engine.start()
+
+ // Pulse the status bar icon to show activity
+ pulseTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ self?.updateStatusIcon()
+ }
+ }
+
+ eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
+ if let popover = self?.popover, popover.isShown {
+ popover.performClose(nil)
+ }
+ }
+ }
+
+ private func updateStatusIcon() {
+ guard let button = statusItem.button else { return }
+ let symbolName: String
+ if engine.gatewayUp {
+ symbolName = engine.recentSkillActivity ? "brain.head.profile.fill" : "brain.head.profile"
+ } else {
+ symbolName = "brain.head.profile"
+ }
+ button.image = NSImage(systemSymbolName: symbolName,
+ accessibilityDescription: "PicoWatch")
+ // Show skill count as badge
+ let skillCount = engine.totalSkills
+ button.title = skillCount > 0 ? " \(skillCount)" : ""
+ }
+
+ @objc private func togglePopover() {
+ if let button = statusItem.button {
+ if popover.isShown {
+ popover.performClose(nil)
+ } else {
+ popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+ }
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ pulseTimer?.invalidate()
+ engine.stop()
+ if let monitor = eventMonitor {
+ NSEvent.removeMonitor(monitor)
+ }
+ }
+}
+
+extension AppDelegate: NSUserNotificationCenterDelegate {
+ func userNotificationCenter(_ center: NSUserNotificationCenter,
+ shouldPresent notification: NSUserNotification) -> Bool {
+ return true
+ }
+}
diff --git a/tools/PicoWatch/PicoWatch/Info.plist b/tools/PicoWatch/PicoWatch/Info.plist
new file mode 100644
index 000000000..c8deb2cfd
--- /dev/null
+++ b/tools/PicoWatch/PicoWatch/Info.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDisplayName
+ PicoWatch
+ CFBundleExecutable
+ PicoWatch
+ CFBundleIdentifier
+ io.picoclaw.PicoWatch
+ CFBundleName
+ PicoWatch
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0.0
+ CFBundleVersion
+ 1
+ LSUIElement
+
+ NSHighResolutionCapable
+
+
+
diff --git a/tools/PicoWatch/PicoWatch/MonitorEngine.swift b/tools/PicoWatch/PicoWatch/MonitorEngine.swift
new file mode 100644
index 000000000..deb7174c2
--- /dev/null
+++ b/tools/PicoWatch/PicoWatch/MonitorEngine.swift
@@ -0,0 +1,683 @@
+import AppKit
+import Foundation
+
+// MARK: - Event types
+
+struct SkillEvent: Codable, Identifiable {
+ let id: UUID
+ let timestamp: Date
+ let operation: String // create, update, patch, delete, block, tool_call
+ let skillName: String
+ let detail: String
+
+ init(operation: String, skillName: String, detail: String = "") {
+ self.id = UUID()
+ self.timestamp = Date()
+ self.operation = operation
+ self.skillName = skillName
+ self.detail = detail
+ }
+}
+
+struct DailyStats: Codable, Identifiable {
+ let date: String // yyyy-MM-dd
+ var skillsCreated: Int = 0
+ var skillsPatched: Int = 0
+ var skillsDeleted: Int = 0
+ var blockedAttempts: Int = 0
+ var totalConversations: Int = 0
+ var toolCalls: Int = 0
+ var skillManageCalls: Int = 0
+ var statusViews: Int = 0
+ var trialStarts: Int = 0
+
+ var id: String { date }
+
+ var totalSkillOps: Int { skillsCreated + skillsPatched + skillsDeleted }
+}
+
+struct StatusInteraction: Codable, Identifiable {
+ let id: UUID
+ let timestamp: Date
+ let phone: String
+ let content: String
+ let crmStatus: String // active, inactive, trial, trial_expired, ignored, unknown
+ let contactName: String
+
+ init(phone: String, content: String, crmStatus: String = "unknown", contactName: String = "") {
+ self.id = UUID()
+ self.timestamp = Date()
+ self.phone = phone
+ self.content = content
+ self.crmStatus = crmStatus
+ self.contactName = contactName
+ }
+}
+
+// MARK: - Engine
+
+@MainActor
+final class MonitorEngine: ObservableObject {
+ // Live state
+ @Published var gatewayUp = false
+ @Published var gatewayUptime = ""
+ @Published var totalSkills = 0
+ @Published var skillsList: [SkillInfo] = []
+ @Published var recentEvents: [SkillEvent] = []
+ @Published var recentSkillActivity = false
+ @Published var weeklyStats: [DailyStats] = []
+ @Published var todayStats = DailyStats(date: MonitorEngine.todayString())
+ @Published var lastMessage = ""
+ @Published var activeSessionCount = 0
+ @Published var statusInteractions: [StatusInteraction] = []
+ @Published var trialInteractions: [StatusInteraction] = []
+
+ struct SkillInfo: Identifiable {
+ let name: String
+ let description: String
+ let modified: Date
+ var id: String { name }
+ }
+
+ private let picoHome = NSHomeDirectory() + "/.picoclaw"
+ private let skillsDir: String
+ private let sessionsDir: String
+ private let logPath: String
+ private let statsPath: String
+
+ // Session file tracking
+ private struct TrackedSession {
+ let path: String
+ var offset: UInt64
+ let sessionId: String
+ }
+ private var trackedSessions: [String: TrackedSession] = [:]
+
+ // Status broadcast parsing state
+ private var pendingStatusContent: String = ""
+ private var pendingStatusPhone: String = ""
+
+ // Trial detection: sessionId -> phone from check-access.sh
+ private var pendingSessionPhone: [String: String] = [:]
+ // Sessions already counted as trial (dedup)
+ private var countedTrialSessions: Set = []
+
+ private struct CRMContact {
+ let name: String
+ let status: String
+ }
+ private var crmCache: [String: CRMContact] = [:]
+
+ // Log file tracking
+ private var logOffset: UInt64 = 0
+
+ private var healthTimer: Timer?
+ private var sessionTimer: Timer?
+ private var sessionScanTimer: Timer?
+ private var skillsTimer: Timer?
+ private var statsTimer: Timer?
+ private var dirWatcher: DispatchSourceFileSystemObject?
+
+ init() {
+ skillsDir = picoHome + "/workspace/skills"
+ sessionsDir = picoHome + "/workspace/sessions"
+ logPath = picoHome + "/logs/gateway.log"
+ statsPath = picoHome + "/logs/picowatch_stats.json"
+ }
+
+ func start() {
+ loadStats()
+ loadCRM()
+ scanSkills()
+ discoverSessions()
+ seekLogEnd()
+
+ processAllSessions(initialScan: true)
+
+ healthTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.checkHealth() }
+ }
+ Task { checkHealth() }
+
+ sessionTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.processAllSessions(initialScan: false) }
+ }
+
+ sessionScanTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.discoverSessions() }
+ }
+
+ skillsTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.scanSkills() }
+ }
+
+ Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.tailLog() }
+ }
+
+ statsTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.saveStats() }
+ }
+
+ Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.loadCRM() }
+ }
+
+ watchSkillsDir()
+ }
+
+ func stop() {
+ healthTimer?.invalidate()
+ sessionTimer?.invalidate()
+ sessionScanTimer?.invalidate()
+ skillsTimer?.invalidate()
+ statsTimer?.invalidate()
+ dirWatcher?.cancel()
+ saveStats()
+ }
+
+ // MARK: - Health
+
+ private func checkHealth() {
+ let url = URL(string: "http://127.0.0.1:18790/health")!
+ let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
+ Task { @MainActor in
+ guard let self else { return }
+ if let data,
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let status = json["status"] as? String, status == "ok" {
+ self.gatewayUp = true
+ self.gatewayUptime = (json["uptime"] as? String) ?? ""
+ } else {
+ self.gatewayUp = false
+ self.gatewayUptime = ""
+ }
+ }
+ }
+ task.resume()
+ }
+
+ // MARK: - Session files (JSONL)
+
+ private func discoverSessions() {
+ let fm = FileManager.default
+ guard let files = try? fm.contentsOfDirectory(atPath: sessionsDir) else { return }
+
+ for file in files where file.hasSuffix(".jsonl") {
+ let path = sessionsDir + "/" + file
+ guard trackedSessions[path] == nil else { continue }
+ let sessionId = file.replacingOccurrences(of: ".jsonl", with: "")
+ trackedSessions[path] = TrackedSession(path: path, offset: 0, sessionId: sessionId)
+ }
+
+ activeSessionCount = trackedSessions.count
+ }
+
+ private func processAllSessions(initialScan: Bool) {
+ let fm = FileManager.default
+
+ for (path, session) in trackedSessions {
+ guard let attrs = try? fm.attributesOfItem(atPath: path),
+ let size = (attrs[.size] as? NSNumber)?.uint64Value else { continue }
+
+ if size <= session.offset { continue }
+
+ var offset = session.offset
+ if initialScan && offset == 0 && size > 20_000 {
+ offset = size - 20_000
+ }
+
+ guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { continue }
+ defer { try? handle.close() }
+
+ do { try handle.seek(toOffset: offset) } catch { continue }
+
+ let data = handle.readDataToEndOfFile()
+ guard let text = String(data: data, encoding: .utf8) else { continue }
+
+ let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ let startIdx = (initialScan && offset > 0) ? 1 : 0
+
+ for i in startIdx..", with: " ")
+ lastMessage = String(clean.prefix(100))
+ }
+ }
+
+ // Detect tool_calls
+ if let toolCalls = json["tool_calls"] as? [[String: Any]] {
+ for tc in toolCalls {
+ guard let function = tc["function"] as? [String: Any],
+ let fnName = function["name"] as? String else { continue }
+
+ todayStats.toolCalls += 1
+
+ // Track check-access.sh for trial detection
+ if fnName == "exec",
+ let argsStr = function["arguments"] as? String,
+ argsStr.contains("check-access.sh") {
+ if let range = argsStr.range(of: "check-access.sh ") {
+ let raw = String(argsStr[range.upperBound...])
+ .replacingOccurrences(of: "\"", with: "")
+ .replacingOccurrences(of: "}", with: "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let digits = raw.unicodeScalars
+ .filter { CharacterSet.decimalDigits.contains($0) }
+ .map { String($0) }.joined()
+ if digits.count >= 10 {
+ pendingSessionPhone[sessionId] = "+" + digits
+ }
+ }
+ }
+
+ if fnName == "skill_manage" {
+ todayStats.skillManageCalls += 1
+
+ if let argsStr = function["arguments"] as? String,
+ let argsData = argsStr.data(using: .utf8),
+ let args = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] {
+
+ let op = args["operation"] as? String ?? "unknown"
+ let name = args["name"] as? String ?? "?"
+
+ recentSkillActivity = true
+ switch op {
+ case "create":
+ todayStats.skillsCreated += 1
+ addEvent(SkillEvent(operation: "create", skillName: name))
+ case "patch":
+ todayStats.skillsPatched += 1
+ addEvent(SkillEvent(operation: "patch", skillName: name))
+ case "update":
+ todayStats.skillsPatched += 1
+ addEvent(SkillEvent(operation: "update", skillName: name))
+ case "delete":
+ todayStats.skillsDeleted += 1
+ addEvent(SkillEvent(operation: "delete", skillName: name))
+ case "list":
+ addEvent(SkillEvent(operation: "list", skillName: "all",
+ detail: "Agente listou skills"))
+ default:
+ break
+ }
+ }
+ } else if !isInitial {
+ addEvent(SkillEvent(operation: "tool_call", skillName: fnName,
+ detail: "via \(sessionId.prefix(20))"))
+ }
+ }
+ }
+
+ // Detect trial starts from check-access.sh tool responses
+ if role == "tool", let phone = pendingSessionPhone[sessionId] {
+ let output = (json["content"] as? String) ?? ""
+ if output.contains("STATUS: TRIAL") && !output.contains("TRIAL_EXPIRED") {
+ // Dedup: only count once per session
+ if !countedTrialSessions.contains(sessionId) {
+ countedTrialSessions.insert(sessionId)
+ // Extract real phone from TELEFONE: +XXXX in tool output
+ var realPhone = phone
+ if let telRange = output.range(of: "TELEFONE: ") {
+ let after = String(output[telRange.upperBound...])
+ let phoneLine = after.components(separatedBy: .newlines).first ?? ""
+ let cleaned = phoneLine.trimmingCharacters(in: .whitespaces)
+ if !cleaned.isEmpty { realPhone = cleaned }
+ }
+ let contact = crmCache[realPhone]
+ let name = contact?.name ?? ""
+ let interaction = StatusInteraction(
+ phone: realPhone,
+ content: "Conversa de teste iniciada",
+ crmStatus: "trial",
+ contactName: name
+ )
+ trialInteractions.insert(interaction, at: 0)
+ if trialInteractions.count > 30 {
+ trialInteractions = Array(trialInteractions.prefix(30))
+ }
+ todayStats.trialStarts += 1
+ if !isInitial {
+ sendTrialNotification(phone: realPhone, name: name)
+ }
+ }
+ pendingSessionPhone.removeValue(forKey: sessionId)
+ } else if output.contains("STATUS:") {
+ pendingSessionPhone.removeValue(forKey: sessionId)
+ }
+ }
+ }
+
+ // MARK: - Status broadcast
+
+ private func processStatusLine(_ json: [String: Any], role: String, isInitial: Bool) {
+ if role == "user" {
+ pendingStatusContent = (json["content"] as? String) ?? ""
+ pendingStatusPhone = ""
+ }
+
+ if role == "assistant", let toolCalls = json["tool_calls"] as? [[String: Any]] {
+ for tc in toolCalls {
+ guard let function = tc["function"] as? [String: Any],
+ let argsStr = function["arguments"] as? String,
+ argsStr.contains("check-access.sh") else { continue }
+
+ if let range = argsStr.range(of: "check-access.sh ") {
+ let raw = String(argsStr[range.upperBound...])
+ .replacingOccurrences(of: "\"", with: "")
+ .replacingOccurrences(of: "}", with: "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let digits = raw.unicodeScalars
+ .filter { CharacterSet.decimalDigits.contains($0) }
+ .map { String($0) }.joined()
+ if digits.count >= 10 {
+ pendingStatusPhone = "+" + digits
+ }
+ }
+ }
+ }
+
+ if role == "tool", !pendingStatusPhone.isEmpty {
+ let output = (json["content"] as? String) ?? ""
+
+ var crmStatus = "unknown"
+ if output.contains("STATUS: ACTIVE") { crmStatus = "active" }
+ else if output.contains("STATUS: TRIAL_EXPIRED") { crmStatus = "trial_expired" }
+ else if output.contains("STATUS: TRIAL") { crmStatus = "trial" }
+ else if output.contains("STATUS: INACTIVE") { crmStatus = "inactive" }
+ else if output.contains("STATUS: IGNORED") { crmStatus = "ignored" }
+
+ let contact = crmCache[pendingStatusPhone]
+ let name = contact?.name ?? ""
+ let finalStatus = contact?.status ?? crmStatus
+
+ let interaction = StatusInteraction(
+ phone: pendingStatusPhone,
+ content: pendingStatusContent,
+ crmStatus: finalStatus,
+ contactName: name
+ )
+ statusInteractions.insert(interaction, at: 0)
+ if statusInteractions.count > 30 {
+ statusInteractions = Array(statusInteractions.prefix(30))
+ }
+ if !isInitial {
+ todayStats.statusViews += 1
+ recentSkillActivity = true
+ }
+
+ pendingStatusContent = ""
+ pendingStatusPhone = ""
+ }
+ }
+
+ // MARK: - CRM cache
+
+ private func loadCRM() {
+ let subscribersPath = picoHome + "/workspace/scripts/.subscribers.json"
+ let trialsPath = picoHome + "/workspace/scripts/.trials.json"
+ var cache: [String: CRMContact] = [:]
+
+ if let data = try? Data(contentsOf: URL(fileURLWithPath: subscribersPath)),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let subs = json["subscribers"] as? [String: [String: Any]] {
+ for (phone, info) in subs {
+ let name = info["name"] as? String ?? ""
+ let status = info["status"] as? String ?? "unknown"
+ cache[phone] = CRMContact(name: name, status: status)
+ }
+ }
+
+ if let data = try? Data(contentsOf: URL(fileURLWithPath: trialsPath)),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let trials = json["trials"] as? [String: [String: Any]] {
+ for (phone, info) in trials {
+ if cache[phone] != nil { continue }
+ let name = info["name"] as? String ?? ""
+ let step = info["step"] as? String ?? ""
+ let status = step == "connected" ? "trial_expired" : "trial"
+ cache[phone] = CRMContact(name: name, status: status)
+ }
+ }
+
+ crmCache = cache
+ }
+
+ // MARK: - Gateway log (for security blocks)
+
+ private func seekLogEnd() {
+ guard let attrs = try? FileManager.default.attributesOfItem(atPath: logPath),
+ let size = (attrs[.size] as? NSNumber)?.uint64Value else { return }
+ logOffset = size
+ }
+
+ private func tailLog() {
+ guard let attrs = try? FileManager.default.attributesOfItem(atPath: logPath),
+ let size = (attrs[.size] as? NSNumber)?.uint64Value,
+ size > logOffset else { return }
+
+ guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: logPath)) else { return }
+ defer { try? handle.close() }
+
+ try? handle.seek(toOffset: logOffset)
+ let data = handle.readDataToEndOfFile()
+ logOffset = size
+
+ guard let text = String(data: data, encoding: .utf8) else { return }
+
+ for line in text.split(separator: "\n") {
+ let s = String(line)
+ guard s.hasPrefix("{"),
+ let d = s.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: d) as? [String: Any] else { continue }
+
+ let message = json["message"] as? String ?? ""
+ if message.contains("security scan blocked") || message.contains("guard blocked") {
+ todayStats.blockedAttempts += 1
+ let name = json["skill_name"] as? String ?? "unknown"
+ addEvent(SkillEvent(operation: "block", skillName: name, detail: message))
+ recentSkillActivity = true
+ }
+ }
+ }
+
+ // MARK: - Skills directory
+
+ private func scanSkills() {
+ let fm = FileManager.default
+ guard let dirs = try? fm.contentsOfDirectory(atPath: skillsDir) else {
+ totalSkills = 0
+ skillsList = []
+ return
+ }
+
+ var skills: [SkillInfo] = []
+ for dir in dirs {
+ let skillMd = skillsDir + "/\(dir)/SKILL.md"
+ guard fm.fileExists(atPath: skillMd) else { continue }
+ let attrs = try? fm.attributesOfItem(atPath: skillMd)
+ let modified = (attrs?[.modificationDate] as? Date) ?? Date.distantPast
+ let desc = extractDescription(from: skillMd)
+ skills.append(SkillInfo(name: dir, description: desc, modified: modified))
+ }
+
+ skills.sort { $0.modified > $1.modified }
+ totalSkills = skills.count
+ skillsList = skills
+ }
+
+ private func extractDescription(from path: String) -> String {
+ guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return "" }
+ let lines = content.components(separatedBy: .newlines)
+ var inFrontmatter = false
+ for line in lines {
+ if line.trimmingCharacters(in: .whitespaces) == "---" {
+ if inFrontmatter { break }
+ inFrontmatter = true
+ continue
+ }
+ if inFrontmatter && line.hasPrefix("description:") {
+ return String(line.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces)
+ }
+ }
+ return ""
+ }
+
+ private func watchSkillsDir() {
+ let fd = open(skillsDir, O_EVTONLY)
+ guard fd >= 0 else { return }
+ let source = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: fd,
+ eventMask: [.write, .rename, .delete],
+ queue: .main
+ )
+ source.setEventHandler { [weak self] in
+ Task { @MainActor in self?.scanSkills() }
+ }
+ source.setCancelHandler { close(fd) }
+ source.resume()
+ dirWatcher = source
+ }
+
+ // MARK: - Notifications
+
+ private func sendTrialNotification(phone: String, name: String) {
+ let notification = NSUserNotification()
+ notification.title = "Novo Trial no WhatsApp"
+ notification.subtitle = name.isEmpty ? phone : "\(name) (\(phone))"
+ notification.informativeText = "Usuario em fase trial iniciou conversa"
+ notification.soundName = NSUserNotificationDefaultSoundName
+ NSUserNotificationCenter.default.deliver(notification)
+ updateWhatsAppStatus()
+ }
+
+ private func updateWhatsAppStatus() {
+ let count = todayStats.trialStarts
+ let f = DateFormatter()
+ f.dateFormat = "dd/MM"
+ let text = "\(f.string(from: Date())): \(count)"
+ let url = URL(string: "http://127.0.0.1:18790/api/send-status")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try? JSONSerialization.data(withJSONObject: ["text": text])
+ URLSession.shared.dataTask(with: request) { _, _, _ in }.resume()
+ }
+
+ // MARK: - Events
+
+ private func addEvent(_ event: SkillEvent) {
+ recentEvents.insert(event, at: 0)
+ if recentEvents.count > 50 {
+ recentEvents = Array(recentEvents.prefix(50))
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in
+ self?.recentSkillActivity = false
+ }
+ }
+
+ // MARK: - Stats persistence
+
+ static func todayString() -> String {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd"
+ return f.string(from: Date())
+ }
+
+ private func loadStats() {
+ guard let data = try? Data(contentsOf: URL(fileURLWithPath: statsPath)),
+ var stats = try? JSONDecoder().decode([DailyStats].self, from: data) else {
+ weeklyStats = []
+ return
+ }
+
+ let cutoff = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd"
+ stats = stats.filter { stat in
+ guard let d = f.date(from: stat.date) else { return false }
+ return d >= cutoff
+ }
+
+ weeklyStats = stats
+
+ let today = Self.todayString()
+ if let existing = stats.first(where: { $0.date == today }) {
+ todayStats = existing
+ }
+ }
+
+ private func saveStats() {
+ let today = Self.todayString()
+ todayStats = DailyStats(
+ date: today,
+ skillsCreated: todayStats.skillsCreated,
+ skillsPatched: todayStats.skillsPatched,
+ skillsDeleted: todayStats.skillsDeleted,
+ blockedAttempts: todayStats.blockedAttempts,
+ totalConversations: todayStats.totalConversations,
+ toolCalls: todayStats.toolCalls,
+ skillManageCalls: todayStats.skillManageCalls,
+ statusViews: todayStats.statusViews,
+ trialStarts: todayStats.trialStarts
+ )
+
+ var stats = weeklyStats.filter { $0.date != today }
+ stats.append(todayStats)
+ stats.sort { $0.date < $1.date }
+
+ if stats.count > 7 {
+ stats = Array(stats.suffix(7))
+ }
+
+ weeklyStats = stats
+
+ if let data = try? JSONEncoder().encode(stats) {
+ try? data.write(to: URL(fileURLWithPath: statsPath))
+ }
+ }
+
+ // MARK: - Weekly summary
+
+ var weeklySummary: (skills: Int, patches: Int, blocks: Int, conversations: Int, toolCalls: Int, statusViews: Int, trialStarts: Int) {
+ let all = weeklyStats + [todayStats]
+ return (
+ skills: all.reduce(0) { $0 + $1.skillsCreated },
+ patches: all.reduce(0) { $0 + $1.skillsPatched },
+ blocks: all.reduce(0) { $0 + $1.blockedAttempts },
+ conversations: all.reduce(0) { $0 + $1.totalConversations },
+ toolCalls: all.reduce(0) { $0 + $1.toolCalls },
+ statusViews: all.reduce(0) { $0 + $1.statusViews },
+ trialStarts: all.reduce(0) { $0 + $1.trialStarts }
+ )
+ }
+}
diff --git a/tools/PicoWatch/PicoWatch/PicoWatchApp.swift b/tools/PicoWatch/PicoWatch/PicoWatchApp.swift
new file mode 100644
index 000000000..d766113ba
--- /dev/null
+++ b/tools/PicoWatch/PicoWatch/PicoWatchApp.swift
@@ -0,0 +1,12 @@
+import SwiftUI
+
+@main
+struct PicoWatchApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ var body: some Scene {
+ Settings {
+ EmptyView()
+ }
+ }
+}
diff --git a/tools/PicoWatch/PicoWatch/PopoverView.swift b/tools/PicoWatch/PicoWatch/PopoverView.swift
new file mode 100644
index 000000000..40f25e330
--- /dev/null
+++ b/tools/PicoWatch/PicoWatch/PopoverView.swift
@@ -0,0 +1,490 @@
+import SwiftUI
+
+struct PopoverView: View {
+ @ObservedObject var engine: MonitorEngine
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ headerView
+ Divider()
+ gatewayStatus
+ Divider()
+ trialActivity
+ Divider()
+ weeklyReport
+ Divider()
+ recentActivity
+ Divider()
+ footerView
+ }
+ .frame(width: 380)
+ }
+
+ // MARK: - Header
+
+ private var headerView: some View {
+ HStack(spacing: 8) {
+ Image(systemName: "brain.head.profile")
+ .font(.title3)
+ .foregroundStyle(.purple)
+ Text("PicoWatch")
+ .font(.headline)
+ Spacer()
+ HStack(spacing: 5) {
+ Circle()
+ .fill(engine.gatewayUp ? Color.green : Color.red)
+ .frame(width: 7, height: 7)
+ Text(engine.gatewayUp ? "Online" : "Offline")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ }
+
+ // MARK: - Gateway
+
+ private var gatewayStatus: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Gateway")
+ .font(.system(.caption, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ if engine.gatewayUp {
+ Text("Uptime: \(engine.gatewayUptime)")
+ .font(.system(.caption, design: .monospaced))
+ } else {
+ Text("Not running")
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ }
+ Spacer()
+ VStack(alignment: .trailing, spacing: 2) {
+ Text("Skills")
+ .font(.system(.caption, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ Text("\(engine.totalSkills)")
+ .font(.system(.title2, design: .rounded, weight: .bold))
+ .foregroundStyle(.purple)
+ }
+ }
+ if !engine.lastMessage.isEmpty {
+ HStack(spacing: 5) {
+ Image(systemName: "bubble.left.fill")
+ .font(.system(size: 8))
+ .foregroundStyle(.green)
+ Text(engine.lastMessage)
+ .font(.system(size: 9))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+ HStack(spacing: 4) {
+ Image(systemName: "antenna.radiowaves.left.and.right")
+ .font(.system(size: 8))
+ .foregroundStyle(.tertiary)
+ Text("\(engine.activeSessionCount) sessoes ativas")
+ .font(.system(size: 9))
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ }
+
+ // MARK: - Trial Activity
+
+ private var trialActivity: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Image(systemName: "person.badge.clock")
+ .font(.system(size: 10))
+ .foregroundStyle(.orange)
+ Text("Conversas de Teste")
+ .font(.system(.caption, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ Spacer()
+ Text("\(engine.todayStats.trialStarts) hoje")
+ .font(.system(size: 9, weight: .semibold, design: .rounded))
+ .foregroundStyle(.orange)
+ }
+
+ if engine.trialInteractions.isEmpty {
+ Text("Nenhuma conversa de teste ainda")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .padding(.vertical, 2)
+ } else {
+ ScrollView {
+ VStack(spacing: 4) {
+ ForEach(engine.trialInteractions.prefix(6)) { trial in
+ HStack(spacing: 8) {
+ Circle()
+ .fill(Color.orange)
+ .frame(width: 7, height: 7)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(trial.contactName.isEmpty ? trial.phone : trial.contactName)
+ .font(.system(size: 10, weight: .semibold))
+ .lineLimit(1)
+ Text(trial.content)
+ .font(.system(size: 9))
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+ Spacer()
+ Text(relativeDate(trial.timestamp))
+ .font(.system(size: 8, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 4)
+ .background(RoundedRectangle(cornerRadius: 5).fill(Color.orange.opacity(0.06)))
+ }
+ }
+ }
+ .frame(maxHeight: 110)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ }
+
+ // MARK: - Status Broadcast
+
+ private var statusActivity: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Image(systemName: "dot.radiowaves.left.and.right")
+ .font(.system(size: 10))
+ .foregroundStyle(.cyan)
+ Text("Status Broadcast")
+ .font(.system(.caption, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ Spacer()
+ Text("\(engine.todayStats.statusViews) hoje")
+ .font(.system(size: 9, weight: .semibold, design: .rounded))
+ .foregroundStyle(.cyan)
+ }
+
+ if engine.statusInteractions.isEmpty {
+ Text("Nenhuma interacao de status ainda")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .padding(.vertical, 2)
+ } else {
+ ScrollView {
+ VStack(spacing: 4) {
+ ForEach(engine.statusInteractions.prefix(6)) { interaction in
+ statusRow(interaction)
+ }
+ }
+ }
+ .frame(maxHeight: 110)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ }
+
+ private func statusRow(_ interaction: StatusInteraction) -> some View {
+ HStack(spacing: 8) {
+ Circle()
+ .fill(crmBadgeColor(interaction.crmStatus))
+ .frame(width: 7, height: 7)
+ VStack(alignment: .leading, spacing: 1) {
+ HStack(spacing: 4) {
+ Text(interaction.contactName.isEmpty ? interaction.phone : interaction.contactName)
+ .font(.system(size: 10, weight: .semibold))
+ .lineLimit(1)
+ Text(crmBadgeLabel(interaction.crmStatus))
+ .font(.system(size: 8, weight: .medium))
+ .foregroundStyle(crmBadgeColor(interaction.crmStatus))
+ .padding(.horizontal, 4)
+ .padding(.vertical, 1)
+ .background(
+ RoundedRectangle(cornerRadius: 3)
+ .fill(crmBadgeColor(interaction.crmStatus).opacity(0.12))
+ )
+ }
+ if !interaction.content.isEmpty {
+ Text(interaction.content)
+ .font(.system(size: 9))
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+ }
+ Spacer()
+ Text(relativeDate(interaction.timestamp))
+ .font(.system(size: 8, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 4)
+ .background(RoundedRectangle(cornerRadius: 5).fill(Color.cyan.opacity(0.04)))
+ }
+
+ private func crmBadgeColor(_ status: String) -> Color {
+ switch status {
+ case "active": return .green
+ case "trial": return .orange
+ case "trial_expired": return .red
+ case "inactive": return .red
+ case "ignored": return .gray
+ default: return .secondary
+ }
+ }
+
+ private func crmBadgeLabel(_ status: String) -> String {
+ switch status {
+ case "active": return "assinante"
+ case "trial": return "trial"
+ case "trial_expired": return "expirado"
+ case "inactive": return "inativo"
+ case "ignored": return "ignorado"
+ default: return "novo"
+ }
+ }
+
+ // MARK: - Skills
+
+ private var skillsSummary: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Installed Skills")
+ .font(.system(.caption, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ if engine.skillsList.isEmpty {
+ Text("Nenhuma skill ainda — o agente cria quando completa tarefas complexas")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .padding(.vertical, 4)
+ } else {
+ ForEach(engine.skillsList) { skill in
+ HStack(spacing: 8) {
+ Image(systemName: "doc.text")
+ .font(.system(size: 10))
+ .foregroundStyle(.purple)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(skill.name)
+ .font(.system(.caption, design: .monospaced, weight: .semibold))
+ if !skill.description.isEmpty {
+ Text(skill.description)
+ .font(.system(size: 9))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ Spacer()
+ Text(relativeDate(skill.modified))
+ .font(.system(size: 9, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 5)
+ .background(RoundedRectangle(cornerRadius: 5).fill(Color.purple.opacity(0.06)))
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ }
+
+ // MARK: - Weekly Report
+
+ private var weeklyReport: some View {
+ let summary = engine.weeklySummary
+ return VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text("Relatorio Semanal")
+ .font(.system(.caption, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ Spacer()
+ Text("\(engine.weeklyStats.count + 1) dias")
+ .font(.system(size: 9))
+ .foregroundStyle(.tertiary)
+ }
+
+ HStack(spacing: 6) {
+ metricCard(icon: "person.badge.clock", color: .orange,
+ label: "Trials", value: "\(summary.trialStarts)")
+ }
+
+ // Mini bar chart for daily activity
+ if !engine.weeklyStats.isEmpty {
+ weeklyChart
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ }
+
+ private func metricCard(icon: String, color: Color, label: String, value: String) -> some View {
+ VStack(spacing: 3) {
+ HStack(spacing: 3) {
+ Image(systemName: icon)
+ .font(.system(size: 8))
+ .foregroundStyle(color)
+ Text(value)
+ .font(.system(.callout, design: .rounded, weight: .bold))
+ }
+ Text(label)
+ .font(.system(size: 8))
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 6)
+ .background(RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.08)))
+ }
+
+ private var weeklyChart: some View {
+ let allDays = engine.weeklyStats + [engine.todayStats]
+ let maxOps = max(allDays.map(\.totalSkillOps).max() ?? 1, 1)
+
+ return HStack(alignment: .bottom, spacing: 3) {
+ ForEach(allDays) { day in
+ VStack(spacing: 2) {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(Color.purple.opacity(0.6))
+ .frame(height: max(2, CGFloat(day.totalSkillOps) / CGFloat(maxOps) * 30))
+ Text(dayLabel(day.date))
+ .font(.system(size: 7, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .frame(height: 45)
+ }
+
+ // MARK: - Recent Activity
+
+ private var recentActivity: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Atividade Recente")
+ .font(.system(.caption, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ if engine.recentEvents.isEmpty {
+ Text("Nenhum evento de skill ainda — use o agente no WhatsApp")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .padding(.vertical, 4)
+ } else {
+ ScrollView {
+ VStack(spacing: 4) {
+ ForEach(engine.recentEvents.prefix(8)) { event in
+ eventRow(event)
+ }
+ }
+ }
+ .frame(maxHeight: 120)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ }
+
+ private func eventRow(_ event: SkillEvent) -> some View {
+ HStack(spacing: 8) {
+ Image(systemName: eventIcon(event.operation))
+ .font(.system(size: 10))
+ .foregroundStyle(eventColor(event.operation))
+ .frame(width: 16)
+ VStack(alignment: .leading, spacing: 1) {
+ HStack(spacing: 4) {
+ Text(eventLabel(event.operation))
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(eventColor(event.operation))
+ Text(event.skillName)
+ .font(.system(size: 10, design: .monospaced))
+ }
+ if !event.detail.isEmpty {
+ Text(event.detail)
+ .font(.system(size: 8))
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+ }
+ Spacer()
+ Text(relativeDate(event.timestamp))
+ .font(.system(size: 8, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 3)
+ }
+
+ private func eventIcon(_ op: String) -> String {
+ switch op {
+ case "create": return "plus.circle.fill"
+ case "patch", "update": return "wrench.fill"
+ case "delete": return "trash.fill"
+ case "block": return "shield.slash.fill"
+ default: return "circle.fill"
+ }
+ }
+
+ private func eventColor(_ op: String) -> Color {
+ switch op {
+ case "create": return .green
+ case "patch", "update": return .orange
+ case "delete": return .red
+ case "block": return .red
+ default: return .gray
+ }
+ }
+
+ private func eventLabel(_ op: String) -> String {
+ switch op {
+ case "create": return "CRIOU"
+ case "patch": return "PATCH"
+ case "update": return "UPDATE"
+ case "delete": return "DELETOU"
+ case "block": return "BLOQUEOU"
+ default: return op.uppercased()
+ }
+ }
+
+ // MARK: - Footer
+
+ private var footerView: some View {
+ HStack {
+ Button("Quit") {
+ NSApplication.shared.terminate(nil)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ .font(.caption)
+ Spacer()
+ Text("~/.picoclaw/workspace/skills")
+ .font(.system(size: 8, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ }
+
+ // MARK: - Helpers
+
+ private func relativeDate(_ date: Date) -> String {
+ let elapsed = Date().timeIntervalSince(date)
+ if elapsed < 60 { return "agora" }
+ if elapsed < 3600 { return "\(Int(elapsed / 60))m" }
+ if elapsed < 86400 { return "\(Int(elapsed / 3600))h" }
+ return "\(Int(elapsed / 86400))d"
+ }
+
+ private func dayLabel(_ dateStr: String) -> String {
+ String(dateStr.suffix(2))
+ }
+}
diff --git a/tools/PicoWatch/build.sh b/tools/PicoWatch/build.sh
new file mode 100755
index 000000000..74b5f1f91
--- /dev/null
+++ b/tools/PicoWatch/build.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+set -euo pipefail
+
+APP_NAME="PicoWatch"
+BUILD_DIR="build"
+APP_DIR="$BUILD_DIR/$APP_NAME.app"
+
+echo "Building $APP_NAME..."
+
+rm -rf "$BUILD_DIR"
+mkdir -p "$APP_DIR/Contents/MacOS"
+mkdir -p "$APP_DIR/Contents/Resources"
+
+xcrun swiftc \
+ -sdk "$(xcrun --show-sdk-path -sdk macosx)" \
+ -target arm64-apple-macosx14.0 \
+ -framework AppKit -framework SwiftUI \
+ -parse-as-library \
+ -O \
+ PicoWatch/PicoWatchApp.swift \
+ PicoWatch/AppDelegate.swift \
+ PicoWatch/MonitorEngine.swift \
+ PicoWatch/PopoverView.swift \
+ -o "$APP_DIR/Contents/MacOS/$APP_NAME"
+
+cp PicoWatch/Info.plist "$APP_DIR/Contents/Info.plist"
+
+echo "Built: $APP_DIR"
+echo "Run with: open \"$APP_DIR\""