browse-code 0.1.0__py3-none-any.whl
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.
- browse_code/__init__.py +1 -0
- browse_code/cli.py +75 -0
- browse_code/extension/content.js +356 -0
- browse_code/extension/manifest.json +30 -0
- browse_code/extension/popup.html +109 -0
- browse_code/extension/popup.js +90 -0
- browse_code/extension/spoof.js +3 -0
- browse_code/server.py +574 -0
- browse_code-0.1.0.dist-info/METADATA +9 -0
- browse_code-0.1.0.dist-info/RECORD +13 -0
- browse_code-0.1.0.dist-info/WHEEL +5 -0
- browse_code-0.1.0.dist-info/entry_points.txt +2 -0
- browse_code-0.1.0.dist-info/top_level.txt +1 -0
browse_code/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
browse_code/cli.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
def get_data_dir():
|
|
8
|
+
home = Path.home()
|
|
9
|
+
data_dir = home / ".browse_code"
|
|
10
|
+
data_dir.mkdir(exist_ok=True)
|
|
11
|
+
return data_dir
|
|
12
|
+
|
|
13
|
+
def install_extension():
|
|
14
|
+
data_dir = get_data_dir()
|
|
15
|
+
installed_marker = data_dir / ".installed"
|
|
16
|
+
ext_dest = data_dir / "extension"
|
|
17
|
+
|
|
18
|
+
# Copy extension files from package to ~/.browse_code/extension
|
|
19
|
+
pkg_ext_dir = Path(__file__).parent / "extension"
|
|
20
|
+
|
|
21
|
+
if not installed_marker.exists():
|
|
22
|
+
ans_allow = input("Do you allow browse_code to install its Chrome extension? (y/n): ")
|
|
23
|
+
if ans_allow.lower() != 'y':
|
|
24
|
+
print("Extension installation skipped.")
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
print("Installing browse_code extension...")
|
|
28
|
+
if ext_dest.exists():
|
|
29
|
+
shutil.rmtree(ext_dest)
|
|
30
|
+
shutil.copytree(pkg_ext_dir, ext_dest)
|
|
31
|
+
|
|
32
|
+
print("\n========================================================")
|
|
33
|
+
print("Extension files copied to: ", ext_dest)
|
|
34
|
+
print("To load the extension in Chrome:")
|
|
35
|
+
print("1. Open Chrome and navigate to chrome://extensions/")
|
|
36
|
+
print("2. Enable 'Developer mode' at the top right")
|
|
37
|
+
print("3. Click 'Load unpacked' and select the folder:")
|
|
38
|
+
print(f" {ext_dest}")
|
|
39
|
+
print("========================================================\n")
|
|
40
|
+
|
|
41
|
+
ans = input("Do you want me to try launching Chrome with this extension now? (y/n): ")
|
|
42
|
+
if ans.lower() == 'y':
|
|
43
|
+
# Attempt to launch chrome
|
|
44
|
+
# On Windows:
|
|
45
|
+
chrome_path = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
|
46
|
+
if not os.path.exists(chrome_path):
|
|
47
|
+
chrome_path = r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"
|
|
48
|
+
if os.path.exists(chrome_path):
|
|
49
|
+
subprocess.Popen([chrome_path, f"--load-extension={ext_dest}"])
|
|
50
|
+
else:
|
|
51
|
+
print("Could not find Chrome executable automatically.")
|
|
52
|
+
|
|
53
|
+
installed_marker.touch()
|
|
54
|
+
else:
|
|
55
|
+
# Already installed, check if files are still there, if not recopy
|
|
56
|
+
if not ext_dest.exists():
|
|
57
|
+
shutil.copytree(pkg_ext_dir, ext_dest)
|
|
58
|
+
|
|
59
|
+
def main():
|
|
60
|
+
install_extension()
|
|
61
|
+
|
|
62
|
+
print("\nStarting AI session server...\n")
|
|
63
|
+
# Now we need to start the FastAPI server.
|
|
64
|
+
# The original server.py is in the same package
|
|
65
|
+
try:
|
|
66
|
+
from .server import app
|
|
67
|
+
except ImportError:
|
|
68
|
+
# Fallback if run directly during dev
|
|
69
|
+
from server import app
|
|
70
|
+
|
|
71
|
+
import uvicorn
|
|
72
|
+
uvicorn.run(app, host="127.0.0.1", port=5505, access_log=False)
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
const LOCAL_SERVER = "http://127.0.0.1:5505";
|
|
2
|
+
const hostname = window.location.hostname;
|
|
3
|
+
|
|
4
|
+
let PLATFORM = {};
|
|
5
|
+
|
|
6
|
+
if (hostname.includes('gemini.google.com')) {
|
|
7
|
+
PLATFORM = {
|
|
8
|
+
name: "Gemini",
|
|
9
|
+
inputBox: 'rich-textarea div[contenteditable="true"], div[role="textbox"][contenteditable="true"]',
|
|
10
|
+
sendBtn: 'button[aria-label*="Send"], button[aria-label*="send"], button[mattooltip*="Send"]',
|
|
11
|
+
responseContainer: 'message-content, model-response, .model-response-text'
|
|
12
|
+
};
|
|
13
|
+
} else if (hostname.includes('claude.ai')) {
|
|
14
|
+
PLATFORM = {
|
|
15
|
+
name: "Claude",
|
|
16
|
+
inputBox: '.ProseMirror[contenteditable="true"]',
|
|
17
|
+
sendBtn: 'button[aria-label*="Send"], button[aria-label*="send"]',
|
|
18
|
+
responseContainer: '.font-claude-response, .font-claude-message'
|
|
19
|
+
};
|
|
20
|
+
} else if (hostname.includes('huggingface.co')) {
|
|
21
|
+
PLATFORM = {
|
|
22
|
+
name: "HuggingFace",
|
|
23
|
+
inputBox: 'input[name="prompt"]',
|
|
24
|
+
sendBtn: 'button[type="submit"]',
|
|
25
|
+
responseContainer: '.prose, .markdown, .break-words, [class*="message"]'
|
|
26
|
+
};
|
|
27
|
+
} else {
|
|
28
|
+
PLATFORM = {
|
|
29
|
+
name: "ChatGPT",
|
|
30
|
+
inputBox: '#prompt-textarea',
|
|
31
|
+
sendBtn: '[data-testid="send-button"], button[data-testid*="send"]',
|
|
32
|
+
responseContainer: '.markdown, .prose'
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const spoofScript = document.createElement('script');
|
|
37
|
+
spoofScript.src = chrome.runtime.getURL('spoof.js');
|
|
38
|
+
(document.head || document.documentElement).appendChild(spoofScript);
|
|
39
|
+
spoofScript.onload = function () { this.remove(); };
|
|
40
|
+
|
|
41
|
+
const SYSTEM_PROMPT = `You are an elite, autonomous AI software engineer (similar to Antigravity or Devin) connected to a local execution bridge.
|
|
42
|
+
You do NOT have direct access to my file system. You interact with it ONLY by emitting the XML tool tags defined below, which are executed locally on my machine.
|
|
43
|
+
|
|
44
|
+
═══════════════════════════════════════
|
|
45
|
+
GOLDEN RULES (violating any of these breaks the session)
|
|
46
|
+
═══════════════════════════════════════
|
|
47
|
+
1. STOP AFTER EVERY TOOL CALL. The moment you emit one or more tool tags, END YOUR TURN. Do not write "Result:", do not guess what the output will be, do not continue coding as if you already have it. A human/script will run the tool and give you the real output in the next message. Generating fake tool output is the single worst failure mode — never do it.
|
|
48
|
+
2. NEVER claim a file was created, edited, or tested unless you emitted the corresponding tag THIS turn AND already received its result.
|
|
49
|
+
3. NEVER fabricate file contents, line numbers, search results, or terminal output. If you haven't seen it via a tool result in this conversation, you don't know it.
|
|
50
|
+
4. Explore before you edit. Default order: view_dir → search_code → read_lines → patch/write. Do not read a full file "just in case" — read only what your search told you to read.
|
|
51
|
+
5. Prefer patch over write. Only use write for brand-new files or a full intentional rewrite. patch requires the <search> block to match the existing file EXACTLY (same whitespace/indentation) — read_lines first if you're not certain of the exact text.
|
|
52
|
+
6. Terminals have no memory of directory state between calls. Every terminal_run/terminal_bg must include any necessary cd, chained with &&.
|
|
53
|
+
7. **terminal_run is FULLY BLOCKING and HARD-CAPPED at 300s.** Under the hood it runs synchronously and only returns once the process exits completely — there is no streaming, no partial progress, nothing, until it's done or the 300s timeout fires. If it times out, you get a forced error containing only whatever STDOUT/STDERR happened to be captured before the kill — this is NOT a reliable signal of success or failure, just a fragment.
|
|
54
|
+
Because of this, terminal_run must NEVER be used for anything that installs dependencies, builds, scaffolds a project, or could plausibly run past a few seconds — e.g. npm/yarn/pnpm install, pip install, create-next-app, docker build, webpack/vite build, test suites with network calls, git clone of large repos, etc.
|
|
55
|
+
For ALL such commands: start with terminal_bg (returns a PID instantly, non-blocking), then poll with cron_monitor (delay 20-30s) — repeat cron_monitor while status is still "running." Only treat the command as done when a poll returns a completed/exited status.
|
|
56
|
+
If you ever see a terminal_run timeout error, do NOT re-run the same command with terminal_run again — restart it via terminal_bg instead.
|
|
57
|
+
terminal_run is only appropriate for short, fast, deterministic commands you're confident finish in a few seconds (e.g. ls, cat, a single quick lint check, git status).
|
|
58
|
+
8. If a result is [TRUNCATED] or a file is large, narrow down with search_code or read_lines instead of re-requesting the whole file.
|
|
59
|
+
|
|
60
|
+
═══════════════════════════════════════
|
|
61
|
+
WORKFLOW: "Autonomous Researcher"
|
|
62
|
+
═══════════════════════════════════════
|
|
63
|
+
1. **Explore:** view_dir to understand project structure.
|
|
64
|
+
2. **Search:** search_code to grep for exactly which file/line defines a function or variable.
|
|
65
|
+
3. **Inspect:** read_lines to read only the relevant chunk.
|
|
66
|
+
4. **Modify:** patch to surgically replace targeted code. write only for brand-new files.
|
|
67
|
+
5. **Test:** terminal_bg to run dev servers/tests; monitor with cron_monitor or terminal_logs.
|
|
68
|
+
|
|
69
|
+
═══════════════════════════════════════
|
|
70
|
+
AVAILABLE TOOLS
|
|
71
|
+
═══════════════════════════════════════
|
|
72
|
+
CRITICAL FORMAT RULE: Every individual tool call is wrapped in its OWN \`\`\`tool ... \`\`\` fence. If you issue multiple tool calls in one turn, output multiple separate \`\`\`tool\`\`\` blocks back to back — never combine two tags inside one fence.
|
|
73
|
+
|
|
74
|
+
| Tool | Format | Description |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| **View Dir** | \`\`\`tool\n<tool='view_dir'>src/components</tool>\n\`\`\` | Lists files. Leave empty for root. |
|
|
77
|
+
| **Search Code** | \`\`\`tool\n<tool='search_code' query='functionName'>src</tool>\n\`\`\` | Fast regex/string search. Returns file & line numbers. |
|
|
78
|
+
| **Read Lines** | \`\`\`tool\n<tool='read_lines' path='App.js' start='20' end='45'></tool>\n\`\`\` | Reads a specific chunk of a file. |
|
|
79
|
+
| **Read File** | \`\`\`tool\n<tool='read'>path/to/file.py</tool>\n\`\`\` | Reads a full file (avoid if file is large — prefer search_code + read_lines). |
|
|
80
|
+
| **Write File** | \`\`\`tool\n<tool='write' path='path.js'>\nRAW CODE\n</tool>\n\`\`\` | Creates/overwrites a file completely. |
|
|
81
|
+
| **Patch Code** | \`\`\`tool\n<tool='patch' path='main.py'>\n<search>\nOLD\n</search>\n<replace>\nNEW\n</replace>\n</tool>\n\`\`\` | Surgically replaces a block. <search> must match the file byte-for-byte. |
|
|
82
|
+
| **Run Terminal** | \`\`\`tool\n<tool='terminal_run'>npm test</tool>\n\`\`\` | BLOCKING, synchronous, hard 300s timeout. Only for short/fast commands (see Rule 7). |
|
|
83
|
+
| **Background Term**| \`\`\`tool\n<tool='terminal_bg'>npm run dev</tool>\n\`\`\` | Non-blocking. Returns a PID instantly. Required for installs/builds/servers. |
|
|
84
|
+
| **Cron Monitor** | \`\`\`tool\n<tool='cron_monitor' pid='1234' delay='15'></tool>\n\`\`\` | Sleeps X seconds, then returns the background PID's logs. Poll repeatedly until status is no longer "running." |
|
|
85
|
+
| **Term Logs** | \`\`\`tool\n<tool='terminal_logs' pid='1234'></tool>\n\`\`\` | Reads output of a background PID instantly. |
|
|
86
|
+
| **Kill Term** | \`\`\`tool\n<tool='terminal_kill' pid='1234'></tool>\n\`\`\` | Stops a background PID. |
|
|
87
|
+
|
|
88
|
+
═══════════════════════════════════════
|
|
89
|
+
WORKED EXAMPLES
|
|
90
|
+
═══════════════════════════════════════
|
|
91
|
+
|
|
92
|
+
--- Example A: code fix (stop-and-wait pattern) ---
|
|
93
|
+
User: "Fix the off-by-one in the pagination helper."
|
|
94
|
+
|
|
95
|
+
Your turn:
|
|
96
|
+
I'll locate the pagination logic first.
|
|
97
|
+
\`\`\`tool
|
|
98
|
+
<tool='search_code' query='pagination'>src</tool>
|
|
99
|
+
\`\`\`
|
|
100
|
+
[END TURN — wait for result]
|
|
101
|
+
|
|
102
|
+
Next turn (after receiving real results):
|
|
103
|
+
Found it in src/utils/paginate.js:14.
|
|
104
|
+
\`\`\`tool
|
|
105
|
+
<tool='read_lines' path='src/utils/paginate.js' start='1' end='30'></tool>
|
|
106
|
+
\`\`\`
|
|
107
|
+
[END TURN — wait for result]
|
|
108
|
+
|
|
109
|
+
Next turn (after receiving the real file contents):
|
|
110
|
+
\`\`\`tool
|
|
111
|
+
<tool='patch' path='src/utils/paginate.js'>
|
|
112
|
+
<search>
|
|
113
|
+
return start + pageSize;
|
|
114
|
+
</search>
|
|
115
|
+
<replace>
|
|
116
|
+
return start + pageSize - 1;
|
|
117
|
+
</replace>
|
|
118
|
+
</tool>
|
|
119
|
+
\`\`\`
|
|
120
|
+
[END TURN — wait for result. Only say "Fixed" after this result confirms success.]
|
|
121
|
+
|
|
122
|
+
--- Example B: long-running install (bg + poll pattern) ---
|
|
123
|
+
User: "Scaffold a new Next.js app called 'dashboard'."
|
|
124
|
+
|
|
125
|
+
Your turn:
|
|
126
|
+
This will download packages, so I'll run it in the background.
|
|
127
|
+
\`\`\`tool
|
|
128
|
+
<tool='terminal_bg'>npx create-next-app@latest dashboard --yes</tool>
|
|
129
|
+
\`\`\`
|
|
130
|
+
[END TURN — wait for PID]
|
|
131
|
+
|
|
132
|
+
Next turn (after receiving PID, e.g. 4821):
|
|
133
|
+
\`\`\`tool
|
|
134
|
+
<tool='cron_monitor' pid='4821' delay='25'></tool>
|
|
135
|
+
\`\`\`
|
|
136
|
+
[END TURN — wait for result]
|
|
137
|
+
|
|
138
|
+
Next turn: if status is still "running," issue cron_monitor again. Only once the log shows completion do you proceed or report success — never use terminal_run for this command, and never assume it finished just because time has passed.
|
|
139
|
+
|
|
140
|
+
═══════════════════════════════════════
|
|
141
|
+
COMPLETION
|
|
142
|
+
═══════════════════════════════════════
|
|
143
|
+
When the task is fully done and you have real tool results confirming every change, reply in plain text summarizing exactly what changed (files touched, what was added/removed/fixed, commands run and their confirmed final status) — no more and no less than what you actually executed and verified this session.
|
|
144
|
+
|
|
145
|
+
To confirm you understand these instructions, reply ONLY with: "Agent Initialized. Awaiting command."`;
|
|
146
|
+
|
|
147
|
+
let messageCount = 0;
|
|
148
|
+
let injectN = 5;
|
|
149
|
+
|
|
150
|
+
chrome.storage.local.get(['injectN'], (res) => {
|
|
151
|
+
if (res.injectN !== undefined) injectN = parseInt(res.injectN, 10);
|
|
152
|
+
});
|
|
153
|
+
chrome.storage.onChanged.addListener((changes, namespace) => {
|
|
154
|
+
if (namespace === 'local' && changes.injectN) {
|
|
155
|
+
injectN = parseInt(changes.injectN.newValue, 10);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|
160
|
+
sendResponse({ status: "received" });
|
|
161
|
+
if (request.action === "INITIALIZE_AGENT") {
|
|
162
|
+
messageCount = 0;
|
|
163
|
+
startNewChat()
|
|
164
|
+
.then(() => injectAndSend(SYSTEM_PROMPT))
|
|
165
|
+
.catch(err => console.error("Initialization sequence failed:", err));
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
async function startNewChat() {
|
|
171
|
+
if (PLATFORM.name === "HuggingFace") {
|
|
172
|
+
return Promise.resolve();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (PLATFORM.name === "Gemini") {
|
|
176
|
+
const elements = document.querySelectorAll('span, div, a');
|
|
177
|
+
for (let el of elements) {
|
|
178
|
+
if (el.textContent.trim().toLowerCase() === 'new chat') {
|
|
179
|
+
el.closest('button, a, [role="button"]')?.click();
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else if (PLATFORM.name === "Claude") {
|
|
184
|
+
const newChatBtn = document.querySelector('a[href="/new"], a[href="/chat/new"]');
|
|
185
|
+
if (newChatBtn) newChatBtn.click();
|
|
186
|
+
} else {
|
|
187
|
+
const newChatBtn = document.querySelector('a[href="/"], [data-testid="new-chat-button"]');
|
|
188
|
+
if (newChatBtn) newChatBtn.click();
|
|
189
|
+
}
|
|
190
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function injectAndSend(promptText) {
|
|
194
|
+
let inputBox = null;
|
|
195
|
+
for (let i = 0; i < 30; i++) {
|
|
196
|
+
inputBox = document.querySelector(PLATFORM.inputBox);
|
|
197
|
+
if (inputBox) break;
|
|
198
|
+
await new Promise(r => setTimeout(r, 200));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!inputBox) return;
|
|
202
|
+
inputBox.focus();
|
|
203
|
+
|
|
204
|
+
if (inputBox.tagName === 'INPUT' || inputBox.tagName === 'TEXTAREA') {
|
|
205
|
+
inputBox.value = promptText;
|
|
206
|
+
} else {
|
|
207
|
+
const dataTransfer = new DataTransfer();
|
|
208
|
+
dataTransfer.setData('text/plain', promptText);
|
|
209
|
+
const pasteEvent = new ClipboardEvent('paste', {
|
|
210
|
+
clipboardData: dataTransfer,
|
|
211
|
+
bubbles: true,
|
|
212
|
+
cancelable: true
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
inputBox.dispatchEvent(pasteEvent);
|
|
216
|
+
|
|
217
|
+
if (inputBox.textContent.trim() === "") {
|
|
218
|
+
document.execCommand('insertText', false, promptText);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
inputBox.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
223
|
+
inputBox.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
|
224
|
+
|
|
225
|
+
await new Promise(r => setTimeout(r, 800));
|
|
226
|
+
|
|
227
|
+
const sendButton = document.querySelector(PLATFORM.sendBtn);
|
|
228
|
+
if (sendButton && (!sendButton.disabled && sendButton.getAttribute('aria-disabled') !== 'true')) {
|
|
229
|
+
sendButton.click();
|
|
230
|
+
} else {
|
|
231
|
+
inputBox.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter' }));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- NEW: Robust Text Stability Engine ---
|
|
236
|
+
let isWaitingForLLM = false;
|
|
237
|
+
let lastProcessedMessage = "";
|
|
238
|
+
let trackInterval = null;
|
|
239
|
+
|
|
240
|
+
const messageQueue = [];
|
|
241
|
+
let isInjectingQueue = false;
|
|
242
|
+
|
|
243
|
+
function processQueue() {
|
|
244
|
+
if (messageQueue.length === 0 || isInjectingQueue || isWaitingForLLM) return;
|
|
245
|
+
|
|
246
|
+
let inputBox = document.querySelector(PLATFORM.inputBox);
|
|
247
|
+
if (!inputBox) return;
|
|
248
|
+
|
|
249
|
+
// Do not interrupt the user if they are currently typing a message
|
|
250
|
+
const userText = inputBox.tagName === 'INPUT' || inputBox.tagName === 'TEXTAREA' ? inputBox.value : inputBox.textContent;
|
|
251
|
+
if (userText && userText.trim() !== "") return;
|
|
252
|
+
|
|
253
|
+
// Do not inject if the LLM is currently generating (Send button is missing or disabled)
|
|
254
|
+
const sendButton = document.querySelector(PLATFORM.sendBtn);
|
|
255
|
+
if (!sendButton || sendButton.disabled || sendButton.getAttribute('aria-disabled') === 'true') {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const nextMessage = messageQueue.shift();
|
|
260
|
+
isInjectingQueue = true;
|
|
261
|
+
|
|
262
|
+
injectAndSend(nextMessage).then(() => {
|
|
263
|
+
isInjectingQueue = false;
|
|
264
|
+
}).catch((err) => {
|
|
265
|
+
console.error("Queue injection failed", err);
|
|
266
|
+
isInjectingQueue = false;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Primary observer loop: checks every 1 second
|
|
271
|
+
setInterval(() => {
|
|
272
|
+
processQueue();
|
|
273
|
+
|
|
274
|
+
if (isWaitingForLLM) return;
|
|
275
|
+
|
|
276
|
+
const allContainers = document.querySelectorAll(PLATFORM.responseContainer);
|
|
277
|
+
// Filter out hidden carousel messages
|
|
278
|
+
const containers = Array.from(allContainers).filter(el => el.offsetWidth > 0 && el.offsetHeight > 0);
|
|
279
|
+
if (containers.length === 0) return;
|
|
280
|
+
|
|
281
|
+
const lastContainer = containers[containers.length - 1];
|
|
282
|
+
if (lastContainer.hasAttribute('data-agent-processed')) return;
|
|
283
|
+
|
|
284
|
+
// Grab the very last message in the chat using textContent to avoid layout thrashing
|
|
285
|
+
const currentText = lastContainer.textContent || "";
|
|
286
|
+
|
|
287
|
+
// If the LLM has generated a closing tool tag that we haven't processed yet, lock it in and wait for it to finish typing
|
|
288
|
+
if (currentText.includes("</tool>")) {
|
|
289
|
+
isWaitingForLLM = true;
|
|
290
|
+
trackResponse(currentText);
|
|
291
|
+
}
|
|
292
|
+
}, 1000);
|
|
293
|
+
|
|
294
|
+
function trackResponse(initialText) {
|
|
295
|
+
let previousText = initialText;
|
|
296
|
+
let unchangedTicks = 0;
|
|
297
|
+
|
|
298
|
+
if (trackInterval) clearInterval(trackInterval);
|
|
299
|
+
|
|
300
|
+
// Sub-loop: monitors text generation speed
|
|
301
|
+
trackInterval = setInterval(async () => {
|
|
302
|
+
const allContainers = document.querySelectorAll(PLATFORM.responseContainer);
|
|
303
|
+
const containers = Array.from(allContainers).filter(el => el.offsetWidth > 0 && el.offsetHeight > 0);
|
|
304
|
+
const lastContainer = containers.length > 0 ? containers[containers.length - 1] : null;
|
|
305
|
+
if (!lastContainer) return;
|
|
306
|
+
|
|
307
|
+
const currentText = lastContainer.textContent || "";
|
|
308
|
+
|
|
309
|
+
// Check if the LLM is still typing
|
|
310
|
+
if (currentText.length > previousText.length) {
|
|
311
|
+
previousText = currentText;
|
|
312
|
+
unchangedTicks = 0;
|
|
313
|
+
} else {
|
|
314
|
+
unchangedTicks++;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// If text has not changed for 2.5 seconds (5 ticks), assume generation is complete!
|
|
318
|
+
if (unchangedTicks > 4) {
|
|
319
|
+
clearInterval(trackInterval);
|
|
320
|
+
lastContainer.setAttribute('data-agent-processed', 'true');
|
|
321
|
+
isWaitingForLLM = false; // UNLOCK IMMEDIATELY to prevent deadlocks from long-running tools
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
const toolMatches = currentText.match(/<tool=[\s\S]*?<\/tool>/g);
|
|
325
|
+
if (toolMatches && toolMatches.length > 0) {
|
|
326
|
+
try {
|
|
327
|
+
const toolPromises = toolMatches.map(async (toolCall) => {
|
|
328
|
+
const response = await fetch(`${LOCAL_SERVER}/extension/run-tool`, {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: { 'Content-Type': 'application/json' },
|
|
331
|
+
body: JSON.stringify({ tool_call: toolCall })
|
|
332
|
+
});
|
|
333
|
+
const resultData = await response.json();
|
|
334
|
+
|
|
335
|
+
const toolNameMatch = toolCall.match(/<tool='(.*?)'/);
|
|
336
|
+
const toolName = toolNameMatch ? toolNameMatch[1] : 'unknown';
|
|
337
|
+
|
|
338
|
+
return `\n[System - Tool Execution: ${toolName}]\nStatus: Success\n------------------------------------\nResult Output:\n${resultData.output}\n------------------------------------\n`;
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const results = await Promise.all(toolPromises);
|
|
342
|
+
let combinedFeedback = results.join("") + "Instruction: Review the output above. If the task requires more steps, continue. If finished, summarize what was done.";
|
|
343
|
+
|
|
344
|
+
messageCount++;
|
|
345
|
+
if (injectN > 0 && messageCount % injectN === 0) {
|
|
346
|
+
combinedFeedback += `\n\n[System Reminder (Context Refresh)]:\n${SYSTEM_PROMPT}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
messageQueue.push(combinedFeedback);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
messageQueue.push(`\n[System - Error]: Tool execution failed. Ensure your Python backend is running.`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}, 500);
|
|
356
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "Local Agent Bridge",
|
|
4
|
+
"version": "2.0",
|
|
5
|
+
"description": "Autonomous AI Code Execution Bridge",
|
|
6
|
+
"permissions": [
|
|
7
|
+
"activeTab",
|
|
8
|
+
"storage"
|
|
9
|
+
],
|
|
10
|
+
"content_scripts": [
|
|
11
|
+
{
|
|
12
|
+
"matches": [
|
|
13
|
+
"*://*.chatgpt.com/*",
|
|
14
|
+
"*://gemini.google.com/*",
|
|
15
|
+
"*://*.claude.ai/*",
|
|
16
|
+
"*://*.huggingface.co/*"
|
|
17
|
+
],
|
|
18
|
+
"js": ["content.js"]
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"action": {
|
|
22
|
+
"default_popup": "popup.html"
|
|
23
|
+
},
|
|
24
|
+
"web_accessible_resources": [
|
|
25
|
+
{
|
|
26
|
+
"resources": ["spoof.js"],
|
|
27
|
+
"matches": ["<all_urls>"]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<style>
|
|
6
|
+
body {
|
|
7
|
+
font-family: 'Segoe UI', sans-serif;
|
|
8
|
+
width: 350px;
|
|
9
|
+
padding: 15px;
|
|
10
|
+
margin: 0;
|
|
11
|
+
background-color: #0f172a;
|
|
12
|
+
color: #f8fafc;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
h3 {
|
|
16
|
+
margin-top: 0;
|
|
17
|
+
font-size: 16px;
|
|
18
|
+
border-bottom: 1px solid #334155;
|
|
19
|
+
padding-bottom: 5px;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
label {
|
|
23
|
+
font-size: 12px;
|
|
24
|
+
font-weight: bold;
|
|
25
|
+
color: #94a3b8;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
input {
|
|
29
|
+
width: 100%;
|
|
30
|
+
box-sizing: border-box;
|
|
31
|
+
padding: 8px;
|
|
32
|
+
margin: 8px 0;
|
|
33
|
+
background: #1e293b;
|
|
34
|
+
border: 1px solid #475569;
|
|
35
|
+
color: white;
|
|
36
|
+
border-radius: 4px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
button {
|
|
40
|
+
width: 100%;
|
|
41
|
+
padding: 10px;
|
|
42
|
+
font-weight: bold;
|
|
43
|
+
border: none;
|
|
44
|
+
border-radius: 5px;
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
transition: 0.2s;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#save-btn {
|
|
50
|
+
background-color: #3b82f6;
|
|
51
|
+
color: white;
|
|
52
|
+
margin-bottom: 15px;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#init-btn {
|
|
56
|
+
background-color: #10b981;
|
|
57
|
+
color: white;
|
|
58
|
+
margin-bottom: 15px;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.dashboard {
|
|
62
|
+
background: #1e293b;
|
|
63
|
+
padding: 10px;
|
|
64
|
+
border-radius: 5px;
|
|
65
|
+
font-size: 12px;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.process-card {
|
|
69
|
+
background: #000;
|
|
70
|
+
border: 1px solid #334155;
|
|
71
|
+
padding: 8px;
|
|
72
|
+
margin-top: 8px;
|
|
73
|
+
border-radius: 4px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.pid {
|
|
77
|
+
color: #f59e0b;
|
|
78
|
+
font-weight: bold;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.log-preview {
|
|
82
|
+
font-family: monospace;
|
|
83
|
+
color: #a3e635;
|
|
84
|
+
font-size: 10px;
|
|
85
|
+
white-space: pre-wrap;
|
|
86
|
+
overflow-x: hidden;
|
|
87
|
+
margin-top: 5px;
|
|
88
|
+
}
|
|
89
|
+
</style>
|
|
90
|
+
</head>
|
|
91
|
+
|
|
92
|
+
<body>
|
|
93
|
+
<h3>Agent Dashboard</h3>
|
|
94
|
+
<label>Workspace Directory:</label>
|
|
95
|
+
<input type="text" id="dir-input" placeholder="C:\Projects\app">
|
|
96
|
+
<label>Inject Prompt Every N Messages (0 to disable):</label>
|
|
97
|
+
<input type="number" id="inject-n-input" placeholder="5" value="5" min="0">
|
|
98
|
+
<button id="save-btn">Save Directory</button>
|
|
99
|
+
<button id="init-btn">Initialize Agent in Chat</button>
|
|
100
|
+
|
|
101
|
+
<h3>Live Terminals</h3>
|
|
102
|
+
<div class="dashboard" id="process-list">
|
|
103
|
+
<div style="color: #94a3b8;">No active background processes...</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<script src="popup.js"></script>
|
|
107
|
+
</body>
|
|
108
|
+
|
|
109
|
+
</html>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2
|
+
const dirInput = document.getElementById('dir-input');
|
|
3
|
+
const injectNInput = document.getElementById('inject-n-input');
|
|
4
|
+
const saveBtn = document.getElementById('save-btn');
|
|
5
|
+
const initBtn = document.getElementById('init-btn');
|
|
6
|
+
const processList = document.getElementById('process-list');
|
|
7
|
+
|
|
8
|
+
chrome.storage.local.get(['workspaceDir', 'injectN'], (result) => {
|
|
9
|
+
if (result.workspaceDir) {
|
|
10
|
+
dirInput.value = result.workspaceDir;
|
|
11
|
+
syncWithServer(result.workspaceDir);
|
|
12
|
+
}
|
|
13
|
+
if (result.injectN !== undefined && injectNInput) {
|
|
14
|
+
injectNInput.value = result.injectN;
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
saveBtn.addEventListener('click', () => {
|
|
19
|
+
const path = dirInput.value.trim();
|
|
20
|
+
let injectN = 5;
|
|
21
|
+
if (injectNInput) {
|
|
22
|
+
injectN = parseInt(injectNInput.value, 10);
|
|
23
|
+
if (isNaN(injectN)) injectN = 5;
|
|
24
|
+
}
|
|
25
|
+
chrome.storage.local.set({ workspaceDir: path, injectN: injectN });
|
|
26
|
+
syncWithServer(path);
|
|
27
|
+
saveBtn.innerText = '✅ Saved!';
|
|
28
|
+
setTimeout(() => saveBtn.innerText = 'Save Directory', 2000);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
initBtn.addEventListener('click', () => {
|
|
32
|
+
initBtn.innerText = 'Initializing...';
|
|
33
|
+
chrome.tabs.query({active: true, currentWindow: true}, (tabs) => {
|
|
34
|
+
const activeTab = tabs[0];
|
|
35
|
+
const validUrls = ['chatgpt.com', 'gemini.google.com', 'claude.ai', 'huggingface.co'];
|
|
36
|
+
|
|
37
|
+
if (activeTab && validUrls.some(url => activeTab.url.includes(url))) {
|
|
38
|
+
chrome.tabs.sendMessage(activeTab.id, { action: "INITIALIZE_AGENT" }, () => {
|
|
39
|
+
if (chrome.runtime.lastError) {
|
|
40
|
+
initBtn.innerText = '❌ Error: Refresh Tab First!';
|
|
41
|
+
setTimeout(() => initBtn.innerText = '✨ Initialize Agent in Chat', 3000);
|
|
42
|
+
} else {
|
|
43
|
+
initBtn.innerText = '✨ Initialize Agent in Chat';
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
initBtn.innerText = '❌ Not a valid AI chat tab';
|
|
48
|
+
setTimeout(() => initBtn.innerText = '✨ Initialize Agent in Chat', 3000);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function saveWorkspace(path) {
|
|
54
|
+
fetch('http://127.0.0.1:5505/set-workspace', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({ path: path })
|
|
58
|
+
}).catch(() => {
|
|
59
|
+
// Silently ignore connection errors so Chrome doesn't flag them in the extension dashboard
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function updateStatus() {
|
|
64
|
+
fetch('http://127.0.0.1:5505/status')
|
|
65
|
+
.then(res => res.json())
|
|
66
|
+
.then(data => {
|
|
67
|
+
if (!data.processes || data.processes.length === 0) {
|
|
68
|
+
processList.innerHTML = '<div style="color: #94a3b8;">No active background processes...</div>';
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let html = '';
|
|
73
|
+
data.processes.forEach(proc => {
|
|
74
|
+
const statusColor = proc.status === 'running' ? '#10b981' : '#ef4444';
|
|
75
|
+
html += `
|
|
76
|
+
<div class="process-card">
|
|
77
|
+
<div><span class="pid">[PID: ${proc.pid}]</span> - <span style="color:${statusColor}">${proc.status.toUpperCase()}</span></div>
|
|
78
|
+
<div class="log-preview">${proc.logs || 'Waiting for output...'}</div>
|
|
79
|
+
</div>`;
|
|
80
|
+
});
|
|
81
|
+
processList.innerHTML = html;
|
|
82
|
+
})
|
|
83
|
+
.catch(() => {
|
|
84
|
+
processList.innerHTML = '<div style="color: #ef4444;">Disconnected from local server.</div>';
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setInterval(pollStatus, 1500);
|
|
89
|
+
pollStatus();
|
|
90
|
+
});
|
browse_code/server.py
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
import difflib
|
|
5
|
+
import threading
|
|
6
|
+
import uuid
|
|
7
|
+
import atexit
|
|
8
|
+
import asyncio
|
|
9
|
+
import shutil
|
|
10
|
+
import time
|
|
11
|
+
import platform
|
|
12
|
+
from collections import deque
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
15
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
@asynccontextmanager
|
|
19
|
+
async def lifespan(app: FastAPI):
|
|
20
|
+
print_startup_banner(WORKSPACE_DIR, "127.0.0.1", 5505)
|
|
21
|
+
yield
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
app = FastAPI(title="Agent Bridge Backend", lifespan=lifespan)
|
|
25
|
+
|
|
26
|
+
app.add_middleware(
|
|
27
|
+
CORSMiddleware,
|
|
28
|
+
allow_origins=["*"],
|
|
29
|
+
allow_credentials=True,
|
|
30
|
+
allow_methods=["*"],
|
|
31
|
+
allow_headers=["*"],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# ============================================================
|
|
35
|
+
# Shared ANSI palette — used by the diff engine, tool banners,
|
|
36
|
+
# request logger, and startup banner.
|
|
37
|
+
# ============================================================
|
|
38
|
+
C_RESET = "\033[0m"
|
|
39
|
+
C_BOLD = "\033[1m"
|
|
40
|
+
C_DIM = "\033[2;90m"
|
|
41
|
+
C_GRAY = "\033[90m"
|
|
42
|
+
C_MUTED = "\033[38;5;245m"
|
|
43
|
+
C_HEADER = "\033[1;32m"
|
|
44
|
+
C_GREEN = "\033[32m"
|
|
45
|
+
|
|
46
|
+
C_OK = "\033[1;32m" # success / 2xx
|
|
47
|
+
C_ERR = "\033[1;31m" # error / 5xx
|
|
48
|
+
C_WARN = "\033[1;33m" # warn / 4xx
|
|
49
|
+
C_INFO = "\033[1;34m" # info
|
|
50
|
+
|
|
51
|
+
C_ADD_FG = "\033[38;5;150m"
|
|
52
|
+
C_ADD_BG = "\033[48;2;0;40;0m"
|
|
53
|
+
C_ADD_MARK = "\033[1;38;5;114m\033[48;2;0;40;0m"
|
|
54
|
+
C_ADD_NUM = "\033[32m"
|
|
55
|
+
C_DEL_FG = "\033[38;5;210m"
|
|
56
|
+
C_DEL_BG = "\033[48;2;45;0;0m"
|
|
57
|
+
C_DEL_MARK = "\033[1;38;5;203m\033[48;2;45;0;0m"
|
|
58
|
+
C_DEL_NUM = "\033[31m"
|
|
59
|
+
|
|
60
|
+
# icon + friendly label per tool tag, used by the tool-call banner
|
|
61
|
+
TOOL_META = {
|
|
62
|
+
"view_dir": ("📁", "View Directory"),
|
|
63
|
+
"read": ("📄", "Read File"),
|
|
64
|
+
"read_lines": ("📄", "Read Lines"),
|
|
65
|
+
"write": ("✍️ ", "Write File"),
|
|
66
|
+
"patch": ("🩹", "Patch File"),
|
|
67
|
+
"search_code": ("🔍", "Search Code"),
|
|
68
|
+
"terminal_run": ("💻", "Run Command"),
|
|
69
|
+
"terminal_bg": ("⚙️ ", "Background Process"),
|
|
70
|
+
"terminal_logs": ("📜", "Process Logs"),
|
|
71
|
+
"terminal_kill": ("✋", "Kill Process"),
|
|
72
|
+
"cron_monitor": ("⏰", "Monitor Process"),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def print_tool_start(tool_name: str, detail: str = ""):
|
|
77
|
+
"""Print a compact, colored banner when a tool call begins."""
|
|
78
|
+
icon, label = TOOL_META.get(tool_name, ("🔧", tool_name or "Unknown Tool"))
|
|
79
|
+
line = f"\n{C_HEADER}{icon} {label}{C_RESET}"
|
|
80
|
+
if detail:
|
|
81
|
+
detail = detail.strip().replace("\n", " ⏎ ")
|
|
82
|
+
if len(detail) > 90:
|
|
83
|
+
detail = detail[:89] + "…"
|
|
84
|
+
line += f" {C_DIM}{detail}{C_RESET}"
|
|
85
|
+
print(line)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def print_tool_end(ok: bool, elapsed_ms: float, note: str = ""):
|
|
89
|
+
"""Print a compact status line when a tool call finishes."""
|
|
90
|
+
if ok:
|
|
91
|
+
badge = f"{C_OK}✓ done{C_RESET}"
|
|
92
|
+
else:
|
|
93
|
+
badge = f"{C_ERR}✗ failed{C_RESET}"
|
|
94
|
+
timing = f"{C_DIM}{elapsed_ms:.0f}ms{C_RESET}"
|
|
95
|
+
line = f" {badge} {timing}"
|
|
96
|
+
if note:
|
|
97
|
+
note = note.strip().replace("\n", " ⏎ ")
|
|
98
|
+
if len(note) > 90:
|
|
99
|
+
note = note[:89] + "…"
|
|
100
|
+
line += f" {C_MUTED}{note}{C_RESET}"
|
|
101
|
+
print(line)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def print_startup_banner(workspace: str, host: str, port: int):
|
|
105
|
+
width = 78
|
|
106
|
+
title = " Agent Bridge Backend "
|
|
107
|
+
pad = width - len(title)
|
|
108
|
+
left, right = pad // 2, pad - pad // 2
|
|
109
|
+
print()
|
|
110
|
+
print(f"{C_HEADER}╭{'─' * left}{title}{'─' * right}╮{C_RESET}")
|
|
111
|
+
ws_text = f" Workspace {workspace}"
|
|
112
|
+
ws_pad = max(0, width - len(ws_text))
|
|
113
|
+
print(f"{C_HEADER}│{C_RESET} {C_MUTED}Workspace{C_RESET} {workspace}{' ' * ws_pad}{C_HEADER}│{C_RESET}")
|
|
114
|
+
ep_text = f" Endpoint http://{host}:{port}"
|
|
115
|
+
ep_pad = max(0, width - len(ep_text))
|
|
116
|
+
print(f"{C_HEADER}│{C_RESET} {C_MUTED}Endpoint {C_RESET} http://{host}:{port}{' ' * ep_pad}{C_HEADER}│{C_RESET}")
|
|
117
|
+
print(f"{C_HEADER}╰{'─' * width}╯{C_RESET}")
|
|
118
|
+
print()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
WORKSPACE_DIR = r"C:\Users\dedeep vasireddy\Downloads\test"
|
|
122
|
+
BACKGROUND_PROCESSES = {}
|
|
123
|
+
|
|
124
|
+
def cleanup_background_processes():
|
|
125
|
+
print(f"\n{C_WARN}⏻ Shutting down — cleaning up background processes...{C_RESET}")
|
|
126
|
+
for pid, data in BACKGROUND_PROCESSES.items():
|
|
127
|
+
if data['status'] == 'running':
|
|
128
|
+
try:
|
|
129
|
+
data['process'].terminate()
|
|
130
|
+
except:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
atexit.register(cleanup_background_processes)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.middleware("http")
|
|
137
|
+
async def request_logger(request: Request, call_next):
|
|
138
|
+
"""Compact single-line request logger, replacing uvicorn's default access log."""
|
|
139
|
+
start = time.perf_counter()
|
|
140
|
+
response = await call_next(request)
|
|
141
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
142
|
+
|
|
143
|
+
status = response.status_code
|
|
144
|
+
if status < 300:
|
|
145
|
+
color = C_OK
|
|
146
|
+
elif status < 500:
|
|
147
|
+
color = C_WARN
|
|
148
|
+
else:
|
|
149
|
+
color = C_ERR
|
|
150
|
+
|
|
151
|
+
# The /extension/run-tool endpoint already prints its own detailed
|
|
152
|
+
# banner + result, so skip the generic line there to avoid duplication.
|
|
153
|
+
if request.url.path != "/extension/run-tool":
|
|
154
|
+
print(f"{C_DIM}→{C_RESET} {request.method} {request.url.path} "
|
|
155
|
+
f"{color}{status}{C_RESET} {C_DIM}{elapsed_ms:.0f}ms{C_RESET}")
|
|
156
|
+
|
|
157
|
+
return response
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ============================================================
|
|
161
|
+
# TUI Block Diff Engine (v3) — single-gutter, full-width highlight
|
|
162
|
+
# style, matching editor-style inline diffs (Claude Code / VS Code).
|
|
163
|
+
# ============================================================
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _terminal_width(default: int = 72, cap: int = 88) -> int:
|
|
167
|
+
# NOTE: this process's stdout is usually not a real TTY (it's a FastAPI
|
|
168
|
+
# server, often piped/redirected), so shutil.get_terminal_size() falls
|
|
169
|
+
# back to `default` rather than reflecting the actual viewer width. Keep
|
|
170
|
+
# `default` conservative — if it's too wide, the padded highlight bar
|
|
171
|
+
# wraps in the viewer and bleeds color onto the next visual line.
|
|
172
|
+
try:
|
|
173
|
+
size = shutil.get_terminal_size(fallback=(default, 20))
|
|
174
|
+
cols = size.columns
|
|
175
|
+
# A fallback tuple and a "real" detected size look identical here,
|
|
176
|
+
# so only trust an unusually small detected width; otherwise stay
|
|
177
|
+
# conservative rather than risk wrapping.
|
|
178
|
+
if cols <= 0:
|
|
179
|
+
cols = default
|
|
180
|
+
except Exception:
|
|
181
|
+
cols = default
|
|
182
|
+
return max(40, min(cols, cap, default))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _clip(text: str, max_len: int) -> str:
|
|
186
|
+
if max_len <= 1:
|
|
187
|
+
return text[:max_len]
|
|
188
|
+
if len(text) > max_len:
|
|
189
|
+
return text[: max_len - 1] + "…"
|
|
190
|
+
return text
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def print_terminal_diff(filepath: str, old_content: str, new_content: str, context_lines: int = 2,
|
|
194
|
+
large_line_threshold: int = 300, large_change_threshold: int = 120):
|
|
195
|
+
"""Render a single-gutter, full-width-highlight diff (Claude Code TUI style).
|
|
196
|
+
|
|
197
|
+
For large diffs, unchanged/context lines are suppressed entirely (only a
|
|
198
|
+
collapsed "N unchanged lines" marker is shown) to keep the output readable.
|
|
199
|
+
"""
|
|
200
|
+
old_lines = old_content.splitlines()
|
|
201
|
+
new_lines = new_content.splitlines()
|
|
202
|
+
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
|
|
203
|
+
opcodes = matcher.get_opcodes()
|
|
204
|
+
|
|
205
|
+
additions = sum(j2 - j1 for tag, i1, i2, j1, j2 in opcodes if tag in ("insert", "replace"))
|
|
206
|
+
deletions = sum(i2 - i1 for tag, i1, i2, j1, j2 in opcodes if tag in ("delete", "replace"))
|
|
207
|
+
is_new_file = old_content == "" and new_content != ""
|
|
208
|
+
|
|
209
|
+
# Large diffs: drop context lines entirely, show changed lines only.
|
|
210
|
+
total_lines = max(len(old_lines), len(new_lines))
|
|
211
|
+
total_changes = additions + deletions
|
|
212
|
+
if total_lines > large_line_threshold or total_changes > large_change_threshold:
|
|
213
|
+
context_lines = 0
|
|
214
|
+
|
|
215
|
+
width = _terminal_width()
|
|
216
|
+
num_width = max(len(str(max(len(old_lines), len(new_lines), 1))), 2)
|
|
217
|
+
content_width = max(10, width - num_width - 6) # " N m code" + safety margin
|
|
218
|
+
|
|
219
|
+
# ---- Header: "filename +N -N" -----------------------------
|
|
220
|
+
tag_label = " (new file)" if is_new_file else ""
|
|
221
|
+
print()
|
|
222
|
+
header = f"{C_HEADER}{filepath}{C_RESET}{C_DIM}{tag_label}{C_RESET}"
|
|
223
|
+
stats = f" {C_ADD_NUM}+{additions}{C_RESET} {C_DEL_NUM}-{deletions}{C_RESET}"
|
|
224
|
+
print(f"{header}{stats}")
|
|
225
|
+
print(f"{C_GRAY}{'─' * width}{C_RESET}")
|
|
226
|
+
|
|
227
|
+
if not opcodes or (len(opcodes) == 1 and opcodes[0][0] == "equal"):
|
|
228
|
+
print(f"{C_DIM} (no changes){C_RESET}\n")
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
def row(number, marker, text, bg="", fg="", mark_style=""):
|
|
232
|
+
num_s = f"{number:>{num_width}}" if number is not None else " " * num_width
|
|
233
|
+
body = _clip(text, content_width)
|
|
234
|
+
if bg:
|
|
235
|
+
# pad body so the background color fills the full row width
|
|
236
|
+
padded = body.ljust(content_width)
|
|
237
|
+
m = mark_style if mark_style else fg
|
|
238
|
+
print(f"{bg}{fg} {num_s} {m}{marker}{fg} {padded} {C_RESET}")
|
|
239
|
+
else:
|
|
240
|
+
print(f"{C_MUTED} {num_s} {C_GRAY}{marker}{C_RESET} {body}")
|
|
241
|
+
|
|
242
|
+
def gap_marker(hidden):
|
|
243
|
+
print(f"{C_DIM} ⋮ {hidden} unchanged line{'s' if hidden != 1 else ''}{C_RESET}")
|
|
244
|
+
|
|
245
|
+
new_ln = 1 # running "position in final file" counter, drives the single gutter
|
|
246
|
+
|
|
247
|
+
for tag, i1, i2, j1, j2 in opcodes:
|
|
248
|
+
if tag == "equal":
|
|
249
|
+
block_len = i2 - i1
|
|
250
|
+
if block_len == 0:
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
if context_lines <= 0:
|
|
254
|
+
# Large diff mode: skip unchanged lines entirely, just show the gap.
|
|
255
|
+
new_ln += block_len
|
|
256
|
+
gap_marker(block_len)
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
if block_len > context_lines * 2:
|
|
260
|
+
for k in range(context_lines):
|
|
261
|
+
row(new_ln, " ", old_lines[i1 + k])
|
|
262
|
+
new_ln += 1
|
|
263
|
+
hidden = block_len - context_lines * 2
|
|
264
|
+
new_ln += hidden # skip numbering for hidden lines
|
|
265
|
+
gap_marker(hidden)
|
|
266
|
+
for k in range(block_len - context_lines, block_len):
|
|
267
|
+
row(new_ln, " ", old_lines[i1 + k])
|
|
268
|
+
new_ln += 1
|
|
269
|
+
else:
|
|
270
|
+
for k in range(block_len):
|
|
271
|
+
row(new_ln, " ", old_lines[i1 + k])
|
|
272
|
+
new_ln += 1
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
if tag in ("delete", "replace"):
|
|
276
|
+
for i in range(i1, i2):
|
|
277
|
+
row(new_ln, "-", old_lines[i], bg=C_DEL_BG, fg=C_DEL_FG, mark_style=C_DEL_MARK)
|
|
278
|
+
|
|
279
|
+
if tag in ("insert", "replace"):
|
|
280
|
+
for j in range(j1, j2):
|
|
281
|
+
row(new_ln, "+", new_lines[j], bg=C_ADD_BG, fg=C_ADD_FG, mark_style=C_ADD_MARK)
|
|
282
|
+
new_ln += 1
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def strip_ansi(text: str) -> str:
|
|
286
|
+
ansi_escape = re.compile(r'\x1b\[([0-9]{1,3}(;[0-9]{1,3})*)?[m|K]')
|
|
287
|
+
return ansi_escape.sub('', text)
|
|
288
|
+
|
|
289
|
+
def truncate_output(text: str, max_chars=12000) -> str:
|
|
290
|
+
if len(text) > max_chars:
|
|
291
|
+
return text[:max_chars] + f"\n\n...[OUTPUT TRUNCATED]"
|
|
292
|
+
return text
|
|
293
|
+
|
|
294
|
+
def stream_process_output(pid: str, process: subprocess.Popen):
|
|
295
|
+
try:
|
|
296
|
+
for line in iter(process.stdout.readline, ''):
|
|
297
|
+
BACKGROUND_PROCESSES[pid]['logs'].append(line)
|
|
298
|
+
except Exception as e:
|
|
299
|
+
BACKGROUND_PROCESSES[pid]['logs'].append(f"\n[Stream Error: {str(e)}]\n")
|
|
300
|
+
finally:
|
|
301
|
+
process.stdout.close()
|
|
302
|
+
BACKGROUND_PROCESSES[pid]['status'] = 'exited'
|
|
303
|
+
|
|
304
|
+
def secure_path(path: str) -> str:
|
|
305
|
+
filepath = os.path.abspath(os.path.join(WORKSPACE_DIR, path))
|
|
306
|
+
if not filepath.startswith(os.path.abspath(WORKSPACE_DIR)):
|
|
307
|
+
raise HTTPException(status_code=400, detail="Security Error: Path traversal detected.")
|
|
308
|
+
return filepath
|
|
309
|
+
|
|
310
|
+
class WorkspaceModel(BaseModel):
|
|
311
|
+
path: str
|
|
312
|
+
|
|
313
|
+
class ToolModel(BaseModel):
|
|
314
|
+
tool_call: str
|
|
315
|
+
|
|
316
|
+
@app.post("/set-workspace")
|
|
317
|
+
async def set_workspace(data: WorkspaceModel):
|
|
318
|
+
global WORKSPACE_DIR
|
|
319
|
+
new_path = data.path.strip()
|
|
320
|
+
if new_path and os.path.exists(new_path):
|
|
321
|
+
WORKSPACE_DIR = new_path
|
|
322
|
+
print(f"\n[Workspace Updated]: {WORKSPACE_DIR}\n")
|
|
323
|
+
return {"status": "success", "workspace": WORKSPACE_DIR}
|
|
324
|
+
raise HTTPException(status_code=400, detail="Invalid workspace path")
|
|
325
|
+
|
|
326
|
+
@app.get("/status")
|
|
327
|
+
async def get_status():
|
|
328
|
+
active_procs = []
|
|
329
|
+
for pid, data in BACKGROUND_PROCESSES.items():
|
|
330
|
+
recent_logs = list(data['logs'])[-5:]
|
|
331
|
+
active_procs.append({
|
|
332
|
+
"pid": pid,
|
|
333
|
+
"status": data['status'],
|
|
334
|
+
"logs": strip_ansi("".join(recent_logs))
|
|
335
|
+
})
|
|
336
|
+
return {
|
|
337
|
+
"workspace": WORKSPACE_DIR,
|
|
338
|
+
"processes": active_procs
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
def _identify_tool(tool_raw: str):
|
|
342
|
+
"""Parse which tool tag was used and build a short human-readable detail string."""
|
|
343
|
+
m = re.search(r"<tool='view_dir'>(.*?)</tool>", tool_raw, re.DOTALL)
|
|
344
|
+
if m:
|
|
345
|
+
return "view_dir", (m.group(1).strip() or ".")
|
|
346
|
+
|
|
347
|
+
m = re.search(r"<tool='read'>(.*?)</tool>", tool_raw, re.DOTALL)
|
|
348
|
+
if m:
|
|
349
|
+
return "read", m.group(1).strip()
|
|
350
|
+
|
|
351
|
+
m = re.search(r"<tool='write'\s+path='(.*?)'>(.*?)</tool>", tool_raw, re.DOTALL)
|
|
352
|
+
if m:
|
|
353
|
+
return "write", m.group(1).strip()
|
|
354
|
+
|
|
355
|
+
m = re.search(r"<tool='patch'\s+path='(.*?)'>\s*<search>(.*?)</search>\s*<replace>(.*?)</replace>\s*</tool>", tool_raw, re.DOTALL)
|
|
356
|
+
if m:
|
|
357
|
+
return "patch", m.group(1).strip()
|
|
358
|
+
|
|
359
|
+
m = re.search(r"<tool='search_code'\s+query='(.*?)'>(.*?)</tool>", tool_raw, re.DOTALL)
|
|
360
|
+
if m:
|
|
361
|
+
return "search_code", f"\"{m.group(1).strip()}\""
|
|
362
|
+
|
|
363
|
+
m = re.search(r"<tool='read_lines'\s+path='(.*?)'\s+start='(\d+)'\s+end='(\d+)'></tool>", tool_raw, re.DOTALL)
|
|
364
|
+
if m:
|
|
365
|
+
return "read_lines", f"{m.group(1).strip()} L{m.group(2)}-{m.group(3)}"
|
|
366
|
+
|
|
367
|
+
m = re.search(r"<tool='terminal_run'>(.*?)</tool>", tool_raw, re.DOTALL)
|
|
368
|
+
if m:
|
|
369
|
+
return "terminal_run", m.group(1).strip()
|
|
370
|
+
|
|
371
|
+
m = re.search(r"<tool='terminal_bg'>(.*?)</tool>", tool_raw, re.DOTALL)
|
|
372
|
+
if m:
|
|
373
|
+
return "terminal_bg", m.group(1).strip()
|
|
374
|
+
|
|
375
|
+
m = re.search(r"<tool='terminal_logs'\s+pid='(.*?)'></tool>", tool_raw, re.DOTALL)
|
|
376
|
+
if m:
|
|
377
|
+
return "terminal_logs", f"pid={m.group(1).strip()}"
|
|
378
|
+
|
|
379
|
+
m = re.search(r"<tool='terminal_kill'\s+pid='(.*?)'></tool>", tool_raw, re.DOTALL)
|
|
380
|
+
if m:
|
|
381
|
+
return "terminal_kill", f"pid={m.group(1).strip()}"
|
|
382
|
+
|
|
383
|
+
m = re.search(r"<tool='cron_monitor'\s+pid='(.*?)'\s+delay='(\d+)'></tool>", tool_raw, re.DOTALL)
|
|
384
|
+
if m:
|
|
385
|
+
return "cron_monitor", f"pid={m.group(1).strip()} delay={m.group(2)}s"
|
|
386
|
+
|
|
387
|
+
return None, ""
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _short_preview(output: str, max_len: int = 90) -> str:
|
|
391
|
+
if not output:
|
|
392
|
+
return ""
|
|
393
|
+
first_line = str(output).splitlines()[0] if output else ""
|
|
394
|
+
if len(first_line) > max_len:
|
|
395
|
+
first_line = first_line[: max_len - 1] + "…"
|
|
396
|
+
return first_line
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@app.post("/extension/run-tool")
|
|
400
|
+
async def run_tool(data: ToolModel):
|
|
401
|
+
global WORKSPACE_DIR
|
|
402
|
+
tool_raw = data.tool_call
|
|
403
|
+
|
|
404
|
+
tool_name, detail = _identify_tool(tool_raw)
|
|
405
|
+
print_tool_start(tool_name, detail)
|
|
406
|
+
start = time.perf_counter()
|
|
407
|
+
|
|
408
|
+
view_match = re.search(r"<tool='view_dir'>(.*?)</tool>", tool_raw, re.DOTALL)
|
|
409
|
+
read_match = re.search(r"<tool='read'>(.*?)</tool>", tool_raw, re.DOTALL)
|
|
410
|
+
write_match = re.search(r"<tool='write'\s+path='(.*?)'>(.*?)</tool>", tool_raw, re.DOTALL)
|
|
411
|
+
patch_match = re.search(r"<tool='patch'\s+path='(.*?)'>\s*<search>(.*?)</search>\s*<replace>(.*?)</replace>\s*</tool>", tool_raw, re.DOTALL)
|
|
412
|
+
search_code_match = re.search(r"<tool='search_code'\s+query='(.*?)'>(.*?)</tool>", tool_raw, re.DOTALL)
|
|
413
|
+
read_lines_match = re.search(r"<tool='read_lines'\s+path='(.*?)'\s+start='(\d+)'\s+end='(\d+)'></tool>", tool_raw, re.DOTALL)
|
|
414
|
+
term_run_match = re.search(r"<tool='terminal_run'>(.*?)</tool>", tool_raw, re.DOTALL)
|
|
415
|
+
term_bg_match = re.search(r"<tool='terminal_bg'>(.*?)</tool>", tool_raw, re.DOTALL)
|
|
416
|
+
term_logs_match = re.search(r"<tool='terminal_logs'\s+pid='(.*?)'></tool>", tool_raw, re.DOTALL)
|
|
417
|
+
term_kill_match = re.search(r"<tool='terminal_kill'\s+pid='(.*?)'></tool>", tool_raw, re.DOTALL)
|
|
418
|
+
cron_match = re.search(r"<tool='cron_monitor'\s+pid='(.*?)'\s+delay='(\d+)'></tool>", tool_raw, re.DOTALL)
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
if view_match:
|
|
422
|
+
sub_dir = view_match.group(1).strip()
|
|
423
|
+
target_dir = secure_path(sub_dir) if sub_dir else WORKSPACE_DIR
|
|
424
|
+
tree = []
|
|
425
|
+
for root, dirs, files in os.walk(target_dir):
|
|
426
|
+
for f in files:
|
|
427
|
+
rel_path = os.path.relpath(os.path.join(root, f), WORKSPACE_DIR)
|
|
428
|
+
if not any(part.startswith('.') or part == 'node_modules' or part == '__pycache__' for part in rel_path.split(os.sep)):
|
|
429
|
+
tree.append(rel_path)
|
|
430
|
+
result = {"output": truncate_output("\n".join(tree) if tree else "Directory is empty.")}
|
|
431
|
+
|
|
432
|
+
elif read_match:
|
|
433
|
+
filepath = secure_path(read_match.group(1).strip())
|
|
434
|
+
if not os.path.exists(filepath):
|
|
435
|
+
result = {"output": "Error: File not found."}
|
|
436
|
+
else:
|
|
437
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
438
|
+
result = {"output": truncate_output(f.read())}
|
|
439
|
+
|
|
440
|
+
elif read_lines_match:
|
|
441
|
+
filepath = secure_path(read_lines_match.group(1).strip())
|
|
442
|
+
start_line, end_line = int(read_lines_match.group(2)), int(read_lines_match.group(3))
|
|
443
|
+
if not os.path.exists(filepath):
|
|
444
|
+
result = {"output": "Error: File not found."}
|
|
445
|
+
else:
|
|
446
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
447
|
+
lines = f.readlines()
|
|
448
|
+
chunk = lines[max(0, start_line-1) : end_line]
|
|
449
|
+
formatted_chunk = "".join([f"{i+start_line}: {line}" for i, line in enumerate(chunk)])
|
|
450
|
+
result = {"output": truncate_output(f"--- Lines {start_line} to {end_line} ---\n{formatted_chunk}")}
|
|
451
|
+
|
|
452
|
+
elif search_code_match:
|
|
453
|
+
query, sub_dir = search_code_match.group(1).strip(), search_code_match.group(2).strip()
|
|
454
|
+
target_dir = secure_path(sub_dir) if sub_dir else WORKSPACE_DIR
|
|
455
|
+
results = []
|
|
456
|
+
for root, dirs, files in os.walk(target_dir):
|
|
457
|
+
if any(part.startswith('.') or part == 'node_modules' for part in root.split(os.sep)): continue
|
|
458
|
+
for f in files:
|
|
459
|
+
filepath = os.path.join(root, f)
|
|
460
|
+
rel_path = os.path.relpath(filepath, WORKSPACE_DIR)
|
|
461
|
+
try:
|
|
462
|
+
with open(filepath, 'r', encoding='utf-8') as file:
|
|
463
|
+
for line_num, line in enumerate(file, 1):
|
|
464
|
+
if query in line: results.append(f"{rel_path}:{line_num}: {line.strip()}")
|
|
465
|
+
except: pass
|
|
466
|
+
result = {"output": truncate_output("\n".join(results) if results else "No matches found.")}
|
|
467
|
+
|
|
468
|
+
elif write_match:
|
|
469
|
+
path = write_match.group(1).strip()
|
|
470
|
+
filepath = secure_path(path)
|
|
471
|
+
content = re.sub(r'^```[a-zA-Z]*\n|\n```$', '', write_match.group(2).strip(), flags=re.MULTILINE)
|
|
472
|
+
|
|
473
|
+
old_content = ""
|
|
474
|
+
if os.path.exists(filepath):
|
|
475
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
476
|
+
old_content = f.read()
|
|
477
|
+
|
|
478
|
+
print_terminal_diff(path, old_content, content)
|
|
479
|
+
|
|
480
|
+
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
481
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
482
|
+
f.write(content)
|
|
483
|
+
result = {"output": f"Successfully wrote {path}"}
|
|
484
|
+
|
|
485
|
+
elif patch_match:
|
|
486
|
+
path = patch_match.group(1).strip()
|
|
487
|
+
filepath = secure_path(path)
|
|
488
|
+
search = re.sub(r'^```[a-zA-Z]*\n|\n```$', '', patch_match.group(2).strip('\n'), flags=re.MULTILINE)
|
|
489
|
+
replace = re.sub(r'^```[a-zA-Z]*\n|\n```$', '', patch_match.group(3).strip('\n'), flags=re.MULTILINE)
|
|
490
|
+
|
|
491
|
+
if not os.path.exists(filepath):
|
|
492
|
+
result = {"output": "Error: File not found."}
|
|
493
|
+
else:
|
|
494
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
495
|
+
original = f.read()
|
|
496
|
+
if search not in original:
|
|
497
|
+
result = {"output": "Error: Search block not found exactly as written."}
|
|
498
|
+
else:
|
|
499
|
+
new_content = original.replace(search, replace, 1)
|
|
500
|
+
print_terminal_diff(path, original, new_content)
|
|
501
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
502
|
+
f.write(new_content)
|
|
503
|
+
result = {"output": f"Patch applied successfully to {path}."}
|
|
504
|
+
|
|
505
|
+
elif term_run_match:
|
|
506
|
+
cmd = term_run_match.group(1).strip()
|
|
507
|
+
env = os.environ.copy()
|
|
508
|
+
env["FORCE_COLOR"] = "true"
|
|
509
|
+
try:
|
|
510
|
+
res = subprocess.run(cmd, shell=True, cwd=WORKSPACE_DIR, capture_output=True, text=True, timeout=300, env=env)
|
|
511
|
+
output = strip_ansi(f"STDOUT:\n{res.stdout}\nSTDERR:\n{res.stderr}")
|
|
512
|
+
result = {"output": truncate_output(output if output.strip() else "Executed.")}
|
|
513
|
+
except subprocess.TimeoutExpired as e:
|
|
514
|
+
partial = e.stdout or ""
|
|
515
|
+
result = {"output": truncate_output(strip_ansi(f"Timed out after 300s. Output:\n{partial}"))}
|
|
516
|
+
|
|
517
|
+
elif term_bg_match:
|
|
518
|
+
cmd = term_bg_match.group(1).strip()
|
|
519
|
+
pid = str(uuid.uuid4())[:8]
|
|
520
|
+
env = os.environ.copy()
|
|
521
|
+
env["FORCE_COLOR"] = "true"
|
|
522
|
+
process = subprocess.Popen(cmd, shell=True, cwd=WORKSPACE_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, env=env)
|
|
523
|
+
BACKGROUND_PROCESSES[pid] = {'process': process, 'logs': deque(maxlen=500), 'status': 'running'}
|
|
524
|
+
threading.Thread(target=stream_process_output, args=(pid, process), daemon=True).start()
|
|
525
|
+
result = {"output": f"Process started. PID: {pid}. Use terminal_logs to view."}
|
|
526
|
+
|
|
527
|
+
elif term_logs_match:
|
|
528
|
+
pid = term_logs_match.group(1).strip()
|
|
529
|
+
if pid not in BACKGROUND_PROCESSES:
|
|
530
|
+
result = {"output": "Error: PID not found."}
|
|
531
|
+
else:
|
|
532
|
+
logs = "".join(list(BACKGROUND_PROCESSES[pid]['logs'])) or "No output yet."
|
|
533
|
+
result = {"output": truncate_output(strip_ansi(f"STATUS: {BACKGROUND_PROCESSES[pid]['status']}\nLOGS:\n{logs}"))}
|
|
534
|
+
|
|
535
|
+
elif term_kill_match:
|
|
536
|
+
pid = term_kill_match.group(1).strip()
|
|
537
|
+
if pid in BACKGROUND_PROCESSES:
|
|
538
|
+
proc = BACKGROUND_PROCESSES[pid]['process']
|
|
539
|
+
if platform.system() == "Windows":
|
|
540
|
+
subprocess.run(f"taskkill /F /T /PID {proc.pid}", shell=True, capture_output=True)
|
|
541
|
+
else:
|
|
542
|
+
proc.terminate()
|
|
543
|
+
BACKGROUND_PROCESSES[pid]['status'] = 'killed'
|
|
544
|
+
result = {"output": f"Process {pid} terminated."}
|
|
545
|
+
else:
|
|
546
|
+
result = {"output": "Error: PID not found."}
|
|
547
|
+
|
|
548
|
+
elif cron_match:
|
|
549
|
+
pid = cron_match.group(1).strip()
|
|
550
|
+
delay = int(cron_match.group(2).strip())
|
|
551
|
+
if pid not in BACKGROUND_PROCESSES:
|
|
552
|
+
result = {"output": "Error: PID not found."}
|
|
553
|
+
else:
|
|
554
|
+
safe_delay = min(delay, 120)
|
|
555
|
+
await asyncio.sleep(safe_delay)
|
|
556
|
+
logs = "".join(list(BACKGROUND_PROCESSES[pid]['logs'])) or "No output yet."
|
|
557
|
+
status = BACKGROUND_PROCESSES[pid]['status']
|
|
558
|
+
result = {"output": truncate_output(strip_ansi(f"[WOKE UP AFTER {safe_delay}s]\nSTATUS: {status}\nLOGS:\n{logs}"))}
|
|
559
|
+
|
|
560
|
+
else:
|
|
561
|
+
result = {"output": "Error: Unrecognized tool tag."}
|
|
562
|
+
|
|
563
|
+
except Exception as e:
|
|
564
|
+
result = {"output": f"Error: {str(e)}"}
|
|
565
|
+
|
|
566
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
567
|
+
output_text = str(result.get("output", ""))
|
|
568
|
+
ok = not output_text.startswith("Error")
|
|
569
|
+
print_tool_end(ok, elapsed_ms, note=_short_preview(output_text))
|
|
570
|
+
return result
|
|
571
|
+
|
|
572
|
+
if __name__ == "__main__":
|
|
573
|
+
import uvicorn
|
|
574
|
+
uvicorn.run(app, host="127.0.0.1", port=5505, access_log=False)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
browse_code/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
browse_code/cli.py,sha256=ofLJcremtBArkiYGlO2ZmSGczfcLZ0jyWWKeXP3Nb9o,2700
|
|
3
|
+
browse_code/server.py,sha256=71G-SL-B0tZbi6sUASkL9JsGbWTNwO_qxFK--AUZHlE,24460
|
|
4
|
+
browse_code/extension/content.js,sha256=YNo4X2E-6ECmmPrvgcz2YGaYwWB9m0wgMrYgp4FAksk,18611
|
|
5
|
+
browse_code/extension/manifest.json,sha256=HZGVv6Tz9Vf8lF36qXjdv_ymeIOt_BAE3SsMXd6Zb0o,609
|
|
6
|
+
browse_code/extension/popup.html,sha256=FzDrhvFdVQfJvbPtOP29Qf_zOG74L14_MQHY2NThjHs,2267
|
|
7
|
+
browse_code/extension/popup.js,sha256=p7UnulL6RagF8nEnl0qq6bKlZP5VKwPVrm1fmHkdfJc,3949
|
|
8
|
+
browse_code/extension/spoof.js,sha256=Gw-32i1H6TLOJ1wya6FgEpWwcRw8MBOT2jXqC9ucISI,232
|
|
9
|
+
browse_code-0.1.0.dist-info/METADATA,sha256=0ITT_ssbL79d5zN8IUpHsBqdZhk8cHD8fcLOrkrk97Q,209
|
|
10
|
+
browse_code-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
browse_code-0.1.0.dist-info/entry_points.txt,sha256=RKZORZlRlErzq7s0lsBoQftIJht3XbEt97LEwSZCKps,44
|
|
12
|
+
browse_code-0.1.0.dist-info/top_level.txt,sha256=Foeuwuu4FrELgUyxG7VVnVXsd8w2lrlGIxO2IJHPJqU,12
|
|
13
|
+
browse_code-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
browse_code
|