-
Notifications
You must be signed in to change notification settings - Fork 3
Add background mode to run_shell_command (fix Termux timeout on servers) #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,13 +42,15 @@ class TermuxBridge(private val appContext: Context) { | |
| suspend fun run( | ||
| command: String, | ||
| workdir: String? = null, | ||
| timeoutMs: Long = 12_000 | ||
| timeoutMs: Long = 12_000, | ||
| background: Boolean = false | ||
| ): Result { | ||
| if (!isInstalled()) return Result( | ||
| false, -1, "", | ||
| "Termux is not installed. Install it from F-Droid then enable RUN_COMMAND." | ||
| ) | ||
| val first = dispatch(command, workdir, timeoutMs) | ||
| val effective = if (background) wrapBackground(command) else command | ||
| val first = dispatch(effective, workdir, timeoutMs) | ||
| if (workdir == null || !isWorkdirDenied(first)) return first | ||
|
|
||
| // Termux refused the WORKDIR because it lacks shared-storage access. | ||
|
|
@@ -62,7 +64,7 @@ class TermuxBridge(private val appContext: Context) { | |
| if (!autoSetupAttempted) { | ||
| autoSetupAttempted = true | ||
| triggerStorageBootstrap() | ||
| val fallback = dispatch(command, null, timeoutMs) | ||
| val fallback = dispatch(effective, null, timeoutMs) | ||
| val hint = "note: Termux couldn't read $workdir yet. Termux was " + | ||
| "just brought to the foreground and `termux-setup-storage` is " + | ||
| "on your clipboard — long-press in Termux, tap Paste, press " + | ||
|
|
@@ -74,7 +76,7 @@ class TermuxBridge(private val appContext: Context) { | |
|
|
||
| // Already tried auto-setup earlier — user likely skipped it. Keep | ||
| // working by falling back to $HOME and surface the manual fix. | ||
| val fallback = dispatch(command, null, timeoutMs) | ||
| val fallback = dispatch(effective, null, timeoutMs) | ||
| val hint = "note: Termux still can't read $workdir. Open Termux and " + | ||
| "run `termux-setup-storage`, then accept the Android permission " + | ||
| "dialog. Until then, prefer the workspace file tools over shell " + | ||
|
|
@@ -207,6 +209,37 @@ class TermuxBridge(private val appContext: Context) { | |
| if (isActive) resume(value) | ||
| } | ||
|
|
||
| // Wrap a long-running command so bash exits immediately and Termux | ||
| // broadcasts exit=0 within milliseconds, while the actual process keeps | ||
| // running in the background. stdout/stderr go to a log file under | ||
| // $HOME/.gemini-bg/ so the model can tail it later via a normal shell | ||
| // call (e.g. `tail -n 80 $LOG`). disown detaches the child so it | ||
| // survives Termux's RunCommandService tearing down. | ||
| private fun wrapBackground(command: String): String { | ||
| val id = UUID.randomUUID().toString().take(8) | ||
| val quoted = bashSingleQuote(command) | ||
| return buildString { | ||
| append("mkdir -p \"\$HOME/.gemini-bg\" && ") | ||
| append("LOG=\"\$HOME/.gemini-bg/run-$id.log\" && ") | ||
| append("nohup bash -lc $quoted > \"\$LOG\" 2>&1 & ") | ||
| append("PID=\$! && ") | ||
| append("disown 2>/dev/null || true ; ") | ||
| append("sleep 0.2 ; ") | ||
| append("if kill -0 \$PID 2>/dev/null ; then ") | ||
| append(" echo \"[background] started pid=\$PID log=\$LOG\" ; ") | ||
| append("else ") | ||
| append(" echo \"[background] process died immediately; last log lines:\" ; ") | ||
| append(" tail -n 30 \"\$LOG\" 2>/dev/null || true ; ") | ||
| append(" exit 1 ; ") | ||
| append("fi") | ||
| } | ||
| } | ||
|
Comment on lines
+218
to
+236
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Confirm bash precedence: is `A && B & C` parsed as `(A && B) & ; C` ?
# Expected: $! is the PID of the (A && B) group, not of B alone.
bash -c '
true && sleep 3 & PID=$!
echo "captured PID=$PID"
ps -o pid,ppid,cmd -p $PID 2>/dev/null || echo "PID $PID not a direct sleep process"
# Show children — if PID is a subshell wrapping nohup, sleep will be its child
pgrep -P $PID | xargs -r ps -o pid,ppid,cmd -p
wait
'Repository: aciderix/gemini-android-app Length of output: 174 🏁 Script executed: #!/bin/bash
# Test 1: Verify bash operator precedence for `A && B & C`
# Does it parse as `(A && B) & ; C` or `(A && B & C)` ?
echo "=== Test 1: Operator Precedence ==="
bash -c '
# If parsed as (A && B) & ; C then PID=$! captures the group
# If parsed as (A && B & C) then the entire sequence is backgrounded
true && (echo "B executed") & PID=$!
echo "Captured PID=$PID"
# Wait a moment for background job to complete
sleep 0.5
wait $PID 2>/dev/null && echo "Successfully waited for $PID"
'
# Test 2: More explicit test with process tree
echo -e "\n=== Test 2: What is $! when using && and & ? ==="
bash -c '
# Create a marker file to track execution order
true && sleep 10 &
PID=$!
echo "Backgrounded PID=$PID"
# Check if PID still exists
if ps -p $PID > /dev/null 2>&1; then
echo "PID $PID still running"
ps -o pid,ppid,cmd -p $PID 2>/dev/null | head -5
fi
kill $PID 2>/dev/null
wait 2>/dev/null
'
# Test 3: Verify the exact TermuxBridge pattern
echo -e "\n=== Test 3: Simulating TermuxBridge nohup pattern ==="
tmpdir=$(mktemp -d)
bash -c "
mkdir -p \"$tmpdir\" && LOG=\"$tmpdir/test.log\" && nohup sleep 10 > \"\$LOG\" 2>&1 & PID=\$!
echo \"Captured PID=\$PID\"
# What process is $PID?
ps -o pid,ppid,cmd -p \$PID 2>/dev/null | head -2
# Cleanup
kill \$PID 2>/dev/null || true
wait 2>/dev/null || true
"
rm -rf "$tmpdir"Repository: aciderix/gemini-android-app Length of output: 296 🏁 Script executed: #!/bin/bash
# Check if the code in the repository matches the review snippet
cat -n core-bridge/src/main/kotlin/com/gemini/bridge/termux/TermuxBridge.kt | sed -n '218,236p'Repository: aciderix/gemini-android-app Length of output: 1095 🏁 Script executed: #!/bin/bash
echo "=== Detailed Test: Process Tree Analysis ==="
tmpdir=$(mktemp -d)
# Test the actual pattern with detailed process inspection
bash -c "
# This simulates the exact TermuxBridge pattern
mkdir -p \"$tmpdir\" && LOG=\"$tmpdir/test.log\" && nohup bash -c 'sleep 30' > \"\$LOG\" 2>&1 & PID=\$!
echo \"Captured PID=\$PID\"
# Get details about the captured PID
echo \"Process info for captured PID \$PID:\"
ps -o pid,ppid,sid,cmd -p \$PID 2>/dev/null || echo \"PID \$PID not found\"
# Show all processes in this shell's process group
echo \"All processes in current shell environment:\"
ps -o pid,ppid,sid,cmd 2>/dev/null | grep -E '(bash|sleep|PID)' | head -20
# Wait briefly and check again
sleep 1
echo \"After 1 second - checking if sleep is still under PID \$PID:\"
pgrep -P \$PID 2>/dev/null || echo \"No children under PID \$PID\"
# Cleanup
kill -9 \$PID 2>/dev/null || true
wait 2>/dev/null || true
" 2>&1
# Also test: what is the difference between capturing on the same line vs different lines?
echo -e "\n=== Comparison: Same line vs different line ==="
bash -c "
mkdir -p \"$tmpdir\" && LOG=\"$tmpdir/test2.log\" && nohup bash -c 'sleep 30' > \"\$LOG\" 2>&1 &
BASHPID_LINE=\$BASHPID
PID_SAME=\$!
# Separate capture on next line
mkdir -p \"$tmpdir\" && LOG=\"$tmpdir/test3.log\" && nohup bash -c 'sleep 30' > \"\$LOG\" 2>&1 &
PID_NEXT=\$!
echo \"PID captured on same line (after &): \$PID_SAME\"
echo \"PID captured on next line: \$PID_NEXT\"
# Cleanup
kill -9 \$PID_SAME \$PID_NEXT 2>/dev/null || true
wait 2>/dev/null || true
"
rm -rf "$tmpdir"Repository: aciderix/gemini-android-app Length of output: 423 🏁 Script executed: #!/bin/bash
# Search for related code and comments
rg -B5 -A10 "wrapBackground" core-bridge/src/main/kotlin/com/gemini/bridge/termux/TermuxBridge.ktRepository: aciderix/gemini-android-app Length of output: 1888
In bash, parses as
Suggested fix: don't chain with 🛠️ Proposed fix private fun wrapBackground(command: String): String {
val id = UUID.randomUUID().toString().take(8)
val quoted = bashSingleQuote(command)
return buildString {
- append("mkdir -p \"\$HOME/.gemini-bg\" && ")
- append("LOG=\"\$HOME/.gemini-bg/run-$id.log\" && ")
- append("nohup bash -lc $quoted > \"\$LOG\" 2>&1 & ")
- append("PID=\$! && ")
- append("disown 2>/dev/null || true ; ")
- append("sleep 0.2 ; ")
+ append("mkdir -p \"\$HOME/.gemini-bg\" || exit 1 ; ")
+ append("LOG=\"\$HOME/.gemini-bg/run-$id.log\" ; ")
+ append("nohup bash -lc $quoted > \"\$LOG\" 2>&1 </dev/null & ")
+ append("PID=\$! ; ")
+ append("disown \$PID 2>/dev/null || true ; ")
+ append("sleep 0.2 ; ")
append("if kill -0 \$PID 2>/dev/null ; then ")
append(" echo \"[background] started pid=\$PID log=\$LOG\" ; ")
append("else ")
append(" echo \"[background] process died immediately; last log lines:\" ; ")
append(" tail -n 30 \"\$LOG\" 2>/dev/null || true ; ")
append(" exit 1 ; ")
append("fi")
}
}Also add 🤖 Prompt for AI Agents |
||
|
|
||
| // Escape a string for safe embedding inside a bash single-quoted word. | ||
| // Single quote → close quote, escaped quote, reopen quote. | ||
| private fun bashSingleQuote(s: String): String = | ||
| "'" + s.replace("'", "'\\''") + "'" | ||
|
|
||
| companion object { | ||
| private const val TERMUX_PKG = "com.termux" | ||
| private const val TERMUX_SERVICE = "com.termux.app.RunCommandService" | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -25,22 +25,35 @@ class RunShellCommandTool( | |||||||||||||||
| "directory when it is reachable from Termux (the device path is shown in the " + | ||||||||||||||||
| "system instruction); otherwise they run in Termux's \$HOME, so use the file " + | ||||||||||||||||
| "tools to read/write workspace files. Requires Termux installed with the " + | ||||||||||||||||
| "RUN_COMMAND permission granted. Returns combined stdout and stderr.", | ||||||||||||||||
| "RUN_COMMAND permission granted. Returns combined stdout and stderr.\n\n" + | ||||||||||||||||
| "Foreground commands must return within ~12 s or the call fails with a " + | ||||||||||||||||
| "timeout. For anything long-running (dev/web servers, watchers, daemons), " + | ||||||||||||||||
| "set `background: true` — the command is detached with nohup, its output " + | ||||||||||||||||
| "is redirected to a log file under `~/.gemini-bg/`, and the result returns " + | ||||||||||||||||
| "immediately with the PID and log path. You can then check progress with " + | ||||||||||||||||
| "`tail -n 80 <log>` and stop the process with `kill <pid>`.", | ||||||||||||||||
| category = ToolCategory.SHELL, | ||||||||||||||||
| destructive = true, | ||||||||||||||||
| parameters = objectParams( | ||||||||||||||||
| "command" to stringProp("Command line to execute (e.g. `python script.py`)"), | ||||||||||||||||
| "background" to booleanProp( | ||||||||||||||||
| "Run in background. Use for servers/daemons/watchers that never return " + | ||||||||||||||||
| "on their own. Returns once the process is confirmed alive (~200 ms), " + | ||||||||||||||||
| "with its PID and log path." | ||||||||||||||||
| ), | ||||||||||||||||
| required = listOf("command") | ||||||||||||||||
| ) | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| override suspend fun execute(call: ToolCall): ToolCallResult = runCatching { | ||||||||||||||||
| val command = call.arguments["command"] as? String ?: error("command is required") | ||||||||||||||||
| val background = call.arguments["background"] as? Boolean ?: false | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Find how tool arguments are deserialized — do they land as typed JSON (Boolean/Number/String)
# or everything as String? Look at the JSON parser feeding ToolCall.arguments.
rg -nP --type=kotlin -C4 '\bToolCall\s*\('
rg -nP --type=kotlin -C3 'arguments\s*[:=]\s*Map'
rg -nP --type=kotlin -C5 'fun\s+\w*[Pp]arse\w*Tool|fromJson|decodeFromString.*Tool'Repository: aciderix/gemini-android-app Length of output: 4089 🏁 Script executed: rg -nP --type=kotlin 'fun\s+jsonToMap|jsonToMap\s*\(' -A 10Repository: aciderix/gemini-android-app Length of output: 5582 🏁 Script executed: cat -n core-bridge/src/main/kotlin/com/gemini/bridge/tools/ShellTool.kt | head -70 | tail -30Repository: aciderix/gemini-android-app Length of output: 1952 🏁 Script executed: rg -nP --type=kotlin 'fun\s+booleanProp|class.*Prop' -A 5 | head -60Repository: aciderix/gemini-android-app Length of output: 688
The schema declares Consider accepting multiple types: Suggested fix- val background = call.arguments["background"] as? Boolean ?: false
+ val background = when (val v = call.arguments["background"]) {
+ is Boolean -> v
+ is String -> v.equals("true", ignoreCase = true)
+ is Number -> v.toInt() != 0
+ else -> false
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
| val workdir = workspace.absolutePath() | ||||||||||||||||
| val reason = workspace.unreachableReason() | ||||||||||||||||
| val r = termux.run(command, workdir) | ||||||||||||||||
| val r = termux.run(command, workdir, background = background) | ||||||||||||||||
| val body = buildString { | ||||||||||||||||
| append("exit=").append(r.exitCode) | ||||||||||||||||
| if (background) append(" mode=background") | ||||||||||||||||
| if (workdir != null) append(" cwd=").append(workdir) | ||||||||||||||||
| else append(" cwd=~ (workspace not reachable from Termux)") | ||||||||||||||||
| append('\n') | ||||||||||||||||
|
|
||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion (performance): Background log files under ~/.gemini-bg can grow without bound; consider a retention/cleanup strategy.
Each background run writes to a new file in
~/.gemini-bg/with no rotation or pruning, so logs can grow without limit. Please consider adding a simple retention mechanism (e.g., keep only the last N logs, cap total directory size, or provide a cleanup command) to avoid long‑term disk usage issues.