poke-gate 0.1.7 → 0.1.9

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/bin/poke-gate.js CHANGED
@@ -21,6 +21,11 @@ async function main() {
21
21
  }
22
22
  const { downloadAgent } = await import("../src/agents.js");
23
23
  await downloadAgent(name);
24
+ } else if (args[0] === "agent" && args[1] === "create") {
25
+ const promptIdx = args.indexOf("--prompt");
26
+ const prompt = promptIdx !== -1 ? args.slice(promptIdx + 1).join(" ") : args.slice(2).join(" ") || null;
27
+ const { createAgent } = await import("../src/agent-create.js");
28
+ await createAgent(prompt);
24
29
  } else {
25
30
  await import("../src/app.js");
26
31
  }
@@ -247,6 +247,8 @@ class AgentsViewModel: ObservableObject {
247
247
 
248
248
  struct AgentsView: View {
249
249
  @StateObject private var viewModel = AgentsViewModel()
250
+ @State private var showingGenerate = false
251
+ @State private var agentToDelete: AgentFile? = nil
250
252
 
251
253
  var body: some View {
252
254
  NavigationSplitView {
@@ -282,20 +284,30 @@ struct AgentsView: View {
282
284
  .tag(agent)
283
285
  .contextMenu {
284
286
  Button("Delete", role: .destructive) {
285
- viewModel.deleteAgent(agent)
287
+ agentToDelete = agent
286
288
  }
287
289
  }
288
290
  }
289
291
  .listStyle(.sidebar)
290
292
  .navigationSplitViewColumnWidth(min: 180, ideal: 220)
291
293
  .safeAreaInset(edge: .bottom) {
292
- Button {
293
- viewModel.addAgent()
294
- } label: {
295
- Label("New Agent", systemImage: "plus")
296
- .font(.caption)
294
+ HStack(spacing: 12) {
295
+ Button {
296
+ viewModel.addAgent()
297
+ } label: {
298
+ Label("New", systemImage: "plus")
299
+ .font(.caption)
300
+ }
301
+ .buttonStyle(.plain)
302
+
303
+ Button {
304
+ showingGenerate = true
305
+ } label: {
306
+ Label("Generate with Poke", systemImage: "sparkles")
307
+ .font(.caption)
308
+ }
309
+ .buttonStyle(.plain)
297
310
  }
298
- .buttonStyle(.plain)
299
311
  .padding(8)
300
312
  .frame(maxWidth: .infinity, alignment: .leading)
301
313
  }
@@ -315,6 +327,153 @@ struct AgentsView: View {
315
327
  .onDisappear {
316
328
  viewModel.stopWatching()
317
329
  }
330
+ .sheet(isPresented: $showingGenerate) {
331
+ GenerateAgentView(viewModel: viewModel, isPresented: $showingGenerate)
332
+ }
333
+ .alert("Delete Agent", isPresented: Binding(
334
+ get: { agentToDelete != nil },
335
+ set: { if !$0 { agentToDelete = nil } }
336
+ )) {
337
+ Button("Cancel", role: .cancel) { agentToDelete = nil }
338
+ Button("Delete", role: .destructive) {
339
+ if let agent = agentToDelete {
340
+ viewModel.deleteAgent(agent)
341
+ agentToDelete = nil
342
+ }
343
+ }
344
+ } message: {
345
+ if let agent = agentToDelete {
346
+ Text("Are you sure you want to delete \"\(agent.name)\"? This will remove the script\(agent.hasEnv ? " and its .env file" : "").")
347
+ }
348
+ }
349
+ }
350
+ }
351
+
352
+ struct GenerateAgentView: View {
353
+ @ObservedObject var viewModel: AgentsViewModel
354
+ @Binding var isPresented: Bool
355
+ @State private var prompt: String = ""
356
+ @State private var isSending: Bool = false
357
+ @State private var sent: Bool = false
358
+
359
+ var body: some View {
360
+ VStack(spacing: 16) {
361
+ if sent {
362
+ Spacer()
363
+
364
+ Image(systemName: "checkmark.circle.fill")
365
+ .font(.system(size: 40))
366
+ .foregroundStyle(.green)
367
+
368
+ Text("Request sent to Poke!")
369
+ .font(.headline)
370
+
371
+ Text("Poke is generating your agent and will save it directly to your Mac. You'll see it appear in the sidebar when it's ready.")
372
+ .font(.caption)
373
+ .foregroundStyle(.secondary)
374
+ .multilineTextAlignment(.center)
375
+
376
+ Text("Keep Poke Gate running while Poke works.")
377
+ .font(.caption)
378
+ .foregroundStyle(.secondary)
379
+ .fontWeight(.medium)
380
+
381
+ Spacer()
382
+
383
+ Button("Done") {
384
+ isPresented = false
385
+ }
386
+ .keyboardShortcut(.defaultAction)
387
+ } else {
388
+ HStack {
389
+ Image(systemName: "sparkles")
390
+ .foregroundStyle(.purple)
391
+ Text("Generate Agent with Poke")
392
+ .font(.headline)
393
+ }
394
+
395
+ Text("Describe what you want the agent to do. Poke will generate the code and save it to your agents folder.")
396
+ .font(.caption)
397
+ .foregroundStyle(.secondary)
398
+ .multilineTextAlignment(.center)
399
+
400
+ TextEditor(text: $prompt)
401
+ .font(.system(.body, design: .default))
402
+ .padding(8)
403
+ .frame(minHeight: 100)
404
+ .scrollContentBackground(.hidden)
405
+ .background(.quaternary.opacity(0.3))
406
+ .cornerRadius(6)
407
+ .overlay(
408
+ RoundedRectangle(cornerRadius: 6)
409
+ .stroke(.quaternary)
410
+ )
411
+
412
+ HStack {
413
+ Button("Cancel") {
414
+ isPresented = false
415
+ }
416
+ .keyboardShortcut(.cancelAction)
417
+
418
+ Spacer()
419
+
420
+ Button("Generate") {
421
+ sendPrompt()
422
+ }
423
+ .keyboardShortcut(.defaultAction)
424
+ .disabled(prompt.isEmpty || isSending)
425
+ }
426
+ }
427
+ }
428
+ .padding(20)
429
+ .frame(width: 420, height: sent ? 250 : nil)
430
+ }
431
+
432
+ private func sendPrompt() {
433
+ isSending = true
434
+
435
+ Task {
436
+ do {
437
+ guard let token = GateService().loadPokeLoginToken() else {
438
+ isSending = false
439
+ return
440
+ }
441
+
442
+ let message = """
443
+ Generate a Poke Gate agent based on my description below.
444
+
445
+ Write the COMPLETE JavaScript code using the write_file tool to save it directly to ~/.config/poke-gate/agents/<name>.<interval>.js
446
+
447
+ RULES:
448
+ - Valid ES module with imports.
449
+ - Start with JSDoc frontmatter: @agent, @name, @description, @interval, @author.
450
+ - Use: import { Poke, getToken } from "poke";
451
+ - Keep under 100 lines. Intervals: 10m, 30m, 1h, 2h, 6h, 12h, 24h.
452
+ - Handle errors with try/catch.
453
+ - Use state files to avoid duplicate sends.
454
+
455
+ IMPORTANT: Use the write_file tool to save the agent code now.
456
+ IMPORTANT: When done, tell me the file name.
457
+
458
+ My request: \(prompt)
459
+ """
460
+
461
+ let url = URL(string: "https://poke.com/api/v1/inbound/api-message")!
462
+ var request = URLRequest(url: url)
463
+ request.httpMethod = "POST"
464
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
465
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
466
+ request.httpBody = try JSONSerialization.data(withJSONObject: ["message": message])
467
+
468
+ let (_, response) = try await URLSession.shared.data(for: request)
469
+ let httpResp = response as? HTTPURLResponse
470
+
471
+ isSending = false
472
+ sent = httpResp?.statusCode == 200
473
+ } catch {
474
+ isSending = false
475
+ }
476
+ }
318
477
  }
319
478
  }
320
479
 
@@ -225,7 +225,7 @@ class GateService: ObservableObject {
225
225
  return paths.joined(separator: ":")
226
226
  }
227
227
 
228
- private func findNpx() -> String {
228
+ func findNpx() -> String {
229
229
  let path = shellPath()
230
230
  for dir in path.split(separator: ":") {
231
231
  let npxPath = "\(dir)/npx"
@@ -1,4 +1,5 @@
1
1
  import SwiftUI
2
+ import ServiceManagement
2
3
 
3
4
  class AppDelegate: NSObject, NSApplicationDelegate {
4
5
  var service: GateService?
@@ -17,13 +18,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
17
18
  struct Poke_macOS_GateApp: App {
18
19
  @StateObject private var service = GateService()
19
20
  @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
20
-
21
21
  var body: some Scene {
22
22
  MenuBarExtra {
23
23
  PopoverContent(service: service)
24
24
  .onAppear {
25
25
  service.autoStartIfNeeded()
26
26
  appDelegate.service = service
27
+ checkLoginItemPrompt()
27
28
  }
28
29
  } label: {
29
30
  Image(systemName: menuBarIcon)
@@ -51,6 +52,37 @@ struct Poke_macOS_GateApp: App {
51
52
  .windowResizability(.contentSize)
52
53
  }
53
54
 
55
+ private func checkLoginItemPrompt() {
56
+ let dismissed = UserDefaults.standard.bool(forKey: "loginItemPromptDismissed")
57
+ let alreadyEnabled = SMAppService.mainApp.status == .enabled
58
+ if dismissed || alreadyEnabled { return }
59
+
60
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
61
+ let alert = NSAlert()
62
+ alert.messageText = "Start on login?"
63
+ alert.informativeText = "Would you like Poke Gate to start automatically when you log in?"
64
+ alert.addButton(withTitle: "Enable")
65
+ alert.addButton(withTitle: "Not now")
66
+ alert.addButton(withTitle: "Don't ask again")
67
+ alert.alertStyle = .informational
68
+
69
+ NSApp.activate(ignoringOtherApps: true)
70
+ let response = alert.runModal()
71
+
72
+ switch response {
73
+ case .alertFirstButtonReturn:
74
+ try? SMAppService.mainApp.register()
75
+ UserDefaults.standard.set(true, forKey: "loginItemPromptDismissed")
76
+ case .alertSecondButtonReturn:
77
+ break
78
+ case .alertThirdButtonReturn:
79
+ UserDefaults.standard.set(true, forKey: "loginItemPromptDismissed")
80
+ default:
81
+ break
82
+ }
83
+ }
84
+ }
85
+
54
86
  private var menuBarIcon: String {
55
87
  switch service.status {
56
88
  case .connected: "door.left.hand.open"
@@ -1,8 +1,10 @@
1
1
  import SwiftUI
2
+ import ServiceManagement
2
3
 
3
4
  struct SettingsView: View {
4
5
  @ObservedObject var service: GateService
5
6
  @Environment(\.dismiss) private var dismiss
7
+ @State private var launchAtLogin = SMAppService.mainApp.status == .enabled
6
8
 
7
9
  var body: some View {
8
10
  VStack(alignment: .leading, spacing: 16) {
@@ -86,6 +88,28 @@ struct SettingsView: View {
86
88
  .cornerRadius(8)
87
89
  }
88
90
 
91
+ VStack(alignment: .leading, spacing: 8) {
92
+ Text("GENERAL")
93
+ .font(.caption2)
94
+ .foregroundStyle(.tertiary)
95
+ .textCase(.uppercase)
96
+ .tracking(0.5)
97
+
98
+ Toggle("Start Poke Gate on login", isOn: $launchAtLogin)
99
+ .font(.subheadline)
100
+ .onChange(of: launchAtLogin) { _, newValue in
101
+ do {
102
+ if newValue {
103
+ try SMAppService.mainApp.register()
104
+ } else {
105
+ try SMAppService.mainApp.unregister()
106
+ }
107
+ } catch {
108
+ launchAtLogin = !newValue
109
+ }
110
+ }
111
+ }
112
+
89
113
  HStack {
90
114
  Spacer()
91
115
  Button("Close") {
@@ -267,7 +267,7 @@
267
267
  "@executable_path/../Frameworks",
268
268
  );
269
269
  MACOSX_DEPLOYMENT_TARGET = 26.0;
270
- MARKETING_VERSION = 0.1.6;
270
+ MARKETING_VERSION = 0.1.7;
271
271
  PRODUCT_BUNDLE_IDENTIFIER = "dev.fka.Poke-macOS-Gate";
272
272
  PRODUCT_NAME = "$(TARGET_NAME)";
273
273
  REGISTER_APP_GROUPS = YES;
@@ -302,7 +302,7 @@
302
302
  "@executable_path/../Frameworks",
303
303
  );
304
304
  MACOSX_DEPLOYMENT_TARGET = 26.0;
305
- MARKETING_VERSION = 0.1.6;
305
+ MARKETING_VERSION = 0.1.7;
306
306
  PRODUCT_BUNDLE_IDENTIFIER = "dev.fka.Poke-macOS-Gate";
307
307
  PRODUCT_NAME = "$(TARGET_NAME)";
308
308
  REGISTER_APP_GROUPS = YES;
@@ -1,6 +1,34 @@
1
1
  # Creating Agents
2
2
 
3
- This guide walks you through creating an agent from scratch.
3
+ There are two ways to create agents: **ask Poke to generate one** (recommended) or write one manually.
4
+
5
+ ## Generate with AI
6
+
7
+ The fastest way — describe what you want and Poke writes the code for you:
8
+
9
+ ```bash
10
+ npx poke-gate agent create --prompt "monitor disk space and alert when above 85%"
11
+ ```
12
+
13
+ Or interactively:
14
+
15
+ ```bash
16
+ npx poke-gate agent create
17
+ > Describe the agent you want to create:
18
+ > track my git repos for uncommitted changes
19
+ ```
20
+
21
+ Poke generates the complete agent code and saves it directly to your agents folder using the `write_file` tool (requires poke-gate to be running). You'll get a confirmation in your chat when it's done.
22
+
23
+ You can also generate agents from the **macOS app** — open Agents, click "Generate with AI", type your description, and Poke does the rest.
24
+
25
+ ::: tip
26
+ Poke may ask clarifying questions before writing the code. Just reply in your chat and it will proceed.
27
+ :::
28
+
29
+ ## Write manually
30
+
31
+ If you prefer to write agents yourself, follow the steps below.
4
32
 
5
33
  ## Step 1: Create the file
6
34
 
package/docs/cli.md CHANGED
@@ -41,6 +41,30 @@ npx poke-gate run-agent beeper
41
41
 
42
42
  Finds `~/.config/poke-gate/agents/beeper.*.js` and runs it with the env from `.env.beeper`.
43
43
 
44
+ ## Generate an agent with AI
45
+
46
+ ```bash
47
+ npx poke-gate agent create --prompt "<description>"
48
+ ```
49
+
50
+ Sends your description to Poke with detailed instructions and examples. Poke generates the agent code and saves it directly to `~/.config/poke-gate/agents/` using the `write_file` tool.
51
+
52
+ **Requires poke-gate to be running** (so Poke can use the `write_file` tool through the tunnel).
53
+
54
+ **Interactive mode:**
55
+
56
+ ```bash
57
+ npx poke-gate agent create
58
+ ```
59
+
60
+ **Examples:**
61
+
62
+ ```bash
63
+ npx poke-gate agent create --prompt "alert me when disk space is above 85%"
64
+ npx poke-gate agent create --prompt "send me a daily git commit summary across all repos"
65
+ npx poke-gate agent create --prompt "track Spotify listening and log my music taste"
66
+ ```
67
+
44
68
  ## Install an agent
45
69
 
46
70
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poke-gate",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Expose your machine to your Poke AI assistant via MCP tunnel",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,100 @@
1
+ import { Poke, getToken, isLoggedIn, login } from "poke";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { createInterface } from "node:readline";
5
+
6
+ const CONFIG_DIR = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
7
+ const AGENTS_DIR = join(CONFIG_DIR, "poke-gate", "agents");
8
+
9
+ const SYSTEM_PROMPT = `Generate a Poke Gate agent based on my description below.
10
+
11
+ Write the COMPLETE JavaScript code using the write_file tool to save it directly to the agents folder.
12
+
13
+ RULES:
14
+ - Save to: ~/.config/poke-gate/agents/<name>.<interval>.js
15
+ - Valid ES module with imports.
16
+ - Start with JSDoc frontmatter: @agent, @name, @description, @interval, @author.
17
+ - Use: import { Poke, getToken } from "poke";
18
+ - Auth: const token = getToken(); const poke = new Poke({ apiKey: token });
19
+ - Send results: await poke.sendMessage("...");
20
+ - Shell commands: import { execSync } from "node:child_process";
21
+ - State files: ~/.config/poke-gate/agents/.<agent-name>-state.json
22
+ - Only send to Poke when something changed (use state files).
23
+ - Handle errors with try/catch. Log with console.log().
24
+ - Keep under 100 lines. Intervals: 10m, 30m, 1h, 2h, 6h, 12h, 24h.
25
+ - If secrets needed, read from process.env (from .env.<name> file).
26
+
27
+ EXAMPLE agent (battery monitor):
28
+ /**
29
+ * @agent battery
30
+ * @name Battery Guardian
31
+ * @description Alerts via Poke when battery drops below 20%.
32
+ * @interval 30m
33
+ */
34
+ import { Poke, getToken } from "poke";
35
+ import { execSync } from "node:child_process";
36
+ import { readFileSync, writeFileSync } from "node:fs";
37
+ import { join } from "node:path";
38
+ import { homedir } from "node:os";
39
+ const token = getToken();
40
+ if (!token) { console.error("Not signed in."); process.exit(1); }
41
+ const STATE = join(homedir(), ".config", "poke-gate", "agents", ".battery-state.json");
42
+ function load() { try { return JSON.parse(readFileSync(STATE, "utf-8")); } catch { return {}; } }
43
+ function save(s) { writeFileSync(STATE, JSON.stringify(s)); }
44
+ const out = execSync("pmset -g batt", { encoding: "utf-8" });
45
+ const level = parseInt(out.match(/(\\d+)%/)?.[1] || "100");
46
+ const charging = out.includes("AC Power");
47
+ const state = load();
48
+ if (level <= 20 && !charging && !state.alerted) {
49
+ await new Poke({ apiKey: token }).sendMessage("Battery: " + level + "%, not charging.");
50
+ save({ alerted: true });
51
+ } else if (level > 20 || charging) { if (state.alerted) save({ alerted: false }); }
52
+
53
+ Now use the write_file tool via Poke Gate to save the generated agent code. After writing, tell me the file name and what the agent does.
54
+ If you cannot reach out the tunnel, you can send the code via iMessage, Telegram, or SMS.
55
+
56
+ IMPORTANT: Now write me immediately, before starting that you will write the agent code now and save to your file.
57
+ IMPORTANT: If you have questions to clarify, ask me first.
58
+ IMPORTANT: When you finish writing the agent code, tell user that you created the agent and saved to the file.
59
+
60
+ My request: `;
61
+
62
+ function ask(question) {
63
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
64
+ return new Promise((resolve) => {
65
+ rl.question(question, (answer) => {
66
+ rl.close();
67
+ resolve(answer.trim());
68
+ });
69
+ });
70
+ }
71
+
72
+ export async function createAgent(promptArg) {
73
+ if (!isLoggedIn()) {
74
+ console.log(" Signing in to Poke...");
75
+ await login();
76
+ }
77
+
78
+ const token = getToken();
79
+ if (!token) {
80
+ console.error(" Not signed in. Run: npx poke login");
81
+ process.exit(1);
82
+ }
83
+
84
+ const prompt = promptArg || await ask("\n Describe the agent you want to create:\n > ");
85
+ if (!prompt) {
86
+ console.error(" No description provided.");
87
+ process.exit(1);
88
+ }
89
+
90
+ console.log("\n Sending request to Poke...");
91
+ console.log(" Poke will generate the code and save it using the write_file tool.\n");
92
+
93
+ const poke = new Poke({ apiKey: token });
94
+ await poke.sendMessage(SYSTEM_PROMPT + prompt);
95
+
96
+ console.log(" Request sent! Poke will write the agent file to:");
97
+ console.log(` ${AGENTS_DIR}/<name>.<interval>.js\n`);
98
+ console.log(" Watch for Poke's confirmation in your chat.");
99
+ console.log(" Once created, test it: npx poke-gate run-agent <name>\n");
100
+ }
package/src/mcp-server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import http from "node:http";
2
2
  import { exec } from "node:child_process";
3
- import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
3
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, symlinkSync, lstatSync } from "node:fs";
4
4
  import { hostname, platform, arch, uptime, totalmem, freemem, homedir } from "node:os";
5
5
  import { join, resolve, extname } from "node:path";
6
6
 
@@ -93,6 +93,19 @@ const TOOLS = [
93
93
  required: ["path"],
94
94
  },
95
95
  },
96
+ {
97
+ name: "run_agent",
98
+ description:
99
+ "Run a Poke Gate agent by name. Agents are scheduled scripts in ~/.config/poke-gate/agents/. " +
100
+ "Use this to manually trigger an agent — it will execute and send its results to you.",
101
+ inputSchema: {
102
+ type: "object",
103
+ properties: {
104
+ name: { type: "string", description: "Agent name (e.g. 'beeper', 'battery', 'context')" },
105
+ },
106
+ required: ["name"],
107
+ },
108
+ },
96
109
  {
97
110
  name: "take_screenshot",
98
111
  description: "Take a screenshot of the user's screen and save it to a file. Returns the file path. Requires screen recording permission on macOS.",
@@ -233,6 +246,40 @@ function handleToolCall(name, args) {
233
246
  }
234
247
  }
235
248
 
249
+ case "run_agent": {
250
+ const agentName = args.name;
251
+ logTool(name, { name: agentName });
252
+ const agentsDir = join(homedir(), ".config", "poke-gate", "agents");
253
+
254
+ // Ensure node_modules symlink so agents can import poke
255
+ const pkgNodeModules = join(new URL(".", import.meta.url).pathname, "..", "node_modules");
256
+ const agentNodeModules = join(agentsDir, "node_modules");
257
+ if (existsSync(pkgNodeModules)) {
258
+ try {
259
+ const s = lstatSync(agentNodeModules);
260
+ if (!s.isSymbolicLink()) throw new Error();
261
+ } catch {
262
+ try { symlinkSync(pkgNodeModules, agentNodeModules, "junction"); } catch {}
263
+ }
264
+ }
265
+
266
+ let files;
267
+ try { files = readdirSync(agentsDir).filter((f) => f.endsWith(".js") && f.startsWith(agentName + ".")); } catch { files = []; }
268
+ if (files.length === 0) {
269
+ let available = [];
270
+ try { available = readdirSync(agentsDir).filter(f => f.endsWith(".js")).map(f => f.split(".")[0]); } catch {}
271
+ return { content: [{ type: "text", text: `Agent "${agentName}" not found. Available: ${available.join(", ") || "none"}` }], isError: true };
272
+ }
273
+ const agentFile = join(agentsDir, files[0]);
274
+ return runCommand(`node "${agentFile}"`, agentsDir).then((result) => {
275
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
276
+ if (result.exitCode === 0) {
277
+ return { content: [{ type: "text", text: `Agent "${agentName}" completed.\n${output || "No output."}` }] };
278
+ }
279
+ return { content: [{ type: "text", text: `Agent "${agentName}" failed (exit ${result.exitCode}).\n${output}` }], isError: true };
280
+ });
281
+ }
282
+
236
283
  case "take_screenshot": {
237
284
  logTool(name, args);
238
285
 
package/src/tunnel.js CHANGED
@@ -6,6 +6,11 @@ import { homedir } from "node:os";
6
6
  const CONFIG_DIR = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
7
7
  const STATE_PATH = join(CONFIG_DIR, "poke-gate", "state.json");
8
8
 
9
+ function log(msg) {
10
+ const ts = new Date().toISOString().slice(11, 19);
11
+ console.log(`[${ts}] ${msg}`);
12
+ }
13
+
9
14
  function loadState() {
10
15
  try {
11
16
  return JSON.parse(readFileSync(STATE_PATH, "utf-8"));
@@ -19,24 +24,36 @@ function saveState(state) {
19
24
  writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
20
25
  }
21
26
 
22
- async function cleanupOldConnection() {
23
- const state = loadState();
24
- if (!state.connectionId) return;
25
-
27
+ async function cleanupStaleConnections() {
26
28
  const token = getToken();
27
29
  if (!token) return;
28
30
  const base = process.env.POKE_API ?? "https://poke.com/api/v1";
31
+ const state = loadState();
29
32
 
30
- try {
31
- await fetch(`${base}/mcp/connections/${state.connectionId}`, {
32
- method: "DELETE",
33
- headers: { Authorization: `Bearer ${token}` },
34
- });
35
- } catch {}
33
+ const ids = new Set();
34
+ if (state.connectionId) ids.add(state.connectionId);
35
+ if (Array.isArray(state.connectionHistory)) {
36
+ for (const id of state.connectionHistory) ids.add(id);
37
+ }
38
+
39
+ if (ids.size === 0) return;
40
+
41
+ log(`Cleaning up ${ids.size} old connection(s)…`);
42
+
43
+ for (const id of ids) {
44
+ try {
45
+ await fetch(`${base}/mcp/connections/${id}`, {
46
+ method: "DELETE",
47
+ headers: { Authorization: `Bearer ${token}` },
48
+ });
49
+ } catch {}
50
+ }
51
+
52
+ saveState({});
36
53
  }
37
54
 
38
55
  export async function startTunnel({ mcpUrl, onEvent }) {
39
- await cleanupOldConnection();
56
+ await cleanupStaleConnections();
40
57
 
41
58
  const token = getToken();
42
59
  if (!token) {
@@ -51,7 +68,13 @@ export async function startTunnel({ mcpUrl, onEvent }) {
51
68
  });
52
69
 
53
70
  tunnel.on("connected", (info) => {
54
- saveState({ connectionId: info.connectionId });
71
+ const state = loadState();
72
+ const history = state.connectionHistory || [];
73
+ history.push(info.connectionId);
74
+ saveState({
75
+ connectionId: info.connectionId,
76
+ connectionHistory: history.slice(-10),
77
+ });
55
78
  onEvent("connected", info);
56
79
  });
57
80
  tunnel.on("disconnected", () => onEvent("disconnected"));