poke-gate 0.1.4 → 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.
@@ -25,6 +25,8 @@ class AgentsViewModel: ObservableObject {
25
25
  @Published var selectedAgent: AgentFile?
26
26
  @Published var editorContent: String = ""
27
27
  @Published var showingEnv: Bool = false
28
+ @Published var isRunning: Bool = false
29
+ @Published var lastRunOutput: String = ""
28
30
 
29
31
  private var fileWatcher: DispatchSourceFileSystemObject?
30
32
  private var dirFD: Int32 = -1
@@ -108,15 +110,20 @@ class AgentsViewModel: ObservableObject {
108
110
  }
109
111
 
110
112
  func select(_ agent: AgentFile) {
111
- selectedAgent = agent
112
- showingEnv = false
113
- loadContent()
113
+ DispatchQueue.main.async {
114
+ self.selectedAgent = agent
115
+ self.showingEnv = false
116
+ self.loadContent()
117
+ }
114
118
  }
115
119
 
116
120
  func loadContent() {
117
121
  guard let agent = selectedAgent else { return }
118
122
  let url = showingEnv ? agent.envPath : agent.path
119
- editorContent = (try? String(contentsOf: url, encoding: .utf8)) ?? (showingEnv ? "# No .env file yet\n" : "")
123
+ let content = (try? String(contentsOf: url, encoding: .utf8)) ?? (showingEnv ? "# No .env file yet\n" : "")
124
+ DispatchQueue.main.async {
125
+ self.editorContent = content
126
+ }
120
127
  }
121
128
 
122
129
  func save() {
@@ -169,6 +176,45 @@ class AgentsViewModel: ObservableObject {
169
176
  }
170
177
  }
171
178
 
179
+ func runAgent(_ agent: AgentFile) {
180
+ guard !isRunning else { return }
181
+ isRunning = true
182
+ lastRunOutput = ""
183
+
184
+ let fullPath = GateService().shellPath()
185
+ let proc = Process()
186
+ let pipe = Pipe()
187
+
188
+ proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
189
+ proc.arguments = ["-c", "npx -y poke-gate run-agent \(agent.agentId)"]
190
+ proc.environment = ["HOME": NSHomeDirectory(), "PATH": fullPath]
191
+ proc.standardOutput = pipe
192
+ proc.standardError = pipe
193
+ proc.currentDirectoryURL = FileManager.default.homeDirectoryForCurrentUser
194
+
195
+ let handle = pipe.fileHandleForReading
196
+ handle.readabilityHandler = { [weak self] fh in
197
+ let data = fh.availableData
198
+ guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
199
+ DispatchQueue.main.async {
200
+ self?.lastRunOutput += text
201
+ }
202
+ }
203
+
204
+ proc.terminationHandler = { [weak self] _ in
205
+ DispatchQueue.main.async {
206
+ self?.isRunning = false
207
+ }
208
+ }
209
+
210
+ do {
211
+ try proc.run()
212
+ } catch {
213
+ isRunning = false
214
+ lastRunOutput = "Failed to run: \(error.localizedDescription)"
215
+ }
216
+ }
217
+
172
218
  func deleteAgent(_ agent: AgentFile) {
173
219
  try? FileManager.default.removeItem(at: agent.path)
174
220
  if agent.hasEnv {
@@ -301,6 +347,15 @@ struct AgentDetailView: View {
301
347
  }
302
348
  }
303
349
  }
350
+
351
+ Button {
352
+ viewModel.runAgent(agent)
353
+ } label: {
354
+ Label(viewModel.isRunning ? "Running…" : "Run", systemImage: "play.fill")
355
+ .font(.caption)
356
+ }
357
+ .disabled(viewModel.isRunning)
358
+ .padding(.leading, 4)
304
359
  }
305
360
  .padding(.horizontal, 12)
306
361
  .padding(.vertical, 8)
@@ -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() ?? "" }
@@ -32,12 +35,75 @@ class GateService: ObservableObject {
32
35
 
33
36
  func runPokeLogin() {
34
37
  let fullPath = shellPath()
38
+ let npxBin = findNpx()
35
39
  let proc = Process()
36
40
  proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
37
- proc.arguments = ["-c", "npx -y poke@latest login"]
41
+ proc.arguments = ["-c", "\(npxBin) -y poke@latest login"]
38
42
  proc.environment = ["HOME": NSHomeDirectory(), "PATH": fullPath]
39
43
  try? proc.run()
40
- appendLog("Launched poke login — check your browser.")
44
+ appendLog("Launched poke login (npx: \(npxBin)) — check your browser.")
45
+ }
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
+ }
41
107
  }
42
108
 
43
109
  func autoStartIfNeeded() {
@@ -79,7 +145,9 @@ class GateService: ObservableObject {
79
145
 
80
146
  func stop() {
81
147
  shouldRestart = false
148
+ stopHealthCheck()
82
149
  killProcess()
150
+ restartAttempts = 0
83
151
  status = .stopped
84
152
  }
85
153
 
@@ -90,19 +158,82 @@ class GateService: ObservableObject {
90
158
  }
91
159
  }
92
160
 
93
- private func shellPath() -> String {
94
- let loginShell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
95
- let pathProc = Process()
96
- let pathPipe = Pipe()
97
- pathProc.executableURL = URL(fileURLWithPath: loginShell)
98
- pathProc.arguments = ["-ilc", "echo $PATH"]
99
- pathProc.standardOutput = pathPipe
100
- pathProc.standardError = FileHandle.nullDevice
101
- pathProc.environment = ["HOME": NSHomeDirectory()]
102
- try? pathProc.run()
103
- pathProc.waitUntilExit()
104
- let data = pathPipe.fileHandleForReading.readDataToEndOfFile()
105
- return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
161
+ func shellPath() -> String {
162
+ let home = NSHomeDirectory()
163
+ let fallback = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin"
164
+
165
+ // Try multiple shells/strategies to get PATH
166
+ let strategies: [(String, [String])] = [
167
+ ("/bin/zsh", ["-ilc", "echo $PATH"]),
168
+ ("/bin/zsh", ["-lc", "echo $PATH"]),
169
+ ("/bin/bash", ["-lc", "echo $PATH"]),
170
+ ]
171
+
172
+ for (shell, args) in strategies {
173
+ let proc = Process()
174
+ let pipe = Pipe()
175
+ proc.executableURL = URL(fileURLWithPath: shell)
176
+ proc.arguments = args
177
+ proc.standardOutput = pipe
178
+ proc.standardError = FileHandle.nullDevice
179
+ proc.environment = ["HOME": home]
180
+ do {
181
+ try proc.run()
182
+ proc.waitUntilExit()
183
+ if proc.terminationStatus == 0 {
184
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
185
+ if let path = String(data: data, encoding: .utf8)?
186
+ .trimmingCharacters(in: .whitespacesAndNewlines),
187
+ !path.isEmpty {
188
+ return path
189
+ }
190
+ }
191
+ } catch {
192
+ continue
193
+ }
194
+ }
195
+
196
+ // Fallback: build PATH from common locations
197
+ var paths = fallback.split(separator: ":").map(String.init)
198
+
199
+ let commonDirs = [
200
+ "\(home)/.nvm/versions/node",
201
+ "\(home)/.volta/bin",
202
+ "\(home)/.fnm/aliases/default/bin",
203
+ "\(home)/.local/bin",
204
+ "\(home)/.cargo/bin",
205
+ "/opt/homebrew/bin",
206
+ "/usr/local/bin",
207
+ ]
208
+
209
+ for dir in commonDirs {
210
+ if FileManager.default.fileExists(atPath: dir) {
211
+ if dir.contains(".nvm") {
212
+ // Find the latest node version in nvm
213
+ if let versions = try? FileManager.default.contentsOfDirectory(atPath: dir) {
214
+ if let latest = versions.sorted().last {
215
+ let binPath = "\(dir)/\(latest)/bin"
216
+ if !paths.contains(binPath) { paths.insert(binPath, at: 0) }
217
+ }
218
+ }
219
+ } else if !paths.contains(dir) {
220
+ paths.insert(dir, at: 0)
221
+ }
222
+ }
223
+ }
224
+
225
+ return paths.joined(separator: ":")
226
+ }
227
+
228
+ private func findNpx() -> String {
229
+ let path = shellPath()
230
+ for dir in path.split(separator: ":") {
231
+ let npxPath = "\(dir)/npx"
232
+ if FileManager.default.isExecutableFile(atPath: npxPath) {
233
+ return npxPath
234
+ }
235
+ }
236
+ return "npx"
106
237
  }
107
238
 
108
239
  private func launchProcess() {
@@ -112,15 +243,17 @@ class GateService: ObservableObject {
112
243
  appendLog("Starting poke-gate…")
113
244
 
114
245
  let fullPath = shellPath()
246
+ let npxBin = findNpx()
247
+
248
+ appendLog("Using npx at: \(npxBin)")
115
249
 
116
250
  let proc = Process()
117
251
  let pipe = Pipe()
118
252
 
119
253
  proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
120
- proc.arguments = ["-c", "npx -y poke-gate --verbose"]
254
+ proc.arguments = ["-c", "\(npxBin) -y poke-gate --verbose"]
121
255
  proc.environment = ProcessInfo.processInfo.environment.merging(
122
256
  [
123
- "POKE_API_KEY": resolveToken() ?? "",
124
257
  "PATH": fullPath,
125
258
  ],
126
259
  uniquingKeysWith: { _, new in new }
@@ -169,6 +302,8 @@ class GateService: ObservableObject {
169
302
 
170
303
  if line.contains("Tunnel connected") || line.contains("Ready") {
171
304
  status = .connected
305
+ restartAttempts = 0
306
+ startHealthCheck()
172
307
  } else if line.contains("Tunnel disconnected") || line.contains("Reconnecting") {
173
308
  status = .disconnected
174
309
  } else if line.contains("Failed to connect") || line.contains("error") {
@@ -181,10 +316,14 @@ class GateService: ObservableObject {
181
316
 
182
317
  private func handleTermination(exitCode: Int32) {
183
318
  appendLog("Process exited with code \(exitCode)")
319
+ stopHealthCheck()
320
+
184
321
  if shouldRestart {
322
+ restartAttempts += 1
323
+ let delay = min(Double(2 * (1 << min(restartAttempts - 1, 5))), 60.0)
185
324
  status = .disconnected
186
- appendLog("Restarting in 2 seconds…")
187
- DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
325
+ appendLog("Restarting in \(Int(delay))s (attempt \(restartAttempts))…")
326
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
188
327
  if self.shouldRestart {
189
328
  self.launchProcess()
190
329
  }
@@ -194,6 +333,24 @@ class GateService: ObservableObject {
194
333
  }
195
334
  }
196
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
+
197
354
  private func appendLog(_ line: String) {
198
355
  let ts = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
199
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 { service.autoStartIfNeeded() }
24
+ .onAppear {
25
+ service.autoStartIfNeeded()
26
+ appDelegate.service = service
27
+ }
11
28
  } label: {
12
29
  Image(systemName: menuBarIcon)
13
30
  }
@@ -259,12 +259,15 @@
259
259
  ENABLE_PREVIEWS = YES;
260
260
  GENERATE_INFOPLIST_FILE = YES;
261
261
  INFOPLIST_FILE = "Poke macOS Gate/Info.plist";
262
+ INFOPLIST_KEY_CFBundleDisplayName = "Poke macOS Gate";
263
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
262
264
  INFOPLIST_KEY_NSHumanReadableCopyright = "";
263
265
  LD_RUNPATH_SEARCH_PATHS = (
264
266
  "$(inherited)",
265
267
  "@executable_path/../Frameworks",
266
268
  );
267
- MARKETING_VERSION = 0.1.3;
269
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
270
+ MARKETING_VERSION = 0.1.5;
268
271
  PRODUCT_BUNDLE_IDENTIFIER = "dev.fka.Poke-macOS-Gate";
269
272
  PRODUCT_NAME = "$(TARGET_NAME)";
270
273
  REGISTER_APP_GROUPS = YES;
@@ -291,12 +294,15 @@
291
294
  ENABLE_PREVIEWS = YES;
292
295
  GENERATE_INFOPLIST_FILE = YES;
293
296
  INFOPLIST_FILE = "Poke macOS Gate/Info.plist";
297
+ INFOPLIST_KEY_CFBundleDisplayName = "Poke macOS Gate";
298
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
294
299
  INFOPLIST_KEY_NSHumanReadableCopyright = "";
295
300
  LD_RUNPATH_SEARCH_PATHS = (
296
301
  "$(inherited)",
297
302
  "@executable_path/../Frameworks",
298
303
  );
299
- MARKETING_VERSION = 0.1.3;
304
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
305
+ MARKETING_VERSION = 0.1.5;
300
306
  PRODUCT_BUNDLE_IDENTIFIER = "dev.fka.Poke-macOS-Gate";
301
307
  PRODUCT_NAME = "$(TARGET_NAME)";
302
308
  REGISTER_APP_GROUPS = YES;
@@ -0,0 +1,7 @@
1
+ .mermaid svg {
2
+ line-height: normal !important;
3
+ }
4
+
5
+ .mermaid svg * {
6
+ line-height: normal !important;
7
+ }
@@ -0,0 +1,4 @@
1
+ import DefaultTheme from 'vitepress/theme'
2
+ import './custom.css'
3
+
4
+ export default DefaultTheme
@@ -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 LR
13
- subgraph YourMac ["Your Mac"]
14
- Agent["Agent script"]
15
- Local["Local data source"]
16
- Local -->|reads| Agent
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
- Agent -->|sendMessage| Poke["Poke Agent"]
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.
@@ -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 LR
9
- A["You (iMessage)"] --> B["Poke Agent"]
10
- B --> C["MCP Tunnel"]
11
- C --> D["Poke Gate"]
12
- D --> E["Executes locally"]
13
- E --> C
14
- C --> B
15
- B --> A
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
package/docs/index.md CHANGED
@@ -3,8 +3,8 @@ layout: home
3
3
 
4
4
  hero:
5
5
  name: Poke Gate
6
- text: Your Mac, controlled by AI from anywhere.
7
- tagline: Give your Poke AI assistant access to your machine. Run commands, read files, take screenshots — all from iMessage, Telegram, or SMS.
6
+ text: A two-way bridge between your Mac and your AI.
7
+ tagline: Poke pulls from your Mac when you ask. Your Mac pushes to Poke when something happens. Run commands, read files, take screenshots — and automate it all with Agents.
8
8
  image:
9
9
  src: /logo.png
10
10
  alt: Poke Gate
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poke-gate",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Expose your machine to your Poke AI assistant via MCP tunnel",
5
5
  "type": "module",
6
6
  "bin": {
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
- log("Connecting tunnel to Poke...");
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
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
238
- const dest = args.path
239
- ? resolve(args.path.replace(/^~/, homedir()))
240
- : join(homedir(), "Desktop", `screenshot-${ts}.png`);
241
- logTool(name, { path: dest });
242
- return runCommand(`/usr/sbin/screencapture -x "${dest}"`, homedir()).then((result) => {
243
- if (result.exitCode === 0) {
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
- return { content: [{ type: "text", text: `Screenshot failed: ${result.stderr || "unknown error"}` }], isError: true };
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
 
@@ -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>