poke-gate 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/Poke macOS Gate/Poke macOS Gate/GateService.swift +93 -2
- package/clients/Poke macOS Gate/Poke macOS Gate/Info.plist +11 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/Poke_macOS_GateApp.swift +18 -1
- package/docs/.vitepress/theme/custom.css +7 -0
- package/docs/.vitepress/theme/index.js +4 -0
- package/docs/agents/index.md +8 -7
- package/docs/how-it-works.md +9 -8
- package/examples/agents/context.30m.js +148 -0
- package/examples/agents/music.10m.js +89 -0
- package/package.json +1 -1
- package/src/app.js +60 -31
- package/src/mcp-server.js +18 -9
- package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.xcworkspace/xcuserdata/fka.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
- package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/xcuserdata/fka.xcuserdatad/xcschemes/xcschememanagement.plist +0 -14
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import Combine
|
|
3
|
+
import ScreenCaptureKit
|
|
3
4
|
|
|
4
5
|
@MainActor
|
|
5
6
|
class GateService: ObservableObject {
|
|
@@ -20,6 +21,8 @@ class GateService: ObservableObject {
|
|
|
20
21
|
private var outputPipe: Pipe?
|
|
21
22
|
private var shouldRestart = true
|
|
22
23
|
private let maxLogs = 200
|
|
24
|
+
private var restartAttempts = 0
|
|
25
|
+
private var healthCheckTimer: Timer?
|
|
23
26
|
|
|
24
27
|
var apiKey: String {
|
|
25
28
|
get { loadAPIKey() ?? "" }
|
|
@@ -41,6 +44,68 @@ class GateService: ObservableObject {
|
|
|
41
44
|
appendLog("Launched poke login (npx: \(npxBin)) — check your browser.")
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
func captureAndSend() {
|
|
48
|
+
appendLog("Screenshot requested via deeplink.")
|
|
49
|
+
|
|
50
|
+
Task {
|
|
51
|
+
do {
|
|
52
|
+
let content = try await SCShareableContent.current
|
|
53
|
+
guard let display = content.displays.first else {
|
|
54
|
+
appendLog("No display found for screenshot.")
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let filter = SCContentFilter(display: display, excludingWindows: [])
|
|
59
|
+
let config = SCStreamConfiguration()
|
|
60
|
+
config.width = display.width * 2
|
|
61
|
+
config.height = display.height * 2
|
|
62
|
+
config.capturesAudio = false
|
|
63
|
+
|
|
64
|
+
let image = try await SCScreenshotManager.captureImage(
|
|
65
|
+
contentFilter: filter,
|
|
66
|
+
configuration: config
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
let rep = NSBitmapImageRep(cgImage: image)
|
|
70
|
+
guard let pngData = rep.representation(using: .png, properties: [:]) else {
|
|
71
|
+
appendLog("Failed to encode screenshot as PNG.")
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let tempPath = NSTemporaryDirectory() + "poke-gate-screenshot.png"
|
|
76
|
+
let tempURL = URL(fileURLWithPath: tempPath)
|
|
77
|
+
try pngData.write(to: tempURL)
|
|
78
|
+
appendLog("Screenshot saved to \(tempPath) (\(pngData.count) bytes)")
|
|
79
|
+
|
|
80
|
+
let base64 = pngData.base64EncodedString()
|
|
81
|
+
|
|
82
|
+
guard let token = loadPokeLoginToken() else {
|
|
83
|
+
appendLog("Cannot send screenshot: not signed in to Poke.")
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let url = URL(string: "https://poke.com/api/v1/inbound/api-message")!
|
|
88
|
+
var request = URLRequest(url: url)
|
|
89
|
+
request.httpMethod = "POST"
|
|
90
|
+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
91
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
92
|
+
|
|
93
|
+
let message = "Here's a screenshot of my screen right now. [Image attached as base64 PNG, \(pngData.count) bytes, \(display.width)x\(display.height)]"
|
|
94
|
+
let body: [String: Any] = ["message": message]
|
|
95
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
96
|
+
|
|
97
|
+
let (_, response) = try await URLSession.shared.data(for: request)
|
|
98
|
+
if let httpResp = response as? HTTPURLResponse, httpResp.statusCode == 200 {
|
|
99
|
+
appendLog("Screenshot sent to Poke.")
|
|
100
|
+
} else {
|
|
101
|
+
appendLog("Failed to send screenshot to Poke.")
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
appendLog("Screenshot error: \(error.localizedDescription)")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
44
109
|
func autoStartIfNeeded() {
|
|
45
110
|
guard !hasAutoStarted else { return }
|
|
46
111
|
hasAutoStarted = true
|
|
@@ -80,7 +145,9 @@ class GateService: ObservableObject {
|
|
|
80
145
|
|
|
81
146
|
func stop() {
|
|
82
147
|
shouldRestart = false
|
|
148
|
+
stopHealthCheck()
|
|
83
149
|
killProcess()
|
|
150
|
+
restartAttempts = 0
|
|
84
151
|
status = .stopped
|
|
85
152
|
}
|
|
86
153
|
|
|
@@ -235,6 +302,8 @@ class GateService: ObservableObject {
|
|
|
235
302
|
|
|
236
303
|
if line.contains("Tunnel connected") || line.contains("Ready") {
|
|
237
304
|
status = .connected
|
|
305
|
+
restartAttempts = 0
|
|
306
|
+
startHealthCheck()
|
|
238
307
|
} else if line.contains("Tunnel disconnected") || line.contains("Reconnecting") {
|
|
239
308
|
status = .disconnected
|
|
240
309
|
} else if line.contains("Failed to connect") || line.contains("error") {
|
|
@@ -247,10 +316,14 @@ class GateService: ObservableObject {
|
|
|
247
316
|
|
|
248
317
|
private func handleTermination(exitCode: Int32) {
|
|
249
318
|
appendLog("Process exited with code \(exitCode)")
|
|
319
|
+
stopHealthCheck()
|
|
320
|
+
|
|
250
321
|
if shouldRestart {
|
|
322
|
+
restartAttempts += 1
|
|
323
|
+
let delay = min(Double(2 * (1 << min(restartAttempts - 1, 5))), 60.0)
|
|
251
324
|
status = .disconnected
|
|
252
|
-
appendLog("Restarting in
|
|
253
|
-
DispatchQueue.main.asyncAfter(deadline: .now() +
|
|
325
|
+
appendLog("Restarting in \(Int(delay))s (attempt \(restartAttempts))…")
|
|
326
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
254
327
|
if self.shouldRestart {
|
|
255
328
|
self.launchProcess()
|
|
256
329
|
}
|
|
@@ -260,6 +333,24 @@ class GateService: ObservableObject {
|
|
|
260
333
|
}
|
|
261
334
|
}
|
|
262
335
|
|
|
336
|
+
private func startHealthCheck() {
|
|
337
|
+
stopHealthCheck()
|
|
338
|
+
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
|
339
|
+
guard let self else { return }
|
|
340
|
+
Task { @MainActor in
|
|
341
|
+
if let proc = self.process, !proc.isRunning {
|
|
342
|
+
self.appendLog("Health check: process died, restarting.")
|
|
343
|
+
self.handleTermination(exitCode: -1)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private func stopHealthCheck() {
|
|
350
|
+
healthCheckTimer?.invalidate()
|
|
351
|
+
healthCheckTimer = nil
|
|
352
|
+
}
|
|
353
|
+
|
|
263
354
|
private func appendLog(_ line: String) {
|
|
264
355
|
let ts = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
|
|
265
356
|
logs.append("[\(ts)] \(line)")
|
|
@@ -4,5 +4,16 @@
|
|
|
4
4
|
<dict>
|
|
5
5
|
<key>LSUIElement</key>
|
|
6
6
|
<true/>
|
|
7
|
+
<key>CFBundleURLTypes</key>
|
|
8
|
+
<array>
|
|
9
|
+
<dict>
|
|
10
|
+
<key>CFBundleURLName</key>
|
|
11
|
+
<string>dev.fka.Poke-macOS-Gate</string>
|
|
12
|
+
<key>CFBundleURLSchemes</key>
|
|
13
|
+
<array>
|
|
14
|
+
<string>poke-gate</string>
|
|
15
|
+
</array>
|
|
16
|
+
</dict>
|
|
17
|
+
</array>
|
|
7
18
|
</dict>
|
|
8
19
|
</plist>
|
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
|
|
3
|
+
class AppDelegate: NSObject, NSApplicationDelegate {
|
|
4
|
+
var service: GateService?
|
|
5
|
+
|
|
6
|
+
func application(_ application: NSApplication, open urls: [URL]) {
|
|
7
|
+
for url in urls {
|
|
8
|
+
guard url.scheme == "poke-gate" else { continue }
|
|
9
|
+
if url.host == "screenshot" {
|
|
10
|
+
service?.captureAndSend()
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
3
16
|
@main
|
|
4
17
|
struct Poke_macOS_GateApp: App {
|
|
5
18
|
@StateObject private var service = GateService()
|
|
19
|
+
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
|
6
20
|
|
|
7
21
|
var body: some Scene {
|
|
8
22
|
MenuBarExtra {
|
|
9
23
|
PopoverContent(service: service)
|
|
10
|
-
.onAppear {
|
|
24
|
+
.onAppear {
|
|
25
|
+
service.autoStartIfNeeded()
|
|
26
|
+
appDelegate.service = service
|
|
27
|
+
}
|
|
11
28
|
} label: {
|
|
12
29
|
Image(systemName: menuBarIcon)
|
|
13
30
|
}
|
package/docs/agents/index.md
CHANGED
|
@@ -9,15 +9,16 @@ Agents are **push-only** — they send data to Poke, but Poke cannot reach back
|
|
|
9
9
|
:::
|
|
10
10
|
|
|
11
11
|
```mermaid
|
|
12
|
-
flowchart
|
|
13
|
-
subgraph
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
flowchart TB
|
|
13
|
+
subgraph mac ["Your Mac"]
|
|
14
|
+
direction TB
|
|
15
|
+
Source["Local data source\nBeeper, files, APIs, logs"]
|
|
16
|
+
Script["Agent script\nruns on a schedule"]
|
|
17
|
+
Source -->|reads| Script
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
Poke -->|replies| You["You"]
|
|
20
|
+
Script -->|sendMessage| Poke["Poke Agent"]
|
|
21
|
+
Poke -->|replies via iMessage,\nTelegram, or SMS| You["You"]
|
|
21
22
|
```
|
|
22
23
|
|
|
23
24
|
**Example:** A Beeper agent runs every hour, fetches your unread messages, and sends a digest to Poke. Now Poke knows who messaged you — and can answer "did anyone text me?" without needing your machine in real time.
|
package/docs/how-it-works.md
CHANGED
|
@@ -5,14 +5,15 @@ Poke Gate bridges your machine to Poke's cloud so your AI assistant can execute
|
|
|
5
5
|
## Architecture
|
|
6
6
|
|
|
7
7
|
```mermaid
|
|
8
|
-
flowchart
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
flowchart TB
|
|
9
|
+
You["You"] -->|message via iMessage,\nTelegram, or SMS| Agent["Poke Agent"]
|
|
10
|
+
Agent -->|tool call| Tunnel["MCP Tunnel"]
|
|
11
|
+
Tunnel -->|WebSocket| Gate["Poke Gate"]
|
|
12
|
+
Gate -->|execute| Mac["Your Mac"]
|
|
13
|
+
Mac -->|result| Gate
|
|
14
|
+
Gate -->|response| Tunnel
|
|
15
|
+
Tunnel -->|result| Agent
|
|
16
|
+
Agent -->|reply| You
|
|
16
17
|
```
|
|
17
18
|
|
|
18
19
|
## Step by step
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @agent context
|
|
3
|
+
* @name Context Fingerprint
|
|
4
|
+
* @description Sends a tiny snapshot of your Mac's state to Poke — volume, battery, WiFi, displays, camera. Maximum context, zero private data.
|
|
5
|
+
* @interval 30m
|
|
6
|
+
* @author f
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Poke, getToken } from "poke";
|
|
10
|
+
import { execSync } from "node:child_process";
|
|
11
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
|
|
15
|
+
const token = getToken();
|
|
16
|
+
if (!token) {
|
|
17
|
+
console.error("Not signed in. Run: npx poke login");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STATE_FILE = join(homedir(), ".config", "poke-gate", "agents", ".context-state.json");
|
|
22
|
+
|
|
23
|
+
function loadState() {
|
|
24
|
+
try { return JSON.parse(readFileSync(STATE_FILE, "utf-8")); }
|
|
25
|
+
catch { return {}; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveState(state) {
|
|
29
|
+
writeFileSync(STATE_FILE, JSON.stringify(state));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function run(cmd) {
|
|
33
|
+
try { return execSync(cmd, { encoding: "utf-8", timeout: 5000 }).trim(); }
|
|
34
|
+
catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getVolume() {
|
|
38
|
+
const raw = run("osascript -e 'get volume settings'");
|
|
39
|
+
if (!raw) return { volume: null, muted: null };
|
|
40
|
+
const vol = raw.match(/output volume:(\d+)/);
|
|
41
|
+
const muted = raw.includes("output muted:true");
|
|
42
|
+
return { volume: vol ? parseInt(vol[1]) : null, muted };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getBattery() {
|
|
46
|
+
const raw = run("pmset -g batt");
|
|
47
|
+
if (!raw) return { level: null, charging: null };
|
|
48
|
+
const match = raw.match(/(\d+)%/);
|
|
49
|
+
const charging = raw.includes("AC Power") || raw.includes("charging");
|
|
50
|
+
return { level: match ? parseInt(match[1]) : null, charging };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getWifi() {
|
|
54
|
+
const raw = run("networksetup -getairportnetwork en0");
|
|
55
|
+
if (!raw || raw.includes("not associated") || raw.includes("off")) return null;
|
|
56
|
+
return raw.replace("Current Wi-Fi Network: ", "").trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getDisplayCount() {
|
|
60
|
+
const raw = run("system_profiler SPDisplaysDataType");
|
|
61
|
+
if (!raw) return 0;
|
|
62
|
+
return (raw.match(/Resolution:/g) || []).length;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getCameraInUse() {
|
|
66
|
+
const raw = run("lsof 2>/dev/null | grep -c 'AppleCamera\\|VDC\\|iSight'");
|
|
67
|
+
return raw && parseInt(raw) > 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getBluetoothDevices() {
|
|
71
|
+
const raw = run("system_profiler SPBluetoothDataType 2>/dev/null");
|
|
72
|
+
if (!raw) return [];
|
|
73
|
+
const devices = [];
|
|
74
|
+
const lines = raw.split("\n");
|
|
75
|
+
for (let i = 0; i < lines.length; i++) {
|
|
76
|
+
if (lines[i].includes("Connected: Yes")) {
|
|
77
|
+
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
|
|
78
|
+
const nameMatch = lines[j].match(/^\s{8}(\S.+):$/);
|
|
79
|
+
if (nameMatch) {
|
|
80
|
+
devices.push(nameMatch[1]);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return devices;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getUptime() {
|
|
90
|
+
const raw = run("uptime");
|
|
91
|
+
if (!raw) return null;
|
|
92
|
+
const match = raw.match(/up\s+(.+?),\s+\d+ user/);
|
|
93
|
+
return match ? match[1].trim() : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Gather all signals
|
|
97
|
+
const volume = getVolume();
|
|
98
|
+
const battery = getBattery();
|
|
99
|
+
const wifi = getWifi();
|
|
100
|
+
const displays = getDisplayCount();
|
|
101
|
+
const camera = getCameraInUse();
|
|
102
|
+
const bluetooth = getBluetoothDevices();
|
|
103
|
+
const uptime = getUptime();
|
|
104
|
+
|
|
105
|
+
const now = new Date();
|
|
106
|
+
const hour = now.getHours();
|
|
107
|
+
const timeOfDay = hour < 6 ? "night" : hour < 12 ? "morning" : hour < 18 ? "afternoon" : "evening";
|
|
108
|
+
|
|
109
|
+
const fingerprint = {
|
|
110
|
+
time: now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }),
|
|
111
|
+
timeOfDay,
|
|
112
|
+
volume: volume.muted ? "muted" : `${volume.volume}%`,
|
|
113
|
+
battery: `${battery.level}%${battery.charging ? " (charging)" : " (on battery)"}`,
|
|
114
|
+
wifi: wifi || "not connected",
|
|
115
|
+
displays,
|
|
116
|
+
camera: camera ? "in use" : "off",
|
|
117
|
+
bluetooth: bluetooth.length > 0 ? bluetooth.join(", ") : "none",
|
|
118
|
+
uptime: uptime || "unknown",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
console.log("Context fingerprint:", JSON.stringify(fingerprint, null, 2));
|
|
122
|
+
|
|
123
|
+
// Check if anything meaningful changed
|
|
124
|
+
const state = loadState();
|
|
125
|
+
const key = `${fingerprint.volume}|${fingerprint.wifi}|${fingerprint.displays}|${fingerprint.camera}|${fingerprint.battery}`;
|
|
126
|
+
|
|
127
|
+
if (key === state.lastKey) {
|
|
128
|
+
console.log("No meaningful change, skipping.");
|
|
129
|
+
process.exit(0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
saveState({ lastKey: key });
|
|
133
|
+
|
|
134
|
+
// Build a natural language summary
|
|
135
|
+
let summary = `Context update (${fingerprint.time}, ${timeOfDay}):\n`;
|
|
136
|
+
summary += `• Volume: ${fingerprint.volume}\n`;
|
|
137
|
+
summary += `• Battery: ${fingerprint.battery}\n`;
|
|
138
|
+
summary += `• WiFi: ${fingerprint.wifi}\n`;
|
|
139
|
+
summary += `• Displays: ${fingerprint.displays}\n`;
|
|
140
|
+
summary += `• Camera: ${fingerprint.camera}\n`;
|
|
141
|
+
summary += `• Bluetooth: ${fingerprint.bluetooth}\n`;
|
|
142
|
+
summary += `• Uptime: ${fingerprint.uptime}\n`;
|
|
143
|
+
summary += `\nUse this to understand my current situation without asking.`;
|
|
144
|
+
|
|
145
|
+
const poke = new Poke({ apiKey: token });
|
|
146
|
+
await poke.sendMessage(summary);
|
|
147
|
+
|
|
148
|
+
console.log("Sent context to Poke.");
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @agent music
|
|
3
|
+
* @name Music Log
|
|
4
|
+
* @description Tracks what you're listening to and sends a log to Poke. Supports Apple Music and Spotify.
|
|
5
|
+
* @interval 10m
|
|
6
|
+
* @author f
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Poke, getToken } from "poke";
|
|
10
|
+
import { execSync } from "node:child_process";
|
|
11
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
|
|
15
|
+
const token = getToken();
|
|
16
|
+
if (!token) {
|
|
17
|
+
console.error("Not signed in. Run: npx poke login");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STATE_FILE = join(homedir(), ".config", "poke-gate", "agents", ".music-state.json");
|
|
22
|
+
|
|
23
|
+
function loadState() {
|
|
24
|
+
try { return JSON.parse(readFileSync(STATE_FILE, "utf-8")); }
|
|
25
|
+
catch { return { lastTrack: null }; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveState(state) {
|
|
29
|
+
writeFileSync(STATE_FILE, JSON.stringify(state));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getNowPlaying() {
|
|
33
|
+
// Try Spotify first
|
|
34
|
+
try {
|
|
35
|
+
const result = execSync(`osascript -e '
|
|
36
|
+
if application "Spotify" is running then
|
|
37
|
+
tell application "Spotify"
|
|
38
|
+
if player state is playing then
|
|
39
|
+
return name of current track & " — " & artist of current track & " (" & album of current track & ")"
|
|
40
|
+
end if
|
|
41
|
+
end tell
|
|
42
|
+
end if
|
|
43
|
+
return "not_playing"
|
|
44
|
+
'`, { encoding: "utf-8", timeout: 5000 }).trim();
|
|
45
|
+
if (result && result !== "not_playing") return { source: "Spotify", track: result };
|
|
46
|
+
} catch {}
|
|
47
|
+
|
|
48
|
+
// Try Apple Music
|
|
49
|
+
try {
|
|
50
|
+
const result = execSync(`osascript -e '
|
|
51
|
+
if application "Music" is running then
|
|
52
|
+
tell application "Music"
|
|
53
|
+
if player state is playing then
|
|
54
|
+
return name of current track & " — " & artist of current track & " (" & album of current track & ")"
|
|
55
|
+
end if
|
|
56
|
+
end tell
|
|
57
|
+
end if
|
|
58
|
+
return "not_playing"
|
|
59
|
+
'`, { encoding: "utf-8", timeout: 5000 }).trim();
|
|
60
|
+
if (result && result !== "not_playing") return { source: "Apple Music", track: result };
|
|
61
|
+
} catch {}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const playing = getNowPlaying();
|
|
67
|
+
const state = loadState();
|
|
68
|
+
|
|
69
|
+
if (!playing) {
|
|
70
|
+
console.log("Nothing playing right now.");
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(`Now playing (${playing.source}): ${playing.track}`);
|
|
75
|
+
|
|
76
|
+
if (playing.track === state.lastTrack) {
|
|
77
|
+
console.log("Same track as last check, skipping.");
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
saveState({ lastTrack: playing.track });
|
|
82
|
+
|
|
83
|
+
const poke = new Poke({ apiKey: token });
|
|
84
|
+
await poke.sendMessage(
|
|
85
|
+
`I'm currently listening to: ${playing.track} (on ${playing.source}). ` +
|
|
86
|
+
`Remember this for context about my mood and taste.`
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
console.log("Sent to Poke.");
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -11,6 +11,10 @@ function log(msg) {
|
|
|
11
11
|
console.log(`[${ts}] ${msg}`);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function sleep(ms) {
|
|
15
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
}
|
|
17
|
+
|
|
14
18
|
async function ensureAuthenticated() {
|
|
15
19
|
if (!isLoggedIn()) {
|
|
16
20
|
log("Signing in to Poke...");
|
|
@@ -25,6 +29,53 @@ async function ensureAuthenticated() {
|
|
|
25
29
|
return token;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
async function connectTunnel(mcpUrl, token) {
|
|
33
|
+
let attempt = 0;
|
|
34
|
+
const maxDelay = 60_000;
|
|
35
|
+
|
|
36
|
+
while (true) {
|
|
37
|
+
attempt++;
|
|
38
|
+
const delay = Math.min(2000 * Math.pow(2, attempt - 1), maxDelay);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
log(attempt > 1 ? `Reconnecting tunnel (attempt ${attempt})…` : "Connecting tunnel to Poke...");
|
|
42
|
+
|
|
43
|
+
await startTunnel({
|
|
44
|
+
mcpUrl,
|
|
45
|
+
onEvent: (type, data) => {
|
|
46
|
+
switch (type) {
|
|
47
|
+
case "connected":
|
|
48
|
+
attempt = 0;
|
|
49
|
+
log(`Tunnel connected (${data.connectionId})`);
|
|
50
|
+
log("Ready — your Poke agent can now access this machine.");
|
|
51
|
+
notifyPoke(data.connectionId, token);
|
|
52
|
+
startAgentScheduler();
|
|
53
|
+
break;
|
|
54
|
+
case "disconnected":
|
|
55
|
+
log("Tunnel disconnected. PokeTunnel will reconnect automatically.");
|
|
56
|
+
break;
|
|
57
|
+
case "error":
|
|
58
|
+
log(`Tunnel error: ${data}`);
|
|
59
|
+
break;
|
|
60
|
+
case "tools-synced":
|
|
61
|
+
log(`Tools synced: ${data}`);
|
|
62
|
+
break;
|
|
63
|
+
case "oauth-required":
|
|
64
|
+
log(`OAuth required: ${data}`);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
break;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
log(`Tunnel failed: ${err.message}`);
|
|
73
|
+
log(`Retrying in ${Math.round(delay / 1000)}s…`);
|
|
74
|
+
await sleep(delay);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
28
79
|
async function main() {
|
|
29
80
|
log("poke-gate starting...");
|
|
30
81
|
|
|
@@ -35,37 +86,7 @@ async function main() {
|
|
|
35
86
|
|
|
36
87
|
const mcpUrl = `http://localhost:${port}/mcp`;
|
|
37
88
|
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
await startTunnel({
|
|
41
|
-
mcpUrl,
|
|
42
|
-
onEvent: (type, data) => {
|
|
43
|
-
switch (type) {
|
|
44
|
-
case "connected":
|
|
45
|
-
log(`Tunnel connected (${data.connectionId})`);
|
|
46
|
-
log("Ready — your Poke agent can now access this machine.");
|
|
47
|
-
notifyPoke(data.connectionId, token);
|
|
48
|
-
startAgentScheduler();
|
|
49
|
-
break;
|
|
50
|
-
case "disconnected":
|
|
51
|
-
log("Tunnel disconnected. Reconnecting...");
|
|
52
|
-
break;
|
|
53
|
-
case "error":
|
|
54
|
-
log(`Tunnel error: ${data}`);
|
|
55
|
-
break;
|
|
56
|
-
case "tools-synced":
|
|
57
|
-
log(`Tools synced: ${data}`);
|
|
58
|
-
break;
|
|
59
|
-
case "oauth-required":
|
|
60
|
-
log(`OAuth required: ${data}`);
|
|
61
|
-
break;
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
} catch (err) {
|
|
66
|
-
log(`Failed to connect: ${err.message}`);
|
|
67
|
-
process.exit(1);
|
|
68
|
-
}
|
|
89
|
+
await connectTunnel(mcpUrl, token);
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
async function notifyPoke(connectionId, token) {
|
|
@@ -93,4 +114,12 @@ process.on("SIGTERM", () => {
|
|
|
93
114
|
process.exit(0);
|
|
94
115
|
});
|
|
95
116
|
|
|
117
|
+
process.on("uncaughtException", (err) => {
|
|
118
|
+
log(`Uncaught exception: ${err.message}`);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
process.on("unhandledRejection", (err) => {
|
|
122
|
+
log(`Unhandled rejection: ${err instanceof Error ? err.message : String(err)}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
96
125
|
main();
|
package/src/mcp-server.js
CHANGED
|
@@ -234,16 +234,25 @@ function handleToolCall(name, args) {
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
case "take_screenshot": {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return { content: [{ type: "text", text: `Screenshot saved to ${dest}` }] };
|
|
237
|
+
logTool(name, args);
|
|
238
|
+
|
|
239
|
+
return runCommand('open -Ra "Poke macOS Gate" 2>/dev/null', homedir()).then((appCheck) => {
|
|
240
|
+
if (appCheck.exitCode === 0) {
|
|
241
|
+
return runCommand('open "poke-gate://screenshot"', homedir()).then(() => {
|
|
242
|
+
return { content: [{ type: "text", text: "Screenshot captured and sent to Poke via the macOS app." }] };
|
|
243
|
+
});
|
|
245
244
|
}
|
|
246
|
-
|
|
245
|
+
|
|
246
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
247
|
+
const dest = args.path
|
|
248
|
+
? resolve(args.path.replace(/^~/, homedir()))
|
|
249
|
+
: join(homedir(), "Desktop", `screenshot-${ts}.png`);
|
|
250
|
+
return runCommand(`/usr/sbin/screencapture -x "${dest}"`, homedir()).then((result) => {
|
|
251
|
+
if (result.exitCode === 0) {
|
|
252
|
+
return { content: [{ type: "text", text: `Screenshot saved to ${dest}` }] };
|
|
253
|
+
}
|
|
254
|
+
return { content: [{ type: "text", text: `Screenshot failed: ${result.stderr || "unknown error"}. Grant Screen Recording permission to Terminal or install the Poke macOS Gate app.` }], isError: true };
|
|
255
|
+
});
|
|
247
256
|
});
|
|
248
257
|
}
|
|
249
258
|
|
|
Binary file
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
-
<plist version="1.0">
|
|
4
|
-
<dict>
|
|
5
|
-
<key>SchemeUserState</key>
|
|
6
|
-
<dict>
|
|
7
|
-
<key>Poke macOS Gate.xcscheme_^#shared#^_</key>
|
|
8
|
-
<dict>
|
|
9
|
-
<key>orderHint</key>
|
|
10
|
-
<integer>0</integer>
|
|
11
|
-
</dict>
|
|
12
|
-
</dict>
|
|
13
|
-
</dict>
|
|
14
|
-
</plist>
|