[{"data":1,"prerenderedAt":1552},["ShallowReactive",2],{"blog-en-how-do-i-know-when-bash-is-done":3,"header-blog-translations-/en/blog/how-do-i-know-when-bash-is-done":1549},{"id":4,"title":5,"author":6,"body":7,"date":1532,"description":1533,"draft":1534,"extension":1535,"image":1536,"meta":1537,"navigation":323,"path":1538,"seo":1539,"stem":1540,"tags":1541,"translationKey":1547,"__hash__":1548},"blog_en/blog/en/how-do-i-know-when-bash-is-done.md","How Do I Know When Bash Is Done?","Patrick Hofmann",{"type":8,"value":9,"toc":1512},"minimark",[10,22,29,35,46,51,54,64,67,103,114,120,124,129,135,146,150,157,161,164,171,177,184,188,202,206,209,220,224,227,233,236,248,252,256,271,278,368,371,377,392,396,399,412,426,699,702,736,740,743,808,822,826,829,1232,1247,1251,1265,1268,1285,1291,1460,1463,1467,1487,1498,1502,1505,1508],[11,12,13,14,18,19,21],"p",{},"Yesterday I wrote here about ",[15,16,17],"code",{},"ape-shell"," — a shell wrapper that routes commands through a grant system before they're executed. The original version was a pure one-shot mode: you pass in a command, ",[15,20,17],{}," gets a grant, bash executes, done. Every command got a fresh bash instance.",[11,23,24,25,28],{},"That works for ",[15,26,27],{},"$SHELL -c"," patterns. It doesn't work for interactive sessions. And the reason is a question that looks trivial at first glance and then turns out to be surprisingly deep:",[11,30,31],{},[32,33,34],"strong",{},"How do I know when bash is done?",[11,36,37,38,40,41,45],{},"This article is the answer. No ",[15,39,17],{}," pitch, no grant system discussion — just a concrete systems programming problem and how to solve it. If you've ever tried to build a shell wrapper that controls a ",[42,43,44],"em",{},"persistent"," bash across multiple commands, you've probably asked the same question.",[47,48,50],"h2",{"id":49},"the-problem","The Problem",[11,52,53],{},"The naive version of a shell wrapper is simple:",[55,56,62],"pre",{"className":57,"code":59,"language":60,"meta":61},[58],"language-text","spawn bash\nwrite command\nread output\nkill bash\n","text","",[15,63,59],{"__ignoreMap":61},[11,65,66],{},"This works but is useless for many use cases. Because if every command gets a new bash instance, shell state is lost between commands:",[68,69,70,77,87,93,96],"ul",{},[71,72,73,76],"li",{},[15,74,75],{},"cd /foo"," in the first command → in the second command you're back in the old directory",[71,78,79,82,83,86],{},[15,80,81],{},"export FOO=bar"," → in the next command ",[15,84,85],{},"$FOO"," is empty",[71,88,89,92],{},[15,90,91],{},"alias ll='ls -la'"," → gone",[71,94,95],{},"Shell functions → gone",[71,97,98,99,102],{},"Loaded ",[15,100,101],{},".bashrc"," configuration → read, then buried along with the bash instance",[11,104,105,106,109,110,113],{},"For a wrapper that's supposed to feel like a ",[42,107,108],{},"real"," shell, this is a dead end. You need ",[32,111,112],{},"one"," bash that stays alive, and you push commands into it.",[11,115,116,117],{},"Then the question becomes: ",[42,118,119],{},"when is bash done with the current command and ready for the next one?",[47,121,123],{"id":122},"naive-approaches-and-why-they-break","Naive Approaches and Why They Break",[125,126,128],"h3",{"id":127},"approach-1-just-wait-a-bit","Approach 1: Just Wait a Bit",[55,130,133],{"className":131,"code":132,"language":60,"meta":61},[58],"write command\nsleep 500ms\nread whatever accumulated\n",[15,134,132],{"__ignoreMap":61},[11,136,137,138,141,142,145],{},"Breaks immediately. What happens with ",[15,139,140],{},"find / -name '*.log'","? That runs for minutes. What happens with ",[15,143,144],{},"yes | head -n 1000000","? That spews megabytes in milliseconds and will certainly extend beyond 500ms. Timeouts are not an answer to a question about semantics.",[125,147,149],{"id":148},"approach-2-wait-for-newlines","Approach 2: Wait for Newlines",[11,151,152,153,156],{},"\"If no new newline came for 200ms, bash is done.\" Also wrong. ",[15,154,155],{},"tail -f log.txt"," sends lines occasionally, then pauses, then lines again. Newline-based heuristics produce flaky results that look different on every tenth invocation.",[125,158,160],{"id":159},"approach-3-wait-for-the-prompt","Approach 3: Wait for the Prompt",[11,162,163],{},"Bash shows a prompt after every command. If you see the prompt, bash is done. Logical, right?",[11,165,166,167,170],{},"Only: ",[32,168,169],{},"which prompt?"," The user's PS1 is freely configurable. Mine looks roughly like this:",[55,172,175],{"className":173,"code":174,"language":60,"meta":61},[58],"patrick@mbp ~/code/openape (main *) $ \n",[15,176,174],{"__ignoreMap":61},[11,178,179,180,183],{},"With ANSI colors, with git branch info, with a dirty-state marker, with Unicode decoration. Sometimes multiline. Sometimes with a newline before it. A parser that wants to recognize ",[42,181,182],{},"arbitrary"," user PS1 is doomed to fail.",[125,185,187],{"id":186},"approach-4-parse-ps1","Approach 4: Parse PS1",[11,189,190,191,194,195,197,198,201],{},"\"Then let's parse PS1 from ",[15,192,193],{},"~/.bashrc","!\" Invalid. PS1 is assembled from environment variables, functions, git hooks, virtualenv wrappers, async status providers, and ten other sources. Statically parsing ",[15,196,101],{}," only sees a fraction of that. And even if you had the complete definition — what bash draws at the terminal is the ",[42,199,200],{},"result of expansion",", not the source form.",[125,203,205],{"id":204},"the-core-insight","The Core Insight",[11,207,208],{},"Bash doesn't tell you \"I'm done.\" The prompt is the only signal, and it's not reliably readable by default.",[11,210,211,212,215,216,219],{},"So the right answer is not ",[42,213,214],{},"to read PS1"," — it's ",[32,217,218],{},"to override PS1",".",[47,221,223],{"id":222},"the-trick-your-own-marker","The Trick: Your Own Marker",[11,225,226],{},"If bash won't give you a reliable \"done\" indicator, give it one. Override PS1 with a sentinel sequence you've defined yourself, and scan the PTY output for it. When you see the marker, you know bash has finished the last command and is waiting for the next one.",[11,228,229,230],{},"This sounds trivial, but it's the moment where the architecture clicks: ",[32,231,232],{},"bash doesn't need to understand that it's running inside a wrapper. You only change how it communicates \"done.\"",[11,234,235],{},"The idea in three steps:",[237,238,239,242,245],"ol",{},[71,240,241],{},"Generate a marker that can't accidentally appear in user output.",[71,243,244],{},"Inject it as PS1 at the start of the bash session.",[71,246,247],{},"Scan the PTY stream for the marker. When you see it, everything before it was command output, and bash is ready for the next line.",[47,249,251],{"id":250},"the-details-that-actually-matter","The Details That Actually Matter",[125,253,255],{"id":254},"random-marker","Random Marker",[11,257,258,259,262,263,266,267,270],{},"If you use ",[15,260,261],{},"\"PROMPT>\""," as a marker and the user types ",[15,264,265],{},"echo \"PROMPT>\"",", you're confused. If you use ",[15,268,269],{},"\"___END___\"",", there's probably a log file somewhere in the world that contains that string.",[11,272,273,274,277],{},"Solution: ",[32,275,276],{},"16 bytes of crypto-random as hex."," 32 hex characters, 2^128 possible values. Collision-resistant in any realistic world. In ape-shell it looks like this:",[55,279,283],{"className":280,"code":281,"language":282,"meta":61,"style":61},"language-typescript shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","import { randomBytes } from 'node:crypto'\n\nthis.marker = randomBytes(16).toString('hex')\n","typescript",[15,284,285,318,325],{"__ignoreMap":61},[286,287,290,294,298,302,305,308,311,315],"span",{"class":288,"line":289},"line",1,[286,291,293],{"class":292},"s7zQu","import",[286,295,297],{"class":296},"sMK4o"," {",[286,299,301],{"class":300},"sTEyZ"," randomBytes",[286,303,304],{"class":296}," }",[286,306,307],{"class":292}," from",[286,309,310],{"class":296}," '",[286,312,314],{"class":313},"sfazB","node:crypto",[286,316,317],{"class":296},"'\n",[286,319,321],{"class":288,"line":320},2,[286,322,324],{"emptyLinePlaceholder":323},true,"\n",[286,326,328,331,334,337,340,343,347,350,352,355,357,360,363,365],{"class":288,"line":327},3,[286,329,330],{"class":296},"this.",[286,332,333],{"class":300},"marker ",[286,335,336],{"class":296},"=",[286,338,301],{"class":339},"s2Zo4",[286,341,342],{"class":300},"(",[286,344,346],{"class":345},"sbssI","16",[286,348,349],{"class":300},")",[286,351,219],{"class":296},[286,353,354],{"class":339},"toString",[286,356,342],{"class":300},[286,358,359],{"class":296},"'",[286,361,362],{"class":313},"hex",[286,364,359],{"class":296},[286,366,367],{"class":300},")\n",[11,369,370],{},"With some structure around it so the regex gets a clear anchor:",[55,372,375],{"className":373,"code":374,"language":60,"meta":61},[58],"__APES_\u003C32-hex-chars>__:\u003Cexit-code>:__END__\n",[15,376,374],{"__ignoreMap":61},[11,378,379,380,383,384,387,388,391],{},"The ",[15,381,382],{},"__APES_"," prefix makes it human-readable when debugging. The ",[15,385,386],{},":__END__"," suffix gives the regex an unambiguous terminator. And the ",[15,389,390],{},"\u003Cexit-code>"," in the middle is the trick for bonus feature number one: you get the exit code of the last command together with the done signal, in a single pattern-match operation.",[125,393,395],{"id":394},"prompt_command-not-just-ps1","PROMPT_COMMAND, Not Just PS1",[11,397,398],{},"This is where almost every first implementation introduces a bug. It's not enough to set PS1 once at startup. Because:",[11,400,401,407,408,411],{},[32,402,403,404,406],{},"The user's ",[15,405,101],{}," is read after your start."," If it contains ",[15,409,410],{},"PS1='...'"," — and that's the rule, not the exception — your carefully set marker PS1 gets overwritten. The user isn't at fault, but your wrapper breaks.",[11,413,414,415,418,419,422,423,425],{},"The solution is a bash variable most people don't know: ",[15,416,417],{},"PROMPT_COMMAND",". This is a shell command that bash executes ",[32,420,421],{},"before every prompt rendering",". If you set PS1 there, you override all of the user's ",[15,424,101],{}," configurations before the next prompt is drawn:",[55,427,429],{"className":280,"code":428,"language":282,"meta":61,"style":61},"this.term = pty.spawn('bash', ['--login', '-i'], {\n  name: 'xterm-256color',\n  cols,\n  rows,\n  cwd: options.cwd ?? process.cwd(),\n  env: {\n    ...process.env,\n    // Force our marker PS1 on every prompt — survives .bashrc overrides.\n    PROMPT_COMMAND: `PS1='__APES_${this.marker}__:$?:__END__'`,\n    // Also set it initially so the very first prompt carries the marker.\n    PS1: `__APES_${this.marker}__:$?:__END__`,\n    PS2: '> ',\n    BASH_SILENCE_DEPRECATION_WARNING: '1',\n  },\n})\n",[15,430,431,487,506,513,521,553,563,579,586,619,625,652,669,686,692],{"__ignoreMap":61},[286,432,433,435,438,440,443,445,448,450,452,455,457,460,463,465,468,470,472,474,477,479,482,484],{"class":288,"line":289},[286,434,330],{"class":296},[286,436,437],{"class":300},"term ",[286,439,336],{"class":296},[286,441,442],{"class":300}," pty",[286,444,219],{"class":296},[286,446,447],{"class":339},"spawn",[286,449,342],{"class":300},[286,451,359],{"class":296},[286,453,454],{"class":313},"bash",[286,456,359],{"class":296},[286,458,459],{"class":296},",",[286,461,462],{"class":300}," [",[286,464,359],{"class":296},[286,466,467],{"class":313},"--login",[286,469,359],{"class":296},[286,471,459],{"class":296},[286,473,310],{"class":296},[286,475,476],{"class":313},"-i",[286,478,359],{"class":296},[286,480,481],{"class":300},"]",[286,483,459],{"class":296},[286,485,486],{"class":296}," {\n",[286,488,489,493,496,498,501,503],{"class":288,"line":320},[286,490,492],{"class":491},"swJcz","  name",[286,494,495],{"class":296},":",[286,497,310],{"class":296},[286,499,500],{"class":313},"xterm-256color",[286,502,359],{"class":296},[286,504,505],{"class":296},",\n",[286,507,508,511],{"class":288,"line":327},[286,509,510],{"class":300},"  cols",[286,512,505],{"class":296},[286,514,516,519],{"class":288,"line":515},4,[286,517,518],{"class":300},"  rows",[286,520,505],{"class":296},[286,522,524,527,529,532,534,537,540,543,545,548,551],{"class":288,"line":523},5,[286,525,526],{"class":491},"  cwd",[286,528,495],{"class":296},[286,530,531],{"class":300}," options",[286,533,219],{"class":296},[286,535,536],{"class":300},"cwd ",[286,538,539],{"class":296},"??",[286,541,542],{"class":300}," process",[286,544,219],{"class":296},[286,546,547],{"class":339},"cwd",[286,549,550],{"class":300},"()",[286,552,505],{"class":296},[286,554,556,559,561],{"class":288,"line":555},6,[286,557,558],{"class":491},"  env",[286,560,495],{"class":296},[286,562,486],{"class":296},[286,564,566,569,572,574,577],{"class":288,"line":565},7,[286,567,568],{"class":296},"    ...",[286,570,571],{"class":300},"process",[286,573,219],{"class":296},[286,575,576],{"class":300},"env",[286,578,505],{"class":296},[286,580,582],{"class":288,"line":581},8,[286,583,585],{"class":584},"sHwdD","    // Force our marker PS1 on every prompt — survives .bashrc overrides.\n",[286,587,589,592,594,597,600,603,605,608,611,614,617],{"class":288,"line":588},9,[286,590,591],{"class":491},"    PROMPT_COMMAND",[286,593,495],{"class":296},[286,595,596],{"class":296}," `",[286,598,599],{"class":313},"PS1='__APES_",[286,601,602],{"class":296},"${",[286,604,330],{"class":296},[286,606,607],{"class":300},"marker",[286,609,610],{"class":296},"}",[286,612,613],{"class":313},"__:$?:__END__'",[286,615,616],{"class":296},"`",[286,618,505],{"class":296},[286,620,622],{"class":288,"line":621},10,[286,623,624],{"class":584},"    // Also set it initially so the very first prompt carries the marker.\n",[286,626,628,631,633,635,637,639,641,643,645,648,650],{"class":288,"line":627},11,[286,629,630],{"class":491},"    PS1",[286,632,495],{"class":296},[286,634,596],{"class":296},[286,636,382],{"class":313},[286,638,602],{"class":296},[286,640,330],{"class":296},[286,642,607],{"class":300},[286,644,610],{"class":296},[286,646,647],{"class":313},"__:$?:__END__",[286,649,616],{"class":296},[286,651,505],{"class":296},[286,653,655,658,660,662,665,667],{"class":288,"line":654},12,[286,656,657],{"class":491},"    PS2",[286,659,495],{"class":296},[286,661,310],{"class":296},[286,663,664],{"class":313},"> ",[286,666,359],{"class":296},[286,668,505],{"class":296},[286,670,672,675,677,679,682,684],{"class":288,"line":671},13,[286,673,674],{"class":491},"    BASH_SILENCE_DEPRECATION_WARNING",[286,676,495],{"class":296},[286,678,310],{"class":296},[286,680,681],{"class":313},"1",[286,683,359],{"class":296},[286,685,505],{"class":296},[286,687,689],{"class":288,"line":688},14,[286,690,691],{"class":296},"  },\n",[286,693,695,697],{"class":288,"line":694},15,[286,696,610],{"class":296},[286,698,367],{"class":300},[11,700,701],{},"Three details that aren't obvious:",[68,703,704,712,728],{},[71,705,706,711],{},[32,707,708],{},[15,709,710],{},"--login -i",": you want the user's rcfiles to be read, otherwise aliases, functions, and environment the user expects are missing. The trade-off is exactly why you need the PROMPT_COMMAND trick.",[71,713,714,719,720,723,724,727],{},[32,715,716],{},[15,717,718],{},"PS2='> '",": this is the ",[42,721,722],{},"secondary prompt"," bash uses when a command spans multiple lines (unclosed quote, continued pipe, ",[15,725,726],{},"if"," block). You set it to something simple so you can recognize it during multi-line handling.",[71,729,730,735],{},[32,731,732],{},[15,733,734],{},"BASH_SILENCE_DEPRECATION_WARNING=1",": on macOS, the system bash prints a deprecation warning to stderr on every start. It pollutes your output stream. Away with it.",[125,737,739],{"id":738},"the-regex","The Regex",[11,741,742],{},"With the marker in the output stream, you can build a regex that matches it and extracts the exit code:",[55,744,746],{"className":280,"code":745,"language":282,"meta":61,"style":61},"this.markerRegex = new RegExp(\n  `__APES_${this.marker}__:(-?\\\\d+):__END__\\\\r?\\\\n?`,\n)\n",[15,747,748,766,804],{"__ignoreMap":61},[286,749,750,752,755,757,760,763],{"class":288,"line":289},[286,751,330],{"class":296},[286,753,754],{"class":300},"markerRegex ",[286,756,336],{"class":296},[286,758,759],{"class":296}," new",[286,761,762],{"class":339}," RegExp",[286,764,765],{"class":300},"(\n",[286,767,768,771,773,775,777,779,781,784,787,790,792,795,797,800,802],{"class":288,"line":320},[286,769,770],{"class":296},"  `",[286,772,382],{"class":313},[286,774,602],{"class":296},[286,776,330],{"class":296},[286,778,607],{"class":300},[286,780,610],{"class":296},[286,782,783],{"class":313},"__:(-?",[286,785,786],{"class":300},"\\\\",[286,788,789],{"class":313},"d+):__END__",[286,791,786],{"class":300},[286,793,794],{"class":313},"r?",[286,796,786],{"class":300},[286,798,799],{"class":313},"n?",[286,801,616],{"class":296},[286,803,505],{"class":296},[286,805,806],{"class":288,"line":327},[286,807,367],{"class":300},[11,809,379,810,813,814,817,818,821],{},[15,811,812],{},"\\\\r?\\\\n?"," at the end is a subtle but important point: depending on how bash renders the prompt (on a fresh line or directly after the last output), a newline may or may not follow. The regex tolerates both cases. The group ",[15,815,816],{},"(-?\\\\d+)"," captures the exit code, including negative values for signals like ",[15,819,820],{},"130"," or unusual conventions.",[125,823,825],{"id":824},"the-output-parser","The Output Parser",[11,827,828],{},"Every PTY chunk that comes in gets appended to a pending buffer and scanned for the marker. When the marker is found, everything before it is the output of the just-finished command:",[55,830,832],{"className":280,"code":831,"language":282,"meta":61,"style":61},"private handleData(chunk: string): void {\n  this.pending += chunk\n\n  for (;;) {\n    const match = this.pending.match(this.markerRegex)\n    if (!match || match.index === undefined) break\n\n    const before = this.pending.slice(0, match.index)\n    const exitCode = Number(match[1])\n\n    // Everything before the marker is command output.\n    if (before.length > 0) {\n      this.currentLineBuffer += before\n      this.events.onOutput(before)\n    }\n\n    // Remove marker and everything before it from the buffer.\n    this.pending = this.pending.slice(match.index + match[0].length)\n\n    // Command is done — frame to the consumer.\n    const frame = { output: this.currentLineBuffer, exitCode }\n    this.currentLineBuffer = ''\n    this.events.onLineDone(frame)\n  }\n\n  // What remains in `pending` is either partial output\n  // or a started marker that continues in the next chunk.\n}\n",[15,833,834,850,864,868,885,916,949,953,986,1010,1014,1019,1043,1056,1074,1079,1084,1090,1133,1138,1144,1172,1184,1203,1209,1214,1220,1226],{"__ignoreMap":61},[286,835,836,839,842,845,848],{"class":288,"line":289},[286,837,838],{"class":300},"private ",[286,840,841],{"class":339},"handleData",[286,843,844],{"class":300},"(chunk: string): ",[286,846,847],{"class":296},"void",[286,849,486],{"class":296},[286,851,852,855,858,861],{"class":288,"line":320},[286,853,854],{"class":296},"  this.",[286,856,857],{"class":300},"pending",[286,859,860],{"class":296}," +=",[286,862,863],{"class":300}," chunk\n",[286,865,866],{"class":288,"line":327},[286,867,324],{"emptyLinePlaceholder":323},[286,869,870,873,876,879,882],{"class":288,"line":515},[286,871,872],{"class":292},"  for",[286,874,875],{"class":491}," (",[286,877,878],{"class":296},";;",[286,880,881],{"class":491},") ",[286,883,884],{"class":296},"{\n",[286,886,887,891,894,897,900,902,904,907,909,911,914],{"class":288,"line":523},[286,888,890],{"class":889},"spNyl","    const",[286,892,893],{"class":300}," match",[286,895,896],{"class":296}," =",[286,898,899],{"class":296}," this.",[286,901,857],{"class":300},[286,903,219],{"class":296},[286,905,906],{"class":339},"match",[286,908,342],{"class":491},[286,910,330],{"class":296},[286,912,913],{"class":300},"markerRegex",[286,915,367],{"class":491},[286,917,918,921,923,926,928,931,933,935,938,941,944,946],{"class":288,"line":555},[286,919,920],{"class":292},"    if",[286,922,875],{"class":491},[286,924,925],{"class":296},"!",[286,927,906],{"class":300},[286,929,930],{"class":296}," ||",[286,932,893],{"class":300},[286,934,219],{"class":296},[286,936,937],{"class":300},"index",[286,939,940],{"class":296}," ===",[286,942,943],{"class":296}," undefined",[286,945,881],{"class":491},[286,947,948],{"class":292},"break\n",[286,950,951],{"class":288,"line":565},[286,952,324],{"emptyLinePlaceholder":323},[286,954,955,957,960,962,964,966,968,971,973,976,978,980,982,984],{"class":288,"line":581},[286,956,890],{"class":889},[286,958,959],{"class":300}," before",[286,961,896],{"class":296},[286,963,899],{"class":296},[286,965,857],{"class":300},[286,967,219],{"class":296},[286,969,970],{"class":339},"slice",[286,972,342],{"class":491},[286,974,975],{"class":345},"0",[286,977,459],{"class":296},[286,979,893],{"class":300},[286,981,219],{"class":296},[286,983,937],{"class":300},[286,985,367],{"class":491},[286,987,988,990,993,995,998,1000,1002,1005,1007],{"class":288,"line":588},[286,989,890],{"class":889},[286,991,992],{"class":300}," exitCode",[286,994,896],{"class":296},[286,996,997],{"class":339}," Number",[286,999,342],{"class":491},[286,1001,906],{"class":300},[286,1003,1004],{"class":491},"[",[286,1006,681],{"class":345},[286,1008,1009],{"class":491},"])\n",[286,1011,1012],{"class":288,"line":621},[286,1013,324],{"emptyLinePlaceholder":323},[286,1015,1016],{"class":288,"line":627},[286,1017,1018],{"class":584},"    // Everything before the marker is command output.\n",[286,1020,1021,1023,1025,1028,1030,1033,1036,1039,1041],{"class":288,"line":654},[286,1022,920],{"class":292},[286,1024,875],{"class":491},[286,1026,1027],{"class":300},"before",[286,1029,219],{"class":296},[286,1031,1032],{"class":300},"length",[286,1034,1035],{"class":296}," >",[286,1037,1038],{"class":345}," 0",[286,1040,881],{"class":491},[286,1042,884],{"class":296},[286,1044,1045,1048,1051,1053],{"class":288,"line":671},[286,1046,1047],{"class":296},"      this.",[286,1049,1050],{"class":300},"currentLineBuffer",[286,1052,860],{"class":296},[286,1054,1055],{"class":300}," before\n",[286,1057,1058,1060,1063,1065,1068,1070,1072],{"class":288,"line":688},[286,1059,1047],{"class":296},[286,1061,1062],{"class":300},"events",[286,1064,219],{"class":296},[286,1066,1067],{"class":339},"onOutput",[286,1069,342],{"class":491},[286,1071,1027],{"class":300},[286,1073,367],{"class":491},[286,1075,1076],{"class":288,"line":694},[286,1077,1078],{"class":296},"    }\n",[286,1080,1082],{"class":288,"line":1081},16,[286,1083,324],{"emptyLinePlaceholder":323},[286,1085,1087],{"class":288,"line":1086},17,[286,1088,1089],{"class":584},"    // Remove marker and everything before it from the buffer.\n",[286,1091,1093,1096,1098,1100,1102,1104,1106,1108,1110,1112,1114,1116,1119,1121,1123,1125,1127,1129,1131],{"class":288,"line":1092},18,[286,1094,1095],{"class":296},"    this.",[286,1097,857],{"class":300},[286,1099,896],{"class":296},[286,1101,899],{"class":296},[286,1103,857],{"class":300},[286,1105,219],{"class":296},[286,1107,970],{"class":339},[286,1109,342],{"class":491},[286,1111,906],{"class":300},[286,1113,219],{"class":296},[286,1115,937],{"class":300},[286,1117,1118],{"class":296}," +",[286,1120,893],{"class":300},[286,1122,1004],{"class":491},[286,1124,975],{"class":345},[286,1126,481],{"class":491},[286,1128,219],{"class":296},[286,1130,1032],{"class":300},[286,1132,367],{"class":491},[286,1134,1136],{"class":288,"line":1135},19,[286,1137,324],{"emptyLinePlaceholder":323},[286,1139,1141],{"class":288,"line":1140},20,[286,1142,1143],{"class":584},"    // Command is done — frame to the consumer.\n",[286,1145,1147,1149,1152,1154,1156,1159,1161,1163,1165,1167,1169],{"class":288,"line":1146},21,[286,1148,890],{"class":889},[286,1150,1151],{"class":300}," frame",[286,1153,896],{"class":296},[286,1155,297],{"class":296},[286,1157,1158],{"class":491}," output",[286,1160,495],{"class":296},[286,1162,899],{"class":296},[286,1164,1050],{"class":300},[286,1166,459],{"class":296},[286,1168,992],{"class":300},[286,1170,1171],{"class":296}," }\n",[286,1173,1175,1177,1179,1181],{"class":288,"line":1174},22,[286,1176,1095],{"class":296},[286,1178,1050],{"class":300},[286,1180,896],{"class":296},[286,1182,1183],{"class":296}," ''\n",[286,1185,1187,1189,1191,1193,1196,1198,1201],{"class":288,"line":1186},23,[286,1188,1095],{"class":296},[286,1190,1062],{"class":300},[286,1192,219],{"class":296},[286,1194,1195],{"class":339},"onLineDone",[286,1197,342],{"class":491},[286,1199,1200],{"class":300},"frame",[286,1202,367],{"class":491},[286,1204,1206],{"class":288,"line":1205},24,[286,1207,1208],{"class":296},"  }\n",[286,1210,1212],{"class":288,"line":1211},25,[286,1213,324],{"emptyLinePlaceholder":323},[286,1215,1217],{"class":288,"line":1216},26,[286,1218,1219],{"class":584},"  // What remains in `pending` is either partial output\n",[286,1221,1223],{"class":288,"line":1222},27,[286,1224,1225],{"class":584},"  // or a started marker that continues in the next chunk.\n",[286,1227,1229],{"class":288,"line":1228},28,[286,1230,1231],{"class":296},"}\n",[11,1233,1234,1235,1238,1239,1242,1243,1246],{},"The subtle point is handling ",[32,1236,1237],{},"partial markers",". A PTY chunk can end in the middle of the marker — bash wrote the beginning, the rest arrives with the next ",[15,1240,1241],{},"data"," event. If you advance the pending buffer in between (for example, trimming to the last newline), you destroy the partial marker and detection fails. The solution: ",[32,1244,1245],{},"keep all unmatched bytes in the pending buffer"," until either the marker arrives completely or the stream ends.",[125,1248,1250],{"id":1249},"the-bootstrap-phase","The Bootstrap Phase",[11,1252,1253,1254,1256,1257,1260,1261,1264],{},"One last detail that ruins a first run: when you start bash, ",[15,1255,193],{}," is loaded first. This often produces output — MOTDs, shell init messages, ",[15,1258,1259],{},"nvm"," status prints, all sorts of things. The first marker you see is ",[32,1262,1263],{},"not"," the end of a user command. It's the end of the startup process.",[11,1266,1267],{},"This means: two phases in the state.",[68,1269,1270,1276],{},[71,1271,1272,1275],{},[32,1273,1274],{},"Phase 1 — Bootstrap:"," wait for the first marker. Discard everything that came before it (startup noise). Signal to the consumer \"bash is ready.\"",[71,1277,1278,1281,1282,1284],{},[32,1279,1280],{},"Phase 2 — Normal:"," every subsequent marker is the end of a user command. Frames go via ",[15,1283,1195],{}," to the consumer.",[11,1286,1287,1288,495],{},"In ape-shell this is a single boolean called ",[15,1289,1290],{},"readyForFirstLine",[55,1292,1294],{"className":280,"code":1293,"language":282,"meta":61,"style":61},"if (!this.readyForFirstLine) {\n  // Bootstrap prompt: discard startup noise, signal ready.\n  // onLineDone deliberately does NOT fire here — that would be\n  // a fake frame from the consumer's perspective.\n  this.readyForFirstLine = true\n  this.currentLineBuffer = ''\n  const resolve = this.awaitingInitialPrompt\n  this.awaitingInitialPrompt = null\n  if (resolve) resolve()\n  continue\n}\n\n// Real command end: deliver frame.\nconst frame = { output: this.currentLineBuffer, exitCode }\nthis.currentLineBuffer = ''\nthis.events.onLineDone(frame)\n",[15,1295,1296,1310,1315,1320,1325,1337,1347,1362,1374,1391,1396,1400,1404,1409,1436,1447],{"__ignoreMap":61},[286,1297,1298,1300,1302,1305,1308],{"class":288,"line":289},[286,1299,726],{"class":292},[286,1301,875],{"class":300},[286,1303,1304],{"class":296},"!this.",[286,1306,1307],{"class":300},"readyForFirstLine) ",[286,1309,884],{"class":296},[286,1311,1312],{"class":288,"line":320},[286,1313,1314],{"class":584},"  // Bootstrap prompt: discard startup noise, signal ready.\n",[286,1316,1317],{"class":288,"line":327},[286,1318,1319],{"class":584},"  // onLineDone deliberately does NOT fire here — that would be\n",[286,1321,1322],{"class":288,"line":515},[286,1323,1324],{"class":584},"  // a fake frame from the consumer's perspective.\n",[286,1326,1327,1329,1331,1333],{"class":288,"line":523},[286,1328,854],{"class":296},[286,1330,1290],{"class":300},[286,1332,896],{"class":296},[286,1334,1336],{"class":1335},"sfNiH"," true\n",[286,1338,1339,1341,1343,1345],{"class":288,"line":555},[286,1340,854],{"class":296},[286,1342,1050],{"class":300},[286,1344,896],{"class":296},[286,1346,1183],{"class":296},[286,1348,1349,1352,1355,1357,1359],{"class":288,"line":565},[286,1350,1351],{"class":889},"  const",[286,1353,1354],{"class":300}," resolve",[286,1356,896],{"class":296},[286,1358,899],{"class":296},[286,1360,1361],{"class":300},"awaitingInitialPrompt\n",[286,1363,1364,1366,1369,1371],{"class":288,"line":581},[286,1365,854],{"class":296},[286,1367,1368],{"class":300},"awaitingInitialPrompt",[286,1370,896],{"class":296},[286,1372,1373],{"class":296}," null\n",[286,1375,1376,1379,1381,1384,1386,1388],{"class":288,"line":588},[286,1377,1378],{"class":292},"  if",[286,1380,875],{"class":491},[286,1382,1383],{"class":300},"resolve",[286,1385,881],{"class":491},[286,1387,1383],{"class":339},[286,1389,1390],{"class":491},"()\n",[286,1392,1393],{"class":288,"line":621},[286,1394,1395],{"class":292},"  continue\n",[286,1397,1398],{"class":288,"line":627},[286,1399,1231],{"class":296},[286,1401,1402],{"class":288,"line":654},[286,1403,324],{"emptyLinePlaceholder":323},[286,1405,1406],{"class":288,"line":671},[286,1407,1408],{"class":584},"// Real command end: deliver frame.\n",[286,1410,1411,1414,1417,1419,1421,1423,1425,1427,1429,1431,1434],{"class":288,"line":688},[286,1412,1413],{"class":889},"const",[286,1415,1416],{"class":300}," frame ",[286,1418,336],{"class":296},[286,1420,297],{"class":296},[286,1422,1158],{"class":491},[286,1424,495],{"class":296},[286,1426,899],{"class":296},[286,1428,1050],{"class":300},[286,1430,459],{"class":296},[286,1432,1433],{"class":300}," exitCode ",[286,1435,1231],{"class":296},[286,1437,1438,1440,1443,1445],{"class":288,"line":694},[286,1439,330],{"class":296},[286,1441,1442],{"class":300},"currentLineBuffer ",[286,1444,336],{"class":296},[286,1446,1183],{"class":296},[286,1448,1449,1451,1453,1455,1457],{"class":288,"line":1081},[286,1450,330],{"class":296},[286,1452,1062],{"class":300},[286,1454,219],{"class":296},[286,1456,1195],{"class":339},[286,1458,1459],{"class":300},"(frame)\n",[11,1461,1462],{},"Without this separation, the consumer gets a broken frame at startup with all the rcfile noise as \"output\" and an arbitrary exit code. This is the kind of bug you notice at the fifth user, and then the cause is hard to find.",[47,1464,1466],{"id":1465},"one-application-ape-shells-ptybridge","One Application: ape-shell's PtyBridge",[11,1468,1469,1470,1478,1479,1486],{},"I use this pattern in ",[1471,1472,1476],"a",{"href":1473,"rel":1474},"https://github.com/openape-ai/openape/tree/main/packages/apes",[1475],"nofollow",[15,1477,17],{},", a grant-secured shell wrapper I'm building for AI agent workflows. The concrete implementation lives in ",[1471,1480,1483],{"href":1481,"rel":1482},"https://github.com/openape-ai/openape/blob/main/packages/apes/src/shell/pty-bridge.ts",[1475],[15,1484,1485],{},"packages/apes/src/shell/pty-bridge.ts"," — a bit more than 200 lines of TypeScript that cover the complete cycle: spawn, bootstrap, line detection, streaming output, exit handling.",[11,1488,1489,1490,1493,1494,1497],{},"In ape-shell, between user input and bash there's a grant check. The PtyBridge itself knows nothing about it — it only cares about the clean abstraction ",[42,1491,1492],{},"\"bash is done with this line, here's the output and the exit code.\""," The grant layer above decides whether a line even reaches ",[15,1495,1496],{},"writeLine",". The separation of concerns is one reason the pattern feels so natural: marker detection is a universal problem, grant logic is specific.",[47,1499,1501],{"id":1500},"closing","Closing",[11,1503,1504],{},"The pattern isn't new. Terminal emulators, REPL orchestrators, shell testing frameworks — they've all solved some variant of this for decades. expect, pexpect, bash-it's test suite, IPython kernel, Jupyter frontends: they all have a marker trick somewhere. But it's rarely described explicitly. Most developers building a shell wrapper stumble onto the solution themselves, sometimes only after the third naive implementation with timeouts and newline heuristics.",[11,1506,1507],{},"If you ever build a tool that needs to control a persistent shell (or any other REPL with a prompt-based \"ready\" signal): this is probably the pattern you're looking for. Boring infrastructure in its best sense — invisible when it works, critical when it's missing.",[1509,1510,1511],"style",{},"html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}",{"title":61,"searchDepth":320,"depth":320,"links":1513},[1514,1515,1522,1523,1530,1531],{"id":49,"depth":320,"text":50},{"id":122,"depth":320,"text":123,"children":1516},[1517,1518,1519,1520,1521],{"id":127,"depth":327,"text":128},{"id":148,"depth":327,"text":149},{"id":159,"depth":327,"text":160},{"id":186,"depth":327,"text":187},{"id":204,"depth":327,"text":205},{"id":222,"depth":320,"text":223},{"id":250,"depth":320,"text":251,"children":1524},[1525,1526,1527,1528,1529],{"id":254,"depth":327,"text":255},{"id":394,"depth":327,"text":395},{"id":738,"depth":327,"text":739},{"id":824,"depth":327,"text":825},{"id":1249,"depth":327,"text":1250},{"id":1465,"depth":320,"text":1466},{"id":1500,"depth":320,"text":1501},"2026-04-10","When you build a shell wrapper that controls a persistent bash across multiple commands, you stumble into a surprisingly deep question: when is bash done with the current command? A small deep dive into prompt markers, PROMPT_COMMAND, and why the answer isn't to read PS1 — but to override it.",false,"md",null,{},"/blog/en/how-do-i-know-when-bash-is-done",{"title":5,"description":1533},"blog/en/how-do-i-know-when-bash-is-done",[1542,1543,1544,1545,1546],"Systems Programming","Shell","Bash","Technical Deep Dive","OpenApe","how-do-i-know-when-bash-is-done","eJmEaF23pp_DwmnDfGam4W3SgD59-xwxIT6ruQuzBtU",{"en":1550,"de":1551},"/en/blog/how-do-i-know-when-bash-is-done","/de/blog/wie-weiss-ich-wann-bash-fertig-ist",1776970806601]