thrust-cli 1.0.11 → 1.0.13

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.
@@ -0,0 +1,226 @@
1
+ using UnityEngine;
2
+ using UnityEditor;
3
+ using System.Net;
4
+ using System.Threading;
5
+ using System.IO;
6
+ using System.Text;
7
+ using System.Collections.Generic;
8
+ using UnityEngine.SceneManagement;
9
+
10
+ [InitializeOnLoad]
11
+ public static class ThrustMCPBridge
12
+ {
13
+ private static HttpListener listener;
14
+ private static Thread listenerThread;
15
+
16
+ // Buffers
17
+ private static List<string> activityBuffer = new List<string>();
18
+ private static List<string> consoleBuffer = new List<string>();
19
+ private static readonly object bufferLock = new object();
20
+
21
+ // Main Thread Dispatcher variables
22
+ private static string pendingAction = null;
23
+ private static string pendingArgument = null;
24
+ private static string actionResult = null;
25
+ private static volatile bool isProcessingOnMainThread = false;
26
+
27
+ private const int PORT = 8081;
28
+
29
+ static ThrustMCPBridge()
30
+ {
31
+ // 1. Hook into Unity Editor Events
32
+ EditorApplication.hierarchyChanged += OnHierarchyChanged;
33
+ EditorApplication.playModeStateChanged += OnPlayModeChanged;
34
+ UnityEditor.SceneManagement.EditorSceneManager.sceneSaved += OnSceneSaved;
35
+ Application.logMessageReceived += OnLogMessage;
36
+
37
+ // Hook into the main thread loop to process read requests
38
+ EditorApplication.update += OnEditorUpdate;
39
+
40
+ // 2. Start the local MCP Server
41
+ StartServer();
42
+ EditorApplication.quitting += StopServer;
43
+ }
44
+
45
+ private static void AddActivity(string activity)
46
+ {
47
+ lock (bufferLock)
48
+ {
49
+ activityBuffer.Add($"[{System.DateTime.Now.ToShortTimeString()}] {activity}");
50
+ if (activityBuffer.Count > 50) activityBuffer.RemoveAt(0);
51
+ }
52
+ }
53
+
54
+ private static void OnLogMessage(string logString, string stackTrace, LogType type)
55
+ {
56
+ lock (bufferLock)
57
+ {
58
+ if (type == LogType.Error || type == LogType.Exception || type == LogType.Warning)
59
+ {
60
+ consoleBuffer.Add($"[{type}] {logString}");
61
+ if (consoleBuffer.Count > 30) consoleBuffer.RemoveAt(0);
62
+ }
63
+ }
64
+ }
65
+
66
+ private static void OnHierarchyChanged() => AddActivity("Hierarchy changed.");
67
+ private static void OnSceneSaved(Scene scene) => AddActivity($"Saved scene: {scene.name}");
68
+ private static void OnPlayModeChanged(PlayModeStateChange state) => AddActivity($"Play mode state: {state}");
69
+
70
+ // --- HTTP SERVER LOOP (Background Thread) ---
71
+ private static void StartServer()
72
+ {
73
+ if (listener != null && listener.IsListening) return;
74
+ listener = new HttpListener();
75
+ listener.Prefixes.Add($"http://localhost:{PORT}/");
76
+ listener.Start();
77
+
78
+ listenerThread = new Thread(ListenForRequests) { IsBackground = true };
79
+ listenerThread.Start();
80
+ Debug.Log($"[Thrust MCP] Two-Way Unity Bridge active on port {PORT}...");
81
+ }
82
+
83
+ private static void ListenForRequests()
84
+ {
85
+ while (listener != null && listener.IsListening)
86
+ {
87
+ try { ProcessRequest(listener.GetContext()); }
88
+ catch (HttpListenerException) { /* Ignored on shutdown */ }
89
+ }
90
+ }
91
+
92
+ private static void ProcessRequest(HttpListenerContext context)
93
+ {
94
+ var request = context.Request;
95
+ var response = context.Response;
96
+
97
+ response.AddHeader("Access-Control-Allow-Origin", "*");
98
+ response.AddHeader("Access-Control-Allow-Headers", "Content-Type");
99
+
100
+ if (request.HttpMethod == "OPTIONS") { SendResponse(response, ""); return; }
101
+
102
+ string requestBody;
103
+ using (var reader = new StreamReader(request.InputStream, request.ContentEncoding))
104
+ {
105
+ requestBody = reader.ReadToEnd();
106
+ }
107
+
108
+ string resultText = "Unknown command";
109
+
110
+ // 1. Parse requested tool name (Crude JSON parsing to avoid dependencies)
111
+ if (requestBody.Contains("\"name\":\"get_recent_activity\"") || requestBody.Contains("\"name\": \"get_recent_activity\""))
112
+ {
113
+ lock (bufferLock)
114
+ {
115
+ resultText = activityBuffer.Count == 0 ? "No new activity" : string.Join("\\n", activityBuffer);
116
+ activityBuffer.Clear();
117
+ }
118
+ }
119
+ else if (requestBody.Contains("\"name\":\"get_console_logs\"") || requestBody.Contains("\"name\": \"get_console_logs\""))
120
+ {
121
+ lock (bufferLock)
122
+ {
123
+ resultText = consoleBuffer.Count == 0 ? "No recent errors/warnings." : string.Join("\\n", consoleBuffer).Replace("\"", "'");
124
+ consoleBuffer.Clear();
125
+ }
126
+ }
127
+ else if (requestBody.Contains("\"name\":\"get_hierarchy\"") || requestBody.Contains("\"name\": \"get_hierarchy\""))
128
+ {
129
+ resultText = DispatchToMainThread("get_hierarchy", "");
130
+ }
131
+ else if (requestBody.Contains("\"name\":\"get_properties\"") || requestBody.Contains("\"name\": \"get_properties\""))
132
+ {
133
+ // Extract the target parameter safely (e.g., "target": "Player")
134
+ string target = ExtractJsonValue(requestBody, "target");
135
+ resultText = DispatchToMainThread("get_properties", target);
136
+ }
137
+
138
+ // Format as JSON-RPC response
139
+ string responseJson = $@"{{
140
+ ""jsonrpc"": ""2.0"",
141
+ ""result"": {{ ""content"": [{{ ""type"": ""text"", ""text"": ""{resultText}"" }}] }}
142
+ }}";
143
+
144
+ SendResponse(response, responseJson);
145
+ }
146
+
147
+ // --- MAIN THREAD DISPATCHER ---
148
+ // Passes the request to the main thread and waits for the result
149
+ private static string DispatchToMainThread(string action, string arg)
150
+ {
151
+ pendingAction = action;
152
+ pendingArgument = arg;
153
+ actionResult = null;
154
+ isProcessingOnMainThread = true;
155
+
156
+ // Block background thread until main thread finishes
157
+ while (isProcessingOnMainThread) { Thread.Sleep(10); }
158
+
159
+ return actionResult;
160
+ }
161
+
162
+ // Executes on Unity's Main Thread
163
+ private static void OnEditorUpdate()
164
+ {
165
+ if (!isProcessingOnMainThread) return;
166
+
167
+ try
168
+ {
169
+ if (pendingAction == "get_hierarchy")
170
+ {
171
+ var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects();
172
+ string hierarchy = "Active Scene Hierarchy:\\n";
173
+ foreach (var go in rootObjects) hierarchy += $"- {go.name}\\n";
174
+ actionResult = hierarchy;
175
+ }
176
+ else if (pendingAction == "get_properties")
177
+ {
178
+ if (string.IsNullOrEmpty(pendingArgument)) {
179
+ actionResult = "Error: No target specified.";
180
+ } else {
181
+ GameObject go = GameObject.Find(pendingArgument);
182
+ if (go == null) {
183
+ actionResult = $"GameObject '{pendingArgument}' not found in active scene.";
184
+ } else {
185
+ actionResult = $"Properties for '{go.name}':\\nPosition: {go.transform.position}\\nRotation: {go.transform.eulerAngles}\\nComponents:\\n";
186
+ foreach (var comp in go.GetComponents<Component>())
187
+ {
188
+ if(comp != null) actionResult += $"- {comp.GetType().Name}\\n";
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+ catch (System.Exception e) { actionResult = $"Error reading Unity state: {e.Message}"; }
195
+
196
+ isProcessingOnMainThread = false; // Release the background thread
197
+ }
198
+
199
+ // Utility
200
+ private static string ExtractJsonValue(string json, string key)
201
+ {
202
+ string search = $"\"{key}\":";
203
+ int index = json.IndexOf(search);
204
+ if (index == -1) search = $"\"{key}\" :"; index = json.IndexOf(search);
205
+ if (index == -1) return "";
206
+
207
+ int start = json.IndexOf("\"", index + search.Length) + 1;
208
+ int end = json.IndexOf("\"", start);
209
+ return json.Substring(start, end - start);
210
+ }
211
+
212
+ private static void SendResponse(HttpListenerResponse response, string json)
213
+ {
214
+ byte[] buffer = Encoding.UTF8.GetBytes(json);
215
+ response.ContentType = "application/json";
216
+ response.ContentLength64 = buffer.Length;
217
+ response.OutputStream.Write(buffer, 0, buffer.Length);
218
+ response.OutputStream.Close();
219
+ }
220
+
221
+ private static void StopServer()
222
+ {
223
+ if (listener != null) { listener.Stop(); listener.Close(); listener = null; }
224
+ if (listenerThread != null) { listenerThread.Abort(); listenerThread = null; }
225
+ }
226
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thrust-cli",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "The local agent for Thrust AI Director",
5
5
  "type": "module",
6
6
  "homepage": "https://thrust.web.app",
package/utils/daemon.js CHANGED
@@ -20,8 +20,8 @@ const API_URL = GATEWAY_URL.replace('ws://', 'http://').replace('wss://', 'https
20
20
  const AUTH_PROXY_URL = "https://everydaycats-thrust-auth-server.hf.space";
21
21
 
22
22
  // --- DEBOUNCE & POLLING TIMERS ---
23
- const INACTIVITY_DELAY_MS = 15 * 1000; // 30 * 1000; // lets make it 15 seconds instead
24
- const MCP_POLL_INTERVAL_MS = 18 * 1000; // 0.3 * 60 * 1000; // Poll external tools every 0.3 mins (18 seconds)
23
+ const INACTIVITY_DELAY_MS = 15 * 1000; // Wait for 15 seconds of silence across ALL inputs before sending
24
+ const MCP_POLL_INTERVAL_MS = 18 * 1000; // Poll external tools every 18 seconds
25
25
 
26
26
  let currentWatcher = null;
27
27
  let inactivityTimer = null;
@@ -59,6 +59,18 @@ function isBinaryData(buffer) {
59
59
  return false;
60
60
  }
61
61
 
62
+ // --- CENTRAL DEBOUNCER ---
63
+ // Called by local file changes AND MCP events. It resets the 15s timer.
64
+ function triggerDebouncedSync() {
65
+ const activeProject = getActiveProject();
66
+ if (!activeProject || !activeProject.path) return;
67
+
68
+ if (inactivityTimer) clearTimeout(inactivityTimer);
69
+ inactivityTimer = setTimeout(() => {
70
+ syncContext(activeProject.path);
71
+ }, INACTIVITY_DELAY_MS);
72
+ }
73
+
62
74
  export async function startDaemon(preferredPort) {
63
75
  const actualPort = await findAvailablePort(preferredPort);
64
76
  const app = express();
@@ -67,7 +79,7 @@ export async function startDaemon(preferredPort) {
67
79
  const origin = req.header('Origin');
68
80
  const allowedWebApps = ['https://thrust.web.app', 'http://localhost:3000'];
69
81
  if (req.path.startsWith('/api/mcp')) {
70
- callback(null, { origin: true });
82
+ callback(null, { origin: true });
71
83
  } else if (req.path === '/api/auth/callback') {
72
84
  if (!origin || allowedWebApps.includes(origin) || origin === `http://localhost:${actualPort}`) {
73
85
  callback(null, { origin: true, credentials: true });
@@ -102,7 +114,7 @@ export async function startDaemon(preferredPort) {
102
114
  headers: { 'Authorization': `Bearer ${token}` }
103
115
  });
104
116
  const data = await response.json();
105
-
117
+
106
118
  res.json({
107
119
  projectId,
108
120
  projectPath: config.leads[projectId].path,
@@ -114,30 +126,26 @@ export async function startDaemon(preferredPort) {
114
126
  }
115
127
  });
116
128
 
117
- app.post('/api/mcp/timeline', async (req, res) => {
129
+ app.post('/api/mcp/timeline', (req, res) => {
118
130
  const { source, action_type, description, requires_code_sync } = req.body;
119
-
131
+
120
132
  if (!source || !description) {
121
133
  return res.status(400).json({ error: "Malformed MCP payload." });
122
134
  }
123
135
 
124
136
  broadcastLocalLog('mcp', `🔗 [${source} Pushed Event] ${action_type}: ${description}`);
125
137
 
138
+ // Add to buffer and reset the 15-second countdown
126
139
  fileActivityBuffer += `\n[${new Date().toLocaleTimeString()}] [MCP EXT EVENT] Source: ${source} | Action: ${action_type} | Desc: ${description} | Needs Code Sync: ${requires_code_sync ? 'Yes' : 'No'}\n`;
140
+ triggerDebouncedSync();
127
141
 
128
- const activeProject = getActiveProject();
129
- if (activeProject && activeProject.path) {
130
- if (inactivityTimer) clearTimeout(inactivityTimer);
131
- await syncContext(activeProject.path);
132
- }
133
-
134
- res.json({ success: true, message: "Timeline event ingested and sync triggered." });
142
+ res.json({ success: true, message: "Timeline event ingested to buffer." });
135
143
  });
136
144
 
137
145
  // ==========================================
138
146
  // MCP CLIENT CONFIGURATION (FEED-IN PULL)
139
147
  // ==========================================
140
-
148
+
141
149
  app.get('/api/mcp/servers', (req, res) => {
142
150
  res.json(getConfig().mcpServers || []);
143
151
  });
@@ -145,13 +153,12 @@ export async function startDaemon(preferredPort) {
145
153
  app.post('/api/mcp/servers', (req, res) => {
146
154
  const { name, url } = req.body;
147
155
  if (!name || !url) return res.status(400).json({ error: "Missing name or url" });
148
-
156
+
149
157
  const config = getConfig();
150
158
  if (!config.mcpServers) config.mcpServers = [];
151
159
  config.mcpServers.push({ name, url, type: 'http' });
152
160
  saveConfig(config);
153
-
154
- // Trigger an immediate poll when a new service is added
161
+
155
162
  pollExternalMCPServers();
156
163
  res.json({ success: true });
157
164
  });
@@ -166,6 +173,53 @@ export async function startDaemon(preferredPort) {
166
173
  res.json({ success: true });
167
174
  });
168
175
 
176
+ // ==========================================
177
+ // EXTERNAL MCP DIRECT QUERY (AI OR MANUAL)
178
+ // ==========================================
179
+ app.post('/api/mcp/query', async (req, res) => {
180
+ const { serverName, toolName, targetArg } = req.body;
181
+ const config = getConfig();
182
+
183
+ const server = config.mcpServers?.find(s => s.name.toLowerCase() === serverName.toLowerCase());
184
+
185
+ if (!server) return res.status(404).json({ error: `MCP Server '${serverName}' not found.` });
186
+
187
+ try {
188
+ const payload = {
189
+ jsonrpc: "2.0",
190
+ method: "tools/call",
191
+ params: {
192
+ name: toolName,
193
+ arguments: targetArg ? { target: targetArg } : {}
194
+ },
195
+ id: Date.now()
196
+ };
197
+
198
+ const response = await fetch(server.url, {
199
+ method: 'POST',
200
+ headers: { 'Content-Type': 'application/json' },
201
+ body: JSON.stringify(payload)
202
+ });
203
+
204
+ if (!response.ok) throw new Error("Server responded with error");
205
+
206
+ const data = await response.json();
207
+ const resultText = data.result?.content?.[0]?.text || "No data returned.";
208
+
209
+ broadcastLocalLog('mcp', `⚡ Queried ${serverName} for ${toolName}.`);
210
+
211
+ // Add the query result to the buffer and reset the 15-second countdown
212
+ fileActivityBuffer += `\n[${new Date().toLocaleTimeString()}] [MCP DIRECT QUERY RESULT] Source: ${serverName} | Tool: ${toolName} | Target: ${targetArg || 'none'} \nResult:\n${resultText}\n`;
213
+ triggerDebouncedSync();
214
+
215
+ res.json({ success: true, data: resultText });
216
+
217
+ } catch (e) {
218
+ broadcastLocalLog('error', `⚠️ Failed to query MCP server: ${serverName}`);
219
+ res.status(500).json({ error: e.message });
220
+ }
221
+ });
222
+
169
223
  // ==========================================
170
224
  // CORE DASHBOARD API
171
225
  // ==========================================
@@ -234,6 +288,7 @@ export async function startDaemon(preferredPort) {
234
288
  if (!response.ok) throw new Error(data.error);
235
289
 
236
290
  if (config.leads[projectId]?.path) {
291
+ // Instantly sync when a task is manually completed to update cloud state fast
237
292
  if (inactivityTimer) clearTimeout(inactivityTimer);
238
293
  syncContext(config.leads[projectId].path);
239
294
  }
@@ -320,10 +375,10 @@ export async function startDaemon(preferredPort) {
320
375
  saveConfig(config);
321
376
 
322
377
  await startWatching(folderPath);
323
-
324
- if (inactivityTimer) clearTimeout(inactivityTimer);
325
- syncContext(folderPath);
326
-
378
+
379
+ // Initial sync on link
380
+ triggerDebouncedSync();
381
+
327
382
  res.json({ success: true });
328
383
  });
329
384
 
@@ -402,7 +457,6 @@ async function pollExternalMCPServers() {
402
457
 
403
458
  for (const server of config.mcpServers) {
404
459
  try {
405
- // Send standard MCP JSON-RPC payload asking for recent activity
406
460
  const res = await fetch(server.url, {
407
461
  method: 'POST',
408
462
  headers: { 'Content-Type': 'application/json' },
@@ -416,11 +470,9 @@ async function pollExternalMCPServers() {
416
470
 
417
471
  if (res.ok) {
418
472
  const data = await res.json();
419
-
420
- // Parse standard MCP response { result: { content: [{ text: "..." }] } }
421
473
  if (data.result && data.result.content && data.result.content.length > 0) {
422
474
  const updateText = data.result.content[0].text;
423
-
475
+
424
476
  if (updateText && updateText.trim() !== "" && updateText.trim() !== "No new activity") {
425
477
  fileActivityBuffer += `\n[${new Date().toLocaleTimeString()}] [MCP POLL] Source: ${server.name} | Update: ${updateText}\n`;
426
478
  broadcastLocalLog('mcp', `🔗 Pulled new data from ${server.name}`);
@@ -429,16 +481,13 @@ async function pollExternalMCPServers() {
429
481
  }
430
482
  }
431
483
  } catch (e) {
432
- broadcastLocalLog('error', `⚠️ Failed to poll MCP: ${server.name}`);
484
+ // Silently fail to avoid spamming the logs if Unity is temporarily closed
433
485
  }
434
486
  }
435
487
 
436
488
  if (hasNewData) {
437
- const activeProject = getActiveProject();
438
- if (activeProject && activeProject.path) {
439
- if (inactivityTimer) clearTimeout(inactivityTimer);
440
- await syncContext(activeProject.path);
441
- }
489
+ // Add to buffer and reset the 15-second countdown
490
+ triggerDebouncedSync();
442
491
  }
443
492
  }
444
493
 
@@ -465,10 +514,8 @@ async function startWatching(projectPath) {
465
514
  fileActivityBuffer += `[${new Date().toLocaleTimeString()}] ${event.toUpperCase()}: ${relativePath}\n`;
466
515
  broadcastLocalLog('watch', `[${event.toUpperCase()}] ${relativePath}`);
467
516
 
468
- if (inactivityTimer) clearTimeout(inactivityTimer);
469
- inactivityTimer = setTimeout(() => {
470
- syncContext(projectPath);
471
- }, INACTIVITY_DELAY_MS);
517
+ // Reset the 15-second countdown
518
+ triggerDebouncedSync();
472
519
  });
473
520
  } catch (err) {}
474
521
  }