Skip to content
Open
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
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
lint:
name: Lint
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: uv sync
- run: mise run lint 2>&1 | tee /tmp/output.txt; exit ${PIPESTATUS[0]}
- if: failure()
run: |
# Emit last 50 lines as a warning annotation so it's visible via API
tail -50 /tmp/output.txt | while IFS= read -r line; do echo "::warning::$line"; done

test-python:
name: Python Tests
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: uv sync
- run: mise run test-py

build:
name: Build
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: mise run build 2>&1 | tee /tmp/output.txt; exit ${PIPESTATUS[0]}
- if: failure()
run: |
tail -50 /tmp/output.txt | while IFS= read -r line; do echo "::warning::$line"; done

test:
name: Swift Tests
runs-on: macos-26
needs: build
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: mise run test 2>&1 | tee /tmp/output.txt; exit ${PIPESTATUS[0]}
- if: failure()
run: |
# Emit errors/failures specifically, then tail
grep -iE '(error:|fail|fatal|assert)' /tmp/output.txt | head -30 | while IFS= read -r line; do echo "::error::$line"; done
echo "::warning::--- LAST 50 LINES ---"
tail -50 /tmp/output.txt | while IFS= read -r line; do echo "::warning::$line"; done
2 changes: 1 addition & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ disabled_rules:
- line_length # swift-format handles this
- opening_brace # conflicts with swift-format multi-line conditions
- implicit_optional_initialization # stylistic, not a bug
- cyclomatic_complexity # menu bar rendering function legitimately complex

opt_in_rules:
- empty_count
- closure_spacing
- contains_over_first_not_nil
- force_unwrapping
- implicitly_unwrapped_optional
- modifier_order
- overridden_super_call
Expand Down
36 changes: 33 additions & 3 deletions Sources/ClawdboardLib/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@ public class AppState {

private var stateWatcher: SessionStateWatcher?
private var remoteWatcher: RemoteSessionWatcher?
private var cloudWatcher: CloudSessionWatcher?
private var usageLimitsWatcher: UsageLimitsWatcher?

/// Remote sessions keyed by host identifier
private var remoteSessions: [String: [AgentSession]] = [:]

/// Cloud sessions from Firestore
private var cloudSessions: [AgentSession] = []

/// Local sessions from the local watcher
private var localSessions: [AgentSession] = []

Expand All @@ -50,6 +54,7 @@ public class AppState {
stateWatcher?.start()

startRemoteWatcher()
startCloudWatcher()
startUsageLimitsWatcher()
}

Expand All @@ -58,6 +63,8 @@ public class AppState {
stateWatcher = nil
remoteWatcher?.stop()
remoteWatcher = nil
cloudWatcher?.stop()
cloudWatcher = nil
usageLimitsWatcher?.stop()
usageLimitsWatcher = nil
}
Expand Down Expand Up @@ -122,6 +129,26 @@ public class AppState {
remoteWatcher?.updateHosts(remoteHosts)
}

// MARK: - Cloud Watcher

private func startCloudWatcher() {
guard KeychainManager.shared.hasKeypair else { return }
cloudWatcher = CloudSessionWatcher { [weak self] sessions in
self?.cloudSessions = sessions
self?.rebuildSessions()
}
cloudWatcher?.start()
}

/// Restart cloud watcher (e.g. after keypair generation).
public func restartCloudWatcher() {
cloudWatcher?.stop()
cloudWatcher = nil
cloudSessions = []
startCloudWatcher()
rebuildSessions()
}

// MARK: - Usage Limits Watcher

private func startUsageLimitsWatcher() {
Expand Down Expand Up @@ -151,12 +178,15 @@ public class AppState {
processSession(session, now: now)
}

let processedRemote = remoteSessions.values.flatMap { $0 }.compactMap {
session -> AgentSession? in
let processedRemote = remoteSessions.values.flatMap { $0 }.compactMap { session -> AgentSession? in
processSession(session, now: now)
}

let processedCloud = cloudSessions.compactMap { session -> AgentSession? in
processSession(session, now: now)
}

sessions = processedLocal + processedRemote
sessions = processedLocal + processedRemote + processedCloud
}

// MARK: - Session Processing
Expand Down
114 changes: 114 additions & 0 deletions Sources/ClawdboardLib/CloudSessionWatcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Foundation

/// Polls Firestore for encrypted cloud session state, decrypts with the local private key,
/// and delivers AgentSession updates. Follows the same pattern as RemoteSessionWatcher.
public class CloudSessionWatcher {
private var timer: Timer?
private var isEnabled = false
private let onChange: (_ sessions: [AgentSession]) -> Void

/// Default Firebase project — used unless overridden.
public static let defaultFirebaseProject = "clawdboard-cloud"

/// Polling interval for cloud sessions (seconds).
public var pollInterval: TimeInterval = 10

public init(onChange: @escaping (_ sessions: [AgentSession]) -> Void) {
self.onChange = onChange
}

/// Start polling Firestore for cloud sessions.
public func start() {
guard !isEnabled else { return }
guard KeychainManager.shared.hasKeypair else { return }
isEnabled = true
poll()
timer = Timer.scheduledTimer(
withTimeInterval: pollInterval, repeats: true
) { [weak self] _ in
self?.poll()
}
}

/// Stop polling.
public func stop() {
timer?.invalidate()
timer = nil
isEnabled = false
}

/// Restart with current settings (e.g. after keypair generation).
public func restart() {
stop()
start()
}

// MARK: - Firestore Polling

private func poll() {
guard let channelId = KeychainManager.shared.channelId else { return }

let project = Self.defaultFirebaseProject
// Query all documents in the channel's sessions collection
let urlString =
"https://firestore.googleapis.com/v1/projects/\(project)/databases/(default)/documents/channels/\(channelId)/sessions"

guard let url = URL(string: urlString) else { return }

let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
guard let self = self else { return }

guard let data = data, error == nil,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
DispatchQueue.main.async {
self.onChange([])
}
return
}

let sessions = self.parseFirestoreResponse(data)
DispatchQueue.main.async {
self.onChange(sessions)
}
}
task.resume()
}

/// Parse Firestore REST API response and decrypt session blobs.
private func parseFirestoreResponse(_ data: Data) -> [AgentSession] {
guard
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let documents = json["documents"] as? [[String: Any]]
else {
return []
}

var sessions: [AgentSession] = []
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

for doc in documents {
guard let fields = doc["fields"] as? [String: Any],
let blobField = fields["blob"] as? [String: Any],
let blobBase64 = blobField["stringValue"] as? String,
let blobData = Data(base64Encoded: blobBase64)
else { continue }

guard let decrypted = try? KeychainManager.shared.decrypt(blob: blobData),
var session = try? decoder.decode(AgentSession.self, from: decrypted)
else { continue }

// Tag as cloud session
session.isCloudSession = true
sessions.append(session)
}

return sessions
}

deinit {
stop()
}
}
68 changes: 68 additions & 0 deletions Sources/ClawdboardLib/HookManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ public class HookManager {

/// The hook script content for remote installation
public static func remoteHookScript() -> String {
// swiftlint:disable:next force_try
try! scriptSource("clawdboard-hook.py")
}

Expand Down Expand Up @@ -197,6 +198,73 @@ public class HookManager {
throw HookError.scriptNotFound(filename)
}

/// Generate a setup script for cloud VMs that installs hooks with cloud push support.
/// This script is pasted into the cloud VM's Setup Script field.
public static func cloudSetupScript() -> String {
let hookScript: String
do {
hookScript = try scriptSource("clawdboard-hook.py")
} catch {
hookScript = "# Error: could not load hook script"
}

return """
#!/bin/bash
set -e

# Install Clawdboard hook for cloud session monitoring
mkdir -p ~/.clawdboard/hooks ~/.clawdboard/sessions

# Install the cryptography package for ECIES encryption
pip install -q cryptography 2>/dev/null || pip3 install -q cryptography 2>/dev/null || {
echo "ERROR: Failed to install 'cryptography' package. Cloud push requires it."
exit 1
}

# Verify the import actually works
python3 -c "from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey" 2>/dev/null || {
echo "ERROR: 'cryptography' installed but import failed. Check Python environment."
exit 1
}

# Write hook script
cat > ~/.clawdboard/hooks/clawdboard-hook.py << 'CLAWDBOARD_HOOK_EOF'
\(hookScript)
CLAWDBOARD_HOOK_EOF

chmod 755 ~/.clawdboard/hooks/clawdboard-hook.py

# Merge hooks into Claude Code settings
python3 -c "
import json, os
settings_path = os.path.expanduser('~/.claude/settings.json')
os.makedirs(os.path.dirname(settings_path), exist_ok=True)
settings = {}
if os.path.isfile(settings_path):
try:
settings = json.load(open(settings_path))
except: pass
hooks = settings.get('hooks', {})
hook_cmd = 'python3 ~/.clawdboard/hooks/clawdboard-hook.py'
events = ['SessionStart','PostToolUse','PermissionRequest','Stop','UserPromptSubmit','SessionEnd','SubagentStart','SubagentStop']
for event in events:
entries = [e for e in hooks.get(event, []) if not any('clawdboard' in h.get('command','') for h in e.get('hooks',[]))]
entries.append({'matcher':'*','hooks':[{'type':'command','command':hook_cmd,'timeout':10}]})
hooks[event] = entries
notifs = [e for e in hooks.get('Notification', []) if not any('clawdboard' in h.get('command','') for h in e.get('hooks',[]))]
for m in ['idle_prompt','permission_prompt']:
notifs.append({'matcher':m,'hooks':[{'type':'command','command':hook_cmd+' '+m,'timeout':10}]})
hooks['Notification'] = notifs
settings['hooks'] = hooks
with open(settings_path, 'w') as f:
json.dump(settings, f, indent=2, sort_keys=True)
print('Clawdboard hooks installed')
"

echo "Clawdboard cloud setup complete"
"""
}

public enum HookError: Error, LocalizedError {
case scriptNotFound(String)

Expand Down
Loading