Skip to content

ADFA-6385 | Reuse plugin terminal sessions and track running commands - #2113

Open
jatezzz wants to merge 8 commits into
feat/ADFA-6373-plugin-gradle-tasks-and-terminalfrom
feat/ADFA-6385-plugin-terminal-session-reuse
Open

jatezzz wants to merge 8 commits into
feat/ADFA-6373-plugin-gradle-tasks-and-terminalfrom
feat/ADFA-6385-plugin-terminal-session-reuse

Conversation

@jatezzz

@jatezzz jatezzz commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Implemented session reuse and command isolation for the Plugin Terminal API. Commands now run in up to 3 reused sessions per plugin via an agent-run.sh script to prevent environment changes (like cd or export) from carrying over to the next command. Introduced TerminalCommandResult.Running for long-running commands, allowing plugins to resume work if a command does not finish within the wait time. Added CommandRecorder and CommandMarkListener to parse OSC 133 shell-integration marks, which ensures plugins only receive the exact, clean output of their specific commands instead of mixed session outputs. Additionally, added readSession to check on running commands and stopSession to gracefully interrupt commands using Ctrl-C.

Details

Logic-related backend changes for terminal session pooling, intent routing, and byte stream tapping.

Screen_Recording_20261007_161740_Code.on.the.Go.mp4

Ticket

ADFA-6385

Observation

#2110 Must be merged first.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

The emulator now reports "ESC ] 133 ; <kind> ..." marks to its session
instead of dropping them, and lets a caller tap the bytes it processes.
The Terminal uses both to find where a plugin command's output starts
and ends, and to record only that command's output.
Comment thread termux/termux-app/src/main/java/com/itsaky/androidide/terminal/AgentRunner.kt Outdated
Conflicts in IdeTerminalServiceImpl(Test): port ADFA-6373's round-4
unload guard (closed flag) onto the LaunchedTerminalCommand model.
runInTerminal checks it before launching and again after joining
running/waiting, so cancelAll either sees the run or the run sees closed.
Addresses PR #2113 review F07 and F08. No behaviour change.

- CommandIntentRouter.route called claim() then start() back to back on
  the main thread, so the Claimed and Cancelled states were unreachable.
  start(id, factory) now takes a queued command directly; State is
  Queued -> Running -> Ended.
- Drop PluginSessionPool.owners/sessionsOf and
  TerminalCommandRequests.sessionsOf, which only tests used; the tests
  now check start/read/find instead.
Addresses PR #2113 review F01-F06.

- F01: a session idle by its last finish mark is reused only if its shell
  holds the terminal's foreground (tpgid from /proc/<shell>/stat), or the
  runner that just finished still does (agent-run now puts cogo-pid in its
  marks). One where the user started vim, top or a REPL counts as busy
  instead of having the run line typed into it.
- F02: agent-run is written only when missing or stale, as a temp file
  renamed over the old one, so a bash still reading it in another session
  is not cut short.
- F03: a command interrupted by a cancelled caller or plugin unload has
  5 s to exit after Ctrl-C, then its session is killed; the session stays
  on screen and the exit is reported, so it no longer holds a slot for good.
- F04: stopSession finds the command by (plugin, session name) through
  TerminalCommandRequests, so it reaches commands of cancelled callers and
  of the plugin before a reload.
- F05: a busy session pruned because it left the Terminal reports its
  command as exited, so a waiting caller no longer hangs.
- F06: a session keeps the name it was opened with; a user rename no
  longer hides it from readSession/stopSession or reissues the name.

Each new regression test was checked to fail with its fix reverted.
@jatezzz
jatezzz requested a review from hal-eisen-adfa October 8, 2026 14:14
Comment thread termux/termux-app/src/main/java/com/itsaky/androidide/terminal/AgentRunner.kt Outdated
jatezzz and others added 2 commits October 8, 2026 13:39
F09 - address commands by id, not session name. Sessions are reused, so
the session named in a Running result could later hold another command
of the same plugin: stopSession("P 1") could Ctrl-C another caller's
Gradle run, and readSession handed it that command's output. Running now
carries a commandId, and readSession/stopSession become
readCommand(commandId)/stopCommand(commandId, waitMillis). The last 8
exited results per plugin are kept, so a command's own result is still
readable after its session moves on. The API is unreleased (26.41).

F10 - end a command whose end mark never arrives (runner SIGKILLed, mark
swallowed by an unterminated escape sequence, or the typed run line taken
by a `read`/PS2 prompt), which otherwise kept its session busy for good.
Each look at a busy command checks, off the main thread, whether the
shell holds the terminal again and the runner is gone:
- runner reported its pid (the C mark carries cogo-pid): gone once that
  pid is no longer a child of the shell; ended with exit code -1 only if
  still gone 5 s later, so an end mark still in flight wins.
- typed run line no runner took after 5 s: the command file is taken
  back and the command ended. The runner claims its file with a rename,
  so exactly one side gets it and a withdrawn command never starts late.
  A new session's command is never taken back; its profile may be loading.
ForegroundProcessGroup becomes ProcStat, which also reads the parent pid.

F11 - start a new session's first command from a login shell
(`bash -l -c`), so it sees the profile like every later command typed
into the session's interactive `bash -l`. Commands still inherit what the
user exports in the session's shell; the API doc now says so.
@jatezzz
jatezzz requested a review from hal-eisen-adfa October 8, 2026 19:56
…eat/ADFA-6385-plugin-terminal-session-reuse

Brings in the ADFA-6373 round-5 review fixes (876adce).

Conflict in PluginTerminalLauncher.kt was imports only: kept HandlerCompat
from this branch and the Lifecycle/LifecycleOwner imports for the new
isStarted() guard, which applies cleanly to the reworked open().

PluginTerminalLauncherTest arrived written against the base's callback
launch API; ported to the suspend launch() returning LaunchedTerminalCommand,
with an AgentRunner on a temp dir. The backgrounded-IDE case still fails
without the isStarted() guard.
…ks-and-terminal' into feat/ADFA-6385-plugin-terminal-session-reuse

# Conflicts:
#	plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImpl.kt

@Daniel-ADFA Daniel-ADFA left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 293dc4033, on top of Hal's approval at c56ac21a3. This is the top of the stack (#2110 <- #2113).

The merge of #2110's 1e12a585b kept the re-enable fix through this PR's rewrite: reopen() at IdeTerminalServiceImpl.kt:217, called from enablePlugin (PluginManager.kt:1136), pinned by reopenAfterCancelAllRunsAgain.

Hal's earlier findings (F01-F08 and the three IMPORTANT from the second round) are fixed at this head. The PR's unit tests pass locally: plugin-manager IdeTerminalServiceImplTest 32, termux-app terminal tests 97, termux-emulator ShellIntegrationTest 8. Nothing exercised on a device. A few non-blocking findings from my review pass follow separately, if they survive verification.

@Daniel-ADFA Daniel-ADFA left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 293dc4033 against ADFA-6385. My approval at this head stands; these findings are for this PR or a follow-up. Hal approved earlier, at c56ac21a3.

Stack: this is the top of #2110 <- #2113, so this head is the tip and claims were checked here. It includes #2110's 1e12a585b, and reopen() survived the merge.

Severity index

IMPORTANT

  • TerminalCommandRequests.kt:203 - a new session whose runner never starts leaves its command Running forever

MINOR

  • TerminalActivity.kt:113 - a second request before the service connects replaces the first
  • TerminalActivity.kt:147 - /proc read on the main thread under allowThreadDiskReads
  • AgentRunner.kt:97 - the runner path depends on the profile's TMPDIR
  • TerminalCommandRequests.kt:169 - a killed session reports -9 where the API documents -1
  • PR description - names readSession/stopSession (below)

NITPICK - 1, below

Findings without a diff anchor

MINOR: The PR description still describes readSession and stopSession, which c56ac21a3 replaced with readCommand(commandId) and stopCommand(commandId, waitMillis). QA working from it will look for methods that no longer exist. Update it to the current API.

NITPICK: The PR body opens with "## Description"; CLAUDE.md (Pull requests) asks for the Jira link as its first line.

Earlier rounds, checked at head

  • F01, run line typed into a user's program: fixed, a session counts as idle only when its shell or last runner holds the foreground (PluginSessionPool.kt:62, TerminalCommandRequests.kt:82).
  • F02, runner rewritten in place: fixed, written only when stale, through a temp file and a rename (AgentRunner.kt:43-53).
  • F03, no guaranteed kill: fixed, Ctrl-C, then kill after 5 s (PluginTerminalLauncher.kt:114-118, TerminalCommandRequests.kt:114-116).
  • F04, stop limited to this instance's commands: fixed, stopCommand goes through the launcher (IdeTerminalServiceImpl.kt:190).
  • F05, pruned sessions never reported: fixed, prune returns them and each is reported (TerminalCommandRequests.kt:78, :165-171).
  • F06, a rename hides the session: fixed, the name is kept from when it opened (PluginSessionPool.kt:27-30).
  • F07, unreachable states: fixed, Queued -> Running -> Ended (TerminalCommand.kt:74-84).
  • F08, test-only API: fixed, owners and both sessionsOf are gone.
  • Session reuse confusing read and stop: fixed, by command id, with each plugin's last 8 exits kept (TerminalCommandRequests.kt:146-153, :242-244).
  • Lost end mark: fixed for a typed run and for a runner that reported in (TerminalCommandRequests.kt:179-223); a first run whose runner never starts is the IMPORTANT finding.
  • First command without the profile: fixed, bash -l -c (AgentRunner.kt:77).
  • Re-enable cancelling every later run (#2110): reopen() kept through the merge (IdeTerminalServiceImpl.kt:217, PluginManager.kt:1136).

Evidence

Area Result
Ticket The acceptance criteria map to code and tests; readSession in them became readCommand/stopCommand by command id at Hal's request.
§1 Exceptions Failures reach plugins as NotStarted, Running or Completed(-1); cancellation is rethrown after the interrupt.
§2 Leaks Commands are interrupted, then killed, on cancel and unload; exited results are capped at 8 per plugin.
§3 Threading /proc and file I/O go through io except the idle check (inline).
§4 Security SYSTEM_COMMANDS on run, read and stop; reads and stops are limited to the plugin's own commands; the command reaches bash through a file, not the command line.
§5 Tests Run locally, all passing: plugin-manager IdeTerminalServiceImplTest 32 at this head; termux-app terminal tests 97 and termux-emulator ShellIntegrationTest 8 at 19f4e2b03 (the merge since touched no termux files). Not run: app PluginTerminalLauncherTest. CI green. Nothing exercised on a device, and the large-screen intent ordering is from the flags, not observed.
§13 Plugins runInTerminal gained waitMillis, Running, readCommand and stopCommand, all in the 26.41 changelog. AI-Core's shell tool (plugin-examples feat/ADFA-6339-agent-run-shell-command) is the caller.

Checked and not reported: a null foreground group counts as at-prompt, but an open session's shell is same-uid and its /proc entry is readable, and dead shells are pruned first; a dead runner is noticed only on a later read, which then reports Completed(-1); cancelling during prepare leaves the command's files in Termux's $TMPDIR until its service stops; each byte of a running command is rendered in a second emulator, which is the design that keeps its output apart.

Verdict rule: REVIEW.md, CLAUDE.md and CONTRIBUTING.md have no written approve/request-changes rule. The approval was a deliberate decision with the IMPORTANT finding open.

if (runnerPid != null) return if (processes.parentOf(runnerPid) == shellPid) Runner.ALIVE else Runner.GONE
// The runner has not reported in. Only a typed run line can have gone elsewhere, and only
// once it had time to start is that worth checking.
if (typedAt == null || now() - typedAt < GONE_GRACE_MS) return Runner.ALIVE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMPORTANT: A command given to a new session whose runner never starts stays Running forever.

A new session runs bash -l -c "trap : INT; bash \"$TMPDIR/agent-run\" <id>; exec bash -l", and a login shell sources the user's profile before the -c string. If ~/.bash_profile ends in exec zsh (one way users switch to zsh), the runner never runs and no mark arrives. Each check then returns ALIVE here: runnerPid is null, and typedAt is null because the line was not typed. runInTerminal returns Running with no output, readCommand keeps saying Running, and after three commands every call is refused with AllSessionsBusy until the user closes those sessions.

Fix: give a new session's first command the same grace and runner.withdraw a typed line gets.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jatezzz please fix this one before merging. With a profile that execs another shell, every new session's first command stays Running forever, and after three of them the plugin is locked out with AllSessionsBusy until the user closes those sessions by hand.

Giving a new session's first command the same grace and runner.withdraw a typed line gets should cover it. Please add a test where the runner never reports in for a command that was not typed.

val commandRequestId = CommandIntentRouter.shared.requestId(intent)
if (commandRequestId != null) {
val service = mTermuxService
if (service != null) runCommand(service, commandRequestId) else pendingCommandRequestId = commandRequestId

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: A second command request that arrives before the Terminal's service connects replaces the first in pendingCommandRequestId.

On a large screen, applyMultiWindowFlags adds SINGLE_TOP, so a second runInTerminal while the Terminal is still binding arrives through onNewIntent and overwrites the id onCreate stored. The first command is never routed, and its caller gets NotStarted("The Terminal did not open") after 15 s although the Terminal opened. On a phone each request opens its own activity, so it does not happen there. The single slot comes from #2110 (line 109 there); session reuse makes back-to-back commands more likely.

Fix: keep the pending ids in a list and route each one in onServiceConnected.

override fun isOpen(session: TerminalSession) = service.getIndexOfSession(session) >= 0 && session.isRunning

override fun foregroundProcessGroup(session: TerminalSession) =
allowThreadDiskReads("/proc is in memory, not on disk") { ProcStat().foregroundProcessGroup(session.pid) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: The idle-session check reads /proc on the main thread and silences StrictMode with allowThreadDiskReads.

REVIEW.md §3: "Don't reach for allowThreadDiskReads() / permitAll() to silence a violation in our code." start runs on the main thread (CommandIntentRouter.route from runCommand), and slotFor calls this once per idle session of the plugin. The read is cheap, but the rule asks for the read to move rather than the policy to bend.

Fix: probe the idle sessions' foreground groups on io and pick the slot when the result posts back, as checkGone already does.

}

/** Runs commands from Termux's `$TMPDIR`, which Termux clears when its service stops. */
val termux = AgentRunner(File(TermuxConstants.TERMUX_TMP_PREFIX_DIR_PATH), "\$TMPDIR")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: The runner path is the literal $TMPDIR, which the session's shell expands after the profile, while the files go to the fixed TERMUX_TMP_PREFIX_DIR_PATH.

A profile that sets its own TMPDIR (export TMPDIR=$HOME/.tmp) makes bash "$TMPDIR/agent-run" fail with "No such file". A typed run then ends with -1 and no output after the 5 s grace, and a new session's first run never ends (the IMPORTANT finding at TerminalCommandRequests.kt:203). MINOR because Termux sets TMPDIR itself and few profiles change it.

Fix: pass the absolute directory.path as shellDirectory; the typed line gets longer but stops depending on the user's environment.

val terminal = session.terminal
terminal.setShellIntegrationListener(null)
if (session.isIdle) return false
exited(session, if (terminal.isRunning) CommandMarkListener.UNKNOWN_EXIT_CODE else terminal.exitStatus)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: A command whose session dies is reported with the shell's exit status, not the -1 the API documents.

TerminalCommandResult.Completed says exitCode is -1 "when the Terminal could not tell it, e.g. the session closed". Here an ended session reports terminal.exitStatus, which is -9 after kill (the SIGKILL that follows an ignored Ctrl-C) or after the user kills the session. A plugin testing exitCode == -1 reads -9 as the command's own exit code.

Fix: report UNKNOWN_EXIT_CODE here, or document that a killed session reports the negated signal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants