How the shell (.a, .af, .aa and the guest shell in
stdplugins/advanced_get.py) runs commands so that their output can be shown
while they run, and stopped. It describes the producers (the code that runs a
command and collects its bytes), the settings and their /settings panel,
the live output of .a, .af and .aa in chats and in guest answers, and
how a running command is stopped: .k, @bot .k and the previews' Stop
buttons.
In the bot's own chat, or a group it belongs to, shell commands accept either
their bare form or a leading mention: @bot .a CMD, @bot .aa CMD,
@bot .af CMD, @bot .k, @bot .x, @bot .xf and @bot .sbb. The primary
username and every active alias work, case-insensitively. At least one
whitespace character must separate the name and the command; commas and colons
are not separators. Existing command flags, arguments and admin checks apply.
User accounts keep the bare-command behavior.
/help@alias and /settings@alias also accept active aliases. Help text uses
the primary name, or the first active alias when there is no primary name.
Names are read from Telegram's profile at startup; restart the bot after
changing aliases. In guest chats, only the existing strict .a and .k
triggers are supported, with the same active aliases and authorization checks
(guest mode).
- Job: one
.a,.afor.aacommand, in a chat or from a guest chat, from the moment it waits for a shell until its final is delivered (shell_stream.ShellJob). - Producer: the code that runs the command and writes its bytes into the
job's live output:
util.brishz_capturefor.aand.af,util.simple_run_capturefor.aa, each givenjob=. - Consumer: the chat side, which shows the output while the command runs and sends the final text when it ends.
- Live output (
shell_stream.LiveOutput): what the command wrote so far, written from any thread and read on the event loop. - Kill hook: the callable that stops the running command
(
BrishPopen.killfor brish, the process-group escalation for.aa). The producer attaches it to the job, andShellJob.cancelcalls it.
Brish keeps long-lived zsh workers ready; a set of them in one Brish object
is a pool. The bot has two:
- The shell pool,
util.persistent_brish:borg_brish_countworkers (16 by default), started at import..a,.afand the guest shell run here, and the plugin passes it explicitly (brish=util.persistent_brish). It reads the global when the command runs, after the replied-to files have downloaded: a.xduring the download retires the pool of when the message came, and a retired pool refuses commands (UninitializedBrishException). A job still waiting for a worker when.xretires its pool waits until that pool shuts down (once the commands that hold its workers end); the pool then refuses the wait, nothing has run, and the job moves to the current pool (_capture_on_shell_pool). A job whose command had started is never run twice: the error stands. - The plugin pool,
util.plugin_brish():borg_plugin_brish_countworkers (4 by default), started on its first use.brishz,brishz_captureandbrishz_helperdefault to it, sojlib_plugins/jlib2.py,stdplugins/tex2png.py,stdplugins/ptv.py(throughaget_brishz) andstdplugins/ebook_processor.pyrun here.
Both pools boot with util.BRISH_BOOT_CMD (JBRISH=y).
Why two: with brish 0.4.0, a pool restarts after one of its workers dies (a
stopped command that needed SIGKILL, or exit N in a non-fork .af), and
that restart waits for the lock of every worker of the pool, so for every
command still running there, an endless one included. On one shared pool, a
stubborn .a tail -f would block .tex until it ended. With two pools, a
restart of either never stalls the other. Brish 0.4.1 (released) replaces
only the dead worker, and its restart() never waits for a worker in use,
so there the two pools only keep the shell's commands and the plugins'
apart; the bot runs on either version.
.x, .sbb and .xf call util.init_brishes, which starts a new shell pool
and retires both old pools on util.executor. The plugin pool is not started
again there; the next plugin command starts it. A retired pool shuts down once
the commands still running on it end. A restart stops none of them, so the
reply (old_pool_note in the plugin) adds a line such as "2 commands are
queued or running on old pools; .k stops them." when unfinished jobs (QUEUED,
RUNNING or STOPPING) have a retired pool as their ShellJob.pool: any pool
but the current util.persistent_brish, so one that an earlier restart
retired counts too. The consumer sets that field when it picks the pool,
after the downloads; .aa jobs and jobs still downloading have none, and an
old-brish .a is no job, so none of these is counted. Only the jobs that a
.k in the chat of the restart can see (shell_stream.visible) get that
line. The others get a second line, "1 command in another chat is queued or
running on an old pool; .k in that chat stops it.", since a .k here would not find
them, and the caller's own guest jobs among them a third, "1 guest command
is queued or running on an old pool; @bot .k in its chat stops it." A fourth
line, "1 command of another admin is queued or running on an old pool; they
can stop it.",
counts the jobs that the caller cannot reach at all: another admin's guest
jobs (@bot .k sees only its caller's own) and their jobs in a private chat
(a positive chat id), which on a bot only they can write in. Another admin's
job in a group counts on the second line, since any admin there can stop it.
A guest job records its pool as a chat job does.
Nothing here depends on whole-pool restarts, so the pools work the same on
brish 0.4.0 and 0.4.1. On 0.4.1 a stopped job also gives up its wait for a
worker at once, through brish's cancelled= argument; on 0.4.0 it waits
until a worker is free, then runs nothing (see "Stopping a queued job" under
Limits).
uniborg/term_render.py shows output as a terminal would, as plain text, so a
progress bar shows its last frame instead of every frame in a row. It is a
pure module (standard library only) and works on any str, streamed or not.
render(text)applies\r(back to column 0; later characters overwrite),\n,\b(back one column, not past 0) and these CSI sequences: SGR (ESC[...m) is removed,ESC[Kwith 0, 1 or 2 erases in the line, andESC[nAmoves up n lines, as multi-bar tqdm does. Every other escape sequence (other CSI, OSC titles and links,ESC (B) is dropped. Text with no\r,\bor ESC is returned unchanged, as the same object.TerminalRendererdoes the same piece by piece (feed, thentext); an escape sequence cut at the end of one piece waits for the next.line_aligned(text)drops everything up to the first newline. A tail cut from a longer output usually starts mid-line, where a\ror a cursor-up would act on text that is not there; aligned, rendering stays inside it. When only blanks follow that newline (the tail is the end of one long line, such as a minified JSON body), the tail is kept whole, so a preview never goes empty.
It is not a terminal emulator: there is no screen, no cursor addressing
beyond the above, and every character is one column wide (tabs and wide
characters are not expanded). Commands still run without a terminal; nothing
fakes one (no script, unbuffer or pseudo-terminal).
Cost: linear in the text. A line written to is kept as an array of code
points, so a write, an overwrite after \r or \b, and an erase cost what
they change, not the length of the line. The last ACTIVE_LINES (16) lines
written to keep their arrays, so moving between them (a newline or a
cursor-up) costs nothing, and a progress display redrawn under a long line
does not copy that line at each redraw (a line of 1 MiB with 2000 redraws
under it renders in about 0.06 s). Only a program that keeps rewriting more
than 16 lines in turn pays the length of each line it comes back to. On the
development machine, per MiB: about 0.02 s for ordinary lines, 0.06 s for
dense \r frames (tqdm), 0.09 s for coloured lines, 0.2 s for one long
coloured line (jq -C -c) and 0.35 s for one long line of
grep --color=always matches. The cap allows 32 MiB per stream, so a final
render of a large output can take seconds, and belongs in a thread, not on
the event loop.
uniborg/shell_stream.py is a core module: a plugin reload never re-executes
it. It imports only term_render and tg_format.
LiveOutput.write(chunk, stream="out" | "err") works from any thread and
never blocks, so a producer reads as fast as the command writes and brish's
backpressure never engages; a slow consumer only ever sees a later tail.
After a write, changed (an asyncio.Event) is set on the loop through
call_soon_threadsafe, with at most one wake pending, so a flood of writes
costs the loop one callback per burst. A closed loop is ignored.
What is kept, per stream:
- the first
HEAD_BYTES(16 MiB) and the lastTAIL_BYTES(16 MiB), with a line[… N bytes not kept …]between them when anything was dropped. The cuts never split a UTF-8 character; - after a stop (
mark_stopped, called byShellJob.cancel), at mostAFTER_STOP_BYTES(256 KiB, brish's read-ahead after its first signal) more over both streams; the rest is counted and reported in a last line,[… N bytes written after the stop not kept …]; - separately, the last
PREVIEW_BYTES(16 KiB) of both streams together, in arrival order, for previews.
Reading:
tail_text(*, max_units, render)is for a preview. It decodes the recent window witherrors="replace"(so invalid bytes show as U+FFFD), leaves out a character cut at either end, and returns at mostmax_unitsUTF-16 units (tg_format.tail_utf16). Withrender, a window cut from a longer output starts at its first whole line (term_render.line_aligned) and is rendered.final_text(*, render)is for the final. It decodes stdout and stderr each whole, with the producer'sdecoding(shell_stream.Decoding), and joins them stdout first. Withoutrenderthis is exactly what capturing the same bytes gave before: brish'sCmdResult.outerr, or for.aasubprocess.run(text=True)with\r\nand\rturned into\n. Withrender,term_render.renderreplaces that newline translation, and renders each stream on its own: they are joined one after the other, not in the order they arrived, so a\ror a cursor-up at the start of stderr must not overwrite the end of stdout. (Both producers send the command's stderr into its stdout,2>&1for brish andstderr=STDOUTfor.aa, so the stderr stream is usually empty.)
A ShellJob holds its owner, chat, command message, guest thread_key,
live output, state and stop reason, and the brish pool it runs on (pool).
Its id is a process-wide counter. Every field is a keyword argument, so
two ids (owner and chat, say) cannot be swapped by position.
The states (JobState): QUEUED (waiting for a shell), RUNNING, STOPPING
(asked to stop; the command may still be ending), ENDED (the command has
ended, and the consumer may still be sending its final or its files) and DONE
(all of it is delivered). Every match on JobState, StopReason (USER or
SHUTDOWN) and CancelOutcome names each member and raises on anything else.
try_start()is called by the producer once it holds a shell. It returns False when the job was stopped while it waited; the producer then frees the shell and runs nothing. A running job answers True again, for a producer that retries.ranrecords that the command was let run, and a job stopped before that is dropped (dropped): its command never runs, so the consumer delivers its final at once instead of waiting for a shell.stop_requested()says whether the job was asked to stop (stopped, as a callable). Brish 0.4.1 calls it every 0.05 s from the executor thread while the job waits for a worker; it takes no lock.attach(kill)stores the kill hook and calls it at once when the job is already stopped.cancel(*, reason)marks the job stopped and then calls the hook it finds. Both take the job's lock, so whichever runs second sees the other, and a cancel that lands betweenpopenandattachstill kills the command (popen-api.md, the Job pattern). The producer callsdetach()once the command has ended: it drops the hook and makes the job ENDED. A stopped job keeps itsstop_reasonthere.cancelreturns STOPPING (the hook was called), NOT_STARTED (it was still queued and will never run; the consumer can deliver its final at once), ALREADY_STOPPING or FINISHED (ENDED or DONE: the command has ended by itself or after an earlier stop, and nothing is done, so a.kduring the upload of the files does not claim a stop). Only the first cancel counts. It returns at once; the command may take seconds to end.- Every state change sets
output.changed, so a preview header can follow.
The registry is a dict, shell_stream.JOBS, used from the event loop only:
register(job), finish(job) (state DONE, sets job.done, forgets it),
find(*, chat_id, message_id) (the job whose command or preview that message
is), visible(*, chat_id, caller_id, thread_key=None) (a chat's jobs; in the
caller's private chat with the bot also the caller's guest jobs; with a
thread_key, the caller's jobs of that guest thread) and
stop_all(*, reason, timeout), which cancels every job, waits up to timeout
seconds for them to finish and returns how many had not.
util.brishz_capture(*, cwd, cmd, fork=True, job=None, brish=None). Without a
job it runs as it always did, through brishz_helper and send_cmd. With a
job, on a brish that has Brish.popen (0.4.0 and later; util.BRISH_POPEN
says whether the installed one does):
- An executor thread takes a worker through
util._on_brish_worker, the sessionbrishz_helperalso uses: the worker lock,$jd,cdandjinit, the command,cd /tmp. Withoutbrish=, that thread also looks up the plugin pool, whose first use (and first use after.x) boots its workers for seconds; the event loop never waits for it. A non-fork command (.af) takes worker 0, which keeps its state between commands, as without a job: while another command holds worker 0 it waits, QUEUED, even when other workers are free. - Right after taking the lock it asks
job.try_start(). False (the job was stopped while it waited for a free worker) frees the lock, runs nothing, and the result is None. With brish 0.4.1 the wait itself can be called off:acquire_lockgetscancelled=job.stop_requested, which brish calls every 0.05 s while the thread waits, and once more when it has the worker. Once the job is stopped, brish frees the worker and raises BrishCancelledException: nothing ran, so the result is None, as for a refusedtry_start, with no retry.util.BRISH_CANCELLEDsays whether the installed brish takes the argument;_on_brish_workerasks each pool's class (_lock_takes_cancelled, once per class), so a pool whoseacquire_lockdoes not take it (0.4.0) gets onlytry_start. - The command runs as
popen('{ eval "$(< /dev/stdin)"; } 2>&1', fork=..., cmd_stdin=cmd). The popen'skillis attached as the job's kill hook, and every chunk goes tojob.output.write(chunk, stream=...). - The loop never breaks on a stop. What a dying command still prints
arrives (a trap's goodbye, say), and brish takes kill steps 2 to 4 inside
these reads, which stay fast because
writenever blocks. No brish call happens inside the loop, so BrishWorkerBusyException cannot occur. cd /tmpruns after thewithblock. Afterexit Nin a non-fork command, or a 9001 (a worker that brish had to SIGKILL), it raises BrishWorkerDiedException and the result stands; a worker that was gone before the command ran gets one retry (6b60222), which is safe because nothing streamed.- The result is
CommandResult(output=job.output.final_text(render=False), retcode=...), the same text as without a job. The output decodes with the brish's ownencodinganddecoding_errors.
If the awaiting task is cancelled (the client disconnecting), the job is cancelled with StopReason.SHUTDOWN and the CancelledError propagates; the executor thread is not awaited, and ends once the command does.
What a stop gives, in the tests (default kill_grace of 2 s): sleep 100
ends with 130 within about 0.1 s, and a non-fork command keeps the worker's
state; a command that traps INT runs its trap (its output arrives) and
returns the trap's status; a fork command that ignores INT (trap '' INT)
gets SIGTERM at step 2, about 2.1 s later, and returns 143 (non-fork: its
sleep gets the SIGTERM, and the command goes on). Under a stubborn command
brish reaches step 4 (SIGKILL of the worker, 9001) after about 5 to 13 s;
with brish 0.4.0 the pool then restarts, waiting for its other commands,
while 0.4.1 replaces only that worker.
Old brish fallback. A brish without popen (before 0.4.0) runs the
command through send_cmd, still asking try_start first, and writes its
whole result into the job at the end, which makes the job ENDED. It cannot
be stopped once it runs: no kill hook is attached. So the plugin runs .a
and .af there with no job at all (no preview, nothing for .k to find),
in chats and in guest answers; .aa streams on any brish.
util.simple_run_capture(*, cwd, command, shell=True, job=None). Without a
job it runs as before, through subprocess.run(shell=True, executable="zsh", text=True) on an executor thread. A job needs shell=True (anything else
raises ValueError). With one, util._stream_zsh:
- asks
job.try_start(); False gives None and runs nothing; - starts
asyncio.create_subprocess_exec("zsh", "-c", command, cwd=cwd, stdin=DEVNULL, stdout=PIPE, stderr=STDOUT, start_new_session=True). The argv is the oneexecutable="zsh"gave, and the input stays empty; - reads
stdout.read(65536)on the event loop intojob.output, so a running.aaholds no executor thread and stays the escape hatch when every thread is busy; - returns
CommandResult(output=job.output.final_text(render=False), retcode=...). That is UTF-8 with\r\nand\rturned into\n, astext=Truegave for valid output; invalid bytes now become\xNNescapes, as in.a, wheretext=Trueraised UnicodeDecodeError. The exit status of a command killed by a signal is negative (-15), as before.
The command runs in a session, and so a process group, of its own. Its kill
hook (util._ProcessGroupKiller) sends the whole group SIGINT, then SIGTERM,
then SIGKILL, ZSH_KILL_GRACE (2 s) apart through loop.call_later, and
stops as soon as the group is empty. The group includes background jobs:
those of a non-interactive zsh ignore SIGINT, so they end at SIGTERM, and a
.aa whose background job holds the output open ends then too. A command
that ignores SIGINT (trap '' INT) ends at SIGTERM, 2 s after the stop.
When the command's own process has been reaped, the producer checks the
group at once. If it is empty, the pending step is cancelled: the group's id
(the command's process id) is then free, and could name a new group, such as
another streamed .aa. A background job left in the group keeps the id
reserved and gets the later steps.
A process that left the group (a daemon, or one started with setsid) can
hold the output open after the whole group is gone, and the read would wait
for it for good, with the job STOPPING and its preview saying "stopping…".
So once a step finds the group empty (a grace after SIGKILL, one more check
does), the transport is closed a grace later: the read ends, and so does
the job, with zsh's exit status. That process is not signalled, and what it
writes after that is not read. Without a stop the job reads it, as before.
A cancelled await (the client disconnecting) cancels the job with
StopReason.SHUTDOWN, sends the group SIGKILL at once and re-raises. Every way
out closes the subprocess transport: a process that left the group (a
daemon) can hold the output open past the SIGKILL, and a transport left open
would be closed by its __del__ after the loop had closed ("Event loop is
closed").
A side effect of the own session: a streamed .aa command has no controlling
terminal, like a brish command, and a terminal Ctrl-C on the bot no longer
reaches it.
uniborg/shell_settings.py (a core module) keeps each admin's shell settings
in UserStorage(purpose="shell"), one JSON file per user under
~/.borg/shell/. ShellSettings.get(user_id) gives a ShellPrefs, and
ShellSettings.set(user_id, prefs) stores only the values that differ from
the defaults, so a changed default reaches everyone who never chose. A stored
value this version does not know is logged and read as its default, and a
file that holds valid JSON other than an object (["drafts"], after a hand
edit) is logged and read as all defaults, so .a still answers. The storage
is injected (ShellSettings(storage=...)), so the tests use a temp dir.
The settings, with their defaults:
stream_private: how a preview shows in private chats, Drafts. The field names and values are the chat bot's (stream_driver.StreamMode), and so are the helpers that read and write them per scope (stream_driver.stream_mode,set_stream_mode).stream_groups: the same in groups, Edits.final_mode(shell_settings.FinalMode): what a command that showed a preview sends when it ends, EDIT_PREVIEW (the preview becomes the final) or NEW_REPLY (a new reply, as before live output).render: show output as a terminal would (term_render), on. It is the only setting that also applies to the admin's guest answers.
On a bot, /settings (in stdplugins/advanced_get.py) shows an admin's
settings and changes them. It has the gate of .a (an admin's message, not
forwarded, not an echoed guest answer), and a non-admin gets no reply.
- In a private chat with the bot it replies with the panel: a Markdown
message that says what each setting does and what it costs, its current
value, and a row of inline buttons per setting. The rows of the two
live-output scopes are the chat bot's
/streamrows (stream_driver.stream_mode_rows); the others are "When it ends" (Edit the preview, New reply) and "Renderer" (On, Off). - In a group it replies that the panel is in the private chat with the bot, and changes nothing.
- Text forms change one setting, then show the panel:
/settings private drafts|edits,/settings groups drafts|edits,/settings final edit|replyand/settings render on|off, in any letter case, with any blanks between the words, line breaks included. Anything else gets the usage line./settings@thisbotworks too. - A button's callback data is
shs:and the same words joined by ":" (shs:private:edits,shs:final:reply,shs:render:off), so a press and a text form go through one parser,setting_change. Its handler takes only data that starts withshs:, and is wrapped incallback_util.hold_bare_answers, so its toast always shows. A press is answered first, with a toast that names the new value ("Groups: Drafts."), and then the panel is redrawn in place. A press by a non-admin gets the toast "Only the bot's admins can do that." and changes nothing; data the parser does not know (a button from an older panel) gets a toast that asks for a new/settings. - A setting that cannot be saved (the user file's lock timed out) gets "Could not save that setting; try again.", by reply or toast.
The panel's text names the costs of each choice: while a bot's draft is
live, Telegram for Android disables the send button, so under Drafts a long
command keeps you from typing (press Stop, or choose Edits; where the
bot's Telethon cannot give a draft a Stop button, the panel says so, since
.k cannot be sent either); Edit the preview finishes an edited preview
silently, with no notification, while a draft becomes a new message, and
New reply notifies.
/help lists the shell's commands. Both are in the plugin's BOT_COMMANDS,
which bot_util.register_bot_commands sets as the bot's command menu at
startup; no other plugin of stdplugins/ sets one, so it replaces nothing.
On a userbot neither command exists: the account's own /settings, typed
to another bot, would be answered there too. A userbot still reads the
settings files under ~/.borg/shell/, so it follows what an admin set
through a bot that runs as the same OS user, and otherwise the defaults (it
cannot show drafts, so its previews are edited messages).
The kill switch is the environment variable borg_shell_streaming, read
once when the module is first imported. It takes the words of the bot's other
switches (borg_guest_trigger_guard, borg_tg_safety_nets), through their one
parser, uniborg/env_switch.py: unset, empty, "1", "true", "yes" or "on" is on,
"0", "false", "no" or "off" is off, in any letter case and with blanks around
them, and any other value raises rather than guess. uniborg/uniborg.py imports
the module, so that error stops the bot at startup. Were the shell plugin the
first to import it, the plugin loader would only log the error and skip the
plugin, and the bot would run on with .a answering nothing. Off, .a, .af
and .aa run exactly as before live output: no job, no preview and no renderer.
.a, .af and .aa in a chat (private or group, on a bot or a userbot) show
a running command's output while it runs. The code is in
stdplugins/advanced_get.py. Terms:
- Preview: the message, or draft, that shows the end of a running command's output and changes as the output grows.
- Final: the output text once the command has ended, built as before live
output (
util.shell_output_text, whichsend_outputuses too): trimmed, or "The process exited N." when empty. A stopped job's final gets a stop note after a blank line. - Pump:
_run_live, which runs the producer and moves its latest output into the preview.
- The
.ahandler keeps its gate (an admin's own, unforwarded message, not an echoed guest answer), reads the caller's settings, registers aShellJoband runsutil.run_and_uploadwith_run_in_chatas its work. So the read receipt, the downloads, the files sent back andhandle_excstay as they were. The job is finished (shell_stream.finish) only after the files are sent, so it can be found until then. _run_in_chatstarts the producer (util.brishz_captureon the shell pool, orutil.simple_run_capturefor.aa, each withjob=) and runs the pump.- A command that ends within the preview delay (2 s) shows no preview: its
final goes out exactly as before (one plain-text reply, or the
.txtfile at 4000 characters or more), then its files. - Otherwise the preview opens, and
stream_driver.followshows the latest output in it through aPacedEditor, whenever the output or the job's state changes, at the preview's pace, until the command ends. - The final takes the preview's place, as the caller's final mode says (below), and the files follow.
The pump is the only reader of job.output.changed; it passes each change on
to follow. That way it also sees a job dropped while it waited for a
shell, and delivers its final ("⏹ Stopped before it ran.", see the stop
notes below) at once, while the producer frees its shell whenever it gets
one and runs nothing.
- Text: a header, a blank line, the output's end and the cursor "▌",
within 3496 UTF-16 units (
PREVIEW_UNITS). A message holds 4096, bututil.edit_messagesplits a text into a chain of messages where it can break it well: it looks for a newline in the lastutil.SPLIT_SEARCH_CHARS(600) characters below that limit, so a text of lines longer than 3496 characters would become two messages, and the second would outlive the preview. A shorter text is never split. The room left also fits a draft's heartbeat suffix ("⏳ 42s"). It is plain text (parse_mode=None) with no link preview. The first message is sent silently, and edits never notify. - Header: "⏳ #3" while the command runs, "⏳ #3 waiting for a free shell" while the job is QUEUED (every shell of the pool is busy), and "⏹ #3 stopping…" once it is stopped. A preview with no Stop button adds " · .k to stop": an edited message on a userbot, and a draft on a Telethon that cannot build the button (before 1.45). That draft is the worse case: on Android it disables the send button for as long as it lives.
- Kind: a draft (
draft_stream.DraftAnswerMessage, opened throughstream_driver.open_stream_targetwithparse_mode=None) when the account is a bot, the installed Telethon can send drafts, and the caller's setting for this kind of chat is Drafts (private chats, by default). Otherwise, and when Telegram refuses the draft (it does in groups), it is a message sent as a reply to the command and edited. In a private topic the draft shows in that topic. - Stop: a draft has a Stop button on Telethon 1.45
(
draft_stream.STOP_SUPPORTED;_shows_stop_buttonasks both). It shows on Telegram 10.3 clients.stream_driver.stop_wiredpoints it atjob.cancel(reason=USER), and the plugin registers the press handler withstream_driver.register_draft_stop(borg, module=__name__), so a plugin reload removes it. An edited preview on a bot has an inline "⏹ Stop" button (_stop_buttons, built withtg_compat.callback_button, so it works on both Telethon versions), whose callback data isshk:and the job id. Edits keep it, sinceMessage.editreuses a message's reply markup whenbuttonsis left out (and a message sent in a private chat, which Telethon builds itself fromUpdateShortSentMessage, keeps the markup of its request,client/messages.pyinsend_message); the final's edit removes it (buttons=None), and so does deleting the preview. See "The Stop button" below. - Pace: an edited preview changes at most every 2 s, then every 5 s once
the command has run 30 s; in groups every 4 s, then every 10 s
(
stream_driver.tiered_pace). That is about 10 edits in a group's first minute, within its edit budget. A draft keeps the draft worker's own pace, about one draft a second, plus a heartbeat every 20 s of quiet so it does not expire.
The costs of drafts, which the settings panel names: while a bot's draft is live, Telegram for Android disables the send button, so under Drafts a long command keeps you from typing until you press Stop (or choose Edits). An edited preview that becomes the final changes silently, with no notification; a new reply notifies.
A command that showed no preview gets the final exactly as before, in both
modes. After a preview, ShellPrefs.final_mode decides:
- Edit the preview (EDIT_PREVIEW, the default).
- A final under 4000 characters (
util.discreet_sends_file, the rule ofdiscreet_send) replaces the preview (stream_driver.show_final). An edited preview is edited in place as plain text, withbuttons=None: Telethon'sMessage.editreuses the old reply markup whenbuttonsis left out (telethon/tl/custom/message.py, inMessage.edit, on both 1.43.2 and 1.45). That edit does not notify. A draft ends its stream, and the final is sent as a real reply to the command after a sync draft, the way the chat bot's draft becomes its answer; that reply notifies. - A final of 4000 characters or more turns the preview into its end, as
much as fits one message (4096 UTF-16 units), under the first line
"✂️ The full output is in the file below.". The same
.txtfile as before follows, as a reply to that message. - If the preview cannot be edited (it was deleted, say), the final is sent anew, as before, and the preview is removed.
- A final under 4000 characters (
- New reply (NEW_REPLY): the final exactly as before, as a new reply to the command, then the edited preview is deleted. A preview that cannot be deleted (a bot cannot delete its messages in a group after 48 hours) is edited to "Finished; output below." A draft's stream ends without its text being sent; before a final sent as text, a sync draft with that text lets the client adopt the draft into the reply.
How a draft goes away (docs/telegram_ai_apis.md, section 2.1): no call clears
one. A client drops a draft 30 s after its last update, or replaces it with a
message from the bot that adopts it. Telegram Desktop adopts only a message
that starts like the last draft, hence the sync draft; Android adopts only
while the chat is open. So under New reply, a final sent only as a .txt
file has no text to adopt the draft, which can stay up to 30 s, as after the
chat bot's image-only answers.
Stop notes: "⏹ Stopped (exit 130)." after a stop by the user (.k or a
Stop button), and "⏹ Stopped: the bot is going offline (exit N)." after a
shutdown. A dropped job's final is the note alone: "⏹ Stopped before it
ran." after a stop by the user, "⏹ Stopped before it ran: the bot is going
offline." after a shutdown. A shutdown note says "going offline" because
.restart, .shutdown and a server stop all take the bot offline, for a
while or for good, and it names no bot. The job records why it was
stopped, not who stopped it, so the note does not name anyone either.
With the caller's render setting on (the default), what a chat shows of
the output is rendered as a terminal would show it (term_render, above):
the preview (LiveOutput.tail_text(render=True)), the final, and the .txt
file of a long final, for .a, .af and .aa alike, and for guest answers
(see "Live guest answers"). So a progress bar shows
its last frame, colours are dropped, and for .aa rendering replaces the old
translation of \r into a newline (\r\n stays a newline). Output with
none of \r, \b or ESC is unchanged. Off, the final is the output as
before: raw for brish, newline-translated for .aa.
A final over RENDER_ON_LOOP_BYTES (64 KiB) is rendered in a thread of the
default executor: at the worst rate measured above (0.35 s per MiB), 64 KiB
holds the event loop for about 20 ms, while the 32 MiB a job may keep could
hold it for seconds. A preview renders at most PREVIEW_BYTES (16 KiB) and
stays on the loop.
On a brish without popen, .a renders its captured output the same way.
- The producer raises: the preview is removed, and
handle_excposts the traceback, as before. - The preview cannot be sent: logged; the command runs on, and its final is sent as if there had been no preview.
- A preview edit fails:
followwaits a second, or the preview's pace when that is longer (4 s in a group), before the next try, and doubles the wait after each further failure in a row, up to a minute (stream_driver.retry_wait). A preview that someone deleted, or one in a group the bot was removed from, so costs a few edits in its first minute and then one a minute, not one a second for as long as the command runs. An edit that works starts the count again. - The handler's task is cancelled (the client disconnecting, or a
standalone bot's loop shutting down; see "Shutdown and restarts"): the
job is stopped with StopReason.SHUTDOWN, the producer is cancelled, and
the cancel propagates. A cancel while the files are sent propagates too
(
util.upload_output_filesandsend_filesre-raise it), rather than being reported as a failed upload through a closing client. So does one during the read receipt thatutil.run_and_uploadsends first, and the command then never runs; a receipt that fails is still ignored. util.forget_edit_chain(preview)dropsutil.edit_message's record of the preview once the pump is done, so it does not outlive the command.borg_shell_streaming=0:.a,.afand.aarun exactly as before: no job, no preview, no renderer.- A brish without
Brish.popen:.aand.afrun as before, with no job and no preview, since they could not be stopped (only the renderer applies);.aastill streams.
Tests: tests/test_advanced_get_shell.py drives the handler with a fake chat
and a fake producer that follows a script, with the timings injected
(LIVE_TIMING); a few tests run inert commands in a real zsh. The test run
(tests/conftest.py) points ZDOTDIR at an empty directory, so no zsh it
starts, .aa's or a brish worker's, reads the user's startup files, whose
output would otherwise land in a command's. The bot leaves ZDOTDIR alone.
.k (kill_handler in stdplugins/advanced_get.py) stops a job. It is
registered right after the .a handler, has the same gate (a non-admin, a
forwarded .k and an echoed guest answer get nothing), and its pattern,
.k alone or followed by whitespace and any text, never matches
pattern_a. Every .k from an admin gets a plain-text reply: the pattern
takes any text after .k, so a form it does not know (.k 3 5) gets the
usage line rather than silence.
The jobs it can see are the visible jobs (shell_stream.visible): the
jobs of this chat, any admin's, and in an admin's private chat with the bot
also that admin's guest jobs. Of those, only the running ones count:
QUEUED, RUNNING or STOPPING. An ENDED job, whose final or files are still
being sent, has nothing left to stop.
- As a reply to a command, or to its preview: stops that job
(
shell_stream.findmatches either message). In a topic, where every message carries a reply header, only a real reply counts (topics.resolve_reply_target); a reply to any other message says "That message has no running command." - Alone: stops the only running job; with several, lists them instead of guessing; with none, says "No running command here."
.k N(or.k #N): stops job N, if it is visible and running..k all: stops every running visible job, one reply line each..k ls: lists them, a line each: id, age, "waiting" or "stopping" when it is, "guest" for a guest job, and the first 60 characters of the command on one line.- Anything else gets the usage line.
A stop is job.cancel(reason=StopReason.USER). Its reply follows the
CancelOutcome: "⏹ Stopping #3…" (STOPPING, or NOT_STARTED for a job that
was still waiting for a shell and will now never run), "#3 is already
stopping." or "#3 has already ended." The command may take seconds to end
(see the producers above); the preview's header says "⏹ #3 stopping…" until
it does, and the final ends with the stop note.
.k sees only jobs, so a reply that finds nothing says why a command might
still be running unseen: with borg_shell_streaming=0 no command is a job
("Live output is off (borg_shell_streaming=0), so no command can be
stopped."), and on a brish without popen .a and .af are not jobs
(".a cannot be stopped until brish is upgraded; .aa can.").
The registry lives in the core module shell_stream, so a .k from a
reloaded plugin still sees the jobs that started on the old code.
@<bot> .k, sent in a guest chat (docs/guest_mode.md), stops the caller's
own running guest jobs of that chat: shell_stream.visible with the query's
thread_key, which matches only jobs that this caller started from this
guest chat. It takes the trigger of the guest shell (the mention first, then
whitespace, then .k; guest_util.shell_command_after_mention), so a
non-admin gets "Not available here." and a loose form gets the usage line.
The forms are those of .k (kill_text serves both) except a reply, which
names no job here: alone it stops the only running job or lists several,
and N, all and ls work as above. The lists and the usage line name
@<bot> .k rather than .k. Each call is answered with a guest note
(guest_util.answer_note), so it costs one message in the chat. In the
caller's private chat with the bot, a plain .k sees the same jobs.
A userbot's trigger guard (guest_util.OutgoingTriggerGuardMixin) defangs
@somebot .k as it does @somebot .a, so text the userbot relays cannot
stop its owner's guest commands.
stop_press_handler takes the presses whose data starts with shk: (a
CallbackQuery pattern), on bots only, and is wrapped in
callback_util.hold_bare_answers, so its toast always shows. buttons_test
no longer answers presses that are not its own, whatever order the plugins
load in.
- A press by anyone but an admin (
util.isAdmin, as for.a) gets the toast "Only the bot's admins can do that." and stops nothing. - Otherwise the job is
shell_stream.JOBS[id], if it is in the chat of the press and the pressed message is its preview (job.preview_id), and it is stopped as by.k: the toast is the same text ("⏹ Stopping #3…", "#3 is already stopping.", "#3 has already ended."), and the header turns to "⏹ #3 stopping…" at the preview's next edit. - A job that is no longer registered (it finished, or the bot restarted) gets "#3 has already ended.", and data whose id is not ASCII digits "That button is out of date." The preview check matters after a restart: job ids start again at 1 in each process, and a preview that a shutdown or a crash left with its button would otherwise stop the newer job that got the same id.
@<bot> .a CMD from a guest chat (docs/guest_mode.md) runs as a job too,
and its answer shows the output live. The code is _run_guest_shell and
_run_guest_live in stdplugins/advanced_get.py.
- The job is registered before the replied-to files download, with the
caller as its owner, no chat (
chat_id=None) and the query'sthread_key, so@<bot> .kin that guest chat and.kin the caller's private chat with the bot see it. It is finished after the answer's last edit. Withborg_shell_streaming=0, or for.aon a brish withoutpopen, there is no job and the answer is as before live output. - The preview is the guest answer itself: it says "⏳ Running…" when
posted, as before, and a command still running after the preview delay
(2 s) turns it into the header, the output's end and the cursor, as in a
chat, through the same pump (
_run_live) and the same text (_preview_text, withinPREVIEW_UNITS). It is never a draft, and the settings' preview kinds and final modes do not apply. It changes at the edit pace of a private chat or of a group, after the kind of the guest chat, andguest_util.GuestAnswerMessagekeeps its own edits at least 1.2 s apart and skips them during a flood wait. A guest answer has no Stop button, so the header always names the command that stops it: "⏳ #3 · @bot .k to stop". - The final is the answer's last edit (
finalize), built as before: the output as plain text, cut to fit, with the lines under it. A command that ends within the preview delay still makes exactly one edit. After a stop, a line "⏹ Stopped" (or "⏹ Stopped: the bot is going offline" on a shutdown) follows "exit N". A job stopped while it waited for a shell says "⏹ Stopped before it ran." alone (on a shutdown, "⏹ Stopped before it ran: the bot is going offline."). - The renderer follows the caller's
rendersetting (the default, on, when they never chose), for the preview, the final and theoutput.txtof long output. Off, and with live output off, the output is raw as before; on a brish withoutpopen,.ais rendered from its captured output, as in a chat. - Failures: a producer's error leaves the preview in place (a guest answer cannot be deleted), and the traceback replaces it, as before.
A shutdown stops every job first, while the bot is still connected, so each
stopped command's final goes out: "⏹ Stopped: the bot is going offline (exit
N)." in a chat, "⏹ Stopped: the bot is going offline" under the exit code of a
guest answer. shell_stream.stop_all_and_disconnect(client) cancels every job
with StopReason.SHUTDOWN, waits up to SHUTDOWN_TIMEOUT (15 s) for them to
finish (their finals and files delivered), logs how many had not, and then
disconnects. The bot still takes commands while it waits, so until the
disconnect is done, shell_stream.register stops each new job at once: its
command never runs, and stop_all waits for its final ("⏹ Stopped before it
ran: the bot is going offline.") as for the others; only a job that arrives
during the disconnect itself may lose its final. A command that is no job (live
output off, or .a on an old brish) is not stopped. Three paths call it:
- Under uvicorn (
start_server.py), the server's shutdown event (stdborg.shutdown_event) runs on SIGINT or SIGTERM, while the event loop still runs. .restartand.shutdown(stdplugins/power_tools.py) reply ("Restarted.", "Turning off ..."), run it, then re-execute the bot or exit. Each must be the whole message (any letter case, blanks around it allowed): Telethon matches a pattern from the start only, so a bare.restartalso took "/restart", "#restart" or ".restarted", which on a userbot can be any of the owner's own messages. Both reply rather than edit the command, since a bot cannot edit a message it did not write. They run it in a task of their own: Telethon'sdisconnectcancels every running event handler, the one that calls it included, so code after it in the handler would never run.- Leftovers: a job that has not finished when the time runs out has its
handler cancelled by the disconnect (
_disconnect_coroin Telethon'stelegrambaseclient.pycancels and awaits its event handler tasks). The pump's cancel path then kills the command, with no final.
Standalone (python3 stdborg.py), Ctrl-C ends asyncio.run, which
cancels every task before it closes the loop; there is no time to send
anything. Each job's pump takes its cancel path: the job is stopped with
SHUTDOWN, a streamed .aa gets SIGKILL for its process group at once, and a
brish command gets its popen's kill. The loop's default executor then waits
for the brish threads, which end once their commands do (a command that
ignores SIGINT ends at a later step of brish's kill, within about 13 s), so
a tail -f no longer keeps the process from exiting.
- Block-buffered programs. Commands run with pipes, not a terminal, and
nothing fakes one, so a program that buffers its output when it is not
writing to a terminal (C stdio, Python) shows it in blocks of a few KiB,
or only when it exits. Ask it to flush each line:
python3 -u,stdbuf -oL CMD(for C programs that use stdio),grep --line-buffered. The bot does not setPYTHONUNBUFFEREDfor you. - Daemons survive a stop. A stop reaches only the command's own
processes: for brish, the worker's descendants at each kill step
(popen-api.md:
kill()signals nothing that has left the worker's process tree); for.aa, the command's process group. A program that detached itself (a daemon that forked twice and was reparented, or one started withsetsid) is not signalled, and outlives the stop and a shutdown, as before live output. A streamed.aawhose output such a process holds open still ends after a stop (see "The.aaproducer"). - Binary output. A preview shows invalid UTF-8 as U+FFFD; the final shows
it as
\xNNescapes, for.aatoo. With brish, an output line that holds only a NUL still ends the stream early (popen-api.md, legacy mode), as it did before live output. Write binary data to a file. - Two drafts in one chat. Clients keep one draft per sender and thread, so two commands previewed as drafts at once in a private chat overwrite each other's draft until one ends (docs/draft_streaming.md, "Known limits"). Their finals are unaffected; Edits avoids it.
- No time limit, and endless output. A command runs until it ends or is
stopped. One that writes without pause (
yes) keeps the bot reading at full speed until it is stopped; memory stays within the caps above. - Stopping a queued job. A job that waits for a worker (every worker
busy, or worker 0 for
.af) is dropped at once by.k, and its final goes out. With brish 0.4.1 its executor thread gives up the wait too, within about 0.05 s (brish's poll ofcancelled=). The tests require an.afqueued behind a busy worker 0 to return within 0.4 s of the stop, while worker 0 stays busy; it took 0.03 to 0.09 s in runs on the development machine. With 0.4.0 that thread still waits until it gets a worker, then runs nothing, so a job queued behind an endless command holds one thread of the event loop's default executor until that command is stopped.