KingNish commited on
Commit
6ee487c
·
1 Parent(s): c18045a

Shell Fix

Browse files
Files changed (4) hide show
  1. agent/shell.py +67 -5
  2. agent/tools.py +20 -12
  3. app.css +1 -1
  4. static/js/landing.js +1 -1
agent/shell.py CHANGED
@@ -2,12 +2,17 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import subprocess
 
6
  import threading
7
  import time
8
  from dataclasses import dataclass, field
 
9
  from typing import IO
10
 
 
 
11
 
12
  @dataclass
13
  class ShellSession:
@@ -15,9 +20,11 @@ class ShellSession:
15
 
16
  session_id: str
17
  process: subprocess.Popen[str]
 
18
  output_buffer: list[str] = field(default_factory=list)
19
  last_read_pos: int = 0
20
  started_at: float = field(default_factory=time.time)
 
21
  closed: bool = False
22
 
23
  @property
@@ -28,22 +35,32 @@ class ShellSession:
28
  def returncode(self) -> int | None:
29
  return self.process.returncode
30
 
 
 
 
 
31
  def read_new_output(self) -> str:
32
  """Read new output since last read."""
 
33
  new_lines = self.output_buffer[self.last_read_pos :]
34
  self.last_read_pos = len(self.output_buffer)
35
  return "".join(new_lines)
36
 
37
  def get_full_output(self) -> str:
38
  """Get all output."""
 
39
  return "".join(self.output_buffer)
40
 
41
  def is_running(self) -> bool:
42
  """Check if process is still running."""
43
  return self.process.poll() is None
44
 
 
 
 
 
45
  def close(self) -> None:
46
- """Close the session."""
47
  if not self.closed:
48
  self.closed = True
49
  try:
@@ -54,6 +71,14 @@ class ShellSession:
54
  self.process.kill()
55
  except Exception:
56
  pass
 
 
 
 
 
 
 
 
57
 
58
 
59
  class ShellManager:
@@ -63,6 +88,29 @@ class ShellManager:
63
  self.sessions: dict[str, ShellSession] = {}
64
  self._lock = threading.Lock()
65
  self.default_timeout = default_timeout
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  def start(
68
  self,
@@ -71,12 +119,21 @@ class ShellManager:
71
  cwd: str | None = None,
72
  env: dict[str, str] | None = None,
73
  ) -> ShellSession:
74
- """Start a new shell session."""
75
  with self._lock:
76
  # Close existing session with same ID
77
  if session_id in self.sessions:
78
  self.sessions[session_id].close()
79
 
 
 
 
 
 
 
 
 
 
80
  # Use shell=True for interactive commands
81
  process = subprocess.Popen(
82
  command,
@@ -86,11 +143,15 @@ class ShellManager:
86
  stderr=subprocess.STDOUT,
87
  text=True,
88
  bufsize=1,
89
- cwd=cwd,
90
- env=env,
91
  )
92
 
93
- session = ShellSession(session_id=session_id, process=process)
 
 
 
 
94
  self.sessions[session_id] = session
95
 
96
  # Start output reader thread
@@ -125,6 +186,7 @@ class ShellManager:
125
  try:
126
  session.process.stdin.write(input_text + "\n")
127
  session.process.stdin.flush()
 
128
  return True
129
  except Exception:
130
  return False
 
2
 
3
  from __future__ import annotations
4
 
5
+ import shutil
6
  import subprocess
7
+ import tempfile
8
  import threading
9
  import time
10
  from dataclasses import dataclass, field
11
+ from pathlib import Path
12
  from typing import IO
13
 
14
+ IDLE_TIMEOUT_SECONDS = 15 * 60 # 15 minutes
15
+
16
 
17
  @dataclass
18
  class ShellSession:
 
20
 
21
  session_id: str
22
  process: subprocess.Popen[str]
23
+ temp_dir: Path
24
  output_buffer: list[str] = field(default_factory=list)
25
  last_read_pos: int = 0
26
  started_at: float = field(default_factory=time.time)
27
+ last_used_at: float = field(default_factory=time.time)
28
  closed: bool = False
29
 
30
  @property
 
35
  def returncode(self) -> int | None:
36
  return self.process.returncode
37
 
38
+ def touch(self) -> None:
39
+ """Update last_used_at to current time."""
40
+ self.last_used_at = time.time()
41
+
42
  def read_new_output(self) -> str:
43
  """Read new output since last read."""
44
+ self.touch()
45
  new_lines = self.output_buffer[self.last_read_pos :]
46
  self.last_read_pos = len(self.output_buffer)
47
  return "".join(new_lines)
48
 
49
  def get_full_output(self) -> str:
50
  """Get all output."""
51
+ self.touch()
52
  return "".join(self.output_buffer)
53
 
54
  def is_running(self) -> bool:
55
  """Check if process is still running."""
56
  return self.process.poll() is None
57
 
58
+ def is_idle_expired(self) -> bool:
59
+ """Check if session has been idle longer than IDLE_TIMEOUT_SECONDS."""
60
+ return (time.time() - self.last_used_at) > IDLE_TIMEOUT_SECONDS
61
+
62
  def close(self) -> None:
63
+ """Close the session and clean up temp directory."""
64
  if not self.closed:
65
  self.closed = True
66
  try:
 
71
  self.process.kill()
72
  except Exception:
73
  pass
74
+ # Clean up temp directory (retry a few times for Windows file locks)
75
+ if self.temp_dir.exists():
76
+ for _ in range(3):
77
+ try:
78
+ shutil.rmtree(self.temp_dir, ignore_errors=False)
79
+ break
80
+ except Exception:
81
+ time.sleep(0.1)
82
 
83
 
84
  class ShellManager:
 
88
  self.sessions: dict[str, ShellSession] = {}
89
  self._lock = threading.Lock()
90
  self.default_timeout = default_timeout
91
+ self._cleanup_thread = threading.Thread(
92
+ target=self._cleanup_loop, daemon=True
93
+ )
94
+ self._cleanup_thread.start()
95
+
96
+ def _cleanup_loop(self) -> None:
97
+ """Background thread that cleans up idle sessions every 60 seconds."""
98
+ while True:
99
+ time.sleep(60)
100
+ self._cleanup_idle_sessions()
101
+
102
+ def _cleanup_idle_sessions(self) -> None:
103
+ """Close sessions that have been idle for more than IDLE_TIMEOUT_SECONDS."""
104
+ with self._lock:
105
+ idle_sessions = [
106
+ sid
107
+ for sid, session in self.sessions.items()
108
+ if session.is_idle_expired()
109
+ ]
110
+ for sid in idle_sessions:
111
+ session = self.sessions.pop(sid, None)
112
+ if session:
113
+ session.close()
114
 
115
  def start(
116
  self,
 
119
  cwd: str | None = None,
120
  env: dict[str, str] | None = None,
121
  ) -> ShellSession:
122
+ """Start a new shell session in a fresh temp directory."""
123
  with self._lock:
124
  # Close existing session with same ID
125
  if session_id in self.sessions:
126
  self.sessions[session_id].close()
127
 
128
+ # Create a unique temp directory for this session
129
+ temp_dir = Path(tempfile.mkdtemp(prefix=f"shell_{session_id}_"))
130
+
131
+ # Merge env with inherited environment
132
+ import os
133
+ process_env = os.environ.copy()
134
+ if env:
135
+ process_env.update(env)
136
+
137
  # Use shell=True for interactive commands
138
  process = subprocess.Popen(
139
  command,
 
143
  stderr=subprocess.STDOUT,
144
  text=True,
145
  bufsize=1,
146
+ cwd=str(temp_dir),
147
+ env=process_env,
148
  )
149
 
150
+ session = ShellSession(
151
+ session_id=session_id,
152
+ process=process,
153
+ temp_dir=temp_dir,
154
+ )
155
  self.sessions[session_id] = session
156
 
157
  # Start output reader thread
 
186
  try:
187
  session.process.stdin.write(input_text + "\n")
188
  session.process.stdin.flush()
189
+ session.touch()
190
  return True
191
  except Exception:
192
  return False
agent/tools.py CHANGED
@@ -179,21 +179,23 @@ from .shell import get_shell_manager
179
 
180
 
181
  def _shell_handler(
182
- command: str,
183
  session_id: str = "",
184
  input_text: str = "",
185
  ) -> str:
186
- """Run shell commands interactively.
187
 
188
- command (required): The shell command to execute (omit when checking output or sending input)
189
  session_id: Session ID to check output or send input to (omit to start new command)
190
  input_text: Text to send to running session's stdin
191
 
192
  How it works:
193
- - Start new command: returns session_id immediately (non-blocking)
194
- - Check output: call with session_id to get current output
195
- - Send input: call with session_id + input_text
196
- - Command runs in background, never blocks the agent
 
 
197
  """
198
  manager = get_shell_manager()
199
 
@@ -203,7 +205,7 @@ def _shell_handler(
203
  if existing_session and input_text:
204
  sent = manager.send_input(session_id, input_text)
205
  if not sent:
206
- return f"Error: Session '{session_id}' closed"
207
  time.sleep(0.3)
208
  output = manager.poll_output(session_id)
209
  running = manager.is_running(session_id)
@@ -222,6 +224,12 @@ def _shell_handler(
222
  return f"[{session_id}] {status}:\n{output}"
223
  return f"[{session_id}] {status}"
224
 
 
 
 
 
 
 
225
  # Start new command
226
  sid = session_id or str(_uuid.uuid4())[:8]
227
  session = manager.start(sid, command)
@@ -248,24 +256,24 @@ def _shell_handler(
248
 
249
  SHELL_TOOL = Tool(
250
  name="shell",
251
- description="Run shell commands interactively. Start command -> returns session_id. Call again with session_id to check output or send input. Never blocks.",
252
  parameters={
253
  "type": "object",
254
  "properties": {
255
  "command": {
256
  "type": "string",
257
- "description": "The shell command to execute (omit when checking output or sending input)",
258
  },
259
  "session_id": {
260
  "type": "string",
261
- "description": "Session ID to check output or send input to (omit to start new command)",
262
  },
263
  "input_text": {
264
  "type": "string",
265
  "description": "Text to send to running session's stdin",
266
  },
267
  },
268
- "required": ["command"],
269
  },
270
  handler=_shell_handler,
271
  streamable=False,
 
179
 
180
 
181
  def _shell_handler(
182
+ command: str = "",
183
  session_id: str = "",
184
  input_text: str = "",
185
  ) -> str:
186
+ """Run shell commands interactively with persistent sessions.
187
 
188
+ command: The shell command to execute (omit when checking output or sending input)
189
  session_id: Session ID to check output or send input to (omit to start new command)
190
  input_text: Text to send to running session's stdin
191
 
192
  How it works:
193
+ - Start new command: provide command, returns session_id immediately (non-blocking)
194
+ - Check output: provide session_id only, returns current output
195
+ - Send input: provide session_id + input_text
196
+ - Sessions auto-destroy after 15 min idle
197
+ - Each session runs in its own temp folder (also cleaned up on timeout)
198
+ - Environment variables persist across calls in the same session
199
  """
200
  manager = get_shell_manager()
201
 
 
205
  if existing_session and input_text:
206
  sent = manager.send_input(session_id, input_text)
207
  if not sent:
208
+ return f"Error: Session '{session_id}' closed or not found"
209
  time.sleep(0.3)
210
  output = manager.poll_output(session_id)
211
  running = manager.is_running(session_id)
 
224
  return f"[{session_id}] {status}:\n{output}"
225
  return f"[{session_id}] {status}"
226
 
227
+ # Need a command to start a new session
228
+ if not command.strip():
229
+ if session_id:
230
+ return f"Error: Session '{session_id}' not found or expired"
231
+ return "Error: Provide a command to start a new session, or a session_id to check status"
232
+
233
  # Start new command
234
  sid = session_id or str(_uuid.uuid4())[:8]
235
  session = manager.start(sid, command)
 
256
 
257
  SHELL_TOOL = Tool(
258
  name="shell",
259
+ description="Run shell commands with persistent sessions. Start command -> get session_id. Call with session_id to check output or send input. Sessions auto-destroy after 15 min idle. Each session has its own temp folder.",
260
  parameters={
261
  "type": "object",
262
  "properties": {
263
  "command": {
264
  "type": "string",
265
+ "description": "Shell command to execute (omit when checking output or sending input)",
266
  },
267
  "session_id": {
268
  "type": "string",
269
+ "description": "Session ID to check output or send input (omit to start new command)",
270
  },
271
  "input_text": {
272
  "type": "string",
273
  "description": "Text to send to running session's stdin",
274
  },
275
  },
276
+ "required": [],
277
  },
278
  handler=_shell_handler,
279
  streamable=False,
app.css CHANGED
@@ -19,7 +19,7 @@ html, body {
19
  }
20
 
21
  #main-row {
22
- height: calc(var(--vh) * 100);
23
  max-height: 100%;
24
  }
25
 
 
19
  }
20
 
21
  #main-row {
22
+ height: var(--vh, 100%);
23
  max-height: 100%;
24
  }
25
 
static/js/landing.js CHANGED
@@ -75,7 +75,7 @@
75
  function setVH() {
76
  document.documentElement.style.setProperty(
77
  '--vh',
78
- `${window.innerHeight * 0.01}px`
79
  );
80
  }
81
 
 
75
  function setVH() {
76
  document.documentElement.style.setProperty(
77
  '--vh',
78
+ `${window.innerHeight}px`
79
  );
80
  }
81