426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
265 lines
10 KiB
Swift
265 lines
10 KiB
Swift
// activity-collector.swift
|
|
//
|
|
// Long-running macOS helper that subscribes to NSWorkspace application-focus
|
|
// notifications and emits one JSON object per line to stdout.
|
|
//
|
|
// Build (Darwin only):
|
|
// swiftc -O activity-collector.swift -o activity-collector
|
|
//
|
|
// Output format (one JSON object per line, trailing \n):
|
|
// {"ts":1714000000000,"event":"activate","bundleId":"com.apple.Safari","appName":"Safari","windowTitle":"Example — Google Search"}
|
|
// {"ts":1714000003000,"event":"deactivate","bundleId":"com.apple.Safari","appName":"Safari"}
|
|
//
|
|
// Contract:
|
|
// - Writes complete lines terminated with \n.
|
|
// - Flushes stdout after every line.
|
|
// - Exits cleanly on SIGTERM / SIGINT.
|
|
// - No stderr output unless a fatal error occurs (stderr line prefixed "[activity-collector] ").
|
|
//
|
|
// System sleep / lock integration:
|
|
// - On system sleep, screen lock, or screens-off, we emit a synthetic
|
|
// `deactivate` for the last-activated app so downstream sleep inference
|
|
// does not treat a lingering frontmost app as hours of continuous use.
|
|
// - On wake / unlock, we re-emit an `activate` for the current frontmost app.
|
|
//
|
|
// The TypeScript service spawns this helper, pipes stdout, and persists events.
|
|
|
|
#if os(macOS)
|
|
import Foundation
|
|
import AppKit
|
|
import CoreGraphics
|
|
|
|
// Avoid Swift's JSONEncoder overhead per-event: build the JSON string manually.
|
|
// Escape the minimum set required by RFC 8259 for string scalars.
|
|
func jsonEscape(_ s: String) -> String {
|
|
var out = ""
|
|
out.reserveCapacity(s.count + 2)
|
|
for scalar in s.unicodeScalars {
|
|
switch scalar {
|
|
case "\"": out += "\\\""
|
|
case "\\": out += "\\\\"
|
|
case "\n": out += "\\n"
|
|
case "\r": out += "\\r"
|
|
case "\t": out += "\\t"
|
|
case "\u{08}": out += "\\b"
|
|
case "\u{0C}": out += "\\f"
|
|
default:
|
|
if scalar.value < 0x20 {
|
|
out += String(format: "\\u%04x", scalar.value)
|
|
} else {
|
|
out.unicodeScalars.append(scalar)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func emit(event: String, bundleId: String, appName: String, windowTitle: String?) {
|
|
let tsMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
var fields = [
|
|
"\"ts\":\(tsMs)",
|
|
"\"event\":\"\(jsonEscape(event))\"",
|
|
"\"bundleId\":\"\(jsonEscape(bundleId))\"",
|
|
"\"appName\":\"\(jsonEscape(appName))\"",
|
|
]
|
|
if let title = windowTitle, !title.isEmpty {
|
|
fields.append("\"windowTitle\":\"\(jsonEscape(title))\"")
|
|
}
|
|
let line = "{" + fields.joined(separator: ",") + "}\n"
|
|
FileHandle.standardOutput.write(line.data(using: .utf8) ?? Data())
|
|
}
|
|
|
|
// Emit a periodic HID idle sample so LifeOps can infer passive-media vs
|
|
// away-from-keyboard without depending on the Electrobun desktop bridge.
|
|
// CGEventSourceSecondsSinceLastEventType is session-bound; it reports the
|
|
// idle time for the active console session which is exactly what we want
|
|
// for the signed-in owner.
|
|
func emitHidIdle(_ idleSeconds: Double) {
|
|
let tsMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
let rounded = max(0, Int(idleSeconds.rounded()))
|
|
let line = "{\"ts\":\(tsMs),\"event\":\"hid_idle\",\"idleSeconds\":\(rounded)}\n"
|
|
FileHandle.standardOutput.write(line.data(using: .utf8) ?? Data())
|
|
}
|
|
|
|
func currentHidIdleSeconds() -> Double {
|
|
// `anyInputEventType` covers mouse + keyboard; using combinedSessionState
|
|
// so we read idle for the active session, not the process.
|
|
let anyInputEventType = CGEventType(rawValue: UInt32.max)!
|
|
return CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyInputEventType)
|
|
}
|
|
|
|
func frontmostWindowTitle(for app: NSRunningApplication) -> String? {
|
|
// Reading the window title requires Accessibility permission. We attempt
|
|
// it via AX API; any failure returns nil (no windowTitle field emitted).
|
|
let pid = app.processIdentifier
|
|
let axApp = AXUIElementCreateApplication(pid)
|
|
var focused: AnyObject?
|
|
let err = AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &focused)
|
|
guard err == .success, let window = focused else { return nil }
|
|
var titleValue: AnyObject?
|
|
let titleErr = AXUIElementCopyAttributeValue(window as! AXUIElement, kAXTitleAttribute as CFString, &titleValue)
|
|
guard titleErr == .success, let titleStr = titleValue as? String else { return nil }
|
|
return titleStr
|
|
}
|
|
|
|
final class Collector {
|
|
let workspace = NSWorkspace.shared
|
|
var lastActivatedBundleId: String?
|
|
var lastActivatedAppName: String?
|
|
|
|
func start() {
|
|
let nc = workspace.notificationCenter
|
|
nc.addObserver(
|
|
forName: NSWorkspace.didActivateApplicationNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] note in
|
|
self?.handleActivate(note)
|
|
}
|
|
nc.addObserver(
|
|
forName: NSWorkspace.didDeactivateApplicationNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] note in
|
|
self?.handleDeactivate(note)
|
|
}
|
|
// macOS does not fire NSWorkspace.didDeactivateApplicationNotification
|
|
// when the system sleeps, the screen locks, or the screensaver kicks
|
|
// in. Without a synthetic deactivate the last-activated app looks
|
|
// "focused" for hours, which hides sleep from downstream inference.
|
|
let systemDeactivateNames: [Notification.Name] = [
|
|
NSWorkspace.willSleepNotification,
|
|
NSWorkspace.screensDidSleepNotification,
|
|
NSWorkspace.sessionDidResignActiveNotification,
|
|
]
|
|
for name in systemDeactivateNames {
|
|
nc.addObserver(
|
|
forName: name,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.emitSystemDeactivate(reason: name.rawValue)
|
|
}
|
|
}
|
|
let systemActivateNames: [Notification.Name] = [
|
|
NSWorkspace.didWakeNotification,
|
|
NSWorkspace.screensDidWakeNotification,
|
|
NSWorkspace.sessionDidBecomeActiveNotification,
|
|
]
|
|
for name in systemActivateNames {
|
|
nc.addObserver(
|
|
forName: name,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.emitSystemActivate()
|
|
}
|
|
}
|
|
// The screen-locked / screen-unlocked notifications live on the
|
|
// Distributed Notification Center, not the workspace center.
|
|
let dnc = DistributedNotificationCenter.default()
|
|
dnc.addObserver(
|
|
forName: Notification.Name("com.apple.screenIsLocked"),
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.emitSystemDeactivate(reason: "screenIsLocked")
|
|
}
|
|
dnc.addObserver(
|
|
forName: Notification.Name("com.apple.screenIsUnlocked"),
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.emitSystemActivate()
|
|
}
|
|
|
|
// Emit the current frontmost app as a synthetic first activate so the
|
|
// consumer has a starting anchor for duration computation.
|
|
if let current = workspace.frontmostApplication {
|
|
let bundleId = current.bundleIdentifier ?? ""
|
|
let appName = current.localizedName ?? ""
|
|
let title = frontmostWindowTitle(for: current)
|
|
lastActivatedBundleId = bundleId
|
|
lastActivatedAppName = appName
|
|
emit(event: "activate", bundleId: bundleId, appName: appName, windowTitle: title)
|
|
}
|
|
}
|
|
|
|
func handleActivate(_ note: Notification) {
|
|
guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else {
|
|
return
|
|
}
|
|
let bundleId = app.bundleIdentifier ?? ""
|
|
let appName = app.localizedName ?? ""
|
|
let title = frontmostWindowTitle(for: app)
|
|
lastActivatedBundleId = bundleId
|
|
lastActivatedAppName = appName
|
|
emit(event: "activate", bundleId: bundleId, appName: appName, windowTitle: title)
|
|
}
|
|
|
|
func handleDeactivate(_ note: Notification) {
|
|
guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else {
|
|
return
|
|
}
|
|
let bundleId = app.bundleIdentifier ?? ""
|
|
let appName = app.localizedName ?? ""
|
|
emit(event: "deactivate", bundleId: bundleId, appName: appName, windowTitle: nil)
|
|
}
|
|
|
|
func emitSystemDeactivate(reason: String) {
|
|
guard let bundleId = lastActivatedBundleId else { return }
|
|
let appName = lastActivatedAppName ?? ""
|
|
emit(event: "deactivate", bundleId: bundleId, appName: appName, windowTitle: nil)
|
|
// Clear so a subsequent deactivate does not double-emit for the same
|
|
// system-sleep transition.
|
|
lastActivatedBundleId = nil
|
|
lastActivatedAppName = nil
|
|
_ = reason
|
|
}
|
|
|
|
func emitSystemActivate() {
|
|
guard let current = workspace.frontmostApplication else { return }
|
|
let bundleId = current.bundleIdentifier ?? ""
|
|
let appName = current.localizedName ?? ""
|
|
let title = frontmostWindowTitle(for: current)
|
|
lastActivatedBundleId = bundleId
|
|
lastActivatedAppName = appName
|
|
emit(event: "activate", bundleId: bundleId, appName: appName, windowTitle: title)
|
|
}
|
|
}
|
|
|
|
// Line-buffer stdout so the consumer sees events immediately.
|
|
setbuf(stdout, nil)
|
|
|
|
let signalSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
|
|
signalSource.setEventHandler { exit(0) }
|
|
signalSource.resume()
|
|
signal(SIGTERM, SIG_IGN)
|
|
|
|
let intSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
|
intSource.setEventHandler { exit(0) }
|
|
intSource.resume()
|
|
signal(SIGINT, SIG_IGN)
|
|
|
|
let collector = Collector()
|
|
collector.start()
|
|
|
|
// Periodic HID idle sampling. 30s cadence is cheap and gives the LifeOps
|
|
// scorer enough resolution to distinguish passive-media from away-from-desk
|
|
// (20 min idle is the awake_state timeout in `sleep-wake-spec.md`).
|
|
let hidIdleTimer = DispatchSource.makeTimerSource(queue: .main)
|
|
hidIdleTimer.schedule(deadline: .now() + .seconds(5), repeating: .seconds(30))
|
|
hidIdleTimer.setEventHandler {
|
|
emitHidIdle(currentHidIdleSeconds())
|
|
}
|
|
hidIdleTimer.resume()
|
|
|
|
RunLoop.main.run()
|
|
#else
|
|
// Non-Darwin unsupported entrypoint. The collector only runs on macOS, but this
|
|
// branch keeps the file compileable on Linux CI.
|
|
import Foundation
|
|
FileHandle.standardError.write("[activity-collector] This helper only runs on macOS.\n".data(using: .utf8) ?? Data())
|
|
exit(2)
|
|
#endif
|